feat: implement full session recording with muxing support
- Add session recording metadata and mux filter builder in src/recorder/sessionRecording.ts. - Update SegmentMetadata to include recordingSessionId in src/types.ts and src/recorder/metadata.ts. - Modify recorder lifecycle to track sessions, register segments, and finalize recordings on stop. - Create tests for session recording functionality in tests/recorder/sessionRecording.test.ts and tests/recorder/metadata.test.ts. - Document session recording design and implementation plan in docs/superpowers/specs/2026-05-16-session-full-recording-design.md and docs/superpowers/plans/2026-05-16-session-full-recording.md.
This commit is contained in:
@@ -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,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.
|
||||
+48
-5
@@ -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();
|
||||
@@ -109,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 });
|
||||
@@ -149,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(
|
||||
@@ -240,6 +280,7 @@ export async function startRecording(
|
||||
});
|
||||
|
||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||
finalizeActiveRecordingSession(channel.guild.id);
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Voice connection destroyed");
|
||||
}
|
||||
@@ -261,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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface SegmentState {
|
||||
}
|
||||
|
||||
export interface SegmentMetadata extends UserMetadata {
|
||||
recordingSessionId: string;
|
||||
sessionId: string;
|
||||
sessionStartTime: number;
|
||||
segmentIndex: number;
|
||||
|
||||
+186
-35
@@ -1,18 +1,102 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const speaking = new EventEmitter();
|
||||
const subscribe = vi.fn();
|
||||
const joinVoiceChannel = vi.fn(() => ({
|
||||
receiver: {
|
||||
speaking,
|
||||
subscriptions: new Map(),
|
||||
subscribe,
|
||||
// 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,
|
||||
},
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
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",
|
||||
@@ -28,40 +112,36 @@ function createChannel() {
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@discordjs/voice", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@discordjs/voice")>(
|
||||
"@discordjs/voice",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
joinVoiceChannel,
|
||||
entersState: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
describe("startRecording", () => {
|
||||
beforeEach(() => {
|
||||
subscribe.mockClear();
|
||||
speaking.removeAllListeners();
|
||||
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 } = await import("../src/recorder");
|
||||
const client = {
|
||||
user: { id: "bot-user" },
|
||||
};
|
||||
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);
|
||||
speaking.emit("start", "bot-user");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
mocks.mockSpeaker.emit("start", "bot-user");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
expect(mocks.mockSubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not subscribe to other bot users", async () => {
|
||||
const { startRecording } = await import("../src/recorder");
|
||||
const { startRecording, resetActiveSessions } = await import(
|
||||
"../src/recorder"
|
||||
);
|
||||
resetActiveSessions();
|
||||
const client = {
|
||||
user: { id: "self-user" },
|
||||
users: {
|
||||
@@ -82,9 +162,80 @@ describe("startRecording", () => {
|
||||
};
|
||||
|
||||
await startRecording(client as never, createChannel() as never);
|
||||
speaking.emit("start", "music-bot");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
mocks.mockSpeaker.emit("start", "music-bot");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user