Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -117,7 +117,7 @@ bun dev:server # http://localhost:3001

| Command | What it does |
|---|---|
| `/play <url>` | Queue a track, playlist, or set and start playing |
| `/play <url>` | Queue a track, playlist, radio (first 25 songs), or set and start playing |
| `/search <query> [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 |
Expand Down
97 changes: 97 additions & 0 deletions odd/tasks/youtube-radio-fast-start.md
Original file line number Diff line number Diff line change
@@ -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 <dev@nxssie.com>`, 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»
121 changes: 95 additions & 26 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ 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 { 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";
Expand Down Expand Up @@ -147,21 +148,43 @@ 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.
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();
Expand Down Expand Up @@ -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 }));
});
}

Expand Down Expand Up @@ -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 };
}
Expand All @@ -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);
}
Expand All @@ -389,14 +441,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 = "";
Expand Down Expand Up @@ -753,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;
}
}

Expand Down Expand Up @@ -923,8 +989,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;
Expand Down
69 changes: 69 additions & 0 deletions packages/server/src/lib/infoCache.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading