From 1be40298b4697e07d26ab770c9060699492d3453 Mon Sep 17 00:00:00 2001 From: nxssie Date: Sun, 18 Oct 2026 17:49:29 +0100 Subject: [PATCH 1/3] feat(play): queue the first 25 songs of a YouTube radio link --- README.md | 4 +-- packages/server/src/index.ts | 22 ++++++++++++---- packages/server/src/lib/youtube.test.ts | 35 ++++++++++++++++++++++++- packages/server/src/lib/youtube.ts | 23 ++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c5ba56f..7f064ff 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ a slash command or a button. ## Features - **Vote-ordered queue** — the highest-voted unplayed track plays next; ties break by insertion order. -- **YouTube, SoundCloud, Mixcloud & Twitch** — single tracks, YouTube playlists, and SoundCloud sets. +- **YouTube, SoundCloud, Mixcloud & Twitch** — single tracks, YouTube playlists, YouTube radio (Mix) links (their first 25 songs), and SoundCloud sets. - **Search in Discord** — `/search` lists results as a select menu, no links to copy. - **Interactive queue panel** — `/queue` pages through the queue with upvote and remove controls. - **Now-playing card** — one message per guild, updated per track, with skip and playlist controls. @@ -117,7 +117,7 @@ bun dev:server # http://localhost:3001 | Command | What it does | |---|---| -| `/play ` | Queue a track, playlist, or set and start playing | +| `/play ` | Queue a track, playlist, radio (first 25 songs), or set and start playing | | `/search [source]` | Search YouTube, SoundCloud, or Twitch, then pick from a select menu | | `/queue` | Paginated queue panel with upvote and remove controls | | `/lyrics` | Lyrics for the track currently playing | diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3f4dfef..d84084d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -35,7 +35,7 @@ import { db } from "./db"; import { users, rooms, songs, votes, guilds, type Song } from "./db/schema"; import { eq, desc, and, inArray, lt, sql } from "drizzle-orm"; import { commands } from "./commands"; -import { extractVideoId, isPlaylistUrl } from "./lib/youtube"; +import { extractVideoId, isPlaylistUrl, isRadioUrl, RADIO_TRACK_LIMIT } from "./lib/youtube"; import { detectSource, isSoundcloudSetUrl, type Source } from "./lib/sources"; import { YTDLP_BASE_ARGS, YTDLP_DOWNLOAD_ARGS } from "./lib/ytdlp"; import { searchTracks, toSelectOptions, type SearchableSource } from "./lib/search"; @@ -389,14 +389,23 @@ async function resolveSingleTrack( // SoundCloud flat-playlist entries carry their own (sometimes internal API, // not the public webpage) url and never a title — titles for those are filled // in lazily by playNextFromRoomInner, same as any song missing a title. +// `limit` caps how many entries yt-dlp walks (`--playlist-end`); it exists for +// endless playlists like YouTube radios, which would otherwise take seconds to +// enumerate hundreds of songs nobody asked for. async function resolvePlaylist( url: string, - source: Source + source: Source, + limit?: number ): Promise<{ title: string; entries: Array<{ videoId: string; title: string | null; url: string }> } | null> { return new Promise((resolve) => { const proc = spawn( "yt-dlp", - ["--flat-playlist", "--dump-single-json", "--no-warnings", ...YTDLP_BASE_ARGS, url], + [ + "--flat-playlist", "--dump-single-json", "--no-warnings", + ...(limit ? ["--playlist-end", String(limit)] : []), + ...YTDLP_BASE_ARGS, + url, + ], { stdio: ["ignore", "pipe", "ignore"], timeout: 30000 } ); let output = ""; @@ -923,8 +932,11 @@ async function queueTrackForGuild( let label: string; let rows: (typeof songs.$inferInsert)[]; - if (isPlaylistUrl(url) || isSoundcloudSetUrl(url)) { - const playlist = await resolvePlaylist(url, source); + // A radio carries `v=` like a single-video link, but the user pasted it for + // the list, so it takes the playlist path — capped, since a radio never ends. + const radio = isRadioUrl(url); + if (radio || isPlaylistUrl(url) || isSoundcloudSetUrl(url)) { + const playlist = await resolvePlaylist(url, source, radio ? RADIO_TRACK_LIMIT : undefined); if (!playlist || playlist.entries.length === 0) { await interaction.editReply("Could not resolve playlist"); return; diff --git a/packages/server/src/lib/youtube.test.ts b/packages/server/src/lib/youtube.test.ts index c0ca193..16cc208 100644 --- a/packages/server/src/lib/youtube.test.ts +++ b/packages/server/src/lib/youtube.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { extractVideoId, isPlaylistUrl } from "./youtube"; +import { extractVideoId, isPlaylistUrl, isRadioUrl } from "./youtube"; test("extractVideoId: standard watch URL", () => { expect(extractVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe( @@ -45,3 +45,36 @@ test("isPlaylistUrl: watch?v=..&list=.. is a single video", () => { test("isPlaylistUrl: plain video is not a playlist", () => { expect(isPlaylistUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(false); }); + +test("isRadioUrl: watch URL with an RD list is a radio", () => { + expect( + isRadioUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ&start_radio=1") + ).toBe(true); +}); + +test("isRadioUrl: YouTube Music RDAMVM radio", () => { + expect( + isRadioUrl("https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=RDAMVMdQw4w9WgXcQ") + ).toBe(true); +}); + +test("isRadioUrl: watch URL with a regular PL list is not a radio", () => { + expect( + isRadioUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc") + ).toBe(false); +}); + +test("isRadioUrl: playlist?list=RD without v is not a radio", () => { + expect(isRadioUrl("https://www.youtube.com/playlist?list=RDdQw4w9WgXcQ")).toBe(false); +}); + +test("isRadioUrl: non-YouTube host is not a radio", () => { + expect( + isRadioUrl("https://example.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ") + ).toBe(false); +}); + +test("isRadioUrl: garbage never throws", () => { + expect(isRadioUrl("not a url at all")).toBe(false); + expect(isRadioUrl("")).toBe(false); +}); diff --git a/packages/server/src/lib/youtube.ts b/packages/server/src/lib/youtube.ts index 135a0c7..e548902 100644 --- a/packages/server/src/lib/youtube.ts +++ b/packages/server/src/lib/youtube.ts @@ -26,3 +26,26 @@ export function isPlaylistUrl(url: string): boolean { return false; } } + +// A radio ("Mix") is YouTube's auto-generated, effectively endless playlist. +// Its `list` id always starts with `RD` (`RD`, `RDAMVM`, `RDMM`, ...) +// and the link always carries the seed video as `v=`. Only the `watch` form is +// a radio: `playlist?list=RD…` without `v=` is rejected by YouTube itself +// ("This playlist type is unviewable"), so it stays out of the radio path. +export function isRadioUrl(url: string): boolean { + try { + const u = new URL(url); + const host = u.hostname.replace(/^www\./, ""); + if (!["youtube.com", "music.youtube.com"].includes(host)) return false; + if (u.pathname !== "/watch") return false; + if (!u.searchParams.get("v")) return false; + return (u.searchParams.get("list") ?? "").startsWith("RD"); + } catch { + return false; + } +} + +// How many songs of a radio get queued. A radio has no natural end: resolving +// it unbounded returned 360 songs in ~15 s (measured), far too slow for /play, +// while `--playlist-end 25` resolves in ~1.3 s — enough music for a session. +export const RADIO_TRACK_LIMIT = 25; From eac9787790dc6624e04798e01da32731b1f93775 Mon Sep 17 00:00:00 2001 From: nxssie Date: Sun, 18 Oct 2026 17:51:24 +0100 Subject: [PATCH 2/3] perf(play): stream from the resolved yt-dlp info instead of extracting twice --- packages/server/src/index.ts | 99 ++++++++++++++++++----- packages/server/src/lib/infoCache.test.ts | 69 ++++++++++++++++ packages/server/src/lib/infoCache.ts | 67 +++++++++++++++ 3 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 packages/server/src/lib/infoCache.test.ts create mode 100644 packages/server/src/lib/infoCache.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d84084d..c0550eb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -38,6 +38,7 @@ import { commands } from "./commands"; import { extractVideoId, isPlaylistUrl, isRadioUrl, RADIO_TRACK_LIMIT } from "./lib/youtube"; import { detectSource, isSoundcloudSetUrl, type Source } from "./lib/sources"; import { YTDLP_BASE_ARGS, YTDLP_DOWNLOAD_ARGS } from "./lib/ytdlp"; +import { createInfoCache } from "./lib/infoCache"; import { searchTracks, toSelectOptions, type SearchableSource } from "./lib/search"; import { encodeId, decodeId } from "./lib/components"; import { replyIsEphemeral, unclassifiedCommands, ephemeralNotice } from "./lib/ack"; @@ -147,6 +148,10 @@ async function ensureRoom(id: string, userId?: string | null) { return db.select().from(rooms).where(eq(rooms.id, id)).get(); } +// Info JSON from resolution (`yt-dlp -J`), keyed by the URL that will be +// streamed, so the stream skips a second full extraction. See lib/infoCache.ts. +const infoCache = createInfoCache(); + // --- yt-dlp + ffmpeg audio stream --- // Returns the audio resource plus a cleanup() that kills both child processes, // so a finished/skipped/stopped track never leaves yt-dlp or ffmpeg lingering. @@ -154,14 +159,32 @@ function createAudioStream(url: string): { resource: AudioResource; cleanup: () => void; } { - const ytdlp = spawn( - "yt-dlp", - ["-f", "bestaudio", "--no-playlist", ...YTDLP_BASE_ARGS, ...YTDLP_DOWNLOAD_ARGS, "-o", "-", url], - // stderr is piped (not ignored) so an extraction failure — 403, "Sign in to - // confirm you're not a bot", "forcing SABR" — is logged instead of silently - // producing an empty stream that ends the track after ~2s. - { stdio: ["ignore", "pipe", "pipe"] } - ); + // stderr is piped (not ignored) so an extraction failure — 403, "Sign in to + // confirm you're not a bot", "forcing SABR" — is logged instead of silently + // producing an empty stream that ends the track after ~2s. + // + // With cached info from resolution, yt-dlp reads it from stdin + // (`--load-info-json -`) and goes straight to the download: first audio + // arrives in ~0.5 s instead of ~2.0 s. Without it — never resolved through + // -J, expired, or already consumed — today's fresh extraction runs unchanged. + const cachedInfo = infoCache.take(url); + const ytdlp = cachedInfo + ? spawn( + "yt-dlp", + ["-f", "bestaudio", "--load-info-json", "-", ...YTDLP_BASE_ARGS, ...YTDLP_DOWNLOAD_ARGS, "-o", "-"], + { stdio: ["pipe", "pipe", "pipe"] } + ) + : spawn( + "yt-dlp", + ["-f", "bestaudio", "--no-playlist", ...YTDLP_BASE_ARGS, ...YTDLP_DOWNLOAD_ARGS, "-o", "-", url], + { stdio: ["ignore", "pipe", "pipe"] } + ); + if (cachedInfo && ytdlp.stdin) { + // A yt-dlp that dies before reading its stdin (killed by a skip, a bad + // binary) turns this write into EPIPE; unhandled, that would crash the bot. + ytdlp.stdin.on("error", (e) => console.error(`❌ yt-dlp stdin error for ${url}:`, e.message)); + ytdlp.stdin.end(cachedInfo); + } let ytdlpErr = ""; ytdlp.stderr.on("data", (chunk) => { if (ytdlpErr.length < 4000) ytdlpErr += chunk.toString(); @@ -217,19 +240,44 @@ function createAudioStream(url: string): { }; } +// One full yt-dlp extraction (`-J`) for a YouTube or generic track: title, +// uploader and thumbnail for the queue, and the raw info JSON cached under +// `url` so createAudioStream streams from it instead of extracting again. +// `url` must be the exact URL the song is stored (and later streamed) with. +// `-f bestaudio` makes the cached info carry the same format the stream would +// pick. Never rejects: on any failure the title falls back to the url and +// nothing is cached, so the stream does its own extraction as before. +// // spawn (not exec) passes the URL as a literal argv entry, never through a // shell — this closes the command-injection sink that string interpolation // into `yt-dlp --get-title "..."` opened. -function getVideoInfo(url: string): Promise<{ title: string; uploader: string | null }> { +function getVideoInfo( + url: string +): Promise<{ title: string; uploader: string | null; thumbnail: string | null }> { return new Promise((resolve) => { - const proc = spawn("yt-dlp", ["--get-title", "--no-warnings", ...YTDLP_BASE_ARGS, url], { - stdio: ["ignore", "pipe", "ignore"], - timeout: 15000, - }); + const proc = spawn( + "yt-dlp", + ["-J", "--no-playlist", "-f", "bestaudio", "--no-warnings", ...YTDLP_BASE_ARGS, url], + { stdio: ["ignore", "pipe", "ignore"], timeout: 15000 } + ); let out = ""; - proc.stdout.on("data", (chunk) => (out += chunk)); - proc.on("close", () => resolve({ title: out.trim() || url, uploader: null })); - proc.on("error", () => resolve({ title: url, uploader: null })); + proc.stdout.on("data", (chunk: Buffer) => { out += chunk.toString(); }); + proc.on("close", (code) => { + try { + if (code !== 0) throw new Error(`yt-dlp exited ${code}`); + const json = out.trim(); + const d = JSON.parse(json); + if (d.title) infoCache.set(url, json); + resolve({ + title: d.title ? String(d.title) : url, + uploader: d.uploader ?? d.channel ?? null, + thumbnail: d.thumbnail ?? d.thumbnails?.at(-1)?.url ?? null, + }); + } catch { + resolve({ title: url, uploader: null, thumbnail: null }); + } + }); + proc.on("error", () => resolve({ title: url, uploader: null, thumbnail: null })); }); } @@ -366,6 +414,10 @@ async function resolveSingleTrack( const videoId = extractVideoId(url); if (!videoId) return null; const canonicalUrl = `https://www.youtube.com/watch?v=${videoId}`; + // Cached under canonicalUrl: that is the url stored in songs.url and streamed. + // The thumbnail stays null: yt-dlp reports maxresdefault without checking + // it exists (older videos have none), while nowPlayingEmbed derives + // hqdefault.jpg from the id, which every video has. const { title, uploader } = await getVideoInfo(canonicalUrl); return { videoId, url: canonicalUrl, title, uploader, thumbnail: null }; } @@ -378,8 +430,8 @@ async function resolveSingleTrack( .map((b) => b.toString(16).padStart(2, "0")) .join("") .slice(0, 16); - const { title, uploader } = await getVideoInfo(url); - return { videoId: hash, url, title, uploader, thumbnail: null }; + const { title, uploader, thumbnail } = await getVideoInfo(url); + return { videoId: hash, url, title, uploader, thumbnail }; } return resolveSoundcloudTrack(url); } @@ -762,13 +814,18 @@ async function playNextFromRoomInner(roomId: string, guildId: string) { nextSong.url = info.url; } } else { - // YouTube, generic HLS/DASH - const { title, uploader } = await getVideoInfo(nextSong.url); + // YouTube, generic HLS/DASH. The extraction also caches the info under + // nextSong.url, which the stream below consumes — so a playlist entry + // that needed its title still extracts only once. + // YouTube keeps its derived hqdefault thumbnail (see resolveSingleTrack). + const { title, uploader, thumbnail } = await getVideoInfo(nextSong.url); + const art = nextSong.source === "youtube" ? nextSong.thumbnail : (nextSong.thumbnail ?? thumbnail); db.update(songs) - .set({ title, uploader }) + .set({ title, uploader, thumbnail: art }) .where(eq(songs.id, nextSong.id)) .run(); nextSong.title = title; + nextSong.thumbnail = art; } } diff --git a/packages/server/src/lib/infoCache.test.ts b/packages/server/src/lib/infoCache.test.ts new file mode 100644 index 0000000..fcf99fb --- /dev/null +++ b/packages/server/src/lib/infoCache.test.ts @@ -0,0 +1,69 @@ +import { test, expect } from "bun:test"; +import { createInfoCache } from "./infoCache"; + +// A hand-driven clock so expiry is tested without sleeping. +function clock(start = 1_000) { + let t = start; + return { now: () => t, advance: (ms: number) => { t += ms; } }; +} + +test("take returns a stored entry", () => { + const cache = createInfoCache(); + cache.set("https://a", '{"id":"a"}'); + expect(cache.take("https://a")).toBe('{"id":"a"}'); +}); + +test("take misses an unknown url", () => { + const cache = createInfoCache(); + expect(cache.take("https://missing")).toBeNull(); +}); + +test("take consumes the entry: a second take misses", () => { + const cache = createInfoCache(); + cache.set("https://a", "{}"); + expect(cache.take("https://a")).toBe("{}"); + expect(cache.take("https://a")).toBeNull(); + expect(cache.size).toBe(0); +}); + +test("an entry past its TTL is a miss", () => { + const c = clock(); + const cache = createInfoCache({ ttlMs: 100, now: c.now }); + cache.set("https://a", "{}"); + c.advance(99); + expect(cache.take("https://a")).toBe("{}"); + + cache.set("https://b", "{}"); + c.advance(100); + expect(cache.take("https://b")).toBeNull(); +}); + +test("expired entries are evicted on the next write", () => { + const c = clock(); + const cache = createInfoCache({ ttlMs: 100, now: c.now }); + cache.set("https://a", "{}"); + c.advance(150); + cache.set("https://b", "{}"); + expect(cache.size).toBe(1); +}); + +test("over capacity, the oldest entry is evicted first", () => { + const cache = createInfoCache({ maxEntries: 2 }); + cache.set("https://a", "a"); + cache.set("https://b", "b"); + cache.set("https://c", "c"); + expect(cache.size).toBe(2); + expect(cache.take("https://a")).toBeNull(); + expect(cache.take("https://b")).toBe("b"); + expect(cache.take("https://c")).toBe("c"); +}); + +test("overwriting a url refreshes it as the newest entry", () => { + const cache = createInfoCache({ maxEntries: 2 }); + cache.set("https://a", "a1"); + cache.set("https://b", "b"); + cache.set("https://a", "a2"); + cache.set("https://c", "c"); + expect(cache.take("https://b")).toBeNull(); + expect(cache.take("https://a")).toBe("a2"); +}); diff --git a/packages/server/src/lib/infoCache.ts b/packages/server/src/lib/infoCache.ts new file mode 100644 index 0000000..1f23583 --- /dev/null +++ b/packages/server/src/lib/infoCache.ts @@ -0,0 +1,67 @@ +// A short-lived, bounded cache of yt-dlp info JSON, keyed by the URL that will +// be streamed. Resolving a track (`yt-dlp -J`) already does the expensive part +// of starting playback — the full YouTube extraction — so the stream can reuse +// that result through `--load-info-json -` instead of extracting a second +// time. Measured locally: first audio byte ~2.0 s through a fresh extraction, +// ~0.5 s from a cached one. +// +// Pure (no I/O) and clock-injectable, so expiry is unit-tested without sleeping. + +// Ten minutes is far below the lifetime of YouTube's signed media URLs (~6 h), +// so a cached entry can never stream from an expired URL; a song that waits +// longer in the queue simply falls back to a fresh extraction. +export const INFO_CACHE_TTL_MS = 10 * 60 * 1000; + +// One entry is 86-660 KB of JSON (measured; videos with many formats, like 4K +// remasters, sit at the top), so 20 entries cap the cache at ~13 MB even when +// busy guilds queue faster than they play. Only the next song of each guild +// really needs its entry; anything evicted just extracts fresh. +export const INFO_CACHE_MAX_ENTRIES = 20; + +export interface InfoCache { + set(url: string, json: string): void; + // Returns the entry and removes it: the stream consumes it exactly once, so a + // replay of the same song extracts fresh info instead of reusing old URLs. + take(url: string): string | null; + readonly size: number; +} + +export function createInfoCache( + options: { ttlMs?: number; maxEntries?: number; now?: () => number } = {} +): InfoCache { + const ttlMs = options.ttlMs ?? INFO_CACHE_TTL_MS; + const maxEntries = options.maxEntries ?? INFO_CACHE_MAX_ENTRIES; + const now = options.now ?? Date.now; + // A Map iterates in insertion order, so its first key is always the oldest + // entry — set() re-inserts on overwrite to keep that true. + const entries = new Map(); + + const evictExpired = () => { + const t = now(); + for (const [url, entry] of entries) { + if (entry.expiresAt <= t) entries.delete(url); + } + }; + + return { + set(url, json) { + evictExpired(); + entries.delete(url); + entries.set(url, { json, expiresAt: now() + ttlMs }); + while (entries.size > maxEntries) { + const oldest = entries.keys().next().value as string; + entries.delete(oldest); + } + }, + take(url) { + const entry = entries.get(url); + if (!entry) return null; + entries.delete(url); + return entry.expiresAt > now() ? entry.json : null; + }, + get size() { + evictExpired(); + return entries.size; + }, + }; +} From 197b43d5470edfbfcb968caff8d606eca6822e2f Mon Sep 17 00:00:00 2001 From: nxssie Date: Sun, 18 Oct 2026 17:52:13 +0100 Subject: [PATCH 3/3] docs(odd): record the radio and fast-start work units --- odd/tasks/youtube-radio-fast-start.md | 97 +++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 odd/tasks/youtube-radio-fast-start.md diff --git a/odd/tasks/youtube-radio-fast-start.md b/odd/tasks/youtube-radio-fast-start.md new file mode 100644 index 0000000..b5d1fd9 --- /dev/null +++ b/odd/tasks/youtube-radio-fast-start.md @@ -0,0 +1,97 @@ +# Feature: YouTube radio links and a faster first play + +A YouTube radio ("Mix") link pasted into `/play` must queue the radio's songs, +and the silence before a song starts must shrink. + +## Status + +| Field | Value | +|---|---| +| State | **Code complete** — checks green; review assessment below | +| Branch | `feat/youtube-radio-fast-start`, stacked on `fix/queue-reset-on-leave` (both rework `index.ts`; the base PR is not open yet) | +| Tasks | 3 | +| TDD | off (no project or session configuration enables it); runner `bun test` in `packages/server` | +| Delivery | `ask-on-risk`; forecast ~250 authored lines, under the ~400 slice budget | +| Commit author | `nxssie `, no AI attribution trailers | + +## Specs + +- **S1** — the user's request, verbatim: «si meto un enlace de radio de youtube + se adquiera esa lista de canciones». A YouTube or YouTube Music URL whose + `list` parameter starts with `RD` is a radio, even when it carries `v=` + (radio links always do). It is queued as a playlist. +- **S2** — the user's choice: a radio queues its first **25** songs. Measured: + `--playlist-end 25` resolves in 1.3 s; an unlimited radio returned 360 songs + in 15.4 s, inside `resolvePlaylist`'s 30 s timeout but far too slow. A + `playlist?list=RD…` URL without `v=` is rejected by YouTube ("This playlist + type is unviewable"), so only the `watch` form is a radio. +- **S3** — `watch?v=X&list=PL…` (a regular playlist with a video) keeps playing + the single video, as `isPlaylistUrl` documents today. +- **S4** — the user's request, verbatim: «arreglar la velocidad antes de la + primera reproducción». Root cause: a YouTube `/play` runs two full yt-dlp + extractions back to back, `getVideoInfo` for the title and then + `createAudioStream` for the audio. Measured locally: first audio byte ~2.0 s + through a fresh extraction, ~0.5 s through `--load-info-json -` fed with an + earlier `-J` extraction. +- **S5** — resolution extracts once (`-J`), keeps the info JSON in a bounded, + short-lived in-memory cache keyed by the song URL, and returns title, + uploader and thumbnail from it. `createAudioStream` streams from a cached + entry through `--load-info-json -` (stdin, no temp file) and consumes it; a + missing or expired entry falls back to today's fresh extraction. The TTL is + well under the lifetime of YouTube's signed media URLs, so a cached entry + never streams from an expired URL. +- **S6** — `README.md` lists radio links among the supported inputs. +- **S7** — checks: `bun test`, `bun build src/index.ts --target bun`. No live + Discord smoke (no harness in this repo). + +## Tasks + +- [x] **T1** — Radio links (S1, S2, S3, S6): `isRadioUrl` in `lib/youtube.ts` + with tests; `/play` routes radios through `resolvePlaylist` with a + 25-song limit; README. `1be4029` +- [x] **T2** — Fast first play (S4, S5): pure info cache in `lib/` with tests; + `-J` resolution for YouTube/generic tracks (resolve and lazy-title + paths); `createAudioStream` streams from the cache with a fallback. `eac9787` +- [x] **T3** — Checks and evidence (S7). + +## Routing + +| Task | Route | Trigger evidence | +|---|---|---| +| T1 | delegated writer | `lib/youtube.ts`, its test, `index.ts`, README: 2+ non-trivial files | +| T2 | delegated writer | new `lib` module, its test, `index.ts`: 2+ non-trivial files | +| T3 | inline | runs checks only | + +## Evidence + +| Check | Result | +|---|---| +| `bun test` | 84 pass, 0 fail (9 files); new: 6 `isRadioUrl` cases, 7 `infoCache` cases | +| `bun build src/index.ts --target bun` | exit 0 | +| Ad-hoc `tsc --strict` | only the 2 pre-existing `member.voice` errors at the voice gates | +| Live radio resolve | `--playlist-end 25` on an `RD` radio: 25 entries in 1.8 s | +| Live first byte | cached info 0.67 s vs fresh extraction 4.99 s (same run) | +| Diff vs base | 6 files, +290 / −29 | + +## Accepted deviations + +- Cache cap is 20 entries, not 50: info JSON measured 86–660 KB per video, so + 20 entries bound memory at ~13 MB. TTL stays 10 minutes. +- YouTube thumbnails stay null: yt-dlp reports `maxresdefault` without checking + it exists; the now-playing card already derives `hqdefault.jpg` from the id. + +## Known residue + +- Only tracks resolved through `getVideoInfo` get the fast start: single + `/play` tracks and playlist entries without a title. YouTube playlist and + radio entries arrive titled, so their first song still extracts while + starting. Prefetching the next song's info during playback would cover them + and the gap between songs; not in this scope. +- A generic HLS/DASH stream with no audio-only format now gets its URL as the + title (the `-f bestaudio` extraction fails where `--get-title` did not). + Such streams already failed to play with the same format selector. + +## Log + +- **L1** — «también se podría hacer que si meto un enlace de radio de youtube se adquiera esa lista de canciones?» +- **L2** — «pues 25 canciones y arreglar la velocidad antes de la primera reproducción»