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 - **migrations**: automated via fly.io release_command 13 - **logs**: `flyctl logs` is BLOCKING - use `run_in_background=true` 14 - **type hints**: required everywhere 15 - - **ATProto namespaces**: NEVER use Bluesky lexicons (app.bsky.*). ALWAYS use our namespace (fm.plyr.*) for ALL records 16 - **communication**: use emojis sparingly and strictly for emphasis (avoid excessive check marks) 17 18 ## structure
··· 12 - **migrations**: automated via fly.io release_command 13 - **logs**: `flyctl logs` is BLOCKING - use `run_in_background=true` 14 - **type hints**: required everywhere 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 - **communication**: use emojis sparingly and strictly for emphasis (avoid excessive check marks) 17 18 ## structure
+6 -1
docs/tools/pdsx.md
··· 115 116 ## atproto namespace 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. 119 120 **critical**: never use bluesky lexicons (app.bsky.*) for plyr.fm records. always use fm.plyr.* namespace. 121 122 ## credential management 123
··· 115 116 ## atproto namespace 117 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` 122 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`. 126 127 ## credential management 128
+31 -1
frontend/src/lib/components/HandleSearch.svelte
··· 8 onRemove: (_did: string) => void; 9 maxFeatures?: number; 10 disabled?: boolean; 11 } 12 13 - let { selected = $bindable([]), onAdd, onRemove, maxFeatures = 5, disabled = false }: Props = $props(); 14 15 let query = $state(''); 16 let results = $state<FeaturedArtist[]>([]); 17 let searching = $state(false); 18 let showResults = $state(false); 19 let searchTimeout: ReturnType<typeof setTimeout> | null = null; 20 21 async function searchHandles() { 22 if (query.length < 2) { 23 results = []; 24 return; 25 } 26 27 searching = true; 28 try { 29 const response = await fetch(`${API_URL}/search/handles?q=${encodeURIComponent(query)}`); 30 if (response.ok) { 31 const data = await response.json(); 32 results = data.results; 33 showResults = true; 34 } 35 } catch (_e) { ··· 57 query = ''; 58 results = []; 59 showResults = false; 60 } 61 62 function removeArtist(did: string) { ··· 108 </div> 109 </button> 110 {/each} 111 </div> 112 {/if} 113 </div> ··· 343 margin-top: 0.5rem; 344 font-size: 0.85rem; 345 color: #ff9966; 346 } 347 348 /* mobile styles */
··· 8 onRemove: (_did: string) => void; 9 maxFeatures?: number; 10 disabled?: boolean; 11 + hasUnresolvedInput?: boolean; 12 } 13 14 + let { selected = $bindable([]), onAdd, onRemove, maxFeatures = 5, disabled = false, hasUnresolvedInput = $bindable(false) }: Props = $props(); 15 16 let query = $state(''); 17 let results = $state<FeaturedArtist[]>([]); 18 let searching = $state(false); 19 let showResults = $state(false); 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 + }); 27 28 async function searchHandles() { 29 if (query.length < 2) { 30 results = []; 31 + noResultsFound = false; 32 return; 33 } 34 35 searching = true; 36 + noResultsFound = false; 37 try { 38 const response = await fetch(`${API_URL}/search/handles?q=${encodeURIComponent(query)}`); 39 if (response.ok) { 40 const data = await response.json(); 41 results = data.results; 42 + if (results.length === 0) { 43 + noResultsFound = true; 44 + } 45 showResults = true; 46 } 47 } catch (_e) { ··· 69 query = ''; 70 results = []; 71 showResults = false; 72 + noResultsFound = false; 73 } 74 75 function removeArtist(did: string) { ··· 121 </div> 122 </button> 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}" 130 </div> 131 {/if} 132 </div> ··· 362 margin-top: 0.5rem; 363 font-size: 0.85rem; 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; 376 } 377 378 /* mobile styles */
+7 -2
frontend/src/routes/portal/+page.svelte
··· 37 let file: File | null = null; 38 let imageFile: File | null = null; 39 let featuredArtists: FeaturedArtist[] = []; 40 41 // track editing state 42 let editingTrackId: number | null = null; ··· 44 let editAlbum = ''; 45 let editFeaturedArtists: FeaturedArtist[] = []; 46 let editImageFile: File | null = null; 47 48 // profile editing state 49 let displayName = ''; ··· 615 <label for="features">featured artists (optional)</label> 616 <HandleSearch 617 bind:selected={featuredArtists} 618 onAdd={(artist) => { featuredArtists = [...featuredArtists, artist]; }} 619 onRemove={(did) => { featuredArtists = featuredArtists.filter(a => a.did !== did); }} 620 /> ··· 649 {/if} 650 </div> 651 652 - <button type="submit" disabled={!file} class="upload-btn"> 653 <span>upload track</span> 654 </button> 655 </form> ··· 692 <div class="edit-label">featured artists (optional)</div> 693 <HandleSearch 694 bind:selected={editFeaturedArtists} 695 onAdd={(artist) => { editFeaturedArtists = [...editFeaturedArtists, artist]; }} 696 onRemove={(did) => { editFeaturedArtists = editFeaturedArtists.filter(a => a.did !== did); }} 697 /> ··· 723 <button 724 class="action-btn save-btn" 725 onclick={() => saveTrackEdit(track.id)} 726 - title="save changes" 727 > 728 729 </button>
··· 37 let file: File | null = null; 38 let imageFile: File | null = null; 39 let featuredArtists: FeaturedArtist[] = []; 40 + let hasUnresolvedFeaturesInput = $state(false); 41 42 // track editing state 43 let editingTrackId: number | null = null; ··· 45 let editAlbum = ''; 46 let editFeaturedArtists: FeaturedArtist[] = []; 47 let editImageFile: File | null = null; 48 + let hasUnresolvedEditFeaturesInput = $state(false); 49 50 // profile editing state 51 let displayName = ''; ··· 617 <label for="features">featured artists (optional)</label> 618 <HandleSearch 619 bind:selected={featuredArtists} 620 + bind:hasUnresolvedInput={hasUnresolvedFeaturesInput} 621 onAdd={(artist) => { featuredArtists = [...featuredArtists, artist]; }} 622 onRemove={(did) => { featuredArtists = featuredArtists.filter(a => a.did !== did); }} 623 /> ··· 652 {/if} 653 </div> 654 655 + <button type="submit" disabled={!file || hasUnresolvedFeaturesInput} class="upload-btn" title={hasUnresolvedFeaturesInput ? "please select or clear featured artist" : ""}> 656 <span>upload track</span> 657 </button> 658 </form> ··· 695 <div class="edit-label">featured artists (optional)</div> 696 <HandleSearch 697 bind:selected={editFeaturedArtists} 698 + bind:hasUnresolvedInput={hasUnresolvedEditFeaturesInput} 699 onAdd={(artist) => { editFeaturedArtists = [...editFeaturedArtists, artist]; }} 700 onRemove={(did) => { editFeaturedArtists = editFeaturedArtists.filter(a => a.did !== did); }} 701 /> ··· 727 <button 728 class="action-btn save-btn" 729 onclick={() => saveTrackEdit(track.id)} 730 + disabled={hasUnresolvedEditFeaturesInput} 731 + title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"} 732 > 733 734 </button>
+39 -29
src/backend/api/tracks/mutations.py
··· 153 track.image_url = image_url 154 image_changed = True 155 156 - if track.atproto_record_uri and ( 157 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) 160 161 await db.commit() 162 await db.refresh(track) ··· 169 auth_session: AuthSession, 170 image_url_override: str | None = None, 171 ) -> None: 172 record_uri = track.atproto_record_uri 173 audio_url = track.r2_url 174 if not record_uri or not audio_url: 175 return 176 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 - ) 194 195 - if result: 196 - _, new_cid = result 197 - track.atproto_record_cid = new_cid 198 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 205 206 207 class RestoreRecordResponse(BaseModel):
··· 153 track.image_url = image_url 154 image_changed = True 155 156 + # always update ATProto record if any metadata changed 157 + metadata_changed = ( 158 title_changed or album is not None or features is not None or image_changed 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 173 174 await db.commit() 175 await db.refresh(track) ··· 182 auth_session: AuthSession, 183 image_url_override: str | None = None, 184 ) -> None: 185 + """Update the ATProto record for a track. 186 + 187 + raises: 188 + Exception: if ATProto record update fails 189 + """ 190 record_uri = track.atproto_record_uri 191 audio_url = track.r2_url 192 if not record_uri or not audio_url: 193 return 194 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 + ) 205 206 + result = await update_record( 207 + auth_session=auth_session, 208 + record_uri=record_uri, 209 + record=updated_record, 210 + ) 211 212 + if result: 213 + _, new_cid = result 214 + track.atproto_record_cid = new_cid 215 216 217 class RestoreRecordResponse(BaseModel):