audio streaming app plyr.fm
38
fork

Configure Feed

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

fix: track page never leaves og:image empty (#1257)

cameron's bsky post of plyr.fm/track/859 (a /record voice memo with
no artwork) rendered with a tangled.sh sheep mascot as the preview
thumbnail. root cause is entirely on our side — definitive chain
of events traced via:

curl -sL https://plyr.fm/track/859 -H "User-Agent: facebookexternalhit/1.1"

when track.image_url is null, our <svelte:head> block wraps og:image
in `{#if track.image_url && !moderation.isSensitive(...)}` and emits
nothing. bsky's card extractor (cardyb.bsky.app) then falls back to
its "first <img> in the HTML" heuristic. the ONLY <img> tag in our
ssr'd track page is the Header's "view source on tangled" social
icon, which points at tangled.sh's bsky avatar CID:

cdn.bsky.app/img/avatar/plain/did:plc:wshs7t2adsemcrrd4snkeqli/
bafkreif6z53z4ukqmdgwstspwh5asmhxheblcd2adisoccl4fflozc3kva@jpeg

that avatar is a 1000x1000 jpeg of the tangled dolly mascot. pixel-
identical to the thumb blob on cameron's post record after bsky's
client re-encoded it to a 38kb webp and uploaded it to his pds.
cardyb currently returns image: "" for the url because the extractor
cache has already expired, but at the moment cameron posted it grabbed
that tangled img tag.

this means EVERY track without artwork shared to bsky today is
rendering as the tangled sheep. it's not a one-off weirdness on
cameron's end — it's a systemic bug we've been shipping.

fix: cascade to pick the best-available preview image, always emit
og:image + twitter:image unconditionally. order:

1. track.image_url (if not sensitive) — per-track art, the primary
2. track.album.image_url (if not sensitive) — album cover if linked
3. track.artist_avatar_url (if not sensitive) — artist's bsky avatar
4. ${APP_CANONICAL_URL}/icons/icon-512.png — stable static brand
logo (served from frontend/static/icons, already referenced by
the web manifest, 200 OK + image/png + CORS *)

the og:image:width/height hint stays gated to "is real track art"
(tracks are standardized 1200x1200, but fallback dimensions vary,
and advertising wrong dimensions is worse than omitting them).
og:image:alt now emits unconditionally.

the existing cameron post has an immutable embed thumb already
committed to his pds — only he can delete and repost to refresh it.
all future shares will have proper previews once this ships.

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

authored by

nate nowack
Claude Opus 4.6 (1M context)
and committed by
GitHub
a5cb9c40 e8f542ce

+32 -7
+32 -7
frontend/src/routes/track/[id]/+page.svelte
··· 40 40 41 41 let track = $state<Track>(data.track); 42 42 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. 47 + const OG_FALLBACK_IMAGE = `${APP_CANONICAL_URL}/icons/icon-512.png`; 48 + 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 (track.artist_avatar_url && !moderation.isSensitive(track.artist_avatar_url)) { 56 + return track.artist_avatar_url; 57 + } 58 + return OG_FALLBACK_IMAGE; 59 + }); 60 + const previewIsTrackArt = $derived( 61 + !!(track.image_url && !moderation.isSensitive(track.image_url)) 62 + ); 63 + 43 64 // comments state - assume enabled until we know otherwise 44 65 let comments = $state<Comment[]>([]); 45 66 let commentsEnabled = $state<boolean | null>(null); // null = unknown, true/false = known ··· 461 482 {#if track.album} 462 483 <meta property="music:album" content="{track.album.title}" /> 463 484 {/if} 464 - {#if track.image_url && !moderation.isSensitive(track.image_url)} 465 - <meta property="og:image" content="{track.image_url}" /> 466 - <meta property="og:image:secure_url" content="{track.image_url}" /> 485 + <!-- 486 + og:image cascade — track art → album art → artist avatar → brand logo. 487 + always emit SOMETHING so scrapers don't fall back to their own heuristics 488 + (favicon, first visible image, or whatever the posting client had cached). 489 + see: https://github.com/zzstoatzz/plyr.fm/pull/1257 490 + --> 491 + <meta property="og:image" content={previewImage} /> 492 + <meta property="og:image:secure_url" content={previewImage} /> 493 + {#if previewIsTrackArt} 467 494 <meta property="og:image:width" content="1200" /> 468 495 <meta property="og:image:height" content="1200" /> 469 - <meta property="og:image:alt" content="{track.title} by {track.artist}" /> 470 496 {/if} 497 + <meta property="og:image:alt" content="{track.title} by {track.artist}" /> 471 498 {#if track.r2_url} 472 499 <meta property="og:audio" content="{track.r2_url}" /> 473 500 <meta property="og:audio:type" content="audio/{track.file_type}" /> ··· 480 507 name="twitter:description" 481 508 content="{track.artist}{track.album ? ` • ${track.album.title}` : ''}" 482 509 /> 483 - {#if track.image_url && !moderation.isSensitive(track.image_url)} 484 - <meta name="twitter:image" content="{track.image_url}" /> 485 - {/if} 510 + <meta name="twitter:image" content={previewImage} /> 486 511 487 512 <!-- oEmbed discovery for embed services like iframely --> 488 513 <link