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

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:
khannurien
2026-08-22 12:14:28 +00:00
parent eb323a8ba8
commit 2606ef4ccc
30 changed files with 1531 additions and 225 deletions

View 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;
}