From 2744e7035b7b1ec22ea2187284bd26c73488ca9c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 16 May 2026 17:59:17 +0700 Subject: [PATCH] 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. --- .../2026-05-16-session-full-recording.md | 673 ++++++++++++++++++ ...026-05-16-session-full-recording-design.md | 87 +++ src/recorder.ts | 53 +- src/recorder/metadata.ts | 2 + src/recorder/sessionRecording.ts | 192 +++++ src/types.ts | 1 + tests/recorder.test.ts | 221 +++++- tests/recorder/metadata.test.ts | 46 ++ tests/recorder/sessionRecording.test.ts | 103 +++ 9 files changed, 1338 insertions(+), 40 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-16-session-full-recording.md create mode 100644 docs/superpowers/specs/2026-05-16-session-full-recording-design.md create mode 100644 src/recorder/sessionRecording.ts create mode 100644 tests/recorder/metadata.test.ts create mode 100644 tests/recorder/sessionRecording.test.ts diff --git a/docs/superpowers/plans/2026-05-16-session-full-recording.md b/docs/superpowers/plans/2026-05-16-session-full-recording.md new file mode 100644 index 0000000..3cbe596 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-session-full-recording.md @@ -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//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 { + 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; +} + +export function createRecordingSession(options: RecordingSessionOptions): RecordingSession { + const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`; + const participants = new Map(); + 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 { + 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(); + +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. +``` diff --git a/docs/superpowers/specs/2026-05-16-session-full-recording-design.md b/docs/superpowers/specs/2026-05-16-session-full-recording-design.md new file mode 100644 index 0000000..6d524cb --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-session-full-recording-design.md @@ -0,0 +1,87 @@ +# Session Full Recording Design + +## Context + +The recorder currently writes per-user OGG segments under `recordings//`. 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/ + / + full.ogg + session.json +``` + +`recordingSessionId` is based on guild ID, channel ID, and session start time: `--`. + +`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. diff --git a/src/recorder.ts b/src/recorder.ts index 831518e..58b3818 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -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(); + +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); } diff --git a/src/recorder/metadata.ts b/src/recorder/metadata.ts index 0d6e5a7..5638cf8 100644 --- a/src/recorder/metadata.ts +++ b/src/recorder/metadata.ts @@ -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, diff --git a/src/recorder/sessionRecording.ts b/src/recorder/sessionRecording.ts new file mode 100644 index 0000000..3cb7937 --- /dev/null +++ b/src/recorder/sessionRecording.ts @@ -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; +} + +export function createRecordingSession( + options: RecordingSessionOptions, +): RecordingSession { + const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`; + const participants = new Map(); + 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 { + 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), + }); + } +} diff --git a/src/types.ts b/src/types.ts index 4c8ff95..8d167fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,7 @@ export interface SegmentState { } export interface SegmentMetadata extends UserMetadata { + recordingSessionId: string; sessionId: string; sessionStartTime: number; segmentIndex: number; diff --git a/tests/recorder.test.ts b/tests/recorder.test.ts index d763387..450cb21 100644 --- a/tests/recorder.test.ts +++ b/tests/recorder.test.ts @@ -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 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( + "@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) => fn()), +})); + +async function flushMicrotasks(): Promise { + 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( - "@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(); }); }); diff --git a/tests/recorder/metadata.test.ts b/tests/recorder/metadata.test.ts new file mode 100644 index 0000000..1ebcc61 --- /dev/null +++ b/tests/recorder/metadata.test.ts @@ -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, + }); + }); +}); diff --git a/tests/recorder/sessionRecording.test.ts b/tests/recorder/sessionRecording.test.ts new file mode 100644 index 0000000..bf723b3 --- /dev/null +++ b/tests/recorder/sessionRecording.test.ts @@ -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 { + 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 }), + ); + }); +});