audio streaming app plyr.fm
38
fork

Configure Feed

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

fix(frontend): inherit album cover when a track has no per-track image (#1337)

the player bar already falls back to `track.album?.image_url` when the
per-track image is unset, but the track detail page, track-list items,
track grid cards, and the embed surface all rendered a placeholder
instead. result: the same track shows artwork in the player and a
blank in every other surface, including its own detail page.

extracted the inheritance rule into `lib/track-cover.ts`
(`trackCoverUrl` + `trackThumbnailUrl`) so every cover-rendering
surface routes through the same helper. semantically this models
the relationship correctly — the album HAS the art, the track
INHERITS unless it sets its own — instead of denormalizing the
album cover into each track row, which would silently go stale if
the album cover ever changed.

side benefit: the recent /tmp upload bug (#1336) orphaned 3 tracks
with `image_id IS NULL` while their album record kept its cover.
those tracks now render the album cover at view time without any
DB backfill, and without needing the artist to re-upload.

surfaces touched:
- routes/track/[id]/+page.svelte — visible cover + og:image cascade
both routed through the helper; previewIsTrackArt simplifies to
`coverUrl !== undefined`
- lib/components/TrackItem.svelte — list item (used in album page,
my tracks, search results, etc.)
- lib/components/TrackCard.svelte — grid card
- routes/embed/track/[id]/+page.svelte — third-party embed (bg blur,
desktop side art, mobile art card all share the same coverUrl)

ATProto track records are unchanged: artists who didn't upload a
per-track image still don't claim one in their portable record.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
1495c643 275e2dce

+76 -33
+9 -4
frontend/src/lib/components/TrackCard.svelte
··· 3 3 import SensitiveImage from './SensitiveImage.svelte'; 4 4 import LikersTooltip from './LikersTooltip.svelte'; 5 5 import { likersSheet } from '$lib/likers-sheet.svelte'; 6 + import { trackCoverUrl, trackThumbnailUrl } from '$lib/track-cover'; 6 7 import type { Track } from '$lib/types'; 7 8 8 9 interface Props { ··· 16 17 17 18 let imageLoading = $derived(index < 3 ? 'eager' as const : 'lazy' as const); 18 19 let likeCount = $derived(track.like_count || 0); 20 + 21 + // cover art with album fallback (matches the player bar's rule) 22 + let coverFullUrl = $derived(trackCoverUrl(track)); 23 + let coverThumbUrl = $derived(trackThumbnailUrl(track)); 19 24 20 25 let isMobile = $state(false); 21 26 ··· 86 91 onPlay(track); 87 92 }} 88 93 > 89 - <div class="artwork" class:gated={track.gated} class:avatar-fallback={!track.image_url && track.artist_avatar_url}> 90 - {#if track.image_url} 91 - <SensitiveImage src={track.thumbnail_url ?? track.image_url}> 94 + <div class="artwork" class:gated={track.gated} class:avatar-fallback={!coverFullUrl && track.artist_avatar_url}> 95 + {#if coverFullUrl || coverThumbUrl} 96 + <SensitiveImage src={coverThumbUrl ?? coverFullUrl}> 92 97 <img 93 - src={track.thumbnail_url ?? track.image_url} 98 + src={coverThumbUrl ?? coverFullUrl} 94 99 alt="{track.title} artwork" 95 100 loading={imageLoading} 96 101 />
+9 -3
frontend/src/lib/components/TrackItem.svelte
··· 8 8 import SensitiveImage from './SensitiveImage.svelte'; 9 9 import { hasPlayableLossless, isLosslessFormat } from '$lib/audio-support'; 10 10 import { likersSheet } from '$lib/likers-sheet.svelte'; 11 + import { trackCoverUrl, trackThumbnailUrl } from '$lib/track-cover'; 11 12 import type { Track } from '$lib/types'; 12 13 import { queue } from '$lib/queue.svelte'; 13 14 import { toast } from '$lib/toast.svelte'; ··· 72 73 // get refreshed avatar URL if available 73 74 let refreshedAvatarUrl = $derived(getRefreshedAvatar(track.artist_did)); 74 75 let artistAvatarUrl = $derived(refreshedAvatarUrl ?? track.artist_avatar_url); 76 + 77 + // cover art with album fallback — keeps track listings in sync with 78 + // the player bar's existing inheritance rule. 79 + let coverFullUrl = $derived(trackCoverUrl(track)); 80 + let coverThumbUrl = $derived(trackThumbnailUrl(track)); 75 81 76 82 // reset local UI state when track changes (component may be recycled) 77 83 // using $effect.pre so state is ready before render ··· 220 226 }} 221 227 > 222 228 <div class="track-image-wrapper" class:gated={track.gated}> 223 - {#if track.image_url && !trackImageError} 224 - <SensitiveImage src={track.thumbnail_url ?? track.image_url}> 229 + {#if (coverFullUrl || coverThumbUrl) && !trackImageError} 230 + <SensitiveImage src={coverThumbUrl ?? coverFullUrl}> 225 231 <div class="track-image"> 226 232 <img 227 - src={track.thumbnail_url ?? track.image_url} 233 + src={coverThumbUrl ?? coverFullUrl} 228 234 alt="{track.title} artwork" 229 235 width="48" 230 236 height="48"
+27
frontend/src/lib/track-cover.ts
··· 1 + import type { Track } from './types'; 2 + 3 + /** 4 + * Resolve a track's cover image URL with semantic inheritance: 5 + * if the track has its own image, use it; otherwise fall back to the 6 + * album's image (if any). Every cover-rendering surface should go 7 + * through this helper so the inheritance rule lives in one place 8 + * — the player bar already implemented this inline; the detail page, 9 + * track lists, and embeds historically did not, leading to the same 10 + * track showing art in the player and a placeholder elsewhere. 11 + */ 12 + export function trackCoverUrl(track: Track): string | undefined { 13 + return track.image_url ?? track.album?.image_url ?? undefined; 14 + } 15 + 16 + /** 17 + * Resolve a track's thumbnail URL with the same inheritance rule. 18 + * Prefers the per-track thumbnail/image when set; otherwise falls back 19 + * to the album's thumbnail (then the album's full image as a last 20 + * resort, since not every album has a generated thumbnail yet). 21 + */ 22 + export function trackThumbnailUrl(track: Track): string | undefined { 23 + if (track.image_url) { 24 + return track.thumbnail_url ?? track.image_url; 25 + } 26 + return track.album?.thumbnail_url ?? track.album?.image_url ?? undefined; 27 + }
+11 -9
frontend/src/routes/embed/track/[id]/+page.svelte
··· 2 2 import { page } from '$app/stores'; 3 3 import { onMount } from 'svelte'; 4 4 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 5 + import { trackCoverUrl } from '$lib/track-cover'; 5 6 import type { PageData } from './$types'; 6 7 7 8 let { data }: { data: PageData } = $props(); 8 9 let track = $derived(data.track); 10 + let coverUrl = $derived(trackCoverUrl(track)); 9 11 10 12 let audio: HTMLAudioElement; 11 13 let paused = $state(true); ··· 56 58 57 59 <div class="embed-container" class:is-playing={!paused}> 58 60 <!-- background image for mobile layout --> 59 - {#if track.image_url} 60 - <SensitiveImage src={track.image_url}> 61 - <div class="bg-image" style="background-image: url({track.image_url})"></div> 61 + {#if coverUrl} 62 + <SensitiveImage src={coverUrl}> 63 + <div class="bg-image" style="background-image: url({coverUrl})"></div> 62 64 </SensitiveImage> 63 65 {/if} 64 66 <div class="bg-overlay"></div> 65 67 66 68 <!-- desktop: side art --> 67 69 <div class="art-container"> 68 - {#if track.image_url} 69 - <SensitiveImage src={track.image_url}> 70 - <img src={track.image_url} alt={track.title} class="art" /> 70 + {#if coverUrl} 71 + <SensitiveImage src={coverUrl}> 72 + <img src={coverUrl} alt={track.title} class="art" /> 71 73 </SensitiveImage> 72 74 {:else} 73 75 <div class="art-placeholder">♪</div> ··· 76 78 77 79 <div class="content"> 78 80 <div class="art-card"> 79 - {#if track.image_url} 80 - <SensitiveImage src={track.image_url}> 81 - <img src={track.image_url} alt={track.title} class="art-card-img" /> 81 + {#if coverUrl} 82 + <SensitiveImage src={coverUrl}> 83 + <img src={coverUrl} alt={track.title} class="art-card-img" /> 82 84 </SensitiveImage> 83 85 {:else} 84 86 <div class="art-card-placeholder">♪</div>
+16 -17
frontend/src/routes/track/[id]/+page.svelte
··· 21 21 import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 22 22 import { auth } from '$lib/auth.svelte'; 23 23 import { toast } from '$lib/toast.svelte'; 24 + import { trackCoverUrl } from '$lib/track-cover'; 24 25 import type { Track } from '$lib/types'; 25 26 26 27 interface Comment { ··· 40 41 41 42 let track = $state<Track>(data.track); 42 43 43 - // og:image cascade — pick the best available preview image and always 44 - // emit *something* so scrapers don't fall back to their own heuristics 45 - // (favicon, first visible image, stale client cache). order: track art → 46 - // album art → artist avatar → brand logo. 44 + // the visible cover and the og:image cascade share the same root rule 45 + // (track art → album art); the og:image then fans out to the artist 46 + // avatar and brand logo so social scrapers always get *something* and 47 + // don't fall back to their own heuristics (favicon, first visible 48 + // image, stale client cache). 47 49 const OG_FALLBACK_IMAGE = `${APP_CANONICAL_URL}/icons/icon-512.png`; 50 + const coverUrl = $derived.by(() => { 51 + const url = trackCoverUrl(track); 52 + return url && !moderation.isSensitive(url) ? url : undefined; 53 + }); 48 54 const previewImage = $derived.by(() => { 49 - if (track.image_url && !moderation.isSensitive(track.image_url)) { 50 - return track.image_url; 51 - } 52 - if (track.album?.image_url && !moderation.isSensitive(track.album.image_url)) { 53 - return track.album.image_url; 54 - } 55 + if (coverUrl) return coverUrl; 55 56 if (track.artist_avatar_url && !moderation.isSensitive(track.artist_avatar_url)) { 56 57 return track.artist_avatar_url; 57 58 } 58 59 return OG_FALLBACK_IMAGE; 59 60 }); 60 - const previewIsTrackArt = $derived( 61 - !!(track.image_url && !moderation.isSensitive(track.image_url)) 62 - ); 61 + const previewIsTrackArt = $derived(coverUrl !== undefined); 63 62 64 63 // comments state - assume enabled until we know otherwise 65 64 let comments = $state<Comment[]>([]); ··· 526 525 527 526 <main> 528 527 <div class="track-detail"> 529 - <!-- cover art --> 530 - <SensitiveImage src={track.image_url} tooltipPosition="center"> 528 + <!-- cover art (inherits from album when no per-track image is set) --> 529 + <SensitiveImage src={coverUrl} tooltipPosition="center"> 531 530 <div class="cover-art-container"> 532 - {#if track.image_url} 533 - <img src={track.image_url} alt="{track.title} artwork" class="cover-art" /> 531 + {#if coverUrl} 532 + <img src={coverUrl} alt="{track.title} artwork" class="cover-art" /> 534 533 {:else} 535 534 <div class="cover-art-placeholder"> 536 535 <svg width="120" height="120" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
+4
loq.toml
··· 269 269 [[rules]] 270 270 path = "backend/tests/api/track_audio_replace/test_revisions.py" 271 271 max_lines = 668 272 + 273 + [[rules]] 274 + path = "frontend/src/routes/embed/track/[[]id[]]/+page.svelte" 275 + max_lines = 556