121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
/**
|
|
* 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 { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
|
|
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
|
|
import type { Tralbum } from "../../shared/player.ts";
|
|
|
|
/**
|
|
* Half the signatures' life, so a cached URL still has at least 12 hours left
|
|
* when it is handed out.
|
|
*/
|
|
const TTL_MS = 12 * 60 * 60 * 1000;
|
|
/** The oldest write goes first. */
|
|
const MAX_ENTRIES = 200;
|
|
|
|
const cache = new Map<string, Tralbum>();
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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<Tralbum> {
|
|
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 = readCache(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");
|
|
}
|
|
|
|
writeCache(key, parsed);
|
|
return parsed;
|
|
}
|