/** * Parser for Bandcamp's `data-tralbum` attribute. * * Every Bandcamp track and album page carries the whole release — titles, * durations and signed mp3 stream URLs — as one HTML-escaped JSON blob on a * `data-tralbum` attribute. One fetch of an album page therefore yields every * track's stream URL; there is no per-track request. * * The stream URLs are signed and expire 24h after the page was fetched, which * is why nothing here is ever persisted to `rich_content` — see * `api/routes/bandcamp.ts` for the resolve-at-play-time endpoint. * * The shape is undocumented, so every field is treated as optional and the * whole parse is best-effort: a `null` return means "not a Bandcamp page", * which callers turn into a fallback to the iframe embed. */ /** Hostnames Bandcamp serves its own sites from. Anchored at the end so that * `bandcamp.com.example.org` cannot pass. Artists on custom domains do not * match this and keep using the embed. */ export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/; export interface TralbumTrack { /** Position in the parsed array — the stable handle used to re-resolve a * track after its signed URL expires. */ index: number; trackNum?: number; title: string; /** Seconds, fractional. */ duration?: number; streamUrl: string | null; /** False for preorder, unreleased and still-encoding tracks. They stay in * the list so numbering matches the release, but never enter the queue. */ streamable: boolean; /** Bandcamp caps free plays per track; a capped track may serve a clip. */ capped: boolean; } export interface Tralbum { sourceUrl: string; itemType: "album" | "track"; title?: string; artist?: string; artworkUrl?: string; tracks: TralbumTrack[]; /** Epoch ms of the fetch that produced these signed URLs. */ resolvedAt: number; } /** * Decode HTML entities in a single pass. * * The single pass is the point: `decodeHtmlEntities` in rich-content-service.ts * replaces `&` before `"`, so an escaped-ampersand-then-quot sequence * (`&quot;`, i.e. the literal text `"` inside a title) decodes twice * and yields a bare `"` — which lands inside the JSON string and breaks the * parse. Matching every entity form in one regex means each is replaced once. */ function decodeEntitiesOnce(input: string): string { const named: Record = { quot: '"', apos: "'", amp: "&", lt: "<", gt: ">", nbsp: " ", }; return input.replace( /&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/g, (match, dec: string, hex: string, name: string) => { if (dec) return String.fromCodePoint(Number(dec)); if (hex) return String.fromCodePoint(parseInt(hex, 16)); return named[name.toLowerCase()] ?? match; }, ); } /** Resolve Bandcamp's protocol-relative and root-relative art URLs. */ function artworkUrl(artId: unknown): string | undefined { if (typeof artId !== "number" && typeof artId !== "string") return undefined; const id = String(artId).trim(); if (!/^\d+$/.test(id)) return undefined; // _16 is the large square art Bandcamp's own player uses. return `https://f4.bcbits.com/img/a${id}_16.jpg`; } /** * Pull the `data-tralbum` blob out of a Bandcamp page and normalise it. * Returns null when the page has no blob, no audio, or nothing parseable. */ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null { const match = html.match(/\bdata-tralbum=(["'])([\s\S]*?)\1/); if (!match) return null; let raw: Record; try { raw = JSON.parse(decodeEntitiesOnce(match[2])); } catch { return null; } if (!raw || typeof raw !== "object") return null; if (raw.hasAudio === false) return null; const rawTracks = Array.isArray(raw.trackinfo) ? raw.trackinfo : []; const tracks: TralbumTrack[] = rawTracks.map((entry, index) => { const t = (entry ?? {}) as Record; const file = (t.file ?? {}) as Record; const mp3 = typeof file["mp3-128"] === "string" ? file["mp3-128"] : null; // Only ever hand out bcbits URLs. Without this the endpoint would relay // whatever host a hostile page put in the blob straight to the browser. const streamUrl = mp3 && isBcbitsUrl(mp3) ? mp3 : null; return { index, trackNum: typeof t.track_num === "number" ? t.track_num : undefined, title: typeof t.title === "string" && t.title.trim() ? t.title : `Track ${index + 1}`, duration: typeof t.duration === "number" && t.duration > 0 ? t.duration : undefined, streamUrl, streamable: streamUrl !== null && t.streaming === 1 && t.unreleased_track !== true && !t.encoding_pending, capped: t.is_capped === true, }; }); if (!tracks.some((t) => t.streamable)) return null; const current = (raw.current ?? {}) as Record; return { sourceUrl, itemType: raw.item_type === "album" ? "album" : "track", title: typeof current.title === "string" ? current.title : undefined, artist: typeof raw.artist === "string" ? raw.artist : undefined, artworkUrl: artworkUrl(raw.art_id), tracks, resolvedAt: Date.now(), }; } function isBcbitsUrl(url: string): boolean { try { const { protocol, hostname } = new URL(url); return protocol === "https:" && /(?:^|\.)bcbits\.com$/.test(hostname); } catch { return false; } }