style: format media music implementation

This commit is contained in:
MythEclipse
2026-05-15 18:04:39 +07:00
parent 192f83d31d
commit ff2792d403
13 changed files with 150 additions and 62 deletions

View File

@@ -2,13 +2,13 @@ import { AppError } from "../errors";
import { discordPlayer } from "../player"; import { discordPlayer } from "../player";
import { MediaQueue } from "./mediaQueue"; import { MediaQueue } from "./mediaQueue";
import { resolveMediaSource } from "./mediaResolver"; import { resolveMediaSource } from "./mediaResolver";
import { createMusicPlayer } from "./musicPlayer";
import type { import type {
MediaState, MediaState,
MusicPlayback, MusicPlayback,
MusicPlayer, MusicPlayer,
ResolvedMediaSource, ResolvedMediaSource,
} from "./mediaTypes"; } from "./mediaTypes";
import { createMusicPlayer } from "./musicPlayer";
export interface MediaControllerDependencies { export interface MediaControllerDependencies {
isVoiceConnected?: () => boolean; isVoiceConnected?: () => boolean;
@@ -39,9 +39,9 @@ export class MediaController {
async queue(source: string): Promise<MediaState> { async queue(source: string): Promise<MediaState> {
this.assertCanStart(); this.assertCanStart();
const resolved = await (this.dependencies.resolveMediaSource ?? resolveMediaSource)( const resolved = await (
source, this.dependencies.resolveMediaSource ?? resolveMediaSource
); )(source);
this.queueStore.add(resolved); this.queueStore.add(resolved);
this.startNextIfIdle(); this.startNextIfIdle();
return this.emitState(); return this.emitState();
@@ -49,7 +49,11 @@ export class MediaController {
async skip(): Promise<MediaState> { async skip(): Promise<MediaState> {
if (this.skipInProgress) { if (this.skipInProgress) {
throw new AppError("Skip already in progress", "MEDIA_SKIP_IN_PROGRESS", 409); throw new AppError(
"Skip already in progress",
"MEDIA_SKIP_IN_PROGRESS",
409,
);
} }
this.skipInProgress = true; this.skipInProgress = true;
@@ -74,8 +78,8 @@ export class MediaController {
} }
private assertCanStart(): void { private assertCanStart(): void {
const isVoiceConnected = this.dependencies.isVoiceConnected ?? const isVoiceConnected =
(() => discordPlayer.isConnected()); this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
if (!isVoiceConnected()) { if (!isVoiceConnected()) {
throw new AppError( throw new AppError(
"Connect to a voice channel before playing media", "Connect to a voice channel before playing media",

View File

@@ -1,5 +1,5 @@
import { spawn as nodeSpawn } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import { discordPlayer } from "../player"; import { discordPlayer } from "../player";
import type { import type {
DiscordAudioPlayer, DiscordAudioPlayer,

View File

@@ -23,7 +23,11 @@ export function createMediaRoutes(controller: MediaRouteController): Router {
try { try {
const { source } = req.body as { source?: string }; const { source } = req.body as { source?: string };
if (!source) { if (!source) {
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); throw new AppError(
"Media source is required",
"MISSING_MEDIA_SOURCE",
400,
);
} }
res.json(await controller.queue(source)); res.json(await controller.queue(source));
} catch (error) { } catch (error) {

View File

@@ -8,6 +8,7 @@ import * as prism from "prism-media";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { AppError } from "./errors"; import { AppError } from "./errors";
import { createChildLogger, logger } from "./logger"; import { createChildLogger, logger } from "./logger";
import { MediaController } from "./media/mediaController";
import { getMetrics, uptimeGauge } from "./metrics"; import { getMetrics, uptimeGauge } from "./metrics";
import { createBroadcaster } from "./moderation/broadcaster"; import { createBroadcaster } from "./moderation/broadcaster";
import type { ModerationBroadcaster } from "./moderation/types"; import type { ModerationBroadcaster } from "./moderation/types";
@@ -19,7 +20,6 @@ import { createMessageRoutes } from "./routes/messageRoutes";
import { createSyncRoutes } from "./routes/syncRoutes"; import { createSyncRoutes } from "./routes/syncRoutes";
import { createUIStateRoutes } from "./routes/uiStateRoutes"; import { createUIStateRoutes } from "./routes/uiStateRoutes";
import { createVoiceRoutes } from "./routes/voiceRoutes"; import { createVoiceRoutes } from "./routes/voiceRoutes";
import { MediaController } from "./media/mediaController";
import type { VoiceController } from "./voiceController"; import type { VoiceController } from "./voiceController";
const wsLogger = createChildLogger("webserver"); const wsLogger = createChildLogger("webserver");
@@ -378,7 +378,12 @@ export async function startWebserver(
}), }),
); );
ws.send(JSON.stringify({ type: "ui_state", state: getSharedUIState() })); ws.send(JSON.stringify({ type: "ui_state", state: getSharedUIState() }));
ws.send(JSON.stringify({ type: "media_state", state: mediaController.getState() })); ws.send(
JSON.stringify({
type: "media_state",
state: mediaController.getState(),
}),
);
ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => { ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
if (!Buffer.isBuffer(data)) return; if (!Buffer.isBuffer(data)) return;

View File

@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AppError } from "../../src/errors"; import { AppError } from "../../src/errors";
import { MediaController } from "../../src/media/mediaController"; import { MediaController } from "../../src/media/mediaController";
import type { MusicPlayback, MusicPlayer, ResolvedMediaSource } from "../../src/media/mediaTypes"; import type {
MusicPlayback,
MusicPlayer,
ResolvedMediaSource,
} from "../../src/media/mediaTypes";
function deferred() { function deferred() {
let resolve!: () => void; let resolve!: () => void;
@@ -26,7 +30,9 @@ describe("MediaController", () => {
musicPlayer: { play: vi.fn() }, musicPlayer: { play: vi.fn() },
}); });
await expect(controller.queue("https://example.com/song.mp3")).rejects.toMatchObject({ await expect(
controller.queue("https://example.com/song.mp3"),
).rejects.toMatchObject({
code: "VOICE_NOT_CONNECTED", code: "VOICE_NOT_CONNECTED",
statusCode: 409, statusCode: 409,
} satisfies Partial<AppError>); } satisfies Partial<AppError>);
@@ -40,7 +46,9 @@ describe("MediaController", () => {
musicPlayer: { play: vi.fn() }, musicPlayer: { play: vi.fn() },
}); });
await expect(controller.queue("https://example.com/song.mp3")).rejects.toMatchObject({ await expect(
controller.queue("https://example.com/song.mp3"),
).rejects.toMatchObject({
code: "BROWSER_STREAM_ACTIVE", code: "BROWSER_STREAM_ACTIVE",
statusCode: 409, statusCode: 409,
} satisfies Partial<AppError>); } satisfies Partial<AppError>);
@@ -94,7 +102,10 @@ describe("MediaController", () => {
const musicPlayer: MusicPlayer = { const musicPlayer: MusicPlayer = {
play: vi play: vi
.fn() .fn()
.mockReturnValueOnce({ done: new Promise<void>(() => {}), stop: currentStop }) .mockReturnValueOnce({
done: new Promise<void>(() => {}),
stop: currentStop,
})
.mockReturnValueOnce({ done: nextPlayback.promise, stop: vi.fn() }), .mockReturnValueOnce({ done: nextPlayback.promise, stop: vi.fn() }),
}; };
const controller = new MediaController({ const controller = new MediaController({
@@ -170,7 +181,9 @@ describe("MediaController", () => {
isVoiceConnected: () => true, isVoiceConnected: () => true,
isBrowserStreaming: () => false, isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input), resolveMediaSource: async (input) => source(input),
musicPlayer: { play: vi.fn(() => ({ done: new Promise<void>(() => {}), stop })) }, musicPlayer: {
play: vi.fn(() => ({ done: new Promise<void>(() => {}), stop })),
},
}); });
await controller.queue("https://example.com/song.mp3"); await controller.queue("https://example.com/song.mp3");
@@ -186,7 +199,9 @@ describe("MediaController", () => {
isVoiceConnected: () => true, isVoiceConnected: () => true,
isBrowserStreaming: () => false, isBrowserStreaming: () => false,
resolveMediaSource: async (input) => source(input), resolveMediaSource: async (input) => source(input),
musicPlayer: { play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })) }, musicPlayer: {
play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })),
},
onStateChange, onStateChange,
}); });

View File

@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import { MediaQueue } from "../../src/media/mediaQueue"; import { MediaQueue } from "../../src/media/mediaQueue";
import type { ResolvedMediaSource } from "../../src/media/mediaTypes"; import type { ResolvedMediaSource } from "../../src/media/mediaTypes";
function source(overrides: Partial<ResolvedMediaSource> = {}): ResolvedMediaSource { function source(
overrides: Partial<ResolvedMediaSource> = {},
): ResolvedMediaSource {
return { return {
source: "https://example.com/audio.ogg", source: "https://example.com/audio.ogg",
title: "audio.ogg", title: "audio.ogg",
@@ -13,7 +15,10 @@ function source(overrides: Partial<ResolvedMediaSource> = {}): ResolvedMediaSour
describe("MediaQueue", () => { describe("MediaQueue", () => {
it("adds items with stable queue metadata", () => { it("adds items with stable queue metadata", () => {
const queue = new MediaQueue(() => "item-1", () => 1700000000000); const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
const item = queue.add(source(), "tester"); const item = queue.add(source(), "tester");
@@ -31,7 +36,10 @@ describe("MediaQueue", () => {
}); });
it("marks the next queued item as playing", () => { it("marks the next queued item as playing", () => {
const queue = new MediaQueue(() => "item-1", () => 1700000000000); const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
const item = queue.add(source(), "tester"); const item = queue.add(source(), "tester");
expect(queue.startNext()).toEqual({ ...item, status: "playing" }); expect(queue.startNext()).toEqual({ ...item, status: "playing" });
@@ -43,7 +51,10 @@ describe("MediaQueue", () => {
it("removes current item and starts following item", () => { it("removes current item and starts following item", () => {
let id = 0; let id = 0;
const queue = new MediaQueue(() => `item-${++id}`, () => 1700000000000); const queue = new MediaQueue(
() => `item-${++id}`,
() => 1700000000000,
);
queue.add(source({ title: "first" }), "tester"); queue.add(source({ title: "first" }), "tester");
queue.add(source({ title: "second" }), "tester"); queue.add(source({ title: "second" }), "tester");
queue.startNext(); queue.startNext();
@@ -56,7 +67,10 @@ describe("MediaQueue", () => {
}); });
it("returns the failed current item", () => { it("returns the failed current item", () => {
const queue = new MediaQueue(() => "item-1", () => 1700000000000); const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
const item = queue.add(source(), "tester"); const item = queue.add(source(), "tester");
queue.startNext(); queue.startNext();
@@ -65,7 +79,10 @@ describe("MediaQueue", () => {
}); });
it("clears current and queued items", () => { it("clears current and queued items", () => {
const queue = new MediaQueue(() => "item-1", () => 1700000000000); const queue = new MediaQueue(
() => "item-1",
() => 1700000000000,
);
queue.add(source(), "tester"); queue.add(source(), "tester");
queue.startNext(); queue.startNext();

View File

@@ -7,7 +7,9 @@ import { resolveMediaSource } from "../../src/media/mediaResolver";
describe("resolveMediaSource", () => { describe("resolveMediaSource", () => {
it("accepts http URLs", async () => { it("accepts http URLs", async () => {
await expect(resolveMediaSource("https://example.com/music.mp3")).resolves.toEqual({ await expect(
resolveMediaSource("https://example.com/music.mp3"),
).resolves.toEqual({
source: "https://example.com/music.mp3", source: "https://example.com/music.mp3",
title: "music.mp3", title: "music.mp3",
kind: "url", kind: "url",
@@ -43,14 +45,18 @@ describe("resolveMediaSource", () => {
}); });
it("rejects unsupported sources", async () => { it("rejects unsupported sources", async () => {
await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject({ await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject(
{
code: "UNSUPPORTED_MEDIA_SOURCE", code: "UNSUPPORTED_MEDIA_SOURCE",
statusCode: 400, statusCode: 400,
} satisfies Partial<AppError>); } satisfies Partial<AppError>,
);
}); });
it("rejects non-http URL sources", async () => { it("rejects non-http URL sources", async () => {
await expect(resolveMediaSource("file:///tmp/song.mp3")).rejects.toMatchObject({ await expect(
resolveMediaSource("file:///tmp/song.mp3"),
).rejects.toMatchObject({
code: "UNSUPPORTED_MEDIA_SOURCE", code: "UNSUPPORTED_MEDIA_SOURCE",
statusCode: 400, statusCode: 400,
} satisfies Partial<AppError>); } satisfies Partial<AppError>);

View File

@@ -1,8 +1,8 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createMusicPlayer } from "../../src/media/musicPlayer";
import type { DiscordAudioPlayer } from "../../src/media/mediaTypes"; import type { DiscordAudioPlayer } from "../../src/media/mediaTypes";
import { createMusicPlayer } from "../../src/media/musicPlayer";
class FakeProcess extends EventEmitter { class FakeProcess extends EventEmitter {
stdout = new PassThrough(); stdout = new PassThrough();
@@ -34,7 +34,9 @@ describe("createMusicPlayer", () => {
proc.emit("close", 0); proc.emit("close", 0);
await playback.done; await playback.done;
expect(spawn).toHaveBeenCalledWith("ffmpeg", [ expect(spawn).toHaveBeenCalledWith(
"ffmpeg",
[
"-hide_banner", "-hide_banner",
"-loglevel", "-loglevel",
"warning", "warning",
@@ -50,7 +52,9 @@ describe("createMusicPlayer", () => {
"-f", "-f",
"ogg", "ogg",
"pipe:1", "pipe:1",
], { stdio: ["ignore", "pipe", "pipe"] }); ],
{ stdio: ["ignore", "pipe", "pipe"] },
);
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout); expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout);
}); });
@@ -64,7 +68,11 @@ describe("createMusicPlayer", () => {
const player = createMusicPlayer({ spawn, discordPlayer }); const player = createMusicPlayer({ spawn, discordPlayer });
expect(() => expect(() =>
player.play({ source: "/tmp/song.ogg", title: "song.ogg", kind: "local" }), player.play({
source: "/tmp/song.ogg",
title: "song.ogg",
kind: "local",
}),
).toThrow("Discord audio player is not connected"); ).toThrow("Discord audio player is not connected");
expect(spawn).not.toHaveBeenCalled(); expect(spawn).not.toHaveBeenCalled();
}); });
@@ -76,9 +84,16 @@ describe("createMusicPlayer", () => {
playStream: vi.fn(), playStream: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
}; };
const player = createMusicPlayer({ spawn: vi.fn(() => proc), discordPlayer }); const player = createMusicPlayer({
spawn: vi.fn(() => proc),
discordPlayer,
});
const playback = player.play({ source: "/tmp/song.ogg", title: "song.ogg", kind: "local" }); const playback = player.play({
source: "/tmp/song.ogg",
title: "song.ogg",
kind: "local",
});
playback.stop(); playback.stop();
playback.stop(); playback.stop();

View File

@@ -2,7 +2,11 @@ import type { Request, Response } from "express";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createMediaRoutes } from "../../src/routes/mediaRoutes"; import { createMediaRoutes } from "../../src/routes/mediaRoutes";
function getHandler(router: ReturnType<typeof createMediaRoutes>, path: string, method: string) { function getHandler(
router: ReturnType<typeof createMediaRoutes>,
path: string,
method: string,
) {
const layer = router.stack.find((item) => item.route?.path === path); const layer = router.stack.find((item) => item.route?.path === path);
return layer?.route?.stack.find((item) => item.method === method)?.handle; return layer?.route?.stack.find((item) => item.method === method)?.handle;
} }
@@ -15,12 +19,20 @@ describe("createMediaRoutes", () => {
skip: vi.fn(), skip: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
}; };
const handler = getHandler(createMediaRoutes(controller), "/media/status", "get"); const handler = getHandler(
createMediaRoutes(controller),
"/media/status",
"get",
);
const json = vi.fn(); const json = vi.fn();
await handler?.({} as Request, { json } as unknown as Response, vi.fn()); await handler?.({} as Request, { json } as unknown as Response, vi.fn());
expect(json).toHaveBeenCalledWith({ playing: false, current: null, queue: [] }); expect(json).toHaveBeenCalledWith({
playing: false,
current: null,
queue: [],
});
}); });
it("queues a source", async () => { it("queues a source", async () => {
@@ -31,7 +43,11 @@ describe("createMediaRoutes", () => {
skip: vi.fn(), skip: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
}; };
const handler = getHandler(createMediaRoutes(controller), "/media/queue", "post"); const handler = getHandler(
createMediaRoutes(controller),
"/media/queue",
"post",
);
const json = vi.fn(); const json = vi.fn();
await handler?.( await handler?.(
@@ -40,7 +56,9 @@ describe("createMediaRoutes", () => {
vi.fn(), vi.fn(),
); );
expect(controller.queue).toHaveBeenCalledWith("https://example.com/song.mp3"); expect(controller.queue).toHaveBeenCalledWith(
"https://example.com/song.mp3",
);
expect(json).toHaveBeenCalledWith(state); expect(json).toHaveBeenCalledWith(state);
}); });
@@ -51,7 +69,11 @@ describe("createMediaRoutes", () => {
skip: vi.fn(), skip: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
}; };
const handler = getHandler(createMediaRoutes(controller), "/media/queue", "post"); const handler = getHandler(
createMediaRoutes(controller),
"/media/queue",
"post",
);
const next = vi.fn(); const next = vi.fn();
await handler?.( await handler?.(