Files
gerbeur/api/remote/bandcamp-tralbum.ts
khannurien d62e4a9883
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00

129 lines
4.7 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.
*
* @module
*/
import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
/**
* The 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$/;
/**
* Decodes HTML entities in a single pass.
*
* The single pass is the point: `decodeHtmlEntities` in remote/html.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;
},
);
}
/** Returns the URL of the large square artwork numbered `artId`. */
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`;
}
/**
* Pulls the `data-tralbum` blob out of a Bandcamp page and normalises 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(),
};
}
/** Checks whether `url` is an https URL on Bandcamp's bcbits.com CDN. */
function isBcbitsUrl(url: string): boolean {
try {
const { protocol, hostname } = new URL(url);
return protocol === "https:" && /(?:^|\.)bcbits\.com$/.test(hostname);
} catch {
return false;
}
}