234 lines
7.5 KiB
TypeScript
234 lines
7.5 KiB
TypeScript
/**
|
|
* Resolves a YouTube playlist to its tracklist, at play time.
|
|
*
|
|
* 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.
|
|
*
|
|
* @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";
|
|
|
|
/**
|
|
* How long a resolved playlist is cached. Titles and lengths rarely change; a
|
|
* stale entry only mislabels a row.
|
|
*/
|
|
const TTL_MS = 6 * 60 * 60 * 1000;
|
|
/** The most playlists cached; 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). */
|
|
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.
|
|
"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 parsed JSON; undefined wherever 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reads the tracklist from a playlist page's `ytInitialData`; null when the
|
|
* page has none 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 can appear in a playlist twice; both rows are kept, so the list
|
|
// stays positional and matches what the embed plays.
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Fetches the playlist `listId` and returns its tracklist, cached.
|
|
*
|
|
* @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 {
|
|
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) {
|
|
// Private, deleted, or auto-generated mixes (RD…) that have no playlist
|
|
// page — the client just 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;
|
|
}
|