Files
gerbeur/api/remote/youtube-playlist.ts
khannurien c3594e4c27 v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:16:52 +00:00

199 lines
6.6 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/remote/youtube`.
*
* 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 { TtlCache } from "../lib/ttl-cache.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 cache = new TtlCache<YouTubePlaylist>(6 * 3_600_000, 200);
/**
* 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) return hit;
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");
}
return cache.set(listId, parsed);
}