import { APIErrorCode, APIException } from "../model/interfaces.ts"; /** * 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. */ export interface YouTubePlaylistTrack { videoId: string; title: string; /** Seconds. */ duration?: number; } export interface YouTubePlaylist { listId: string; title?: string; tracks: YouTubePlaylistTrack[]; } /** Titles and lengths rarely change; a stale entry only mislabels a row. */ const TTL_MS = 6 * 60 * 60 * 1000; const MAX_ENTRIES = 200; const cache = new Map(); /** Playlist IDs are URL-safe base64-ish: PL…, OLAK5uy_…, RD… (mixes). */ const LIST_ID_RE = /^[\w-]{2,64}$/; 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", }; /** "3:55", "1:02:03" → 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); } /** Depth-first walk collecting every value stored under `key`. */ 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); } } } 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"); } // deno-lint-ignore no-explicit-any type Json = any; function fromLockup(lockup: Json): YouTubePlaylistTrack | null { if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null; const videoId = lockup.contentId; const title = lockup.metadata?.lockupMetadataViewModel?.title?.content; if (typeof videoId !== "string" || typeof title !== "string") return null; const badges: unknown[] = []; collect(lockup.contentImage, "thumbnailBadgeViewModel", badges); const duration = badges .map((b) => (b as Json)?.text) .filter((t): t is string => typeof t === "string") .map(parseClock) .find((d) => d !== undefined); return { videoId, title, duration }; } function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null { const videoId = r?.videoId; const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText; if (typeof videoId !== "string" || typeof title !== "string") return null; const secs = Number(r.lengthSeconds); return { videoId, title, duration: Number.isFinite(secs) && secs > 0 ? secs : undefined, }; } 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: Json; try { data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end)); } catch { return null; } const lockups: unknown[] = []; collect(data?.contents, "lockupViewModel", lockups); const legacy: unknown[] = []; collect(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 title = data?.metadata?.playlistMetadataRenderer?.title ?? firstString(data?.header, "content"); return { listId, title, tracks }; } 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 res.text(); } 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; }