93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
/**
|
|
* Reads a release out of a Bandcamp page, for `bandcamp-streams.ts`. Every
|
|
* track and album page carries the whole release, stream URLs included, as
|
|
* HTML-escaped JSON in a `data-tralbum` attribute, so one fetch covers an
|
|
* album. The format is undocumented, so every field is treated as optional.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
|
|
import { decodeHtmlEntities } from "./html.ts";
|
|
|
|
/**
|
|
* Matches the hostnames `bandcamp-streams.ts` accepts: bandcamp.com and its
|
|
* subdomains, and not `bandcamp.com.example.org`. An artist on a custom domain
|
|
* doesn't match, and the player falls back to the embed.
|
|
*/
|
|
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
|
|
|
|
/** Returns the URL of the artwork `artId`; undefined when it isn't numeric. */
|
|
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`;
|
|
}
|
|
|
|
/**
|
|
* Returns the release on the Bandcamp page `html`, fetched from `sourceUrl`;
|
|
* null when the page has no release data, no audio or no streamable track.
|
|
* Stream URLs outside bcbits.com are dropped.
|
|
*/
|
|
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(decodeHtmlEntities(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;
|
|
// A hostile page could put any host here, and the endpoint would hand it
|
|
// 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;
|
|
}
|
|
}
|