Files
dc-recorder/docs/superpowers/plans/2026-05-15-media-music-phase-1.md
MythEclipse a97feb1e2a feat: implement split text and voice selection in configuration and UI
- Added a new implementation plan for separating text moderation and voice recording configurations.
- Introduced new configuration keys for text and voice guild/channel IDs with backward compatibility.
- Updated moderation capture and backlog sync to filter based on the new text-specific settings.
- Split shared UI state into distinct text and voice fields, ensuring backward compatibility.
- Enhanced the static dashboard to support separate selections for text and voice channels.
- Created a new media subsystem for audio playback, allowing users to queue, play, skip, and stop audio sources.
- Defined API routes for media control and integrated with existing voice functionalities.
2026-05-15 18:29:20 +07:00

35 KiB

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:

import { describe, expect, it } from "vitest";
import { MediaQueue } from "../../src/media/mediaQueue";
import type { ResolvedMediaSource } from "../../src/media/mediaTypes";

function source(overrides: Partial<ResolvedMediaSource> = {}): 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:

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:

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<void>;
  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:

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<MediaState, "current" | "queue"> {
    return {
      current: this.current ? { ...this.current } : null,
      queue: this.items.map((item) => ({ ...item })),
    };
  }
}
  • Step 5: Run queue test to verify it passes

Run:

pnpm -C /mnt/code/bete vitest run tests/media/mediaQueue.test.ts

Expected: PASS.

  • Step 6: Commit task 1
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:

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<AppError>);
  });

  it("rejects unsupported sources", async () => {
    await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject({
      code: "UNSUPPORTED_MEDIA_SOURCE",
      statusCode: 400,
    } satisfies Partial<AppError>);
  });
});
  • Step 2: Run test to verify it fails

Run:

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:

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<ResolvedMediaSource> {
  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:

pnpm -C /mnt/code/bete vitest run tests/media/mediaResolver.test.ts

Expected: PASS.

  • Step 5: Commit task 2
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:

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:

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():

  public isConnected(): boolean {
    return this.connection !== null;
  }
  • Step 4: Implement music player

Create src/media/musicPlayer.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<void>((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:

pnpm -C /mnt/code/bete vitest run tests/media/musicPlayer.test.ts

Expected: PASS.

  • Step 6: Commit task 3
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:

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<void>((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<AppError>);
  });

  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:

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:

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<ResolvedMediaSource>;
  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<MediaState> {
    this.assertCanStart();
    const resolved = await (this.dependencies.resolveMediaSource ?? resolveMediaSource)(
      source,
    );
    this.queueStore.add(resolved);
    this.startNextIfIdle();
    return this.emitState();
  }

  async skip(): Promise<MediaState> {
    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<MediaState> {
    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:

pnpm -C /mnt/code/bete vitest run tests/media/mediaController.test.ts

Expected: PASS.

  • Step 5: Commit task 4
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:

import type { Request, Response } from "express";
import { describe, expect, it, vi } from "vitest";
import { createMediaRoutes } from "../../src/routes/mediaRoutes";

function getHandler(router: ReturnType<typeof createMediaRoutes>, 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:

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:

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:

pnpm -C /mnt/code/bete vitest run tests/routes/mediaRoutes.test.ts

Expected: PASS.

  • Step 5: Commit task 5
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:

  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:

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:

import { MediaController } from "./media/mediaController";
import { createMediaRoutes } from "./routes/mediaRoutes";

After broadcaster creation at line 160, add:

  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:

mediaState(state: MediaState): void;

and implement it with the same broadcast pattern used for uiState.

Mount routes after createSyncRoutes(_client):

  app.use("/api", createMediaRoutes(mediaController));

Inside the WebSocket connection setup after sending ui_state, send current media state:

    ws.send(JSON.stringify({ type: "media_state", state: mediaController.getState() }));
  • Step 4: Run typecheck

Run:

pnpm -C /mnt/code/bete run typecheck

Expected: PASS.

  • Step 5: Commit task 6
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 <div class="voice-layout"> after the Live Audio card, add:

        <div class="content-card">
          <div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
          <div class="field-group"><label for="mediaSourceInput">Music URL / file path</label><input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3"></div>
          <div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
          <div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div>
        </div>
  • Step 2: Add media state and element references

In the state object add:

      media: { playing: false, current: null, queue: [] },

In the el object add references:

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:

if (message.type === 'media_state') { state.media = message.state; renderMedia(); }
  • Step 4: Add media functions

Before event listener registration, add:

    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:

    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:

apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));

to:

apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).then(fetchMediaStatus).catch((error) => showError(error.message));
  • Step 6: Run lint

Run:

pnpm -C /mnt/code/bete run lint

Expected: PASS.

  • Step 7: Commit task 7
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

pnpm -C /mnt/code/bete run test

Expected: all tests pass.

  • Step 2: Run typecheck
pnpm -C /mnt/code/bete run typecheck

Expected: PASS.

  • Step 3: Run lint
pnpm -C /mnt/code/bete run lint

Expected: PASS.

  • Step 4: Manual UI verification

Run the app with a real Discord token/environment:

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:

git -C /mnt/code/bete add <changed-files>
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.