diff --git a/docs/superpowers/plans/2026-05-15-media-music-phase-1.md b/docs/superpowers/plans/2026-05-15-media-music-phase-1.md new file mode 100644 index 0000000..0a058e9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-media-music-phase-1.md @@ -0,0 +1,1291 @@ +# Media Music Phase 1 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:** Add audio-only media queue playback so the dashboard can queue, play, skip, and stop music in the currently connected Discord voice channel. + +**Architecture:** Add a focused `src/media/` subsystem with pure queue/resolver units, an ffmpeg-backed music player, and a controller that owns playback state. Keep `VoiceController` as the only voice join/leave path; media playback requires an existing voice connection and uses `DiscordPlayer` for Ogg Opus output. + +**Tech Stack:** TypeScript, Express, Vitest, Node `child_process`, Node streams, existing `DiscordPlayer`, ffmpeg producing Ogg Opus. + +--- + +## File Structure + +- Create `src/media/mediaTypes.ts` — shared media mode, queue item, resolved source, state, and dependency types. +- Create `src/media/mediaQueue.ts` — pure in-memory queue operations. +- Create `src/media/mediaResolver.ts` — resolve and validate HTTP(S) URLs and existing local file paths. +- Create `src/media/musicPlayer.ts` — spawn ffmpeg and pipe Ogg Opus into `DiscordPlayer`. +- Create `src/media/mediaController.ts` — coordinate queue, playback, skip, stop, and state snapshots. +- Create `src/routes/mediaRoutes.ts` — REST endpoints for media status, queue, skip, stop. +- Modify `src/player.ts` — expose a minimal `isConnected()` helper for media preflight. +- Modify `src/webserver.ts` — create `MediaController`, mount media routes, broadcast media state over WebSocket. +- Modify `public/index.html` — add compact Media controls to the voice tab. +- Tests: + - `tests/media/mediaQueue.test.ts` + - `tests/media/mediaResolver.test.ts` + - `tests/media/musicPlayer.test.ts` + - `tests/media/mediaController.test.ts` + - `tests/routes/mediaRoutes.test.ts` + +--- + +### Task 1: Media Types and Queue + +**Files:** +- Create: `src/media/mediaTypes.ts` +- Create: `src/media/mediaQueue.ts` +- Test: `tests/media/mediaQueue.test.ts` + +- [ ] **Step 1: Write the failing queue tests** + +Create `tests/media/mediaQueue.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { MediaQueue } from "../../src/media/mediaQueue"; +import type { ResolvedMediaSource } from "../../src/media/mediaTypes"; + +function source(overrides: Partial = {}): ResolvedMediaSource { + return { + source: "https://example.com/audio.ogg", + title: "audio.ogg", + kind: "url", + ...overrides, + }; +} + +describe("MediaQueue", () => { + it("adds items with stable queue metadata", () => { + const queue = new MediaQueue(() => "item-1", () => 1700000000000); + + const item = queue.add(source(), "tester"); + + expect(item).toMatchObject({ + id: "item-1", + mode: "music", + source: "https://example.com/audio.ogg", + title: "audio.ogg", + kind: "url", + requestedBy: "tester", + addedAt: 1700000000000, + status: "queued", + }); + expect(queue.snapshot()).toEqual({ current: null, queue: [item] }); + }); + + it("marks the next queued item as playing", () => { + const queue = new MediaQueue(() => "item-1", () => 1700000000000); + const item = queue.add(source(), "tester"); + + expect(queue.startNext()).toEqual({ ...item, status: "playing" }); + expect(queue.snapshot()).toEqual({ + current: { ...item, status: "playing" }, + queue: [], + }); + }); + + it("removes current item and starts following item", () => { + let id = 0; + const queue = new MediaQueue(() => `item-${++id}`, () => 1700000000000); + queue.add(source({ title: "first" }), "tester"); + queue.add(source({ title: "second" }), "tester"); + queue.startNext(); + + queue.completeCurrent(); + const next = queue.startNext(); + + expect(next?.title).toBe("second"); + expect(queue.snapshot().queue).toEqual([]); + }); + + it("clears current and queued items", () => { + const queue = new MediaQueue(() => "item-1", () => 1700000000000); + queue.add(source(), "tester"); + queue.startNext(); + + queue.clear(); + + expect(queue.snapshot()).toEqual({ current: null, queue: [] }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/mediaQueue.test.ts +``` + +Expected: FAIL because `src/media/mediaQueue.ts` does not exist. + +- [ ] **Step 3: Create media types** + +Create `src/media/mediaTypes.ts`: + +```ts +import type { Readable } from "node:stream"; + +export type MediaMode = "music" | "screen"; +export type MediaSourceKind = "url" | "local"; +export type MediaQueueItemStatus = "queued" | "playing" | "failed"; + +export interface ResolvedMediaSource { + source: string; + title: string; + kind: MediaSourceKind; +} + +export interface MediaQueueItem extends ResolvedMediaSource { + id: string; + mode: MediaMode; + requestedBy: string; + addedAt: number; + status: MediaQueueItemStatus; +} + +export interface MediaState { + playing: boolean; + current: MediaQueueItem | null; + queue: MediaQueueItem[]; +} + +export interface MusicPlayback { + done: Promise; + stop(): void; +} + +export interface MusicPlayer { + play(source: ResolvedMediaSource): MusicPlayback; +} + +export interface DiscordAudioPlayer { + isConnected(): boolean; + playStream(stream: Readable): void; + stop(): void; +} +``` + +- [ ] **Step 4: Implement queue** + +Create `src/media/mediaQueue.ts`: + +```ts +import type { + MediaQueueItem, + MediaState, + ResolvedMediaSource, +} from "./mediaTypes"; + +export class MediaQueue { + private current: MediaQueueItem | null = null; + private readonly items: MediaQueueItem[] = []; + + constructor( + private readonly createId = () => crypto.randomUUID(), + private readonly now = () => Date.now(), + ) {} + + add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem { + const item: MediaQueueItem = { + id: this.createId(), + mode: "music", + requestedBy, + addedAt: this.now(), + status: "queued", + ...source, + }; + this.items.push(item); + return { ...item }; + } + + startNext(): MediaQueueItem | null { + if (this.current) return { ...this.current }; + const next = this.items.shift(); + if (!next) return null; + this.current = { ...next, status: "playing" }; + return { ...this.current }; + } + + completeCurrent(): void { + this.current = null; + } + + failCurrent(): void { + if (this.current) { + this.current = { ...this.current, status: "failed" }; + } + this.current = null; + } + + clear(): void { + this.current = null; + this.items.length = 0; + } + + snapshot(): Pick { + return { + current: this.current ? { ...this.current } : null, + queue: this.items.map((item) => ({ ...item })), + }; + } +} +``` + +- [ ] **Step 5: Run queue test to verify it passes** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/mediaQueue.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit task 1** + +```bash +git -C /mnt/code/bete add src/media/mediaTypes.ts src/media/mediaQueue.ts tests/media/mediaQueue.test.ts +git -C /mnt/code/bete commit -m "feat: add media queue foundation" +``` + +--- + +### Task 2: Media Resolver + +**Files:** +- Create: `src/media/mediaResolver.ts` +- Test: `tests/media/mediaResolver.test.ts` + +- [ ] **Step 1: Write failing resolver tests** + +Create `tests/media/mediaResolver.test.ts`: + +```ts +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { AppError } from "../../src/errors"; +import { resolveMediaSource } from "../../src/media/mediaResolver"; + +describe("resolveMediaSource", () => { + it("accepts http URLs", async () => { + await expect(resolveMediaSource("https://example.com/music.mp3")).resolves.toEqual({ + source: "https://example.com/music.mp3", + title: "music.mp3", + kind: "url", + }); + }); + + it("accepts existing local files", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "media-resolver-")); + const file = path.join(dir, "song.ogg"); + writeFileSync(file, "audio"); + + await expect(resolveMediaSource(file)).resolves.toEqual({ + source: file, + title: "song.ogg", + kind: "local", + }); + }); + + it("rejects empty sources", async () => { + await expect(resolveMediaSource(" ")).rejects.toMatchObject({ + code: "MISSING_MEDIA_SOURCE", + statusCode: 400, + } satisfies Partial); + }); + + it("rejects unsupported sources", async () => { + await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject({ + code: "UNSUPPORTED_MEDIA_SOURCE", + statusCode: 400, + } satisfies Partial); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/mediaResolver.test.ts +``` + +Expected: FAIL because `src/media/mediaResolver.ts` does not exist. + +- [ ] **Step 3: Implement resolver** + +Create `src/media/mediaResolver.ts`: + +```ts +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; +import { AppError } from "../errors"; +import type { ResolvedMediaSource } from "./mediaTypes"; + +export async function resolveMediaSource( + input: string, +): Promise { + const source = input.trim(); + if (!source) { + throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); + } + + if (source.startsWith("http://") || source.startsWith("https://")) { + return { + source, + title: titleFromUrl(source), + kind: "url", + }; + } + + if (existsSync(source) && statSync(source).isFile()) { + return { + source, + title: path.basename(source), + kind: "local", + }; + } + + throw new AppError( + "Media source must be an HTTP(S) URL or existing local file", + "UNSUPPORTED_MEDIA_SOURCE", + 400, + ); +} + +function titleFromUrl(source: string): string { + const url = new URL(source); + const filename = decodeURIComponent(url.pathname.split("/").pop() || ""); + return filename || url.hostname; +} +``` + +- [ ] **Step 4: Run resolver test to verify it passes** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/mediaResolver.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit task 2** + +```bash +git -C /mnt/code/bete add src/media/mediaResolver.ts tests/media/mediaResolver.test.ts +git -C /mnt/code/bete commit -m "feat: resolve media music sources" +``` + +--- + +### Task 3: Music Player and DiscordPlayer Connection State + +**Files:** +- Modify: `src/player.ts:11-55` +- Create: `src/media/musicPlayer.ts` +- Test: `tests/media/musicPlayer.test.ts` + +- [ ] **Step 1: Write failing music player tests** + +Create `tests/media/musicPlayer.test.ts`: + +```ts +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { createMusicPlayer } from "../../src/media/musicPlayer"; +import type { DiscordAudioPlayer } from "../../src/media/mediaTypes"; + +class FakeProcess extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + killed = false; + kill = vi.fn(() => { + this.killed = true; + this.emit("close", 0); + return true; + }); +} + +describe("createMusicPlayer", () => { + it("spawns ffmpeg as Ogg Opus and passes stdout to Discord", async () => { + const proc = new FakeProcess(); + const spawn = vi.fn(() => proc); + const discordPlayer: DiscordAudioPlayer = { + isConnected: () => true, + playStream: vi.fn(), + stop: vi.fn(), + }; + const player = createMusicPlayer({ spawn, discordPlayer }); + + const playback = player.play({ + source: "https://example.com/song.mp3", + title: "song.mp3", + kind: "url", + }); + proc.emit("close", 0); + await playback.done; + + expect(spawn).toHaveBeenCalledWith("ffmpeg", [ + "-hide_banner", + "-loglevel", + "warning", + "-i", + "https://example.com/song.mp3", + "-vn", + "-acodec", + "libopus", + "-ar", + "48000", + "-ac", + "2", + "-f", + "ogg", + "pipe:1", + ], { stdio: ["ignore", "pipe", "pipe"] }); + expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout); + }); + + it("kills ffmpeg and stops Discord playback", () => { + const proc = new FakeProcess(); + const discordPlayer: DiscordAudioPlayer = { + isConnected: () => true, + playStream: vi.fn(), + stop: vi.fn(), + }; + const player = createMusicPlayer({ spawn: vi.fn(() => proc), discordPlayer }); + + const playback = player.play({ source: "/tmp/song.ogg", title: "song.ogg", kind: "local" }); + playback.stop(); + + expect(proc.kill).toHaveBeenCalledWith("SIGTERM"); + expect(discordPlayer.stop).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/musicPlayer.test.ts +``` + +Expected: FAIL because `src/media/musicPlayer.ts` does not exist. + +- [ ] **Step 3: Add connection helper to DiscordPlayer** + +Modify `src/player.ts` by adding this method after `setConnection()`: + +```ts + public isConnected(): boolean { + return this.connection !== null; + } +``` + +- [ ] **Step 4: Implement music player** + +Create `src/media/musicPlayer.ts`: + +```ts +import { spawn as nodeSpawn } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { discordPlayer } from "../player"; +import type { + DiscordAudioPlayer, + MusicPlayback, + MusicPlayer, + ResolvedMediaSource, +} from "./mediaTypes"; + +export interface MusicPlayerDependencies { + spawn?: typeof nodeSpawn; + discordPlayer?: DiscordAudioPlayer; +} + +export function createMusicPlayer( + dependencies: MusicPlayerDependencies = {}, +): MusicPlayer { + const spawn = dependencies.spawn ?? nodeSpawn; + const audioPlayer = dependencies.discordPlayer ?? discordPlayer; + + return { + play(source: ResolvedMediaSource): MusicPlayback { + const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), { + stdio: ["ignore", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + + audioPlayer.playStream(proc.stdout); + + const done = new Promise((resolve, reject) => { + proc.on("error", reject); + proc.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`ffmpeg exited with code ${code}`)); + }); + }); + + return { + done, + stop() { + proc.kill("SIGTERM"); + audioPlayer.stop(); + }, + }; + }, + }; +} + +export function buildFfmpegArgs(source: string): string[] { + return [ + "-hide_banner", + "-loglevel", + "warning", + "-i", + source, + "-vn", + "-acodec", + "libopus", + "-ar", + "48000", + "-ac", + "2", + "-f", + "ogg", + "pipe:1", + ]; +} +``` + +- [ ] **Step 5: Run music player test to verify it passes** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/musicPlayer.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit task 3** + +```bash +git -C /mnt/code/bete add src/player.ts src/media/musicPlayer.ts tests/media/musicPlayer.test.ts +git -C /mnt/code/bete commit -m "feat: add ffmpeg music player" +``` + +--- + +### Task 4: Media Controller + +**Files:** +- Create: `src/media/mediaController.ts` +- Test: `tests/media/mediaController.test.ts` + +- [ ] **Step 1: Write failing controller tests** + +Create `tests/media/mediaController.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { AppError } from "../../src/errors"; +import { MediaController } from "../../src/media/mediaController"; +import type { MusicPlayback, MusicPlayer, ResolvedMediaSource } from "../../src/media/mediaTypes"; + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function source(input: string): ResolvedMediaSource { + return { source: input, title: input.split("/").pop() || input, kind: "url" }; +} + +describe("MediaController", () => { + it("rejects queue playback when voice is not connected", async () => { + const controller = new MediaController({ + isVoiceConnected: () => false, + isBrowserStreaming: () => false, + resolveMediaSource: async () => source("https://example.com/song.mp3"), + musicPlayer: { play: vi.fn() }, + }); + + await expect(controller.queue("https://example.com/song.mp3")).rejects.toMatchObject({ + code: "VOICE_NOT_CONNECTED", + statusCode: 409, + } satisfies Partial); + }); + + it("queues and starts the first item", async () => { + const done = deferred(); + const playback: MusicPlayback = { done: done.promise, stop: vi.fn() }; + const musicPlayer: MusicPlayer = { play: vi.fn(() => playback) }; + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async () => source("https://example.com/song.mp3"), + musicPlayer, + }); + + const state = await controller.queue("https://example.com/song.mp3"); + + expect(state.playing).toBe(true); + expect(state.current?.title).toBe("song.mp3"); + expect(musicPlayer.play).toHaveBeenCalledWith(state.current); + }); + + it("advances to the next item when playback finishes", async () => { + const first = deferred(); + const second = deferred(); + const musicPlayer: MusicPlayer = { + play: vi + .fn() + .mockReturnValueOnce({ done: first.promise, stop: vi.fn() }) + .mockReturnValueOnce({ done: second.promise, stop: vi.fn() }), + }; + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async (input) => source(input), + musicPlayer, + }); + + await controller.queue("https://example.com/first.mp3"); + await controller.queue("https://example.com/second.mp3"); + first.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(controller.getState().current?.title).toBe("second.mp3"); + }); + + it("stops current playback and clears the queue", async () => { + const stop = vi.fn(); + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async (input) => source(input), + musicPlayer: { play: vi.fn(() => ({ done: new Promise(() => {}), stop })) }, + }); + await controller.queue("https://example.com/song.mp3"); + + const state = await controller.stop(); + + expect(stop).toHaveBeenCalled(); + expect(state).toEqual({ playing: false, current: null, queue: [] }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/mediaController.test.ts +``` + +Expected: FAIL because `src/media/mediaController.ts` does not exist. + +- [ ] **Step 3: Implement controller** + +Create `src/media/mediaController.ts`: + +```ts +import { AppError } from "../errors"; +import { discordPlayer } from "../player"; +import { MediaQueue } from "./mediaQueue"; +import { resolveMediaSource } from "./mediaResolver"; +import { createMusicPlayer } from "./musicPlayer"; +import type { + MediaState, + MusicPlayback, + MusicPlayer, + ResolvedMediaSource, +} from "./mediaTypes"; + +export interface MediaControllerDependencies { + isVoiceConnected?: () => boolean; + isBrowserStreaming?: () => boolean; + resolveMediaSource?: (source: string) => Promise; + musicPlayer?: MusicPlayer; + onStateChange?: (state: MediaState) => void; +} + +export class MediaController { + private readonly queueStore = new MediaQueue(); + private playback: MusicPlayback | null = null; + private skipInProgress = false; + + constructor(private readonly dependencies: MediaControllerDependencies = {}) {} + + getState(): MediaState { + const snapshot = this.queueStore.snapshot(); + return { + playing: snapshot.current?.status === "playing", + ...snapshot, + }; + } + + async queue(source: string): Promise { + this.assertCanStart(); + const resolved = await (this.dependencies.resolveMediaSource ?? resolveMediaSource)( + source, + ); + this.queueStore.add(resolved); + this.startNextIfIdle(); + return this.emitState(); + } + + async skip(): Promise { + if (this.skipInProgress) { + throw new AppError("Skip already in progress", "MEDIA_SKIP_IN_PROGRESS", 409); + } + + this.skipInProgress = true; + try { + this.playback?.stop(); + this.playback = null; + this.queueStore.completeCurrent(); + this.startNextIfIdle(); + return this.emitState(); + } finally { + this.skipInProgress = false; + } + } + + async stop(): Promise { + this.playback?.stop(); + this.playback = null; + this.queueStore.clear(); + return this.emitState(); + } + + private assertCanStart(): 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.dependencies.isBrowserStreaming?.()) { + throw new AppError( + "Stop browser microphone streaming before playing media", + "BROWSER_STREAM_ACTIVE", + 409, + ); + } + } + + private startNextIfIdle(): void { + if (this.playback) return; + const item = this.queueStore.startNext(); + if (!item) return; + + const player = this.dependencies.musicPlayer ?? createMusicPlayer(); + this.playback = player.play(item); + this.playback.done.then( + () => this.finishCurrent(false), + () => this.finishCurrent(true), + ); + } + + private finishCurrent(failed: boolean): void { + this.playback = null; + if (failed) { + this.queueStore.failCurrent(); + } else { + this.queueStore.completeCurrent(); + } + this.startNextIfIdle(); + this.emitState(); + } + + private emitState(): MediaState { + const state = this.getState(); + this.dependencies.onStateChange?.(state); + return state; + } +} +``` + +- [ ] **Step 4: Run controller tests to verify they pass** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/mediaController.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit task 4** + +```bash +git -C /mnt/code/bete add src/media/mediaController.ts tests/media/mediaController.test.ts +git -C /mnt/code/bete commit -m "feat: coordinate media playback state" +``` + +--- + +### Task 5: Media Routes + +**Files:** +- Create: `src/routes/mediaRoutes.ts` +- Test: `tests/routes/mediaRoutes.test.ts` + +- [ ] **Step 1: Write failing route tests** + +Create `tests/routes/mediaRoutes.test.ts`: + +```ts +import type { Request, Response } from "express"; +import { describe, expect, it, vi } from "vitest"; +import { createMediaRoutes } from "../../src/routes/mediaRoutes"; + +function getHandler(router: ReturnType, path: string, method: string) { + const layer = router.stack.find((item) => item.route?.path === path); + return layer?.route?.stack.find((item) => item.method === method)?.handle; +} + +describe("createMediaRoutes", () => { + it("returns media status", async () => { + const controller = { + getState: vi.fn(() => ({ playing: false, current: null, queue: [] })), + queue: vi.fn(), + skip: vi.fn(), + stop: vi.fn(), + }; + const handler = getHandler(createMediaRoutes(controller), "/media/status", "get"); + const json = vi.fn(); + + await handler?.({} as Request, { json } as unknown as Response, vi.fn()); + + expect(json).toHaveBeenCalledWith({ playing: false, current: null, queue: [] }); + }); + + it("queues a source", async () => { + const state = { playing: true, 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://example.com/song.mp3" } } as Request, + { json } as unknown as Response, + vi.fn(), + ); + + expect(controller.queue).toHaveBeenCalledWith("https://example.com/song.mp3"); + expect(json).toHaveBeenCalledWith(state); + }); + + it("passes missing source 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: {} } as Request, + { json: vi.fn() } as unknown as Response, + next, + ); + + expect(next.mock.calls[0][0]).toMatchObject({ + code: "MISSING_MEDIA_SOURCE", + statusCode: 400, + }); + }); +}); +``` + +- [ ] **Step 2: Run route test to verify it fails** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/routes/mediaRoutes.test.ts +``` + +Expected: FAIL because `src/routes/mediaRoutes.ts` does not exist. + +- [ ] **Step 3: Implement media routes** + +Create `src/routes/mediaRoutes.ts`: + +```ts +import type { Router } from "express"; +import express from "express"; +import { AppError } from "../errors"; +import type { MediaController } from "../media/mediaController"; + +export type MediaRouteController = Pick< + MediaController, + "getState" | "queue" | "skip" | "stop" +>; + +export function createMediaRoutes(controller: MediaRouteController): Router { + const router = express.Router(); + + router.get("/media/status", (_req, res, next) => { + try { + res.json(controller.getState()); + } catch (error) { + next(error); + } + }); + + router.post("/media/queue", async (req, res, next) => { + try { + const { source } = req.body as { source?: string }; + if (!source) { + throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); + } + res.json(await controller.queue(source)); + } catch (error) { + next(error); + } + }); + + router.post("/media/skip", async (_req, res, next) => { + try { + res.json(await controller.skip()); + } catch (error) { + next(error); + } + }); + + router.post("/media/stop", async (_req, res, next) => { + try { + res.json(await controller.stop()); + } catch (error) { + next(error); + } + }); + + return router; +} +``` + +- [ ] **Step 4: Run route test to verify it passes** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/routes/mediaRoutes.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit task 5** + +```bash +git -C /mnt/code/bete add src/routes/mediaRoutes.ts tests/routes/mediaRoutes.test.ts +git -C /mnt/code/bete commit -m "feat: expose media playback routes" +``` + +--- + +### Task 6: Webserver Wiring and WebSocket State + +**Files:** +- Modify: `src/webserver.ts:12-236` +- Test: `tests/routes/mediaRoutes.test.ts` or new `tests/media/mediaController.test.ts` assertion if needed + +- [ ] **Step 1: Add media state broadcast test to controller tests** + +Append to `tests/media/mediaController.test.ts`: + +```ts + it("emits state changes", async () => { + const onStateChange = vi.fn(); + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async (input) => source(input), + musicPlayer: { play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })) }, + onStateChange, + }); + + await controller.queue("https://example.com/song.mp3"); + + expect(onStateChange).toHaveBeenCalledWith( + expect.objectContaining({ playing: true }), + ); + }); +``` + +- [ ] **Step 2: Run test to verify behavior passes before wiring** + +Run: + +```bash +pnpm -C /mnt/code/bete vitest run tests/media/mediaController.test.ts +``` + +Expected: PASS if Task 4 already emits state; if it fails, fix `emitState()` before webserver wiring. + +- [ ] **Step 3: Wire media controller into webserver** + +Modify `src/webserver.ts` imports: + +```ts +import { MediaController } from "./media/mediaController"; +import { createMediaRoutes } from "./routes/mediaRoutes"; +``` + +After broadcaster creation at line 160, add: + +```ts + const mediaController = new MediaController({ + isVoiceConnected: () => voiceController.getStatus().connected, + isBrowserStreaming: () => sharedUIState.isStreaming, + onStateChange: (state) => broadcaster.sendJson?.({ + type: "media_state", + state, + timestamp: Date.now(), + }), + }); +``` + +If `ModerationBroadcaster` does not expose `sendJson`, add a typed method in `src/moderation/broadcaster.ts` instead: + +```ts +mediaState(state: MediaState): void; +``` + +and implement it with the same broadcast pattern used for `uiState`. + +Mount routes after `createSyncRoutes(_client)`: + +```ts + app.use("/api", createMediaRoutes(mediaController)); +``` + +Inside the WebSocket connection setup after sending `ui_state`, send current media state: + +```ts + ws.send(JSON.stringify({ type: "media_state", state: mediaController.getState() })); +``` + +- [ ] **Step 4: Run typecheck** + +Run: + +```bash +pnpm -C /mnt/code/bete run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit task 6** + +```bash +git -C /mnt/code/bete add src/webserver.ts src/moderation/broadcaster.ts src/moderation/types.ts tests/media/mediaController.test.ts +git -C /mnt/code/bete commit -m "feat: wire media playback into webserver" +``` + +Only include `src/moderation/broadcaster.ts` and `src/moderation/types.ts` if the broadcaster method was required. + +--- + +### Task 7: Dashboard Media Controls + +**Files:** +- Modify: `public/index.html:32-164` + +- [ ] **Step 1: Add static Media card markup** + +In `public/index.html`, inside `
` after the Live Audio card, add: + +```html +
+

