All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m59s
Re-resolving a stream is a network round-trip, and nothing checked that the player still belonged to that item once it landed. Picking another dump — or closing the player — while a resolve was in flight let the stale result swap the queue back; from onError, which resolves with autoplay, it would also start playing over whatever was chosen instead. A generation counter, bumped by playQueue/advanceTo/stop, now drops any result that no longer owns the player, with a separate counter owning the resolving flag so a superseded resolve can't clear a newer one's spinner. A track that stays dead after a re-resolve is one track, not one album: the cooldown path now steps to the next entry and only falls back to the iframe on the last one. A failed resolve still goes straight to the embed, since that's an album-wide failure rather than a single bad URL. And a track that has vanished from the release, or lost its streamable flag to Bandcamp's free-play cap, no longer resumes track 1 at the dead track's offset — the findIndex miss resets the offset instead of seeking past the end of another track. Clicking the playing row rewound it but never resumed: seekTo only moves currentTime, so re-selecting a finished or paused track looked like a no-op. It now resumes as well. Same idea in the compact rich-content card, whose button advertises "Pause" while active but re-played on click — in native mode that meant a fresh /api/bandcamp/tracks and a restart from track 1. It pauses for streams now; embeds have no transport of their own and keep restarting, as before. playRichContent reports whether playback actually started, so a native-mode Bandcamp page with no streams and no stored embedUrl — an artist root, a /music index, a preorder — is no longer a dead click: the journal card navigates to the dump as it used to, and the rich-content card opens the source page. Also: the tralbum fetch releases the response body on its two error paths. The endpoint is unauthenticated, so random /track/<slug> URLs would leak a connection per request while also defeating the cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E56F55N7m5GGKKEwoYFJtH
139 lines
3.8 KiB
TypeScript
139 lines
3.8 KiB
TypeScript
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<string, Tralbum>();
|
|
|
|
/** 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<Tralbum> {
|
|
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",
|
|
);
|
|
}
|
|
// Every path out of here that doesn't read the body has to release it, or the
|
|
// connection stays open until GC. This endpoint is unauthenticated: a caller
|
|
// hammering random /track/<slug> URLs would otherwise leak one body a request.
|
|
if (!res.ok) {
|
|
await res.body?.cancel();
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
502,
|
|
`Bandcamp returned ${res.status}`,
|
|
);
|
|
}
|
|
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
|
|
await res.body?.cancel();
|
|
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;
|
|
}
|