225 lines
7.1 KiB
TypeScript
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;
|
|
}
|