/** * Resolves a YouTube playlist to its tracklist, at play time. * * Playback itself stays in the iframe embed — YouTube plays the list and * advances through it on its own. This only supplies what the embed doesn't * expose: every entry's title and length, so the global player can draw a * tracklist the way it does for a Bandcamp album. The client syncs the * highlighted row and row clicks through the IFrame Player API. * * The data comes from the `ytInitialData` blob on the public playlist page. Its * shape is undocumented and changes, so the parse is best-effort and accepts * both the current `lockupViewModel` rows and the older `playlistVideoRenderer` * ones. Only the first page (100 entries) is read; the client appends any * further IDs the player reports, untitled. * * @module */ import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { readTextCapped } from "./fetch.ts"; import type { YouTubePlaylist, YouTubePlaylistTrack, } from "../../shared/player.ts"; /** * How long a resolved playlist is cached. Titles and lengths rarely change; a * stale entry only mislabels a row. */ const TTL_MS = 6 * 60 * 60 * 1000; /** The most playlists cached; the oldest write goes first. */ const MAX_ENTRIES = 200; /** Resolved playlists, by list id. */ const cache = new Map(); /** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */ const LIST_ID_RE = /^[\w-]{2,64}$/; /** The headers the playlist page is requested with. */ const HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", "Accept": "text/html", "Accept-Language": "en-US,en;q=0.8", // Pre-answered consent: without it EU requests are redirected to // consent.youtube.com and never see the playlist. "Cookie": "SOCS=CAI", }; /** Converts a clock like "3:55" or "1:02:03" to seconds. */ function parseClock(text: string): number | undefined { if (!/^\d+(?::\d{1,2}){1,2}$/.test(text)) return undefined; return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0); } /** Collects, depth-first, every value stored under `key` into `out`. */ function collect(node: unknown, key: string, out: unknown[]): void { if (Array.isArray(node)) { for (const child of node) collect(child, key, out); } else if (node && typeof node === "object") { for (const [k, v] of Object.entries(node)) { if (k === key) out.push(v); else collect(v, key, out); } } } /** Returns the first string stored under `key`, at any depth. */ function firstString(node: unknown, key: string): string | undefined { const found: unknown[] = []; collect(node, key, found); return found.find((v): v is string => typeof v === "string"); } /** * Returns the value at `path` in parsed JSON; undefined wherever a step is * missing. */ function at(node: unknown, ...path: (string | number)[]): unknown { let cur = node; for (const key of path) { if (cur === null || typeof cur !== "object") return undefined; cur = (cur as Record)[key]; } return cur; } /** Reads a track from a current `lockupViewModel` row; null for a non-video. */ function fromLockup(lockup: unknown): YouTubePlaylistTrack | null { if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null; const videoId = at(lockup, "contentId"); const title = at( lockup, "metadata", "lockupMetadataViewModel", "title", "content", ); if (typeof videoId !== "string" || typeof title !== "string") return null; const badges: unknown[] = []; collect(at(lockup, "contentImage"), "thumbnailBadgeViewModel", badges); const duration = badges .map((b) => at(b, "text")) .filter((t): t is string => typeof t === "string") .map(parseClock) .find((d) => d !== undefined); return { videoId, title, duration }; } /** Reads a track from an older `playlistVideoRenderer` row. */ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null { const videoId = at(r, "videoId"); const title = at(r, "title", "runs", 0, "text") ?? at(r, "title", "simpleText"); if (typeof videoId !== "string" || typeof title !== "string") return null; const secs = Number(at(r, "lengthSeconds")); return { videoId, title, duration: Number.isFinite(secs) && secs > 0 ? secs : undefined, }; } /** * Reads the tracklist from a playlist page's `ytInitialData`; null when the * page has none or no track in it. */ export function parsePlaylistPage( html: string, listId: string, ): YouTubePlaylist | null { const start = html.indexOf("var ytInitialData = "); if (start < 0) return null; const end = html.indexOf(";", start); if (end < 0) return null; let data: unknown; try { data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end)); } catch { return null; } const lockups: unknown[] = []; collect(at(data, "contents"), "lockupViewModel", lockups); const legacy: unknown[] = []; collect(at(data, "contents"), "playlistVideoRenderer", legacy); // A video can appear in a playlist twice; both rows are kept, so the list // stays positional and matches what the embed plays. const tracks = [ ...lockups.map(fromLockup), ...legacy.map(fromLegacyRenderer), ].filter((t): t is YouTubePlaylistTrack => t !== null); if (tracks.length === 0) return null; const metaTitle = at(data, "metadata", "playlistMetadataRenderer", "title"); const title = typeof metaTitle === "string" ? metaTitle : firstString(at(data, "header"), "content"); return { listId, title, tracks }; } /** * Fetches the playlist `listId` and returns its tracklist, cached. * * @throws {APIException} 400 when `listId` isn't a playlist id, 502 when * YouTube can't be reached or answers with an error, 404 when the playlist has * no tracks to read (private, deleted, or a mix). */ export async function resolveYouTubePlaylist( listId: string, ): Promise { if (!LIST_ID_RE.test(listId)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid playlist ID", ); } const hit = cache.get(listId); if (hit && Date.now() - hit.at < TTL_MS) return hit.value; if (hit) cache.delete(listId); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); let html: string; try { const res = await fetch( `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}`, { headers: HEADERS, signal: controller.signal }, ); if (!res.ok) { await res.body?.cancel(); throw new APIException( APIErrorCode.SERVER_ERROR, 502, `YouTube returned ${res.status}`, ); } html = await readTextCapped(res); } catch (err) { if (err instanceof APIException) throw err; throw new APIException( APIErrorCode.SERVER_ERROR, 502, "Could not reach YouTube", ); } finally { clearTimeout(timer); } const parsed = parsePlaylistPage(html, listId); if (!parsed) { // Private, deleted, or auto-generated mixes (RD…) that have no playlist // page — the client just shows the embed without a tracklist. throw new APIException( APIErrorCode.NOT_FOUND, 404, "No tracks on this playlist", ); } if (cache.size >= MAX_ENTRIES) { const oldest = cache.keys().next().value; if (oldest !== undefined) cache.delete(oldest); } cache.set(listId, { value: parsed, at: Date.now() }); return parsed; }