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:
+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