diff --git a/api/main.ts b/api/main.ts index 1152e2f8..d5b4f8fe 100644 --- a/api/main.ts +++ b/api/main.ts @@ -10,6 +10,7 @@ import avatarsRouter from "./routes/avatars.ts"; import wsRouter from "./routes/ws.ts"; import previewRouter from "./routes/preview.ts"; import bandcampRouter from "./routes/bandcamp.ts"; +import youtubeRouter from "./routes/youtube.ts"; import playlistsRouter from "./routes/playlists.ts"; import commentsRouter from "./routes/comments.ts"; import chatRouter from "./routes/chat.ts"; @@ -76,6 +77,10 @@ app.use( bandcampRouter.routes(), bandcampRouter.allowedMethods(), ); +app.use( + youtubeRouter.routes(), + youtubeRouter.allowedMethods(), +); app.use( playlistsRouter.routes(), playlistsRouter.allowedMethods(), diff --git a/api/routes/youtube.ts b/api/routes/youtube.ts new file mode 100644 index 00000000..e2712df6 --- /dev/null +++ b/api/routes/youtube.ts @@ -0,0 +1,20 @@ +import { Router } from "@oak/oak"; +import { resolveYouTubePlaylist } from "../services/youtube-playlist-service.ts"; + +const youtubeRouter = new Router(); + +/** + * Resolve a YouTube playlist ID to its tracklist (titles and lengths). + * + * Public, like /api/bandcamp/tracks: it only reads a public page. The client + * treats any failure as "show the embed without a tracklist". + */ +youtubeRouter.get("/api/youtube/playlist", async (ctx) => { + const listId = ctx.request.url.searchParams.get("list") ?? ""; + const playlist = await resolveYouTubePlaylist(listId); + + ctx.response.headers.set("Cache-Control", "public, max-age=3600"); + ctx.response.body = { success: true, data: playlist }; +}); + +export default youtubeRouter; diff --git a/api/services/youtube-playlist-service.ts b/api/services/youtube-playlist-service.ts new file mode 100644 index 00000000..d02ee600 --- /dev/null +++ b/api/services/youtube-playlist-service.ts @@ -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(); + +/** 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(";", 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 { + 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; +} diff --git a/src/App.css b/src/App.css index 41954edf..c91808a7 100644 --- a/src/App.css +++ b/src/App.css @@ -1407,6 +1407,10 @@ a.global-player-title:hover { color: var(--color-text-muted); } +/* The video already claims most of the viewport, so its list stays shorter. */ +.global-player--youtube .global-player-tracks { + max-height: 30vh; +} .global-player-tracks { list-style: none; margin: 0; @@ -1434,6 +1438,14 @@ a.global-player-title:hover { .global-player-track > button:hover { background: var(--color-surface-alt, rgba(127, 127, 127, 0.12)); } +/* A YouTube tracklist is only clickable once its player API answers; until + then the rows are a table of contents. */ +.global-player-track > button:disabled { + cursor: default; +} +.global-player-track > button:disabled:hover { + background: none; +} /* An accent border rather than a filled pill, so no theme has to un-round it. */ .global-player-track.is-current > button { border-left-color: var(--color-accent); diff --git a/src/components/GlobalPlayer.tsx b/src/components/GlobalPlayer.tsx index 3c318c1c..b62543bc 100644 --- a/src/components/GlobalPlayer.tsx +++ b/src/components/GlobalPlayer.tsx @@ -10,9 +10,40 @@ import { import { MediaPlayer } from "./MediaPlayer.tsx"; import { fmt } from "../utils/duration.ts"; import Thumbnail from "./Thumbnail.tsx"; +import { useYouTubePlaylist } from "../hooks/useYouTubePlaylist.ts"; +import { embedListId, withJsApi } from "../utils/youtube.ts"; type EmbedItem = Extract; +/** One row of the tracklist, whether it comes from the queue or from a + * YouTube playlist the embed is holding. */ +function TrackRow( + { num, title, duration, isCurrent, disabled, onPlay, rowRef }: { + num: number | string; + title: string; + duration?: number; + isCurrent: boolean; + disabled?: boolean; + onPlay: () => void; + rowRef?: React.Ref; + }, +) { + return ( +
  • + +
  • + ); +} + // The stored embedUrl is the canonical, non-playing form — it is also rendered // outside the player, so the autoplay parameter is added here at playback time // rather than baked into rich_content. Only a fresh play() sets autoplay: a @@ -21,16 +52,20 @@ type EmbedItem = Extract; // Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native // playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve. function playbackUrl(item: EmbedItem, autoplay: boolean) { - if (!autoplay) return item.embedUrl; + // A playlist embed is also driven from the outside — see useYouTubePlaylist. + const base = item.type === "youtube" && embedListId(item.embedUrl) + ? withJsApi(item.embedUrl) + : item.embedUrl; + if (!autoplay) return base; try { - const url = new URL(item.embedUrl); + const url = new URL(base); if (item.type === "youtube") url.searchParams.set("autoplay", "1"); else if (item.type === "soundcloud") { url.searchParams.set("auto_play", "true"); - } else return item.embedUrl; + } else return base; return url.toString(); } catch { - return item.embedUrl; // malformed stored URL — hand it to the iframe as-is + return base; // malformed stored URL — hand it to the iframe as-is } } @@ -56,9 +91,19 @@ export function GlobalPlayer() { onError, } = useContext(PlayerContext); const ref = useRef(null); + const iframeRef = useRef(null); const currentRowRef = useRef(null); const [reduced, setReduced] = useState(false); - const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null); + const [prevKey, setPrevKey] = useState( + current ? playerItemKey(current) : null, + ); + + // A YouTube playlist keeps its own list inside the iframe, so it is asked + // for one rather than being expanded into the queue. + const ytEmbedUrl = current?.kind === "embed" && current.type === "youtube" + ? current.embedUrl + : null; + const yt = useYouTubePlaylist(ytEmbedUrl, iframeRef); const currentKey = current ? playerItemKey(current) : null; if (prevKey !== currentKey) { @@ -93,10 +138,10 @@ export function GlobalPlayer() { }; }, [current]); - // Keep the playing row visible as the queue advances on its own. + // Keep the playing row visible as the list advances on its own. useEffect(() => { currentRowRef.current?.scrollIntoView({ block: "nearest" }); - }, [queueIndex]); + }, [queueIndex, yt.index]); if (!current) return null; @@ -106,14 +151,23 @@ export function GlobalPlayer() { ? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio") : current.type; - const title = current.title ?? + const showQueue = queue.length > 1; + const ytTracks = !showQueue && yt.rows.length > 1 ? yt.rows : null; + const showTransport = showQueue || ytTracks !== null; + + // A playing playlist names the video in the header, the way a Bandcamp queue + // names the track, and demotes the dump's own title to the line below. + const ytRow = ytTracks && yt.index >= 0 ? ytTracks[yt.index] : null; + const itemTitle = current.title ?? (current.kind === "embed" ? current.embedUrl : current.kind === "file" ? current.fileUrl : current.streamUrl); - - const showQueue = queue.length > 1; + const title = ytRow?.title ?? itemTitle; + const subtitle = ytRow?.title && ytRow.title !== itemTitle + ? itemTitle + : current.subtitle; return (
    ) : {title}} - {current.subtitle && ( - {current.subtitle} + {subtitle && ( + {subtitle} )}
    - {showQueue && ( + {showTransport && (
    - {queueIndex + 1} / {queue.length} + {ytTracks + ? `${yt.index >= 0 ? yt.index + 1 : "–"} / ${ytTracks.length}` + : `${queueIndex + 1} / ${queue.length}`}