129 lines
4.7 KiB
TypeScript
129 lines
4.7 KiB
TypeScript
/**
|
||
* 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 `&` before `"`, so an escaped-ampersand-then-quot sequence
|
||
* (`"`, 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;
|
||
},
|
||
);
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
}
|