audio streaming app plyr.fm
38
fork

Configure Feed

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

fix: handle stale avatar URLs with auto-refresh from Bluesky (#749)

when avatar images fail to load (404), the profile page now:
1. shows a placeholder silhouette immediately (good UX)
2. calls POST /artists/{did}/refresh-avatar to fetch fresh URL from Bluesky
3. updates the database and re-renders with the new avatar

this fixes broken avatars for users who changed their Bluesky avatar
but haven't logged in since - the DB gets fixed automatically when
anyone visits the profile.

extends the onerror fallback pattern from TrackItem/TrackInfo (0a4d1c1)
to the user profile page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

authored by

nate nowack
Claude Opus 4.5
and committed by
GitHub
405c7976 63fd1d3a

+109 -3
+34
backend/src/backend/api/artists.py
··· 345 345 returns zeros if artist has no tracks - no need to verify artist exists. 346 346 """ 347 347 return await get_artist_analytics(auth_session.did, db) 348 + 349 + 350 + class RefreshAvatarResponse(BaseModel): 351 + """response from avatar refresh.""" 352 + 353 + avatar_url: str | None 354 + 355 + 356 + @router.post("/{did}/refresh-avatar") 357 + async def refresh_artist_avatar( 358 + did: str, 359 + db: Annotated[AsyncSession, Depends(get_db)], 360 + ) -> RefreshAvatarResponse: 361 + """refresh an artist's avatar from Bluesky (public endpoint). 362 + 363 + called when the frontend detects a stale/broken avatar URL (404). 364 + fetches the current avatar from Bluesky and updates the database. 365 + """ 366 + result = await db.execute(select(Artist).where(Artist.did == did)) 367 + artist = result.scalar_one_or_none() 368 + if not artist: 369 + raise HTTPException(status_code=404, detail="artist not found") 370 + 371 + # fetch fresh avatar from Bluesky 372 + fresh_avatar = await fetch_user_avatar(did) 373 + 374 + # update database if changed 375 + if fresh_avatar != artist.avatar_url: 376 + artist.avatar_url = fresh_avatar 377 + artist.updated_at = datetime.now(UTC) 378 + await db.commit() 379 + logger.info(f"refreshed avatar for {did}: {fresh_avatar}") 380 + 381 + return RefreshAvatarResponse(avatar_url=fresh_avatar)
+75 -3
frontend/src/routes/u/[handle]/+page.svelte
··· 81 81 // track which artist we've loaded data for to detect navigation 82 82 let loadedForDid = $state<string | null>(null); 83 83 84 + // track avatar load errors for fallback 85 + let avatarError = $state(false); 86 + let refreshedAvatarUrl = $state<string | null>(null); 87 + const avatarUrl = $derived(refreshedAvatarUrl || artist?.avatar_url); 88 + 89 + /** 90 + * called when avatar image fails to load (404/broken URL). 91 + * triggers a backend refresh from Bluesky and updates the display. 92 + */ 93 + async function handleAvatarError() { 94 + avatarError = true; 95 + 96 + // don't retry if we've already refreshed 97 + if (refreshedAvatarUrl !== null || !artist?.did) return; 98 + 99 + try { 100 + const response = await fetch(`${API_URL}/artists/${artist.did}/refresh-avatar`, { 101 + method: 'POST' 102 + }); 103 + if (response.ok) { 104 + const data = await response.json(); 105 + if (data.avatar_url) { 106 + refreshedAvatarUrl = data.avatar_url; 107 + avatarError = false; // try loading the new URL 108 + } 109 + } 110 + } catch (_e) { 111 + // silently fail - placeholder is already showing 112 + } 113 + } 114 + 84 115 async function handleLogout() { 85 116 await auth.logout(); 86 117 window.location.href = '/'; ··· 221 252 isSupporter = false; 222 253 supporterCount = null; 223 254 supporters = []; 255 + avatarError = false; 256 + refreshedAvatarUrl = null; 224 257 225 258 // sync tracks and pagination from server data 226 259 tracks = data.tracks ?? []; ··· 382 415 383 416 <main> 384 417 <section class="artist-header"> 385 - {#if artist.avatar_url} 386 - <SensitiveImage src={artist.avatar_url}> 387 - <img src={artist.avatar_url} alt={artist.display_name} class="artist-avatar" /> 418 + {#if avatarUrl && !avatarError} 419 + <SensitiveImage src={avatarUrl}> 420 + <img 421 + src={avatarUrl} 422 + alt={artist.display_name} 423 + class="artist-avatar" 424 + onerror={handleAvatarError} 425 + /> 388 426 </SensitiveImage> 427 + {:else} 428 + <div class="artist-avatar-placeholder"> 429 + <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 430 + <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" /> 431 + <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 432 + </svg> 433 + </div> 389 434 {/if} 390 435 <div class="artist-details"> 391 436 <div class="artist-info"> ··· 744 789 border-radius: var(--radius-full); 745 790 object-fit: cover; 746 791 border: 3px solid var(--border-default); 792 + } 793 + 794 + .artist-avatar-placeholder { 795 + width: 120px; 796 + height: 120px; 797 + border-radius: var(--radius-full); 798 + border: 3px solid var(--border-default); 799 + background: var(--bg-tertiary); 800 + display: flex; 801 + align-items: center; 802 + justify-content: center; 803 + color: var(--text-muted); 804 + } 805 + 806 + .artist-avatar-placeholder svg { 807 + width: 48px; 808 + height: 48px; 747 809 } 748 810 749 811 .artist-info h1 { ··· 1224 1286 .artist-avatar { 1225 1287 width: 100px; 1226 1288 height: 100px; 1289 + } 1290 + 1291 + .artist-avatar-placeholder { 1292 + width: 100px; 1293 + height: 100px; 1294 + } 1295 + 1296 + .artist-avatar-placeholder svg { 1297 + width: 40px; 1298 + height: 40px; 1227 1299 } 1228 1300 1229 1301 .artist-info h1 {