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:
MythEclipse
2026-05-16 17:59:17 +07:00
parent 8b33af8286
commit 2744e7035b
9 changed files with 1338 additions and 40 deletions

View 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 }),
);
});
});