Files
gerbeur/api/services/bandcamp-tralbum.ts
khannurien 2606ef4ccc
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
v3: native bandcamp playback and a queue in the global player
The player now holds a queue instead of a single item, so albums play
through and single tracks share the same code path. Adds a "stream" item
kind for expiring, remotely-hosted sources: Bandcamp signs its mp3 URLs
for ~24h, so an item carries the page it came from and re-resolves itself
on error or on a stale restored session, falling back to the iframe embed
if that fails too.

Gated behind GERBEUR_BANDCAMP_PLAYER (default "embed"), injected into a
meta tag the same way as site-name/site-emoji.

Also: player header gains artwork and a subtitle, volume persists across
queue advances and reloads, and cross-origin streams get a plain seek bar
rather than a waveform that can never decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ
2026-08-22 12:14:28 +00:00

150 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `&amp;` before `&quot;`, so an escaped-ampersand-then-quot sequence
* (`&amp;quot;`, i.e. the literal text `&quot;` 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<string, string> = {
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<string, unknown>;
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<string, unknown>;
const file = (t.file ?? {}) as Record<string, unknown>;
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<string, unknown>;
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;
}
}