audio streaming app plyr.fm

fix: trigger avatar refresh from any broken avatar (#751)

Previously, stale avatar URLs were only fixed when visiting the artist
detail page. Now any component that renders a broken avatar will trigger
the background refresh:

- TrackItem (artist avatar as fallback)
- LikersTooltip (liker avatars)
- CommentersTooltip (commenter avatars)
- User profile page (now uses shared system)

New avatar-refresh.svelte.ts provides:
- Global cache of refreshed URLs (shared across components)
- Request deduplication (won't retry same DID)
- Reactive updates (components re-render when refresh completes)

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

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

authored by zzstoatzz.io

Claude Opus 4.5 and committed by
GitHub
8eec8608 5dce8ff2

+237 -33
+108
frontend/src/lib/avatar-refresh.svelte.ts
··· 1 + /** 2 + * global avatar refresh system. 3 + * 4 + * when any avatar fails to load (404/stale URL), trigger a background refresh 5 + * from Bluesky. the refreshed URL is cached globally so all components showing 6 + * that avatar will update. 7 + * 8 + * usage: 9 + * import { getRefreshedAvatar, triggerAvatarRefresh } from '$lib/avatar-refresh.svelte'; 10 + * 11 + * const refreshedUrl = $derived(getRefreshedAvatar(did)); 12 + * const displayUrl = $derived(refreshedUrl ?? originalAvatarUrl); 13 + * 14 + * function handleError() { 15 + * avatarError = true; 16 + * triggerAvatarRefresh(did); 17 + * } 18 + */ 19 + 20 + import { API_URL } from './config'; 21 + 22 + // map of DID -> refreshed avatar URL (string or null if no avatar) 23 + let refreshedAvatars = $state<Map<string, string | null>>(new Map()); 24 + 25 + // DIDs currently being refreshed (to avoid duplicate requests) 26 + const refreshingDids = new Set<string>(); 27 + 28 + // DIDs that have been attempted (to avoid repeated failures) 29 + const attemptedDids = new Set<string>(); 30 + 31 + /** 32 + * get the refreshed avatar URL for a DID. 33 + * returns the refreshed URL, or null if not available/not yet refreshed. 34 + */ 35 + export function getRefreshedAvatar(did: string | undefined): string | null { 36 + if (!did) return null; 37 + return refreshedAvatars.get(did) ?? null; 38 + } 39 + 40 + /** 41 + * check if we've already attempted to refresh this DID. 42 + * (either succeeded, failed, or currently in progress) 43 + */ 44 + export function hasAttemptedRefresh(did: string | undefined): boolean { 45 + if (!did) return true; // treat missing DID as "already attempted" 46 + return attemptedDids.has(did) || refreshingDids.has(did); 47 + } 48 + 49 + /** 50 + * trigger a background refresh of an avatar from Bluesky. 51 + * safe to call multiple times - will dedupe requests. 52 + * 53 + * @param did - the user's DID 54 + * @returns promise that resolves when refresh completes (or immediately if skipped) 55 + */ 56 + export async function triggerAvatarRefresh(did: string | undefined): Promise<void> { 57 + if (!did) return; 58 + 59 + // skip if already refreshed, attempted, or in progress 60 + if (refreshedAvatars.has(did) || attemptedDids.has(did) || refreshingDids.has(did)) { 61 + return; 62 + } 63 + 64 + refreshingDids.add(did); 65 + 66 + try { 67 + const response = await fetch(`${API_URL}/artists/${did}/refresh-avatar`, { 68 + method: 'POST' 69 + }); 70 + 71 + if (response.ok) { 72 + const data = await response.json(); 73 + // create new map to trigger reactivity 74 + const newMap = new Map(refreshedAvatars); 75 + newMap.set(did, data.avatar_url || null); 76 + refreshedAvatars = newMap; 77 + } else { 78 + // mark as attempted even on failure (don't retry 404s etc) 79 + attemptedDids.add(did); 80 + } 81 + } catch { 82 + // silently fail - mark as attempted so we don't retry 83 + attemptedDids.add(did); 84 + } finally { 85 + refreshingDids.delete(did); 86 + attemptedDids.add(did); 87 + } 88 + } 89 + 90 + /** 91 + * manually set a refreshed avatar URL. 92 + * useful when you've already fetched the URL elsewhere (e.g., profile page). 93 + */ 94 + export function setRefreshedAvatar(did: string, url: string | null): void { 95 + const newMap = new Map(refreshedAvatars); 96 + newMap.set(did, url); 97 + refreshedAvatars = newMap; 98 + attemptedDids.add(did); 99 + } 100 + 101 + /** 102 + * clear the refresh cache. mainly for testing. 103 + */ 104 + export function clearAvatarCache(): void { 105 + refreshedAvatars = new Map(); 106 + refreshingDids.clear(); 107 + attemptedDids.clear(); 108 + }
+45 -3
frontend/src/lib/components/CommentersTooltip.svelte
··· 1 1 <script lang="ts"> 2 2 import { API_URL } from '$lib/config'; 3 3 import { getCommenters, setCommenters, type CommenterData } from '$lib/tooltip-cache.svelte'; 4 + import { 5 + getRefreshedAvatar, 6 + triggerAvatarRefresh, 7 + hasAttemptedRefresh 8 + } from '$lib/avatar-refresh.svelte'; 4 9 import SensitiveImage from './SensitiveImage.svelte'; 5 10 6 11 interface Props { ··· 17 22 let error = $state<string | null>(null); 18 23 let tooltipElement: HTMLDivElement | null = $state(null); 19 24 let positionBelow = $state(false); 25 + 26 + // track which avatars have errored (by DID) 27 + let avatarErrors = $state<Set<string>>(new Set()); 28 + 29 + /** 30 + * get the display URL for a commenter's avatar. 31 + * prefers refreshed URL from global cache, falls back to original. 32 + */ 33 + function getDisplayUrl(commenter: CommenterData): string | null { 34 + const refreshed = getRefreshedAvatar(commenter.did); 35 + return refreshed ?? commenter.avatar_url; 36 + } 37 + 38 + /** 39 + * handle avatar load error - show fallback and trigger refresh. 40 + */ 41 + function handleAvatarError(did: string) { 42 + avatarErrors = new Set([...avatarErrors, did]); 43 + 44 + if (!hasAttemptedRefresh(did)) { 45 + triggerAvatarRefresh(did); 46 + } 47 + } 48 + 49 + /** 50 + * check if avatar should show fallback. 51 + */ 52 + function shouldShowFallback(commenter: CommenterData): boolean { 53 + const url = getDisplayUrl(commenter); 54 + return !url || avatarErrors.has(commenter.did); 55 + } 20 56 21 57 // check if tooltip should flip below based on viewport position 22 58 $effect(() => { ··· 103 139 {:else if commenters.length > 0} 104 140 <div class="commenters-avatars"> 105 141 {#each commenters as commenter (commenter.did)} 142 + {@const displayUrl = getDisplayUrl(commenter)} 143 + {@const showFallback = shouldShowFallback(commenter)} 106 144 <a 107 145 href="/u/{commenter.handle}" 108 146 class="commenter-circle" 109 147 title="{commenter.display_name || commenter.handle} (@{commenter.handle})" 110 148 > 111 - {#if commenter.avatar_url} 112 - <SensitiveImage src={commenter.avatar_url} compact> 113 - <img src={commenter.avatar_url} alt="" /> 149 + {#if displayUrl && !showFallback} 150 + <SensitiveImage src={displayUrl} compact> 151 + <img 152 + src={displayUrl} 153 + alt="" 154 + onerror={() => handleAvatarError(commenter.did)} 155 + /> 114 156 </SensitiveImage> 115 157 {:else} 116 158 <span>{(commenter.display_name || commenter.handle).charAt(0).toUpperCase()}</span>
+46 -4
frontend/src/lib/components/LikersTooltip.svelte
··· 1 1 <script lang="ts"> 2 2 import { API_URL } from '$lib/config'; 3 3 import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 4 + import { 5 + getRefreshedAvatar, 6 + triggerAvatarRefresh, 7 + hasAttemptedRefresh 8 + } from '$lib/avatar-refresh.svelte'; 4 9 import SensitiveImage from './SensitiveImage.svelte'; 5 10 6 11 interface Props { ··· 17 22 let error = $state<string | null>(null); 18 23 let tooltipElement: HTMLDivElement | null = $state(null); 19 24 let positionBelow = $state(false); 25 + 26 + // track which avatars have errored (by DID) 27 + let avatarErrors = $state<Set<string>>(new Set()); 28 + 29 + /** 30 + * get the display URL for a liker's avatar. 31 + * prefers refreshed URL from global cache, falls back to original. 32 + */ 33 + function getDisplayUrl(liker: LikerData): string | null { 34 + const refreshed = getRefreshedAvatar(liker.did); 35 + return refreshed ?? liker.avatar_url; 36 + } 37 + 38 + /** 39 + * handle avatar load error - show fallback and trigger refresh. 40 + */ 41 + function handleAvatarError(did: string) { 42 + avatarErrors = new Set([...avatarErrors, did]); 43 + 44 + if (!hasAttemptedRefresh(did)) { 45 + triggerAvatarRefresh(did); 46 + } 47 + } 48 + 49 + /** 50 + * check if avatar should show fallback. 51 + */ 52 + function shouldShowFallback(liker: LikerData): boolean { 53 + const url = getDisplayUrl(liker); 54 + return !url || avatarErrors.has(liker.did); 55 + } 20 56 21 57 // check if tooltip should flip below based on viewport position 22 58 $effect(() => { ··· 101 137 {:else if likers.length > 0} 102 138 <div class="likers-avatars"> 103 139 {#each likers as liker (liker.did)} 140 + {@const displayUrl = getDisplayUrl(liker)} 141 + {@const showFallback = shouldShowFallback(liker)} 104 142 <a 105 143 href="/u/{liker.handle}/liked" 106 144 class="liker-circle" 107 145 title="{liker.display_name} (@{liker.handle}) • {formatTime(liker.liked_at)}" 108 146 > 109 - {#if liker.avatar_url} 110 - <SensitiveImage src={liker.avatar_url} compact> 111 - <img src={liker.avatar_url} alt="" /> 147 + {#if displayUrl && !showFallback} 148 + <SensitiveImage src={displayUrl} compact> 149 + <img 150 + src={displayUrl} 151 + alt="" 152 + onerror={() => handleAvatarError(liker.did)} 153 + /> 112 154 </SensitiveImage> 113 155 {:else} 114 - <span>{liker.display_name.charAt(0).toUpperCase()}</span> 156 + <span>{(liker.display_name || liker.handle).charAt(0).toUpperCase()}</span> 115 157 {/if} 116 158 </a> 117 159 {/each}
+23 -4
frontend/src/lib/components/TrackItem.svelte
··· 9 9 import { queue } from '$lib/queue.svelte'; 10 10 import { toast } from '$lib/toast.svelte'; 11 11 import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 12 + import { 13 + getRefreshedAvatar, 14 + triggerAvatarRefresh, 15 + hasAttemptedRefresh 16 + } from '$lib/avatar-refresh.svelte'; 12 17 13 18 interface Props { 14 19 track: Track; ··· 49 54 let tagsExpanded = $state(false); 50 55 let prevTrackId: number | undefined; 51 56 57 + // get refreshed avatar URL if available 58 + let refreshedAvatarUrl = $derived(getRefreshedAvatar(track.artist_did)); 59 + let artistAvatarUrl = $derived(refreshedAvatarUrl ?? track.artist_avatar_url); 60 + 52 61 // reset local UI state when track changes (component may be recycled) 53 62 // using $effect.pre so state is ready before render 54 63 $effect.pre(() => { ··· 59 68 } 60 69 prevTrackId = track.id; 61 70 }); 71 + 72 + /** 73 + * handle avatar error - show placeholder and trigger background refresh. 74 + */ 75 + function handleAvatarError() { 76 + avatarError = true; 77 + if (track.artist_did && !hasAttemptedRefresh(track.artist_did)) { 78 + triggerAvatarRefresh(track.artist_did); 79 + } 80 + } 62 81 63 82 // limit visible tags to prevent vertical sprawl (max 2 shown) 64 83 const MAX_VISIBLE_TAGS = 2; ··· 189 208 /> 190 209 </div> 191 210 </SensitiveImage> 192 - {:else if track.artist_avatar_url && !avatarError} 193 - <SensitiveImage src={track.artist_avatar_url}> 211 + {:else if artistAvatarUrl && !avatarError} 212 + <SensitiveImage src={artistAvatarUrl}> 194 213 <a 195 214 href="/u/{track.artist_handle}" 196 215 class="track-avatar" 197 216 > 198 217 <img 199 - src={track.artist_avatar_url} 218 + src={artistAvatarUrl} 200 219 alt={track.artist} 201 220 width="48" 202 221 height="48" 203 222 loading={imageLoading} 204 223 fetchpriority={imageFetchPriority} 205 - onerror={() => avatarError = true} 224 + onerror={handleAvatarError} 206 225 /> 207 226 </a> 208 227 </SensitiveImage>
+2 -2
frontend/src/lib/tooltip-cache.svelte.ts
··· 19 19 export interface LikerData { 20 20 did: string; 21 21 handle: string; 22 - display_name: string; 23 - avatar_url?: string; 22 + display_name: string | null; 23 + avatar_url: string | null; 24 24 liked_at: string; 25 25 } 26 26
+13 -20
frontend/src/routes/u/[handle]/+page.svelte
··· 15 15 import { queue } from '$lib/queue.svelte'; 16 16 import { auth } from '$lib/auth.svelte'; 17 17 import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 18 + import { 19 + getRefreshedAvatar, 20 + triggerAvatarRefresh, 21 + hasAttemptedRefresh 22 + } from '$lib/avatar-refresh.svelte'; 18 23 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 19 24 import { getAtprotofansProfile, getAtprotofansSupporters, type Supporter } from '$lib/atprotofans'; 20 25 import type { PageData } from './$types'; ··· 83 88 84 89 // track avatar load errors for fallback 85 90 let avatarError = $state(false); 86 - let refreshedAvatarUrl = $state<string | null>(null); 87 - const avatarUrl = $derived(refreshedAvatarUrl || artist?.avatar_url); 91 + 92 + // get refreshed avatar from global cache (may have been fixed elsewhere) 93 + let refreshedAvatarUrl = $derived(getRefreshedAvatar(artist?.did)); 94 + const avatarUrl = $derived(refreshedAvatarUrl ?? artist?.avatar_url); 88 95 89 96 /** 90 97 * called when avatar image fails to load (404/broken URL). 91 98 * triggers a backend refresh from Bluesky and updates the display. 92 99 */ 93 - async function handleAvatarError() { 100 + function handleAvatarError() { 94 101 avatarError = true; 95 102 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 103 + // trigger refresh via shared system (handles deduplication) 104 + if (artist?.did && !hasAttemptedRefresh(artist.did)) { 105 + triggerAvatarRefresh(artist.did); 112 106 } 113 107 } 114 108 ··· 253 247 supporterCount = null; 254 248 supporters = []; 255 249 avatarError = false; 256 - refreshedAvatarUrl = null; 257 250 258 251 // sync tracks and pagination from server data 259 252 tracks = data.tracks ?? [];