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>
This commit is contained in:
@@ -1,17 +1,13 @@
|
||||
/**
|
||||
* Resolves a YouTube playlist to its tracklist, at play time.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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
|
||||
*/
|
||||
@@ -24,28 +20,25 @@ import type {
|
||||
YouTubePlaylistTrack,
|
||||
} from "../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* How long a resolved playlist is cached. Titles and lengths rarely change; a
|
||||
* stale entry only mislabels a row.
|
||||
*/
|
||||
/** Titles and lengths rarely change, and a stale one only mislabels a row. */
|
||||
const TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** The most playlists cached; the oldest write goes first. */
|
||||
/** The oldest write goes first. */
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
/** Resolved playlists, by list id. */
|
||||
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
|
||||
|
||||
/** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */
|
||||
/**
|
||||
* Admits every kind of list id (`PL…`, `OLAK5uy_…`, `RD…`) and nothing else.
|
||||
*/
|
||||
const LIST_ID_RE = /^[\w-]{2,64}$/;
|
||||
|
||||
/** The headers the playlist page is requested with. */
|
||||
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.
|
||||
// Declines the consent prompt, which EU requests are otherwise redirected
|
||||
// to instead of the playlist.
|
||||
"Cookie": "SOCS=CAI",
|
||||
};
|
||||
|
||||
@@ -55,7 +48,7 @@ function parseClock(text: string): number | undefined {
|
||||
return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
|
||||
}
|
||||
|
||||
/** Collects, depth-first, every value stored under `key` into `out`. */
|
||||
/** 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);
|
||||
@@ -74,10 +67,7 @@ function firstString(node: unknown, key: string): string | undefined {
|
||||
return found.find((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value at `path` in parsed JSON; undefined wherever a step is
|
||||
* missing.
|
||||
*/
|
||||
/** 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) {
|
||||
@@ -124,8 +114,8 @@ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the tracklist from a playlist page's `ytInitialData`; null when the
|
||||
* page has none or no track in it.
|
||||
* 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,
|
||||
@@ -148,8 +138,7 @@ export function parsePlaylistPage(
|
||||
const legacy: unknown[] = [];
|
||||
collect(at(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.
|
||||
// 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),
|
||||
@@ -164,7 +153,8 @@ export function parsePlaylistPage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the playlist `listId` and returns its tracklist, cached.
|
||||
* 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
|
||||
@@ -189,6 +179,8 @@ export async function resolveYouTubePlaylist(
|
||||
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 },
|
||||
@@ -215,8 +207,7 @@ export async function resolveYouTubePlaylist(
|
||||
|
||||
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.
|
||||
// The client then shows the embed without a tracklist.
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
|
||||
Reference in New Issue
Block a user