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

@@ -98,6 +98,17 @@ export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
// only needs a restart — no rebuild.
export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
// Which Bandcamp playback path the frontend uses.
// "embed" — the bandcamp.com iframe (cannot autoplay)
// "native" — resolve the page's mp3 streams and play them in the app's own
// player, which autoplays and supports albums as a queue
// Anything unrecognised falls back to "embed", so a typo degrades safely.
export type BandcampPlayer = "embed" | "native";
export const BANDCAMP_PLAYER: BandcampPlayer =
Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native"
? "native"
: "embed";
// Background color for generated icons and the manifest. Mirrors the
// hard-coded theme-color in index.html.
export const THEME_COLOR = "#111827";

View File

@@ -1,5 +1,5 @@
import { Context, Next, send } from "@oak/oak";
import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "./site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
@@ -13,7 +13,8 @@ async function serveIndexHtml(
const html = raw
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION);
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
context.response.type = "text/html";
context.response.body = html;
}

View File

@@ -9,6 +9,7 @@ import usersRouter from "./routes/users.ts";
import avatarsRouter from "./routes/avatars.ts";
import wsRouter from "./routes/ws.ts";
import previewRouter from "./routes/preview.ts";
import bandcampRouter from "./routes/bandcamp.ts";
import playlistsRouter from "./routes/playlists.ts";
import commentsRouter from "./routes/comments.ts";
import chatRouter from "./routes/chat.ts";
@@ -71,6 +72,10 @@ app.use(
previewRouter.routes(),
previewRouter.allowedMethods(),
);
app.use(
bandcampRouter.routes(),
bandcampRouter.allowedMethods(),
);
app.use(
playlistsRouter.routes(),
playlistsRouter.allowedMethods(),

View File

@@ -2,7 +2,7 @@ import { Context, Next } from "@oak/oak";
import { getDump } from "../services/dump-service.ts";
import { getUserByUsername } from "../services/user-service.ts";
import { getPlaylistById } from "../services/playlist-service.ts";
import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "../lib/site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
@@ -69,7 +69,8 @@ async function loadIndexHtml(): Promise<string | null> {
cachedHtml = (await Deno.readTextFile(path))
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION);
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
return cachedHtml;
} catch {
continue;

26
api/routes/bandcamp.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Router } from "@oak/oak";
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
const bandcampRouter = new Router();
/**
* Resolve a Bandcamp page to its streamable tracks.
*
* Public, like /api/preview: it only reads a public page and returns URLs the
* same page already hands any visitor. Errors are thrown as APIException and
* rendered by errorMiddleware; the client treats any failure as "fall back to
* the iframe embed".
*/
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
const force = ctx.request.url.searchParams.get("force") === "1";
const album = await resolveBandcamp(url, { force });
// Private: the signed URLs in the body are per-fetch, so they must not be
// held in a shared cache. Well inside the 24h signature life either way.
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
ctx.response.body = { success: true, data: album };
});
export default bandcampRouter;

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

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