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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 <url>` — Add a track to the queue and start playing
- `/listen` — Join the caller's voice channel and start the queue
Expand Down
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, SoundCloud, or Twitch URL").setRequired(true)
option.setName("url").setDescription("YouTube, SoundCloud, Mixcloud, or Twitch URL").setRequired(true)
),
new SlashCommandBuilder()
.setName("listen")
Expand Down
59 changes: 55 additions & 4 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ function createAudioStream(url: string): {
ffmpegStatic!,
[
"-i", "pipe:0",
"-vn",
"-c:a", "libopus",
"-ar", "48000",
"-ac", "2",
Expand Down Expand Up @@ -341,6 +342,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.
Expand Down Expand Up @@ -398,6 +438,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.
Expand Down Expand Up @@ -562,7 +603,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) {
Expand All @@ -576,6 +617,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) {
Expand Down Expand Up @@ -962,7 +1013,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);

Expand Down Expand Up @@ -1549,12 +1600,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;
}

Expand Down
5 changes: 5 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,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");
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/lib/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
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" | "twitch" | "generic";
source: "youtube" | "soundcloud" | "mixcloud" | "twitch" | "generic";
url: string;
title: string | null;
uploader: string | null;
Expand Down
6 changes: 3 additions & 3 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" | "twitch" | "generic";
type Source = "youtube" | "soundcloud" | "mixcloud" | "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;" : 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;"}
</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_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)" }}
/>
Expand Down
Loading