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
2 changes: 1 addition & 1 deletion packages/server/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
78 changes: 69 additions & 9 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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 })
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/lib/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 18 additions & 1 deletion packages/server/src/lib/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/SongItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 6 additions & 6 deletions packages/web/src/pages/Room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
>
<Glyph name="reticle" className="w-3 h-3" />
{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;"}
</a>
</div>
) : (
Expand Down Expand Up @@ -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)" }}
/>
Expand All @@ -659,7 +659,7 @@ export default function Room() {
) : (
<div className="space-y-2">
<div className="flex border border-white/10 w-fit">
{(["youtube", "soundcloud"] as const).map((s) => (
{(["youtube", "soundcloud", "twitch"] as const).map((s) => (
<button
key={s}
type="button"
Expand All @@ -670,7 +670,7 @@ export default function Room() {
: "text-ps-steel-400 hover:text-ps-fg-inv-2 bg-transparent"
}`}
>
{s === "youtube" ? "YT" : "SC"}
{s === "youtube" ? "YT" : s === "soundcloud" ? "SC" : "TW"}
</button>
))}
</div>
Expand All @@ -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)" }}
Expand Down
Loading