v3: native bandcamp playback and a queue in the global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
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
This commit is contained in:
133
api/services/bandcamp-stream-service.ts
Normal file
133
api/services/bandcamp-stream-service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { fetchWithTimeout, isValidHttpUrl } from "./rich-content-service.ts";
|
||||
import {
|
||||
BANDCAMP_HOST_RE,
|
||||
parseTralbum,
|
||||
type Tralbum,
|
||||
} from "./bandcamp-tralbum.ts";
|
||||
|
||||
/**
|
||||
* Resolves a Bandcamp page to its streamable tracks, at play time.
|
||||
*
|
||||
* Bandcamp's signed stream URLs expire 24h after the page fetch, so they can
|
||||
* never be baked into `rich_content` at dump-creation time the way `embedUrl`
|
||||
* is. This service is the play-time counterpart: the frontend asks for a page,
|
||||
* gets a whole tracklist back, and plays the mp3s directly from bcbits.
|
||||
*/
|
||||
|
||||
/** Comfortably inside the 24h signature life, so a cache hit is never a URL
|
||||
* that is about to expire mid-listen. */
|
||||
const TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
const cache = new Map<string, Tralbum>();
|
||||
|
||||
/** Cache key: a page is the same page regardless of query, fragment or case. */
|
||||
function cacheKey(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.protocol}//${u.hostname.toLowerCase()}${
|
||||
u.pathname.replace(/\/+$/, "")
|
||||
}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function readCache(key: string): Tralbum | null {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() - hit.resolvedAt > TTL_MS) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
function writeCache(key: string, value: Tralbum): void {
|
||||
// Map preserves insertion order, so the first key is the oldest write.
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse a Bandcamp page.
|
||||
*
|
||||
* `force` bypasses the cache — used by the client's re-resolve path, so a
|
||||
* cached entry whose URLs have started 403ing can't keep being handed back.
|
||||
*/
|
||||
export async function resolveBandcamp(
|
||||
pageUrl: string,
|
||||
{ force = false }: { force?: boolean } = {},
|
||||
): Promise<Tralbum> {
|
||||
if (!isValidHttpUrl(pageUrl)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid URL",
|
||||
);
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(pageUrl).hostname;
|
||||
} catch {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid URL");
|
||||
}
|
||||
if (!BANDCAMP_HOST_RE.test(hostname)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Not a Bandcamp URL",
|
||||
);
|
||||
}
|
||||
|
||||
const key = cacheKey(pageUrl);
|
||||
if (!force) {
|
||||
const hit = readCache(key);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchWithTimeout(pageUrl, 8000);
|
||||
} catch {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
"Could not reach Bandcamp",
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
`Bandcamp returned ${res.status}`,
|
||||
);
|
||||
}
|
||||
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
502,
|
||||
"Bandcamp did not return a page",
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseTralbum(await res.text(), pageUrl);
|
||||
if (!parsed) {
|
||||
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases
|
||||
// with nothing streamable — all cases where the client falls back to the
|
||||
// iframe embed rather than showing an error.
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No streamable tracks on this page",
|
||||
);
|
||||
}
|
||||
|
||||
writeCache(key, parsed);
|
||||
return parsed;
|
||||
}
|
||||
149
api/services/bandcamp-tralbum.ts
Normal file
149
api/services/bandcamp-tralbum.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user