Files
dc-recorder/docs/superpowers/plans/2026-05-15-media-music-phase-1.md

1292 lines
35 KiB
Markdown
Raw Normal View History

# 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> = {}): 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<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`:
```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:
```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<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:
```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<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:
```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<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:
```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<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:
```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<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:
```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<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:
```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 `<div class="voice-layout">` after the Live Audio card, add:
```html
<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:
```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 <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.