Files
gerbeur/api/remote/youtube-playlist.ts
khannurien 0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00

225 lines
7.1 KiB
TypeScript

/**
* 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 { 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";
/** 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(
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 {
// 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(
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) {
// The client then 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;
}