/** * Resolves a Bandcamp page to its streamable tracks when the native player * starts it, for `GET /api/remote/bandcamp`. The stream URLs are signed and * expire 24 hours after the page is fetched, so unlike the embed URL they * can't be stored in the dump's preview. The browser plays them from bcbits * directly. * * @module */ import { APIException } from "../lib/api-exception.ts"; import { TtlCache } from "../lib/ttl-cache.ts"; import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts"; import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts"; import type { Tralbum } from "../../shared/player.ts"; /** * Lives half the signatures' life, so a cached URL still has at least 12 * hours left when it is handed out. */ const cache = new TtlCache(12 * 3_600_000, 200); /** * Returns `url` without its query, fragment and trailing slashes, and with its * host in lowercase. */ function cacheKey(url: string): string { try { const u = new URL(url); return `${u.protocol}//${u.hostname.toLowerCase()}${ u.pathname.replace(/\/+$/, "") }`; } catch { return url; } } /** * Returns the tracklist of the Bandcamp page `pageUrl`, from the cache when it * was resolved in the last 12 hours. `force` skips the cache: the player sets * it when it re-resolves a queue whose URLs expired or are about to, which a * cached entry could hand back. * * @throws {APIException} 400 when `pageUrl` isn't a public bandcamp.com URL, * 502 when Bandcamp can't be reached or doesn't answer with a page, 404 when * the page has nothing streamable. */ export async function resolveBandcamp( pageUrl: string, { force = false }: { force?: boolean } = {}, ): Promise { if (!isValidHttpUrl(pageUrl)) { throw new APIException(400, "Invalid URL"); } let hostname: string; try { hostname = new URL(pageUrl).hostname; } catch { throw new APIException(400, "Invalid URL"); } if (!BANDCAMP_HOST_RE.test(hostname)) { throw new APIException(400, "Not a Bandcamp URL"); } const key = cacheKey(pageUrl); if (!force) { const hit = cache.get(key); if (hit) return hit; } let res: Response; try { res = await fetchWithTimeout(pageUrl, 8000); } catch { throw new APIException(502, "Could not reach Bandcamp"); } // A body left unread keeps its connection open until GC, and this endpoint // is public: every early return cancels it. if (!res.ok) { await res.body?.cancel(); throw new APIException(502, `Bandcamp returned ${res.status}`); } if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) { await res.body?.cancel(); throw new APIException(502, "Bandcamp did not return a page"); } const parsed = parseTralbum(await readTextCapped(res), pageUrl); if (!parsed) { // No data, no audio or nothing streamable yet (a preorder). The client // plays the embed instead. throw new APIException(404, "No streamable tracks on this page"); } return cache.set(key, parsed); }