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"
>