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(); /** 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 { 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; }