v3: youtube playlists get a tracklist too — the embed keeps playing the list, so the rows come from two halves: /api/youtube/playlist scrapes titles and lengths off the playlist page (current lockup rows and the older renderer, cached 6h), while the IFrame API attached to the iframe reports the position and takes row clicks and ⏮/⏭; the header names the playing video with the dump's title below it, like a bandcamp track; either half can fail — no scrape leaves the bare embed, no API leaves the rows as a table of contents — and the pause an API-enabled embed gives itself when the tab goes to the background is undone within a short window, so a later pause still stands
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-19 21:51:10 +00:00
parent 084ebcb627
commit d70761ecae
11 changed files with 727 additions and 101 deletions

View File

@@ -0,0 +1,199 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
/**
* 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.
*/
export interface YouTubePlaylistTrack {
videoId: string;
title: string;
/** Seconds. */
duration?: number;
}
export interface YouTubePlaylist {
listId: string;
title?: string;
tracks: YouTubePlaylistTrack[];
}
/** Titles and lengths rarely change; a stale entry only mislabels a row. */
const TTL_MS = 6 * 60 * 60 * 1000;
const MAX_ENTRIES = 200;
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
/** Playlist IDs are URL-safe base64-ish: PL…, OLAK5uy_…, RD… (mixes). */
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",
// Pre-answered consent: without it EU requests are redirected to
// consent.youtube.com and never see the playlist.
"Cookie": "SOCS=CAI",
};
/** "3:55", "1:02:03" → 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);
}
/** Depth-first walk collecting every value stored under `key`. */
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);
}
}
}
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");
}
// deno-lint-ignore no-explicit-any
type Json = any;
function fromLockup(lockup: Json): YouTubePlaylistTrack | null {
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
const videoId = lockup.contentId;
const title = lockup.metadata?.lockupMetadataViewModel?.title?.content;
if (typeof videoId !== "string" || typeof title !== "string") return null;
const badges: unknown[] = [];
collect(lockup.contentImage, "thumbnailBadgeViewModel", badges);
const duration = badges
.map((b) => (b as Json)?.text)
.filter((t): t is string => typeof t === "string")
.map(parseClock)
.find((d) => d !== undefined);
return { videoId, title, duration };
}
function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null {
const videoId = r?.videoId;
const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText;
if (typeof videoId !== "string" || typeof title !== "string") return null;
const secs = Number(r.lengthSeconds);
return {
videoId,
title,
duration: Number.isFinite(secs) && secs > 0 ? secs : undefined,
};
}
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: Json;
try {
data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end));
} catch {
return null;
}
const lockups: unknown[] = [];
collect(data?.contents, "lockupViewModel", lockups);
const legacy: unknown[] = [];
collect(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 title = data?.metadata?.playlistMetadataRenderer?.title ??
firstString(data?.header, "content");
return { listId, title, tracks };
}
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 res.text();
} 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;
}