Share songs with friends on atproto atjams.pdewey.com
elixir atproto music
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: edits

Patrick Dewey (May 14, 2026, 10:52 PM EDT) 3d8616bd 2d5dad99

+262 -40
+48
lib/atjams/atproto.ex
··· 124 124 end 125 125 126 126 @doc """ 127 + Updates an existing `com.pdewey.atjams.share` record via `putRecord`, 128 + preserving the original `createdAt`. Returns the new cid (record changed). 129 + """ 130 + def update_share(%{did: did, session_key: session_key}, rkey, %{} = form, %DateTime{} = original_created_at) 131 + when is_binary(rkey) do 132 + record = 133 + %{ 134 + "$type" => @share_collection, 135 + "songUrl" => form.song_url, 136 + "createdAt" => DateTime.to_iso8601(original_created_at) 137 + } 138 + |> maybe_put("title", form.title) 139 + |> maybe_put("artist", form.artist) 140 + |> maybe_put("notes", form.notes) 141 + 142 + with {:ok, client} <- OAuthClient.new(session_key), 143 + {:ok, %{status: 200, body: body}, _client} <- 144 + XRPC.post(client, "com.atproto.repo.putRecord", 145 + json: %{ 146 + repo: did, 147 + collection: @share_collection, 148 + rkey: rkey, 149 + record: record 150 + } 151 + ) do 152 + {:ok, 153 + %{ 154 + uri: Map.get(body, "uri"), 155 + cid: Map.get(body, "cid"), 156 + did: did, 157 + rkey: rkey, 158 + song_url: form.song_url, 159 + title: form.title, 160 + artist: form.artist, 161 + notes: form.notes, 162 + created_at: original_created_at 163 + }} 164 + else 165 + {:ok, %{status: status, body: body}, _client} -> 166 + Logger.warning("putRecord failed: status=#{status} body=#{inspect(body)}") 167 + {:error, {:pds_error, status, body}} 168 + 169 + {:error, reason, _client} -> {:error, reason} 170 + {:error, reason} -> {:error, reason} 171 + end 172 + end 173 + 174 + @doc """ 127 175 Creates a `com.pdewey.atjams.like` record on the user's PDS referencing 128 176 the given share (via strongRef: `{uri, cid}`). 129 177 """
+16
lib/atjams/feed.ex
··· 160 160 end 161 161 162 162 @doc """ 163 + Clears the enrichment columns on a share so the next `Enrichment.start/1` 164 + refetches. Call this when a share's `song_url` changes via edit. 165 + """ 166 + def invalidate_enrichment(uri) when is_binary(uri) do 167 + case Repo.get(Share, uri) do 168 + nil -> 169 + :not_found 170 + 171 + %Share{} = share -> 172 + share 173 + |> Share.changeset(%{links: nil, link_source: nil, links_fetched_at: nil}) 174 + |> Repo.update() 175 + end 176 + end 177 + 178 + @doc """ 163 179 Lists shares that should be (re)enriched: never fetched, or fetched more 164 180 than `max_age_days` ago. Useful for a periodic catch-up sweep. 165 181 """
+3 -5
lib/atjams_web/components/feed_components.ex
··· 113 113 </summary> 114 114 <ul class="menu menu-sm dropdown-content bg-base-100 rounded-xl z-10 w-36 p-1 shadow-lg border border-base-200 mt-1"> 115 115 <li> 116 - <button 117 - type="button" 118 - phx-click="edit_share" 119 - phx-value-uri={@item.share.uri} 116 + <.link 117 + navigate={~p"/profile/#{@item.share.did}/#{@item.share.rkey}/edit"} 120 118 class="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-base-200" 121 119 > 122 120 <.icon name="hero-pencil-square" class="size-4" /> Edit 123 - </button> 121 + </.link> 124 122 </li> 125 123 <li> 126 124 <button
-4
lib/atjams_web/live/feed_live.ex
··· 80 80 {:noreply, AtjamsWeb.ShareActions.delete_share(socket, share_uri)} 81 81 end 82 82 83 - def handle_event("edit_share", _params, socket) do 84 - {:noreply, put_flash(socket, :info, "Editing shares isn't built yet — delete and re-share for now.")} 85 - end 86 - 87 83 ## Helpers 88 84 89 85 defp current_user_did(%{assigns: %{current_user: %{did: did}}}), do: did
-4
lib/atjams_web/live/profile_live.ex
··· 102 102 {:noreply, AtjamsWeb.ShareActions.delete_share(socket, share_uri)} 103 103 end 104 104 105 - def handle_event("edit_share", _params, socket) do 106 - {:noreply, put_flash(socket, :info, "Editing shares isn't built yet — delete and re-share for now.")} 107 - end 108 - 109 105 ## Helpers 110 106 111 107 defp current_user_did(%{assigns: %{current_user: %{did: did}}}), do: did
-4
lib/atjams_web/live/share_live.ex
··· 70 70 {:noreply, AtjamsWeb.ShareActions.delete_share(socket, share_uri)} 71 71 end 72 72 73 - def handle_event("edit_share", _params, socket) do 74 - {:noreply, put_flash(socket, :info, "Editing shares isn't built yet — delete and re-share for now.")} 75 - end 76 - 77 73 ## Helpers 78 74 79 75 defp current_user_did(%{assigns: %{current_user: %{did: did}}}), do: did
+115 -18
lib/atjams_web/live/share_new_live.ex
··· 1 1 defmodule AtjamsWeb.ShareNewLive do 2 2 use AtjamsWeb, :live_view 3 3 4 - alias Atjams.{Atproto, Enrichment, Feed, MusicLinks} 4 + alias Atjams.{Atproto, Enrichment, Feed} 5 5 alias Atjams.Feed.ShareForm 6 6 7 7 @impl true 8 - def mount(_params, _session, socket) do 8 + def mount(params, _session, socket) do 9 + socket = 10 + socket 11 + |> assign(:submitting?, false) 12 + |> assign(:looking_up?, false) 13 + |> assign(:last_lookup_url, nil) 14 + 15 + case socket.assigns.live_action do 16 + :new -> mount_new(socket) 17 + :edit -> mount_edit(socket, params) 18 + end 19 + end 20 + 21 + defp mount_new(socket) do 9 22 {:ok, 10 23 socket 11 24 |> assign(:page_title, "Share a song") 12 - |> assign(:submitting?, false) 13 - |> assign(:looking_up?, false) 14 - |> assign(:last_lookup_url, nil) 25 + |> assign(:editing_share, nil) 15 26 |> assign(:form_params, %{}) 16 27 |> assign_form(ShareForm.changeset(%{}))} 17 28 end 18 29 30 + defp mount_edit(socket, %{"identifier" => identifier, "rkey" => rkey}) do 31 + current_did = socket.assigns.current_user.did 32 + 33 + case resolve_did(identifier) do 34 + {:ok, share_did} when share_did == current_did -> 35 + case Feed.get_share_by_did_and_rkey(share_did, rkey) do 36 + %{did: ^current_did} = share -> 37 + params = %{ 38 + "song_url" => share.song_url, 39 + "title" => share.title, 40 + "artist" => share.artist, 41 + "notes" => share.notes 42 + } 43 + 44 + changeset = ShareForm.changeset(params) |> Map.put(:action, :validate) 45 + 46 + {:ok, 47 + socket 48 + |> assign(:page_title, "Edit share") 49 + |> assign(:editing_share, share) 50 + |> assign(:last_lookup_url, share.song_url) 51 + |> assign(:form_params, params) 52 + |> assign_form(changeset)} 53 + 54 + _ -> 55 + {:ok, 56 + socket 57 + |> put_flash(:error, "Share not found.") 58 + |> push_navigate(to: ~p"/feed")} 59 + end 60 + 61 + {:ok, _other_did} -> 62 + {:ok, 63 + socket 64 + |> put_flash(:error, "You can only edit your own shares.") 65 + |> push_navigate(to: ~p"/feed")} 66 + 67 + :error -> 68 + {:ok, 69 + socket 70 + |> put_flash(:error, "Couldn't find that account.") 71 + |> push_navigate(to: ~p"/feed")} 72 + end 73 + end 74 + 75 + ## Events 76 + 19 77 @impl true 20 78 def handle_event("validate", %{"share_form" => params}, socket) do 21 79 changeset = params |> ShareForm.changeset() |> Map.put(:action, :validate) ··· 35 93 case Ecto.Changeset.apply_action(changeset, :insert) do 36 94 {:ok, form} -> 37 95 socket = assign(socket, :submitting?, true) 38 - do_create(socket, form) 96 + 97 + case socket.assigns.editing_share do 98 + nil -> do_create(socket, form) 99 + share -> do_update(socket, form, share) 100 + end 39 101 40 102 {:error, changeset} -> 41 103 {:noreply, assign_form(socket, changeset)} 42 104 end 43 105 end 106 + 107 + ## Async lookup (autofill) 44 108 45 109 @impl true 46 110 def handle_async(:lookup, {:ok, {url, {:ok, payload}}}, socket) do ··· 59 123 {:noreply, assign(socket, :looking_up?, false)} 60 124 end 61 125 62 - ## URL lookup orchestration 126 + ## Helpers — lookup 63 127 64 128 defp maybe_start_lookup(socket, song_url) do 65 129 cond do ··· 73 137 socket 74 138 |> assign(:last_lookup_url, url) 75 139 |> assign(:looking_up?, true) 76 - |> start_async(:lookup, fn -> {url, MusicLinks.fetch_for(url)} end) 140 + |> start_async(:lookup, fn -> {url, Atjams.MusicLinks.fetch_for(url)} end) 77 141 end 78 142 79 143 defp looks_like_url?(url) when is_binary(url) do ··· 96 160 |> assign_form(changeset) 97 161 end 98 162 99 - # Only autofill empty fields — never clobber what the user typed. 100 163 defp maybe_put(map, key, val) when is_binary(val) and val != "" do 101 164 case Map.get(map, key) do 102 165 nil -> Map.put(map, key, val) ··· 107 170 108 171 defp maybe_put(map, _key, _), do: map 109 172 110 - ## Share creation 173 + ## Create vs update 111 174 112 175 defp do_create(socket, form) do 113 176 case Atproto.create_share(socket.assigns.current_user, form) do ··· 120 183 |> put_flash(:info, "Shared.") 121 184 |> push_navigate(to: ~p"/feed")} 122 185 123 - {:error, {:pds_error, status, body}} -> 124 - msg = pds_error_message(status, body) 186 + {:error, reason} -> 187 + {:noreply, error_flash(socket, reason)} 188 + end 189 + end 190 + 191 + defp do_update(socket, form, original) do 192 + case Atproto.update_share(socket.assigns.current_user, original.rkey, form, original.created_at) do 193 + {:ok, share_attrs} -> 194 + {:ok, share} = Feed.upsert_share(share_attrs) 195 + 196 + if share_attrs.song_url != original.song_url do 197 + Feed.invalidate_enrichment(share.uri) 198 + end 199 + 200 + Enrichment.start(share.uri) 125 201 126 202 {:noreply, 127 203 socket 128 - |> assign(:submitting?, false) 129 - |> put_flash(:error, msg)} 204 + |> put_flash(:info, "Updated.") 205 + |> push_navigate(to: ~p"/profile/#{share.did}/#{share.rkey}")} 130 206 131 207 {:error, reason} -> 132 - {:noreply, 133 - socket 134 - |> assign(:submitting?, false) 135 - |> put_flash(:error, "Couldn't reach your PDS: #{inspect(reason)}")} 208 + {:noreply, error_flash(socket, reason)} 136 209 end 137 210 end 138 211 212 + defp error_flash(socket, {:pds_error, status, body}) do 213 + msg = pds_error_message(status, body) 214 + socket |> assign(:submitting?, false) |> put_flash(:error, msg) 215 + end 216 + 217 + defp error_flash(socket, reason) do 218 + socket 219 + |> assign(:submitting?, false) 220 + |> put_flash(:error, "Couldn't reach your PDS: #{inspect(reason)}") 221 + end 222 + 139 223 defp pds_error_message(status, %{"message" => msg}) when is_binary(msg) do 140 224 "PDS rejected the record (#{status}): #{msg}" 141 225 end ··· 150 234 151 235 defp assign_form(socket, %Ecto.Changeset{} = changeset) do 152 236 assign(socket, :form, to_form(changeset, as: "share_form")) 237 + end 238 + 239 + defp resolve_did("did:" <> _ = did), do: {:ok, did} 240 + 241 + defp resolve_did(handle) when is_binary(handle) do 242 + case Feed.get_profile_by_handle(handle) do 243 + %_{did: did} -> {:ok, did} 244 + nil -> 245 + case Atproto.resolve_identity(handle) do 246 + {:ok, %{did: did}} -> {:ok, did} 247 + _ -> :error 248 + end 249 + end 153 250 end 154 251 end
+14 -3
lib/atjams_web/live/share_new_live.html.heex
··· 7 7 <.icon name="hero-arrow-left" class="size-4" /> Back to feed 8 8 </.link> 9 9 10 - <h1 class="text-2xl font-bold tracking-tight mt-3">Share a song</h1> 10 + <h1 class="text-2xl font-bold tracking-tight mt-3"> 11 + {if @editing_share, do: "Edit share", else: "Share a song"} 12 + </h1> 11 13 <p class="text-sm text-base-content/60 mt-0.5"> 12 - Drop a link from Spotify, Apple Music, YouTube, Bandcamp, or anywhere else. 14 + <%= if @editing_share do %> 15 + Update the link or notes. Posted {Calendar.strftime(@editing_share.created_at, "%b %d, %Y")}. 16 + <% else %> 17 + Drop a link from Spotify, Apple Music, YouTube, Bandcamp, or anywhere else. 18 + <% end %> 13 19 </p> 14 20 </div> 15 21 ··· 102 108 disabled={@submitting?} 103 109 class="px-5 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white text-sm font-medium shadow-sm shadow-violet-500/20 hover:brightness-105 disabled:opacity-50 disabled:cursor-not-allowed transition" 104 110 > 105 - {if @submitting?, do: "Posting…", else: "Share"} 111 + {cond do 112 + @submitting? and @editing_share -> "Saving…" 113 + @submitting? -> "Posting…" 114 + @editing_share -> "Save" 115 + true -> "Share" 116 + end} 106 117 </button> 107 118 </div> 108 119 </.form>
+1
lib/atjams_web/router.ex
··· 33 33 {AtjamsWeb.UserAuth, :require_session} 34 34 ] do 35 35 live "/share/new", ShareNewLive, :new 36 + live "/profile/:identifier/:rkey/edit", ShareNewLive, :edit 36 37 end 37 38 end 38 39
+65 -2
nix/default.nix
··· 1 1 { 2 2 lib, 3 3 beam, 4 - elixir, 5 4 nodejs, 5 + stdenv, 6 + zstd, 6 7 appName ? "atjams", 7 8 }: 8 9 10 + let 11 + version = "0.1.0"; 12 + in 9 13 beam.mixRelease { 10 14 pname = appName; 11 - version = "0.1.0"; 15 + inherit version; 12 16 src = ../.; 13 17 mixEnv = "prod"; 14 18 ··· 16 20 nodejs 17 21 ]; 18 22 23 + # ezstd is a NIF dependency that requires zstd. We provide the system 24 + # zstd and patch build_deps.sh to be a no-op (it normally clones & builds 25 + # zstd from source, which doesn't work in the Nix sandbox). 26 + buildInputs = [ 27 + stdenv.cc 28 + zstd 29 + ]; 30 + 31 + configurePhase = '' 32 + runHook preConfigure 33 + echo "=== CUSTOM configurePhase from atjams ===" 34 + 35 + # Fix ezstd: provide system zstd headers+lib and make build_deps.sh a 36 + # no-op (it normally clones & builds zstd from source, which doesn't 37 + # work in the Nix sandbox). 38 + if [ -d "$MIX_DEPS_PATH/ezstd" ]; then 39 + echo "ezstd found at $MIX_DEPS_PATH/ezstd" 40 + ZSTD_DIR="$MIX_DEPS_PATH/ezstd/_build/deps/zstd" 41 + mkdir -p "$ZSTD_DIR/lib" 42 + 43 + for h in zstd.h zstd_errors.h zdict.h; do 44 + if [ -f "${zstd.dev}/include/$h" ]; then 45 + ln -sf "${zstd.dev}/include/$h" "$ZSTD_DIR/lib/" 46 + fi 47 + done 48 + 49 + ln -sf "${zstd.out}/lib/libzstd.so" "$ZSTD_DIR/lib/libzstd.so" 2>/dev/null || true 50 + 51 + printf '#!/usr/bin/env bash\nexit 0\n' > "$MIX_DEPS_PATH/ezstd/build_deps.sh" 52 + chmod +x "$MIX_DEPS_PATH/ezstd/build_deps.sh" 53 + chmod +x "$MIX_DEPS_PATH/ezstd/c_src/"*.sh 2>/dev/null || true 54 + else 55 + echo "ezstd NOT FOUND at $MIX_DEPS_PATH/ezstd" 56 + echo "MIX_DEPS_PATH=$MIX_DEPS_PATH" 57 + ls "$MIX_DEPS_PATH/" 2>/dev/null || echo "deps dir empty or missing" 58 + fi 59 + 60 + mix deps.compile --no-deps-check --skip-umbrella-children 61 + 62 + ln -sfn "$MIX_DEPS_PATH" deps 63 + 64 + runHook postConfigure 65 + ''; 66 + 67 + # Fetch hex dependencies as a fixed-output derivation. 68 + # To update: delete the hash, rebuild, and copy the suggested hash. 69 + mixFodDeps = beam.fetchMixDeps { 70 + pname = appName; 71 + inherit version; 72 + src = ../.; 73 + hash = "sha256-e2YhuIeAlKYVJBwae4ly5WAI+E8tihmjlhADgkRCwfU="; 74 + }; 75 + 19 76 preBuild = '' 20 77 mix assets.deploy 78 + ''; 79 + 80 + postInstall = '' 81 + chmod +x "$out/bin/atjams" 82 + chmod +x "$out/bin/server" 2>/dev/null || true 83 + chmod +x "$out/bin/migrate" 2>/dev/null || true 21 84 ''; 22 85 23 86 meta = with lib; {