From 0ecf9c458603ef23a0189a9a85111606bcaefc9c Mon Sep 17 00:00:00 2001 From: Nxssie Date: Fri, 3 Jul 2026 11:35:25 +0100 Subject: [PATCH] feat(sources): add twitch and generic hls/dash streaming support - extend Source type with "twitch" (twitch.tv, clips.twitch.tv) and "generic" (.m3u8/.mpd manifests) - add resolveTwitchTrack using yt-dlp --dump-json for VOD/clip metadata - generic URLs use SHA-256 hash as videoId since HLS/DASH have no native id - SSRF gate maintained: generic only accepts URLs with manifest file extensions - update frontend Source types, search selector (TW), input placeholder, and open-in links - add tests for twitch host detection and manifest URL patterns --- packages/server/src/commands.ts | 2 +- packages/server/src/index.ts | 78 +++++++++++++++++++++--- packages/server/src/lib/sources.test.ts | 15 +++++ packages/server/src/lib/sources.ts | 19 +++++- packages/web/src/components/SongItem.tsx | 2 +- packages/web/src/pages/Room.tsx | 12 ++-- 6 files changed, 110 insertions(+), 18 deletions(-) diff --git a/packages/server/src/commands.ts b/packages/server/src/commands.ts index 145562d..59d44c2 100644 --- a/packages/server/src/commands.ts +++ b/packages/server/src/commands.ts @@ -8,7 +8,7 @@ export const commands = [ .setName("play") .setDescription("Add a song to the queue and start playing") .addStringOption((option) => - option.setName("url").setDescription("YouTube or SoundCloud URL").setRequired(true) + option.setName("url").setDescription("YouTube, SoundCloud, or Twitch URL").setRequired(true) ), new SlashCommandBuilder() .setName("listen") diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 14aa2f4..f1ca805 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -341,6 +341,44 @@ function resolveSoundcloudTrack(url: string): Promise<{ }); } +// Single yt-dlp round trip for a Twitch VOD/clip: id, title, uploader, and +// thumbnail. Twitch URLs don't carry a parseable ID like YouTube, so we rely +// entirely on yt-dlp's --dump-json to extract metadata. +function resolveTwitchTrack(url: string): Promise<{ + videoId: string; + url: string; + title: string; + uploader: string | null; + thumbnail: string | null; +} | null> { + return new Promise((resolve) => { + const proc = spawn( + "yt-dlp", + ["--dump-json", "--no-playlist", "--no-warnings", ...YTDLP_BASE_ARGS, url], + { stdio: ["ignore", "pipe", "ignore"], timeout: 15000 } + ); + let output = ""; + proc.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + proc.on("close", () => { + try { + const d = JSON.parse(output.trim()); + if (!d.id || !d.title) return resolve(null); + const thumbnail = d.thumbnail ?? d.thumbnails?.at(-1)?.url ?? null; + resolve({ + videoId: String(d.id), + url: d.webpage_url || url, + title: String(d.title), + uploader: d.uploader ?? d.channel ?? null, + thumbnail, + }); + } catch { + resolve(null); + } + }); + proc.on("error", () => resolve(null)); + }); +} + // Shared by the HTTP add-song endpoint and the Discord /play command so the // two surfaces can't drift on how a single track is resolved. async function resolveSingleTrack( @@ -360,6 +398,17 @@ async function resolveSingleTrack( const { title, uploader } = await getVideoInfo(canonicalUrl); return { videoId, url: canonicalUrl, title, uploader, thumbnail: null }; } + if (source === "twitch") return resolveTwitchTrack(url); + if (source === "generic") { + // Direct streaming manifest — yt-dlp handles HLS/DASH natively. + // No parseable video ID; use a truncated SHA-256 of the URL. + const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(url)))] + .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 }; + } return resolveSoundcloudTrack(url); } @@ -513,10 +562,9 @@ async function playNextFromRoomInner(roomId: string, guildId: string) { return; } - // Get title if not cached. SoundCloud playlist/set entries also carry no - // title from flat-playlist resolution, and their stored url may still be the - // internal api-v2 url yt-dlp's flat-playlist gave us — resolving here also - // self-heals it to the public webpage_url. + // Get title if not cached. SoundCloud and Twitch entries carry no title + // from flat-playlist resolution, and their stored url may still be an + // internal url — resolving here also self-heals it to the public webpage_url. if (!nextSong.title) { if (nextSong.source === "soundcloud") { const info = await resolveSoundcloudTrack(nextSong.url); @@ -528,7 +576,18 @@ async function playNextFromRoomInner(roomId: string, guildId: string) { nextSong.title = info.title; nextSong.url = info.url; } + } else if (nextSong.source === "twitch") { + const info = await resolveTwitchTrack(nextSong.url); + if (info) { + db.update(songs) + .set({ title: info.title, uploader: info.uploader, thumbnail: info.thumbnail, url: info.url }) + .where(eq(songs.id, nextSong.id)) + .run(); + nextSong.title = info.title; + nextSong.url = info.url; + } } else { + // YouTube, generic HLS/DASH const { title, uploader } = await getVideoInfo(nextSong.url); db.update(songs) .set({ title, uploader }) @@ -903,7 +962,7 @@ app.post("/api/rooms/:id/songs", async (c) => { const { url } = body; const source = detectSource(url); - if (!source) return c.json({ error: "Invalid YouTube or SoundCloud URL" }, 400); + if (!source) return c.json({ error: "Invalid YouTube, SoundCloud, Twitch, or streaming URL (.m3u8/.mpd)" }, 400); await ensureRoom(id, user.id); @@ -1165,8 +1224,9 @@ app.get("/api/search", async (c) => { if (!q) return c.json({ results: [] }); const n = Math.min(Number(c.req.query("n") || 5), 10); - const source: Source = c.req.query("source") === "soundcloud" ? "soundcloud" : "youtube"; - const searchPrefix = source === "youtube" ? "ytsearch" : "scsearch"; + const rawSource = c.req.query("source"); + const source: Source = rawSource === "soundcloud" ? "soundcloud" : rawSource === "twitch" ? "twitch" : "youtube"; + const searchPrefix = source === "youtube" ? "ytsearch" : source === "twitch" ? "twitchsearch" : "scsearch"; type SearchResult = { source: Source; @@ -1481,12 +1541,12 @@ discord.on(Events.InteractionCreate, async (interaction) => { if (interaction.commandName === "play") { const url = interaction.options.getString("url"); if (!url || !guildId) { - await interaction.reply("Provide a YouTube or SoundCloud URL and be in a server"); + await interaction.reply("Provide a YouTube, SoundCloud, Twitch, or streaming URL and be in a server"); return; } const source = detectSource(url); if (!source) { - await interaction.reply("Invalid YouTube or SoundCloud URL"); + await interaction.reply("Invalid YouTube, SoundCloud, Twitch, or streaming URL"); return; } diff --git a/packages/server/src/lib/sources.test.ts b/packages/server/src/lib/sources.test.ts index ee14ded..4fe9275 100644 --- a/packages/server/src/lib/sources.test.ts +++ b/packages/server/src/lib/sources.test.ts @@ -13,6 +13,21 @@ test("detectSource: soundcloud hosts", () => { expect(detectSource("https://on.soundcloud.com/abc123")).toBe("soundcloud"); }); +test("detectSource: twitch hosts", () => { + expect(detectSource("https://www.twitch.tv/videos/1234567890")).toBe("twitch"); + expect(detectSource("https://twitch.tv/videos/1234567890")).toBe("twitch"); + expect(detectSource("https://clips.twitch.tv/AdoringFluffyTireTheFrank")).toBe("twitch"); + expect(detectSource("https://www.twitch.tv/channel_name")).toBe("twitch"); +}); + +test("detectSource: generic streaming manifests", () => { + expect(detectSource("https://example.com/live/stream.m3u8")).toBe("generic"); + expect(detectSource("https://cdn.example.com/path/manifest.mpd")).toBe("generic"); + expect(detectSource("https://example.com/stream.m3u8?token=abc")).toBe("generic"); + expect(detectSource("https://example.com/video.mp4")).toBeNull(); + expect(detectSource("https://example.com/live")).toBeNull(); +}); + test("detectSource: rejects other hosts and garbage", () => { expect(detectSource("https://example.com/watch?v=abc")).toBeNull(); expect(detectSource("not a url at all")).toBeNull(); diff --git a/packages/server/src/lib/sources.ts b/packages/server/src/lib/sources.ts index b80f738..086a1e7 100644 --- a/packages/server/src/lib/sources.ts +++ b/packages/server/src/lib/sources.ts @@ -2,10 +2,16 @@ // security gate that keeps yt-dlp from being pointed at an arbitrary host (SSRF); // shell injection is separately closed by spawn() passing argv, never a shell. -export type Source = "youtube" | "soundcloud"; +export type Source = "youtube" | "soundcloud" | "twitch" | "generic"; const YOUTUBE_HOSTS = new Set(["youtube.com", "music.youtube.com", "youtu.be"]); const SOUNDCLOUD_HOSTS = new Set(["soundcloud.com", "m.soundcloud.com", "on.soundcloud.com"]); +const TWITCH_HOSTS = new Set(["twitch.tv", "clips.twitch.tv"]); + +// Streaming manifest patterns — file extension in the path or query string. +// Keeps the SSRF gate tight: only direct .m3u8 (HLS) and .mpd (DASH) URLs +// are accepted as generic sources, not arbitrary hostnames. +const MANIFEST_RE = /\.(m3u8|mpd)(?:[?#]|$)/i; function hostOf(url: string): string | null { try { @@ -15,11 +21,22 @@ function hostOf(url: string): string | null { } } +function isManifestUrl(url: string): boolean { + try { + const u = new URL(url); + return MANIFEST_RE.test(u.pathname + u.search); + } catch { + return false; + } +} + export function detectSource(url: string): Source | null { const host = hostOf(url); if (!host) return null; if (YOUTUBE_HOSTS.has(host)) return "youtube"; if (SOUNDCLOUD_HOSTS.has(host)) return "soundcloud"; + if (TWITCH_HOSTS.has(host)) return "twitch"; + if (isManifestUrl(url)) return "generic"; return null; } diff --git a/packages/web/src/components/SongItem.tsx b/packages/web/src/components/SongItem.tsx index 4e0336d..45f4465 100644 --- a/packages/web/src/components/SongItem.tsx +++ b/packages/web/src/components/SongItem.tsx @@ -4,7 +4,7 @@ import Glyph from "./Glyph"; interface Song { id: number; videoId: string; - source: "youtube" | "soundcloud"; + source: "youtube" | "soundcloud" | "twitch" | "generic"; url: string; title: string | null; uploader: string | null; diff --git a/packages/web/src/pages/Room.tsx b/packages/web/src/pages/Room.tsx index 548f01c..af33c88 100644 --- a/packages/web/src/pages/Room.tsx +++ b/packages/web/src/pages/Room.tsx @@ -8,7 +8,7 @@ import ReticleCorners from "../components/ReticleCorners"; import LyricsPanel from "../components/LyricsPanel"; import { useAuth } from "../hooks/useAuth"; -type Source = "youtube" | "soundcloud"; +type Source = "youtube" | "soundcloud" | "twitch" | "generic"; interface Song { id: number; @@ -522,7 +522,7 @@ export default function Room() { className="flex items-center justify-center gap-1.5 text-[9px] font-mono text-ps-steel-400 hover:text-ps-iris-cyan transition-colors" > - {previewSong.source === "youtube" ? "_open_in_youtube;" : "_open_in_soundcloud;"} + {previewSong.source === "youtube" ? "_open_in_youtube;" : previewSong.source === "soundcloud" ? "_open_in_soundcloud;" : previewSong.source === "twitch" ? "_open_in_twitch;" : "_open_stream;"} ) : ( @@ -642,7 +642,7 @@ export default function Room() { type="text" value={newUrl} onChange={(e) => setNewUrl(e.target.value)} - placeholder="_youtube_or_soundcloud_url;" + placeholder="_youtube_soundcloud_twitch_or_stream_url;" className="flex-1 px-4 py-3 bg-ps-graphite-700 border border-white/10 text-ps-fg-inv-1 placeholder-ps-steel-400 font-mono text-sm tracking-wide focus:outline-none focus:border-ps-iris-rose/40 transition-all duration-120" style={{ transitionTimingFunction: "var(--ps-ease-print)" }} /> @@ -659,7 +659,7 @@ export default function Room() { ) : (
- {(["youtube", "soundcloud"] as const).map((s) => ( + {(["youtube", "soundcloud", "twitch"] as const).map((s) => ( ))}
@@ -679,7 +679,7 @@ export default function Room() { type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - placeholder={searchSource === "youtube" ? "_search_youtube;" : "_search_soundcloud;"} + placeholder={searchSource === "youtube" ? "_search_youtube;" : searchSource === "soundcloud" ? "_search_soundcloud;" : "_search_twitch;"} autoFocus className="flex-1 px-4 py-3 bg-ps-graphite-700 border border-white/10 text-ps-fg-inv-1 placeholder-ps-steel-400 font-mono text-sm tracking-wide focus:outline-none focus:border-ps-iris-cyan/40 transition-all duration-120" style={{ transitionTimingFunction: "var(--ps-ease-print)" }}