Media

Idle
+
+
+
No media queued
+
+``` + +- [ ] **Step 2: Add media state and element references** + +In the `state` object add: + +```js + media: { playing: false, current: null, queue: [] }, +``` + +In the `el` object add references: + +```js +mediaSourceInput: document.getElementById('mediaSourceInput'), mediaStatus: document.getElementById('mediaStatus'), queueMediaBtn: document.getElementById('queueMediaBtn'), skipMediaBtn: document.getElementById('skipMediaBtn'), stopMediaBtn: document.getElementById('stopMediaBtn'), mediaQueueList: document.getElementById('mediaQueueList') +``` + +- [ ] **Step 3: Handle media WebSocket events** + +In `handleJsonEvent(raw)`, add: + +```js +if (message.type === 'media_state') { state.media = message.state; renderMedia(); } +``` + +- [ ] **Step 4: Add media functions** + +Before event listener registration, add: + +```js + async function fetchMediaStatus() { state.media = await apiRequest('/api/media/status'); renderMedia(); } + async function queueMedia() { const source = el.mediaSourceInput.value.trim(); if (!source) return showError('Enter a music URL or local file path'); state.media = await apiRequest('/api/media/queue', { method: 'POST', body: JSON.stringify({ source }) }); el.mediaSourceInput.value = ''; renderMedia(); } + async function skipMedia() { state.media = await apiRequest('/api/media/skip', { method: 'POST' }); renderMedia(); } + async function stopMedia() { state.media = await apiRequest('/api/media/stop', { method: 'POST' }); renderMedia(); } + function renderMedia() { el.mediaQueueList.replaceChildren(); const current = state.media.current; el.mediaStatus.textContent = current ? `Playing ${current.title}` : 'Idle'; if (current) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = `Now: ${current.title}`; el.mediaQueueList.appendChild(item); } for (const queued of state.media.queue || []) { const item = document.createElement('div'); item.className = 'event-card'; item.textContent = queued.title; el.mediaQueueList.appendChild(item); } if (!current && (!state.media.queue || state.media.queue.length === 0)) appendEmpty(el.mediaQueueList, 'No media queued'); } +``` + +- [ ] **Step 5: Add media event listeners and init fetch** + +Add listeners: + +```js + el.queueMediaBtn.addEventListener('click', () => queueMedia().catch((error) => showError(error.message))); + el.skipMediaBtn.addEventListener('click', () => skipMedia().catch((error) => showError(error.message))); + el.stopMediaBtn.addEventListener('click', () => stopMedia().catch((error) => showError(error.message))); +``` + +Change init chain from: + +```js +apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message)); +``` + +to: + +```js +apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message)); +``` + +- [ ] **Step 6: Run lint** + +Run: + +```bash +pnpm -C /mnt/code/bete run lint +``` + +Expected: PASS. + +- [ ] **Step 7: Commit task 7** + +```bash +git -C /mnt/code/bete add public/index.html +git -C /mnt/code/bete commit -m "feat: add dashboard media controls" +``` + +--- + +### Task 8: Full Verification + +**Files:** +- No new files unless tests reveal a defect. + +- [ ] **Step 1: Run full tests** + +```bash +pnpm -C /mnt/code/bete run test +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run typecheck** + +```bash +pnpm -C /mnt/code/bete run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Run lint** + +```bash +pnpm -C /mnt/code/bete run lint +``` + +Expected: PASS. + +- [ ] **Step 4: Manual UI verification** + +Run the app with a real Discord token/environment: + +```bash +pnpm -C /mnt/code/bete run dev +``` + +Manual checks: + +1. Open `http://localhost:3000/`. +2. Connect to a voice channel from the Voice card. +3. Queue a short local audio file path or direct HTTP(S) audio URL. +4. Confirm audio plays in Discord. +5. Queue a second item and confirm it advances. +6. Click Skip and confirm current playback stops. +7. Click Stop and confirm queue clears. +8. Confirm browser microphone transmit returns `BROWSER_STREAM_ACTIVE` if active during media queue. + +- [ ] **Step 5: Commit any verification fixes** + +If fixes were required: + +```bash +git -C /mnt/code/bete add +git -C /mnt/code/bete commit -m "fix: stabilize media music playback" +``` + +If no fixes were required, do not create an empty commit. + +--- + +## Self-Review + +Spec coverage: + +- Queue foundation: Task 1. +- Source resolution: Task 2. +- ffmpeg Ogg Opus playback: Task 3. +- Voice-connected preflight, browser stream conflict, skip/stop/advance: Task 4. +- REST API: Task 5. +- WebSocket state and webserver integration: Task 6. +- Dashboard controls: Task 7. +- Full and manual verification: Task 8. +- Phase 2 compatibility: `MediaMode` includes `screen`, but no `Streamer` is instantiated in phase 1. + +Placeholder scan: no `TBD`, incomplete steps, or unspecified tests remain. + +Type consistency: `MediaState`, `MediaQueueItem`, `ResolvedMediaSource`, `MusicPlayer`, and route/controller method names are consistent across tasks. diff --git a/docs/superpowers/plans/2026-05-15-split-text-voice-selection.md b/docs/superpowers/plans/2026-05-15-split-text-voice-selection.md new file mode 100644 index 0000000..dfe21ff --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-split-text-voice-selection.md @@ -0,0 +1,466 @@ +# Split Text Voice Selection 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:** Separate text moderation guild/channel selection from voice recording guild/channel selection in config, backend state, and dashboard UI. + +**Architecture:** Add explicit text and voice config keys while keeping legacy `MONITOR_GUILD_ID` and `GUILD_ID` as fallbacks. Split shared UI state into `selectedTextGuild`/`selectedTextChannel` and `selectedVoiceGuild`/`selectedVoiceChannel`, with backward-compatible migration from old persisted `selectedGuild`. Update capture/backlog to use text-specific settings and voice routes to update only voice-specific state. + +**Tech Stack:** TypeScript, Zod config, Express routes, Discord selfbot client, Vitest, static dashboard JavaScript. + +--- + +## File Structure + +- Modify `src/config.ts`: add `TEXT_GUILD_ID`, `TEXT_CHANNEL_ID`, `VOICE_GUILD_ID`; derive effective text/voice IDs with legacy fallbacks. +- Modify `.env.example`: document split text/voice configuration. +- Modify `src/moderation/messageCapture.ts`: filter live capture by effective text guild and optional text channel. +- Modify `src/moderation/backlogSync.ts`: use effective text guild and optional text channel for readiness/on-demand sync. +- Modify `src/webserver.ts`: change `SharedUIState` to split text/voice guild fields and migrate old persisted state. +- Modify `src/routes/uiStateRoutes.ts`: update shared UI state type. +- Modify `src/routes/voiceRoutes.ts`: patch `selectedVoiceGuild` only on connect/disconnect. +- Modify `public/index.html`: add separate voice guild select and text guild select behavior. +- Tests: `tests/config.test.ts`, `tests/moderation/messageCapture.test.ts`, and a new UI state route/unit test if needed. + +## Task 1: Split Config Defaults + +**Files:** +- Modify: `src/config.ts` +- Modify: `.env.example` +- Test: `tests/config.test.ts` + +- [ ] **Step 1: Write failing config tests** + +Add tests to `tests/config.test.ts`: + +```ts + it("derives split text and voice guild defaults from legacy config", async () => { + process.env = { + ...originalEnv, + DISCORD_TOKEN: "token", + MONITOR_GUILD_ID: "legacy-text-guild", + GUILD_ID: "legacy-voice-guild", + VOICE_CHANNEL_ID: "voice-channel", + NODE_ENV: "test", + }; + + const { loadConfig } = await import("../src/config"); + const config = loadConfig(process.env); + + expect(config.TEXT_GUILD_ID).toBeUndefined(); + expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild"); + expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild"); + expect(config.VOICE_CHANNEL_ID).toBe("voice-channel"); + }); + + it("uses explicit split text and voice config before legacy values", async () => { + process.env = { + ...originalEnv, + DISCORD_TOKEN: "token", + MONITOR_GUILD_ID: "legacy-text-guild", + GUILD_ID: "legacy-voice-guild", + TEXT_GUILD_ID: "text-guild", + TEXT_CHANNEL_ID: "text-channel", + VOICE_GUILD_ID: "voice-guild", + VOICE_CHANNEL_ID: "voice-channel", + NODE_ENV: "test", + }; + + const { loadConfig } = await import("../src/config"); + const config = loadConfig(process.env); + + expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild"); + expect(config.TEXT_CHANNEL_ID).toBe("text-channel"); + expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild"); + }); +``` + +- [ ] **Step 2: Run config tests red** + +Run: `pnpm exec vitest run tests/config.test.ts` + +Expected: FAIL because `EFFECTIVE_TEXT_GUILD_ID` and `EFFECTIVE_VOICE_GUILD_ID` do not exist. + +- [ ] **Step 3: Add split config fields and derived values** + +In `src/config.ts`, add schema fields near legacy guild config: + +```ts +TEXT_GUILD_ID: z.string().min(1).optional(), +TEXT_CHANNEL_ID: z.string().min(1).optional(), +VOICE_GUILD_ID: z.string().min(1).optional(), +``` + +Change `loadConfig` to parse then return derived values: + +```ts +const parsed = configSchema.parse(env); +return { + ...parsed, + EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID, + EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID, +}; +``` + +Update `AppConfig` to include derived fields: + +```ts +export type AppConfig = z.infer & { + EFFECTIVE_TEXT_GUILD_ID?: string; + EFFECTIVE_VOICE_GUILD_ID?: string; +}; +``` + +- [ ] **Step 4: Update `.env.example`** + +Document: + +```env +# Text moderation capture target. Falls back to MONITOR_GUILD_ID for compatibility. +TEXT_GUILD_ID= +TEXT_CHANNEL_ID= + +# Voice recording default target. Falls back to GUILD_ID for compatibility. +VOICE_GUILD_ID= +VOICE_CHANNEL_ID= +``` + +Keep existing legacy keys with notes rather than deleting them. + +- [ ] **Step 5: Run config tests green** + +Run: `pnpm exec vitest run tests/config.test.ts` + +Expected: PASS. + +## Task 2: Apply Text Capture Guild/Channel Filtering + +**Files:** +- Modify: `src/moderation/messageCapture.ts` +- Modify: `src/moderation/backlogSync.ts` +- Test: `tests/moderation/messageCapture.test.ts` + +- [ ] **Step 1: Write failing channel filter test** + +In `tests/moderation/messageCapture.test.ts`, mock config before importing `captureMessage` if needed or add a new test file `tests/moderation/messageCaptureFilter.test.ts` that imports a new exported helper. + +Preferred helper test: create `tests/moderation/messageCaptureFilter.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { shouldCaptureMessageLocation } from "../../src/moderation/messageCapture"; + +describe("shouldCaptureMessageLocation", () => { + it("matches only configured text guild and optional channel", () => { + expect( + shouldCaptureMessageLocation( + { guildId: "guild-1", channelId: "channel-1" }, + { guildId: "guild-1", channelId: "channel-1" }, + ), + ).toBe(true); + + expect( + shouldCaptureMessageLocation( + { guildId: "guild-1", channelId: "channel-2" }, + { guildId: "guild-1", channelId: "channel-1" }, + ), + ).toBe(false); + + expect( + shouldCaptureMessageLocation( + { guildId: "guild-2", channelId: "channel-1" }, + { guildId: "guild-1", channelId: "channel-1" }, + ), + ).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run filter test red** + +Run: `pnpm exec vitest run tests/moderation/messageCaptureFilter.test.ts` + +Expected: FAIL because `shouldCaptureMessageLocation` does not exist. + +- [ ] **Step 3: Add capture filter helper** + +In `src/moderation/messageCapture.ts`, export: + +```ts +export interface TextCaptureTarget { + guildId?: string; + channelId?: string; +} + +export interface MessageLocationInput { + guildId?: string | null; + channelId?: string | null; +} + +export function shouldCaptureMessageLocation( + message: MessageLocationInput, + target: TextCaptureTarget, +): boolean { + if (!message.guildId || message.guildId !== target.guildId) return false; + if (target.channelId && message.channelId !== target.channelId) return false; + return true; +} +``` + +Replace event checks: + +```ts +if ( + !shouldCaptureMessageLocation(message, { + guildId: config.EFFECTIVE_TEXT_GUILD_ID, + channelId: config.TEXT_CHANNEL_ID, + }) +) + return; +``` + +Use the same helper for `messageUpdate` and `messageDelete`. + +- [ ] **Step 4: Update backlog sync config** + +In `src/moderation/backlogSync.ts`, replace readiness checks with `config.EFFECTIVE_TEXT_GUILD_ID` and log names with `TEXT_GUILD_ID`. If `config.TEXT_CHANNEL_ID` is present in `syncBacklogMessages`, verify the channel exists and call `syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID)` instead of only logging readiness. + +- [ ] **Step 5: Run focused moderation tests** + +Run: `pnpm exec vitest run tests/moderation/messageCapture.test.ts tests/moderation/messageCaptureFilter.test.ts` + +Expected: PASS. + +## Task 3: Split Shared UI State + +**Files:** +- Modify: `src/webserver.ts` +- Modify: `src/routes/uiStateRoutes.ts` +- Modify: `src/routes/voiceRoutes.ts` +- Test: create `tests/routes/uiStateRoutes.test.ts` if no existing route test fits. + +- [ ] **Step 1: Write state migration test** + +Create `tests/routes/uiStateRoutes.test.ts` with a pure helper import if extracted. Add helper in Task 3 implementation. + +```ts +import { describe, expect, it } from "vitest"; +import { normalizeSharedUIState } from "../../src/webserver"; + +describe("normalizeSharedUIState", () => { + it("migrates legacy selectedGuild into split text and voice guilds", () => { + expect( + normalizeSharedUIState({ + selectedGuild: "legacy-guild", + selectedVoiceChannel: "voice-channel", + selectedTextChannel: "text-channel", + }), + ).toMatchObject({ + selectedVoiceGuild: "legacy-guild", + selectedVoiceChannel: "voice-channel", + selectedTextGuild: "legacy-guild", + selectedTextChannel: "text-channel", + }); + }); +}); +``` + +- [ ] **Step 2: Run state test red** + +Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts` + +Expected: FAIL because `normalizeSharedUIState` does not exist/export. + +- [ ] **Step 3: Update shared state types** + +In `src/routes/uiStateRoutes.ts` and `src/webserver.ts`, replace `selectedGuild` with: + +```ts +selectedVoiceGuild: string; +selectedVoiceChannel: string; +selectedTextGuild: string; +selectedTextChannel: string; +``` + +Keep request patch compatibility by allowing `selectedGuild?: string` in the normalization helper input. + +- [ ] **Step 4: Add normalizer and use it after persistence load** + +In `src/webserver.ts`, export: + +```ts +export function normalizeSharedUIState(value: Partial & { selectedGuild?: string }): SharedUIState { + const legacyGuild = value.selectedGuild ?? ""; + return { + selectedVoiceGuild: value.selectedVoiceGuild ?? legacyGuild, + selectedVoiceChannel: value.selectedVoiceChannel ?? "", + selectedTextGuild: value.selectedTextGuild ?? legacyGuild, + selectedTextChannel: value.selectedTextChannel ?? "", + activeTab: value.activeTab === "text" ? "text" : "voice", + isListening: value.isListening ?? false, + isStreaming: value.isStreaming ?? false, + }; +} +``` + +Use it in `initializeSharedUIState()`: + +```ts +sharedUIState = normalizeSharedUIState( + await getPersistedValue("web-ui-state", defaultSharedUIState), +); +``` + +Update `patchSharedUIState` to accept `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel`; if legacy `selectedGuild` arrives, set both guild fields. + +- [ ] **Step 5: Update voice route patches** + +In `src/routes/voiceRoutes.ts`, connect patch becomes: + +```ts +selectedVoiceGuild: guildId, +selectedVoiceChannel: channelId, +``` + +Disconnect clears only: + +```ts +selectedVoiceGuild: "", +selectedVoiceChannel: "", +``` + +Do not clear text guild/channel on voice disconnect. + +- [ ] **Step 6: Run state tests** + +Run: `pnpm exec vitest run tests/routes/uiStateRoutes.test.ts` + +Expected: PASS. + +## Task 4: Update Static Dashboard Selection + +**Files:** +- Modify: `public/index.html` + +- [ ] **Step 1: Replace state fields** + +Change JS state fields: + +```js +selectedVoiceGuild: '', +selectedVoiceChannel: '', +selectedTextGuild: '', +selectedTextChannel: '', +``` + +Remove direct reliance on `selectedGuild` except migration when applying server state. + +- [ ] **Step 2: Add separate DOM selectors** + +In the UI markup, provide separate select elements for voice guild and text guild. Use IDs: + +```html + + + + +``` + +Update the `el` map to use `voiceGuildSelect` and `textGuildSelect`. + +- [ ] **Step 3: Split channel loading functions** + +Replace `loadChannels(guildId)` with: + +```js +async function loadVoiceChannels(guildId) { + if (!guildId) return renderOptions(el.channelSelect, [], 'Select voice channel'); + const voiceChannels = await apiRequest(`/api/guilds/${guildId}/voice-channels`); + renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); + if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; +} + +async function loadTextChannels(guildId) { + if (!guildId) return renderOptions(el.channelFilter, [], 'Select channel'); + const watchChannels = await apiRequest(`/api/guilds/${guildId}/channels`); + renderOptions(el.channelFilter, watchChannels, 'Select channel'); + if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; + apiRequest(`/api/guilds/${guildId}/threads`) + .then((threads) => { + appendOptions(el.channelFilter, threads); + if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; + }) + .catch((error) => showError(`Thread discovery failed: ${error.message}`)); +} +``` + +- [ ] **Step 4: Split state application** + +In `applyServerState`, compute: + +```js +const nextVoiceGuild = next.selectedVoiceGuild || next.selectedGuild || ''; +const nextTextGuild = next.selectedTextGuild || next.selectedGuild || ''; +const voiceGuildChanged = nextVoiceGuild !== state.selectedVoiceGuild; +const textGuildChanged = nextTextGuild !== state.selectedTextGuild; +``` + +Load voice channels only when voice guild changes; load text channels only when text guild changes. Backlog sync uses `state.selectedTextGuild`. + +- [ ] **Step 5: Split event listeners** + +Use: + +```js +el.voiceGuildSelect.addEventListener('change', () => postUIState({ selectedVoiceGuild: el.voiceGuildSelect.value, selectedVoiceChannel: '' }).catch((error) => showError(error.message))); +el.textGuildSelect.addEventListener('change', () => postUIState({ selectedTextGuild: el.textGuildSelect.value, selectedTextChannel: '' }).catch((error) => showError(error.message))); +el.channelSelect.addEventListener('change', () => postUIState({ selectedVoiceChannel: el.channelSelect.value }).catch((error) => showError(error.message))); +el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.textGuildSelect.value) url.searchParams.set('guild', el.textGuildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); }); +``` + +- [ ] **Step 6: Manual UI verification** + +Run: `pnpm run build` + +Expected: PASS. Then start the app if credentials are available and verify selecting voice guild does not reset text guild/channel and selecting text guild does not reset voice guild/channel. + +## Task 5: Final Verification + +**Files:** +- No planned edits unless verification fails. + +- [ ] **Step 1: Run lint** + +Run: `pnpm run lint` + +Expected: PASS. + +- [ ] **Step 2: Run typecheck** + +Run: `pnpm run typecheck` + +Expected: PASS. + +- [ ] **Step 3: Run tests** + +Run: `pnpm run test` + +Expected: PASS. + +- [ ] **Step 4: Run build** + +Run: `pnpm run build` + +Expected: PASS. + +- [ ] **Step 5: Inspect status** + +Run: `git status --short` + +Expected: only intended files changed. + +## Self-Review + +- Spec coverage: config split is Task 1; capture/backlog filtering is Task 2; backend UI state split is Task 3; dashboard split is Task 4; verification is Task 5. +- Placeholder scan: no TBD/TODO/fill-in steps remain. +- Type consistency: split fields use `selectedVoiceGuild`, `selectedVoiceChannel`, `selectedTextGuild`, `selectedTextChannel` consistently. diff --git a/docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md b/docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md new file mode 100644 index 0000000..5624398 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-media-music-phase-1-design.md @@ -0,0 +1,110 @@ +# Media Music Phase 1 Design + +## Goal + +Add a first media playback phase focused on play music: users can queue, play, skip, and stop audio sources from the dashboard while preserving the existing Discord voice recorder, browser microphone transmit, and moderation capture flows. + +## Scope + +Phase 1 implements audio-only playback and queue control. Share screen/video streaming is intentionally reserved for phase 2, but the controller shape should leave room for a later `screen` mode using the already vendored `@dank074/discord-video-stream` APIs seen in `MythEclipse/StreamBot`. + +## Recommended Architecture + +Create a small media subsystem under `src/media/`: + +- `mediaTypes.ts` defines `MediaMode`, `MediaQueueItem`, `MediaState`, and request/response types. +- `mediaQueue.ts` owns in-memory queue operations: add, current, next, remove current, clear, snapshot. +- `mediaResolver.ts` resolves initial supported sources. Phase 1 should support direct HTTP(S) URLs and local file paths. YouTube/search can be added later because it requires adding or wrapping yt-dlp behavior. +- `musicPlayer.ts` converts a media source to Ogg Opus using ffmpeg and feeds the existing `discordPlayer.playStream()`. +- `mediaController.ts` coordinates queue state, voice connection assumptions, play/skip/stop, and WebSocket broadcast state. + +The existing `VoiceController` remains the owner of joining/leaving voice channels. Phase 1 does not create a second voice connection path. Music playback requires the bot to already be connected through the existing voice UI or `/api/connect`; otherwise the media route returns `409 VOICE_NOT_CONNECTED`. + +## Data Flow + +1. Browser submits a source to `/api/media/queue` with `{ source }`. +2. `mediaResolver` validates and resolves the source into `{ source, title, kind }`. +3. `mediaQueue` appends a `MediaQueueItem`. +4. If no item is playing, `mediaController` starts playback of the current queue item. +5. `musicPlayer` spawns ffmpeg and outputs Ogg Opus to `discordPlayer.playStream()`. +6. When playback finishes, the controller removes the completed item and starts the next item. +7. State changes broadcast over the existing moderation broadcaster as a JSON WebSocket event, or via a small media broadcaster wrapper if that keeps types cleaner. + +## API Design + +Add `src/routes/mediaRoutes.ts` mounted under `/api`: + +- `GET /api/media/status` returns `{ playing, current, queue }`. +- `POST /api/media/queue` accepts `{ source: string }`, queues it, and returns the updated state. +- `POST /api/media/skip` skips current item and starts the next if present. +- `POST /api/media/stop` stops playback and clears the queue. + +All routes should use `AppError` for boundary validation. Empty source returns `400 MISSING_MEDIA_SOURCE`. No voice connection returns `409 VOICE_NOT_CONNECTED`. + +## Dashboard Design + +Add a compact Media card to the existing voice tab for phase 1: + +- Source input: URL or local path. +- Buttons: Queue/Play, Skip, Stop. +- Current item label and queue list. + +Do not add a separate full media tab yet. The voice tab already owns voice channel selection and connection state, so colocating music controls there reduces user confusion. + +## Playback Details + +Use ffmpeg directly or the existing `src/audio/ffmpegProcess.ts` helper if it already fits. The target stream should be Ogg Opus because `DiscordPlayer.playStream()` currently expects `StreamType.OggOpus`. + +Recommended ffmpeg output shape: + +- Input: local file or HTTP(S) URL. +- Output format: `ogg`. +- Audio codec: `libopus`. +- Sample rate: `48000`. +- Channels: `2`. + +The controller owns an `AbortController` or child process handle so skip/stop can terminate ffmpeg. Stop must also call `discordPlayer.stop()` so the audio player releases the current resource. + +## Concurrency Rules + +- Only one media item plays at a time. +- Browser microphone transmit and music playback both use `discordPlayer`; phase 1 should disable music start while `isStreaming` is true, or stop browser transmit before playback. Prefer returning `409 BROWSER_STREAM_ACTIVE` to avoid surprising the user. +- Voice recording can continue while music plays because recording uses the receiver pipeline and music uses the player pipeline. +- Skip is serialized: concurrent skip calls should return the same resulting state or reject with `409 MEDIA_SKIP_IN_PROGRESS`. + +## Error Handling + +- Unsupported source format: `400 UNSUPPORTED_MEDIA_SOURCE`. +- ffmpeg spawn failure: current item becomes failed, playback advances to the next queued item if present. +- ffmpeg runtime failure: log stderr summary, mark item failed, advance queue. +- Stop is idempotent: stopping while idle returns current idle state. + +## Tests + +Unit tests should cover: + +- Queue add/next/remove/clear behavior. +- Resolver accepts HTTP(S) URLs and existing local paths, rejects empty/unsupported input. +- Controller rejects playback when voice is not connected. +- Controller starts next item after completion. +- Skip aborts current playback and advances queue. +- Routes validate payloads and call controller methods. + +Manual verification should cover: + +- Connect to a voice channel, queue a short audio URL or local file, hear playback in Discord. +- Queue two items, confirm automatic advance. +- Skip moves to the next item. +- Stop clears playback and queue. +- Existing voice recording and text moderation still work after media playback. + +## Phase 2 Compatibility + +Phase 2 can add `MediaMode = "screen"` and a `screenSharePlayer.ts` using StreamBot's pattern: + +- `new Streamer(client)` +- `streamer.joinVoice(guildId, channelId)` only if phase 2 decides to own its own connection path +- `prepareStream(source, videoOptions, signal)` +- `playStream(output, streamer, { type: "go-live" }, signal)` + +Phase 1 should not instantiate `Streamer`; it should only reserve type and controller seams so adding screen share later does not rewrite queue/status APIs.