From d50ce8698f585e476ca484c24f332781c448deb3 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 16 May 2026 15:48:28 +0700 Subject: [PATCH] feat: implement media echo fix and YouTube screenshare design - Introduced a new `ScreenShareController` to manage YouTube screenshare functionality. - Updated `DiscordPlayer` to track ownership of audio streams, preventing conflicts between music playback and screenshare. - Added error handling for various states including voice connection checks and media busy states. - Created unit tests for `ScreenShareController` and `DiscordPlayer` ownership rules to ensure correct functionality. - Added documentation for the new media echo fix and screenshare design. --- .../2026-05-16-media-echo-screenshare.md | 1427 +++++++++++++++++ ...026-05-16-media-echo-screenshare-design.md | 71 + src/media/mediaController.ts | 74 +- src/media/mediaQueue.ts | 9 +- src/media/mediaTypes.ts | 27 +- src/media/musicPlayer.ts | 22 +- src/media/screenShareController.ts | 123 ++ src/media/ytdlp.ts | 14 + src/moderation/aiAnalyzer.ts | 6 +- src/player.ts | 38 +- src/recorder.ts | 2 + src/routes/mediaRoutes.ts | 11 +- src/webserver.ts | 40 +- tests/media/mediaController.test.ts | 63 +- tests/media/mediaQueue.test.ts | 12 +- tests/media/musicPlayer.test.ts | 58 +- tests/media/screenShareController.test.ts | 94 ++ tests/media/ytdlp.test.ts | 42 +- tests/player.test.ts | 82 + tests/recorder.test.ts | 49 + tests/routes/mediaRoutes.test.ts | 71 +- 21 files changed, 2284 insertions(+), 51 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-16-media-echo-screenshare.md create mode 100644 docs/superpowers/specs/2026-05-16-media-echo-screenshare-design.md create mode 100644 src/media/screenShareController.ts create mode 100644 tests/media/screenShareController.test.ts create mode 100644 tests/player.test.ts create mode 100644 tests/recorder.test.ts diff --git a/docs/superpowers/plans/2026-05-16-media-echo-screenshare.md b/docs/superpowers/plans/2026-05-16-media-echo-screenshare.md new file mode 100644 index 0000000..f63e79d --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-media-echo-screenshare.md @@ -0,0 +1,1427 @@ +# Media Echo Fix and YouTube Screenshare Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent media playback echo by making audio player ownership explicit, then add a YouTube Go Live screenshare path through the existing media API. + +**Architecture:** `DiscordPlayer` becomes the ownership gate for the shared Discord voice audio player. Music playback claims `music`, browser bridge claims `browser-bridge`, and screenshare uses a new `ScreenShareController` with `@dank074/discord-video-stream` while coordinating busy state through `MediaController`. + +**Tech Stack:** TypeScript, Vitest, Express, `@discordjs/voice`, `prism-media`, `yt-dlp`, `@dank074/discord-video-stream`. + +--- + +## File Structure + +- Modify `src/player.ts`: add player owner types and claim/release behavior while preserving pause/unpause/status APIs. +- Modify `src/media/mediaTypes.ts`: add `MediaMode`, queue mode options, screen controller interfaces, and owner-aware `DiscordAudioPlayer` methods. +- Modify `src/media/musicPlayer.ts`: claim `music` ownership before starting ffmpeg output and release ownership on stop or normal close. +- Modify `src/webserver.ts`: lazily start browser bridge and make it claim `browser-bridge` only when no media owner is active. +- Modify `src/media/mediaQueue.ts`: accept a mode argument instead of hardcoding `music`. +- Modify `src/media/mediaController.ts`: route `mode: "screen"` to the new screen controller and enforce busy-state rules. +- Modify `src/routes/mediaRoutes.ts`: parse optional `mode` from POST body and pass it to controller. +- Modify `src/media/ytdlp.ts`: add `getDirectVideoUrl` for screenshare-friendly direct media URLs. +- Create `src/media/screenShareController.ts`: encapsulate Go Live lifecycle and dependency injection for tests. +- Create `tests/player.test.ts`: test ownership behavior. +- Modify `tests/media/musicPlayer.test.ts`: update mocks for ownership methods and verify release behavior. +- Modify `tests/media/mediaController.test.ts`: cover mode routing and busy conflicts. +- Modify `tests/routes/mediaRoutes.test.ts`: cover mode body parsing. +- Modify `tests/media/ytdlp.test.ts`: cover direct video URL command. +- Create `tests/media/screenShareController.test.ts`: test screenshare lifecycle with mocked dependencies. + +--- + +### Task 1: Add DiscordPlayer Ownership + +**Files:** +- Modify: `src/player.ts` +- Modify: `src/media/mediaTypes.ts` +- Create: `tests/player.test.ts` + +- [ ] **Step 1: Write the failing ownership tests** + +Create `tests/player.test.ts`: + +```ts +import { PassThrough } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const audioPlayer = { + on: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + unpause: vi.fn(() => true), + stop: vi.fn(), + state: { status: "idle" }, +}; + +vi.mock("@discordjs/voice", () => ({ + AudioPlayerStatus: { Idle: "idle", Playing: "playing" }, + StreamType: { OggOpus: "ogg/opus" }, + createAudioPlayer: vi.fn(() => audioPlayer), + createAudioResource: vi.fn((stream, options) => ({ stream, options })), +})); + +describe("DiscordPlayer ownership", () => { + beforeEach(() => { + vi.clearAllMocks(); + audioPlayer.state.status = "idle"; + }); + + it("prevents browser bridge from overriding music playback", async () => { + const { DiscordPlayer } = await import("../src/player"); + const player = new DiscordPlayer(); + + player.playStream(new PassThrough(), "music"); + + expect(() => player.playStream(new PassThrough(), "browser-bridge")).toThrow( + "Discord audio player is owned by music", + ); + expect(audioPlayer.play).toHaveBeenCalledTimes(1); + expect(player.getOwner()).toBe("music"); + }); + + it("allows the current owner to replace its own stream", async () => { + const { DiscordPlayer } = await import("../src/player"); + const player = new DiscordPlayer(); + + player.playStream(new PassThrough(), "browser-bridge"); + player.playStream(new PassThrough(), "browser-bridge"); + + expect(audioPlayer.play).toHaveBeenCalledTimes(2); + expect(player.getOwner()).toBe("browser-bridge"); + }); + + it("releases ownership when the owner stops playback", async () => { + const { DiscordPlayer } = await import("../src/player"); + const player = new DiscordPlayer(); + + player.playStream(new PassThrough(), "music"); + player.stop("music"); + + expect(audioPlayer.stop).toHaveBeenCalledTimes(1); + expect(player.getOwner()).toBe("none"); + }); + + it("ignores stop calls from non-owners", async () => { + const { DiscordPlayer } = await import("../src/player"); + const player = new DiscordPlayer(); + + player.playStream(new PassThrough(), "music"); + player.stop("browser-bridge"); + + expect(audioPlayer.stop).not.toHaveBeenCalled(); + expect(player.getOwner()).toBe("music"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm exec vitest run tests/player.test.ts +``` + +Expected: FAIL with TypeScript/runtime errors because `playStream` does not accept an owner and `getOwner` does not exist. + +- [ ] **Step 3: Add owner types** + +Modify `src/media/mediaTypes.ts`: + +```ts +import type { Readable } from "node:stream"; + +export type MediaMode = "music" | "screen"; +export type DiscordPlayerOwner = "none" | "browser-bridge" | MediaMode; +export type MediaSourceKind = + | "url" + | "local" + | "youtube" + | "spotify" + | "search"; +export type MediaQueueItemStatus = "queued" | "playing" | "failed"; + +export interface ResolvedMediaSource { + source: string; + title: string; + kind: MediaSourceKind; +} + +export interface QueueMediaOptions { + mode?: MediaMode; + requestedBy?: string; +} + +export interface MediaQueueItem extends ResolvedMediaSource { + id: string; + mode: MediaMode; + requestedBy: string; + addedAt: number; + status: MediaQueueItemStatus; +} + +export interface MediaState { + playing: boolean; + activeMode: MediaMode | null; + current: MediaQueueItem | null; + queue: MediaQueueItem[]; +} + +export interface MusicPlayback { + done: Promise; + stop(): void; +} + +export interface MusicPlayer { + play(source: ResolvedMediaSource): MusicPlayback; +} + +export interface ScreenSharePlayback { + done: Promise; + stop(): void; +} + +export interface ScreenShareController { + isActive(): boolean; + start(source: string): Promise; +} + +export interface DiscordAudioPlayer { + getOwner(): DiscordPlayerOwner; + isConnected(): boolean; + playStream(stream: Readable, owner: DiscordPlayerOwner): void; + pause(owner?: DiscordPlayerOwner): void; + unpause(owner?: DiscordPlayerOwner): boolean; + stop(owner?: DiscordPlayerOwner): void; +} +``` + +- [ ] **Step 4: Implement ownership in DiscordPlayer** + +Replace `src/player.ts` with: + +```ts +import { Readable } from "node:stream"; +import { + AudioPlayer, + AudioPlayerStatus, + createAudioPlayer, + createAudioResource, + StreamType, + VoiceConnection, +} from "@discordjs/voice"; +import type { DiscordPlayerOwner } from "./media/mediaTypes"; + +export class DiscordPlayer { + private player: AudioPlayer; + private connection: VoiceConnection | null = null; + private owner: DiscordPlayerOwner = "none"; + + constructor() { + this.player = createAudioPlayer(); + + this.player.on(AudioPlayerStatus.Playing, () => { + console.log("[player] Audio player is now playing!"); + }); + + this.player.on("error", (error) => { + console.error(`[player] Error: ${error.message}`); + this.owner = "none"; + }); + } + + public setConnection(connection: VoiceConnection) { + this.connection = connection; + this.connection.subscribe(this.player); + } + + public getOwner(): DiscordPlayerOwner { + return this.owner; + } + + public isConnected(): boolean { + return this.connection !== null; + } + + public playStream(stream: Readable, owner: DiscordPlayerOwner) { + if (owner === "none") { + throw new Error("Discord audio player owner is required"); + } + this.assertOwnerAvailable(owner); + console.log("[player] Starting new audio stream..."); + + const resource = createAudioResource(stream, { + inputType: StreamType.OggOpus, + }); + + this.owner = owner; + this.player.play(resource); + this.connection?.subscribe(this.player); + } + + public getStatus(): AudioPlayerStatus { + return this.player.state.status; + } + + public pause(owner?: DiscordPlayerOwner) { + if (!this.canControl(owner)) return; + this.player.pause(true); + } + + public unpause(owner?: DiscordPlayerOwner): boolean { + if (!this.canControl(owner)) return false; + return this.player.unpause(); + } + + public stop(owner?: DiscordPlayerOwner) { + if (!this.canControl(owner)) return; + this.player.stop(); + this.owner = "none"; + } + + private assertOwnerAvailable(owner: DiscordPlayerOwner): void { + if (this.owner !== "none" && this.owner !== owner) { + throw new Error(`Discord audio player is owned by ${this.owner}`); + } + } + + private canControl(owner?: DiscordPlayerOwner): boolean { + return !owner || this.owner === "none" || this.owner === owner; + } +} + +export const discordPlayer = new DiscordPlayer(); +``` + +- [ ] **Step 5: Run ownership tests** + +Run: + +```bash +pnpm exec vitest run tests/player.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 1** + +Run: + +```bash +git add src/player.ts src/media/mediaTypes.ts tests/player.test.ts +git commit -m "feat: add discord player ownership" +``` + +--- + +### Task 2: Make Music Playback Claim Ownership + +**Files:** +- Modify: `src/media/musicPlayer.ts` +- Modify: `tests/media/musicPlayer.test.ts` + +- [ ] **Step 1: Update failing music player tests** + +In `tests/media/musicPlayer.test.ts`, update all fake `DiscordAudioPlayer` objects to include `getOwner`, owner-aware methods, and assertions: + +```ts +const discordPlayer: DiscordAudioPlayer = { + getOwner: () => "none", + isConnected: () => true, + playStream: vi.fn(), + pause: vi.fn(), + unpause: vi.fn(() => true), + stop: vi.fn(), +}; +``` + +Change the first test assertion to: + +```ts +expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music"); +``` + +Change the stop assertion to: + +```ts +expect(discordPlayer.stop).toHaveBeenCalledWith("music"); +``` + +Add this test before the closing `});`: + +```ts +it("releases music ownership when ffmpeg exits normally", async () => { + const proc = new FakeProcess(); + const discordPlayer: DiscordAudioPlayer = { + getOwner: () => "none", + isConnected: () => true, + playStream: vi.fn(), + pause: vi.fn(), + unpause: vi.fn(() => true), + stop: vi.fn(), + }; + const player = createMusicPlayer({ + spawn: vi.fn(() => proc), + discordPlayer, + }); + + const playback = player.play({ + source: "/tmp/song.ogg", + title: "song.ogg", + kind: "local", + }); + proc.emit("close", 0); + await playback.done; + + expect(discordPlayer.stop).toHaveBeenCalledWith("music"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm exec vitest run tests/media/musicPlayer.test.ts +``` + +Expected: FAIL because `createMusicPlayer` still calls `playStream(proc.stdout)` and does not release ownership on normal close. + +- [ ] **Step 3: Update music player ownership** + +Modify `src/media/musicPlayer.ts` so the `play` body uses owner-aware methods: + +```ts +play(source: ResolvedMediaSource): MusicPlayback { + if (!audioPlayer.isConnected()) { + throw new Error("Discord audio player is not connected"); + } + + const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), { + stdio: ["ignore", "pipe", "pipe"], + }) as unknown as ChildProcessWithoutNullStreams; + proc.stderr.resume(); + + audioPlayer.playStream(proc.stdout, "music"); + + let stopped = false; + let released = false; + const release = () => { + if (released) return; + released = true; + audioPlayer.stop("music"); + }; + + const done = new Promise((resolve, reject) => { + proc.on("error", (error) => { + release(); + reject(error); + }); + proc.stdout.on("error", (error) => { + release(); + reject(error); + }); + proc.on("close", (code) => { + release(); + if (code === 0 || stopped) { + resolve(); + return; + } + reject(new Error(`ffmpeg exited with code ${code}`)); + }); + }); + + return { + done, + stop() { + if (stopped) return; + stopped = true; + proc.kill("SIGTERM"); + release(); + }, + }; +}, +``` + +- [ ] **Step 4: Run music player tests** + +Run: + +```bash +pnpm exec vitest run tests/media/musicPlayer.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 2** + +Run: + +```bash +git add src/media/musicPlayer.ts tests/media/musicPlayer.test.ts +git commit -m "fix: claim music playback ownership" +``` + +--- + +### Task 3: Make Browser Audio Bridge Lazy and Owner-Aware + +**Files:** +- Modify: `src/webserver.ts:282-412` + +- [ ] **Step 1: Update bridge start/control logic** + +In `src/webserver.ts`, remove the eager call: + +```ts +startBrowserAudioBridge(); +``` + +Replace `startBrowserAudioBridge` and `ensureBrowserAudioBridge` with: + +```ts +function startBrowserAudioBridge(): void { + opusEncoder = new prism.opus.Encoder({ + rate: RATE, + channels: CHANNELS, + frameSize: FRAME_SIZE, + }); + const oggBitstream = new prism.opus.OggLogicalBitstream({ + opusHead: new prism.opus.OpusHead({ + channelCount: CHANNELS, + sampleRate: RATE, + }), + pageSizeControl: { maxPackets: 1 }, + crc: true, + }); + opusEncoder.on("error", () => {}); + opusEncoder.pipe(oggBitstream); + opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0)); + discordPlayer.playStream(oggBitstream, "browser-bridge"); + discordPlayer.pause("browser-bridge"); + bridgePlayerPaused = true; +} + +function ensureBrowserAudioBridge(): boolean { + const owner = discordPlayer.getOwner(); + if (owner !== "none" && owner !== "browser-bridge") return false; + if (owner === "none" || discordPlayer.getStatus() === AudioPlayerStatus.Idle) { + startBrowserAudioBridge(); + } + return true; +} +``` + +In the 20ms interval, replace: + +```ts +ensureBrowserAudioBridge(); +if (bridgePlayerPaused) { + const unpaused = discordPlayer.unpause(); +``` + +with: + +```ts +if (!ensureBrowserAudioBridge()) { + pcmBuffer = Buffer.alloc(0); + return; +} +if (bridgePlayerPaused) { + const unpaused = discordPlayer.unpause("browser-bridge"); +``` + +Replace: + +```ts +discordPlayer.pause(); +``` + +with: + +```ts +discordPlayer.pause("browser-bridge"); +``` + +- [ ] **Step 2: Run typecheck** + +Run: + +```bash +pnpm run typecheck +``` + +Expected: PASS. If it fails because `opusEncoder` may be used before assignment, change its declaration to `let opusEncoder: prism.opus.Encoder | null = null;` and write with `opusEncoder?.write(frame)` guarded by `if (!opusEncoder) return;`. + +- [ ] **Step 3: Commit Task 3** + +Run: + +```bash +git add src/webserver.ts +git commit -m "fix: isolate browser audio bridge ownership" +``` + +--- + +### Task 4: Add Media Mode Parsing and Queue Support + +**Files:** +- Modify: `src/media/mediaQueue.ts` +- Modify: `src/media/mediaController.ts` +- Modify: `src/routes/mediaRoutes.ts` +- Modify: `tests/media/mediaController.test.ts` +- Modify: `tests/routes/mediaRoutes.test.ts` + +- [ ] **Step 1: Write failing route test for mode** + +In `tests/routes/mediaRoutes.test.ts`, change the existing queue assertion to default mode: + +```ts +expect(controller.queue).toHaveBeenCalledWith("https://example.com/song.mp3", { + mode: "music", +}); +``` + +Add this test: + +```ts +it("queues a screen source", async () => { + const state = { playing: true, activeMode: "screen" as const, current: null, queue: [] }; + const controller = { + getState: vi.fn(), + queue: vi.fn(async () => state), + skip: vi.fn(), + stop: vi.fn(), + }; + const handler = getHandler( + createMediaRoutes(controller), + "/media/queue", + "post", + ); + const json = vi.fn(); + + await handler?.( + { body: { source: "https://youtu.be/video", mode: "screen" } } as Request, + { json } as unknown as Response, + vi.fn(), + ); + + expect(controller.queue).toHaveBeenCalledWith("https://youtu.be/video", { + mode: "screen", + }); + expect(json).toHaveBeenCalledWith(state); +}); +``` + +- [ ] **Step 2: Run route test to verify it fails** + +Run: + +```bash +pnpm exec vitest run tests/routes/mediaRoutes.test.ts +``` + +Expected: FAIL because route does not pass mode/options. + +- [ ] **Step 3: Update media route parsing** + +Replace `MediaRouteController` and queue handler in `src/routes/mediaRoutes.ts` with: + +```ts +export type MediaRouteController = Pick< + MediaController, + "getState" | "queue" | "skip" | "stop" +>; + +type MediaQueueBody = { + source?: string; + mode?: "music" | "screen"; +}; +``` + +```ts +router.post("/media/queue", async (req, res, next) => { + try { + const { source, mode = "music" } = req.body as MediaQueueBody; + if (!source) { + throw new AppError( + "Media source is required", + "MISSING_MEDIA_SOURCE", + 400, + ); + } + if (mode !== "music" && mode !== "screen") { + throw new AppError("Media mode is invalid", "INVALID_MEDIA_MODE", 400); + } + res.json(await controller.queue(source, { mode })); + } catch (error) { + next(error); + } +}); +``` + +- [ ] **Step 4: Update MediaQueue mode support** + +Replace `add` in `src/media/mediaQueue.ts` with: + +```ts +add( + source: ResolvedMediaSource, + mode: MediaQueueItem["mode"] = "music", + requestedBy = "dashboard", +): MediaQueueItem { + const item: MediaQueueItem = { + id: this.createId(), + mode, + requestedBy, + addedAt: this.now(), + status: "queued", + ...source, + }; + this.items.push(item); + return { ...item }; +} +``` + +- [ ] **Step 5: Update MediaController state and queue signature** + +In `src/media/mediaController.ts`, import `QueueMediaOptions` and update `getState` and `queue`: + +```ts +import type { + MediaState, + MusicPlayback, + MusicPlayer, + QueueMediaOptions, + ResolvedMediaSource, +} from "./mediaTypes"; +``` + +```ts +getState(): MediaState { + const snapshot = this.queueStore.snapshot(); + return { + playing: snapshot.current?.status === "playing", + activeMode: snapshot.current?.mode ?? null, + ...snapshot, + }; +} + +async queue( + source: string, + options: QueueMediaOptions = {}, +): Promise { + const mode = options.mode ?? "music"; + this.assertCanStart(); + const resolved = await ( + this.dependencies.resolveMediaSource ?? resolveMediaSource + )(source); + this.queueStore.add(resolved, mode, options.requestedBy); + this.startNextIfIdle(); + return this.emitState(); +} +``` + +- [ ] **Step 6: Update affected media controller expectations** + +In `tests/media/mediaController.test.ts`, update state equality in the stop test to: + +```ts +expect(state).toEqual({ + playing: false, + activeMode: null, + current: null, + queue: [], +}); +``` + +No other expectations need full state equality. + +- [ ] **Step 7: Run route and media controller tests** + +Run: + +```bash +pnpm exec vitest run tests/routes/mediaRoutes.test.ts tests/media/mediaController.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 4** + +Run: + +```bash +git add src/media/mediaQueue.ts src/media/mediaController.ts src/routes/mediaRoutes.ts tests/media/mediaController.test.ts tests/routes/mediaRoutes.test.ts +git commit -m "feat: add media mode routing" +``` + +--- + +### Task 5: Add yt-dlp Direct Video URL Support + +**Files:** +- Modify: `src/media/ytdlp.ts` +- Modify: `tests/media/ytdlp.test.ts` + +- [ ] **Step 1: Write failing yt-dlp test** + +In `tests/media/ytdlp.test.ts`, add: + +```ts +it("gets a direct video URL", async () => { + const spawn = createSpawn("https://cdn.example.com/video.mp4\n"); + const ytdlp = createYtDlp({ spawn }); + + const result = await ytdlp.getDirectVideoUrl("https://youtu.be/video"); + + expect(result).toBe("https://cdn.example.com/video.mp4"); + expect(spawn).toHaveBeenCalledWith( + "yt-dlp", + [ + "https://youtu.be/video", + "--get-url", + "--format", + "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", + "--no-playlist", + "--no-warnings", + "--quiet", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm exec vitest run tests/media/ytdlp.test.ts +``` + +Expected: FAIL because `getDirectVideoUrl` does not exist. + +- [ ] **Step 3: Add direct video method** + +In `src/media/ytdlp.ts`, update `YtDlpClient`: + +```ts +export interface YtDlpClient { + getMetadata(url: string): Promise; + getDirectAudioUrl(url: string): Promise; + getDirectVideoUrl(url: string): Promise; +} +``` + +Add this method after `getDirectAudioUrl`: + +```ts +async getDirectVideoUrl(url: string): Promise { + const value = await runYtDlp(spawn, [ + url, + "--get-url", + "--format", + "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", + "--no-playlist", + "--no-warnings", + "--quiet", + ]); + return value.trim().split("\n")[0] || url; +}, +``` + +- [ ] **Step 4: Run yt-dlp tests** + +Run: + +```bash +pnpm exec vitest run tests/media/ytdlp.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 5** + +Run: + +```bash +git add src/media/ytdlp.ts tests/media/ytdlp.test.ts +git commit -m "feat: resolve direct video urls" +``` + +--- + +### Task 6: Add ScreenShareController + +**Files:** +- Create: `src/media/screenShareController.ts` +- Create: `tests/media/screenShareController.test.ts` + +- [ ] **Step 1: Write failing screenshare controller tests** + +Create `tests/media/screenShareController.test.ts`: + +```ts +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { AppError } from "../../src/errors"; +import { createScreenShareController } from "../../src/media/screenShareController"; + +function createDependencies() { + const output = new PassThrough(); + return { + getVoiceStatus: vi.fn(() => ({ + connected: true, + activeGuildId: "guild-1", + activeChannelId: "channel-1", + })), + getPlayerOwner: vi.fn(() => "none" as const), + getDirectVideoUrl: vi.fn(async () => "https://cdn.example.com/video.mp4"), + prepareStream: vi.fn(() => ({ command: { on: vi.fn(), kill: vi.fn() }, output })), + playStream: vi.fn(async () => undefined), + streamer: { id: "streamer" }, + }; +} + +describe("createScreenShareController", () => { + it("starts a YouTube Go Live stream", async () => { + const dependencies = createDependencies(); + const controller = createScreenShareController(dependencies); + + const playback = await controller.start("https://youtu.be/video"); + + expect(dependencies.getDirectVideoUrl).toHaveBeenCalledWith( + "https://youtu.be/video", + ); + expect(dependencies.prepareStream).toHaveBeenCalledWith( + "https://cdn.example.com/video.mp4", + expect.objectContaining({ includeAudio: true }), + ); + expect(dependencies.playStream).toHaveBeenCalledWith( + dependencies.prepareStream.mock.results[0].value.output, + dependencies.streamer, + { type: "go-live" }, + ); + expect(controller.isActive()).toBe(true); + playback.stop(); + expect(controller.isActive()).toBe(false); + }); + + it("rejects when voice is not connected", async () => { + const dependencies = createDependencies(); + dependencies.getVoiceStatus.mockReturnValue({ + connected: false, + activeGuildId: null, + activeChannelId: null, + }); + const controller = createScreenShareController(dependencies); + + await expect(controller.start("https://youtu.be/video")).rejects.toMatchObject({ + code: "VOICE_NOT_CONNECTED", + statusCode: 409, + } satisfies Partial); + }); + + it("rejects when music owns the shared player", async () => { + const dependencies = createDependencies(); + dependencies.getPlayerOwner.mockReturnValue("music"); + const controller = createScreenShareController(dependencies); + + await expect(controller.start("https://youtu.be/video")).rejects.toMatchObject({ + code: "MEDIA_BUSY", + statusCode: 409, + } satisfies Partial); + }); + + it("wraps stream startup failures", async () => { + const dependencies = createDependencies(); + dependencies.playStream.mockRejectedValue(new Error("go live failed")); + const controller = createScreenShareController(dependencies); + + await expect(controller.start("https://youtu.be/video")).rejects.toMatchObject({ + code: "SCREEN_STREAM_FAILED", + statusCode: 500, + } satisfies Partial); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm exec vitest run tests/media/screenShareController.test.ts +``` + +Expected: FAIL because `src/media/screenShareController.ts` does not exist. + +- [ ] **Step 3: Implement ScreenShareController** + +Create `src/media/screenShareController.ts`: + +```ts +import { + Encoders, + playStream as defaultPlayStream, + prepareStream as defaultPrepareStream, + Utils, +} from "@dank074/discord-video-stream"; +import { AppError } from "../errors"; +import { discordPlayer } from "../player"; +import type { + DiscordPlayerOwner, + ScreenSharePlayback, +} from "./mediaTypes"; +import { createYtDlp } from "./ytdlp"; + +export interface ScreenShareVoiceStatus { + connected: boolean; + activeGuildId: string | null; + activeChannelId: string | null; +} + +export interface ScreenShareControllerDependencies { + getVoiceStatus: () => ScreenShareVoiceStatus; + getPlayerOwner?: () => DiscordPlayerOwner; + getDirectVideoUrl?: (source: string) => Promise; + prepareStream?: typeof defaultPrepareStream; + playStream?: typeof defaultPlayStream; + streamer: unknown; +} + +export function createScreenShareController( + dependencies: ScreenShareControllerDependencies, +) { + let active: ScreenSharePlayback | null = null; + const ytdlp = createYtDlp(); + const getPlayerOwner = + dependencies.getPlayerOwner ?? (() => discordPlayer.getOwner()); + const getDirectVideoUrl = + dependencies.getDirectVideoUrl ?? ((source) => ytdlp.getDirectVideoUrl(source)); + const prepareStream = dependencies.prepareStream ?? defaultPrepareStream; + const playStream = dependencies.playStream ?? defaultPlayStream; + + return { + isActive(): boolean { + return active !== null; + }, + + async start(source: string): Promise { + const status = dependencies.getVoiceStatus(); + if (!status.connected || !status.activeGuildId || !status.activeChannelId) { + throw new AppError( + "Connect to a voice channel before sharing screen", + "VOICE_NOT_CONNECTED", + 409, + ); + } + + if (active || getPlayerOwner() !== "none") { + throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); + } + + try { + const directUrl = await getDirectVideoUrl(source); + const { command, output } = prepareStream(directUrl, { + encoder: Encoders.software({ + x264: { preset: "superfast" }, + }), + height: 720, + fps: 30, + bitrateVideo: 2500, + bitrateVideoMax: 4000, + includeAudio: true, + videoCodec: Utils.normalizeVideoCodec("H264"), + }); + + let stopped = false; + const done = playStream(output, dependencies.streamer, { + type: "go-live", + }).finally(() => { + active = null; + }); + + active = { + done, + stop() { + if (stopped) return; + stopped = true; + command.kill?.("SIGTERM"); + active = null; + }, + }; + return active; + } catch (error) { + active = null; + throw new AppError( + error instanceof Error ? error.message : "Screen stream failed", + "SCREEN_STREAM_FAILED", + 500, + ); + } + }, + }; +} +``` + +- [ ] **Step 4: Run screenshare controller tests** + +Run: + +```bash +pnpm exec vitest run tests/media/screenShareController.test.ts +``` + +Expected: PASS. If TypeScript rejects the `streamer: unknown` type, replace it with `streamer: Parameters[1]` and cast the fake streamer in the test with `as Parameters[1]`. + +- [ ] **Step 5: Commit Task 6** + +Run: + +```bash +git add src/media/screenShareController.ts tests/media/screenShareController.test.ts +git commit -m "feat: add youtube screenshare controller" +``` + +--- + +### Task 7: Wire Screen Mode into MediaController and Webserver + +**Files:** +- Modify: `src/media/mediaController.ts` +- Modify: `src/webserver.ts` +- Modify: `tests/media/mediaController.test.ts` + +- [ ] **Step 1: Write failing MediaController screen tests** + +In `tests/media/mediaController.test.ts`, update imports: + +```ts +import type { + MusicPlayback, + MusicPlayer, + ResolvedMediaSource, + ScreenShareController, +} from "../../src/media/mediaTypes"; +``` + +Add tests before `emits state changes`: + +```ts +it("starts screen share mode without resolving music source", async () => { + const screenPlayback = deferred(); + const screenController: ScreenShareController = { + isActive: vi.fn(() => false), + start: vi.fn(async () => ({ done: screenPlayback.promise, stop: vi.fn() })), + }; + const resolveMediaSource = vi.fn(async (input) => source(input)); + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource, + musicPlayer: { play: vi.fn() }, + screenController, + }); + + const state = await controller.queue("https://youtu.be/video", { mode: "screen" }); + + expect(screenController.start).toHaveBeenCalledWith("https://youtu.be/video"); + expect(resolveMediaSource).not.toHaveBeenCalled(); + expect(state).toMatchObject({ playing: true, activeMode: "screen" }); +}); + +it("rejects music while screen share is active", async () => { + const screenController: ScreenShareController = { + isActive: vi.fn(() => true), + start: vi.fn(), + }; + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async (input) => source(input), + musicPlayer: { play: vi.fn() }, + screenController, + }); + + await expect(controller.queue("https://example.com/song.mp3")).rejects.toMatchObject({ + code: "MEDIA_BUSY", + statusCode: 409, + } satisfies Partial); +}); +``` + +- [ ] **Step 2: Run controller tests to verify failure** + +Run: + +```bash +pnpm exec vitest run tests/media/mediaController.test.ts +``` + +Expected: FAIL because `screenController` dependency and screen mode are not implemented. + +- [ ] **Step 3: Add screen dependency and state to MediaController** + +In `src/media/mediaController.ts`, update imports: + +```ts +import type { + MediaMode, + MediaState, + MusicPlayback, + MusicPlayer, + QueueMediaOptions, + ResolvedMediaSource, + ScreenShareController, + ScreenSharePlayback, +} from "./mediaTypes"; +``` + +Update dependencies: + +```ts +export interface MediaControllerDependencies { + isVoiceConnected?: () => boolean; + isBrowserStreaming?: () => boolean; + resolveMediaSource?: (source: string) => Promise; + musicPlayer?: MusicPlayer; + screenController?: ScreenShareController; + onStateChange?: (state: MediaState) => void; +} +``` + +Add properties: + +```ts +private screenPlayback: ScreenSharePlayback | null = null; +private activeMode: MediaMode | null = null; +``` + +Update `getState`: + +```ts +getState(): MediaState { + const snapshot = this.queueStore.snapshot(); + return { + playing: this.activeMode === "screen" || snapshot.current?.status === "playing", + activeMode: this.activeMode ?? snapshot.current?.mode ?? null, + ...snapshot, + }; +} +``` + +Replace `queue` with: + +```ts +async queue( + source: string, + options: QueueMediaOptions = {}, +): Promise { + const mode = options.mode ?? "music"; + if (mode === "screen") { + return this.startScreen(source); + } + + this.assertCanStartMusic(); + const resolved = await ( + this.dependencies.resolveMediaSource ?? resolveMediaSource + )(source); + this.queueStore.add(resolved, mode, options.requestedBy); + this.startNextIfIdle(); + return this.emitState(); +} +``` + +Rename `assertCanStart` to `assertCanStartMusic` and add screen busy check: + +```ts +private assertCanStartMusic(): void { + const isVoiceConnected = + this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected()); + if (!isVoiceConnected()) { + throw new AppError( + "Connect to a voice channel before playing media", + "VOICE_NOT_CONNECTED", + 409, + ); + } + + if (this.screenPlayback || this.dependencies.screenController?.isActive()) { + throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); + } + + if (this.dependencies.isBrowserStreaming?.()) { + throw new AppError( + "Stop browser microphone streaming before playing media", + "BROWSER_STREAM_ACTIVE", + 409, + ); + } +} +``` + +Add `startScreen`: + +```ts +private async startScreen(source: string): Promise { + if (this.playback || this.queueStore.snapshot().current) { + throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); + } + const screenController = this.dependencies.screenController; + if (!screenController) { + throw new AppError("Screen sharing is unavailable", "SCREEN_UNAVAILABLE", 500); + } + + this.activeMode = "screen"; + try { + this.screenPlayback = await screenController.start(source); + } catch (error) { + this.activeMode = null; + throw error; + } + + this.screenPlayback.done.then( + () => this.finishScreen(), + () => this.finishScreen(), + ); + return this.emitState(); +} + +private finishScreen(): void { + this.screenPlayback = null; + if (this.activeMode === "screen") { + this.activeMode = null; + } + this.emitState(); +} +``` + +Update `stop` to stop screen too: + +```ts +async stop(): Promise { + this.playbackToken++; + this.playback?.stop(); + this.playback = null; + this.screenPlayback?.stop(); + this.screenPlayback = null; + this.activeMode = null; + this.queueStore.clear(); + return this.emitState(); +} +``` + +- [ ] **Step 4: Wire controller in webserver** + +In `src/webserver.ts`, add imports: + +```ts +import { Streamer } from "@dank074/discord-video-stream"; +import { createScreenShareController } from "./media/screenShareController"; +``` + +Before `const mediaController = new MediaController({`, add: + +```ts +const streamer = new Streamer(_client); +const screenController = createScreenShareController({ + getVoiceStatus: () => voiceController.getStatus(), + streamer, +}); +``` + +Update MediaController dependencies: + +```ts +const mediaController = new MediaController({ + isVoiceConnected: () => voiceController.getStatus().connected, + isBrowserStreaming: () => sharedUIState.isStreaming, + screenController, + onStateChange: (state) => broadcaster.mediaState(state), +}); +``` + +- [ ] **Step 5: Run controller tests and typecheck** + +Run: + +```bash +pnpm exec vitest run tests/media/mediaController.test.ts +pnpm run typecheck +``` + +Expected: both PASS. + +- [ ] **Step 6: Commit Task 7** + +Run: + +```bash +git add src/media/mediaController.ts src/webserver.ts tests/media/mediaController.test.ts +git commit -m "feat: wire screen mode into media controller" +``` + +--- + +### Task 8: Final Verification + +**Files:** +- All changed implementation and test files. + +- [ ] **Step 1: Run focused media tests** + +Run: + +```bash +pnpm exec vitest run tests/player.test.ts tests/media/musicPlayer.test.ts tests/media/mediaController.test.ts tests/routes/mediaRoutes.test.ts tests/media/ytdlp.test.ts tests/media/screenShareController.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run full test suite** + +Run: + +```bash +pnpm run test +``` + +Expected: PASS. + +- [ ] **Step 3: Run typecheck** + +Run: + +```bash +pnpm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 4: Run lint** + +Run: + +```bash +pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 5: Check git status** + +Run: + +```bash +git status --short +``` + +Expected: clean or only intentional uncommitted planning/spec files if the user requested no commits. + +- [ ] **Step 6: Report result** + +Report exact verification commands and outcomes. Do not claim completion unless all commands above pass. diff --git a/docs/superpowers/specs/2026-05-16-media-echo-screenshare-design.md b/docs/superpowers/specs/2026-05-16-media-echo-screenshare-design.md new file mode 100644 index 0000000..b5352c1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-media-echo-screenshare-design.md @@ -0,0 +1,71 @@ +# Media Echo Fix and YouTube Screenshare Design + +## Context + +Media playback currently uses the same `DiscordPlayer` instance as the browser audio bridge. The browser bridge is started during webserver startup and subscribes the shared player to the active voice connection. Music playback also uses that player. This shared ownership can let the bridge interfere with media playback and contribute to voice audio being reflected back during playback. + +The project already includes `@dank074/discord-video-stream`, which supports Discord Go Live video streaming from a direct media URL or readable stream. + +## Goals + +- Prevent voice audio from being reflected back while music/media playback is active. +- Keep normal music playback behavior for existing `/api/media/queue` users. +- Add a YouTube screenshare path that streams video through Discord Go Live. +- Fail clearly when voice is not connected, another media mode is busy, or screenshare dependencies fail. + +## Non-goals + +- Replace the existing voice recorder pipeline. +- Disable message or voice monitoring during music playback. +- Build full production UI for screenshare controls in the first implementation. +- Add Discord integration tests that require a live account or server. + +## Design + +### Audio player ownership + +`DiscordPlayer` will track which subsystem owns the active stream: `none`, `browser-bridge`, `music`, or `screen`. A caller may only start playback when the player has no owner or when the caller owns the current stream. This prevents the browser bridge from overwriting music or screen playback. + +The browser bridge in `src/webserver.ts` will not start at server boot. It will be created lazily only when browser audio arrives and no media playback is active. When media playback starts, the bridge is stopped or left inactive so it cannot transmit captured audio back into Discord. + +Music playback will claim the `music` owner before calling `playStream`. When music finishes or stops, ownership is released and browser audio may resume later if the browser sends new audio. + +### Screenshare mode + +The media queue endpoint will accept an optional `mode` field. If omitted, mode defaults to `music` to preserve existing API behavior. `mode: "screen"` starts a separate screenshare flow instead of audio-only music playback. + +A new `ScreenShareController` will: + +1. Verify a voice channel is connected. +2. Reject start if music or browser bridge owns playback, or if another screen stream is active. +3. Resolve a YouTube URL to a direct playable video URL through the existing yt-dlp utilities. +4. Use `@dank074/discord-video-stream` with `prepareStream(...)` and `playStream(..., { type: "go-live" })`. +5. Track active screen state and provide stop behavior. + +Screenshare state will be exposed through media state as the active mode so the frontend can distinguish music from screen playback. + +### Busy-state rules + +- Music cannot start while screen is active. +- Screen cannot start while music is active. +- Browser bridge cannot start while music or screen is active. +- Stop stops the active media mode and releases ownership. + +### Error handling + +- `VOICE_NOT_CONNECTED`: media or screen requested before joining voice. +- `MEDIA_BUSY`: another active media mode owns playback. +- `SCREEN_STREAM_FAILED`: yt-dlp, stream preparation, or Go Live playback fails. + +Errors should surface through existing Express error handling as JSON responses. + +## Testing + +- Unit test `DiscordPlayer` ownership rules: browser bridge cannot override music; music releases ownership on stop. +- Media controller tests: default mode remains music, screen mode is routed separately, and busy conflicts reject with `MEDIA_BUSY`. +- Route tests: `/api/media/queue` accepts optional `mode` and passes it to the controller. +- Screenshare controller tests mock yt-dlp and `@dank074/discord-video-stream`; no live Discord account is required. + +## Rollout + +Implement ownership first and verify existing music tests still pass. Then add mode parsing and the screenshare controller behind the same media route. UI changes can follow as a small enhancement after API behavior is stable. \ No newline at end of file diff --git a/src/media/mediaController.ts b/src/media/mediaController.ts index 8efb236..7bdee4c 100644 --- a/src/media/mediaController.ts +++ b/src/media/mediaController.ts @@ -3,10 +3,14 @@ import { discordPlayer } from "../player"; import { MediaQueue } from "./mediaQueue"; import { resolveMediaSource } from "./mediaResolver"; import type { + MediaMode, MediaState, MusicPlayback, MusicPlayer, + QueueMediaOptions, ResolvedMediaSource, + ScreenShareController, + ScreenSharePlayback, } from "./mediaTypes"; import { createMusicPlayer } from "./musicPlayer"; @@ -15,6 +19,7 @@ export interface MediaControllerDependencies { isBrowserStreaming?: () => boolean; resolveMediaSource?: (source: string) => Promise; musicPlayer?: MusicPlayer; + screenController?: ScreenShareController; onStateChange?: (state: MediaState) => void; } @@ -24,6 +29,8 @@ export class MediaController { private playback: MusicPlayback | null = null; private playbackToken = 0; private skipInProgress = false; + private screenPlayback: ScreenSharePlayback | null = null; + private activeMode: MediaMode | null = null; constructor(private readonly dependencies: MediaControllerDependencies = {}) { this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer(); @@ -32,17 +39,27 @@ export class MediaController { getState(): MediaState { const snapshot = this.queueStore.snapshot(); return { - playing: snapshot.current?.status === "playing", + playing: + this.activeMode === "screen" || snapshot.current?.status === "playing", + activeMode: this.activeMode ?? snapshot.current?.mode ?? null, ...snapshot, }; } - async queue(source: string): Promise { - this.assertCanStart(); + async queue( + source: string, + options: QueueMediaOptions = {}, + ): Promise { + const mode = options.mode ?? "music"; + if (mode === "screen") { + return this.startScreen(source); + } + + this.assertCanStartMusic(); const resolved = await ( this.dependencies.resolveMediaSource ?? resolveMediaSource )(source); - this.queueStore.add(resolved); + this.queueStore.add(resolved, mode, options.requestedBy); this.startNextIfIdle(); return this.emitState(); } @@ -73,11 +90,14 @@ export class MediaController { this.playbackToken++; this.playback?.stop(); this.playback = null; + this.screenPlayback?.stop(); + this.screenPlayback = null; + this.activeMode = null; this.queueStore.clear(); return this.emitState(); } - private assertCanStart(): void { + private assertCanStartMusic(): void { const isVoiceConnected = this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected()); if (!isVoiceConnected()) { @@ -88,6 +108,10 @@ export class MediaController { ); } + if (this.screenPlayback || this.dependencies.screenController?.isActive()) { + throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); + } + if (this.dependencies.isBrowserStreaming?.()) { throw new AppError( "Stop browser microphone streaming before playing media", @@ -97,6 +121,46 @@ export class MediaController { } } + private async startScreen(source: string): Promise { + if ( + this.screenPlayback || + this.dependencies.screenController?.isActive() || + this.playback || + this.queueStore.snapshot().current + ) { + throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); + } + const screenController = this.dependencies.screenController; + if (!screenController) { + throw new AppError( + "Screen sharing is unavailable", + "SCREEN_UNAVAILABLE", + 500, + ); + } + + this.activeMode = "screen"; + try { + this.screenPlayback = await screenController.start(source); + } catch (error) { + this.activeMode = null; + throw error; + } + + this.screenPlayback.done.then( + () => this.finishScreen(), + () => this.finishScreen(), + ); + return this.emitState(); + } + + private finishScreen(): void { + if (!this.screenPlayback || this.activeMode !== "screen") return; + this.screenPlayback = null; + this.activeMode = null; + this.emitState(); + } + private startNextIfIdle(): void { if (this.playback) return; const item = this.queueStore.startNext(); diff --git a/src/media/mediaQueue.ts b/src/media/mediaQueue.ts index a98b265..9194f0f 100644 --- a/src/media/mediaQueue.ts +++ b/src/media/mediaQueue.ts @@ -1,4 +1,5 @@ import type { + MediaMode, MediaQueueItem, MediaState, ResolvedMediaSource, @@ -13,10 +14,14 @@ export class MediaQueue { private readonly now = () => Date.now(), ) {} - add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem { + add( + source: ResolvedMediaSource, + mode: MediaQueueItem["mode"] = "music", + requestedBy = "dashboard", + ): MediaQueueItem { const item: MediaQueueItem = { id: this.createId(), - mode: "music", + mode, requestedBy, addedAt: this.now(), status: "queued", diff --git a/src/media/mediaTypes.ts b/src/media/mediaTypes.ts index a214d8e..ec77c53 100644 --- a/src/media/mediaTypes.ts +++ b/src/media/mediaTypes.ts @@ -25,10 +25,16 @@ export interface MediaQueueItem extends ResolvedMediaSource { export interface MediaState { playing: boolean; + activeMode: MediaMode | null; current: MediaQueueItem | null; queue: MediaQueueItem[]; } +export interface QueueMediaOptions { + mode?: MediaMode; + requestedBy?: string; +} + export interface MusicPlayback { done: Promise; stop(): void; @@ -38,8 +44,23 @@ export interface MusicPlayer { play(source: ResolvedMediaSource): MusicPlayback; } -export interface DiscordAudioPlayer { - isConnected(): boolean; - playStream(stream: Readable): void; +export interface ScreenSharePlayback { + done: Promise; stop(): void; } + +export interface ScreenShareController { + isActive(): boolean; + start(source: string): Promise; +} + +export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen"; + +export interface DiscordAudioPlayer { + getOwner(): DiscordPlayerOwner; + isConnected(): boolean; + playStream(stream: Readable, owner: DiscordPlayerOwner): void; + pause(owner?: DiscordPlayerOwner): void; + unpause(owner?: DiscordPlayerOwner): boolean; + stop(owner?: DiscordPlayerOwner): void; +} diff --git a/src/media/musicPlayer.ts b/src/media/musicPlayer.ts index e03ce01..75b1418 100644 --- a/src/media/musicPlayer.ts +++ b/src/media/musicPlayer.ts @@ -30,13 +30,27 @@ export function createMusicPlayer( }) as unknown as ChildProcessWithoutNullStreams; proc.stderr.resume(); - audioPlayer.playStream(proc.stdout); + audioPlayer.playStream(proc.stdout, "music"); let stopped = false; + let released = false; + const release = () => { + if (released) return; + released = true; + audioPlayer.stop("music"); + }; + const done = new Promise((resolve, reject) => { - proc.on("error", reject); - proc.stdout.on("error", reject); + proc.on("error", (error) => { + release(); + reject(error); + }); + proc.stdout.on("error", (error) => { + release(); + reject(error); + }); proc.on("close", (code) => { + release(); if (code === 0 || stopped) { resolve(); return; @@ -51,7 +65,7 @@ export function createMusicPlayer( if (stopped) return; stopped = true; proc.kill("SIGTERM"); - audioPlayer.stop(); + release(); }, }; }, diff --git a/src/media/screenShareController.ts b/src/media/screenShareController.ts new file mode 100644 index 0000000..f245467 --- /dev/null +++ b/src/media/screenShareController.ts @@ -0,0 +1,123 @@ +import type { Readable } from "node:stream"; +import { + playStream as defaultPlayStream, + prepareStream as defaultPrepareStream, + Encoders, + Utils, +} from "@dank074/discord-video-stream"; +import { AppError } from "../errors"; +import { discordPlayer } from "../player"; +import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes"; +import { createYtDlp } from "./ytdlp"; + +export interface ScreenShareVoiceStatus { + connected: boolean; + activeGuildId: string | null; + activeChannelId: string | null; +} + +interface PreparedScreenStream { + command: { kill?: (signal: NodeJS.Signals) => unknown }; + output: Readable; +} + +type PrepareScreenStream = ( + source: string, + options: object, +) => PreparedScreenStream; + +type PlayScreenStream = ( + output: Readable, + streamer: unknown, + options: { type: "go-live" }, +) => Promise; + +export interface ScreenShareControllerDependencies { + getVoiceStatus: () => ScreenShareVoiceStatus; + getPlayerOwner?: () => DiscordPlayerOwner; + getDirectVideoUrl?: (source: string) => Promise; + prepareStream?: PrepareScreenStream; + playStream?: PlayScreenStream; + streamer: unknown; +} + +export function createScreenShareController( + dependencies: ScreenShareControllerDependencies, +) { + let active: ScreenSharePlayback | null = null; + const ytdlp = createYtDlp(); + const getPlayerOwner = + dependencies.getPlayerOwner ?? (() => discordPlayer.getOwner()); + const getDirectVideoUrl = + dependencies.getDirectVideoUrl ?? + ((source) => ytdlp.getDirectVideoUrl(source)); + const prepareStream = + dependencies.prepareStream ?? + (defaultPrepareStream as unknown as PrepareScreenStream); + const playStream = + dependencies.playStream ?? + (defaultPlayStream as unknown as PlayScreenStream); + + return { + isActive(): boolean { + return active !== null; + }, + + async start(source: string): Promise { + const status = dependencies.getVoiceStatus(); + if ( + !status.connected || + !status.activeGuildId || + !status.activeChannelId + ) { + throw new AppError( + "Connect to a voice channel before sharing screen", + "VOICE_NOT_CONNECTED", + 409, + ); + } + + if (active || getPlayerOwner() !== "none") { + throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); + } + + try { + const directUrl = await getDirectVideoUrl(source); + const { command, output } = prepareStream(directUrl, { + encoder: Encoders.software({ x264: { preset: "superfast" } }), + height: 720, + frameRate: 30, + bitrateVideo: 2500, + bitrateVideoMax: 4000, + includeAudio: true, + videoCodec: Utils.normalizeVideoCodec("H264"), + }); + + let stopped = false; + const done = playStream(output, dependencies.streamer, { + type: "go-live", + }).finally(() => { + active = null; + }); + + active = { + done, + stop() { + if (stopped) return; + stopped = true; + command.kill?.("SIGTERM"); + active = null; + }, + }; + return active; + } catch (error) { + active = null; + throw new AppError( + error instanceof Error ? error.message : "Screen stream failed", + "SCREEN_STREAM_FAILED", + 500, + ); + } + }, + }; +} diff --git a/src/media/ytdlp.ts b/src/media/ytdlp.ts index a2005ee..b17cad7 100644 --- a/src/media/ytdlp.ts +++ b/src/media/ytdlp.ts @@ -9,6 +9,7 @@ export interface YtDlpMetadata { export interface YtDlpClient { getMetadata(url: string): Promise; getDirectAudioUrl(url: string): Promise; + getDirectVideoUrl(url: string): Promise; } export interface YtDlpDependencies { @@ -49,6 +50,19 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient { ]); return value.trim().split("\n")[0] || url; }, + + async getDirectVideoUrl(url: string): Promise { + const value = await runYtDlp(spawn, [ + url, + "--get-url", + "--format", + "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", + "--no-playlist", + "--no-warnings", + "--quiet", + ]); + return value.trim().split("\n")[0] || url; + }, }; } diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index d4b17d9..e4f19aa 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -144,7 +144,9 @@ async function runAnalysisInWorker( messages: MessageRecord[], ): Promise { return new Promise((resolve, reject) => { - const worker = new Worker(new URL("./aiAnalysisWorker.ts", import.meta.url)); + const worker = new Worker( + new URL("./aiAnalysisWorker.ts", import.meta.url), + ); worker.once("message", (response: AnalysisWorkerResponse) => { worker.terminate().catch((error) => { @@ -213,7 +215,6 @@ function scheduleConversationAnalysis(conversationKey: string): void { export async function queueMessageAnalysis(messageId: string): Promise { if (!config.AI_ANALYSIS_ENABLED) return; - try { // Look up the message to get its conversation key const message = await getMessageById(messageId); @@ -242,7 +243,6 @@ export async function queueMessageAnalysis(messageId: string): Promise { export function queueConversationAnalysis(conversationKey: string): void { if (!config.AI_ANALYSIS_ENABLED) return; - // Schedule debounced analysis scheduleConversationAnalysis(conversationKey); } diff --git a/src/player.ts b/src/player.ts index 5662683..efdb47d 100644 --- a/src/player.ts +++ b/src/player.ts @@ -7,10 +7,12 @@ import { StreamType, VoiceConnection, } from "@discordjs/voice"; +import type { DiscordPlayerOwner } from "./media/mediaTypes"; export class DiscordPlayer { private player: AudioPlayer; private connection: VoiceConnection | null = null; + private owner: DiscordPlayerOwner = "none"; constructor() { this.player = createAudioPlayer(); @@ -21,6 +23,7 @@ export class DiscordPlayer { this.player.on("error", (error) => { console.error(`[player] Error: ${error.message}`); + this.owner = "none"; }); } @@ -29,17 +32,28 @@ export class DiscordPlayer { this.connection.subscribe(this.player); } + public getOwner(): DiscordPlayerOwner { + return this.owner; + } + public isConnected(): boolean { return this.connection !== null; } - public playStream(stream: Readable) { - console.log("[player] Starting new audio stream..."); + public playStream(stream: Readable, owner: DiscordPlayerOwner) { + if (owner === "none") { + throw new Error("Discord audio player owner is required"); + } + this.assertOwnerAvailable(owner); const resource = createAudioResource(stream, { inputType: StreamType.OggOpus, }); + if (this.owner === owner) { + this.player.stop(); + } + this.owner = owner; this.player.play(resource); this.connection?.subscribe(this.player); } @@ -48,16 +62,30 @@ export class DiscordPlayer { return this.player.state.status; } - public pause() { + public pause(owner?: DiscordPlayerOwner) { + if (!this.canControl(owner)) return; this.player.pause(true); } - public unpause(): boolean { + public unpause(owner?: DiscordPlayerOwner): boolean { + if (!this.canControl(owner)) return false; return this.player.unpause(); } - public stop() { + public stop(owner?: DiscordPlayerOwner) { + if (!this.canControl(owner)) return; this.player.stop(); + this.owner = "none"; + } + + private assertOwnerAvailable(owner: DiscordPlayerOwner): void { + if (this.owner !== "none" && this.owner !== owner) { + throw new Error(`Discord audio player is owned by ${this.owner}`); + } + } + + private canControl(owner?: DiscordPlayerOwner): boolean { + return !owner || this.owner === "none" || this.owner === owner; } } diff --git a/src/recorder.ts b/src/recorder.ts index 693cddb..54c9ef8 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -89,6 +89,8 @@ export async function startRecording( // Dengarkan siapapun yang mulai bicara receiver.speaking.on("start", async (userId) => { + if (userId === client.user?.id) return; + const userMetadata = await collectUserMetadata(client, userId, channel); logger.info( { userId, username: userMetadata.username }, diff --git a/src/routes/mediaRoutes.ts b/src/routes/mediaRoutes.ts index d1e79a1..e693f76 100644 --- a/src/routes/mediaRoutes.ts +++ b/src/routes/mediaRoutes.ts @@ -2,6 +2,7 @@ import type { Router } from "express"; import express from "express"; import { AppError } from "../errors"; import type { MediaController } from "../media/mediaController"; +import type { MediaMode } from "../media/mediaTypes"; export type MediaRouteController = Pick< MediaController, @@ -21,7 +22,10 @@ export function createMediaRoutes(controller: MediaRouteController): Router { router.post("/media/queue", async (req, res, next) => { try { - const { source } = req.body as { source?: string }; + const { source, mode = "music" } = req.body as { + source?: string; + mode?: MediaMode; + }; if (!source) { throw new AppError( "Media source is required", @@ -29,7 +33,10 @@ export function createMediaRoutes(controller: MediaRouteController): Router { 400, ); } - res.json(await controller.queue(source)); + if (mode !== "music" && mode !== "screen") { + throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400); + } + res.json(await controller.queue(source, { mode })); } catch (error) { next(error); } diff --git a/src/webserver.ts b/src/webserver.ts index fe1ee74..eef3855 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -1,15 +1,17 @@ import fs from "node:fs"; import http from "node:http"; import path from "node:path"; +import { Streamer } from "@dank074/discord-video-stream"; +import { AudioPlayerStatus } from "@discordjs/voice"; import type { Client } from "discord.js-selfbot-v13"; import express from "express"; import helmet from "helmet"; -import { AudioPlayerStatus } from "@discordjs/voice"; import * as prism from "prism-media"; import { WebSocketServer } from "ws"; import { AppError } from "./errors"; import { createChildLogger, logger } from "./logger"; import { MediaController } from "./media/mediaController"; +import { createScreenShareController } from "./media/screenShareController"; import { getMetrics, uptimeGauge } from "./metrics"; import { createBroadcaster } from "./moderation/broadcaster"; import type { ModerationBroadcaster } from "./moderation/types"; @@ -163,9 +165,16 @@ export async function startWebserver( const broadcaster = createBroadcaster(); (globalThis as VoiceGlobals).moderationBroadcaster = broadcaster; + const streamer = new Streamer(_client); + const screenController = createScreenShareController({ + getVoiceStatus: () => voiceController.getStatus(), + streamer, + }); + const mediaController = new MediaController({ isVoiceConnected: () => voiceController.getStatus().connected, isBrowserStreaming: () => sharedUIState.isStreaming, + screenController, onStateChange: (state) => broadcaster.mediaState(state), }); @@ -287,11 +296,12 @@ export async function startWebserver( const SILENCE_TAIL_MS = 300; // continue sending silence for 300ms after browser stops const MAX_BUF_BYTES = BYTES_PER_FRAME * 50; // cap at 1 second to avoid runaway buffer - let opusEncoder: prism.opus.Encoder; + let opusEncoder: prism.opus.Encoder | null = null; let bridgePlayerPaused = true; const SILENCE_FRAME = Buffer.alloc(BYTES_PER_FRAME, 0); function startBrowserAudioBridge(): void { + if (opusEncoder) return; opusEncoder = new prism.opus.Encoder({ rate: RATE, channels: CHANNELS, @@ -308,19 +318,23 @@ export async function startWebserver( opusEncoder.on("error", () => {}); opusEncoder.pipe(oggBitstream); opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0)); - discordPlayer.playStream(oggBitstream); - discordPlayer.pause(); + discordPlayer.playStream(oggBitstream, "browser-bridge"); + discordPlayer.pause("browser-bridge"); bridgePlayerPaused = true; } - function ensureBrowserAudioBridge(): void { - if (discordPlayer.getStatus() === AudioPlayerStatus.Idle) { + function ensureBrowserAudioBridge(): boolean { + const owner = discordPlayer.getOwner(); + if (owner !== "none" && owner !== "browser-bridge") return false; + if ( + owner === "none" || + discordPlayer.getStatus() === AudioPlayerStatus.Idle + ) { startBrowserAudioBridge(); } + return true; } - startBrowserAudioBridge(); - let pcmBuffer = Buffer.alloc(0); let lastBrowserAudioTime = 0; @@ -351,9 +365,12 @@ export async function startWebserver( dbAccum += rmsDb(frame); dbCount++; - ensureBrowserAudioBridge(); + if (!ensureBrowserAudioBridge()) { + pcmBuffer = Buffer.alloc(0); + return; + } if (bridgePlayerPaused) { - const unpaused = discordPlayer.unpause(); + const unpaused = discordPlayer.unpause("browser-bridge"); bridgePlayerPaused = false; wsLogger.info({ unpaused }, "Transmitting — Discord indicator ON"); } @@ -362,7 +379,7 @@ export async function startWebserver( frame = SILENCE_FRAME; } else if (!bridgePlayerPaused && msSinceAudio >= SILENCE_TAIL_MS) { // No audio for a while — pause Discord indicator - discordPlayer.pause(); + discordPlayer.pause("browser-bridge"); bridgePlayerPaused = true; wsLogger.info("Stopped — Discord indicator OFF"); return; @@ -371,6 +388,7 @@ export async function startWebserver( } // Write one frame. If encoder is backpressured, skip this tick to avoid stalling. + if (!opusEncoder) return; const ok = opusEncoder.write(frame); if (!ok) { opusEncoder.once("drain", () => {}); // re-arm drain without blocking diff --git a/tests/media/mediaController.test.ts b/tests/media/mediaController.test.ts index de41a4d..d5d813b 100644 --- a/tests/media/mediaController.test.ts +++ b/tests/media/mediaController.test.ts @@ -5,6 +5,7 @@ import type { MusicPlayback, MusicPlayer, ResolvedMediaSource, + ScreenShareController, } from "../../src/media/mediaTypes"; function deferred() { @@ -190,7 +191,62 @@ describe("MediaController", () => { const state = await controller.stop(); expect(stop).toHaveBeenCalled(); - expect(state).toEqual({ playing: false, current: null, queue: [] }); + expect(state).toEqual({ + playing: false, + activeMode: null, + current: null, + queue: [], + }); + }); + + it("starts screen share mode without resolving music source", async () => { + const screenPlayback = deferred(); + const screenController: ScreenShareController = { + isActive: vi.fn(() => false), + start: vi.fn(async () => ({ + done: screenPlayback.promise, + stop: vi.fn(), + })), + }; + const resolveMediaSource = vi.fn(async (input) => source(input)); + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource, + musicPlayer: { play: vi.fn() }, + screenController, + }); + + const state = await controller.queue("https://youtu.be/video", { + mode: "screen", + }); + + expect(screenController.start).toHaveBeenCalledWith( + "https://youtu.be/video", + ); + expect(resolveMediaSource).not.toHaveBeenCalled(); + expect(state).toMatchObject({ playing: true, activeMode: "screen" }); + }); + + it("rejects music while screen share is active", async () => { + const screenController: ScreenShareController = { + isActive: vi.fn(() => true), + start: vi.fn(), + }; + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async (input) => source(input), + musicPlayer: { play: vi.fn() }, + screenController, + }); + + await expect( + controller.queue("https://example.com/song.mp3"), + ).rejects.toMatchObject({ + code: "MEDIA_BUSY", + statusCode: 409, + } satisfies Partial); }); it("emits state changes", async () => { @@ -200,7 +256,10 @@ describe("MediaController", () => { isBrowserStreaming: () => false, resolveMediaSource: async (input) => source(input), musicPlayer: { - play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })), + play: vi.fn(() => ({ + done: new Promise(() => {}), + stop: vi.fn(), + })), }, onStateChange, }); diff --git a/tests/media/mediaQueue.test.ts b/tests/media/mediaQueue.test.ts index 7f450c2..e9e47ce 100644 --- a/tests/media/mediaQueue.test.ts +++ b/tests/media/mediaQueue.test.ts @@ -20,7 +20,7 @@ describe("MediaQueue", () => { () => 1700000000000, ); - const item = queue.add(source(), "tester"); + const item = queue.add(source(), "music", "tester"); expect(item).toMatchObject({ id: "item-1", @@ -40,7 +40,7 @@ describe("MediaQueue", () => { () => "item-1", () => 1700000000000, ); - const item = queue.add(source(), "tester"); + const item = queue.add(source(), "music", "tester"); expect(queue.startNext()).toEqual({ ...item, status: "playing" }); expect(queue.snapshot()).toEqual({ @@ -55,8 +55,8 @@ describe("MediaQueue", () => { () => `item-${++id}`, () => 1700000000000, ); - queue.add(source({ title: "first" }), "tester"); - queue.add(source({ title: "second" }), "tester"); + queue.add(source({ title: "first" }), "music", "tester"); + queue.add(source({ title: "second" }), "music", "tester"); queue.startNext(); queue.completeCurrent(); @@ -71,7 +71,7 @@ describe("MediaQueue", () => { () => "item-1", () => 1700000000000, ); - const item = queue.add(source(), "tester"); + const item = queue.add(source(), "music", "tester"); queue.startNext(); expect(queue.failCurrent()).toEqual({ ...item, status: "failed" }); @@ -83,7 +83,7 @@ describe("MediaQueue", () => { () => "item-1", () => 1700000000000, ); - queue.add(source(), "tester"); + queue.add(source(), "music", "tester"); queue.startNext(); queue.clear(); diff --git a/tests/media/musicPlayer.test.ts b/tests/media/musicPlayer.test.ts index 3f18580..7f646a0 100644 --- a/tests/media/musicPlayer.test.ts +++ b/tests/media/musicPlayer.test.ts @@ -1,7 +1,14 @@ +import type { spawn as nodeSpawn } from "node:child_process"; + +type Spawn = typeof nodeSpawn; + import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { describe, expect, it, vi } from "vitest"; -import type { DiscordAudioPlayer } from "../../src/media/mediaTypes"; +import type { + DiscordAudioPlayer, + DiscordPlayerOwner, +} from "../../src/media/mediaTypes"; import { createMusicPlayer } from "../../src/media/musicPlayer"; class FakeProcess extends EventEmitter { @@ -22,9 +29,15 @@ describe("createMusicPlayer", () => { const discordPlayer: DiscordAudioPlayer = { isConnected: () => true, playStream: vi.fn(), + getOwner: vi.fn((): DiscordPlayerOwner => "none"), + pause: vi.fn(), + unpause: vi.fn(() => true), stop: vi.fn(), }; - const player = createMusicPlayer({ spawn, discordPlayer }); + const player = createMusicPlayer({ + spawn: spawn as unknown as Spawn, + discordPlayer, + }); const playback = player.play({ source: "https://example.com/song.mp3", @@ -55,7 +68,7 @@ describe("createMusicPlayer", () => { ], { stdio: ["ignore", "pipe", "pipe"] }, ); - expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout); + expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music"); }); it("rejects playback when Discord is not connected", () => { @@ -63,9 +76,15 @@ describe("createMusicPlayer", () => { const discordPlayer: DiscordAudioPlayer = { isConnected: () => false, playStream: vi.fn(), + getOwner: vi.fn((): DiscordPlayerOwner => "none"), + pause: vi.fn(), + unpause: vi.fn(() => true), stop: vi.fn(), }; - const player = createMusicPlayer({ spawn, discordPlayer }); + const player = createMusicPlayer({ + spawn: spawn as unknown as Spawn, + discordPlayer, + }); expect(() => player.play({ @@ -77,15 +96,44 @@ describe("createMusicPlayer", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("releases ownership on normal ffmpeg close", async () => { + const proc = new FakeProcess(); + const discordPlayer: DiscordAudioPlayer = { + isConnected: () => true, + playStream: vi.fn(), + getOwner: vi.fn((): DiscordPlayerOwner => "none"), + pause: vi.fn(), + unpause: vi.fn(() => true), + stop: vi.fn(), + }; + const player = createMusicPlayer({ + spawn: vi.fn(() => proc) as unknown as Spawn, + discordPlayer, + }); + + const playback = player.play({ + source: "/tmp/song.ogg", + title: "song.ogg", + kind: "local", + }); + // simulate normal close + proc.emit("close", 0); + await playback.done; + expect(discordPlayer.stop).toHaveBeenCalledWith("music"); + }); + it("kills ffmpeg and stops Discord playback once", () => { const proc = new FakeProcess(); const discordPlayer: DiscordAudioPlayer = { isConnected: () => true, playStream: vi.fn(), + getOwner: vi.fn((): DiscordPlayerOwner => "none"), + pause: vi.fn(), + unpause: vi.fn(() => true), stop: vi.fn(), }; const player = createMusicPlayer({ - spawn: vi.fn(() => proc), + spawn: vi.fn(() => proc) as unknown as Spawn, discordPlayer, }); diff --git a/tests/media/screenShareController.test.ts b/tests/media/screenShareController.test.ts new file mode 100644 index 0000000..0232b08 --- /dev/null +++ b/tests/media/screenShareController.test.ts @@ -0,0 +1,94 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { AppError } from "../../src/errors"; +import type { DiscordPlayerOwner } from "../../src/media/mediaTypes"; +import { createScreenShareController } from "../../src/media/screenShareController"; + +function createDependencies() { + const output = new PassThrough(); + return { + getVoiceStatus: vi.fn(() => ({ + connected: true, + activeGuildId: "guild-1" as string | null, + activeChannelId: "channel-1" as string | null, + })), + getPlayerOwner: vi.fn((): DiscordPlayerOwner => "none"), + getDirectVideoUrl: vi.fn(async () => "https://cdn.example.com/video.mp4"), + prepareStream: vi.fn(() => ({ + command: { kill: vi.fn() }, + output, + })), + playStream: vi.fn(() => new Promise(() => {})), + streamer: { id: "streamer" }, + }; +} + +describe("createScreenShareController", () => { + it("starts a YouTube Go Live stream", async () => { + const dependencies = createDependencies(); + const controller = createScreenShareController(dependencies); + + const playback = await controller.start("https://youtu.be/video"); + + expect(dependencies.getDirectVideoUrl).toHaveBeenCalledWith( + "https://youtu.be/video", + ); + expect(dependencies.prepareStream).toHaveBeenCalledWith( + "https://cdn.example.com/video.mp4", + expect.objectContaining({ includeAudio: true }), + ); + expect(dependencies.playStream).toHaveBeenCalledWith( + dependencies.prepareStream.mock.results[0].value.output, + dependencies.streamer, + { type: "go-live" }, + ); + expect(controller.isActive()).toBe(true); + playback.stop(); + expect(controller.isActive()).toBe(false); + }); + + it("rejects when voice is not connected", async () => { + const dependencies = createDependencies(); + dependencies.getVoiceStatus.mockReturnValue({ + connected: false, + activeGuildId: null, + activeChannelId: null, + }); + const controller = createScreenShareController(dependencies); + + await expect( + controller.start("https://youtu.be/video"), + ).rejects.toMatchObject({ + code: "VOICE_NOT_CONNECTED", + statusCode: 409, + } satisfies Partial); + }); + + it("rejects when music owns the shared player", async () => { + const dependencies = createDependencies(); + dependencies.getPlayerOwner.mockReturnValue("music"); + const controller = createScreenShareController(dependencies); + + await expect( + controller.start("https://youtu.be/video"), + ).rejects.toMatchObject({ + code: "MEDIA_BUSY", + statusCode: 409, + } satisfies Partial); + }); + + it("wraps stream startup failures", async () => { + const dependencies = createDependencies(); + dependencies.playStream.mockImplementation(() => { + throw new Error("go live failed"); + }); + const controller = createScreenShareController(dependencies); + + await expect( + controller.start("https://youtu.be/video"), + ).rejects.toMatchObject({ + code: "SCREEN_STREAM_FAILED", + statusCode: 500, + } satisfies Partial); + }); +}); diff --git a/tests/media/ytdlp.test.ts b/tests/media/ytdlp.test.ts index 19912a3..a716535 100644 --- a/tests/media/ytdlp.test.ts +++ b/tests/media/ytdlp.test.ts @@ -43,7 +43,8 @@ describe("createYtDlp", () => { it("reads direct audio URL", async () => { const proc = new FakeProcess(); - const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) }); + const spawn = vi.fn(() => proc); + const ytdlp = createYtDlp({ spawn }); const result = ytdlp.getDirectAudioUrl("https://youtu.be/video"); proc.stdout.write("https://audio.example.com/stream\n"); @@ -51,6 +52,45 @@ describe("createYtDlp", () => { proc.emit("close", 0); await expect(result).resolves.toBe("https://audio.example.com/stream"); + expect(spawn).toHaveBeenCalledWith( + "yt-dlp", + [ + "https://youtu.be/video", + "--get-url", + "--format", + "bestaudio[protocol^=http]/bestaudio/best", + "--no-playlist", + "--no-warnings", + "--quiet", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + }); + + it("reads direct video URL", async () => { + const proc = new FakeProcess(); + const spawn = vi.fn(() => proc); + const ytdlp = createYtDlp({ spawn }); + + const result = ytdlp.getDirectVideoUrl("https://youtu.be/video"); + proc.stdout.write("https://video.example.com/stream\n"); + proc.stdout.end(); + proc.emit("close", 0); + + await expect(result).resolves.toBe("https://video.example.com/stream"); + expect(spawn).toHaveBeenCalledWith( + "yt-dlp", + [ + "https://youtu.be/video", + "--get-url", + "--format", + "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", + "--no-playlist", + "--no-warnings", + "--quiet", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); }); it("rejects when yt-dlp exits non-zero", async () => { diff --git a/tests/player.test.ts b/tests/player.test.ts new file mode 100644 index 0000000..c540652 --- /dev/null +++ b/tests/player.test.ts @@ -0,0 +1,82 @@ +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock @discordjs/voice +vi.mock("@discordjs/voice", () => { + const mockPlayer = { + play: vi.fn(), + pause: vi.fn(), + unpause: vi.fn().mockReturnValue(true), + stop: vi.fn(), + on: vi.fn(), + state: { status: "idle" }, + }; + const mockConnection = { + subscribe: vi.fn().mockReturnValue({}), + }; + return { + AudioPlayerStatus: { Idle: "idle", Playing: "playing", Paused: "paused" }, + createAudioPlayer: vi.fn(() => mockPlayer), + createAudioResource: vi.fn(() => ({})), + StreamType: { OggOpus: "OggOpus" }, + AudioPlayer: vi.fn(), + VoiceConnection: vi.fn(), + __mockPlayer: mockPlayer, + __mockConnection: mockConnection, + }; +}); + +// Import after mocks +import { DiscordPlayer } from "../src/player"; + +describe("DiscordPlayer", () => { + let player: DiscordPlayer; + const dummyStream = new Readable(); + + beforeEach(() => { + vi.clearAllMocks(); + player = new DiscordPlayer(); + }); + + describe("ownership", () => { + it("starts with owner none", () => { + expect(player.getOwner()).toBe("none"); + }); + + it("playStream with owner sets owner", () => { + player.playStream(dummyStream, "music"); + expect(player.getOwner()).toBe("music"); + }); + + it("browser bridge cannot override music owner", () => { + player.playStream(dummyStream, "music"); + expect(() => player.playStream(dummyStream, "browser-bridge")).toThrow( + "Discord audio player is owned by music", + ); + }); + + it("same owner can replace stream without error", () => { + player.playStream(dummyStream, "music"); + expect(() => player.playStream(dummyStream, "music")).not.toThrow(); + expect(player.getOwner()).toBe("music"); + }); + + it("matching owner stop releases ownership", () => { + player.playStream(dummyStream, "music"); + player.stop("music"); + expect(player.getOwner()).toBe("none"); + }); + + it("non-owner stop is ignored", () => { + player.playStream(dummyStream, "music"); + player.stop("browser-bridge"); + expect(player.getOwner()).toBe("music"); + }); + + it("stop without owner releases ownership", () => { + player.playStream(dummyStream, "music"); + player.stop(); + expect(player.getOwner()).toBe("none"); + }); + }); +}); diff --git a/tests/recorder.test.ts b/tests/recorder.test.ts new file mode 100644 index 0000000..99d11b4 --- /dev/null +++ b/tests/recorder.test.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; + +const speaking = new EventEmitter(); +const subscribe = vi.fn(); +const joinVoiceChannel = vi.fn(() => ({ + receiver: { + speaking, + subscriptions: new Map(), + subscribe, + }, + on: vi.fn(), + destroy: vi.fn(), +})); + +vi.mock("@discordjs/voice", async () => { + const actual = + await vi.importActual( + "@discordjs/voice", + ); + return { + ...actual, + joinVoiceChannel, + entersState: vi.fn(async () => undefined), + }; +}); + +describe("startRecording", () => { + it("does not subscribe to the bot user's own audio", async () => { + const { startRecording } = await import("../src/recorder"); + const client = { + user: { id: "bot-user" }, + }; + const channel = { + id: "voice-channel", + name: "Voice", + guild: { + id: "guild", + voiceAdapterCreator: {}, + }, + }; + + await startRecording(client as never, channel as never); + speaking.emit("start", "bot-user"); + await new Promise((resolve) => setImmediate(resolve)); + + expect(subscribe).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/routes/mediaRoutes.test.ts b/tests/routes/mediaRoutes.test.ts index 5634044..9ef8e72 100644 --- a/tests/routes/mediaRoutes.test.ts +++ b/tests/routes/mediaRoutes.test.ts @@ -14,7 +14,12 @@ function getHandler( describe("createMediaRoutes", () => { it("returns media status", async () => { const controller = { - getState: vi.fn(() => ({ playing: false, current: null, queue: [] })), + getState: vi.fn(() => ({ + playing: false, + activeMode: null, + current: null, + queue: [], + })), queue: vi.fn(), skip: vi.fn(), stop: vi.fn(), @@ -30,13 +35,14 @@ describe("createMediaRoutes", () => { expect(json).toHaveBeenCalledWith({ playing: false, + activeMode: null, current: null, queue: [], }); }); it("queues a source", async () => { - const state = { playing: true, current: null, queue: [] }; + const state = { playing: true, activeMode: null, current: null, queue: [] }; const controller = { getState: vi.fn(), queue: vi.fn(async () => state), @@ -58,10 +64,71 @@ describe("createMediaRoutes", () => { expect(controller.queue).toHaveBeenCalledWith( "https://example.com/song.mp3", + { mode: "music" }, ); expect(json).toHaveBeenCalledWith(state); }); + it("queues a screen source", async () => { + const state = { + playing: true, + activeMode: "screen" as const, + current: null, + queue: [], + }; + const controller = { + getState: vi.fn(), + queue: vi.fn(async () => state), + skip: vi.fn(), + stop: vi.fn(), + }; + const handler = getHandler( + createMediaRoutes(controller), + "/media/queue", + "post", + ); + const json = vi.fn(); + + await handler?.( + { body: { source: "https://youtu.be/video", mode: "screen" } } as Request, + { json } as unknown as Response, + vi.fn(), + ); + + expect(controller.queue).toHaveBeenCalledWith("https://youtu.be/video", { + mode: "screen", + }); + expect(json).toHaveBeenCalledWith(state); + }); + + it("passes invalid mode errors to Express", async () => { + const controller = { + getState: vi.fn(), + queue: vi.fn(), + skip: vi.fn(), + stop: vi.fn(), + }; + const handler = getHandler( + createMediaRoutes(controller), + "/media/queue", + "post", + ); + const next = vi.fn(); + + await handler?.( + { + body: { source: "https://example.com/song.mp3", mode: "video" }, + } as Request, + { json: vi.fn() } as unknown as Response, + next, + ); + + expect(next.mock.calls[0][0]).toMatchObject({ + code: "INVALID_MEDIA_MODE", + statusCode: 400, + }); + }); + it("passes missing source errors to Express", async () => { const controller = { getState: vi.fn(),