diff --git a/shared/proxy-routes.ts b/shared/proxy-routes.ts index ce743ee..4dbbbfd 100644 --- a/shared/proxy-routes.ts +++ b/shared/proxy-routes.ts @@ -15,8 +15,12 @@ export type ProxyRoute = { target: string; /** True if this route needs the Last.fm API key to do anything useful. */ requiresLastfmKey?: boolean; - /** Builds the full upstream URL from the incoming pathname and query string. */ - buildUrl: (pathname: string, search: string, lastfmApiKey: string) => string; + /** + * Builds the full upstream URL from the incoming pathname and query string. + * Omitted for routes the Worker handles itself rather than proxying + * verbatim (e.g. "/yt", which scrapes and caches instead of forwarding). + */ + buildUrl?: (pathname: string, search: string, lastfmApiKey: string) => string; /** Extra headers to send upstream, beyond whatever fetch adds by default. */ headers?: Record; }; @@ -50,6 +54,14 @@ export const routes: ProxyRoute[] = [ return `https://ws.audioscrobbler.com/2.0/?${query.toString()}`; }, }, + { + // Bare path plus a query string (?artist=&title=), like /fm — the + // (artist, title) pair doesn't decompose into a path segment. Handled by + // the Worker directly (see worker/youtube.ts) rather than proxied: it + // scrapes and caches instead of forwarding a request verbatim. + prefix: '/yt', + target: 'https://www.youtube.com', + }, ]; /** Finds the route matching a request pathname, if any. */ diff --git a/src/App.tsx b/src/App.tsx index 57c8fcf..54fd5ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { DialRoot, useDialKit } from 'dialkit'; import 'dialkit/styles.css'; import { @@ -33,6 +33,7 @@ import { updateRun, type Run, } from './runlog'; +import { usePlaylistPlayer } from './youtubePlayer'; /** * The roster must be considerably larger than the artist draw or adventurousness @@ -64,7 +65,6 @@ export default function App() { const [compare, setCompare] = useState<[string?, string?]>([]); const [reference, setReference] = useState(''); const [detail, setDetail] = useState>(new Map()); - const audio = useRef(null); useEffect(() => setRuns(loadRuns()), []); @@ -261,6 +261,7 @@ export default function App() { const shown = result?.tracks ?? []; const drops = result?.dropped ?? {}; + const player = usePlaylistPlayer(shown); const save = () => { if (!cache || !shown.length) return; @@ -282,13 +283,6 @@ export default function App() { setRuns(next); }; - const play = (t: Track) => { - audio.current?.pause(); - if (!t.preview) return; - audio.current = new Audio(t.preview); - void audio.current.play(); - }; - const download = () => { const blob = new Blob([toCsv(shown)], { type: 'text/csv' }); const a = document.createElement('a'); @@ -388,30 +382,63 @@ export default function App() { )} +
+
+
+ + + + + {player.currentIndex !== null && shown[player.currentIndex] + ? `${shown[player.currentIndex].artist} — ${shown[player.currentIndex].title}` + : 'Nothing playing'} + +
+
+
    - {shown.map((t, i) => ( -
  1. - - {String(i + 1).padStart(3, '0')} - {t.title} - {t.artist} - - {t.year ?? '—'} · d{Math.round(t.depth * 100)} - {t.bpm ? ` · ${Math.round(t.bpm)}bpm` : ''} - {t.sources?.length ? ` · ${t.sources.map((s) => s[0]).join('')}` : ''} - - - - YT - - - SC - - -
  2. - ))} + {shown.map((t, i) => { + const trackStatus = player.statuses[i]; + const isCurrent = i === player.currentIndex; + return ( +
  3. + + {String(i + 1).padStart(3, '0')} + {t.title} + {t.artist} + + {t.year ?? '—'} · d{Math.round(t.depth * 100)} + {t.bpm ? ` · ${Math.round(t.bpm)}bpm` : ''} + {t.sources?.length ? ` · ${t.sources.map((s) => s[0]).join('')}` : ''} + {trackStatus === 'failed' ? ' · unplayable' : ''} + + + + YT + + + SC + + +
  4. + ); + })}
diff --git a/src/styles.css b/src/styles.css index 9469994..b5c852c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -54,6 +54,15 @@ button { button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } button:disabled { opacity: 0.35; cursor: default; } +.player-bar { + display: flex; + align-items: center; + gap: 16px; + margin: 20px 0 0; +} +.player-frame { flex: none; width: 200px; height: 113px; background: var(--panel); border-radius: 6px; } +.player-controls { display: flex; align-items: center; gap: 8px; } + /* * The whole list is one grid and each row is a subgrid of it. Giving every row * its own grid instead lets the auto-width metadata column size per row, so a @@ -77,6 +86,9 @@ button:disabled { opacity: 0.35; cursor: default; } padding: 7px 0; border-bottom: 1px solid var(--line); } +.tracks li.current { background: color-mix(in srgb, var(--accent) 10%, transparent); } +.tracks li.current .title { color: var(--accent); } +.tracks li.failed .title, .tracks li.failed .artist { color: #ff6b6b; opacity: 0.7; } .play { padding: 2px 6px; font-size: 11px; } .n { color: var(--dim); font-variant-numeric: tabular-nums; font-size: 12px; } diff --git a/src/youtubePlayer.ts b/src/youtubePlayer.ts new file mode 100644 index 0000000..080d308 --- /dev/null +++ b/src/youtubePlayer.ts @@ -0,0 +1,217 @@ +// Whole-track playback via the YouTube IFrame Player API, so YouTube itself +// handles embedding rules and ads rather than this app trying to. The +// browser can't look up a track's videoId directly (YouTube sends no CORS +// headers on search), so resolution goes through the Worker's /yt route, +// which scrapes and caches it server-side. +// +// Resolution is lazy: only the current track and the next couple are looked +// up, not the whole ~100-track playlist up front. + +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; +import type { Track } from './sources'; + +declare global { + interface Window { + YT?: { + Player: new (el: HTMLElement, options: Record) => YTPlayerInstance; + PlayerState: { ENDED: number; PLAYING: number; PAUSED: number }; + }; + onYouTubeIframeAPIReady?: () => void; + } +} + +interface YTPlayerInstance { + loadVideoById(videoId: string): void; + playVideo(): void; + pauseVideo(): void; + stopVideo(): void; +} + +/** How many tracks ahead of the current one get resolved in the background. */ +const PREFETCH_AHEAD = 2; + +export type TrackStatus = 'resolving' | 'ready' | 'failed'; + +export type PlaylistPlayer = { + /** DOM node the IFrame API replaces with the actual player. */ + containerRef: RefObject; + currentIndex: number | null; + playing: boolean; + /** Per-track-index resolution state; absent entries have not been looked up yet. */ + statuses: Record; + /** Starts (or resumes, if already current) playback at this index. */ + play: (index: number) => void; + toggle: () => void; + next: () => void; + previous: () => void; +}; + +/** Loads the IFrame API script once and resolves when window.YT.Player exists. */ +function useIframeApiReady(): boolean { + const [ready, setReady] = useState(() => !!window.YT?.Player); + + useEffect(() => { + if (ready) return; + if (!document.getElementById('youtube-iframe-api')) { + const script = document.createElement('script'); + script.id = 'youtube-iframe-api'; + script.src = 'https://www.youtube.com/iframe_api'; + document.head.appendChild(script); + } + const previous = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + previous?.(); + setReady(true); + }; + }, [ready]); + + return ready; +} + +/** + * Drives one YouTube IFrame player across an entire track list: resolves + * videoIds lazily, advances on end, and skips — marking the row so it's + * visible why — when a track can't be resolved or can't be embedded. + */ +export function usePlaylistPlayer(tracks: Track[]): PlaylistPlayer { + const apiReady = useIframeApiReady(); + const containerRef = useRef(null); + const playerRef = useRef(null); + + const [currentIndex, setCurrentIndex] = useState(null); + const [playing, setPlaying] = useState(false); + const [statuses, setStatuses] = useState>({}); + + // Refs mirroring the latest state/props, so the player's event callbacks + // (registered once, when the player is constructed) never close over stale + // values. + const tracksRef = useRef(tracks); + tracksRef.current = tracks; + const indexRef = useRef(null); + indexRef.current = currentIndex; + const videoIds = useRef>(new Map()); + const statusesRef = useRef(statuses); + statusesRef.current = statuses; + + // A new playlist invalidates every cached videoId and in-flight lookup. + useEffect(() => { + videoIds.current.clear(); + setStatuses({}); + setCurrentIndex(null); + setPlaying(false); + playerRef.current?.stopVideo(); + }, [tracks]); + + const loadIndex = useCallback((index: number) => { + const videoId = videoIds.current.get(index); + if (!videoId || !playerRef.current) return; + playerRef.current.loadVideoById(videoId); + setPlaying(true); + }, []); + + const play = useCallback( + (index: number) => { + const list = tracksRef.current; + if (index < 0 || index >= list.length) return; + setCurrentIndex(index); + indexRef.current = index; + if (videoIds.current.has(index)) { + loadIndex(index); + } + // Resolution below (including for `index` itself, if not yet cached) + // triggers loadIndex once the id comes back. + }, + [loadIndex], + ); + + const advance = useCallback( + (delta: number) => { + const list = tracksRef.current; + const from = indexRef.current; + if (from === null) return; + const to = from + delta; + if (to < 0 || to >= list.length) { + setPlaying(false); + return; + } + play(to); + }, + [play], + ); + + const resolve = useCallback( + async (index: number) => { + const list = tracksRef.current; + if (index < 0 || index >= list.length) return; + if (videoIds.current.has(index) || statusesRef.current[index]) return; + setStatuses((s) => ({ ...s, [index]: 'resolving' })); + const t = list[index]; + try { + const res = await fetch(`/yt?artist=${encodeURIComponent(t.artist)}&title=${encodeURIComponent(t.title)}`); + const body: { videoId?: string; error?: string } = await res.json(); + if (!res.ok || !body.videoId) { + setStatuses((s) => ({ ...s, [index]: 'failed' })); + if (indexRef.current === index) advance(1); + return; + } + videoIds.current.set(index, body.videoId); + setStatuses((s) => ({ ...s, [index]: 'ready' })); + if (indexRef.current === index) loadIndex(index); + } catch { + setStatuses((s) => ({ ...s, [index]: 'failed' })); + if (indexRef.current === index) advance(1); + } + }, + [advance, loadIndex], + ); + + // Whenever the current track changes, resolve it and prefetch a couple + // ahead — never the whole list at once. + useEffect(() => { + if (currentIndex === null) return; + for (let i = currentIndex; i <= currentIndex + PREFETCH_AHEAD; i++) void resolve(i); + }, [currentIndex, resolve]); + + // The player instance is created once the IFrame API script has loaded, + // and its event handlers read state through refs so they always see the + // current track rather than the one that was playing when it was built. + useEffect(() => { + if (!apiReady || !containerRef.current || playerRef.current || !window.YT) return; + playerRef.current = new window.YT.Player(containerRef.current, { + width: 200, + height: 113, + events: { + onStateChange: (e: { data: number }) => { + const YT = window.YT!; + if (e.data === YT.PlayerState.ENDED) advance(1); + else if (e.data === YT.PlayerState.PLAYING) setPlaying(true); + else if (e.data === YT.PlayerState.PAUSED) setPlaying(false); + }, + // Non-embeddable (101/150), removed/private (100), or otherwise + // unplayable (2, 5) videos all mean the same thing here: this track + // doesn't play, so mark it and move on rather than sit on a dead row. + onError: () => { + const failedIndex = indexRef.current; + if (failedIndex !== null) setStatuses((s) => ({ ...s, [failedIndex]: 'failed' })); + advance(1); + }, + }, + }); + }, [apiReady, advance]); + + const toggle = useCallback(() => { + if (!playerRef.current || currentIndex === null) return; + if (playing) { + playerRef.current.pauseVideo(); + setPlaying(false); + } else { + playerRef.current.playVideo(); + setPlaying(true); + } + }, [playing, currentIndex]); + + const next = useCallback(() => advance(1), [advance]); + const previous = useCallback(() => advance(-1), [advance]); + + return { containerRef, currentIndex, playing, statuses, play, toggle, next, previous }; +} diff --git a/worker/index.ts b/worker/index.ts index 97d0ca0..a8d4849 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,8 +1,48 @@ import { matchRoute } from '../shared/proxy-routes'; +import { cacheKey, NEGATIVE_TTL, POSITIVE_TTL, ScrapeError, searchVideoId } from './youtube'; export interface Env { ASSETS: Fetcher; LASTFM_API_KEY: string; + YT_CACHE: KVNamespace; +} + +/** Stored in KV in place of a videoId when a track has already been searched and failed. */ +const NEGATIVE_MARKER = 'NONE'; + +/** + * Resolves ?artist=&title= to a YouTube videoId, caching the result so a + * playlist scrapes each track once rather than on every play. Scrape + * failures (YouTube's markup no longer matching) are never cached — they're + * a fact about the scraper, not about the track, and should surface again on + * the very next attempt rather than look like a permanently unresolvable song. + */ +async function resolveYoutube(url: URL, env: Env): Promise { + const artist = url.searchParams.get('artist'); + const title = url.searchParams.get('title'); + if (!artist || !title) { + return Response.json({ error: 'artist and title query params are required' }, { status: 400 }); + } + + const key = cacheKey(artist, title); + const cached = await env.YT_CACHE.get(key); + if (cached === NEGATIVE_MARKER) return Response.json({ error: 'no plausible video found' }, { status: 404 }); + if (cached) return Response.json({ videoId: cached }); + + try { + const videoId = await searchVideoId(artist, title); + if (!videoId) { + await env.YT_CACHE.put(key, NEGATIVE_MARKER, { expirationTtl: NEGATIVE_TTL }); + return Response.json({ error: 'no plausible video found' }, { status: 404 }); + } + await env.YT_CACHE.put(key, videoId, { expirationTtl: POSITIVE_TTL }); + return Response.json({ videoId }); + } catch (e) { + if (e instanceof ScrapeError) { + return Response.json({ error: e.message }, { status: 502 }); + } + throw e; + } } // Every call the client makes through these routes is a GET, so the handler @@ -17,7 +57,8 @@ export default { if (route.requiresLastfmKey && !env.LASTFM_API_KEY) { return new Response('Missing LASTFM_API_KEY binding', { status: 500 }); } - const upstream = route.buildUrl(url.pathname, url.search, env.LASTFM_API_KEY); + if (route.prefix === '/yt') return resolveYoutube(url, env); + const upstream = route.buildUrl!(url.pathname, url.search, env.LASTFM_API_KEY); return fetch(upstream, { headers: route.headers }); } return env.ASSETS.fetch(request); diff --git a/worker/youtube.ts b/worker/youtube.ts new file mode 100644 index 0000000..fcdc460 --- /dev/null +++ b/worker/youtube.ts @@ -0,0 +1,88 @@ +// Resolves an (artist, title) pair to a YouTube videoId. +// +// The official YouTube Data API's search quota (100 queries/day, shared +// across the whole application) is exhausted by a single 100-track +// playlist, and YouTube's search page sends no CORS headers, so the browser +// can't do this fetch itself. The only way around both limits is to request +// the search page here, server-side, and pick a videoId out of the +// `ytInitialData` blob embedded in the HTML. +// +// This is brittle by construction — YouTube can change this markup at any +// time, and scraping it is against YouTube's terms of service, so this only +// exists as a personal tool. When the markup no longer matches what this +// expects, resolution throws instead of quietly returning nothing, so a +// broken scraper is visible rather than indistinguishable from "no such +// song". + +const UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; +// Pre-accepts the EU cookie-consent interstitial, which otherwise replaces +// the search results with a consent page carrying no ytInitialData at all. +const CONSENT_COOKIE = 'SOCS=CAISEwgDEgk0ODE3Nzk3MjQaAmVuIAEaBgiA_LyaBg'; + +/** Video ids don't change once assigned, so a resolved id can be kept a long time. */ +export const POSITIVE_TTL = 60 * 60 * 24 * 30; +/** A title that resolves to nothing today may be uploaded tomorrow, so negative + * results expire much sooner than positive ones. */ +export const NEGATIVE_TTL = 60 * 60 * 24; + +/** Same normalization style as sources.ts normalizeName(), applied to both fields. */ +const norm = (s: string) => s.toLowerCase().replace(/^the\s+/, '').replace(/[^a-z0-9]/g, ''); + +export const cacheKey = (artist: string, title: string) => `yt:${norm(artist)}::${norm(title)}`; + +/** Recursively finds the first embedded videoRenderer with a real video id and a duration. */ +function firstVideoId(node: unknown): string | undefined { + if (Array.isArray(node)) { + for (const item of node) { + const found = firstVideoId(item); + if (found) return found; + } + return undefined; + } + if (node && typeof node === 'object') { + const obj = node as Record; + const renderer = obj.videoRenderer as Record | undefined; + // lengthText is absent for live streams and premieres, which aren't + // useful as playlist entries. + if (typeof renderer?.videoId === 'string' && renderer.lengthText) { + return renderer.videoId; + } + for (const value of Object.values(obj)) { + const found = firstVideoId(value); + if (found) return found; + } + } + return undefined; +} + +/** Thrown when the search page's markup no longer matches what this scraper expects. */ +export class ScrapeError extends Error {} + +function extractFirstVideoId(html: string): string | undefined { + const match = html.match(/ytInitialData\s*=\s*(\{[\s\S]*?\});<\/script>/); + if (!match) { + throw new ScrapeError('ytInitialData not found in YouTube search page — markup has likely changed'); + } + let data: unknown; + try { + data = JSON.parse(match[1]); + } catch { + throw new ScrapeError('ytInitialData did not parse as JSON — markup has likely changed'); + } + return firstVideoId(data); +} + +/** Scrapes YouTube search results for the first plausible videoId. Throws on scrape failure. */ +export async function searchVideoId(artist: string, title: string): Promise { + const query = `${artist} ${title}`; + const res = await fetch(`https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`, { + headers: { + 'User-Agent': UA, + 'Accept-Language': 'en-US,en;q=0.9', + Cookie: CONSENT_COOKIE, + }, + }); + if (!res.ok) throw new ScrapeError(`YouTube search returned ${res.status}`); + return extractFirstVideoId(await res.text()); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 606df7c..5cdef2b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,6 +10,12 @@ "directory": "./dist", "not_found_handling": "single-page-application", "binding": "ASSETS", - "run_worker_first": ["/dz/*", "/lb/*", "/mb/*", "/fm*"], + "run_worker_first": ["/dz/*", "/lb/*", "/mb/*", "/fm*", "/yt*"], }, + "kv_namespaces": [ + { + "binding": "YT_CACHE", + "id": "745c2a02699c4f2cb7bbe40a62986a66", + }, + ], }