/** * Resolves a YouTube playlist to its entries' titles and lengths, for * `GET /api/youtube/playlist`. The embed plays the list but doesn't expose * them, and the global player needs them to draw a tracklist. * * They are read from the `ytInitialData` JSON of the public playlist page, * whose undocumented shape changes, so both the current `lockupViewModel` rows * and the older `playlistVideoRenderer` ones are accepted. Only the first 100 * entries are there; the client adds the rest, untitled, as the player reports * them. * * @module */ import { APIException } from "../lib/api-exception.ts"; import { readTextCapped } from "./fetch.ts"; import type { YouTubePlaylist, YouTubePlaylistTrack, } from "../../shared/player.ts"; /** Titles and lengths rarely change, and a stale one only mislabels a row. */ const TTL_MS = 6 * 60 * 60 * 1000; /** The oldest write goes first. */ const MAX_ENTRIES = 200; const cache = new Map(); /** * Admits every kind of list id (`PL…`, `OLAK5uy_…`, `RD…`) and nothing else. */ 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", // Declines the consent prompt, which EU requests are otherwise redirected // to instead of 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 `node`; undefined when 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, }; } /** * Returns the playlist `listId` as read from its page `html`; null when the * page has no `ytInitialData` 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 listed twice keeps both rows, so that row n is the embed's entry n. 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 }; } /** * Returns the tracklist of the playlist `listId`, from the cache when it was * resolved in the last 6 hours. * * @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(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 { // youtube.com needs no address check, and fetchWithTimeout can't send the // consent cookie. 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(502, `YouTube returned ${res.status}`); } html = await readTextCapped(res); } catch (err) { if (err instanceof APIException) throw err; throw new APIException(502, "Could not reach YouTube"); } finally { clearTimeout(timer); } const parsed = parsePlaylistPage(html, listId); if (!parsed) { // The client then shows the embed without a tracklist. throw new APIException(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; }