From 0de7f22380f1cbfc0e71b4da2a5288c5dbf1798f Mon Sep 17 00:00:00 2001 From: Nxssie Date: Fri, 3 Jul 2026 13:59:29 +0100 Subject: [PATCH 1/2] feat(source): add mixcloud support - detect mixcloud.com URLs as a new source type - resolve track metadata via yt-dlp --dump-json - handle mixcloud in playNextFromRoomInner title resolution - update UI types, placeholder text, and preview link - update README and slash command descriptions - add mixcloud URL detection tests --- README.md | 6 +-- packages/server/src/commands.ts | 2 +- packages/server/src/index.ts | 58 ++++++++++++++++++++++-- packages/server/src/lib/sources.test.ts | 5 ++ packages/server/src/lib/sources.ts | 4 +- packages/web/src/components/SongItem.tsx | 2 +- packages/web/src/pages/Room.tsx | 6 +-- 7 files changed, 70 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ab4de76..e3ccc50 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ and a Discord bot that joins voice chat to play it back. ## Features - **Vote-ordered queue** — the highest-voted unplayed track plays next; ties break by recency. -- **YouTube & SoundCloud** — single tracks, YouTube playlists, and SoundCloud sets. +- **YouTube & SoundCloud** — single tracks, YouTube playlists, SoundCloud sets, and Mixcloud mixes. - **In-app search** — find tracks on YouTube or SoundCloud without leaving the room. - **Discord OAuth** — JWT sessions in an http-only cookie, revocable on logout via token versioning. - **Discord bot** — slash commands to play and control playback from a voice channel. @@ -119,7 +119,7 @@ unauthenticated and `429` when rate-limited. ### Songs - `GET /api/rooms/:id/songs` — Queue, the caller's votes, presence count, and the currently-streaming song -- `POST /api/rooms/:id/songs` — Add a track or playlist `{ url }` (YouTube or SoundCloud) +- `POST /api/rooms/:id/songs` — Add a track or playlist `{ url }` (YouTube, SoundCloud, Mixcloud, or Twitch) - `DELETE /api/rooms/:id/songs/:songId` — Remove a song (adder or admin) - `POST /api/rooms/:id/songs/:songId/vote` — Cast a vote (one per user per song) - `POST /api/rooms/:id/skip` — Skip the streaming song (owner skips free; otherwise votes ≥ threshold) @@ -142,7 +142,7 @@ unauthenticated and `429` when rate-limited. ### Discord Bot Slash commands (the bot must be invited to the server). `/play` accepts a -YouTube or SoundCloud URL: +YouTube, SoundCloud, Mixcloud, or Twitch URL: - `/play ` — Add a track to the queue and start playing - `/listen` — Join the caller's voice channel and start the queue diff --git a/packages/server/src/commands.ts b/packages/server/src/commands.ts index 59d44c2..c6793a4 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, SoundCloud, or Twitch URL").setRequired(true) + option.setName("url").setDescription("YouTube, SoundCloud, Mixcloud, or Twitch URL").setRequired(true) ), new SlashCommandBuilder() .setName("listen") diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6599adc..3e62d81 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -341,6 +341,45 @@ function resolveSoundcloudTrack(url: string): Promise<{ }); } +// Single yt-dlp round trip for a Mixcloud track: id, title, uploader, and +// thumbnail. Mixcloud pages embed JSON-LD metadata that yt-dlp extracts +// reliably. Returns null on timeout/failure so callers can 400 instead of +// inserting a song with no usable metadata. +function resolveMixcloudTrack(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.creator ?? null, + thumbnail, + }); + } catch { + resolve(null); + } + }); + proc.on("error", () => resolve(null)); + }); +} + // 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. @@ -398,6 +437,7 @@ async function resolveSingleTrack( const { title, uploader } = await getVideoInfo(canonicalUrl); return { videoId, url: canonicalUrl, title, uploader, thumbnail: null }; } + if (source === "mixcloud") return resolveMixcloudTrack(url); if (source === "twitch") return resolveTwitchTrack(url); if (source === "generic") { // Direct streaming manifest — yt-dlp handles HLS/DASH natively. @@ -562,7 +602,7 @@ async function playNextFromRoomInner(roomId: string, guildId: string) { return; } - // Get title if not cached. SoundCloud and Twitch entries carry no title + // Get title if not cached. SoundCloud, Mixcloud, 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) { @@ -576,6 +616,16 @@ async function playNextFromRoomInner(roomId: string, guildId: string) { nextSong.title = info.title; nextSong.url = info.url; } + } else if (nextSong.source === "mixcloud") { + const info = await resolveMixcloudTrack(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 if (nextSong.source === "twitch") { const info = await resolveTwitchTrack(nextSong.url); if (info) { @@ -962,7 +1012,7 @@ app.post("/api/rooms/:id/songs", async (c) => { const { url } = body; const source = detectSource(url); - if (!source) return c.json({ error: "Invalid YouTube, SoundCloud, Twitch, or streaming URL (.m3u8/.mpd)" }, 400); + if (!source) return c.json({ error: "Invalid YouTube, SoundCloud, Mixcloud, Twitch, or streaming URL" }, 400); await ensureRoom(id, user.id); @@ -1549,12 +1599,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, SoundCloud, Twitch, or streaming URL and be in a server"); + await interaction.reply("Provide a YouTube, SoundCloud, Mixcloud, Twitch, or streaming URL and be in a server"); return; } const source = detectSource(url); if (!source) { - await interaction.reply("Invalid YouTube, SoundCloud, Twitch, or streaming URL"); + await interaction.reply("Invalid YouTube, SoundCloud, Mixcloud, Twitch, or streaming URL"); return; } diff --git a/packages/server/src/lib/sources.test.ts b/packages/server/src/lib/sources.test.ts index 4fe9275..c411761 100644 --- a/packages/server/src/lib/sources.test.ts +++ b/packages/server/src/lib/sources.test.ts @@ -13,6 +13,11 @@ test("detectSource: soundcloud hosts", () => { expect(detectSource("https://on.soundcloud.com/abc123")).toBe("soundcloud"); }); +test("detectSource: mixcloud hosts", () => { + expect(detectSource("https://www.mixcloud.com/user/mix-name/")).toBe("mixcloud"); + expect(detectSource("https://mixcloud.com/user/mix-name/")).toBe("mixcloud"); +}); + test("detectSource: twitch hosts", () => { expect(detectSource("https://www.twitch.tv/videos/1234567890")).toBe("twitch"); expect(detectSource("https://twitch.tv/videos/1234567890")).toBe("twitch"); diff --git a/packages/server/src/lib/sources.ts b/packages/server/src/lib/sources.ts index 086a1e7..244ae9e 100644 --- a/packages/server/src/lib/sources.ts +++ b/packages/server/src/lib/sources.ts @@ -2,10 +2,11 @@ // 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" | "twitch" | "generic"; +export type Source = "youtube" | "soundcloud" | "mixcloud" | "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 MIXCLOUD_HOSTS = new Set(["mixcloud.com", "www.mixcloud.com"]); const TWITCH_HOSTS = new Set(["twitch.tv", "clips.twitch.tv"]); // Streaming manifest patterns — file extension in the path or query string. @@ -35,6 +36,7 @@ export function detectSource(url: string): Source | null { if (!host) return null; if (YOUTUBE_HOSTS.has(host)) return "youtube"; if (SOUNDCLOUD_HOSTS.has(host)) return "soundcloud"; + if (MIXCLOUD_HOSTS.has(host)) return "mixcloud"; 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 45f4465..7ea89e1 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" | "twitch" | "generic"; + source: "youtube" | "soundcloud" | "mixcloud" | "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 af33c88..01fe974 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" | "twitch" | "generic"; +type Source = "youtube" | "soundcloud" | "mixcloud" | "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;" : previewSong.source === "soundcloud" ? "_open_in_soundcloud;" : previewSong.source === "twitch" ? "_open_in_twitch;" : "_open_stream;"} + {previewSong.source === "youtube" ? "_open_in_youtube;" : previewSong.source === "soundcloud" ? "_open_in_soundcloud;" : previewSong.source === "mixcloud" ? "_open_in_mixcloud;" : 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_soundcloud_twitch_or_stream_url;" + placeholder="_youtube_soundcloud_mixcloud_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)" }} /> From 4fa654d430bb02e7e785b0b5db97743f29e974b7 Mon Sep 17 00:00:00 2001 From: Nxssie Date: Fri, 3 Jul 2026 13:59:36 +0100 Subject: [PATCH 2/2] fix(audio): strip video from ffmpeg output for live streams - add -vn flag to prevent video tracks from leaking into OggOpus output - live Twitch/HLS streams include both audio and video in the manifest - @discordjs/voice expects audio-only OggOpus, video caused immediate Idle --- packages/server/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3e62d81..7aa00e3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -240,6 +240,7 @@ function createAudioStream(url: string): { ffmpegStatic!, [ "-i", "pipe:0", + "-vn", "-c:a", "libopus", "-ar", "48000", "-ac", "2",