fix: ensure ATProto records sync when tracks are updated (#302)

* fix: ensure ATProto records sync when tracks are updated

this fixes an issue where track metadata updates would succeed in the database but fail silently when syncing to ATProto, leaving records out of sync.

changes:
- make ATProto sync errors blocking - if ATProto update fails, the entire request fails
- explicit rollback on ATProto sync failure to prevent database/PDS inconsistency
- return 500 error with clear message when sync fails

before this fix, users would see success messages even when their ATProto records weren't actually updated, which was confusing and led to data inconsistency.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: clarify environment-specific ATProto namespaces

updates CLAUDE.md and docs/tools/pdsx.md to accurately reflect that plyr.fm uses environment-specific namespaces configured via ATPROTO_APP_NAMESPACE:
- dev: fm.plyr.dev
- staging: fm.plyr.stg
- prod: fm.plyr

previous docs incorrectly stated all environments use unified fm.plyr.track namespace.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: show feedback when featured artist handle not found

adds clear "no artist found" message when handle search returns no results, preventing confusion when users type non-existent handles.

before: users could type invalid handles and submit would silently save empty features array
after: users see clear feedback that handle wasn't found

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: prevent form submission with unresolved featured artist input

disables upload and edit form submission when there's text in the featured artists search that hasn't been resolved to an actual artist.

before: could submit forms with typed text that wasn't added to features
after: submit buttons disabled until text is either selected or cleared

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 024f54f0 89b7e51d

Changed files
+84 -34
docs
tools
frontend
src
lib
components
routes
portal
src
backend
api
tracks
+1 -1
CLAUDE.md
··· 12 12 - **migrations**: automated via fly.io release_command 13 13 - **logs**: `flyctl logs` is BLOCKING - use `run_in_background=true` 14 14 - **type hints**: required everywhere 15 - - **ATProto namespaces**: NEVER use Bluesky lexicons (app.bsky.*). ALWAYS use our namespace (fm.plyr.*) for ALL records 15 + - **ATProto namespaces**: NEVER use Bluesky lexicons (app.bsky.*). ALWAYS use our namespace (fm.plyr.*), configured per environment via `ATPROTO_APP_NAMESPACE` (e.g., fm.plyr.dev for dev, fm.plyr for prod) 16 16 - **communication**: use emojis sparingly and strictly for emphasis (avoid excessive check marks) 17 17 18 18 ## structure
+6 -1
docs/tools/pdsx.md
··· 115 115 116 116 ## atproto namespace 117 117 118 - all plyr.fm records use the unified `fm.plyr.track` namespace across all environments (dev, staging, prod). there are no environment-specific namespaces. 118 + plyr.fm uses environment-specific namespaces configured via `ATPROTO_APP_NAMESPACE`: 119 + - **dev**: `fm.plyr.dev` โ†’ track collection: `fm.plyr.dev.track` 120 + - **staging**: `fm.plyr.stg` โ†’ track collection: `fm.plyr.stg.track` 121 + - **prod**: `fm.plyr` โ†’ track collection: `fm.plyr.track` 119 122 120 123 **critical**: never use bluesky lexicons (app.bsky.*) for plyr.fm records. always use fm.plyr.* namespace. 124 + 125 + when using pdsx with dev environment, query `fm.plyr.dev.track`, not `fm.plyr.track`. 121 126 122 127 ## credential management 123 128
+31 -1
frontend/src/lib/components/HandleSearch.svelte
··· 8 8 onRemove: (_did: string) => void; 9 9 maxFeatures?: number; 10 10 disabled?: boolean; 11 + hasUnresolvedInput?: boolean; 11 12 } 12 13 13 - let { selected = $bindable([]), onAdd, onRemove, maxFeatures = 5, disabled = false }: Props = $props(); 14 + let { selected = $bindable([]), onAdd, onRemove, maxFeatures = 5, disabled = false, hasUnresolvedInput = $bindable(false) }: Props = $props(); 14 15 15 16 let query = $state(''); 16 17 let results = $state<FeaturedArtist[]>([]); 17 18 let searching = $state(false); 18 19 let showResults = $state(false); 19 20 let searchTimeout: ReturnType<typeof setTimeout> | null = null; 21 + let noResultsFound = $state(false); 22 + 23 + // update parent when there's unresolved input 24 + $effect(() => { 25 + hasUnresolvedInput = query.trim().length > 0; 26 + }); 20 27 21 28 async function searchHandles() { 22 29 if (query.length < 2) { 23 30 results = []; 31 + noResultsFound = false; 24 32 return; 25 33 } 26 34 27 35 searching = true; 36 + noResultsFound = false; 28 37 try { 29 38 const response = await fetch(`${API_URL}/search/handles?q=${encodeURIComponent(query)}`); 30 39 if (response.ok) { 31 40 const data = await response.json(); 32 41 results = data.results; 42 + if (results.length === 0) { 43 + noResultsFound = true; 44 + } 33 45 showResults = true; 34 46 } 35 47 } catch (_e) { ··· 57 69 query = ''; 58 70 results = []; 59 71 showResults = false; 72 + noResultsFound = false; 60 73 } 61 74 62 75 function removeArtist(did: string) { ··· 108 121 </div> 109 122 </button> 110 123 {/each} 124 + </div> 125 + {/if} 126 + 127 + {#if noResultsFound && query.length >= 2} 128 + <div class="no-results-message"> 129 + no artist found with handle "{query}" 111 130 </div> 112 131 {/if} 113 132 </div> ··· 343 362 margin-top: 0.5rem; 344 363 font-size: 0.85rem; 345 364 color: #ff9966; 365 + } 366 + 367 + .no-results-message { 368 + margin-top: 0.5rem; 369 + padding: 0.75rem; 370 + background: #2a1a1a; 371 + border: 1px solid #4a3030; 372 + border-radius: 4px; 373 + color: #ff9966; 374 + font-size: 0.9rem; 375 + text-align: center; 346 376 } 347 377 348 378 /* mobile styles */
+7 -2
frontend/src/routes/portal/+page.svelte
··· 37 37 let file: File | null = null; 38 38 let imageFile: File | null = null; 39 39 let featuredArtists: FeaturedArtist[] = []; 40 + let hasUnresolvedFeaturesInput = $state(false); 40 41 41 42 // track editing state 42 43 let editingTrackId: number | null = null; ··· 44 45 let editAlbum = ''; 45 46 let editFeaturedArtists: FeaturedArtist[] = []; 46 47 let editImageFile: File | null = null; 48 + let hasUnresolvedEditFeaturesInput = $state(false); 47 49 48 50 // profile editing state 49 51 let displayName = ''; ··· 615 617 <label for="features">featured artists (optional)</label> 616 618 <HandleSearch 617 619 bind:selected={featuredArtists} 620 + bind:hasUnresolvedInput={hasUnresolvedFeaturesInput} 618 621 onAdd={(artist) => { featuredArtists = [...featuredArtists, artist]; }} 619 622 onRemove={(did) => { featuredArtists = featuredArtists.filter(a => a.did !== did); }} 620 623 /> ··· 649 652 {/if} 650 653 </div> 651 654 652 - <button type="submit" disabled={!file} class="upload-btn"> 655 + <button type="submit" disabled={!file || hasUnresolvedFeaturesInput} class="upload-btn" title={hasUnresolvedFeaturesInput ? "please select or clear featured artist" : ""}> 653 656 <span>upload track</span> 654 657 </button> 655 658 </form> ··· 692 695 <div class="edit-label">featured artists (optional)</div> 693 696 <HandleSearch 694 697 bind:selected={editFeaturedArtists} 698 + bind:hasUnresolvedInput={hasUnresolvedEditFeaturesInput} 695 699 onAdd={(artist) => { editFeaturedArtists = [...editFeaturedArtists, artist]; }} 696 700 onRemove={(did) => { editFeaturedArtists = editFeaturedArtists.filter(a => a.did !== did); }} 697 701 /> ··· 723 727 <button 724 728 class="action-btn save-btn" 725 729 onclick={() => saveTrackEdit(track.id)} 726 - title="save changes" 730 + disabled={hasUnresolvedEditFeaturesInput} 731 + title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"} 727 732 > 728 733 โœ“ 729 734 </button>
+39 -29
src/backend/api/tracks/mutations.py
··· 153 153 track.image_url = image_url 154 154 image_changed = True 155 155 156 - if track.atproto_record_uri and ( 156 + # always update ATProto record if any metadata changed 157 + metadata_changed = ( 157 158 title_changed or album is not None or features is not None or image_changed 158 - ): 159 - await _update_atproto_record(track, auth_session, image_url) 159 + ) 160 + if track.atproto_record_uri and metadata_changed: 161 + try: 162 + await _update_atproto_record(track, auth_session, image_url) 163 + except Exception as exc: 164 + logger.error( 165 + f"failed to update ATProto record for track {track.id}: {exc}", 166 + exc_info=True, 167 + ) 168 + await db.rollback() 169 + raise HTTPException( 170 + status_code=500, 171 + detail=f"failed to sync track update to ATProto: {exc!s}", 172 + ) from exc 160 173 161 174 await db.commit() 162 175 await db.refresh(track) ··· 169 182 auth_session: AuthSession, 170 183 image_url_override: str | None = None, 171 184 ) -> None: 185 + """Update the ATProto record for a track. 186 + 187 + raises: 188 + Exception: if ATProto record update fails 189 + """ 172 190 record_uri = track.atproto_record_uri 173 191 audio_url = track.r2_url 174 192 if not record_uri or not audio_url: 175 193 return 176 194 177 - try: 178 - updated_record = build_track_record( 179 - title=track.title, 180 - artist=track.artist.display_name, 181 - audio_url=audio_url, 182 - file_type=track.file_type, 183 - album=track.album, 184 - duration=None, 185 - features=track.features if track.features else None, 186 - image_url=image_url_override or await track.get_image_url(), 187 - ) 188 - 189 - result = await update_record( 190 - auth_session=auth_session, 191 - record_uri=record_uri, 192 - record=updated_record, 193 - ) 195 + updated_record = build_track_record( 196 + title=track.title, 197 + artist=track.artist.display_name, 198 + audio_url=audio_url, 199 + file_type=track.file_type, 200 + album=track.album, 201 + duration=None, 202 + features=track.features if track.features else None, 203 + image_url=image_url_override or await track.get_image_url(), 204 + ) 194 205 195 - if result: 196 - _, new_cid = result 197 - track.atproto_record_cid = new_cid 206 + result = await update_record( 207 + auth_session=auth_session, 208 + record_uri=record_uri, 209 + record=updated_record, 210 + ) 198 211 199 - except Exception as exc: # pragma: no cover - network/service failures 200 - logger.warning( 201 - f"failed to update ATProto record for track {track.id}: {exc}", 202 - exc_info=True, 203 - ) 204 - # continue even if ATProto update fails - database changes are primary 212 + if result: 213 + _, new_cid = result 214 + track.atproto_record_cid = new_cid 205 215 206 216 207 217 class RestoreRecordResponse(BaseModel):