Files
gerbeur/api/remote/youtube-playlist.ts
khannurien f3f07ed1e3
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
v3: every doc opens with the role, then the mechanism, then the details — the docs were written bottom-up: what a thing does first ("Declares the three contexts WSProvider fills…"), details that only make sense once its purpose is known next, and the reason it exists last ("…so that a feed doesn't re-render"), so a reader had nothing to hang the first sentences on. The README's rule caused it, since a module doc "opened with what the module does"; it now opens with the module's role in the app, and the value-side phrasing of a type ("Returned to Y by X") comes once the role is said, with WSContext as the before and after. WSContext.ts is rewritten that way throughout, and so are the docs elsewhere that buried their purpose in a trailing "so that" (the migrations, og, image-proxy, public-address, useFading, useFeedCache, the drafts), opened on a generic verb that hid it ("Holds", "Declares", "Loads", "Defines", "Types": the contexts and providers, the shared types, the models, the hooks), or were label fragments ("The page of a dump…", "A row of tabs."). A function whose verb is its action ("Stores session"), a props or result type, and a protocol message ("Sent by X when Y") already say their role and are left as they are. Only comments changed, and the catalogs only move their references; deno fmt, deno lint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:42:08 +00:00

208 lines
6.9 KiB
TypeScript

/**
* Gives the global player a tracklist for a YouTube playlist, which the embed
* plays but doesn't list. It resolves the playlist to its entries' titles and
* lengths, for `GET /api/youtube/playlist`.
*
* 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<string, { value: YouTubePlaylist; at: number }>();
/**
* 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<string | number, unknown>)[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(";</script>", 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<YouTubePlaylist> {
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;
}