Compare commits
3 Commits
e32e092596
...
2744e7035b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2744e7035b | ||
|
|
8b33af8286 | ||
|
|
d50ce8698f |
1427
docs/superpowers/plans/2026-05-16-media-echo-screenshare.md
Normal file
1427
docs/superpowers/plans/2026-05-16-media-echo-screenshare.md
Normal file
File diff suppressed because it is too large
Load Diff
673
docs/superpowers/plans/2026-05-16-session-full-recording.md
Normal file
673
docs/superpowers/plans/2026-05-16-session-full-recording.md
Normal file
@@ -0,0 +1,673 @@
|
||||
# Session Full Recording 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:** Build background full-session OGG recording generation from voice join to leave while preserving existing per-user segment recordings.
|
||||
|
||||
**Architecture:** Add a focused session tracker that records session timing, participants, and per-user segment references. Add a session muxer that builds timeline-offset ffmpeg filters and writes `recordings/sessions/<sessionId>/session.json` plus `full.ogg`. Wire recorder lifecycle to create a session on join, register finished human segments, and finalize in the background on stop/destroy.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, Node fs/path, ffmpeg via existing `buildMuxFfmpegArgs` and `runFfmpeg`, Discord voice receiver pipeline.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/recorder/sessionRecording.ts`: session metadata types, session tracker, mux filter builder, and session finalization function.
|
||||
- Modify `src/types.ts`: add `recordingSessionId` to per-user `SegmentMetadata`.
|
||||
- Modify `src/recorder/metadata.ts`: accept and write shared `recordingSessionId` into segment metadata.
|
||||
- Modify `src/recorder.ts`: create session on ready, skip bots as now, register segment metadata, finalize session in background on stop/destroy.
|
||||
- Create `tests/recorder/sessionRecording.test.ts`: unit tests for session tracker, mux filter, empty session, and failed mux metadata.
|
||||
- Modify `tests/recorder.test.ts`: assert bot/self users do not register session participants or subscriptions; add stop finalization trigger test with injected session finalizer if needed.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Session Recording Metadata and Mux Builder
|
||||
|
||||
**Files:**
|
||||
- Create: `src/recorder/sessionRecording.ts`
|
||||
- Test: `tests/recorder/sessionRecording.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for session tracker and mux filter**
|
||||
|
||||
Create `tests/recorder/sessionRecording.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildSessionMuxFilter,
|
||||
createRecordingSession,
|
||||
finalizeRecordingSession,
|
||||
} from "../../src/recorder/sessionRecording";
|
||||
import type { UserMetadata } from "../../src/types";
|
||||
|
||||
function user(overrides: Partial<UserMetadata> = {}): UserMetadata {
|
||||
return {
|
||||
userId: "user-1",
|
||||
username: "Alice",
|
||||
tag: "Alice#0001",
|
||||
displayName: "Alice",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
bot: false,
|
||||
roles: [],
|
||||
highestRole: null,
|
||||
joinedTimestamp: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sessionRecording", () => {
|
||||
it("tracks participants and segment refs", () => {
|
||||
const session = createRecordingSession({
|
||||
guildId: "guild",
|
||||
channelId: "voice",
|
||||
channelName: "Voice",
|
||||
startTime: 1000,
|
||||
recordingsDir: "/recordings",
|
||||
});
|
||||
|
||||
session.registerSegment({
|
||||
user: user(),
|
||||
oggPath: "/recordings/user-1/1500.ogg",
|
||||
jsonPath: "/recordings/user-1/1500.json",
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
});
|
||||
|
||||
const snapshot = session.snapshot(3000);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
sessionId: "guild-voice-1000",
|
||||
guildId: "guild",
|
||||
channelId: "voice",
|
||||
channelName: "Voice",
|
||||
startTime: 1000,
|
||||
endTime: 3000,
|
||||
durationMs: 2000,
|
||||
status: "pending",
|
||||
participants: [{ userId: "user-1", username: "Alice" }],
|
||||
segments: [
|
||||
{
|
||||
userId: "user-1",
|
||||
oggPath: "/recordings/user-1/1500.ogg",
|
||||
jsonPath: "/recordings/user-1/1500.json",
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
offsetMs: 500,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds timeline-offset ffmpeg filter", () => {
|
||||
const filter = buildSessionMuxFilter([
|
||||
{ startTime: 1000 },
|
||||
{ startTime: 2500 },
|
||||
], 1000);
|
||||
|
||||
expect(filter).toBe(
|
||||
"[0:a]adelay=0|0[pad0];[1:a]adelay=1500|1500[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes empty metadata without running ffmpeg", async () => {
|
||||
const session = createRecordingSession({
|
||||
guildId: "guild",
|
||||
channelId: "voice",
|
||||
channelName: "Voice",
|
||||
startTime: 1000,
|
||||
recordingsDir: "/recordings",
|
||||
});
|
||||
const writeJson = vi.fn();
|
||||
const mkdir = vi.fn();
|
||||
const runFfmpeg = vi.fn();
|
||||
|
||||
await finalizeRecordingSession(session, {
|
||||
endTime: 4000,
|
||||
mkdir,
|
||||
writeJson,
|
||||
runFfmpeg,
|
||||
});
|
||||
|
||||
expect(runFfmpeg).not.toHaveBeenCalled();
|
||||
expect(mkdir).toHaveBeenCalledWith("/recordings/sessions/guild-voice-1000");
|
||||
expect(writeJson).toHaveBeenCalledWith(
|
||||
"/recordings/sessions/guild-voice-1000/session.json",
|
||||
expect.objectContaining({ status: "empty", durationMs: 3000 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `src/recorder/sessionRecording.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement session tracker and mux filter**
|
||||
|
||||
Create `src/recorder/sessionRecording.ts`:
|
||||
|
||||
```ts
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildMuxFfmpegArgs, runFfmpeg as defaultRunFfmpeg } from "../audio/ffmpegProcess";
|
||||
import type { UserMetadata } from "../types";
|
||||
|
||||
export type SessionRecordingStatus = "pending" | "completed" | "failed" | "empty";
|
||||
|
||||
export interface RecordingSessionOptions {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
recordingsDir: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentInput {
|
||||
user: UserMetadata;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SessionParticipant {
|
||||
userId: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentRef {
|
||||
userId: string;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
offsetMs: number;
|
||||
}
|
||||
|
||||
export interface SessionRecordingMetadata {
|
||||
sessionId: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
status: SessionRecordingStatus;
|
||||
outputFile: string | null;
|
||||
participants: SessionParticipant[];
|
||||
segments: SessionSegmentRef[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
readonly sessionId: string;
|
||||
readonly recordingsDir: string;
|
||||
readonly startTime: number;
|
||||
registerSegment(input: SessionSegmentInput): void;
|
||||
snapshot(endTime: number): SessionRecordingMetadata;
|
||||
}
|
||||
|
||||
export interface FinalizeRecordingSessionDependencies {
|
||||
endTime?: number;
|
||||
mkdir?: (dir: string) => void;
|
||||
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
|
||||
runFfmpeg?: (args: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createRecordingSession(options: RecordingSessionOptions): RecordingSession {
|
||||
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
|
||||
const participants = new Map<string, SessionParticipant>();
|
||||
const segments: SessionSegmentRef[] = [];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
recordingsDir: options.recordingsDir,
|
||||
startTime: options.startTime,
|
||||
|
||||
registerSegment(input: SessionSegmentInput): void {
|
||||
participants.set(input.user.userId, {
|
||||
userId: input.user.userId,
|
||||
username: input.user.username,
|
||||
tag: input.user.tag,
|
||||
displayName: input.user.displayName,
|
||||
avatarUrl: input.user.avatarUrl,
|
||||
});
|
||||
segments.push({
|
||||
userId: input.user.userId,
|
||||
oggPath: input.oggPath,
|
||||
jsonPath: input.jsonPath,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
durationMs: input.endTime - input.startTime,
|
||||
offsetMs: input.startTime - options.startTime,
|
||||
});
|
||||
},
|
||||
|
||||
snapshot(endTime: number): SessionRecordingMetadata {
|
||||
return {
|
||||
sessionId,
|
||||
guildId: options.guildId,
|
||||
channelId: options.channelId,
|
||||
channelName: options.channelName,
|
||||
startTime: options.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - options.startTime,
|
||||
status: "pending",
|
||||
outputFile: null,
|
||||
participants: Array.from(participants.values()),
|
||||
segments: [...segments],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionMuxFilter(
|
||||
segments: Array<{ startTime: number }>,
|
||||
sessionStartTime: number,
|
||||
): string {
|
||||
const filters = segments.map((segment, index) => {
|
||||
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
|
||||
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
|
||||
});
|
||||
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
|
||||
filters.push(`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`);
|
||||
return filters.join(";");
|
||||
}
|
||||
|
||||
export async function finalizeRecordingSession(
|
||||
session: RecordingSession,
|
||||
dependencies: FinalizeRecordingSessionDependencies = {},
|
||||
): Promise<void> {
|
||||
const endTime = dependencies.endTime ?? Date.now();
|
||||
const sessionDir = path.join(session.recordingsDir, "sessions", session.sessionId);
|
||||
const outputFile = path.join(sessionDir, "full.ogg");
|
||||
const metadataFile = path.join(sessionDir, "session.json");
|
||||
const mkdir = dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
|
||||
const writeJson =
|
||||
dependencies.writeJson ??
|
||||
((file, metadata) => fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
|
||||
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
|
||||
|
||||
mkdir(sessionDir);
|
||||
const metadata = session.snapshot(endTime);
|
||||
|
||||
if (metadata.segments.length === 0) {
|
||||
writeJson(metadataFile, { ...metadata, status: "empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runFfmpeg(
|
||||
buildMuxFfmpegArgs({
|
||||
inputs: metadata.segments.map((segment) => segment.oggPath),
|
||||
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
|
||||
output: outputFile,
|
||||
codec: "libopus",
|
||||
}),
|
||||
);
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "completed",
|
||||
outputFile,
|
||||
});
|
||||
} catch (error) {
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit Task 1**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/recorder/sessionRecording.ts tests/recorder/sessionRecording.test.ts
|
||||
git commit -m "feat: add recording session metadata"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add Shared Recording Session ID to Segment Metadata
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `src/recorder/metadata.ts`
|
||||
- Test: `tests/recorder/metadata.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing metadata test**
|
||||
|
||||
Create `tests/recorder/metadata.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSegmentMetadata } from "../../src/recorder/metadata";
|
||||
import type { SegmentState, UserMetadata } from "../../src/types";
|
||||
|
||||
const user: UserMetadata = {
|
||||
userId: "user-1",
|
||||
username: "Alice",
|
||||
tag: "Alice#0001",
|
||||
displayName: "Alice",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
bot: false,
|
||||
roles: [],
|
||||
highestRole: null,
|
||||
joinedTimestamp: null,
|
||||
};
|
||||
|
||||
const segment = {
|
||||
index: 0,
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
filename: "/recordings/user-1/1500.ogg",
|
||||
jsonFilename: "/recordings/user-1/1500.json",
|
||||
} as SegmentState;
|
||||
|
||||
describe("createSegmentMetadata", () => {
|
||||
it("includes shared recording session id", () => {
|
||||
const metadata = createSegmentMetadata(
|
||||
user,
|
||||
segment,
|
||||
"user-1-1500",
|
||||
"guild-voice-1000",
|
||||
1000,
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
sessionId: "user-1-1500",
|
||||
recordingSessionId: "guild-voice-1000",
|
||||
sessionStartTime: 1000,
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/recorder/metadata.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `createSegmentMetadata` does not accept `recordingSessionId` yet.
|
||||
|
||||
- [ ] **Step 3: Update metadata type and function signature**
|
||||
|
||||
Modify `src/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface SegmentMetadata extends UserMetadata {
|
||||
sessionId: string;
|
||||
recordingSessionId: string;
|
||||
sessionStartTime: number;
|
||||
segmentIndex: number;
|
||||
segmentMs: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
filename: string;
|
||||
}
|
||||
```
|
||||
|
||||
Modify `src/recorder/metadata.ts` function signature and return object:
|
||||
|
||||
```ts
|
||||
export function createSegmentMetadata(
|
||||
user: UserMetadata,
|
||||
segment: SegmentState,
|
||||
sessionId: string,
|
||||
recordingSessionId: string,
|
||||
sessionStartTime: number,
|
||||
recordingSegmentMs: number,
|
||||
): SegmentMetadata {
|
||||
const endTime = segment.endTime ?? Date.now();
|
||||
return {
|
||||
...user,
|
||||
sessionId,
|
||||
recordingSessionId,
|
||||
sessionStartTime,
|
||||
segmentIndex: segment.index,
|
||||
segmentMs: recordingSegmentMs,
|
||||
startTime: segment.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - segment.startTime,
|
||||
filename: path.basename(segment.filename),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update existing call sites**
|
||||
|
||||
In `src/recorder.ts`, update the call to include `recordingSession.sessionId` after the per-user `sessionId` argument:
|
||||
|
||||
```ts
|
||||
const metadata = createSegmentMetadata(
|
||||
userMetadata,
|
||||
currentSegment,
|
||||
sessionId,
|
||||
recordingSession.sessionId,
|
||||
sessionStartTime,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run metadata tests and typecheck**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/recorder/metadata.test.ts
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Task 2**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/types.ts src/recorder/metadata.ts src/recorder.ts tests/recorder/metadata.test.ts
|
||||
git commit -m "feat: tag segments with recording session"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire Session Tracking into Recorder Lifecycle
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/recorder.ts`
|
||||
- Modify: `tests/recorder.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing recorder lifecycle tests**
|
||||
|
||||
Append to `tests/recorder.test.ts`:
|
||||
|
||||
```ts
|
||||
it("finalizes the active recording session when stopped", async () => {
|
||||
const { startRecording, stopRecording } = await import("../src/recorder");
|
||||
const { getVoiceConnection } = await import("@discordjs/voice");
|
||||
const destroy = vi.fn();
|
||||
vi.mocked(getVoiceConnection).mockReturnValue({ destroy } as never);
|
||||
|
||||
await startRecording({ user: { id: "self-user" } } as never, createChannel() as never);
|
||||
stopRecording("guild");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(destroy).toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
Then add a test that emits a non-bot user and asserts `subscribe` is called once, while existing self/bot tests still assert zero subscriptions.
|
||||
|
||||
- [ ] **Step 2: Run recorder tests to verify failure if session APIs are missing**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/recorder.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL until recorder imports and uses session recording APIs.
|
||||
|
||||
- [ ] **Step 3: Add active session map and finalize helper**
|
||||
|
||||
Modify `src/recorder.ts` imports:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRecordingSession,
|
||||
finalizeRecordingSession,
|
||||
type RecordingSession,
|
||||
} from "./recorder/sessionRecording";
|
||||
```
|
||||
|
||||
Add near `recordingsDir`:
|
||||
|
||||
```ts
|
||||
const activeRecordingSessions = new Map<string, RecordingSession>();
|
||||
|
||||
function finalizeActiveRecordingSession(guildId: string): void {
|
||||
const session = activeRecordingSessions.get(guildId);
|
||||
if (!session) return;
|
||||
activeRecordingSessions.delete(guildId);
|
||||
finalizeRecordingSession(session).catch((error) => {
|
||||
logger.error({ error }, "Failed to finalize recording session");
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
After connection reaches ready, create and store the session:
|
||||
|
||||
```ts
|
||||
const recordingSession = createRecordingSession({
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
startTime: Date.now(),
|
||||
recordingsDir,
|
||||
});
|
||||
activeRecordingSessions.set(channel.guild.id, recordingSession);
|
||||
```
|
||||
|
||||
In segment finish handler, after writing per-user JSON, register the segment:
|
||||
|
||||
```ts
|
||||
recordingSession.registerSegment({
|
||||
user: userMetadata,
|
||||
oggPath: currentSegment.filename,
|
||||
jsonPath: currentSegment.jsonFilename,
|
||||
startTime: currentSegment.startTime,
|
||||
endTime: metadata.endTime,
|
||||
});
|
||||
```
|
||||
|
||||
In `stopRecording(guildId)`, call `finalizeActiveRecordingSession(guildId)` before destroying connection.
|
||||
|
||||
In `connection.on(VoiceConnectionStatus.Destroyed, ...)`, call `finalizeActiveRecordingSession(channel.guild.id)`.
|
||||
|
||||
- [ ] **Step 4: Run recorder tests and typecheck**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit Task 3**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/recorder.ts tests/recorder.test.ts
|
||||
git commit -m "feat: finalize recording sessions on disconnect"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final Verification
|
||||
|
||||
**Files:**
|
||||
- All changed recorder/session files.
|
||||
|
||||
- [ ] **Step 1: Run recorder-focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts tests/audio/ffmpegProcess.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run lint**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Check git status**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: only intentional implementation, spec, and plan changes are present.
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Media Echo Fix and YouTube Screenshare Design
|
||||
|
||||
## Context
|
||||
|
||||
Media playback currently uses the same `DiscordPlayer` instance as the browser audio bridge. The browser bridge is started during webserver startup and subscribes the shared player to the active voice connection. Music playback also uses that player. This shared ownership can let the bridge interfere with media playback and contribute to voice audio being reflected back during playback.
|
||||
|
||||
The project already includes `@dank074/discord-video-stream`, which supports Discord Go Live video streaming from a direct media URL or readable stream.
|
||||
|
||||
## Goals
|
||||
|
||||
- Prevent voice audio from being reflected back while music/media playback is active.
|
||||
- Keep normal music playback behavior for existing `/api/media/queue` users.
|
||||
- Add a YouTube screenshare path that streams video through Discord Go Live.
|
||||
- Fail clearly when voice is not connected, another media mode is busy, or screenshare dependencies fail.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replace the existing voice recorder pipeline.
|
||||
- Disable message or voice monitoring during music playback.
|
||||
- Build full production UI for screenshare controls in the first implementation.
|
||||
- Add Discord integration tests that require a live account or server.
|
||||
|
||||
## Design
|
||||
|
||||
### Audio player ownership
|
||||
|
||||
`DiscordPlayer` will track which subsystem owns the active stream: `none`, `browser-bridge`, `music`, or `screen`. A caller may only start playback when the player has no owner or when the caller owns the current stream. This prevents the browser bridge from overwriting music or screen playback.
|
||||
|
||||
The browser bridge in `src/webserver.ts` will not start at server boot. It will be created lazily only when browser audio arrives and no media playback is active. When media playback starts, the bridge is stopped or left inactive so it cannot transmit captured audio back into Discord.
|
||||
|
||||
Music playback will claim the `music` owner before calling `playStream`. When music finishes or stops, ownership is released and browser audio may resume later if the browser sends new audio.
|
||||
|
||||
### Screenshare mode
|
||||
|
||||
The media queue endpoint will accept an optional `mode` field. If omitted, mode defaults to `music` to preserve existing API behavior. `mode: "screen"` starts a separate screenshare flow instead of audio-only music playback.
|
||||
|
||||
A new `ScreenShareController` will:
|
||||
|
||||
1. Verify a voice channel is connected.
|
||||
2. Reject start if music or browser bridge owns playback, or if another screen stream is active.
|
||||
3. Resolve a YouTube URL to a direct playable video URL through the existing yt-dlp utilities.
|
||||
4. Use `@dank074/discord-video-stream` with `prepareStream(...)` and `playStream(..., { type: "go-live" })`.
|
||||
5. Track active screen state and provide stop behavior.
|
||||
|
||||
Screenshare state will be exposed through media state as the active mode so the frontend can distinguish music from screen playback.
|
||||
|
||||
### Busy-state rules
|
||||
|
||||
- Music cannot start while screen is active.
|
||||
- Screen cannot start while music is active.
|
||||
- Browser bridge cannot start while music or screen is active.
|
||||
- Stop stops the active media mode and releases ownership.
|
||||
|
||||
### Error handling
|
||||
|
||||
- `VOICE_NOT_CONNECTED`: media or screen requested before joining voice.
|
||||
- `MEDIA_BUSY`: another active media mode owns playback.
|
||||
- `SCREEN_STREAM_FAILED`: yt-dlp, stream preparation, or Go Live playback fails.
|
||||
|
||||
Errors should surface through existing Express error handling as JSON responses.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit test `DiscordPlayer` ownership rules: browser bridge cannot override music; music releases ownership on stop.
|
||||
- Media controller tests: default mode remains music, screen mode is routed separately, and busy conflicts reject with `MEDIA_BUSY`.
|
||||
- Route tests: `/api/media/queue` accepts optional `mode` and passes it to the controller.
|
||||
- Screenshare controller tests mock yt-dlp and `@dank074/discord-video-stream`; no live Discord account is required.
|
||||
|
||||
## Rollout
|
||||
|
||||
Implement ownership first and verify existing music tests still pass. Then add mode parsing and the screenshare controller behind the same media route. UI changes can follow as a small enhancement after API behavior is stable.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Session Full Recording Design
|
||||
|
||||
## Context
|
||||
|
||||
The recorder currently writes per-user OGG segments under `recordings/<userId>/`. Each segment has JSON metadata with user identity, bot flag, segment timing, and filename. The requested addition is a second recording view: one full-session OGG from the time the bot joins a voice channel until it leaves, while preserving the current per-user recording files.
|
||||
|
||||
Bot/self audio is excluded before segment creation, so session-level output should only include human participants.
|
||||
|
||||
## Goals
|
||||
|
||||
- Track one recording session from successful voice join until disconnect/leave.
|
||||
- Preserve existing per-user OGG segment behavior.
|
||||
- Create a background full-session OGG/Opus mix after the session ends.
|
||||
- Store session metadata with duration, participants, segment references, output status, and full recording path.
|
||||
- Keep muxing failures isolated from voice connection shutdown.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Real-time mixed full-session recording.
|
||||
- Replacing per-user segment recording.
|
||||
- Dashboard UI for session playback in this phase.
|
||||
- Database-backed mux job retries in this phase.
|
||||
|
||||
## Output structure
|
||||
|
||||
A completed session writes:
|
||||
|
||||
```text
|
||||
recordings/
|
||||
sessions/
|
||||
<recordingSessionId>/
|
||||
full.ogg
|
||||
session.json
|
||||
```
|
||||
|
||||
`recordingSessionId` is based on guild ID, channel ID, and session start time: `<guildId>-<channelId>-<sessionStartTime>`.
|
||||
|
||||
`session.json` contains:
|
||||
|
||||
- `sessionId`
|
||||
- `guildId`
|
||||
- `channelId`
|
||||
- `channelName`
|
||||
- `startTime`
|
||||
- `endTime`
|
||||
- `durationMs`
|
||||
- `status`: `completed`, `failed`, or `empty`
|
||||
- `outputFile`: relative path to `full.ogg` when present
|
||||
- `participants`: non-bot users observed in the session
|
||||
- `segments`: per-user segment metadata references with absolute timing
|
||||
- `error`: failure message when muxing fails
|
||||
|
||||
Per-user segment JSON also records the shared `recordingSessionId` so full-session muxing can identify which files belong to the same join/leave session.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. `startRecording()` creates a session object after the voice connection reaches ready state.
|
||||
2. Each non-bot speaking user still gets the existing per-user `SegmentManager` flow.
|
||||
3. Each finished segment is registered with the active session using its metadata path, OGG path, user ID, start time, and end time.
|
||||
4. `stopRecording(guildId)` or connection destruction finalizes the active session with `endTime`.
|
||||
5. Finalization starts muxing in the background and does not block disconnect.
|
||||
6. Muxing writes `session.json` with `empty`, `completed`, or `failed` status.
|
||||
|
||||
## Muxing design
|
||||
|
||||
The post-processor reads all registered segment metadata for the session. It builds an ffmpeg `filter_complex` that delays each input by `segment.startTime - session.startTime` milliseconds, mixes all delayed inputs with `amix`, and encodes the result to OGG/Opus.
|
||||
|
||||
For a session with no human segments, muxing skips ffmpeg and writes `session.json` with `status: "empty"` and the full session duration.
|
||||
|
||||
For successful muxing, it writes `full.ogg` and `session.json` with `status: "completed"`.
|
||||
|
||||
For failed muxing, it writes `session.json` with `status: "failed"` and the error message.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Failure to write `session.json` is logged and does not crash shutdown.
|
||||
- ffmpeg failure is captured in metadata as `status: "failed"`.
|
||||
- Missing or empty segment files are skipped from the mix and recorded as skipped references if needed.
|
||||
- Background mux errors never reject `stopRecording()`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit test session metadata creation from join to stop.
|
||||
- Unit test bot/self users do not register participants or segments.
|
||||
- Unit test mux filter generation with timeline offsets.
|
||||
- Unit test empty sessions write `status: "empty"` without calling ffmpeg.
|
||||
- Unit test stop triggers background finalization without awaiting ffmpeg.
|
||||
@@ -3,10 +3,14 @@ import { discordPlayer } from "../player";
|
||||
import { MediaQueue } from "./mediaQueue";
|
||||
import { resolveMediaSource } from "./mediaResolver";
|
||||
import type {
|
||||
MediaMode,
|
||||
MediaState,
|
||||
MusicPlayback,
|
||||
MusicPlayer,
|
||||
QueueMediaOptions,
|
||||
ResolvedMediaSource,
|
||||
ScreenShareController,
|
||||
ScreenSharePlayback,
|
||||
} from "./mediaTypes";
|
||||
import { createMusicPlayer } from "./musicPlayer";
|
||||
|
||||
@@ -15,6 +19,7 @@ export interface MediaControllerDependencies {
|
||||
isBrowserStreaming?: () => boolean;
|
||||
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
||||
musicPlayer?: MusicPlayer;
|
||||
screenController?: ScreenShareController;
|
||||
onStateChange?: (state: MediaState) => void;
|
||||
}
|
||||
|
||||
@@ -24,6 +29,8 @@ export class MediaController {
|
||||
private playback: MusicPlayback | null = null;
|
||||
private playbackToken = 0;
|
||||
private skipInProgress = false;
|
||||
private screenPlayback: ScreenSharePlayback | null = null;
|
||||
private activeMode: MediaMode | null = null;
|
||||
|
||||
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
||||
@@ -32,17 +39,27 @@ export class MediaController {
|
||||
getState(): MediaState {
|
||||
const snapshot = this.queueStore.snapshot();
|
||||
return {
|
||||
playing: snapshot.current?.status === "playing",
|
||||
playing:
|
||||
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||
...snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async queue(source: string): Promise<MediaState> {
|
||||
this.assertCanStart();
|
||||
async queue(
|
||||
source: string,
|
||||
options: QueueMediaOptions = {},
|
||||
): Promise<MediaState> {
|
||||
const mode = options.mode ?? "music";
|
||||
if (mode === "screen") {
|
||||
return this.startScreen(source);
|
||||
}
|
||||
|
||||
this.assertCanStartMusic();
|
||||
const resolved = await (
|
||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||
)(source);
|
||||
this.queueStore.add(resolved);
|
||||
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||
this.startNextIfIdle();
|
||||
return this.emitState();
|
||||
}
|
||||
@@ -73,11 +90,14 @@ export class MediaController {
|
||||
this.playbackToken++;
|
||||
this.playback?.stop();
|
||||
this.playback = null;
|
||||
this.screenPlayback?.stop();
|
||||
this.screenPlayback = null;
|
||||
this.activeMode = null;
|
||||
this.queueStore.clear();
|
||||
return this.emitState();
|
||||
}
|
||||
|
||||
private assertCanStart(): void {
|
||||
private assertCanStartMusic(): void {
|
||||
const isVoiceConnected =
|
||||
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
|
||||
if (!isVoiceConnected()) {
|
||||
@@ -88,6 +108,10 @@ export class MediaController {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
|
||||
if (this.dependencies.isBrowserStreaming?.()) {
|
||||
throw new AppError(
|
||||
"Stop browser microphone streaming before playing media",
|
||||
@@ -97,6 +121,46 @@ export class MediaController {
|
||||
}
|
||||
}
|
||||
|
||||
private async startScreen(source: string): Promise<MediaState> {
|
||||
if (
|
||||
this.screenPlayback ||
|
||||
this.dependencies.screenController?.isActive() ||
|
||||
this.playback ||
|
||||
this.queueStore.snapshot().current
|
||||
) {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
const screenController = this.dependencies.screenController;
|
||||
if (!screenController) {
|
||||
throw new AppError(
|
||||
"Screen sharing is unavailable",
|
||||
"SCREEN_UNAVAILABLE",
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
this.activeMode = "screen";
|
||||
try {
|
||||
this.screenPlayback = await screenController.start(source);
|
||||
} catch (error) {
|
||||
this.activeMode = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.screenPlayback.done.then(
|
||||
() => this.finishScreen(),
|
||||
() => this.finishScreen(),
|
||||
);
|
||||
return this.emitState();
|
||||
}
|
||||
|
||||
private finishScreen(): void {
|
||||
if (!this.screenPlayback || this.activeMode !== "screen") return;
|
||||
this.screenPlayback = null;
|
||||
this.activeMode = null;
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private startNextIfIdle(): void {
|
||||
if (this.playback) return;
|
||||
const item = this.queueStore.startNext();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
MediaMode,
|
||||
MediaQueueItem,
|
||||
MediaState,
|
||||
ResolvedMediaSource,
|
||||
@@ -13,10 +14,14 @@ export class MediaQueue {
|
||||
private readonly now = () => Date.now(),
|
||||
) {}
|
||||
|
||||
add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem {
|
||||
add(
|
||||
source: ResolvedMediaSource,
|
||||
mode: MediaQueueItem["mode"] = "music",
|
||||
requestedBy = "dashboard",
|
||||
): MediaQueueItem {
|
||||
const item: MediaQueueItem = {
|
||||
id: this.createId(),
|
||||
mode: "music",
|
||||
mode,
|
||||
requestedBy,
|
||||
addedAt: this.now(),
|
||||
status: "queued",
|
||||
|
||||
@@ -25,10 +25,16 @@ export interface MediaQueueItem extends ResolvedMediaSource {
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
|
||||
export interface QueueMediaOptions {
|
||||
mode?: MediaMode;
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export interface MusicPlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
@@ -38,8 +44,23 @@ export interface MusicPlayer {
|
||||
play(source: ResolvedMediaSource): MusicPlayback;
|
||||
}
|
||||
|
||||
export interface DiscordAudioPlayer {
|
||||
isConnected(): boolean;
|
||||
playStream(stream: Readable): void;
|
||||
export interface ScreenSharePlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface ScreenShareController {
|
||||
isActive(): boolean;
|
||||
start(source: string): Promise<ScreenSharePlayback>;
|
||||
}
|
||||
|
||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||
|
||||
export interface DiscordAudioPlayer {
|
||||
getOwner(): DiscordPlayerOwner;
|
||||
isConnected(): boolean;
|
||||
playStream(stream: Readable, owner: DiscordPlayerOwner): void;
|
||||
pause(owner?: DiscordPlayerOwner): void;
|
||||
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||
stop(owner?: DiscordPlayerOwner): void;
|
||||
}
|
||||
|
||||
@@ -30,13 +30,27 @@ export function createMusicPlayer(
|
||||
}) as unknown as ChildProcessWithoutNullStreams;
|
||||
proc.stderr.resume();
|
||||
|
||||
audioPlayer.playStream(proc.stdout);
|
||||
audioPlayer.playStream(proc.stdout, "music");
|
||||
|
||||
let stopped = false;
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
audioPlayer.stop("music");
|
||||
};
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
proc.on("error", reject);
|
||||
proc.stdout.on("error", reject);
|
||||
proc.on("error", (error) => {
|
||||
release();
|
||||
reject(error);
|
||||
});
|
||||
proc.stdout.on("error", (error) => {
|
||||
release();
|
||||
reject(error);
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
release();
|
||||
if (code === 0 || stopped) {
|
||||
resolve();
|
||||
return;
|
||||
@@ -51,7 +65,7 @@ export function createMusicPlayer(
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
proc.kill("SIGTERM");
|
||||
audioPlayer.stop();
|
||||
release();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
123
src/media/screenShareController.ts
Normal file
123
src/media/screenShareController.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import {
|
||||
playStream as defaultPlayStream,
|
||||
prepareStream as defaultPrepareStream,
|
||||
Encoders,
|
||||
Utils,
|
||||
} from "@dank074/discord-video-stream";
|
||||
import { AppError } from "../errors";
|
||||
import { discordPlayer } from "../player";
|
||||
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
||||
import { createYtDlp } from "./ytdlp";
|
||||
|
||||
export interface ScreenShareVoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
}
|
||||
|
||||
interface PreparedScreenStream {
|
||||
command: { kill?: (signal: NodeJS.Signals) => unknown };
|
||||
output: Readable;
|
||||
}
|
||||
|
||||
type PrepareScreenStream = (
|
||||
source: string,
|
||||
options: object,
|
||||
) => PreparedScreenStream;
|
||||
|
||||
type PlayScreenStream = (
|
||||
output: Readable,
|
||||
streamer: unknown,
|
||||
options: { type: "go-live" },
|
||||
) => Promise<void>;
|
||||
|
||||
export interface ScreenShareControllerDependencies {
|
||||
getVoiceStatus: () => ScreenShareVoiceStatus;
|
||||
getPlayerOwner?: () => DiscordPlayerOwner;
|
||||
getDirectVideoUrl?: (source: string) => Promise<string>;
|
||||
prepareStream?: PrepareScreenStream;
|
||||
playStream?: PlayScreenStream;
|
||||
streamer: unknown;
|
||||
}
|
||||
|
||||
export function createScreenShareController(
|
||||
dependencies: ScreenShareControllerDependencies,
|
||||
) {
|
||||
let active: ScreenSharePlayback | null = null;
|
||||
const ytdlp = createYtDlp();
|
||||
const getPlayerOwner =
|
||||
dependencies.getPlayerOwner ?? (() => discordPlayer.getOwner());
|
||||
const getDirectVideoUrl =
|
||||
dependencies.getDirectVideoUrl ??
|
||||
((source) => ytdlp.getDirectVideoUrl(source));
|
||||
const prepareStream =
|
||||
dependencies.prepareStream ??
|
||||
(defaultPrepareStream as unknown as PrepareScreenStream);
|
||||
const playStream =
|
||||
dependencies.playStream ??
|
||||
(defaultPlayStream as unknown as PlayScreenStream);
|
||||
|
||||
return {
|
||||
isActive(): boolean {
|
||||
return active !== null;
|
||||
},
|
||||
|
||||
async start(source: string): Promise<ScreenSharePlayback> {
|
||||
const status = dependencies.getVoiceStatus();
|
||||
if (
|
||||
!status.connected ||
|
||||
!status.activeGuildId ||
|
||||
!status.activeChannelId
|
||||
) {
|
||||
throw new AppError(
|
||||
"Connect to a voice channel before sharing screen",
|
||||
"VOICE_NOT_CONNECTED",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (active || getPlayerOwner() !== "none") {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const directUrl = await getDirectVideoUrl(source);
|
||||
const { command, output } = prepareStream(directUrl, {
|
||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
bitrateVideo: 2500,
|
||||
bitrateVideoMax: 4000,
|
||||
includeAudio: true,
|
||||
videoCodec: Utils.normalizeVideoCodec("H264"),
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
const done = playStream(output, dependencies.streamer, {
|
||||
type: "go-live",
|
||||
}).finally(() => {
|
||||
active = null;
|
||||
});
|
||||
|
||||
active = {
|
||||
done,
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
command.kill?.("SIGTERM");
|
||||
active = null;
|
||||
},
|
||||
};
|
||||
return active;
|
||||
} catch (error) {
|
||||
active = null;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : "Screen stream failed",
|
||||
"SCREEN_STREAM_FAILED",
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface YtDlpMetadata {
|
||||
export interface YtDlpClient {
|
||||
getMetadata(url: string): Promise<YtDlpMetadata>;
|
||||
getDirectAudioUrl(url: string): Promise<string>;
|
||||
getDirectVideoUrl(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface YtDlpDependencies {
|
||||
@@ -49,6 +50,19 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
||||
]);
|
||||
return value.trim().split("\n")[0] || url;
|
||||
},
|
||||
|
||||
async getDirectVideoUrl(url: string): Promise<string> {
|
||||
const value = await runYtDlp(spawn, [
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
return value.trim().split("\n")[0] || url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,9 @@ async function runAnalysisInWorker(
|
||||
messages: MessageRecord[],
|
||||
): Promise<AnalysisWorkerResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL("./aiAnalysisWorker.ts", import.meta.url));
|
||||
const worker = new Worker(
|
||||
new URL("./aiAnalysisWorker.ts", import.meta.url),
|
||||
);
|
||||
|
||||
worker.once("message", (response: AnalysisWorkerResponse) => {
|
||||
worker.terminate().catch((error) => {
|
||||
@@ -213,7 +215,6 @@ function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
export async function queueMessageAnalysis(messageId: string): Promise<void> {
|
||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||
|
||||
|
||||
try {
|
||||
// Look up the message to get its conversation key
|
||||
const message = await getMessageById(messageId);
|
||||
@@ -242,7 +243,6 @@ export async function queueMessageAnalysis(messageId: string): Promise<void> {
|
||||
export function queueConversationAnalysis(conversationKey: string): void {
|
||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||
|
||||
|
||||
// Schedule debounced analysis
|
||||
scheduleConversationAnalysis(conversationKey);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
StreamType,
|
||||
VoiceConnection,
|
||||
} from "@discordjs/voice";
|
||||
import type { DiscordPlayerOwner } from "./media/mediaTypes";
|
||||
|
||||
export class DiscordPlayer {
|
||||
private player: AudioPlayer;
|
||||
private connection: VoiceConnection | null = null;
|
||||
private owner: DiscordPlayerOwner = "none";
|
||||
|
||||
constructor() {
|
||||
this.player = createAudioPlayer();
|
||||
@@ -21,6 +23,7 @@ export class DiscordPlayer {
|
||||
|
||||
this.player.on("error", (error) => {
|
||||
console.error(`[player] Error: ${error.message}`);
|
||||
this.owner = "none";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,17 +32,28 @@ export class DiscordPlayer {
|
||||
this.connection.subscribe(this.player);
|
||||
}
|
||||
|
||||
public getOwner(): DiscordPlayerOwner {
|
||||
return this.owner;
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connection !== null;
|
||||
}
|
||||
|
||||
public playStream(stream: Readable) {
|
||||
console.log("[player] Starting new audio stream...");
|
||||
public playStream(stream: Readable, owner: DiscordPlayerOwner) {
|
||||
if (owner === "none") {
|
||||
throw new Error("Discord audio player owner is required");
|
||||
}
|
||||
this.assertOwnerAvailable(owner);
|
||||
|
||||
const resource = createAudioResource(stream, {
|
||||
inputType: StreamType.OggOpus,
|
||||
});
|
||||
|
||||
if (this.owner === owner) {
|
||||
this.player.stop();
|
||||
}
|
||||
this.owner = owner;
|
||||
this.player.play(resource);
|
||||
this.connection?.subscribe(this.player);
|
||||
}
|
||||
@@ -48,16 +62,30 @@ export class DiscordPlayer {
|
||||
return this.player.state.status;
|
||||
}
|
||||
|
||||
public pause() {
|
||||
public pause(owner?: DiscordPlayerOwner) {
|
||||
if (!this.canControl(owner)) return;
|
||||
this.player.pause(true);
|
||||
}
|
||||
|
||||
public unpause(): boolean {
|
||||
public unpause(owner?: DiscordPlayerOwner): boolean {
|
||||
if (!this.canControl(owner)) return false;
|
||||
return this.player.unpause();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
public stop(owner?: DiscordPlayerOwner) {
|
||||
if (!this.canControl(owner)) return;
|
||||
this.player.stop();
|
||||
this.owner = "none";
|
||||
}
|
||||
|
||||
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
||||
if (this.owner !== "none" && this.owner !== owner) {
|
||||
throw new Error(`Discord audio player is owned by ${this.owner}`);
|
||||
}
|
||||
}
|
||||
|
||||
private canControl(owner?: DiscordPlayerOwner): boolean {
|
||||
return !owner || this.owner === "none" || this.owner === owner;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
createSegmentMetadata,
|
||||
} from "./recorder/metadata";
|
||||
import { SegmentManager } from "./recorder/segment";
|
||||
import {
|
||||
createRecordingSession,
|
||||
finalizeRecordingSession,
|
||||
type RecordingSession,
|
||||
} from "./recorder/sessionRecording";
|
||||
import { retryWithBackoff } from "./retry";
|
||||
import type { PcmBroadcaster } from "./types";
|
||||
|
||||
@@ -32,6 +37,21 @@ if (!fs.existsSync(recordingsDir)) {
|
||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, RecordingSession>();
|
||||
|
||||
export function resetActiveSessions(): void {
|
||||
activeSessions.clear();
|
||||
}
|
||||
|
||||
function finalizeActiveRecordingSession(guildId: string): void {
|
||||
const session = activeSessions.get(guildId);
|
||||
if (!session) return;
|
||||
activeSessions.delete(guildId);
|
||||
finalizeRecordingSession(session).catch((error) => {
|
||||
logger.error({ error }, "Failed to finalize recording session");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
||||
*/
|
||||
@@ -78,6 +98,17 @@ export async function startRecording(
|
||||
},
|
||||
);
|
||||
logger.info("Connected to voice channel. Recording started");
|
||||
|
||||
// Create recording session after connection is ready
|
||||
const sessionStartTime = Date.now();
|
||||
const session = createRecordingSession({
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
startTime: sessionStartTime,
|
||||
recordingsDir,
|
||||
});
|
||||
activeSessions.set(channel.guild.id, session);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to connect to voice channel");
|
||||
connection.destroy();
|
||||
@@ -89,7 +120,11 @@ export async function startRecording(
|
||||
|
||||
// Dengarkan siapapun yang mulai bicara
|
||||
receiver.speaking.on("start", async (userId) => {
|
||||
if (userId === client.user?.id) return;
|
||||
|
||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||
if (userMetadata.bot) return;
|
||||
|
||||
logger.info(
|
||||
{ userId, username: userMetadata.username },
|
||||
"Voice activity detected",
|
||||
@@ -105,9 +140,6 @@ export async function startRecording(
|
||||
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||
if (receiver.subscriptions.has(userId)) return;
|
||||
|
||||
const timestamp = Date.now();
|
||||
const sessionStartTime = timestamp;
|
||||
const sessionId = `${userId}-${sessionStartTime}`;
|
||||
const userDir = path.join(recordingsDir, userId);
|
||||
if (!fs.existsSync(userDir)) {
|
||||
fs.mkdirSync(userDir, { recursive: true });
|
||||
@@ -145,16 +177,28 @@ export async function startRecording(
|
||||
},
|
||||
});
|
||||
|
||||
const activeSession = activeSessions.get(channel.guild.id);
|
||||
let currentSegment = segmentManager.open(oggPacketStream);
|
||||
currentSegment.out.on("finish", () => {
|
||||
if (config.VERBOSE) {
|
||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||
}
|
||||
const endTime = currentSegment.endTime ?? Date.now();
|
||||
if (activeSession) {
|
||||
activeSession.registerSegment({
|
||||
user: userMetadata,
|
||||
oggPath: currentSegment.filename,
|
||||
jsonPath: currentSegment.jsonFilename,
|
||||
startTime: currentSegment.startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
const metadata = createSegmentMetadata(
|
||||
userMetadata,
|
||||
currentSegment,
|
||||
sessionId,
|
||||
sessionStartTime,
|
||||
activeSession?.sessionId ?? `${userId}-0`,
|
||||
activeSession?.sessionId ?? `${channel.guild.id}-${channel.id}-0`,
|
||||
activeSession?.startTime ?? 0,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
@@ -236,6 +280,7 @@ export async function startRecording(
|
||||
});
|
||||
|
||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||
finalizeActiveRecordingSession(channel.guild.id);
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Voice connection destroyed");
|
||||
}
|
||||
@@ -257,4 +302,6 @@ export function stopRecording(guildId: string): void {
|
||||
} else {
|
||||
logger.warn("No active connection to stop");
|
||||
}
|
||||
|
||||
finalizeActiveRecordingSession(guildId);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function createSegmentMetadata(
|
||||
user: UserMetadata,
|
||||
segment: SegmentState,
|
||||
sessionId: string,
|
||||
recordingSessionId: string,
|
||||
sessionStartTime: number,
|
||||
recordingSegmentMs: number,
|
||||
): SegmentMetadata {
|
||||
@@ -62,6 +63,7 @@ export function createSegmentMetadata(
|
||||
return {
|
||||
...user,
|
||||
sessionId,
|
||||
recordingSessionId,
|
||||
sessionStartTime,
|
||||
segmentIndex: segment.index,
|
||||
segmentMs: recordingSegmentMs,
|
||||
|
||||
192
src/recorder/sessionRecording.ts
Normal file
192
src/recorder/sessionRecording.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildMuxFfmpegArgs,
|
||||
runFfmpeg as defaultRunFfmpeg,
|
||||
} from "../audio/ffmpegProcess";
|
||||
import type { UserMetadata } from "../types";
|
||||
|
||||
export type SessionRecordingStatus =
|
||||
| "pending"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "empty";
|
||||
|
||||
export interface RecordingSessionOptions {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
recordingsDir: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentInput {
|
||||
user: UserMetadata;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SessionParticipant {
|
||||
userId: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentRef {
|
||||
userId: string;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
offsetMs: number;
|
||||
}
|
||||
|
||||
export interface SessionRecordingMetadata {
|
||||
sessionId: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
status: SessionRecordingStatus;
|
||||
outputFile: string | null;
|
||||
participants: SessionParticipant[];
|
||||
segments: SessionSegmentRef[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
readonly sessionId: string;
|
||||
readonly recordingsDir: string;
|
||||
readonly startTime: number;
|
||||
registerSegment(input: SessionSegmentInput): void;
|
||||
snapshot(endTime: number): SessionRecordingMetadata;
|
||||
}
|
||||
|
||||
export interface FinalizeRecordingSessionDependencies {
|
||||
endTime?: number;
|
||||
mkdir?: (dir: string) => void;
|
||||
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
|
||||
runFfmpeg?: (args: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createRecordingSession(
|
||||
options: RecordingSessionOptions,
|
||||
): RecordingSession {
|
||||
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
|
||||
const participants = new Map<string, SessionParticipant>();
|
||||
const segments: SessionSegmentRef[] = [];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
recordingsDir: options.recordingsDir,
|
||||
startTime: options.startTime,
|
||||
|
||||
registerSegment(input: SessionSegmentInput): void {
|
||||
participants.set(input.user.userId, {
|
||||
userId: input.user.userId,
|
||||
username: input.user.username,
|
||||
tag: input.user.tag,
|
||||
displayName: input.user.displayName,
|
||||
avatarUrl: input.user.avatarUrl,
|
||||
});
|
||||
segments.push({
|
||||
userId: input.user.userId,
|
||||
oggPath: input.oggPath,
|
||||
jsonPath: input.jsonPath,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
durationMs: input.endTime - input.startTime,
|
||||
offsetMs: input.startTime - options.startTime,
|
||||
});
|
||||
},
|
||||
|
||||
snapshot(endTime: number): SessionRecordingMetadata {
|
||||
return {
|
||||
sessionId,
|
||||
guildId: options.guildId,
|
||||
channelId: options.channelId,
|
||||
channelName: options.channelName,
|
||||
startTime: options.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - options.startTime,
|
||||
status: "pending",
|
||||
outputFile: null,
|
||||
participants: Array.from(participants.values()),
|
||||
segments: [...segments],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionMuxFilter(
|
||||
segments: Array<{ startTime: number }>,
|
||||
sessionStartTime: number,
|
||||
): string {
|
||||
const filters = segments.map((segment, index) => {
|
||||
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
|
||||
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
|
||||
});
|
||||
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
|
||||
filters.push(
|
||||
`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`,
|
||||
);
|
||||
return filters.join(";");
|
||||
}
|
||||
|
||||
export async function finalizeRecordingSession(
|
||||
session: RecordingSession,
|
||||
dependencies: FinalizeRecordingSessionDependencies = {},
|
||||
): Promise<void> {
|
||||
const endTime = dependencies.endTime ?? Date.now();
|
||||
const sessionDir = path.join(
|
||||
session.recordingsDir,
|
||||
"sessions",
|
||||
session.sessionId,
|
||||
);
|
||||
const outputFile = path.join(sessionDir, "full.ogg");
|
||||
const metadataFile = path.join(sessionDir, "session.json");
|
||||
const mkdir =
|
||||
dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
|
||||
const writeJson =
|
||||
dependencies.writeJson ??
|
||||
((file, metadata) =>
|
||||
fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
|
||||
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
|
||||
|
||||
mkdir(sessionDir);
|
||||
const metadata = session.snapshot(endTime);
|
||||
|
||||
if (metadata.segments.length === 0) {
|
||||
writeJson(metadataFile, { ...metadata, status: "empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runFfmpeg(
|
||||
buildMuxFfmpegArgs({
|
||||
inputs: metadata.segments.map((segment) => segment.oggPath),
|
||||
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
|
||||
output: outputFile,
|
||||
codec: "libopus",
|
||||
}),
|
||||
);
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "completed",
|
||||
outputFile,
|
||||
});
|
||||
} catch (error) {
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors";
|
||||
import type { MediaController } from "../media/mediaController";
|
||||
import type { MediaMode } from "../media/mediaTypes";
|
||||
|
||||
export type MediaRouteController = Pick<
|
||||
MediaController,
|
||||
@@ -21,7 +22,10 @@ export function createMediaRoutes(controller: MediaRouteController): Router {
|
||||
|
||||
router.post("/media/queue", async (req, res, next) => {
|
||||
try {
|
||||
const { source } = req.body as { source?: string };
|
||||
const { source, mode = "music" } = req.body as {
|
||||
source?: string;
|
||||
mode?: MediaMode;
|
||||
};
|
||||
if (!source) {
|
||||
throw new AppError(
|
||||
"Media source is required",
|
||||
@@ -29,7 +33,10 @@ export function createMediaRoutes(controller: MediaRouteController): Router {
|
||||
400,
|
||||
);
|
||||
}
|
||||
res.json(await controller.queue(source));
|
||||
if (mode !== "music" && mode !== "screen") {
|
||||
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
|
||||
}
|
||||
res.json(await controller.queue(source, { mode }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface SegmentState {
|
||||
}
|
||||
|
||||
export interface SegmentMetadata extends UserMetadata {
|
||||
recordingSessionId: string;
|
||||
sessionId: string;
|
||||
sessionStartTime: number;
|
||||
segmentIndex: number;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { Streamer } from "@dank074/discord-video-stream";
|
||||
import { AudioPlayerStatus } from "@discordjs/voice";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import { AudioPlayerStatus } from "@discordjs/voice";
|
||||
import * as prism from "prism-media";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { AppError } from "./errors";
|
||||
import { createChildLogger, logger } from "./logger";
|
||||
import { MediaController } from "./media/mediaController";
|
||||
import { createScreenShareController } from "./media/screenShareController";
|
||||
import { getMetrics, uptimeGauge } from "./metrics";
|
||||
import { createBroadcaster } from "./moderation/broadcaster";
|
||||
import type { ModerationBroadcaster } from "./moderation/types";
|
||||
@@ -163,9 +165,16 @@ export async function startWebserver(
|
||||
const broadcaster = createBroadcaster();
|
||||
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
||||
|
||||
const streamer = new Streamer(_client);
|
||||
const screenController = createScreenShareController({
|
||||
getVoiceStatus: () => voiceController.getStatus(),
|
||||
streamer,
|
||||
});
|
||||
|
||||
const mediaController = new MediaController({
|
||||
isVoiceConnected: () => voiceController.getStatus().connected,
|
||||
isBrowserStreaming: () => sharedUIState.isStreaming,
|
||||
screenController,
|
||||
onStateChange: (state) => broadcaster.mediaState(state),
|
||||
});
|
||||
|
||||
@@ -287,11 +296,12 @@ export async function startWebserver(
|
||||
const SILENCE_TAIL_MS = 300; // continue sending silence for 300ms after browser stops
|
||||
const MAX_BUF_BYTES = BYTES_PER_FRAME * 50; // cap at 1 second to avoid runaway buffer
|
||||
|
||||
let opusEncoder: prism.opus.Encoder;
|
||||
let opusEncoder: prism.opus.Encoder | null = null;
|
||||
let bridgePlayerPaused = true;
|
||||
const SILENCE_FRAME = Buffer.alloc(BYTES_PER_FRAME, 0);
|
||||
|
||||
function startBrowserAudioBridge(): void {
|
||||
if (opusEncoder) return;
|
||||
opusEncoder = new prism.opus.Encoder({
|
||||
rate: RATE,
|
||||
channels: CHANNELS,
|
||||
@@ -308,19 +318,23 @@ export async function startWebserver(
|
||||
opusEncoder.on("error", () => {});
|
||||
opusEncoder.pipe(oggBitstream);
|
||||
opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0));
|
||||
discordPlayer.playStream(oggBitstream);
|
||||
discordPlayer.pause();
|
||||
discordPlayer.playStream(oggBitstream, "browser-bridge");
|
||||
discordPlayer.pause("browser-bridge");
|
||||
bridgePlayerPaused = true;
|
||||
}
|
||||
|
||||
function ensureBrowserAudioBridge(): void {
|
||||
if (discordPlayer.getStatus() === AudioPlayerStatus.Idle) {
|
||||
function ensureBrowserAudioBridge(): boolean {
|
||||
const owner = discordPlayer.getOwner();
|
||||
if (owner !== "none" && owner !== "browser-bridge") return false;
|
||||
if (
|
||||
owner === "none" ||
|
||||
discordPlayer.getStatus() === AudioPlayerStatus.Idle
|
||||
) {
|
||||
startBrowserAudioBridge();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
startBrowserAudioBridge();
|
||||
|
||||
let pcmBuffer = Buffer.alloc(0);
|
||||
let lastBrowserAudioTime = 0;
|
||||
|
||||
@@ -351,9 +365,12 @@ export async function startWebserver(
|
||||
dbAccum += rmsDb(frame);
|
||||
dbCount++;
|
||||
|
||||
ensureBrowserAudioBridge();
|
||||
if (!ensureBrowserAudioBridge()) {
|
||||
pcmBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
if (bridgePlayerPaused) {
|
||||
const unpaused = discordPlayer.unpause();
|
||||
const unpaused = discordPlayer.unpause("browser-bridge");
|
||||
bridgePlayerPaused = false;
|
||||
wsLogger.info({ unpaused }, "Transmitting — Discord indicator ON");
|
||||
}
|
||||
@@ -362,7 +379,7 @@ export async function startWebserver(
|
||||
frame = SILENCE_FRAME;
|
||||
} else if (!bridgePlayerPaused && msSinceAudio >= SILENCE_TAIL_MS) {
|
||||
// No audio for a while — pause Discord indicator
|
||||
discordPlayer.pause();
|
||||
discordPlayer.pause("browser-bridge");
|
||||
bridgePlayerPaused = true;
|
||||
wsLogger.info("Stopped — Discord indicator OFF");
|
||||
return;
|
||||
@@ -371,6 +388,7 @@ export async function startWebserver(
|
||||
}
|
||||
|
||||
// Write one frame. If encoder is backpressured, skip this tick to avoid stalling.
|
||||
if (!opusEncoder) return;
|
||||
const ok = opusEncoder.write(frame);
|
||||
if (!ok) {
|
||||
opusEncoder.once("drain", () => {}); // re-arm drain without blocking
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
MusicPlayback,
|
||||
MusicPlayer,
|
||||
ResolvedMediaSource,
|
||||
ScreenShareController,
|
||||
} from "../../src/media/mediaTypes";
|
||||
|
||||
function deferred() {
|
||||
@@ -190,7 +191,62 @@ describe("MediaController", () => {
|
||||
const state = await controller.stop();
|
||||
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(state).toEqual({ playing: false, current: null, queue: [] });
|
||||
expect(state).toEqual({
|
||||
playing: false,
|
||||
activeMode: null,
|
||||
current: null,
|
||||
queue: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("starts screen share mode without resolving music source", async () => {
|
||||
const screenPlayback = deferred();
|
||||
const screenController: ScreenShareController = {
|
||||
isActive: vi.fn(() => false),
|
||||
start: vi.fn(async () => ({
|
||||
done: screenPlayback.promise,
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
};
|
||||
const resolveMediaSource = vi.fn(async (input) => source(input));
|
||||
const controller = new MediaController({
|
||||
isVoiceConnected: () => true,
|
||||
isBrowserStreaming: () => false,
|
||||
resolveMediaSource,
|
||||
musicPlayer: { play: vi.fn() },
|
||||
screenController,
|
||||
});
|
||||
|
||||
const state = await controller.queue("https://youtu.be/video", {
|
||||
mode: "screen",
|
||||
});
|
||||
|
||||
expect(screenController.start).toHaveBeenCalledWith(
|
||||
"https://youtu.be/video",
|
||||
);
|
||||
expect(resolveMediaSource).not.toHaveBeenCalled();
|
||||
expect(state).toMatchObject({ playing: true, activeMode: "screen" });
|
||||
});
|
||||
|
||||
it("rejects music while screen share is active", async () => {
|
||||
const screenController: ScreenShareController = {
|
||||
isActive: vi.fn(() => true),
|
||||
start: vi.fn(),
|
||||
};
|
||||
const controller = new MediaController({
|
||||
isVoiceConnected: () => true,
|
||||
isBrowserStreaming: () => false,
|
||||
resolveMediaSource: async (input) => source(input),
|
||||
musicPlayer: { play: vi.fn() },
|
||||
screenController,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.queue("https://example.com/song.mp3"),
|
||||
).rejects.toMatchObject({
|
||||
code: "MEDIA_BUSY",
|
||||
statusCode: 409,
|
||||
} satisfies Partial<AppError>);
|
||||
});
|
||||
|
||||
it("emits state changes", async () => {
|
||||
@@ -200,7 +256,10 @@ describe("MediaController", () => {
|
||||
isBrowserStreaming: () => false,
|
||||
resolveMediaSource: async (input) => source(input),
|
||||
musicPlayer: {
|
||||
play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })),
|
||||
play: vi.fn(() => ({
|
||||
done: new Promise<void>(() => {}),
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
},
|
||||
onStateChange,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("MediaQueue", () => {
|
||||
() => 1700000000000,
|
||||
);
|
||||
|
||||
const item = queue.add(source(), "tester");
|
||||
const item = queue.add(source(), "music", "tester");
|
||||
|
||||
expect(item).toMatchObject({
|
||||
id: "item-1",
|
||||
@@ -40,7 +40,7 @@ describe("MediaQueue", () => {
|
||||
() => "item-1",
|
||||
() => 1700000000000,
|
||||
);
|
||||
const item = queue.add(source(), "tester");
|
||||
const item = queue.add(source(), "music", "tester");
|
||||
|
||||
expect(queue.startNext()).toEqual({ ...item, status: "playing" });
|
||||
expect(queue.snapshot()).toEqual({
|
||||
@@ -55,8 +55,8 @@ describe("MediaQueue", () => {
|
||||
() => `item-${++id}`,
|
||||
() => 1700000000000,
|
||||
);
|
||||
queue.add(source({ title: "first" }), "tester");
|
||||
queue.add(source({ title: "second" }), "tester");
|
||||
queue.add(source({ title: "first" }), "music", "tester");
|
||||
queue.add(source({ title: "second" }), "music", "tester");
|
||||
queue.startNext();
|
||||
|
||||
queue.completeCurrent();
|
||||
@@ -71,7 +71,7 @@ describe("MediaQueue", () => {
|
||||
() => "item-1",
|
||||
() => 1700000000000,
|
||||
);
|
||||
const item = queue.add(source(), "tester");
|
||||
const item = queue.add(source(), "music", "tester");
|
||||
queue.startNext();
|
||||
|
||||
expect(queue.failCurrent()).toEqual({ ...item, status: "failed" });
|
||||
@@ -83,7 +83,7 @@ describe("MediaQueue", () => {
|
||||
() => "item-1",
|
||||
() => 1700000000000,
|
||||
);
|
||||
queue.add(source(), "tester");
|
||||
queue.add(source(), "music", "tester");
|
||||
queue.startNext();
|
||||
|
||||
queue.clear();
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { spawn as nodeSpawn } from "node:child_process";
|
||||
|
||||
type Spawn = typeof nodeSpawn;
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordAudioPlayer } from "../../src/media/mediaTypes";
|
||||
import type {
|
||||
DiscordAudioPlayer,
|
||||
DiscordPlayerOwner,
|
||||
} from "../../src/media/mediaTypes";
|
||||
import { createMusicPlayer } from "../../src/media/musicPlayer";
|
||||
|
||||
class FakeProcess extends EventEmitter {
|
||||
@@ -22,9 +29,15 @@ describe("createMusicPlayer", () => {
|
||||
const discordPlayer: DiscordAudioPlayer = {
|
||||
isConnected: () => true,
|
||||
playStream: vi.fn(),
|
||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||
pause: vi.fn(),
|
||||
unpause: vi.fn(() => true),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const player = createMusicPlayer({ spawn, discordPlayer });
|
||||
const player = createMusicPlayer({
|
||||
spawn: spawn as unknown as Spawn,
|
||||
discordPlayer,
|
||||
});
|
||||
|
||||
const playback = player.play({
|
||||
source: "https://example.com/song.mp3",
|
||||
@@ -55,7 +68,7 @@ describe("createMusicPlayer", () => {
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout);
|
||||
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music");
|
||||
});
|
||||
|
||||
it("rejects playback when Discord is not connected", () => {
|
||||
@@ -63,9 +76,15 @@ describe("createMusicPlayer", () => {
|
||||
const discordPlayer: DiscordAudioPlayer = {
|
||||
isConnected: () => false,
|
||||
playStream: vi.fn(),
|
||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||
pause: vi.fn(),
|
||||
unpause: vi.fn(() => true),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const player = createMusicPlayer({ spawn, discordPlayer });
|
||||
const player = createMusicPlayer({
|
||||
spawn: spawn as unknown as Spawn,
|
||||
discordPlayer,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
player.play({
|
||||
@@ -77,15 +96,44 @@ describe("createMusicPlayer", () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases ownership on normal ffmpeg close", async () => {
|
||||
const proc = new FakeProcess();
|
||||
const discordPlayer: DiscordAudioPlayer = {
|
||||
isConnected: () => true,
|
||||
playStream: vi.fn(),
|
||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||
pause: vi.fn(),
|
||||
unpause: vi.fn(() => true),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const player = createMusicPlayer({
|
||||
spawn: vi.fn(() => proc) as unknown as Spawn,
|
||||
discordPlayer,
|
||||
});
|
||||
|
||||
const playback = player.play({
|
||||
source: "/tmp/song.ogg",
|
||||
title: "song.ogg",
|
||||
kind: "local",
|
||||
});
|
||||
// simulate normal close
|
||||
proc.emit("close", 0);
|
||||
await playback.done;
|
||||
expect(discordPlayer.stop).toHaveBeenCalledWith("music");
|
||||
});
|
||||
|
||||
it("kills ffmpeg and stops Discord playback once", () => {
|
||||
const proc = new FakeProcess();
|
||||
const discordPlayer: DiscordAudioPlayer = {
|
||||
isConnected: () => true,
|
||||
playStream: vi.fn(),
|
||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||
pause: vi.fn(),
|
||||
unpause: vi.fn(() => true),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const player = createMusicPlayer({
|
||||
spawn: vi.fn(() => proc),
|
||||
spawn: vi.fn(() => proc) as unknown as Spawn,
|
||||
discordPlayer,
|
||||
});
|
||||
|
||||
|
||||
94
tests/media/screenShareController.test.ts
Normal file
94
tests/media/screenShareController.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppError } from "../../src/errors";
|
||||
import type { DiscordPlayerOwner } from "../../src/media/mediaTypes";
|
||||
import { createScreenShareController } from "../../src/media/screenShareController";
|
||||
|
||||
function createDependencies() {
|
||||
const output = new PassThrough();
|
||||
return {
|
||||
getVoiceStatus: vi.fn(() => ({
|
||||
connected: true,
|
||||
activeGuildId: "guild-1" as string | null,
|
||||
activeChannelId: "channel-1" as string | null,
|
||||
})),
|
||||
getPlayerOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||
getDirectVideoUrl: vi.fn(async () => "https://cdn.example.com/video.mp4"),
|
||||
prepareStream: vi.fn(() => ({
|
||||
command: { kill: vi.fn() },
|
||||
output,
|
||||
})),
|
||||
playStream: vi.fn(() => new Promise<void>(() => {})),
|
||||
streamer: { id: "streamer" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("createScreenShareController", () => {
|
||||
it("starts a YouTube Go Live stream", async () => {
|
||||
const dependencies = createDependencies();
|
||||
const controller = createScreenShareController(dependencies);
|
||||
|
||||
const playback = await controller.start("https://youtu.be/video");
|
||||
|
||||
expect(dependencies.getDirectVideoUrl).toHaveBeenCalledWith(
|
||||
"https://youtu.be/video",
|
||||
);
|
||||
expect(dependencies.prepareStream).toHaveBeenCalledWith(
|
||||
"https://cdn.example.com/video.mp4",
|
||||
expect.objectContaining({ includeAudio: true }),
|
||||
);
|
||||
expect(dependencies.playStream).toHaveBeenCalledWith(
|
||||
dependencies.prepareStream.mock.results[0].value.output,
|
||||
dependencies.streamer,
|
||||
{ type: "go-live" },
|
||||
);
|
||||
expect(controller.isActive()).toBe(true);
|
||||
playback.stop();
|
||||
expect(controller.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when voice is not connected", async () => {
|
||||
const dependencies = createDependencies();
|
||||
dependencies.getVoiceStatus.mockReturnValue({
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
});
|
||||
const controller = createScreenShareController(dependencies);
|
||||
|
||||
await expect(
|
||||
controller.start("https://youtu.be/video"),
|
||||
).rejects.toMatchObject({
|
||||
code: "VOICE_NOT_CONNECTED",
|
||||
statusCode: 409,
|
||||
} satisfies Partial<AppError>);
|
||||
});
|
||||
|
||||
it("rejects when music owns the shared player", async () => {
|
||||
const dependencies = createDependencies();
|
||||
dependencies.getPlayerOwner.mockReturnValue("music");
|
||||
const controller = createScreenShareController(dependencies);
|
||||
|
||||
await expect(
|
||||
controller.start("https://youtu.be/video"),
|
||||
).rejects.toMatchObject({
|
||||
code: "MEDIA_BUSY",
|
||||
statusCode: 409,
|
||||
} satisfies Partial<AppError>);
|
||||
});
|
||||
|
||||
it("wraps stream startup failures", async () => {
|
||||
const dependencies = createDependencies();
|
||||
dependencies.playStream.mockImplementation(() => {
|
||||
throw new Error("go live failed");
|
||||
});
|
||||
const controller = createScreenShareController(dependencies);
|
||||
|
||||
await expect(
|
||||
controller.start("https://youtu.be/video"),
|
||||
).rejects.toMatchObject({
|
||||
code: "SCREEN_STREAM_FAILED",
|
||||
statusCode: 500,
|
||||
} satisfies Partial<AppError>);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,8 @@ describe("createYtDlp", () => {
|
||||
|
||||
it("reads direct audio URL", async () => {
|
||||
const proc = new FakeProcess();
|
||||
const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) });
|
||||
const spawn = vi.fn(() => proc);
|
||||
const ytdlp = createYtDlp({ spawn });
|
||||
|
||||
const result = ytdlp.getDirectAudioUrl("https://youtu.be/video");
|
||||
proc.stdout.write("https://audio.example.com/stream\n");
|
||||
@@ -51,6 +52,45 @@ describe("createYtDlp", () => {
|
||||
proc.emit("close", 0);
|
||||
|
||||
await expect(result).resolves.toBe("https://audio.example.com/stream");
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"yt-dlp",
|
||||
[
|
||||
"https://youtu.be/video",
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestaudio[protocol^=http]/bestaudio/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("reads direct video URL", async () => {
|
||||
const proc = new FakeProcess();
|
||||
const spawn = vi.fn(() => proc);
|
||||
const ytdlp = createYtDlp({ spawn });
|
||||
|
||||
const result = ytdlp.getDirectVideoUrl("https://youtu.be/video");
|
||||
proc.stdout.write("https://video.example.com/stream\n");
|
||||
proc.stdout.end();
|
||||
proc.emit("close", 0);
|
||||
|
||||
await expect(result).resolves.toBe("https://video.example.com/stream");
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"yt-dlp",
|
||||
[
|
||||
"https://youtu.be/video",
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when yt-dlp exits non-zero", async () => {
|
||||
|
||||
82
tests/player.test.ts
Normal file
82
tests/player.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock @discordjs/voice
|
||||
vi.mock("@discordjs/voice", () => {
|
||||
const mockPlayer = {
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
unpause: vi.fn().mockReturnValue(true),
|
||||
stop: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: { status: "idle" },
|
||||
};
|
||||
const mockConnection = {
|
||||
subscribe: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
return {
|
||||
AudioPlayerStatus: { Idle: "idle", Playing: "playing", Paused: "paused" },
|
||||
createAudioPlayer: vi.fn(() => mockPlayer),
|
||||
createAudioResource: vi.fn(() => ({})),
|
||||
StreamType: { OggOpus: "OggOpus" },
|
||||
AudioPlayer: vi.fn(),
|
||||
VoiceConnection: vi.fn(),
|
||||
__mockPlayer: mockPlayer,
|
||||
__mockConnection: mockConnection,
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks
|
||||
import { DiscordPlayer } from "../src/player";
|
||||
|
||||
describe("DiscordPlayer", () => {
|
||||
let player: DiscordPlayer;
|
||||
const dummyStream = new Readable();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
player = new DiscordPlayer();
|
||||
});
|
||||
|
||||
describe("ownership", () => {
|
||||
it("starts with owner none", () => {
|
||||
expect(player.getOwner()).toBe("none");
|
||||
});
|
||||
|
||||
it("playStream with owner sets owner", () => {
|
||||
player.playStream(dummyStream, "music");
|
||||
expect(player.getOwner()).toBe("music");
|
||||
});
|
||||
|
||||
it("browser bridge cannot override music owner", () => {
|
||||
player.playStream(dummyStream, "music");
|
||||
expect(() => player.playStream(dummyStream, "browser-bridge")).toThrow(
|
||||
"Discord audio player is owned by music",
|
||||
);
|
||||
});
|
||||
|
||||
it("same owner can replace stream without error", () => {
|
||||
player.playStream(dummyStream, "music");
|
||||
expect(() => player.playStream(dummyStream, "music")).not.toThrow();
|
||||
expect(player.getOwner()).toBe("music");
|
||||
});
|
||||
|
||||
it("matching owner stop releases ownership", () => {
|
||||
player.playStream(dummyStream, "music");
|
||||
player.stop("music");
|
||||
expect(player.getOwner()).toBe("none");
|
||||
});
|
||||
|
||||
it("non-owner stop is ignored", () => {
|
||||
player.playStream(dummyStream, "music");
|
||||
player.stop("browser-bridge");
|
||||
expect(player.getOwner()).toBe("music");
|
||||
});
|
||||
|
||||
it("stop without owner releases ownership", () => {
|
||||
player.playStream(dummyStream, "music");
|
||||
player.stop();
|
||||
expect(player.getOwner()).toBe("none");
|
||||
});
|
||||
});
|
||||
});
|
||||
241
tests/recorder.test.ts
Normal file
241
tests/recorder.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Use vi.hoisted so mocks are available at module evaluation time (when vi.mock hoists)
|
||||
const mocks = vi.hoisted(() => {
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
const speaker = {
|
||||
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
||||
return speaker;
|
||||
}),
|
||||
emit: vi.fn((event: string, ...args: unknown[]) => {
|
||||
for (const listener of listeners.get(event) ?? []) listener(...args);
|
||||
return true;
|
||||
}),
|
||||
removeAllListeners: vi.fn(() => {
|
||||
listeners.clear();
|
||||
return speaker;
|
||||
}),
|
||||
};
|
||||
return {
|
||||
mockSpeaker: speaker,
|
||||
mockSubscribe: vi.fn(() => {
|
||||
const oggPacketStream = {
|
||||
pipe: vi.fn(() => ({ pipe: vi.fn(() => ({ on: vi.fn() })) })),
|
||||
unpipe: vi.fn(),
|
||||
};
|
||||
return {
|
||||
pipe: vi.fn(() => oggPacketStream),
|
||||
on: vi.fn(),
|
||||
};
|
||||
}),
|
||||
mockDestroy: vi.fn(),
|
||||
mockWriteFileSync: vi.fn(),
|
||||
mockMkdirSync: vi.fn(),
|
||||
mockOggPipe: vi.fn(() => ({ pipe: vi.fn(() => ({ on: vi.fn() })) })),
|
||||
mockCreateWriteStream: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
})),
|
||||
mockFsExistsSync: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
default: {
|
||||
createWriteStream: mocks.mockCreateWriteStream,
|
||||
existsSync: mocks.mockFsExistsSync,
|
||||
mkdirSync: mocks.mockMkdirSync,
|
||||
writeFileSync: mocks.mockWriteFileSync,
|
||||
},
|
||||
createWriteStream: mocks.mockCreateWriteStream,
|
||||
existsSync: mocks.mockFsExistsSync,
|
||||
mkdirSync: mocks.mockMkdirSync,
|
||||
writeFileSync: mocks.mockWriteFileSync,
|
||||
}));
|
||||
|
||||
vi.mock("prism-media", () => ({
|
||||
opus: {
|
||||
OggLogicalBitstream: vi.fn(function OggLogicalBitstream() {
|
||||
return {
|
||||
pipe: mocks.mockOggPipe,
|
||||
end: vi.fn(),
|
||||
};
|
||||
}),
|
||||
OpusHead: vi.fn(function OpusHead() {}),
|
||||
Decoder: vi.fn(function Decoder() {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@discordjs/voice", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@discordjs/voice")>(
|
||||
"@discordjs/voice",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
joinVoiceChannel: vi.fn(() => ({
|
||||
receiver: {
|
||||
speaking: mocks.mockSpeaker,
|
||||
subscriptions: new Map(),
|
||||
subscribe: mocks.mockSubscribe,
|
||||
},
|
||||
on: vi.fn(),
|
||||
destroy: mocks.mockDestroy,
|
||||
})),
|
||||
entersState: vi.fn().mockResolvedValue(undefined),
|
||||
getVoiceConnection: vi.fn(() => ({
|
||||
destroy: mocks.mockDestroy,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/retry", () => ({
|
||||
retryWithBackoff: vi.fn((fn: () => Promise<unknown>) => fn()),
|
||||
}));
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function createChannel() {
|
||||
return {
|
||||
id: "voice-channel",
|
||||
name: "Voice",
|
||||
guild: {
|
||||
id: "guild",
|
||||
voiceAdapterCreator: {},
|
||||
members: {
|
||||
cache: new Map(),
|
||||
fetch: vi.fn(async () => null),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("startRecording", () => {
|
||||
beforeEach(() => {
|
||||
mocks.mockSubscribe.mockClear();
|
||||
mocks.mockSpeaker.removeAllListeners();
|
||||
mocks.mockDestroy.mockClear();
|
||||
mocks.mockWriteFileSync.mockClear();
|
||||
mocks.mockMkdirSync.mockClear();
|
||||
mocks.mockOggPipe.mockClear();
|
||||
});
|
||||
|
||||
it("does not subscribe to the bot user's own audio", async () => {
|
||||
const { startRecording, resetActiveSessions } = await import(
|
||||
"../src/recorder"
|
||||
);
|
||||
resetActiveSessions();
|
||||
const client = { user: { id: "bot-user" } };
|
||||
const channel = createChannel();
|
||||
|
||||
await startRecording(client as never, channel as never);
|
||||
mocks.mockSpeaker.emit("start", "bot-user");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mocks.mockSubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not subscribe to other bot users", async () => {
|
||||
const { startRecording, resetActiveSessions } = await import(
|
||||
"../src/recorder"
|
||||
);
|
||||
resetActiveSessions();
|
||||
const client = {
|
||||
user: { id: "self-user" },
|
||||
users: {
|
||||
cache: new Map([
|
||||
[
|
||||
"music-bot",
|
||||
{
|
||||
id: "music-bot",
|
||||
username: "Jockie Music",
|
||||
tag: "Jockie Music#8158",
|
||||
bot: true,
|
||||
displayAvatarURL: vi.fn(() => "https://example.com/avatar.png"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
fetch: vi.fn(async () => null),
|
||||
},
|
||||
};
|
||||
|
||||
await startRecording(client as never, createChannel() as never);
|
||||
mocks.mockSpeaker.emit("start", "music-bot");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mocks.mockSubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("subscribes to a non-bot human user", async () => {
|
||||
const { startRecording, resetActiveSessions } = await import(
|
||||
"../src/recorder"
|
||||
);
|
||||
resetActiveSessions();
|
||||
const client = {
|
||||
user: { id: "self-user" },
|
||||
users: {
|
||||
cache: new Map([
|
||||
[
|
||||
"human-user",
|
||||
{
|
||||
id: "human-user",
|
||||
username: "Alice",
|
||||
tag: "Alice#0001",
|
||||
bot: false,
|
||||
displayAvatarURL: vi.fn(() => "https://example.com/avatar.png"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
fetch: vi.fn(async () => null),
|
||||
},
|
||||
};
|
||||
|
||||
await startRecording(client as never, createChannel() as never);
|
||||
mocks.mockSpeaker.emit("start", "human-user");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mocks.mockSubscribe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopRecording", () => {
|
||||
beforeEach(() => {
|
||||
mocks.mockSubscribe.mockClear();
|
||||
mocks.mockSpeaker.removeAllListeners();
|
||||
mocks.mockDestroy.mockClear();
|
||||
mocks.mockWriteFileSync.mockClear();
|
||||
mocks.mockMkdirSync.mockClear();
|
||||
mocks.mockOggPipe.mockClear();
|
||||
});
|
||||
|
||||
it("destroys the voice connection", async () => {
|
||||
const { startRecording, stopRecording, resetActiveSessions } = await import(
|
||||
"../src/recorder"
|
||||
);
|
||||
resetActiveSessions();
|
||||
const client = { user: { id: "self-user" } };
|
||||
|
||||
await startRecording(client as never, createChannel() as never);
|
||||
stopRecording("guild");
|
||||
|
||||
expect(mocks.mockDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalizes the active recording session", async () => {
|
||||
const { startRecording, stopRecording, resetActiveSessions } = await import(
|
||||
"../src/recorder"
|
||||
);
|
||||
resetActiveSessions();
|
||||
const client = { user: { id: "self-user" } };
|
||||
|
||||
await startRecording(client as never, createChannel() as never);
|
||||
stopRecording("guild");
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mocks.mockMkdirSync).toHaveBeenCalled();
|
||||
expect(mocks.mockWriteFileSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
46
tests/recorder/metadata.test.ts
Normal file
46
tests/recorder/metadata.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSegmentMetadata } from "../../src/recorder/metadata";
|
||||
import type { SegmentState, UserMetadata } from "../../src/types";
|
||||
|
||||
describe("createSegmentMetadata", () => {
|
||||
const user: UserMetadata = {
|
||||
userId: "user-1",
|
||||
username: "Alice",
|
||||
tag: "Alice#0001",
|
||||
displayName: "Alice",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
bot: false,
|
||||
roles: [],
|
||||
highestRole: null,
|
||||
joinedTimestamp: null,
|
||||
};
|
||||
|
||||
const segment = {
|
||||
index: 0,
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
filename: "/recordings/user-1/1500.ogg",
|
||||
jsonFilename: "/recordings/user-1/1500.json",
|
||||
oggStream: {} as any,
|
||||
out: {} as any,
|
||||
} as SegmentState;
|
||||
|
||||
it("includes shared recording session id", () => {
|
||||
const metadata = createSegmentMetadata(
|
||||
user,
|
||||
segment,
|
||||
"user-1-1500",
|
||||
"guild-voice-1000",
|
||||
1000,
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
sessionId: "user-1-1500",
|
||||
recordingSessionId: "guild-voice-1000",
|
||||
sessionStartTime: 1000,
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
});
|
||||
});
|
||||
});
|
||||
103
tests/recorder/sessionRecording.test.ts
Normal file
103
tests/recorder/sessionRecording.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildSessionMuxFilter,
|
||||
createRecordingSession,
|
||||
finalizeRecordingSession,
|
||||
} from "../../src/recorder/sessionRecording";
|
||||
import type { UserMetadata } from "../../src/types";
|
||||
|
||||
function user(overrides: Partial<UserMetadata> = {}): UserMetadata {
|
||||
return {
|
||||
userId: "user-1",
|
||||
username: "Alice",
|
||||
tag: "Alice#0001",
|
||||
displayName: "Alice",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
bot: false,
|
||||
roles: [],
|
||||
highestRole: null,
|
||||
joinedTimestamp: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sessionRecording", () => {
|
||||
it("tracks participants and segment refs", () => {
|
||||
const session = createRecordingSession({
|
||||
guildId: "guild",
|
||||
channelId: "voice",
|
||||
channelName: "Voice",
|
||||
startTime: 1000,
|
||||
recordingsDir: "/recordings",
|
||||
});
|
||||
|
||||
session.registerSegment({
|
||||
user: user(),
|
||||
oggPath: "/recordings/user-1/1500.ogg",
|
||||
jsonPath: "/recordings/user-1/1500.json",
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
});
|
||||
|
||||
const snapshot = session.snapshot(3000);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
sessionId: "guild-voice-1000",
|
||||
guildId: "guild",
|
||||
channelId: "voice",
|
||||
channelName: "Voice",
|
||||
startTime: 1000,
|
||||
endTime: 3000,
|
||||
durationMs: 2000,
|
||||
status: "pending",
|
||||
participants: [{ userId: "user-1", username: "Alice" }],
|
||||
segments: [
|
||||
{
|
||||
userId: "user-1",
|
||||
oggPath: "/recordings/user-1/1500.ogg",
|
||||
jsonPath: "/recordings/user-1/1500.json",
|
||||
startTime: 1500,
|
||||
endTime: 2500,
|
||||
offsetMs: 500,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds timeline-offset ffmpeg filter", () => {
|
||||
const filter = buildSessionMuxFilter(
|
||||
[{ startTime: 1000 }, { startTime: 2500 }],
|
||||
1000,
|
||||
);
|
||||
expect(filter).toBe(
|
||||
"[0:a]adelay=0|0[pad0];[1:a]adelay=1500|1500[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes empty metadata without running ffmpeg", async () => {
|
||||
const session = createRecordingSession({
|
||||
guildId: "guild",
|
||||
channelId: "voice",
|
||||
channelName: "Voice",
|
||||
startTime: 1000,
|
||||
recordingsDir: "/recordings",
|
||||
});
|
||||
const writeJson = vi.fn();
|
||||
const mkdir = vi.fn();
|
||||
const runFfmpeg = vi.fn();
|
||||
|
||||
await finalizeRecordingSession(session, {
|
||||
endTime: 4000,
|
||||
mkdir,
|
||||
writeJson,
|
||||
runFfmpeg,
|
||||
});
|
||||
|
||||
expect(runFfmpeg).not.toHaveBeenCalled();
|
||||
expect(mkdir).toHaveBeenCalledWith("/recordings/sessions/guild-voice-1000");
|
||||
expect(writeJson).toHaveBeenCalledWith(
|
||||
"/recordings/sessions/guild-voice-1000/session.json",
|
||||
expect.objectContaining({ status: "empty", durationMs: 3000 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,12 @@ function getHandler(
|
||||
describe("createMediaRoutes", () => {
|
||||
it("returns media status", async () => {
|
||||
const controller = {
|
||||
getState: vi.fn(() => ({ playing: false, current: null, queue: [] })),
|
||||
getState: vi.fn(() => ({
|
||||
playing: false,
|
||||
activeMode: null,
|
||||
current: null,
|
||||
queue: [],
|
||||
})),
|
||||
queue: vi.fn(),
|
||||
skip: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
@@ -30,13 +35,14 @@ describe("createMediaRoutes", () => {
|
||||
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
playing: false,
|
||||
activeMode: null,
|
||||
current: null,
|
||||
queue: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("queues a source", async () => {
|
||||
const state = { playing: true, current: null, queue: [] };
|
||||
const state = { playing: true, activeMode: null, current: null, queue: [] };
|
||||
const controller = {
|
||||
getState: vi.fn(),
|
||||
queue: vi.fn(async () => state),
|
||||
@@ -58,10 +64,71 @@ describe("createMediaRoutes", () => {
|
||||
|
||||
expect(controller.queue).toHaveBeenCalledWith(
|
||||
"https://example.com/song.mp3",
|
||||
{ mode: "music" },
|
||||
);
|
||||
expect(json).toHaveBeenCalledWith(state);
|
||||
});
|
||||
|
||||
it("queues a screen source", async () => {
|
||||
const state = {
|
||||
playing: true,
|
||||
activeMode: "screen" as const,
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
const controller = {
|
||||
getState: vi.fn(),
|
||||
queue: vi.fn(async () => state),
|
||||
skip: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const handler = getHandler(
|
||||
createMediaRoutes(controller),
|
||||
"/media/queue",
|
||||
"post",
|
||||
);
|
||||
const json = vi.fn();
|
||||
|
||||
await handler?.(
|
||||
{ body: { source: "https://youtu.be/video", mode: "screen" } } as Request,
|
||||
{ json } as unknown as Response,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(controller.queue).toHaveBeenCalledWith("https://youtu.be/video", {
|
||||
mode: "screen",
|
||||
});
|
||||
expect(json).toHaveBeenCalledWith(state);
|
||||
});
|
||||
|
||||
it("passes invalid mode errors to Express", async () => {
|
||||
const controller = {
|
||||
getState: vi.fn(),
|
||||
queue: vi.fn(),
|
||||
skip: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const handler = getHandler(
|
||||
createMediaRoutes(controller),
|
||||
"/media/queue",
|
||||
"post",
|
||||
);
|
||||
const next = vi.fn();
|
||||
|
||||
await handler?.(
|
||||
{
|
||||
body: { source: "https://example.com/song.mp3", mode: "video" },
|
||||
} as Request,
|
||||
{ json: vi.fn() } as unknown as Response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next.mock.calls[0][0]).toMatchObject({
|
||||
code: "INVALID_MEDIA_MODE",
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes missing source errors to Express", async () => {
|
||||
const controller = {
|
||||
getState: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user