From b5a7d0d514f69ae7fdc2835a352410b7284bf067 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Mon, 22 Sep 2025 11:09:42 +0200 Subject: [PATCH 01/73] [IMP] recording TODO --- src/config.ts | 24 ++++++++++++++++++++++++ src/models/channel.ts | 10 ++++++++-- src/models/recorder.ts | 18 ++++++++++++++++++ src/models/session.ts | 6 ++++++ src/services/auth.ts | 3 ++- src/services/http.ts | 3 ++- src/services/ws.ts | 3 ++- 7 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 src/models/recorder.ts diff --git a/src/config.ts b/src/config.ts index 23474ca..53d218e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,6 +64,11 @@ export const HTTP_INTERFACE: string = process.env.HTTP_INTERFACE || "0.0.0.0"; */ export const PORT: number = Number(process.env.PORT) || 8070; +/** + * Whether the recording feature is enabled, true by default. + */ +export const RECORDING: boolean = !FALSY_INPUT.has(process.env.LOG_TIMESTAMP!); + /** * The number of workers to spawn (up to core limits) to manage RTC servers. * 0 < NUM_WORKERS <= os.availableParallelism() @@ -197,6 +202,25 @@ export const timeouts: TimeoutConfig = Object.freeze({ busBatch: process.env.JEST_WORKER_ID ? 10 : 300 }); +export const recording = Object.freeze({ + directory: os.tmpdir() + "/recordings", + enabled: RECORDING, + maxDuration: 1000 * 60 * 60, // 1 hour, could be a env-var. + fileTTL: 1000 * 60 * 60 * 24, // 24 hours + fileType: "mp4", + videoCodec: "libx264", + audioCodec: "aac", + audioLimit: 20, + cameraLimit: 4, // how many camera can be merged into one recording + screenLimit: 1, +}); + +export const dynamicPorts = Object.freeze({ + min: 50000, + max: 59999, +}); + + // how many errors can occur before the session is closed, recovery attempts will be made until this limit is reached export const maxSessionErrors: number = 6; diff --git a/src/models/channel.ts b/src/models/channel.ts index b6c0b3c..4802e4e 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -11,6 +11,7 @@ import { SESSION_CLOSE_CODE, type SessionId, } from "#src/models/session.ts"; +import { Recorder } from "#src/models/recorder.ts"; import { getWorker, type RtcWorker } from "#src/services/rtc.ts"; const logger = new Logger("CHANNEL"); @@ -52,6 +53,7 @@ interface ChannelCreateOptions { key?: string; /** Whether to enable WebRTC functionality */ useWebRtc?: boolean; + useRecording?: boolean; } interface JoinResult { /** The channel instance */ @@ -86,6 +88,8 @@ export class Channel extends EventEmitter { public readonly sessions = new Map(); /** mediasoup Worker handling this channel */ private readonly _worker?: RtcWorker; + /** Manages the recording of this channel, undefined if the feature is disabled */ + private recorder?: Recorder; /** Timeout for auto-closing empty channels */ private _closeTimeout?: NodeJS.Timeout; @@ -101,7 +105,7 @@ export class Channel extends EventEmitter { issuer: string, options: ChannelCreateOptions = {} ): Promise { - const { key, useWebRtc = true } = options; + const { key, useWebRtc = true, useRecording = true } = options; const safeIssuer = `${remoteAddress}::${issuer}`; const oldChannel = Channel.recordsByIssuer.get(safeIssuer); if (oldChannel) { @@ -111,7 +115,7 @@ export class Channel extends EventEmitter { const channelOptions: ChannelCreateOptions & { worker?: Worker; router?: Router; - } = { key }; + } = { key, useRecording: useWebRtc && useRecording }; if (useWebRtc) { channelOptions.worker = await getWorker(); channelOptions.router = await channelOptions.worker.createRouter({ @@ -182,6 +186,8 @@ export class Channel extends EventEmitter { const now = new Date(); this.createDate = now.toISOString(); this.remoteAddress = remoteAddress; + this.recorder = config.recording.enabled && options.useRecording ? new Recorder(this) : undefined; + this.recorder?.todo(); this.key = key ? Buffer.from(key, "base64") : undefined; this.uuid = crypto.randomUUID(); this.name = `${remoteAddress}*${this.uuid.slice(-5)}`; diff --git a/src/models/recorder.ts b/src/models/recorder.ts new file mode 100644 index 0000000..36f5484 --- /dev/null +++ b/src/models/recorder.ts @@ -0,0 +1,18 @@ +import {EventEmitter} from "node:events"; +import type { Channel } from "./channel"; +import {Logger} from "#src/utils/utils.ts"; + +const logger = new Logger("RECORDER"); + +export class Recorder extends EventEmitter { + channel: Channel; + + constructor(channel: Channel) { + super(); + this.channel = channel; + } + + todo() { + logger.warn("TODO: Everything"); + } +} diff --git a/src/models/session.ts b/src/models/session.ts index e79d722..0308ab1 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -56,6 +56,9 @@ export enum SESSION_CLOSE_CODE { KICKED = "kicked", ERROR = "error" } +export interface SessionPermissions { + recording?: boolean; +} export interface TransportConfig { /** Transport identifier */ id: string; @@ -135,6 +138,9 @@ export class Session extends EventEmitter { camera: null, screen: null }; + public permissions: SessionPermissions = { + recording: false + }; /** Parent channel containing this session */ private readonly _channel: Channel; /** Recovery timeouts for failed consumers */ diff --git a/src/services/auth.ts b/src/services/auth.ts index 4c4c5aa..89c2357 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -3,7 +3,7 @@ import crypto from "node:crypto"; import * as config from "#src/config.ts"; import { Logger } from "#src/utils/utils.ts"; import { AuthenticationError } from "#src/utils/errors.ts"; -import type { SessionId } from "#src/models/session.ts"; +import type { SessionId, SessionPermissions } from "#src/models/session.ts"; import type { StringLike } from "#src/shared/types.ts"; /** @@ -43,6 +43,7 @@ interface PrivateJWTClaims { sfu_channel_uuid?: string; session_id?: SessionId; ice_servers?: object[]; + permissions: SessionPermissions, sessionIdsByChannel?: Record; /** If provided when requesting a channel, this key will be used instead of the global key to verify JWTs related to this channel */ key?: string; diff --git a/src/services/http.ts b/src/services/http.ts index c00d9b5..ebc8790 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -98,7 +98,8 @@ function setupRoutes(routeListener: RouteListener): void { } const channel = await Channel.create(remoteAddress, claims.iss, { key: claims.key, - useWebRtc: searchParams.get("webRTC") !== "false" + useWebRtc: searchParams.get("webRTC") !== "false", + useRecording: searchParams.get("recording") !== "false" }); res.setHeader("Content-Type", "application/json"); res.statusCode = 200; diff --git a/src/services/ws.ts b/src/services/ws.ts index 5ca1bda..021a323 100644 --- a/src/services/ws.ts +++ b/src/services/ws.ts @@ -112,7 +112,7 @@ function connect(webSocket: WebSocket, credentials: Credentials): Session { const { channelUUID, jwt } = credentials; let channel = channelUUID ? Channel.records.get(channelUUID) : undefined; const authResult = verify(jwt, channel?.key); - const { sfu_channel_uuid, session_id } = authResult; + const { sfu_channel_uuid, session_id, permissions } = authResult; if (!channelUUID && sfu_channel_uuid) { // Cases where the channelUUID is not provided in the credentials for backwards compatibility with version 1.1 and earlier. channel = Channel.records.get(sfu_channel_uuid); @@ -131,6 +131,7 @@ function connect(webSocket: WebSocket, credentials: Credentials): Session { webSocket.send(""); // client can start using ws after this message. const bus = new Bus(webSocket, { batchDelay: config.timeouts.busBatch }); const { session } = Channel.join(channel.uuid, session_id); + session.permissions = permissions; session.once("close", ({ code }: { code: string }) => { let wsCloseCode = WS_CLOSE_CODE.CLEAN; switch (code) { From 529271044543f7a0606ea29dc4e8c2eb077dee30 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 24 Sep 2025 14:34:45 +0200 Subject: [PATCH 02/73] fixup --- src/client.ts | 9 +++++++-- src/models/channel.ts | 4 ++-- src/models/session.ts | 9 ++++++++- src/services/auth.ts | 2 +- src/services/ws.ts | 6 ++++-- src/shared/types.ts | 5 +++++ 6 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index f1bb5eb..c207e55 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,7 +18,7 @@ import { SERVER_REQUEST, WS_CLOSE_CODE } from "#src/shared/enums.ts"; -import type { JSONSerializable, StreamType, BusMessage } from "#src/shared/types"; +import type { JSONSerializable, StreamType, BusMessage, AvailableFeatures } from "#src/shared/types"; import type { TransportConfig, SessionId, SessionInfo } from "#src/models/session"; interface Consumers { @@ -141,6 +141,10 @@ const ACTIVE_STATES = new Set([ export class SfuClient extends EventTarget { /** Connection errors encountered */ public errors: Error[] = []; + public availableFeatures: AvailableFeatures = { + "rtc": false, + "recording": false, + }; /** Current client state */ private _state: SfuClientState = SfuClientState.DISCONNECTED; /** Communication bus */ @@ -445,7 +449,8 @@ export class SfuClient extends EventTarget { */ webSocket.addEventListener( "message", - () => { + (message) => { + this.availableFeatures = JSON.parse(message.data) as AvailableFeatures; resolve(new Bus(webSocket)); }, { once: true } diff --git a/src/models/channel.ts b/src/models/channel.ts index 4802e4e..b280039 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -84,12 +84,12 @@ export class Channel extends EventEmitter { public readonly key?: Buffer; /** mediasoup Router for media routing */ public readonly router?: Router; + /** Manages the recording of this channel, undefined if the feature is disabled */ + public readonly recorder?: Recorder; /** Active sessions in this channel */ public readonly sessions = new Map(); /** mediasoup Worker handling this channel */ private readonly _worker?: RtcWorker; - /** Manages the recording of this channel, undefined if the feature is disabled */ - private recorder?: Recorder; /** Timeout for auto-closing empty channels */ private _closeTimeout?: NodeJS.Timeout; diff --git a/src/models/session.ts b/src/models/session.ts index 0308ab1..c22028d 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -20,7 +20,7 @@ import { SERVER_REQUEST, STREAM_TYPE } from "#src/shared/enums.ts"; -import type { JSONSerializable, StreamType, BusMessage } from "#src/shared/types"; +import type {JSONSerializable, StreamType, BusMessage, AvailableFeatures } from "#src/shared/types"; import type { Bus } from "#src/shared/bus.ts"; import type { Channel } from "#src/models/channel.ts"; @@ -167,6 +167,13 @@ export class Session extends EventEmitter { this.setMaxListeners(config.CHANNEL_SIZE * 2); } + get availableFeatures(): AvailableFeatures { + return { + "rtc": Boolean(this._channel.router), + "recording": Boolean(this._channel.router && this._channel.recorder && this.permissions.recording) + } + } + get name(): string { return `${this._channel.name}:${this.id}@${this.remote}`; } diff --git a/src/services/auth.ts b/src/services/auth.ts index 89c2357..7efef4a 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -43,7 +43,7 @@ interface PrivateJWTClaims { sfu_channel_uuid?: string; session_id?: SessionId; ice_servers?: object[]; - permissions: SessionPermissions, + permissions?: SessionPermissions, sessionIdsByChannel?: Record; /** If provided when requesting a channel, this key will be used instead of the global key to verify JWTs related to this channel */ key?: string; diff --git a/src/services/ws.ts b/src/services/ws.ts index 021a323..54fd8cd 100644 --- a/src/services/ws.ts +++ b/src/services/ws.ts @@ -128,10 +128,12 @@ function connect(webSocket: WebSocket, credentials: Credentials): Session { if (!session_id) { throw new AuthenticationError("Malformed JWT payload"); } - webSocket.send(""); // client can start using ws after this message. const bus = new Bus(webSocket, { batchDelay: config.timeouts.busBatch }); const { session } = Channel.join(channel.uuid, session_id); - session.permissions = permissions; + if (permissions) { + Object.assign(session.permissions, permissions); + } + webSocket.send(JSON.stringify(session.availableFeatures)); // client can start using ws after this message. session.once("close", ({ code }: { code: string }) => { let wsCloseCode = WS_CLOSE_CODE.CLEAN; switch (code) { diff --git a/src/shared/types.ts b/src/shared/types.ts index 4210662..3f661ff 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -10,6 +10,11 @@ export type StreamType = "audio" | "camera" | "screen"; export type StringLike = Buffer | string; +export type AvailableFeatures = { + "rtc": boolean, + "recording": boolean, +} + import type { DownloadStates } from "#src/client.ts"; import type { SessionId, SessionInfo, TransportConfig } from "#src/models/session.ts"; From 19047dc0efa0d4d2ea489e4db21f4826ef15f96f Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 21 Oct 2025 10:09:57 +0200 Subject: [PATCH 03/73] [IMP] wip/poc --- src/client.ts | 17 +++++++++++++++++ src/config.ts | 2 +- src/models/channel.ts | 1 - src/models/recorder.ts | 16 ++++++++++++++-- src/models/session.ts | 39 +++++++++++++++++++++++++++++++++++++-- src/services/ws.ts | 4 +--- src/shared/enums.ts | 6 +++++- src/shared/types.ts | 2 ++ src/utils/utils.ts | 17 +++++++++++++++++ tests/network.test.ts | 12 ++++++++++++ 10 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/client.ts b/src/client.ts index c207e55..bb07ede 100644 --- a/src/client.ts +++ b/src/client.ts @@ -260,6 +260,23 @@ export class SfuClient extends EventTarget { await Promise.all(proms); return stats; } + async startRecording() { + return this._bus?.request( + { + name: CLIENT_REQUEST.START_RECORDING, + }, + { batch: true } + ); + } + + async stopRecording() { + return this._bus?.request( + { + name: CLIENT_REQUEST.STOP_RECORDING, + }, + { batch: true } + ); + } /** * Updates the server with the info of the session (isTalking, isCameraOn,...) so that it can broadcast it to the diff --git a/src/config.ts b/src/config.ts index 53d218e..3a7f614 100644 --- a/src/config.ts +++ b/src/config.ts @@ -67,7 +67,7 @@ export const PORT: number = Number(process.env.PORT) || 8070; /** * Whether the recording feature is enabled, true by default. */ -export const RECORDING: boolean = !FALSY_INPUT.has(process.env.LOG_TIMESTAMP!); +export const RECORDING: boolean = !FALSY_INPUT.has(process.env.RECORDING!); /** * The number of workers to spawn (up to core limits) to manage RTC servers. diff --git a/src/models/channel.ts b/src/models/channel.ts index b280039..ddb8d33 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -187,7 +187,6 @@ export class Channel extends EventEmitter { this.createDate = now.toISOString(); this.remoteAddress = remoteAddress; this.recorder = config.recording.enabled && options.useRecording ? new Recorder(this) : undefined; - this.recorder?.todo(); this.key = key ? Buffer.from(key, "base64") : undefined; this.uuid = crypto.randomUUID(); this.name = `${remoteAddress}*${this.uuid.slice(-5)}`; diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 36f5484..3da6f98 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -6,13 +6,25 @@ const logger = new Logger("RECORDER"); export class Recorder extends EventEmitter { channel: Channel; + state: "started" | "stopped" = "stopped"; + ffmpeg = null; constructor(channel: Channel) { super(); this.channel = channel; } - todo() { - logger.warn("TODO: Everything"); + async start() { + this.state = "started"; + logger.trace("TO IMPLEMENT"); + // TODO ffmpeg instance creation, start + return { state: this.state }; + } + + async stop() { + this.state = "stopped"; + logger.trace("TO IMPLEMENT"); + // TODO ffmpeg instance stop, cleanup, save,... + return { state: this.state }; } } diff --git a/src/models/session.ts b/src/models/session.ts index c22028d..c2625c2 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -110,6 +110,7 @@ const logger = new Logger("SESSION"); * * @fires Session#stateChange - Emitted when session state changes * @fires Session#close - Emitted when session is closed + * @fires Session#producer - Emitted when a new producer is created */ export class Session extends EventEmitter { /** Communication bus for WebSocket messaging */ @@ -138,9 +139,9 @@ export class Session extends EventEmitter { camera: null, screen: null }; - public permissions: SessionPermissions = { + public readonly permissions: SessionPermissions = Object.seal({ recording: false - }; + }); /** Parent channel containing this session */ private readonly _channel: Channel; /** Recovery timeouts for failed consumers */ @@ -184,9 +185,26 @@ export class Session extends EventEmitter { set state(state: SESSION_STATE) { this._state = state; + /** + * @event Session#stateChange + * @type {{ state: SESSION_STATE }} + */ this.emit("stateChange", state); } + updatePermissions(permissions: SessionPermissions | undefined): void { + if (!permissions) { + return; + } + for (const key of Object.keys(this.permissions) as (keyof SessionPermissions)[]) { + const newVal = permissions[key]; + if (newVal === undefined) { + continue; + } + this.permissions[key] = Boolean(permissions[key]); + } + } + async getProducerBitRates(): Promise { const bitRates: ProducerBitRates = {}; const proms: Promise[] = []; @@ -651,8 +669,25 @@ export class Session extends EventEmitter { logger.debug(`[${this.name}] producing ${type}: ${codec?.mimeType}`); this._updateRemoteConsumers(); this._broadcastInfo(); + /** + * @event Session#producer + * @type {{ type: StreamType, producer: Producer }} + */ + this.emit("producer", { type, producer }); return { id: producer.id }; } + case CLIENT_REQUEST.START_RECORDING: { + if (this.permissions.recording && this._channel.recorder) { + return this._channel.recorder.start(); + } + return; + } + case CLIENT_REQUEST.STOP_RECORDING: { + if (this.permissions.recording && this._channel.recorder) { + return this._channel.recorder.stop(); + } + return; + } default: logger.warn(`[${this.name}] Unknown request type: ${name}`); throw new Error(`Unknown request type: ${name}`); diff --git a/src/services/ws.ts b/src/services/ws.ts index 54fd8cd..d6f6bea 100644 --- a/src/services/ws.ts +++ b/src/services/ws.ts @@ -130,9 +130,7 @@ function connect(webSocket: WebSocket, credentials: Credentials): Session { } const bus = new Bus(webSocket, { batchDelay: config.timeouts.busBatch }); const { session } = Channel.join(channel.uuid, session_id); - if (permissions) { - Object.assign(session.permissions, permissions); - } + session.updatePermissions(permissions); webSocket.send(JSON.stringify(session.availableFeatures)); // client can start using ws after this message. session.once("close", ({ code }: { code: string }) => { let wsCloseCode = WS_CLOSE_CODE.CLEAN; diff --git a/src/shared/enums.ts b/src/shared/enums.ts index a8703d6..e5a3a92 100644 --- a/src/shared/enums.ts +++ b/src/shared/enums.ts @@ -33,7 +33,11 @@ export enum CLIENT_REQUEST { /** Requests the server to connect the server-to-client transport */ CONNECT_STC_TRANSPORT = "CONNECT_STC_TRANSPORT", /** Requests the creation of a consumer that is used to upload a track to the server */ - INIT_PRODUCER = "INIT_PRODUCER" + INIT_PRODUCER = "INIT_PRODUCER", + /** Requests to start recording of the call */ + START_RECORDING = "START_RECORDING", + /** Requests to stop recording of the call */ + STOP_RECORDING = "STOP_RECORDING" } export enum CLIENT_MESSAGE { diff --git a/src/shared/types.ts b/src/shared/types.ts index 3f661ff..f59c891 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -54,6 +54,8 @@ export type BusMessage = name: typeof CLIENT_REQUEST.INIT_PRODUCER; payload: { type: StreamType; kind: MediaKind; rtpParameters: RtpParameters }; } + | { name: typeof CLIENT_REQUEST.START_RECORDING; payload?: never } + | { name: typeof CLIENT_REQUEST.STOP_RECORDING; payload?: never } | { name: typeof SERVER_MESSAGE.BROADCAST; payload: { senderId: SessionId; message: JSONSerializable }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 39773cb..8c668af 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -12,6 +12,7 @@ const ASCII = { green: "\x1b[32m", yellow: "\x1b[33m", white: "\x1b[37m", + cyan: "\x1b[36m", default: "\x1b[0m" } } as const; @@ -48,6 +49,19 @@ export interface ParseBodyOptions { json?: boolean; } +function getCallChain(depth: number = 8): string { + const stack = new Error().stack?.split("\n").slice(2, depth + 2) ?? []; + return stack + .map(line => { + const match = line.trim().match(/^at\s+(.*?)\s+\(/); + return match ? match[1] : null; + }) + .slice(1, depth + 1) + .filter(Boolean) + .reverse() + .join(" > "); +} + export class Logger { private readonly _name: string; private readonly _colorize: (text: string, color?: string) => string; @@ -83,6 +97,9 @@ export class Logger { verbose(text: string): void { this._log(console.log, ":VERBOSE:", text, ASCII.color.white); } + trace(message: string, { depth = 8 }: { depth?: number } = {}): void { + this._log(console.log, ":TRACE:", `${getCallChain(depth)} ${message}`, ASCII.color.cyan); + } private _generateTimeStamp(): string { const now = new Date(); return now.toISOString() + " "; diff --git a/tests/network.test.ts b/tests/network.test.ts index e158cb2..310f4b8 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -289,4 +289,16 @@ describe("Full network", () => { expect(event1.detail.payload.message).toBe(message); expect(event2.detail.payload.message).toBe(message); }); + test("POC RECORDING", async () => { + const channelUUID = await network.getChannelUUID(); + const user1 = await network.connect(channelUUID, 1); + await once(user1.session, "stateChange"); + const sender = await network.connect(channelUUID, 3); + await once(sender.session, "stateChange"); + sender.session.updatePermissions({ recording: true }); + const startResult = await sender.sfuClient.startRecording() as { state: string }; + expect(startResult.state).toBe("started"); + const stopResult = await sender.sfuClient.stopRecording() as { state: string }; + expect(stopResult.state).toBe("stopped"); + }); }); From f64b87076053fea5303e5474831b9fa19988558e Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 23 Oct 2025 06:58:13 +0200 Subject: [PATCH 04/73] [IMP] rec addr --- src/models/channel.ts | 9 +++++---- src/models/recorder.ts | 5 ++++- src/services/http.ts | 25 ++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/models/channel.ts b/src/models/channel.ts index ddb8d33..f806768 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -53,7 +53,7 @@ interface ChannelCreateOptions { key?: string; /** Whether to enable WebRTC functionality */ useWebRtc?: boolean; - useRecording?: boolean; + recordingAddress?: string | null; } interface JoinResult { /** The channel instance */ @@ -105,7 +105,7 @@ export class Channel extends EventEmitter { issuer: string, options: ChannelCreateOptions = {} ): Promise { - const { key, useWebRtc = true, useRecording = true } = options; + const { key, useWebRtc = true, recordingAddress } = options; const safeIssuer = `${remoteAddress}::${issuer}`; const oldChannel = Channel.recordsByIssuer.get(safeIssuer); if (oldChannel) { @@ -115,7 +115,7 @@ export class Channel extends EventEmitter { const channelOptions: ChannelCreateOptions & { worker?: Worker; router?: Router; - } = { key, useRecording: useWebRtc && useRecording }; + } = { key, recordingAddress: useWebRtc ? recordingAddress : null }; if (useWebRtc) { channelOptions.worker = await getWorker(); channelOptions.router = await channelOptions.worker.createRouter({ @@ -186,7 +186,7 @@ export class Channel extends EventEmitter { const now = new Date(); this.createDate = now.toISOString(); this.remoteAddress = remoteAddress; - this.recorder = config.recording.enabled && options.useRecording ? new Recorder(this) : undefined; + this.recorder = config.recording.enabled && options.recordingAddress ? new Recorder(this, options.recordingAddress) : undefined; this.key = key ? Buffer.from(key, "base64") : undefined; this.uuid = crypto.randomUUID(); this.name = `${remoteAddress}*${this.uuid.slice(-5)}`; @@ -291,6 +291,7 @@ export class Channel extends EventEmitter { * @fires Channel#close */ close(): void { + this.recorder?.stop(); for (const session of this.sessions.values()) { session.off("close", this._onSessionClose); session.close({ code: SESSION_CLOSE_CODE.CHANNEL_CLOSED }); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 3da6f98..d57bb52 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -8,10 +8,13 @@ export class Recorder extends EventEmitter { channel: Channel; state: "started" | "stopped" = "stopped"; ffmpeg = null; + /** Path to which the final recording will be uploaded to */ + recordingAddress: string; - constructor(channel: Channel) { + constructor(channel: Channel, recordingAddress: string) { super(); this.channel = channel; + this.recordingAddress = recordingAddress; } async start() { diff --git a/src/services/http.ts b/src/services/http.ts index ebc8790..bb20ef3 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -79,6 +79,29 @@ function setupRoutes(routeListener: RouteListener): void { return res.end(JSON.stringify(channelStats)); } }); + /** + * GET /v1/channel + * + * Creates (or reuses) a media channel for the authenticated client. + * + * ### Headers + * - `Authorization: Bearer ` — required. + * The JWT must include the `iss` (issuer) claim identifying the caller. + * + * ### Query Parameters + * - `webRTC` — optional, defaults to `"true"`. + * When set to `"false"`, disables WebRTC setup and creates a non-media channel. + * - `recordingAddress` — optional. + * If provided, enables recording and specifies the destination address + * for recorded media streams. This address should most likely include a secret token, + * so that it can be used publicly. For example http://example.com/recording/123?token=asdasdasdasd + * + * ### Responses + * - `200 OK` — returns `{ uuid: string, url: string }` + * - `401 Unauthorized` — missing or invalid Authorization header + * - `403 Forbidden` — missing `iss` claim + * - `500 Internal Server Error` — failed to create the channel + */ routeListener.get(`/v${API_VERSION}/channel`, { callback: async (req, res, { host, protocol, remoteAddress, searchParams }) => { try { @@ -99,7 +122,7 @@ function setupRoutes(routeListener: RouteListener): void { const channel = await Channel.create(remoteAddress, claims.iss, { key: claims.key, useWebRtc: searchParams.get("webRTC") !== "false", - useRecording: searchParams.get("recording") !== "false" + recordingAddress: searchParams.get("recordingAddress") }); res.setHeader("Content-Type", "application/json"); res.statusCode = 200; From 10432af87a896c9d88eb00e868a2984f1f580232 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 23 Oct 2025 14:16:41 +0200 Subject: [PATCH 05/73] [IMP] fixup --- src/models/channel.ts | 2 +- src/models/ffmpeg.ts | 8 ++++++ src/models/recorder.ts | 36 ++++++++++++++++++++------- src/server.ts | 6 ++--- src/services/{rtc.ts => resources.ts} | 19 +++++++++++++- tests/models.test.ts | 6 ++--- tests/rtc.test.ts | 8 +++--- tests/utils/network.ts | 6 ++--- 8 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 src/models/ffmpeg.ts rename src/services/{rtc.ts => resources.ts} (83%) diff --git a/src/models/channel.ts b/src/models/channel.ts index f806768..4930642 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -12,7 +12,7 @@ import { type SessionId, } from "#src/models/session.ts"; import { Recorder } from "#src/models/recorder.ts"; -import { getWorker, type RtcWorker } from "#src/services/rtc.ts"; +import { getWorker, type RtcWorker } from "#src/services/resources.ts"; const logger = new Logger("CHANNEL"); diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts new file mode 100644 index 0000000..48189b9 --- /dev/null +++ b/src/models/ffmpeg.ts @@ -0,0 +1,8 @@ +import { EventEmitter } from "node:events"; + +export class FFMPEG extends EventEmitter { + + constructor() { + super(); + } +} diff --git a/src/models/recorder.ts b/src/models/recorder.ts index d57bb52..c80a33b 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -1,13 +1,19 @@ -import {EventEmitter} from "node:events"; +import { EventEmitter } from "node:events"; import type { Channel } from "./channel"; -import {Logger} from "#src/utils/utils.ts"; +import { getFolder } from "#src/services/resources.ts"; +import { Logger } from "#src/utils/utils.ts"; +export enum RECORDER_STATE { + STARTED = "started", + STOPPED = "stopped", +} const logger = new Logger("RECORDER"); export class Recorder extends EventEmitter { channel: Channel; - state: "started" | "stopped" = "stopped"; + state: RECORDER_STATE = RECORDER_STATE.STOPPED; ffmpeg = null; + destPath: string | undefined; /** Path to which the final recording will be uploaded to */ recordingAddress: string; @@ -18,16 +24,28 @@ export class Recorder extends EventEmitter { } async start() { - this.state = "started"; - logger.trace("TO IMPLEMENT"); - // TODO ffmpeg instance creation, start + if (this.state === RECORDER_STATE.STOPPED) { + const { path, sealFolder } = getFolder(); + this.destPath = path; + this.once("stopped", sealFolder); + this.state = RECORDER_STATE.STARTED; + logger.trace("TO IMPLEMENT"); + // TODO ffmpeg instance creation for recording to destPath with proper name, start, build timestamps object + } + return { state: this.state }; } async stop() { - this.state = "stopped"; - logger.trace("TO IMPLEMENT"); - // TODO ffmpeg instance stop, cleanup, save,... + if (this.state === RECORDER_STATE.STARTED) { + + logger.trace("TO IMPLEMENT"); + this.emit("stopped"); + // TODO ffmpeg instance stop, cleanup, + // only resolve promise and switch state when completely ready to start a new recording. + this.state = RECORDER_STATE.STOPPED; + } + return { state: this.state }; } } diff --git a/src/server.ts b/src/server.ts index 80ab97d..2d36a96 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import * as rtc from "#src/services/rtc.ts"; +import * as resources from "#src/services/resources.ts"; import * as http from "#src/services/http.ts"; import * as auth from "#src/services/auth.ts"; import { Logger } from "#src/utils/utils.ts"; @@ -8,7 +8,7 @@ const logger = new Logger("SERVER", { logLevel: "all" }); async function run(): Promise { auth.start(); - await rtc.start(); + await resources.start(); await http.start(); logger.info(`ready - PID: ${process.pid}`); } @@ -16,7 +16,7 @@ async function run(): Promise { function cleanup(): void { Channel.closeAll(); http.close(); - rtc.close(); + resources.close(); logger.info("cleanup complete"); } diff --git a/src/services/rtc.ts b/src/services/resources.ts similarity index 83% rename from src/services/rtc.ts rename to src/services/resources.ts index 2e546c7..9f7cff2 100644 --- a/src/services/rtc.ts +++ b/src/services/resources.ts @@ -10,7 +10,7 @@ export interface RtcWorker extends mediasoup.types.Worker { }; } -const logger = new Logger("RTC"); +const logger = new Logger("RESOURCES"); const workers = new Set(); export async function start(): Promise { @@ -76,3 +76,20 @@ export async function getWorker(): Promise { logger.debug(`worker ${leastUsedWorker!.pid} with ${lowestUsage} ru_maxrss was selected`); return leastUsedWorker; } + +export function getFolder() { + // create a temp folder at a path, returns the path and a function to seal the folder + return { + path: "", + sealFolder: () => { + // move the content into a permanent folder location so it can easily be retrieved for processing later + // or directly forward for transcription + }, + } +} + +export function getPort() { +} + +export function releasePort(port: number) { +} diff --git a/tests/models.test.ts b/tests/models.test.ts index d84f49b..0b00d9d 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -1,17 +1,17 @@ import { describe, beforeEach, afterEach, expect, jest } from "@jest/globals"; -import * as rtc from "#src/services/rtc"; +import * as resources from "#src/services/resources"; import { Channel } from "#src/models/channel"; import { timeouts, CHANNEL_SIZE } from "#src/config"; import { OvercrowdedError } from "#src/utils/errors"; describe("Models", () => { beforeEach(async () => { - await rtc.start(); + await resources.start(); }); afterEach(() => { Channel.closeAll(); - rtc.close(); + resources.close(); }); test("Create channel and session", async () => { const channel = await Channel.create("testRemote", "testIssuer"); diff --git a/tests/rtc.test.ts b/tests/rtc.test.ts index 08c188d..37caa6f 100644 --- a/tests/rtc.test.ts +++ b/tests/rtc.test.ts @@ -1,19 +1,19 @@ import { afterEach, beforeEach, describe, expect } from "@jest/globals"; -import * as rtc from "#src/services/rtc"; +import * as resources from "#src/services/resources"; import * as config from "#src/config"; describe("rtc service", () => { beforeEach(async () => { - await rtc.start(); + await resources.start(); }); afterEach(() => { - rtc.close(); + resources.close(); }); test("worker load should be evenly distributed", async () => { const usedWorkers = new Set(); for (let i = 0; i < config.NUM_WORKERS; ++i) { - const worker = await rtc.getWorker(); + const worker = await resources.getWorker(); const router = await worker.createRouter({}); const webRtcServer = await worker.createWebRtcServer(config.rtc.rtcServerOptions); const promises = []; diff --git a/tests/utils/network.ts b/tests/utils/network.ts index dace247..9ac1d16 100644 --- a/tests/utils/network.ts +++ b/tests/utils/network.ts @@ -5,7 +5,7 @@ import * as fakeParameters from "mediasoup-client/lib/test/fakeParameters"; import * as auth from "#src/services/auth"; import * as http from "#src/services/http"; -import * as rtc from "#src/services/rtc"; +import * as resources from "#src/services/resources"; import { SfuClient, SfuClientState } from "#src/client"; import { Channel } from "#src/models/channel"; import type { Session } from "#src/models/session"; @@ -69,7 +69,7 @@ export class LocalNetwork { this.port = port; // Start all services in correct order - await rtc.start(); + await resources.start(); await http.start({ httpInterface: hostname, port }); await auth.start(HMAC_B64_KEY); } @@ -217,7 +217,7 @@ export class LocalNetwork { // Stop all services auth.close(); http.close(); - rtc.close(); + resources.close(); // Clear network info this.hostname = undefined; From 659892fb0528cf377eed001073baccec57f19b16 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 29 Oct 2025 14:38:18 +0100 Subject: [PATCH 06/73] [IMP] wip --- src/config.ts | 5 +++-- src/models/recorder.ts | 15 ++++++++++++--- src/services/resources.ts | 29 +++++++++++++++++++++++------ src/utils/errors.ts | 4 ++++ 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3a7f614..b7d209e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,9 +65,9 @@ export const HTTP_INTERFACE: string = process.env.HTTP_INTERFACE || "0.0.0.0"; export const PORT: number = Number(process.env.PORT) || 8070; /** - * Whether the recording feature is enabled, true by default. + * Whether the recording feature is enabled, false by default. */ -export const RECORDING: boolean = !FALSY_INPUT.has(process.env.RECORDING!); +export const RECORDING: boolean = Boolean(process.env.RECORDING); /** * The number of workers to spawn (up to core limits) to manage RTC servers. @@ -215,6 +215,7 @@ export const recording = Object.freeze({ screenLimit: 1, }); +// TODO: This should probably be env variable, and at least documented so that deployment can open these ports. export const dynamicPorts = Object.freeze({ min: 50000, max: 59999, diff --git a/src/models/recorder.ts b/src/models/recorder.ts index c80a33b..71eadb3 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -32,20 +32,29 @@ export class Recorder extends EventEmitter { logger.trace("TO IMPLEMENT"); // TODO ffmpeg instance creation for recording to destPath with proper name, start, build timestamps object } - + this._record(); return { state: this.state }; } async stop() { if (this.state === RECORDER_STATE.STARTED) { - logger.trace("TO IMPLEMENT"); this.emit("stopped"); // TODO ffmpeg instance stop, cleanup, // only resolve promise and switch state when completely ready to start a new recording. this.state = RECORDER_STATE.STOPPED; } - return { state: this.state }; } + + /** + * @param video whether we want to record videos or not (will always record audio) + */ + _record(video: boolean = false) { + console.trace(`TO IMPLEMENT: recording channel ${this.channel.name}, video: ${video}`); + // iterate all producers on all sessions of the channel, create a ffmpeg for each, + // save them on a map by session id+type. + // check if recording for that session id+type is already in progress + // add listener to the channel for producer creation (and closure). + } } diff --git a/src/services/resources.ts b/src/services/resources.ts index 9f7cff2..46561af 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -3,6 +3,10 @@ import type { WebRtcServerOptions } from "mediasoup/node/lib/types"; import * as config from "#src/config.ts"; import { Logger } from "#src/utils/utils.ts"; +import { PortLimitReachedError } from "#src/utils/errors.ts"; +import os from "node:os"; + +const availablePorts: Set = new Set(); export interface RtcWorker extends mediasoup.types.Worker { appData: { @@ -12,6 +16,7 @@ export interface RtcWorker extends mediasoup.types.Worker { const logger = new Logger("RESOURCES"); const workers = new Set(); +const tempDir = os.tmpdir() + "/ongoing_recordings"; export async function start(): Promise { logger.info("starting..."); @@ -22,6 +27,10 @@ export async function start(): Promise { logger.info( `transport(RTC) layer at ${config.PUBLIC_IP}:${config.RTC_MIN_PORT}-${config.RTC_MAX_PORT}` ); + for (let i = config.dynamicPorts.min; i <= config.dynamicPorts.max; i++) { + availablePorts.add(i); + } + logger.info(`${availablePorts.size} dynamic ports available [${config.dynamicPorts.min}-${config.dynamicPorts.max}]`); } export function close(): void { @@ -78,18 +87,26 @@ export async function getWorker(): Promise { } export function getFolder() { - // create a temp folder at a path, returns the path and a function to seal the folder + const tempName = `${Date.now()}`; + const path = `${tempDir}/${tempName}`; + // TODO we may want to track these temp folders to remove them periodically (although os.tempDir() has already such a mechanism) return { - path: "", - sealFolder: () => { - // move the content into a permanent folder location so it can easily be retrieved for processing later - // or directly forward for transcription + path, + sealFolder: (name: string = tempName) => { + // TODO move whatever is in path to + console.log(`${config.recording.directory}/${name}`); }, } } -export function getPort() { +export function getPort(): number { + const port = availablePorts.values().next().value; + if (!port) { + throw new PortLimitReachedError(); + } + return port; } export function releasePort(port: number) { + availablePorts.add(port); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 5eb7855..62ee4f9 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,3 +5,7 @@ export class AuthenticationError extends Error { export class OvercrowdedError extends Error { name = "OvercrowdedError"; } + +export class PortLimitReachedError extends Error { + name = "PortLimitReachedError"; +} From e00d452e163163e0becf93ccd6baa91ac66607bf Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 30 Oct 2025 11:07:15 +0100 Subject: [PATCH 07/73] [IMP] wip --- src/models/recorder.ts | 10 ++++---- src/services/resources.ts | 49 +++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 71eadb3..ed98c75 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -25,9 +25,11 @@ export class Recorder extends EventEmitter { async start() { if (this.state === RECORDER_STATE.STOPPED) { - const { path, sealFolder } = getFolder(); - this.destPath = path; - this.once("stopped", sealFolder); + const folder = getFolder(); + this.destPath = folder.path; + this.once("stopped", ({ name }) => { + folder.seal(name); + }); this.state = RECORDER_STATE.STARTED; logger.trace("TO IMPLEMENT"); // TODO ffmpeg instance creation for recording to destPath with proper name, start, build timestamps object @@ -39,7 +41,7 @@ export class Recorder extends EventEmitter { async stop() { if (this.state === RECORDER_STATE.STARTED) { logger.trace("TO IMPLEMENT"); - this.emit("stopped"); + this.emit("stopped", { name: "test" }); // TODO ffmpeg instance stop, cleanup, // only resolve promise and switch state when completely ready to start a new recording. this.state = RECORDER_STATE.STOPPED; diff --git a/src/services/resources.ts b/src/services/resources.ts index 46561af..63fc62a 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -7,6 +7,7 @@ import { PortLimitReachedError } from "#src/utils/errors.ts"; import os from "node:os"; const availablePorts: Set = new Set(); +let unique = 1; export interface RtcWorker extends mediasoup.types.Worker { appData: { @@ -14,6 +15,8 @@ export interface RtcWorker extends mediasoup.types.Worker { }; } +// TODO maybe write some docstring, file used to manage resources such as folders, workers, ports + const logger = new Logger("RESOURCES"); const workers = new Set(); const tempDir = os.tmpdir() + "/ongoing_recordings"; @@ -86,27 +89,39 @@ export async function getWorker(): Promise { return leastUsedWorker; } -export function getFolder() { - const tempName = `${Date.now()}`; - const path = `${tempDir}/${tempName}`; - // TODO we may want to track these temp folders to remove them periodically (although os.tempDir() has already such a mechanism) - return { - path, - sealFolder: (name: string = tempName) => { - // TODO move whatever is in path to - console.log(`${config.recording.directory}/${name}`); - }, +class Folder { + path: string; + + constructor(path: string) { + this.path = path; + } + + seal(name: string) { + console.trace(`TO IMPLEMENT, MOVING TO ${config.recording.directory}/${name}`); } } -export function getPort(): number { - const port = availablePorts.values().next().value; - if (!port) { - throw new PortLimitReachedError(); +export function getFolder(): Folder { + return new Folder(`${tempDir}/${Date.now()}-${unique++}`); +} + +class DynamicPort { + number: number; + + constructor(number: number) { + availablePorts.delete(number); + this.number = number; + } + + release() { + availablePorts.add(this.number); } - return port; } -export function releasePort(port: number) { - availablePorts.add(port); +export function getPort(): DynamicPort { + const number = availablePorts.values().next().value; + if (!number) { + throw new PortLimitReachedError(); + } + return new DynamicPort(number); } From 9e88cc3639ccfe482b6e73fb4b53cca3d93d09c7 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 4 Nov 2025 08:50:58 +0100 Subject: [PATCH 08/73] [WIP] discuss: folder move --- src/client.ts | 6 ++++++ src/models/recorder.ts | 14 ++++++-------- src/services/resources.ts | 11 ++++++++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/client.ts b/src/client.ts index bb07ede..94fedb5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -261,6 +261,9 @@ export class SfuClient extends EventTarget { return stats; } async startRecording() { + if (this.state !== SfuClientState.CONNECTED) { + throw new Error("InvalidState"); + } return this._bus?.request( { name: CLIENT_REQUEST.START_RECORDING, @@ -270,6 +273,9 @@ export class SfuClient extends EventTarget { } async stopRecording() { + if (this.state !== SfuClientState.CONNECTED) { + throw new Error("InvalidState"); + } return this._bus?.request( { name: CLIENT_REQUEST.STOP_RECORDING, diff --git a/src/models/recorder.ts b/src/models/recorder.ts index ed98c75..c2a0c5e 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -1,6 +1,7 @@ import { EventEmitter } from "node:events"; import type { Channel } from "./channel"; import { getFolder } from "#src/services/resources.ts"; +import type { Folder } from "#src/services/resources.ts"; import { Logger } from "#src/utils/utils.ts"; export enum RECORDER_STATE { @@ -12,8 +13,8 @@ const logger = new Logger("RECORDER"); export class Recorder extends EventEmitter { channel: Channel; state: RECORDER_STATE = RECORDER_STATE.STOPPED; + folder: Folder | undefined; ffmpeg = null; - destPath: string | undefined; /** Path to which the final recording will be uploaded to */ recordingAddress: string; @@ -25,14 +26,10 @@ export class Recorder extends EventEmitter { async start() { if (this.state === RECORDER_STATE.STOPPED) { - const folder = getFolder(); - this.destPath = folder.path; - this.once("stopped", ({ name }) => { - folder.seal(name); - }); + this.folder = getFolder(); this.state = RECORDER_STATE.STARTED; logger.trace("TO IMPLEMENT"); - // TODO ffmpeg instance creation for recording to destPath with proper name, start, build timestamps object + // TODO ffmpeg instance creation for recording to folder.path with proper name, start, build timestamps object } this._record(); return { state: this.state }; @@ -41,7 +38,8 @@ export class Recorder extends EventEmitter { async stop() { if (this.state === RECORDER_STATE.STARTED) { logger.trace("TO IMPLEMENT"); - this.emit("stopped", { name: "test" }); + await this.folder!.seal("test-name"); + this.folder = undefined; // TODO ffmpeg instance stop, cleanup, // only resolve promise and switch state when completely ready to start a new recording. this.state = RECORDER_STATE.STOPPED; diff --git a/src/services/resources.ts b/src/services/resources.ts index 63fc62a..899d405 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -5,6 +5,8 @@ import * as config from "#src/config.ts"; import { Logger } from "#src/utils/utils.ts"; import { PortLimitReachedError } from "#src/utils/errors.ts"; import os from "node:os"; +import fs from "node:fs/promises"; +import path from "node:path"; const availablePorts: Set = new Set(); let unique = 1; @@ -89,15 +91,18 @@ export async function getWorker(): Promise { return leastUsedWorker; } -class Folder { +export class Folder { path: string; constructor(path: string) { this.path = path; } - seal(name: string) { - console.trace(`TO IMPLEMENT, MOVING TO ${config.recording.directory}/${name}`); + async seal(name: string) { + const destinationPath = path.join(config.recording.directory, name); + await fs.rename(this.path, destinationPath); + this.path = destinationPath; + logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); } } From 123690d7f7fcb30197bce016f04bdf9c4fa55ab3 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 4 Nov 2025 14:05:31 +0100 Subject: [PATCH 09/73] [WIP] recording: state broadcasting, updated tests --- package-lock.json | 17 +++++++++++++++++ package.json | 1 + src/client.ts | 33 ++++++++++++++++++++++++++------- src/config.ts | 14 +++++++------- src/models/channel.ts | 29 ++++++++++++++++++++++++++++- src/models/recorder.ts | 37 +++++++++++++++++++++++++++---------- src/models/session.ts | 28 +++++++++++++++++----------- src/services/ws.ts | 2 +- src/shared/enums.ts | 4 +++- src/shared/types.ts | 11 ++++++++--- tests/network.test.ts | 28 ++++++++++------------------ tests/utils/network.ts | 26 ++++++++++++++++++++------ 12 files changed, 165 insertions(+), 65 deletions(-) diff --git a/package-lock.json b/package-lock.json index 152cfca..f2c5b56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@typescript-eslint/eslint-plugin": "8.32.1", "@typescript-eslint/parser": "8.32.1", "eslint": "8.57.1", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.25.3", "eslint-plugin-jest": "28.11.0", "eslint-plugin-node": "^11.1.0", @@ -2982,6 +2983,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", diff --git a/package.json b/package.json index e05078b..9deb2c6 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@typescript-eslint/eslint-plugin": "8.32.1", "@typescript-eslint/parser": "8.32.1", "eslint": "8.57.1", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.25.3", "eslint-plugin-jest": "28.11.0", "eslint-plugin-node": "^11.1.0", diff --git a/src/client.ts b/src/client.ts index 94fedb5..cdd986b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,7 +18,13 @@ import { SERVER_REQUEST, WS_CLOSE_CODE } from "#src/shared/enums.ts"; -import type { JSONSerializable, StreamType, BusMessage, AvailableFeatures } from "#src/shared/types"; +import type { + JSONSerializable, + StreamType, + BusMessage, + AvailableFeatures, + StartupData +} from "#src/shared/types"; import type { TransportConfig, SessionId, SessionInfo } from "#src/models/session"; interface Consumers { @@ -55,11 +61,13 @@ export enum CLIENT_UPDATE { /** A session has left the channel */ DISCONNECT = "disconnect", /** Session info has changed */ - INFO_CHANGE = "info_change" + INFO_CHANGE = "info_change", + CHANNEL_INFO_CHANGE = "channel_info_change" } type ClientUpdatePayload = | { senderId: SessionId; message: JSONSerializable } | { sessionId: SessionId } + | { isRecording: boolean } | Record | { type: StreamType; @@ -142,9 +150,10 @@ export class SfuClient extends EventTarget { /** Connection errors encountered */ public errors: Error[] = []; public availableFeatures: AvailableFeatures = { - "rtc": false, - "recording": false, + rtc: false, + recording: false }; + public isRecording: boolean = false; /** Current client state */ private _state: SfuClientState = SfuClientState.DISCONNECTED; /** Communication bus */ @@ -266,7 +275,7 @@ export class SfuClient extends EventTarget { } return this._bus?.request( { - name: CLIENT_REQUEST.START_RECORDING, + name: CLIENT_REQUEST.START_RECORDING }, { batch: true } ); @@ -278,7 +287,7 @@ export class SfuClient extends EventTarget { } return this._bus?.request( { - name: CLIENT_REQUEST.STOP_RECORDING, + name: CLIENT_REQUEST.STOP_RECORDING }, { batch: true } ); @@ -473,7 +482,13 @@ export class SfuClient extends EventTarget { webSocket.addEventListener( "message", (message) => { - this.availableFeatures = JSON.parse(message.data) as AvailableFeatures; + if (message.data) { + const { availableFeatures, isRecording } = JSON.parse( + message.data + ) as StartupData; + this.availableFeatures = availableFeatures; + this.isRecording = isRecording; + } resolve(new Bus(webSocket)); }, { once: true } @@ -604,6 +619,10 @@ export class SfuClient extends EventTarget { case SERVER_MESSAGE.INFO_CHANGE: this._updateClient(CLIENT_UPDATE.INFO_CHANGE, payload); break; + case SERVER_MESSAGE.CHANNEL_INFO_CHANGE: + this.isRecording = payload.isRecording; + this._updateClient(CLIENT_UPDATE.CHANNEL_INFO_CHANGE, payload); + break; } } diff --git a/src/config.ts b/src/config.ts index b7d209e..7376b1b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ import type { ProducerOptions } from "mediasoup-client/lib/Producer"; const FALSY_INPUT = new Set(["disable", "false", "none", "no", "0"]); type LogLevel = "none" | "error" | "warn" | "info" | "debug" | "verbose"; type WorkerLogLevel = "none" | "error" | "warn" | "debug"; +const testingMode = Boolean(process.env.JEST_WORKER_ID); // ------------------------------------------------------------ // ------------------ ENV VARIABLES ----------------------- @@ -22,7 +23,7 @@ type WorkerLogLevel = "none" | "error" | "warn" | "debug"; * e.g: AUTH_KEY=u6bsUQEWrHdKIuYplirRnbBmLbrKV5PxKG7DtA71mng= */ export const AUTH_KEY: string = process.env.AUTH_KEY!; -if (!AUTH_KEY && !process.env.JEST_WORKER_ID) { +if (!AUTH_KEY && !testingMode) { throw new Error( "AUTH_KEY env variable is required, it is not possible to authenticate requests without it" ); @@ -34,7 +35,7 @@ if (!AUTH_KEY && !process.env.JEST_WORKER_ID) { * e.g: PUBLIC_IP=190.165.1.70 */ export const PUBLIC_IP: string = process.env.PUBLIC_IP!; -if (!PUBLIC_IP && !process.env.JEST_WORKER_ID) { +if (!PUBLIC_IP && !testingMode) { throw new Error( "PUBLIC_IP env variable is required, clients cannot establish webRTC connections without it" ); @@ -67,7 +68,7 @@ export const PORT: number = Number(process.env.PORT) || 8070; /** * Whether the recording feature is enabled, false by default. */ -export const RECORDING: boolean = Boolean(process.env.RECORDING); +export const RECORDING: boolean = Boolean(process.env.RECORDING) || testingMode; /** * The number of workers to spawn (up to core limits) to manage RTC servers. @@ -199,7 +200,7 @@ export const timeouts: TimeoutConfig = Object.freeze({ // how long before a channel is closed after the last session leaves channel: 60 * 60_000, // how long to wait to gather messages before sending through the bus - busBatch: process.env.JEST_WORKER_ID ? 10 : 300 + busBatch: testingMode ? 10 : 300 }); export const recording = Object.freeze({ @@ -212,16 +213,15 @@ export const recording = Object.freeze({ audioCodec: "aac", audioLimit: 20, cameraLimit: 4, // how many camera can be merged into one recording - screenLimit: 1, + screenLimit: 1 }); // TODO: This should probably be env variable, and at least documented so that deployment can open these ports. export const dynamicPorts = Object.freeze({ min: 50000, - max: 59999, + max: 59999 }); - // how many errors can occur before the session is closed, recovery attempts will be made until this limit is reached export const maxSessionErrors: number = 6; diff --git a/src/models/channel.ts b/src/models/channel.ts index 4930642..7021ff5 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -13,6 +13,7 @@ import { } from "#src/models/session.ts"; import { Recorder } from "#src/models/recorder.ts"; import { getWorker, type RtcWorker } from "#src/services/resources.ts"; +import { SERVER_MESSAGE } from "#src/shared/enums.ts"; const logger = new Logger("CHANNEL"); @@ -186,7 +187,11 @@ export class Channel extends EventEmitter { const now = new Date(); this.createDate = now.toISOString(); this.remoteAddress = remoteAddress; - this.recorder = config.recording.enabled && options.recordingAddress ? new Recorder(this, options.recordingAddress) : undefined; + this.recorder = + config.recording.enabled && options.recordingAddress + ? new Recorder(this, options.recordingAddress) + : undefined; + this.recorder?.on("stateChange", () => this._broadcastState()); this.key = key ? Buffer.from(key, "base64") : undefined; this.uuid = crypto.randomUUID(); this.name = `${remoteAddress}*${this.uuid.slice(-5)}`; @@ -306,6 +311,28 @@ export class Channel extends EventEmitter { this.emit("close", this.uuid); } + /** + * Broadcast the state of this channel to all its participants + */ + private _broadcastState() { + for (const session of this.sessions.values()) { + // TODO maybe the following should be on session and some can be made in common with the startupData getter. + if (!session.bus) { + logger.warn(`tried to broadcast state to session ${session.id}, but had no Bus`); + continue; + } + session.bus.send( + { + name: SERVER_MESSAGE.CHANNEL_INFO_CHANGE, + payload: { + isRecording: Boolean(this.recorder?.isRecording) + } + }, + { batch: true } + ); + } + } + /** * @param event - Close event with session ID * @fires Channel#sessionLeave diff --git a/src/models/recorder.ts b/src/models/recorder.ts index c2a0c5e..6f4afa2 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -1,22 +1,22 @@ import { EventEmitter } from "node:events"; -import type { Channel } from "./channel"; -import { getFolder } from "#src/services/resources.ts"; -import type { Folder } from "#src/services/resources.ts"; +import { getFolder, type Folder } from "#src/services/resources.ts"; import { Logger } from "#src/utils/utils.ts"; +import type { Channel } from "./channel"; + export enum RECORDER_STATE { STARTED = "started", - STOPPED = "stopped", + STOPPED = "stopped" } const logger = new Logger("RECORDER"); export class Recorder extends EventEmitter { channel: Channel; - state: RECORDER_STATE = RECORDER_STATE.STOPPED; folder: Folder | undefined; ffmpeg = null; /** Path to which the final recording will be uploaded to */ recordingAddress: string; + private _state: RECORDER_STATE = RECORDER_STATE.STOPPED; constructor(channel: Channel, recordingAddress: string) { super(); @@ -26,10 +26,10 @@ export class Recorder extends EventEmitter { async start() { if (this.state === RECORDER_STATE.STOPPED) { - this.folder = getFolder(); + this.folder = getFolder(); this.state = RECORDER_STATE.STARTED; - logger.trace("TO IMPLEMENT"); - // TODO ffmpeg instance creation for recording to folder.path with proper name, start, build timestamps object + logger.trace("TO IMPLEMENT"); + // TODO ffmpeg instance creation for recording to folder.path with proper name, start, build timestamps object } this._record(); return { state: this.state }; @@ -38,7 +38,11 @@ export class Recorder extends EventEmitter { async stop() { if (this.state === RECORDER_STATE.STARTED) { logger.trace("TO IMPLEMENT"); - await this.folder!.seal("test-name"); + try { + await this.folder!.seal("test-name"); + } catch { + logger.verbose("failed to save the recording"); // TODO maybe warn and give more info + } this.folder = undefined; // TODO ffmpeg instance stop, cleanup, // only resolve promise and switch state when completely ready to start a new recording. @@ -47,11 +51,24 @@ export class Recorder extends EventEmitter { return { state: this.state }; } + get isRecording(): boolean { + return this.state === RECORDER_STATE.STARTED; + } + + get state(): RECORDER_STATE { + return this._state; + } + + set state(state: RECORDER_STATE) { + this._state = state; + this.emit("stateChange", state); + } + /** * @param video whether we want to record videos or not (will always record audio) */ _record(video: boolean = false) { - console.trace(`TO IMPLEMENT: recording channel ${this.channel.name}, video: ${video}`); + logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}, video: ${video}`); // iterate all producers on all sessions of the channel, create a ffmpeg for each, // save them on a map by session id+type. // check if recording for that session id+type is already in progress diff --git a/src/models/session.ts b/src/models/session.ts index c2625c2..9c73cbf 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -1,14 +1,14 @@ import { EventEmitter } from "node:events"; import type { - IceParameters, - IceCandidate, - DtlsParameters, - SctpParameters, Consumer, + DtlsParameters, + IceCandidate, + IceParameters, Producer, - WebRtcTransport, - RtpCapabilities + RtpCapabilities, + SctpParameters, + WebRtcTransport } from "mediasoup/node/lib/types"; import * as config from "#src/config.ts"; @@ -20,9 +20,10 @@ import { SERVER_REQUEST, STREAM_TYPE } from "#src/shared/enums.ts"; -import type {JSONSerializable, StreamType, BusMessage, AvailableFeatures } from "#src/shared/types"; +import type { BusMessage, JSONSerializable, StartupData, StreamType } from "#src/shared/types"; import type { Bus } from "#src/shared/bus.ts"; import type { Channel } from "#src/models/channel.ts"; +import { RECORDER_STATE } from "#src/models/recorder.ts"; export type SessionId = number | string; export type SessionInfo = { @@ -168,11 +169,16 @@ export class Session extends EventEmitter { this.setMaxListeners(config.CHANNEL_SIZE * 2); } - get availableFeatures(): AvailableFeatures { + get startupData(): StartupData { return { - "rtc": Boolean(this._channel.router), - "recording": Boolean(this._channel.router && this._channel.recorder && this.permissions.recording) - } + availableFeatures: { + rtc: Boolean(this._channel.router), + recording: Boolean( + this._channel.router && this._channel.recorder && this.permissions.recording + ) + }, + isRecording: this._channel.recorder?.state === RECORDER_STATE.STARTED + }; } get name(): string { diff --git a/src/services/ws.ts b/src/services/ws.ts index d6f6bea..620fca6 100644 --- a/src/services/ws.ts +++ b/src/services/ws.ts @@ -131,7 +131,7 @@ function connect(webSocket: WebSocket, credentials: Credentials): Session { const bus = new Bus(webSocket, { batchDelay: config.timeouts.busBatch }); const { session } = Channel.join(channel.uuid, session_id); session.updatePermissions(permissions); - webSocket.send(JSON.stringify(session.availableFeatures)); // client can start using ws after this message. + webSocket.send(JSON.stringify(session.startupData)); // client can start using ws after this message. session.once("close", ({ code }: { code: string }) => { let wsCloseCode = WS_CLOSE_CODE.CLEAN; switch (code) { diff --git a/src/shared/enums.ts b/src/shared/enums.ts index e5a3a92..3d9ea18 100644 --- a/src/shared/enums.ts +++ b/src/shared/enums.ts @@ -24,7 +24,9 @@ export enum SERVER_MESSAGE { /** Signals the clients that one of the session in their channel has left */ SESSION_LEAVE = "SESSION_LEAVE", /** Signals the clients that the info (talking, mute,...) of one of the session in their channel has changed */ - INFO_CHANGE = "S_INFO_CHANGE" + INFO_CHANGE = "S_INFO_CHANGE", + /** Signals the clients that the info of the channel (isRecording,...) has changed */ + CHANNEL_INFO_CHANGE = "C_INFO_CHANGE" } export enum CLIENT_REQUEST { diff --git a/src/shared/types.ts b/src/shared/types.ts index f59c891..0453813 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -10,10 +10,14 @@ export type StreamType = "audio" | "camera" | "screen"; export type StringLike = Buffer | string; +export type StartupData = { + availableFeatures: AvailableFeatures; + isRecording: boolean; +}; export type AvailableFeatures = { - "rtc": boolean, - "recording": boolean, -} + rtc: boolean; + recording: boolean; +}; import type { DownloadStates } from "#src/client.ts"; import type { SessionId, SessionInfo, TransportConfig } from "#src/models/session.ts"; @@ -62,6 +66,7 @@ export type BusMessage = } | { name: typeof SERVER_MESSAGE.SESSION_LEAVE; payload: { sessionId: SessionId } } | { name: typeof SERVER_MESSAGE.INFO_CHANGE; payload: Record } + | { name: typeof SERVER_MESSAGE.CHANNEL_INFO_CHANGE; payload: { isRecording: boolean } } | { name: typeof SERVER_REQUEST.INIT_CONSUMER; payload: { diff --git a/tests/network.test.ts b/tests/network.test.ts index 310f4b8..a2a408d 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -146,11 +146,9 @@ describe("Full network", () => { test("A client can forward a track to other clients", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); - await once(user1.session, "stateChange"); const user2 = await network.connect(channelUUID, 2); - await once(user2.session, "stateChange"); const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await Promise.all([user1.isConnected, user2.isConnected, sender.isConnected]); const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); const prom1 = once(user1.sfuClient, "update"); @@ -166,9 +164,8 @@ describe("Full network", () => { test("Recovery attempts are made if the production fails, a failure does not close the connection", async () => { const channelUUID = await network.getChannelUUID(); const user = await network.connect(channelUUID, 1); - await once(user.session, "stateChange"); const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await Promise.all([user.isConnected, sender.isConnected]); const track = new FakeMediaStreamTrack({ kind: "audio" }); // closing the transport so the `updateUpload` should fail. // @ts-expect-error accessing private property for testing purposes @@ -180,9 +177,8 @@ describe("Full network", () => { test("Recovery attempts are made if the consumption fails, a failure does not close the connection", async () => { const channelUUID = await network.getChannelUUID(); const user = await network.connect(channelUUID, 1); - await once(user.session, "stateChange"); const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await Promise.all([user.isConnected, sender.isConnected]); const track = new FakeMediaStreamTrack({ kind: "audio" }); // closing the transport so the consumption should fail. // @ts-expect-error accessing private property for testing purposes @@ -196,9 +192,8 @@ describe("Full network", () => { test("The client can obtain download and upload statistics", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); - await once(user1.session, "stateChange"); const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); + await Promise.all([user1.isConnected, sender.isConnected]); const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); await once(user1.sfuClient, "update"); @@ -211,9 +206,8 @@ describe("Full network", () => { test("The client can update the state of their downloads", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1234); - await once(user1.session, "stateChange"); const sender = await network.connect(channelUUID, 123); - await once(sender.session, "stateChange"); + await Promise.all([user1.isConnected, sender.isConnected]); const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); await once(user1.sfuClient, "update"); @@ -233,9 +227,8 @@ describe("Full network", () => { test("The client can update the state of their upload", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1234); - await once(user1.session, "stateChange"); const sender = await network.connect(channelUUID, 123); - await once(sender.session, "stateChange"); + await Promise.all([user1.isConnected, sender.isConnected]); const track = new FakeMediaStreamTrack({ kind: "video" }); await sender.sfuClient.updateUpload(STREAM_TYPE.CAMERA, track); await once(user1.sfuClient, "update"); @@ -292,13 +285,12 @@ describe("Full network", () => { test("POC RECORDING", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); - await once(user1.session, "stateChange"); const sender = await network.connect(channelUUID, 3); - await once(sender.session, "stateChange"); - sender.session.updatePermissions({ recording: true }); - const startResult = await sender.sfuClient.startRecording() as { state: string }; + await Promise.all([user1.isConnected, sender.isConnected]); + expect(sender.sfuClient.availableFeatures.recording).toBe(true); + const startResult = (await sender.sfuClient.startRecording()) as { state: string }; expect(startResult.state).toBe("started"); - const stopResult = await sender.sfuClient.stopRecording() as { state: string }; + const stopResult = (await sender.sfuClient.stopRecording()) as { state: string }; expect(stopResult.state).toBe("stopped"); }); }); diff --git a/tests/utils/network.ts b/tests/utils/network.ts index 9ac1d16..33d6cfc 100644 --- a/tests/utils/network.ts +++ b/tests/utils/network.ts @@ -37,6 +37,7 @@ interface ConnectionResult { session: Session; /** Client-side SFU client instance */ sfuClient: SfuClient; + isConnected: Promise; } /** @@ -71,7 +72,7 @@ export class LocalNetwork { // Start all services in correct order await resources.start(); await http.start({ httpInterface: hostname, port }); - await auth.start(HMAC_B64_KEY); + auth.start(HMAC_B64_KEY); } /** @@ -90,9 +91,9 @@ export class LocalNetwork { iss: `http://${this.hostname}:${this.port}/`, key }); - + const TEST_RECORDING_ADDRESS = "http://localhost:8080"; // TODO maybe to change and use that later const response = await fetch( - `http://${this.hostname}:${this.port}/v${http.API_VERSION}/channel?webRTC=${useWebRtc}`, + `http://${this.hostname}:${this.port}/v${http.API_VERSION}/channel?webRTC=${useWebRtc}&recordingAddress=${TEST_RECORDING_ADDRESS}`, { method: "GET", headers: { @@ -167,17 +168,30 @@ export class LocalNetwork { break; } }; - sfuClient.addEventListener("stateChange", handleStateChange as EventListener); }); + const isConnected = new Promise((resolve, reject) => { + const connectedHandler = (event: CustomEvent) => { + const { state } = event.detail; + if (state === SfuClientState.CONNECTED) { + sfuClient.removeEventListener("stateChange", connectedHandler as EventListener); + resolve(true); + } + }; + sfuClient.addEventListener("stateChange", connectedHandler as EventListener); + }); + // Start connection sfuClient.connect( `ws://${this.hostname}:${this.port}`, this.makeJwt( { sfu_channel_uuid: channelUUID, - session_id: sessionId + session_id: sessionId, + permissions: { + recording: true + } }, key ), @@ -198,7 +212,7 @@ export class LocalNetwork { throw new Error(`Session ${sessionId} not found in channel ${channelUUID}`); } - return { session, sfuClient }; + return { session, sfuClient, isConnected }; } /** From e769b535065f679b2211e5f6bece0d60b01b446c Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 5 Nov 2025 10:39:54 +0100 Subject: [PATCH 10/73] [WIP] recording: ts version bump, bundle, simpler rec api --- package-lock.json | 196 +++++++++++++++++++++++++---------------- package.json | 10 +-- rollup.config.js | 3 - src/client.ts | 4 +- src/models/channel.ts | 2 + src/models/recorder.ts | 4 +- src/models/session.ts | 1 + src/services/auth.ts | 2 +- src/shared/types.ts | 2 +- tests/network.test.ts | 8 +- tests/utils/network.ts | 1 + tsconfig.json | 4 - tsconfig_bundle.json | 4 +- 13 files changed, 141 insertions(+), 100 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2c5b56..89355c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,12 @@ "@jest/globals": "^29.6.2", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^13.0.4", - "@rollup/plugin-typescript": "^10.0.1", + "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.0", - "@types/node": "^20.5.0", + "@types/node": "^22.13.14", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "8.32.1", - "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/eslint-plugin": "^8.46.3", + "@typescript-eslint/parser": "^8.46.3", "eslint": "8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.25.3", @@ -36,7 +36,7 @@ "rollup": "^2.79.1", "rollup-plugin-license": "3.2.0", "ts-jest": "^29.3.4", - "typescript": "~5.4.3" + "typescript": "~5.9.3" }, "engines": { "node": ">=22.16.0" @@ -1344,20 +1344,20 @@ "dev": true }, "node_modules/@rollup/plugin-typescript": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-10.0.1.tgz", - "integrity": "sha512-wBykxRLlX7EzL8BmUqMqk5zpx2onnmRMSw/l9M1sVfkJvdwfxogZQVNUM9gVMJbjRLDR5H6U0OMOrlDGmIV45A==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", "dev": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.0.1", + "@rollup/pluginutils": "^5.1.0", "resolve": "^1.22.1" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "rollup": "^2.14.0||^3.0.0", + "rollup": "^2.14.0||^3.0.0||^4.0.0", "tslib": "*", "typescript": ">=3.7.0" }, @@ -1535,12 +1535,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.10.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", - "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", + "version": "22.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", + "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-fetch": { @@ -1601,17 +1602,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", - "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", + "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/type-utils": "8.32.1", - "@typescript-eslint/utils": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/type-utils": "8.46.3", + "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1625,9 +1626,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1641,16 +1642,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", - "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", + "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4" }, "engines": { @@ -1662,18 +1663,40 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", + "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.3", + "@typescript-eslint/types": "^8.46.3", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", - "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", + "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1" + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1683,15 +1706,33 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", + "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", - "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", + "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.32.1", - "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/utils": "8.46.3", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1704,13 +1745,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", - "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", + "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", "dev": true, "license": "MIT", "engines": { @@ -1722,14 +1763,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", - "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", + "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", + "@typescript-eslint/project-service": "8.46.3", + "@typescript-eslint/tsconfig-utils": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1745,7 +1788,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -1775,9 +1818,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -1788,16 +1831,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", - "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", + "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1" + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1808,18 +1851,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", - "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", + "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.1", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.46.3", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1830,9 +1873,9 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7318,9 +7361,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7420,10 +7463,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.0.13", diff --git a/package.json b/package.json index 9deb2c6..92e333a 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,12 @@ "@jest/globals": "^29.6.2", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^13.0.4", - "@rollup/plugin-typescript": "^10.0.1", + "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.0", - "@types/node": "^20.5.0", + "@types/node": "^22.13.14", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "8.32.1", - "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/eslint-plugin": "^8.46.3", + "@typescript-eslint/parser": "^8.46.3", "eslint": "8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.25.3", @@ -50,6 +50,6 @@ "rollup": "^2.79.1", "rollup-plugin-license": "3.2.0", "ts-jest": "^29.3.4", - "typescript": "~5.4.3" + "typescript": "~5.9.3" } } diff --git a/rollup.config.js b/rollup.config.js index dc669db..f421cf5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -35,9 +35,6 @@ export default { plugins: [ typescript({ tsconfig: "./tsconfig_bundle.json", - declaration: false, - declarationMap: false, - sourceMap: false, }), resolve({ browser: true, diff --git a/src/client.ts b/src/client.ts index cdd986b..63aec13 100644 --- a/src/client.ts +++ b/src/client.ts @@ -271,7 +271,7 @@ export class SfuClient extends EventTarget { } async startRecording() { if (this.state !== SfuClientState.CONNECTED) { - throw new Error("InvalidState"); + return; } return this._bus?.request( { @@ -283,7 +283,7 @@ export class SfuClient extends EventTarget { async stopRecording() { if (this.state !== SfuClientState.CONNECTED) { - throw new Error("InvalidState"); + return; } return this._bus?.request( { diff --git a/src/models/channel.ts b/src/models/channel.ts index 7021ff5..6ac481c 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -129,6 +129,8 @@ export class Channel extends EventEmitter { logger.info( `created channel ${channel.uuid} (${key ? "unique" : "global"} key) for ${safeIssuer}` ); + logger.verbose(`rtc feature: ${Boolean(channel.router)}`); + logger.verbose(`recording feature: ${Boolean(channel.recorder)}`); const onWorkerDeath = () => { logger.warn(`worker died, closing channel ${channel.uuid}`); channel.close(); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 6f4afa2..d02d186 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -32,7 +32,7 @@ export class Recorder extends EventEmitter { // TODO ffmpeg instance creation for recording to folder.path with proper name, start, build timestamps object } this._record(); - return { state: this.state }; + return this.isRecording; } async stop() { @@ -48,7 +48,7 @@ export class Recorder extends EventEmitter { // only resolve promise and switch state when completely ready to start a new recording. this.state = RECORDER_STATE.STOPPED; } - return { state: this.state }; + return this.isRecording; } get isRecording(): boolean { diff --git a/src/models/session.ts b/src/models/session.ts index 9c73cbf..d9f317e 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -208,6 +208,7 @@ export class Session extends EventEmitter { continue; } this.permissions[key] = Boolean(permissions[key]); + logger.verbose(`Permissions updated: ${key} = ${this.permissions[key]}`); } } diff --git a/src/services/auth.ts b/src/services/auth.ts index 7efef4a..9d4b0b8 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -43,7 +43,7 @@ interface PrivateJWTClaims { sfu_channel_uuid?: string; session_id?: SessionId; ice_servers?: object[]; - permissions?: SessionPermissions, + permissions?: SessionPermissions; sessionIdsByChannel?: Record; /** If provided when requesting a channel, this key will be used instead of the global key to verify JWTs related to this channel */ key?: string; diff --git a/src/shared/types.ts b/src/shared/types.ts index 0453813..0c653da 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -30,7 +30,7 @@ import type { RtpParameters // eslint-disable-next-line node/no-unpublished-import } from "mediasoup-client/lib/types"; -import type { CLIENT_MESSAGE, CLIENT_REQUEST, SERVER_MESSAGE, SERVER_REQUEST } from "./enums.ts"; +import type { CLIENT_MESSAGE, CLIENT_REQUEST, SERVER_MESSAGE, SERVER_REQUEST } from "./enums"; export type BusMessage = | { name: typeof CLIENT_MESSAGE.BROADCAST; payload: JSONSerializable } diff --git a/tests/network.test.ts b/tests/network.test.ts index a2a408d..69ad4c2 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -288,9 +288,9 @@ describe("Full network", () => { const sender = await network.connect(channelUUID, 3); await Promise.all([user1.isConnected, sender.isConnected]); expect(sender.sfuClient.availableFeatures.recording).toBe(true); - const startResult = (await sender.sfuClient.startRecording()) as { state: string }; - expect(startResult.state).toBe("started"); - const stopResult = (await sender.sfuClient.stopRecording()) as { state: string }; - expect(stopResult.state).toBe("stopped"); + const startResult = (await sender.sfuClient.startRecording()) as boolean; + expect(startResult).toBe(true); + const stopResult = (await sender.sfuClient.stopRecording()) as boolean; + expect(stopResult).toBe(false); }); }); diff --git a/tests/utils/network.ts b/tests/utils/network.ts index 33d6cfc..943f445 100644 --- a/tests/utils/network.ts +++ b/tests/utils/network.ts @@ -37,6 +37,7 @@ interface ConnectionResult { session: Session; /** Client-side SFU client instance */ sfuClient: SfuClient; + /** Promise resolving to true when client is connected */ isConnected: Promise; } diff --git a/tsconfig.json b/tsconfig.json index 9140ec1..43c116e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,13 +10,11 @@ "lib": ["ES2022", "DOM"], "module": "ESNext", "allowImportingTsExtensions": true, - "noEmit": true, "baseUrl": ".", "paths": { "#src/*": ["./src/*"], "#tests/*": ["./tests/*"] }, - "outDir": "dist", "strict": true, "forceConsistentCasingInFileNames": true, "noUnusedLocals": true, @@ -30,8 +28,6 @@ ], "esModuleInterop": true, "resolveJsonModule": true, - "declaration": true, - "declarationDir": "dist/types", "preserveConstEnums": false, } } diff --git a/tsconfig_bundle.json b/tsconfig_bundle.json index aca1cdc..ba7b048 100644 --- a/tsconfig_bundle.json +++ b/tsconfig_bundle.json @@ -5,8 +5,8 @@ "module": "es6", "declaration": false, "sourceMap": false, - "noEmit": false, - "allowImportingTsExtensions": false + "allowImportingTsExtensions": true, + "noEmit": true }, "include": ["src/client.ts", "src/shared/**/*"], "exclude": ["tests/**/*", "src/server.ts", "src/services/**/*", "src/models/**/*"] From 6d1cd011d623208c1c244ca8f79747813284727e Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 5 Nov 2025 11:31:37 +0100 Subject: [PATCH 11/73] [IMP] discuss: wip recording see: https://github.com/odoo/sfu/pull/27 --- src/client.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index 63aec13..eb2b876 100644 --- a/src/client.ts +++ b/src/client.ts @@ -269,28 +269,28 @@ export class SfuClient extends EventTarget { await Promise.all(proms); return stats; } - async startRecording() { + async startRecording(): Promise { if (this.state !== SfuClientState.CONNECTED) { - return; + throw new Error("Cannot start recording when not connected"); } return this._bus?.request( { name: CLIENT_REQUEST.START_RECORDING }, { batch: true } - ); + ) as Promise; } - async stopRecording() { + async stopRecording(): Promise { if (this.state !== SfuClientState.CONNECTED) { - return; + throw new Error("Cannot stop recording when not connected"); } return this._bus?.request( { name: CLIENT_REQUEST.STOP_RECORDING }, { batch: true } - ); + ) as Promise; } /** From e9c74250e0359d2dc887ac70bcb884732cbba5d6 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 5 Nov 2025 12:31:29 +0100 Subject: [PATCH 12/73] [IMP] discuss: better bus types --- src/client.ts | 18 +++++++++++------- src/models/session.ts | 14 +++++++++----- src/shared/bus-types.ts | 21 +++++++++++++++++++++ src/shared/bus.ts | 29 +++++++++++++++++------------ src/utils/utils.ts | 2 +- tests/bus.test.ts | 7 ++++--- 6 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 src/shared/bus-types.ts diff --git a/src/client.ts b/src/client.ts index eb2b876..33a193a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -25,6 +25,7 @@ import type { AvailableFeatures, StartupData } from "#src/shared/types"; +import type { RequestMessage } from "#src/shared/bus-types"; import type { TransportConfig, SessionId, SessionInfo } from "#src/models/session"; interface Consumers { @@ -273,24 +274,24 @@ export class SfuClient extends EventTarget { if (this.state !== SfuClientState.CONNECTED) { throw new Error("Cannot start recording when not connected"); } - return this._bus?.request( + return this._bus!.request( { name: CLIENT_REQUEST.START_RECORDING }, { batch: true } - ) as Promise; + ); } async stopRecording(): Promise { if (this.state !== SfuClientState.CONNECTED) { throw new Error("Cannot stop recording when not connected"); } - return this._bus?.request( + return this._bus!.request( { name: CLIENT_REQUEST.STOP_RECORDING }, { batch: true } - ) as Promise; + ); } /** @@ -531,10 +532,10 @@ export class SfuClient extends EventTarget { }); transport.on("produce", async ({ kind, rtpParameters, appData }, callback, errback) => { try { - const result = (await this._bus!.request({ + const result = await this._bus!.request({ name: CLIENT_REQUEST.INIT_PRODUCER, payload: { type: appData.type as StreamType, kind, rtpParameters } - })) as { id: string }; + }); callback({ id: result.id }); } catch (error) { errback(error as Error); @@ -626,7 +627,10 @@ export class SfuClient extends EventTarget { } } - private async _handleRequest({ name, payload }: BusMessage): Promise { + private async _handleRequest({ + name, + payload + }: RequestMessage): Promise { switch (name) { case SERVER_REQUEST.INIT_CONSUMER: { const { id, kind, producerId, rtpParameters, sessionId, type, active } = payload; diff --git a/src/models/session.ts b/src/models/session.ts index d9f317e..432bc29 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -21,6 +21,7 @@ import { STREAM_TYPE } from "#src/shared/enums.ts"; import type { BusMessage, JSONSerializable, StartupData, StreamType } from "#src/shared/types"; +import type { RequestMessage } from "#src/shared/bus-types"; import type { Bus } from "#src/shared/bus.ts"; import type { Channel } from "#src/models/channel.ts"; import { RECORDER_STATE } from "#src/models/recorder.ts"; @@ -361,15 +362,15 @@ export class Session extends EventEmitter { this._ctsTransport?.close(); this._stcTransport?.close(); }); - this._clientCapabilities = (await this.bus!.request({ + this._clientCapabilities = await this.bus!.request({ name: SERVER_REQUEST.INIT_TRANSPORTS, payload: { capabilities: this._channel.router!.rtpCapabilities, - stcConfig: this._createTransportConfig(this._stcTransport), - ctsConfig: this._createTransportConfig(this._ctsTransport), + stcConfig: this._createTransportConfig(this._stcTransport!), + ctsConfig: this._createTransportConfig(this._ctsTransport!), producerOptionsByKind: config.rtc.producerOptionsByKind } - })) as RtpCapabilities; + }); await Promise.all([ this._ctsTransport.setMaxIncomingBitrate(config.MAX_BITRATE_IN), this._stcTransport.setMaxOutgoingBitrate(config.MAX_BITRATE_OUT) @@ -636,7 +637,10 @@ export class Session extends EventEmitter { } } - private async _handleRequest({ name, payload }: BusMessage): Promise { + private async _handleRequest({ + name, + payload + }: RequestMessage): Promise { switch (name) { case CLIENT_REQUEST.CONNECT_STC_TRANSPORT: { const { dtlsParameters } = payload; diff --git a/src/shared/bus-types.ts b/src/shared/bus-types.ts new file mode 100644 index 0000000..dc69233 --- /dev/null +++ b/src/shared/bus-types.ts @@ -0,0 +1,21 @@ +// eslint-disable-next-line node/no-unpublished-import +import type { RtpCapabilities } from "mediasoup-client/lib/types"; +import type { CLIENT_REQUEST, SERVER_REQUEST } from "./enums"; +import type { BusMessage } from "./types"; + +export interface RequestMap { + [CLIENT_REQUEST.CONNECT_CTS_TRANSPORT]: void; + [CLIENT_REQUEST.CONNECT_STC_TRANSPORT]: void; + [CLIENT_REQUEST.INIT_PRODUCER]: { id: string }; + [CLIENT_REQUEST.START_RECORDING]: boolean; + [CLIENT_REQUEST.STOP_RECORDING]: boolean; + [SERVER_REQUEST.INIT_CONSUMER]: void; + [SERVER_REQUEST.INIT_TRANSPORTS]: RtpCapabilities; + [SERVER_REQUEST.PING]: void; +} + +export type RequestName = keyof RequestMap; + +export type RequestMessage = Extract; + +export type ResponseFrom = RequestMap[T]; diff --git a/src/shared/bus.ts b/src/shared/bus.ts index 5171485..4edf54e 100644 --- a/src/shared/bus.ts +++ b/src/shared/bus.ts @@ -1,9 +1,11 @@ import type { WebSocket as NodeWebSocket } from "ws"; import type { JSONSerializable, BusMessage } from "./types"; +import type { RequestMessage, RequestName, ResponseFrom } from "./bus-types"; + export interface Payload { /** The actual message content */ - message: BusMessage; + message: BusMessage | JSONSerializable; /** Request ID if this message expects a response */ needResponse?: string; /** Response ID if this message is responding to a request */ @@ -46,11 +48,9 @@ export class Bus { /** Global ID counter for Bus instances */ private static _idCount = 0; /** Message handler for incoming messages */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any public onMessage?: (message: BusMessage) => void; /** Request handler for incoming requests */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public onRequest?: (request: BusMessage) => Promise; + public onRequest?: (request: RequestMessage) => Promise; /** Unique bus instance identifier */ public readonly id: number = Bus._idCount++; /** Request counter for generating unique request IDs */ @@ -96,8 +96,10 @@ export class Bus { /** * Sends a request and waits for a response */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request(message: BusMessage, options: RequestOptions = {}): Promise { + request( + message: RequestMessage, + options: RequestOptions = {} + ): Promise> { const { timeout = 5000, batch } = options; const requestId = this._getNextRequestId(); return new Promise((resolve, reject) => { @@ -105,7 +107,11 @@ export class Bus { reject(new Error("bus request timed out")); this._pendingRequests.delete(requestId); }, timeout); - this._pendingRequests.set(requestId, { resolve, reject, timeout: timeoutId }); + this._pendingRequests.set(requestId, { + resolve, + reject, + timeout: timeoutId + }); this._sendPayload(message, { needResponse: requestId, batch }); }); } @@ -138,8 +144,7 @@ export class Bus { } private _sendPayload( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - message: BusMessage, + message: BusMessage | JSONSerializable, options: { needResponse?: string; responseTo?: string; @@ -212,11 +217,11 @@ export class Bus { } } else if (needResponse) { // This is a request that expects a response - const response = await this.onRequest?.(message); - this._sendPayload(response!, { responseTo: needResponse }); + const response = await this.onRequest?.(message as RequestMessage); + this._sendPayload(response ?? {}, { responseTo: needResponse }); } else { // This is a plain message - this.onMessage?.(message); + this.onMessage?.(message as BusMessage); } } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 8c668af..4323679 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -52,7 +52,7 @@ export interface ParseBodyOptions { function getCallChain(depth: number = 8): string { const stack = new Error().stack?.split("\n").slice(2, depth + 2) ?? []; return stack - .map(line => { + .map((line) => { const match = line.trim().match(/^at\s+(.*?)\s+\(/); return match ? match[1] : null; }) diff --git a/tests/bus.test.ts b/tests/bus.test.ts index 2d980cc..c0cc5a7 100644 --- a/tests/bus.test.ts +++ b/tests/bus.test.ts @@ -4,6 +4,7 @@ import { expect, describe, jest } from "@jest/globals"; import { Bus } from "#src/shared/bus"; import type { JSONSerializable, BusMessage } from "#src/shared/types"; +import { RequestMessage } from "#src/shared/bus-types.ts"; class MockTargetWebSocket extends EventTarget { send(message: JSONSerializable) { @@ -74,14 +75,14 @@ describe("Bus API", () => { return "pong"; } }; - const response = await aliceBus.request("ping" as unknown as BusMessage); + const response = await aliceBus.request("ping" as unknown as RequestMessage); expect(response).toBe("pong"); }); test("promises are rejected when the bus is closed", async () => { const { aliceSocket } = mockSocketPair(); const aliceBus = new Bus(aliceSocket as unknown as WebSocket); let rejected = false; - const promise = aliceBus.request("ping" as unknown as BusMessage); + const promise = aliceBus.request("ping" as unknown as RequestMessage); aliceBus.close(); try { await promise; @@ -96,7 +97,7 @@ describe("Bus API", () => { const { aliceSocket } = mockSocketPair(); const aliceBus = new Bus(aliceSocket as unknown as WebSocket); const timeout = 500; - const promise = aliceBus.request("hello" as unknown as BusMessage, { timeout }); + const promise = aliceBus.request("hello" as unknown as RequestMessage, { timeout }); jest.advanceTimersByTime(timeout); await expect(promise).rejects.toThrow(); jest.useRealTimers(); From 677eb3a6317e677d916792275b85eee2eb53d344 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Mon, 10 Nov 2025 10:35:13 +0100 Subject: [PATCH 13/73] [WIP] fixup deferred --- src/utils/utils.ts | 30 +++++++++++++++++++ tests/utils/network.ts | 67 +++++++++++++++++++++--------------------- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 4323679..f0590e6 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -49,6 +49,36 @@ export interface ParseBodyOptions { json?: boolean; } +export class Deferred { + private readonly _promise: Promise; + public resolve!: (value: T | PromiseLike) => void; + public reject!: (reason?: unknown) => void; + + constructor() { + this._promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + + public then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this._promise.then(onfulfilled, onrejected); + } + + public catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this._promise.catch(onrejected); + } + + public finally(onfinally?: (() => void) | null): Promise { + return this._promise.finally(onfinally); + } +} + function getCallChain(depth: number = 8): string { const stack = new Error().stack?.split("\n").slice(2, depth + 2) ?? []; return stack diff --git a/tests/utils/network.ts b/tests/utils/network.ts index 943f445..591583c 100644 --- a/tests/utils/network.ts +++ b/tests/utils/network.ts @@ -6,6 +6,7 @@ import * as fakeParameters from "mediasoup-client/lib/test/fakeParameters"; import * as auth from "#src/services/auth"; import * as http from "#src/services/http"; import * as resources from "#src/services/resources"; +import { Deferred } from "#src/utils/utils"; import { SfuClient, SfuClientState } from "#src/client"; import { Channel } from "#src/models/channel"; import type { Session } from "#src/models/session"; @@ -38,7 +39,7 @@ interface ConnectionResult { /** Client-side SFU client instance */ sfuClient: SfuClient; /** Promise resolving to true when client is connected */ - isConnected: Promise; + isConnected: Deferred; } /** @@ -149,39 +150,37 @@ export class LocalNetwork { }; // Set up authentication promise - const isClientAuthenticated = new Promise((resolve, reject) => { - const handleStateChange = (event: CustomEvent) => { - const { state } = event.detail; - switch (state) { - case SfuClientState.AUTHENTICATED: - sfuClient.removeEventListener( - "stateChange", - handleStateChange as EventListener - ); - resolve(true); - break; - case SfuClientState.CLOSED: - sfuClient.removeEventListener( - "stateChange", - handleStateChange as EventListener - ); - reject(new Error("client closed")); - break; - } - }; - sfuClient.addEventListener("stateChange", handleStateChange as EventListener); - }); - - const isConnected = new Promise((resolve, reject) => { - const connectedHandler = (event: CustomEvent) => { - const { state } = event.detail; - if (state === SfuClientState.CONNECTED) { - sfuClient.removeEventListener("stateChange", connectedHandler as EventListener); - resolve(true); - } - }; - sfuClient.addEventListener("stateChange", connectedHandler as EventListener); - }); + const isClientAuthenticated = new Deferred(); + const handleStateChange = (event: CustomEvent) => { + const { state } = event.detail; + switch (state) { + case SfuClientState.AUTHENTICATED: + sfuClient.removeEventListener( + "stateChange", + handleStateChange as EventListener + ); + isClientAuthenticated.resolve(true); + break; + case SfuClientState.CLOSED: + sfuClient.removeEventListener( + "stateChange", + handleStateChange as EventListener + ); + isClientAuthenticated.reject(new Error("client closed")); + break; + } + }; + sfuClient.addEventListener("stateChange", handleStateChange as EventListener); + + const isConnected = new Deferred(); + const connectedHandler = (event: CustomEvent) => { + const { state } = event.detail; + if (state === SfuClientState.CONNECTED) { + sfuClient.removeEventListener("stateChange", connectedHandler as EventListener); + isConnected.resolve(true); + } + }; + sfuClient.addEventListener("stateChange", connectedHandler as EventListener); // Start connection sfuClient.connect( From 23de8d092f020d09e2702ea31928879afe49cfab Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 12 Nov 2025 08:42:21 +0100 Subject: [PATCH 14/73] [WIP] recorder API --- src/models/ffmpeg.ts | 3 +- src/models/recorder.ts | 74 ++++++++++++++++++++++++--------------- src/services/resources.ts | 7 +++- 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 48189b9..167a43a 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,8 +1,9 @@ import { EventEmitter } from "node:events"; export class FFMPEG extends EventEmitter { - constructor() { super(); } + + async kill() {} } diff --git a/src/models/recorder.ts b/src/models/recorder.ts index d02d186..e9167cf 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -3,9 +3,11 @@ import { getFolder, type Folder } from "#src/services/resources.ts"; import { Logger } from "#src/utils/utils.ts"; import type { Channel } from "./channel"; +import { FFMPEG } from "#src/models/ffmpeg.ts"; export enum RECORDER_STATE { STARTED = "started", + STOPPING = "stopping", STOPPED = "stopped" } const logger = new Logger("RECORDER"); @@ -13,11 +15,22 @@ const logger = new Logger("RECORDER"); export class Recorder extends EventEmitter { channel: Channel; folder: Folder | undefined; - ffmpeg = null; + ffmpeg: FFMPEG | undefined; /** Path to which the final recording will be uploaded to */ recordingAddress: string; private _state: RECORDER_STATE = RECORDER_STATE.STOPPED; + get isRecording(): boolean { + return this.state === RECORDER_STATE.STARTED; + } + get state(): RECORDER_STATE { + return this._state; + } + set state(state: RECORDER_STATE) { + this._state = state; + this.emit("stateChange", state); + } + constructor(channel: Channel, recordingAddress: string) { super(); this.channel = channel; @@ -25,53 +38,58 @@ export class Recorder extends EventEmitter { } async start() { - if (this.state === RECORDER_STATE.STOPPED) { - this.folder = getFolder(); - this.state = RECORDER_STATE.STARTED; - logger.trace("TO IMPLEMENT"); - // TODO ffmpeg instance creation for recording to folder.path with proper name, start, build timestamps object + if (!this.isRecording) { + try { + await this._start(); + } catch { + await this._stop(); + } } - this._record(); return this.isRecording; } async stop() { - if (this.state === RECORDER_STATE.STARTED) { - logger.trace("TO IMPLEMENT"); + if (this.isRecording) { try { - await this.folder!.seal("test-name"); + await this._stop({ save: true }); } catch { logger.verbose("failed to save the recording"); // TODO maybe warn and give more info } - this.folder = undefined; - // TODO ffmpeg instance stop, cleanup, - // only resolve promise and switch state when completely ready to start a new recording. - this.state = RECORDER_STATE.STOPPED; } return this.isRecording; } - get isRecording(): boolean { - return this.state === RECORDER_STATE.STARTED; - } - - get state(): RECORDER_STATE { - return this._state; - } - - set state(state: RECORDER_STATE) { - this._state = state; - this.emit("stateChange", state); - } - /** * @param video whether we want to record videos or not (will always record audio) */ - _record(video: boolean = false) { + private async _start({ video = true }: { video?: boolean } = {}) { + this.state = RECORDER_STATE.STARTED; + this.folder = getFolder(); logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}, video: ${video}`); + this.ffmpeg = new FFMPEG(); // iterate all producers on all sessions of the channel, create a ffmpeg for each, // save them on a map by session id+type. // check if recording for that session id+type is already in progress // add listener to the channel for producer creation (and closure). } + + private async _stop({ save = false }: { save?: boolean } = {}) { + this.state = RECORDER_STATE.STOPPING; + // remove all listener from the channel + let failure = false; + try { + await this.ffmpeg?.kill(); + } catch (error) { + logger.error(`failed to kill ffmpeg: ${error}`); + failure = true; + } + this.ffmpeg = undefined; + if (save && !failure) { + await this.folder?.seal("test-name"); + } else { + await this.folder?.delete(); + } + this.folder = undefined; + this.state = RECORDER_STATE.STOPPED; + } } diff --git a/src/services/resources.ts b/src/services/resources.ts index 899d405..0521a7e 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -35,7 +35,9 @@ export async function start(): Promise { for (let i = config.dynamicPorts.min; i <= config.dynamicPorts.max; i++) { availablePorts.add(i); } - logger.info(`${availablePorts.size} dynamic ports available [${config.dynamicPorts.min}-${config.dynamicPorts.max}]`); + logger.info( + `${availablePorts.size} dynamic ports available [${config.dynamicPorts.min}-${config.dynamicPorts.max}]` + ); } export function close(): void { @@ -104,6 +106,9 @@ export class Folder { this.path = destinationPath; logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); } + async delete() { + logger.trace(`TO IMPLEMENT`); + } } export function getFolder(): Folder { From 688881fe7165862bed54086ab284b34e8e12c789 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 12 Nov 2025 15:05:20 +0100 Subject: [PATCH 15/73] [IMP] discuss: wip recording task --- src/client.ts | 4 +-- src/models/channel.ts | 3 +- src/models/recorder.ts | 58 ++++++++++++++++++++++++++---------- src/models/recording_task.ts | 37 +++++++++++++++++++++++ src/models/session.ts | 9 ++++-- src/shared/bus-types.ts | 21 ------------- src/shared/bus.ts | 9 ++++-- src/shared/types.ts | 22 +++++++++++++- tests/bus.test.ts | 3 +- 9 files changed, 120 insertions(+), 46 deletions(-) create mode 100644 src/models/recording_task.ts delete mode 100644 src/shared/bus-types.ts diff --git a/src/client.ts b/src/client.ts index 33a193a..eb29728 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,13 +19,13 @@ import { WS_CLOSE_CODE } from "#src/shared/enums.ts"; import type { + AvailableFeatures, JSONSerializable, StreamType, BusMessage, - AvailableFeatures, + RequestMessage, StartupData } from "#src/shared/types"; -import type { RequestMessage } from "#src/shared/bus-types"; import type { TransportConfig, SessionId, SessionInfo } from "#src/models/session"; interface Consumers { diff --git a/src/models/channel.ts b/src/models/channel.ts index 6ac481c..c92f9f0 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -327,7 +327,8 @@ export class Channel extends EventEmitter { { name: SERVER_MESSAGE.CHANNEL_INFO_CHANGE, payload: { - isRecording: Boolean(this.recorder?.isRecording) + isRecording: Boolean(this.recorder?.isRecording), + isTranscribing: false // TODO } }, { batch: true } diff --git a/src/models/recorder.ts b/src/models/recorder.ts index e9167cf..3aa72b5 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -1,9 +1,11 @@ import { EventEmitter } from "node:events"; + import { getFolder, type Folder } from "#src/services/resources.ts"; +import { RecordingTask } from "#src/models/recording_task.ts"; import { Logger } from "#src/utils/utils.ts"; -import type { Channel } from "./channel"; -import { FFMPEG } from "#src/models/ffmpeg.ts"; +import type { Channel } from "#src/models/channel"; +import type { SessionId } from "#src/models/session.ts"; export enum RECORDER_STATE { STARTED = "started", @@ -15,9 +17,11 @@ const logger = new Logger("RECORDER"); export class Recorder extends EventEmitter { channel: Channel; folder: Folder | undefined; - ffmpeg: FFMPEG | undefined; + tasks = new Map(); /** Path to which the final recording will be uploaded to */ recordingAddress: string; + isPlainRecording: boolean = false; + isTranscription: boolean = false; private _state: RECORDER_STATE = RECORDER_STATE.STOPPED; get isRecording(): boolean { @@ -38,6 +42,7 @@ export class Recorder extends EventEmitter { } async start() { + // TODO: for the transcription, we should play with isPlainRecording / isTranscription to see whether to stop or start or just disabled one of the features if (!this.isRecording) { try { await this._start(); @@ -59,18 +64,33 @@ export class Recorder extends EventEmitter { return this.isRecording; } - /** - * @param video whether we want to record videos or not (will always record audio) - */ - private async _start({ video = true }: { video?: boolean } = {}) { + private async _start() { this.state = RECORDER_STATE.STARTED; this.folder = getFolder(); - logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}, video: ${video}`); - this.ffmpeg = new FFMPEG(); - // iterate all producers on all sessions of the channel, create a ffmpeg for each, - // save them on a map by session id+type. - // check if recording for that session id+type is already in progress - // add listener to the channel for producer creation (and closure). + logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); + for (const [sessionId, session] of this.channel.sessions) { + this.tasks.set( + sessionId, + new RecordingTask(session, { audio: true, camera: true, screen: true }) + ); + } + this.channel.on("sessionJoin", (id) => { + const session = this.channel.sessions.get(id); + if (!session) { + return; + } + this.tasks.set( + session.id, + new RecordingTask(session, { audio: true, camera: true, screen: true }) + ); + }); + this.channel.on("sessionLeave", (id) => { + const task = this.tasks.get(id); + if (task) { + task.stop(); + this.tasks.delete(id); + } + }); } private async _stop({ save = false }: { save?: boolean } = {}) { @@ -78,12 +98,11 @@ export class Recorder extends EventEmitter { // remove all listener from the channel let failure = false; try { - await this.ffmpeg?.kill(); + await this.stopTasks(); } catch (error) { logger.error(`failed to kill ffmpeg: ${error}`); failure = true; } - this.ffmpeg = undefined; if (save && !failure) { await this.folder?.seal("test-name"); } else { @@ -92,4 +111,13 @@ export class Recorder extends EventEmitter { this.folder = undefined; this.state = RECORDER_STATE.STOPPED; } + + private async stopTasks() { + const proms = []; + for (const task of this.tasks.values()) { + proms.push(task.stop()); + } + this.tasks.clear(); + await Promise.allSettled(proms); + } } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts new file mode 100644 index 0000000..6ec3c84 --- /dev/null +++ b/src/models/recording_task.ts @@ -0,0 +1,37 @@ +import { Session } from "#src/models/session.ts"; +import { Logger } from "#src/utils/utils.ts"; + +const logger = new Logger("RECORDING_TASK"); + +export class RecordingTask { + private session: Session; + private _audio: boolean = false; + private _camera: boolean = false; + private _screen: boolean = false; + + // TODO when set, start/stop recording process + set audio(value: boolean) { + this._audio = value; + } + set camera(value: boolean) { + this._camera = value; + } + set screen(value: boolean) { + this._screen = value; + } + + constructor( + session: Session, + { audio, camera, screen }: { audio?: boolean; camera?: boolean; screen?: boolean } = {} + ) { + this.session = session; + this._audio = audio ?? false; + this._camera = camera ?? false; + this._screen = screen ?? false; + logger.trace( + `TO IMPLEMENT: recording task for session ${this.session.id} - audio: ${this._audio}, camera: ${this._camera}, screen: ${this._screen}` + ); + } + + async stop() {} +} diff --git a/src/models/session.ts b/src/models/session.ts index 432bc29..ee63482 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -20,8 +20,13 @@ import { SERVER_REQUEST, STREAM_TYPE } from "#src/shared/enums.ts"; -import type { BusMessage, JSONSerializable, StartupData, StreamType } from "#src/shared/types"; -import type { RequestMessage } from "#src/shared/bus-types"; +import type { + BusMessage, + JSONSerializable, + RequestMessage, + StartupData, + StreamType +} from "#src/shared/types"; import type { Bus } from "#src/shared/bus.ts"; import type { Channel } from "#src/models/channel.ts"; import { RECORDER_STATE } from "#src/models/recorder.ts"; diff --git a/src/shared/bus-types.ts b/src/shared/bus-types.ts deleted file mode 100644 index dc69233..0000000 --- a/src/shared/bus-types.ts +++ /dev/null @@ -1,21 +0,0 @@ -// eslint-disable-next-line node/no-unpublished-import -import type { RtpCapabilities } from "mediasoup-client/lib/types"; -import type { CLIENT_REQUEST, SERVER_REQUEST } from "./enums"; -import type { BusMessage } from "./types"; - -export interface RequestMap { - [CLIENT_REQUEST.CONNECT_CTS_TRANSPORT]: void; - [CLIENT_REQUEST.CONNECT_STC_TRANSPORT]: void; - [CLIENT_REQUEST.INIT_PRODUCER]: { id: string }; - [CLIENT_REQUEST.START_RECORDING]: boolean; - [CLIENT_REQUEST.STOP_RECORDING]: boolean; - [SERVER_REQUEST.INIT_CONSUMER]: void; - [SERVER_REQUEST.INIT_TRANSPORTS]: RtpCapabilities; - [SERVER_REQUEST.PING]: void; -} - -export type RequestName = keyof RequestMap; - -export type RequestMessage = Extract; - -export type ResponseFrom = RequestMap[T]; diff --git a/src/shared/bus.ts b/src/shared/bus.ts index 4edf54e..10ca2c6 100644 --- a/src/shared/bus.ts +++ b/src/shared/bus.ts @@ -1,7 +1,12 @@ import type { WebSocket as NodeWebSocket } from "ws"; -import type { JSONSerializable, BusMessage } from "./types"; -import type { RequestMessage, RequestName, ResponseFrom } from "./bus-types"; +import type { + JSONSerializable, + BusMessage, + RequestMessage, + RequestName, + ResponseFrom +} from "./types"; export interface Payload { /** The actual message content */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 0c653da..7e966a1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -66,7 +66,10 @@ export type BusMessage = } | { name: typeof SERVER_MESSAGE.SESSION_LEAVE; payload: { sessionId: SessionId } } | { name: typeof SERVER_MESSAGE.INFO_CHANGE; payload: Record } - | { name: typeof SERVER_MESSAGE.CHANNEL_INFO_CHANGE; payload: { isRecording: boolean } } + | { + name: typeof SERVER_MESSAGE.CHANNEL_INFO_CHANGE; + payload: { isRecording: boolean; isTranscribing: boolean }; + } | { name: typeof SERVER_REQUEST.INIT_CONSUMER; payload: { @@ -89,3 +92,20 @@ export type BusMessage = }; } | { name: typeof SERVER_REQUEST.PING; payload?: never }; + +export interface RequestMap { + [CLIENT_REQUEST.CONNECT_CTS_TRANSPORT]: void; + [CLIENT_REQUEST.CONNECT_STC_TRANSPORT]: void; + [CLIENT_REQUEST.INIT_PRODUCER]: { id: string }; + [CLIENT_REQUEST.START_RECORDING]: boolean; + [CLIENT_REQUEST.STOP_RECORDING]: boolean; + [SERVER_REQUEST.INIT_CONSUMER]: void; + [SERVER_REQUEST.INIT_TRANSPORTS]: RtpCapabilities; + [SERVER_REQUEST.PING]: void; +} + +export type RequestName = keyof RequestMap; + +export type RequestMessage = Extract; + +export type ResponseFrom = RequestMap[T]; diff --git a/tests/bus.test.ts b/tests/bus.test.ts index c0cc5a7..0acd0d6 100644 --- a/tests/bus.test.ts +++ b/tests/bus.test.ts @@ -3,8 +3,7 @@ import { EventEmitter } from "node:events"; import { expect, describe, jest } from "@jest/globals"; import { Bus } from "#src/shared/bus"; -import type { JSONSerializable, BusMessage } from "#src/shared/types"; -import { RequestMessage } from "#src/shared/bus-types.ts"; +import type { JSONSerializable, BusMessage, RequestMessage } from "#src/shared/types"; class MockTargetWebSocket extends EventTarget { send(message: JSONSerializable) { From 33a2ad07f07e0af2e2ebbaaade623b4248c9fe51 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 13 Nov 2025 11:44:24 +0100 Subject: [PATCH 16/73] [WIP] support transcription in recorder.ts --- src/client.ts | 5 +- src/models/channel.ts | 6 +- src/models/recorder.ts | 192 ++++++++++++++++++++++++----------- src/models/recording_task.ts | 25 ++++- src/models/session.ts | 4 +- src/shared/types.ts | 1 + 6 files changed, 163 insertions(+), 70 deletions(-) diff --git a/src/client.ts b/src/client.ts index eb29728..1d15daa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -155,6 +155,7 @@ export class SfuClient extends EventTarget { recording: false }; public isRecording: boolean = false; + public isTranscribing: boolean = false; /** Current client state */ private _state: SfuClientState = SfuClientState.DISCONNECTED; /** Communication bus */ @@ -484,11 +485,12 @@ export class SfuClient extends EventTarget { "message", (message) => { if (message.data) { - const { availableFeatures, isRecording } = JSON.parse( + const { availableFeatures, isRecording, isTranscribing } = JSON.parse( message.data ) as StartupData; this.availableFeatures = availableFeatures; this.isRecording = isRecording; + this.isTranscribing = isTranscribing; } resolve(new Bus(webSocket)); }, @@ -622,6 +624,7 @@ export class SfuClient extends EventTarget { break; case SERVER_MESSAGE.CHANNEL_INFO_CHANGE: this.isRecording = payload.isRecording; + this.isTranscribing = payload.isTranscribing; this._updateClient(CLIENT_UPDATE.CHANNEL_INFO_CHANGE, payload); break; } diff --git a/src/models/channel.ts b/src/models/channel.ts index c92f9f0..fde2cce 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -193,7 +193,7 @@ export class Channel extends EventEmitter { config.recording.enabled && options.recordingAddress ? new Recorder(this, options.recordingAddress) : undefined; - this.recorder?.on("stateChange", () => this._broadcastState()); + this.recorder?.on("update", () => this._broadcastState()); this.key = key ? Buffer.from(key, "base64") : undefined; this.uuid = crypto.randomUUID(); this.name = `${remoteAddress}*${this.uuid.slice(-5)}`; @@ -298,7 +298,7 @@ export class Channel extends EventEmitter { * @fires Channel#close */ close(): void { - this.recorder?.stop(); + this.recorder?.terminate(); for (const session of this.sessions.values()) { session.off("close", this._onSessionClose); session.close({ code: SESSION_CLOSE_CODE.CHANNEL_CLOSED }); @@ -328,7 +328,7 @@ export class Channel extends EventEmitter { name: SERVER_MESSAGE.CHANNEL_INFO_CHANGE, payload: { isRecording: Boolean(this.recorder?.isRecording), - isTranscribing: false // TODO + isTranscribing: Boolean(this.recorder?.isTranscribing) } }, { batch: true } diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 3aa72b5..8efa377 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -7,74 +7,56 @@ import { Logger } from "#src/utils/utils.ts"; import type { Channel } from "#src/models/channel"; import type { SessionId } from "#src/models/session.ts"; +enum TIME_TAG { + RECORDING_STARTED = "recording_started", + RECORDING_STOPPED = "recording_stopped", + TRANSCRIPTION_STARTED = "transcription_started", + TRANSCRIPTION_STOPPED = "transcription_stopped" +} export enum RECORDER_STATE { STARTED = "started", STOPPING = "stopping", STOPPED = "stopped" } +export type Metadata = { + uploadAddress: string; + timeStamps: object; +}; + const logger = new Logger("RECORDER"); export class Recorder extends EventEmitter { - channel: Channel; - folder: Folder | undefined; - tasks = new Map(); + /** + * Plain recording means that we mark the recording to be saved as a audio/video file + **/ + isRecording: boolean = false; + /** + * Transcribing means that we mark the audio for being transcribed later, + * this captures only the audio of the call. + **/ + isTranscribing: boolean = false; + private channel: Channel; + private folder: Folder | undefined; + private tasks = new Map(); /** Path to which the final recording will be uploaded to */ - recordingAddress: string; - isPlainRecording: boolean = false; - isTranscription: boolean = false; - private _state: RECORDER_STATE = RECORDER_STATE.STOPPED; + private metaData: Metadata = { + uploadAddress: "", + timeStamps: {} + }; + private state: RECORDER_STATE = RECORDER_STATE.STOPPED; - get isRecording(): boolean { + get isActive(): boolean { return this.state === RECORDER_STATE.STARTED; } - get state(): RECORDER_STATE { - return this._state; - } - set state(state: RECORDER_STATE) { - this._state = state; - this.emit("stateChange", state); - } constructor(channel: Channel, recordingAddress: string) { super(); this.channel = channel; - this.recordingAddress = recordingAddress; - } - - async start() { - // TODO: for the transcription, we should play with isPlainRecording / isTranscription to see whether to stop or start or just disabled one of the features - if (!this.isRecording) { - try { - await this._start(); - } catch { - await this._stop(); - } - } - return this.isRecording; - } - - async stop() { - if (this.isRecording) { - try { - await this._stop({ save: true }); - } catch { - logger.verbose("failed to save the recording"); // TODO maybe warn and give more info - } - } - return this.isRecording; - } - - private async _start() { - this.state = RECORDER_STATE.STARTED; - this.folder = getFolder(); - logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); - for (const [sessionId, session] of this.channel.sessions) { - this.tasks.set( - sessionId, - new RecordingTask(session, { audio: true, camera: true, screen: true }) - ); - } + this.metaData.uploadAddress = recordingAddress; this.channel.on("sessionJoin", (id) => { + if (!this.isActive) { + return; + } const session = this.channel.sessions.get(id); if (!session) { return; @@ -93,31 +75,117 @@ export class Recorder extends EventEmitter { }); } - private async _stop({ save = false }: { save?: boolean } = {}) { + async start() { + // TODO: for the transcription, we should play with isRecording / isTranscribing to see whether to stop or start or just disabled one of the features + if (!this.isRecording) { + this.isRecording = true; + await this.refreshConfiguration(); + this.mark(TIME_TAG.RECORDING_STARTED); + } + return this.isRecording; + } + + async stop() { + if (this.isRecording) { + this.isRecording = false; + await this.refreshConfiguration(); + this.mark(TIME_TAG.RECORDING_STOPPED); + } + return this.isRecording; + } + + async startTranscription() { + if (!this.isTranscribing) { + this.isTranscribing = true; + await this.refreshConfiguration(); + this.mark(TIME_TAG.TRANSCRIPTION_STARTED); + } + return this.isTranscribing; + } + + async stopTranscription() { + if (this.isTranscribing) { + this.isTranscribing = false; + await this.refreshConfiguration(); + this.mark(TIME_TAG.TRANSCRIPTION_STOPPED); + } + return this.isTranscribing; + } + + async terminate({ save = false }: { save?: boolean } = {}) { + if (!this.isActive) { + return; + } + this.isRecording = false; + this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; // remove all listener from the channel - let failure = false; - try { - await this.stopTasks(); - } catch (error) { - logger.error(`failed to kill ffmpeg: ${error}`); - failure = true; - } - if (save && !failure) { - await this.folder?.seal("test-name"); + // TODO name + const name = "test-folder-name"; + const results = await this.stopTasks(); + const hasFailure = results.some((r) => r.status === "rejected"); + if (save && !hasFailure) { + // TODO turn this.metadata to JSON, then add it as a file in the folder. + await this.folder?.seal(name); } else { + logger.error(`failed at generating recording: ${name}`); await this.folder?.delete(); } this.folder = undefined; + this.metaData.timeStamps = {}; this.state = RECORDER_STATE.STOPPED; } + private mark(tag: TIME_TAG) { + logger.trace(`TO IMPLEMENT: mark ${tag}`); + // TODO we basically add an entry to the timestamp object. + } + + private async refreshConfiguration() { + if (this.isRecording || this.isTranscribing) { + if (this.isActive) { + await this.update().catch(async () => { + logger.warn(`Failed to update recording or ${this.channel.name}`); + await this.terminate(); + }); + } else { + await this.init().catch(async () => { + logger.error(`Failed to start recording or ${this.channel.name}`); + await this.terminate(); + }); + } + } else { + await this.terminate(); + } + this.emit("update", { isRecording: this.isRecording, isTranscribing: this.isTranscribing }); + } + + private async update() { + for (const task of this.tasks.values()) { + task.audio = this.isRecording || this.isTranscribing; + task.camera = this.isRecording; + task.screen = this.isRecording; + } + } + + private async init() { + this.state = RECORDER_STATE.STARTED; + this.folder = getFolder(); + logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); + for (const [sessionId, session] of this.channel.sessions) { + this.tasks.set( + sessionId, + new RecordingTask(session, { audio: true, camera: true, screen: true }) + ); + } + } + private async stopTasks() { const proms = []; for (const task of this.tasks.values()) { proms.push(task.stop()); } this.tasks.clear(); - await Promise.allSettled(proms); + return Promise.allSettled(proms); } } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 6ec3c84..8a95326 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -4,19 +4,38 @@ import { Logger } from "#src/utils/utils.ts"; const logger = new Logger("RECORDING_TASK"); export class RecordingTask { + isStopped = false; private session: Session; private _audio: boolean = false; private _camera: boolean = false; private _screen: boolean = false; - // TODO when set, start/stop recording process + // TODO when set, start/stop recording process (create a RTP, create FFMPEG/Gstreamer process, pipe RTP to FFMPEG/Gstreamer) set audio(value: boolean) { + if (value === this._audio || this.isStopped) { + return; + } + logger.trace( + `TO IMPLEMENT: recording task for session ${this.session.id} - audio: ${value}` + ); this._audio = value; } set camera(value: boolean) { + if (value === this._camera || this.isStopped) { + return; + } + logger.trace( + `TO IMPLEMENT: recording task for session ${this.session.id} - camera: ${value}` + ); this._camera = value; } set screen(value: boolean) { + if (value === this._screen || this.isStopped) { + return; + } + logger.trace( + `TO IMPLEMENT: recording task for session ${this.session.id} - screen: ${value}` + ); this._screen = value; } @@ -33,5 +52,7 @@ export class RecordingTask { ); } - async stop() {} + async stop() { + this.isStopped = true; + } } diff --git a/src/models/session.ts b/src/models/session.ts index ee63482..fa3342d 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -29,7 +29,6 @@ import type { } from "#src/shared/types"; import type { Bus } from "#src/shared/bus.ts"; import type { Channel } from "#src/models/channel.ts"; -import { RECORDER_STATE } from "#src/models/recorder.ts"; export type SessionId = number | string; export type SessionInfo = { @@ -183,7 +182,8 @@ export class Session extends EventEmitter { this._channel.router && this._channel.recorder && this.permissions.recording ) }, - isRecording: this._channel.recorder?.state === RECORDER_STATE.STARTED + isRecording: this._channel.recorder?.isRecording || false, + isTranscribing: this._channel.recorder?.isTranscribing || false }; } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7e966a1..ca3f05a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,6 +13,7 @@ export type StringLike = Buffer | string; export type StartupData = { availableFeatures: AvailableFeatures; isRecording: boolean; + isTranscribing: boolean; }; export type AvailableFeatures = { rtc: boolean; From 5639213ce4f61097b8a93cba697475cdd974003b Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 13 Nov 2025 13:55:34 +0100 Subject: [PATCH 17/73] [wip] recording async management spec --- src/models/recorder.ts | 11 +++++++++-- src/models/recording_task.ts | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 8efa377..866315e 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -25,6 +25,13 @@ export type Metadata = { const logger = new Logger("RECORDER"); +/** + * TODO some docstring + * The recorder generates a "raw" file bundle, of recordings of individual audio and video streams, + * accompanied with a metadata file describing the recording (timestamps, ids,...). + * + * These raw recordings can then be used for further processing (transcription, compilation,...). + */ export class Recorder extends EventEmitter { /** * Plain recording means that we mark the recording to be saved as a audio/video file @@ -53,7 +60,7 @@ export class Recorder extends EventEmitter { super(); this.channel = channel; this.metaData.uploadAddress = recordingAddress; - this.channel.on("sessionJoin", (id) => { + this.channel.on("sessionJoin", (id: SessionId) => { if (!this.isActive) { return; } @@ -66,7 +73,7 @@ export class Recorder extends EventEmitter { new RecordingTask(session, { audio: true, camera: true, screen: true }) ); }); - this.channel.on("sessionLeave", (id) => { + this.channel.on("sessionLeave", (id: SessionId) => { const task = this.tasks.get(id); if (task) { task.stop(); diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 8a95326..e76d96e 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -4,6 +4,9 @@ import { Logger } from "#src/utils/utils.ts"; const logger = new Logger("RECORDING_TASK"); export class RecordingTask { + /** + * Whether or not the recording process has been stopped. Used as termination/cleanup condition for async processes + */ isStopped = false; private session: Session; private _audio: boolean = false; @@ -11,6 +14,8 @@ export class RecordingTask { private _screen: boolean = false; // TODO when set, start/stop recording process (create a RTP, create FFMPEG/Gstreamer process, pipe RTP to FFMPEG/Gstreamer) + // The initialization process will likely be async and prone to race conditions, once the process has started, we should + // remember to check if this.isStopped, and if so, stop the process. set audio(value: boolean) { if (value === this._audio || this.isStopped) { return; @@ -19,6 +24,8 @@ export class RecordingTask { `TO IMPLEMENT: recording task for session ${this.session.id} - audio: ${value}` ); this._audio = value; + // await record(audio) + // if (this.isStopped) { cleanup(audio) }; } set camera(value: boolean) { if (value === this._camera || this.isStopped) { From c2b6d65ff4603e2263c5a59664f5b0b5e55a2bef Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 14 Nov 2025 08:22:02 +0100 Subject: [PATCH 18/73] [wip] more transcript support --- src/client.ts | 3 ++- src/models/channel.ts | 10 ++++------ src/models/session.ts | 10 ++++++---- src/services/resources.ts | 9 +++------ src/shared/types.ts | 1 + 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/client.ts b/src/client.ts index 1d15daa..ae47362 100644 --- a/src/client.ts +++ b/src/client.ts @@ -152,7 +152,8 @@ export class SfuClient extends EventTarget { public errors: Error[] = []; public availableFeatures: AvailableFeatures = { rtc: false, - recording: false + recording: false, + transcription: false }; public isRecording: boolean = false; public isTranscribing: boolean = false; diff --git a/src/models/channel.ts b/src/models/channel.ts index fde2cce..5eb8ef9 100644 --- a/src/models/channel.ts +++ b/src/models/channel.ts @@ -189,18 +189,16 @@ export class Channel extends EventEmitter { const now = new Date(); this.createDate = now.toISOString(); this.remoteAddress = remoteAddress; + this._worker = worker; + this.router = router; this.recorder = - config.recording.enabled && options.recordingAddress + this.router && config.recording.enabled && options.recordingAddress ? new Recorder(this, options.recordingAddress) : undefined; this.recorder?.on("update", () => this._broadcastState()); this.key = key ? Buffer.from(key, "base64") : undefined; this.uuid = crypto.randomUUID(); this.name = `${remoteAddress}*${this.uuid.slice(-5)}`; - this.router = router; - this._worker = worker; - - // Bind event handlers this._onSessionClose = this._onSessionClose.bind(this); } @@ -210,7 +208,7 @@ export class Channel extends EventEmitter { uuid: this.uuid, remoteAddress: this.remoteAddress, sessionsStats: await this.getSessionsStats(), - webRtcEnabled: Boolean(this._worker) + webRtcEnabled: Boolean(this.router) }; } diff --git a/src/models/session.ts b/src/models/session.ts index fa3342d..5a3fff8 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -64,6 +64,7 @@ export enum SESSION_CLOSE_CODE { } export interface SessionPermissions { recording?: boolean; + transcription?: boolean; } export interface TransportConfig { /** Transport identifier */ @@ -146,7 +147,8 @@ export class Session extends EventEmitter { screen: null }; public readonly permissions: SessionPermissions = Object.seal({ - recording: false + recording: false, + transcription: false }); /** Parent channel containing this session */ private readonly _channel: Channel; @@ -178,10 +180,10 @@ export class Session extends EventEmitter { return { availableFeatures: { rtc: Boolean(this._channel.router), - recording: Boolean( - this._channel.router && this._channel.recorder && this.permissions.recording - ) + recording: Boolean(this._channel.recorder && this.permissions.recording), + transcription: Boolean(this._channel.recorder && this.permissions.transcription) }, + // TODO could be a channelState type isRecording: this._channel.recorder?.isRecording || false, isTranscribing: this._channel.recorder?.isTranscribing || false }; diff --git a/src/services/resources.ts b/src/services/resources.ts index 0521a7e..ef927bd 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -1,5 +1,4 @@ import * as mediasoup from "mediasoup"; -import type { WebRtcServerOptions } from "mediasoup/node/lib/types"; import * as config from "#src/config.ts"; import { Logger } from "#src/utils/utils.ts"; @@ -49,11 +48,9 @@ export function close(): void { } async function makeWorker(): Promise { - const worker = (await mediasoup.createWorker(config.rtc.workerSettings)) as RtcWorker; - worker.appData.webRtcServer = await worker.createWebRtcServer( - config.rtc.rtcServerOptions as WebRtcServerOptions - ); - workers.add(worker); + const worker = await mediasoup.createWorker(config.rtc.workerSettings); + worker.appData.webRtcServer = await worker.createWebRtcServer(config.rtc.rtcServerOptions); + workers.add(worker as RtcWorker); worker.once("died", (error: Error) => { logger.error(`worker died: ${error.message} ${error.stack ?? ""}`); workers.delete(worker); diff --git a/src/shared/types.ts b/src/shared/types.ts index ca3f05a..3db459a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -18,6 +18,7 @@ export type StartupData = { export type AvailableFeatures = { rtc: boolean; recording: boolean; + transcription: boolean; }; import type { DownloadStates } from "#src/client.ts"; From e46917765701fbb917730d588477dad0fa1bdb9b Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 18 Nov 2025 08:04:00 +0100 Subject: [PATCH 19/73] [wip] more recording task typing --- src/models/recorder.ts | 2 +- src/models/recording_task.ts | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 866315e..a297f49 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -43,7 +43,7 @@ export class Recorder extends EventEmitter { **/ isTranscribing: boolean = false; private channel: Channel; - private folder: Folder | undefined; + private folder?: Folder; private tasks = new Map(); /** Path to which the final recording will be uploaded to */ private metaData: Metadata = { diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index e76d96e..56abea6 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -1,9 +1,15 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { EventEmitter } from "node:events"; + import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; +import { FFMPEG } from "#src/models/ffmpeg.ts"; + +import type { PlainTransport } from "mediasoup/node/lib/PlainTransportTypes"; const logger = new Logger("RECORDING_TASK"); -export class RecordingTask { +export class RecordingTask extends EventEmitter { /** * Whether or not the recording process has been stopped. Used as termination/cleanup condition for async processes */ @@ -12,6 +18,12 @@ export class RecordingTask { private _audio: boolean = false; private _camera: boolean = false; private _screen: boolean = false; + private _audioRTP?: PlainTransport = undefined; + private _cameraRTP?: PlainTransport = undefined; + private _screenRTP?: PlainTransport = undefined; + private _audioFFFMPEG?: FFMPEG = undefined; + private _cameraFFMPEG?: FFMPEG = undefined; + private _screenFFMPEG?: FFMPEG = undefined; // TODO when set, start/stop recording process (create a RTP, create FFMPEG/Gstreamer process, pipe RTP to FFMPEG/Gstreamer) // The initialization process will likely be async and prone to race conditions, once the process has started, we should @@ -20,10 +32,11 @@ export class RecordingTask { if (value === this._audio || this.isStopped) { return; } + this._audio = value; logger.trace( `TO IMPLEMENT: recording task for session ${this.session.id} - audio: ${value}` ); - this._audio = value; + logger.debug(`rtp: ${this._audioRTP}, ffmpeg: ${this._audioFFFMPEG}`); // await record(audio) // if (this.isStopped) { cleanup(audio) }; } @@ -31,25 +44,28 @@ export class RecordingTask { if (value === this._camera || this.isStopped) { return; } + this._camera = value; logger.trace( `TO IMPLEMENT: recording task for session ${this.session.id} - camera: ${value}` ); - this._camera = value; + logger.debug(`rtp: ${this._cameraRTP}, ffmpeg: ${this._cameraFFMPEG}`); } set screen(value: boolean) { if (value === this._screen || this.isStopped) { return; } + this._screen = value; logger.trace( `TO IMPLEMENT: recording task for session ${this.session.id} - screen: ${value}` ); - this._screen = value; + logger.debug(`rtp: ${this._screenRTP}, ffmpeg: ${this._screenFFMPEG}`); } constructor( session: Session, { audio, camera, screen }: { audio?: boolean; camera?: boolean; screen?: boolean } = {} ) { + super(); this.session = session; this._audio = audio ?? false; this._camera = camera ?? false; From f759edaaa76209ddc2228ce96c823f106d9caf5f Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 18 Nov 2025 08:29:08 +0100 Subject: [PATCH 20/73] [wip] transcription front API --- src/client.ts | 28 +++++++++++++++++++++++++-- src/models/recorder.ts | 2 +- src/models/session.ts | 20 +++++++++++++++---- src/shared/enums.ts | 4 +++- src/shared/types.ts | 4 ++++ tests/network.test.ts | 44 +++++++++++++++++++++++++++++++++++++----- tests/utils/network.ts | 3 ++- 7 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/client.ts b/src/client.ts index ae47362..f2d48d6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -274,7 +274,7 @@ export class SfuClient extends EventTarget { } async startRecording(): Promise { if (this.state !== SfuClientState.CONNECTED) { - throw new Error("Cannot start recording when not connected"); + return false; } return this._bus!.request( { @@ -286,7 +286,7 @@ export class SfuClient extends EventTarget { async stopRecording(): Promise { if (this.state !== SfuClientState.CONNECTED) { - throw new Error("Cannot stop recording when not connected"); + return false; } return this._bus!.request( { @@ -296,6 +296,30 @@ export class SfuClient extends EventTarget { ); } + async startTranscription(): Promise { + if (this.state !== SfuClientState.CONNECTED) { + return false; + } + return this._bus!.request( + { + name: CLIENT_REQUEST.START_TRANSCRIPTION + }, + { batch: true } + ); + } + + async stopTranscription(): Promise { + if (this.state !== SfuClientState.CONNECTED) { + return false; + } + return this._bus!.request( + { + name: CLIENT_REQUEST.STOP_TRANSCRIPTION + }, + { batch: true } + ); + } + /** * Updates the server with the info of the session (isTalking, isCameraOn,...) so that it can broadcast it to the * other call participants. diff --git a/src/models/recorder.ts b/src/models/recorder.ts index a297f49..4170798 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -42,6 +42,7 @@ export class Recorder extends EventEmitter { * this captures only the audio of the call. **/ isTranscribing: boolean = false; + state: RECORDER_STATE = RECORDER_STATE.STOPPED; private channel: Channel; private folder?: Folder; private tasks = new Map(); @@ -50,7 +51,6 @@ export class Recorder extends EventEmitter { uploadAddress: "", timeStamps: {} }; - private state: RECORDER_STATE = RECORDER_STATE.STOPPED; get isActive(): boolean { return this.state === RECORDER_STATE.STARTED; diff --git a/src/models/session.ts b/src/models/session.ts index 5a3fff8..ce6083b 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -695,14 +695,26 @@ export class Session extends EventEmitter { return { id: producer.id }; } case CLIENT_REQUEST.START_RECORDING: { - if (this.permissions.recording && this._channel.recorder) { - return this._channel.recorder.start(); + if (this.permissions.recording) { + return this._channel.recorder?.start(); } return; } case CLIENT_REQUEST.STOP_RECORDING: { - if (this.permissions.recording && this._channel.recorder) { - return this._channel.recorder.stop(); + if (this.permissions.recording) { + return this._channel.recorder?.stop(); + } + return; + } + case CLIENT_REQUEST.START_TRANSCRIPTION: { + if (this.permissions.transcription) { + return this._channel.recorder?.startTranscription(); + } + return; + } + case CLIENT_REQUEST.STOP_TRANSCRIPTION: { + if (this.permissions.transcription) { + return this._channel.recorder?.stopTranscription(); } return; } diff --git a/src/shared/enums.ts b/src/shared/enums.ts index 3d9ea18..1edd9fc 100644 --- a/src/shared/enums.ts +++ b/src/shared/enums.ts @@ -39,7 +39,9 @@ export enum CLIENT_REQUEST { /** Requests to start recording of the call */ START_RECORDING = "START_RECORDING", /** Requests to stop recording of the call */ - STOP_RECORDING = "STOP_RECORDING" + STOP_RECORDING = "STOP_RECORDING", + START_TRANSCRIPTION = "START_TRANSCRIPTION", + STOP_TRANSCRIPTION = "STOP_TRANSCRIPTION" } export enum CLIENT_MESSAGE { diff --git a/src/shared/types.ts b/src/shared/types.ts index 3db459a..fd67eec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -62,6 +62,8 @@ export type BusMessage = } | { name: typeof CLIENT_REQUEST.START_RECORDING; payload?: never } | { name: typeof CLIENT_REQUEST.STOP_RECORDING; payload?: never } + | { name: typeof CLIENT_REQUEST.START_TRANSCRIPTION; payload?: never } + | { name: typeof CLIENT_REQUEST.STOP_TRANSCRIPTION; payload?: never } | { name: typeof SERVER_MESSAGE.BROADCAST; payload: { senderId: SessionId; message: JSONSerializable }; @@ -101,6 +103,8 @@ export interface RequestMap { [CLIENT_REQUEST.INIT_PRODUCER]: { id: string }; [CLIENT_REQUEST.START_RECORDING]: boolean; [CLIENT_REQUEST.STOP_RECORDING]: boolean; + [CLIENT_REQUEST.START_TRANSCRIPTION]: boolean; + [CLIENT_REQUEST.STOP_TRANSCRIPTION]: boolean; [SERVER_REQUEST.INIT_CONSUMER]: void; [SERVER_REQUEST.INIT_TRANSPORTS]: RtpCapabilities; [SERVER_REQUEST.PING]: void; diff --git a/tests/network.test.ts b/tests/network.test.ts index 69ad4c2..6fadde9 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -11,6 +11,7 @@ import { timeouts } from "#src/config"; import { LocalNetwork } from "#tests/utils/network"; import { delay } from "#tests/utils/utils.ts"; +import { RECORDER_STATE } from "#src/models/recorder.ts"; const HTTP_INTERFACE = "0.0.0.0"; const PORT = 61254; @@ -285,12 +286,45 @@ describe("Full network", () => { test("POC RECORDING", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); - const sender = await network.connect(channelUUID, 3); - await Promise.all([user1.isConnected, sender.isConnected]); - expect(sender.sfuClient.availableFeatures.recording).toBe(true); - const startResult = (await sender.sfuClient.startRecording()) as boolean; + const user2 = await network.connect(channelUUID, 3); + await Promise.all([user1.isConnected, user2.isConnected]); + expect(user2.sfuClient.availableFeatures.recording).toBe(true); + const startResult = (await user2.sfuClient.startRecording()) as boolean; + expect(startResult).toBe(true); + const stopResult = (await user2.sfuClient.stopRecording()) as boolean; + expect(stopResult).toBe(false); + }); + test("POC TRANSCRIPTION", async () => { + const channelUUID = await network.getChannelUUID(); + const user1 = await network.connect(channelUUID, 1); + const user2 = await network.connect(channelUUID, 3); + await Promise.all([user1.isConnected, user2.isConnected]); + expect(user2.sfuClient.availableFeatures.transcription).toBe(true); + const startResult = (await user2.sfuClient.startTranscription()) as boolean; expect(startResult).toBe(true); - const stopResult = (await sender.sfuClient.stopRecording()) as boolean; + const stopResult = (await user2.sfuClient.stopTranscription()) as boolean; expect(stopResult).toBe(false); }); + test("POC COMBINED TRANSCRIPTION/RECORDING", async () => { + const channelUUID = await network.getChannelUUID(); + const channel = Channel.records.get(channelUUID); + const user1 = await network.connect(channelUUID, 1); + const user2 = await network.connect(channelUUID, 3); + await Promise.all([user1.isConnected, user2.isConnected]); + await user2.sfuClient.startTranscription(); + await user1.sfuClient.startRecording(); + const recorder = channel!.recorder!; + expect(recorder.isRecording).toBe(true); + expect(recorder.isTranscribing).toBe(true); + expect(recorder.state).toBe(RECORDER_STATE.STARTED); + await user1.sfuClient.stopRecording(); + // stopping the recording while a transcription is active should not stop the transcription + expect(recorder.isRecording).toBe(false); + expect(recorder.isTranscribing).toBe(true); + expect(recorder.state).toBe(RECORDER_STATE.STARTED); + await user2.sfuClient.stopTranscription(); + expect(recorder.isRecording).toBe(false); + expect(recorder.isTranscribing).toBe(false); + expect(recorder.state).toBe(RECORDER_STATE.STOPPED); + }); }); diff --git a/tests/utils/network.ts b/tests/utils/network.ts index 591583c..8597d4b 100644 --- a/tests/utils/network.ts +++ b/tests/utils/network.ts @@ -190,7 +190,8 @@ export class LocalNetwork { sfu_channel_uuid: channelUUID, session_id: sessionId, permissions: { - recording: true + recording: true, + transcription: true } }, key From 01aebf547dcbf35109ba7fa7b8faf49c7aad313a Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 18 Nov 2025 10:33:10 +0100 Subject: [PATCH 21/73] [wip] update audit fix --- package-lock.json | 313 +++++----------------------------------------- 1 file changed, 31 insertions(+), 282 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89355c4..c1b87e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -641,95 +641,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -780,10 +691,11 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1260,15 +1172,6 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.7", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", @@ -1963,6 +1866,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -1971,6 +1875,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2275,7 +2180,8 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/brace-expansion": { "version": "1.1.12", @@ -2525,6 +2431,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2535,7 +2442,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2604,6 +2512,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2771,11 +2680,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -2813,7 +2717,8 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/error-ex": { "version": "1.3.2", @@ -3637,32 +3542,6 @@ "is-callable": "^1.1.3" } }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -4281,6 +4160,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } @@ -4493,7 +4373,8 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -4594,20 +4475,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -5310,9 +5177,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5708,71 +5575,15 @@ } }, "node_modules/minizlib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", - "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/minizlib/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/minizlib/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minizlib/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dependencies": { - "brace-expansion": "^2.0.1" + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minizlib/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 18" } }, "node_modules/mkdirp": { @@ -6049,11 +5860,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" - }, "node_modules/package-name-regex": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/package-name-regex/-/package-name-regex-2.0.6.tgz", @@ -6119,6 +5925,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -6129,26 +5936,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -6688,6 +6475,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6699,6 +6487,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -6897,20 +6686,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -6969,18 +6745,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7573,6 +7338,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7635,23 +7401,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", From bc88ddff97272e4fc524394be47153c05592409e Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 18 Nov 2025 11:16:14 +0100 Subject: [PATCH 22/73] [wip] no window reference in client --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index f2d48d6..0b3a5d3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -629,7 +629,7 @@ export class SfuClient extends EventTarget { // Retry connecting with an exponential backoff. this._connectRetryDelay = Math.min(this._connectRetryDelay * 1.5, MAXIMUM_RECONNECT_DELAY) + 1000 * Math.random(); - const timeout = window.setTimeout(() => this._connect(), this._connectRetryDelay); + const timeout = setTimeout(() => this._connect(), this._connectRetryDelay); this._onCleanup(() => clearTimeout(timeout)); } From b19b9d738711c10357b9fb34d9643a8c94bd4701 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 18 Nov 2025 11:29:36 +0100 Subject: [PATCH 23/73] [wip] f remove concurrent connections in tests, it can lead to race conditions in some tests environments, probably caused by a limitation when opening multiple ws connections at the same time. --- tests/network.test.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/network.test.ts b/tests/network.test.ts index 6fadde9..a1e5701 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -147,9 +147,11 @@ describe("Full network", () => { test("A client can forward a track to other clients", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; const user2 = await network.connect(channelUUID, 2); + await user2.isConnected; const sender = await network.connect(channelUUID, 3); - await Promise.all([user1.isConnected, user2.isConnected, sender.isConnected]); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); const prom1 = once(user1.sfuClient, "update"); @@ -165,8 +167,9 @@ describe("Full network", () => { test("Recovery attempts are made if the production fails, a failure does not close the connection", async () => { const channelUUID = await network.getChannelUUID(); const user = await network.connect(channelUUID, 1); + await user.isConnected; const sender = await network.connect(channelUUID, 3); - await Promise.all([user.isConnected, sender.isConnected]); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); // closing the transport so the `updateUpload` should fail. // @ts-expect-error accessing private property for testing purposes @@ -178,8 +181,9 @@ describe("Full network", () => { test("Recovery attempts are made if the consumption fails, a failure does not close the connection", async () => { const channelUUID = await network.getChannelUUID(); const user = await network.connect(channelUUID, 1); + await user.isConnected; const sender = await network.connect(channelUUID, 3); - await Promise.all([user.isConnected, sender.isConnected]); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); // closing the transport so the consumption should fail. // @ts-expect-error accessing private property for testing purposes @@ -193,8 +197,9 @@ describe("Full network", () => { test("The client can obtain download and upload statistics", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; const sender = await network.connect(channelUUID, 3); - await Promise.all([user1.isConnected, sender.isConnected]); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); await once(user1.sfuClient, "update"); @@ -207,8 +212,9 @@ describe("Full network", () => { test("The client can update the state of their downloads", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1234); + await user1.isConnected; const sender = await network.connect(channelUUID, 123); - await Promise.all([user1.isConnected, sender.isConnected]); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); await once(user1.sfuClient, "update"); @@ -228,8 +234,9 @@ describe("Full network", () => { test("The client can update the state of their upload", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1234); + await user1.isConnected; const sender = await network.connect(channelUUID, 123); - await Promise.all([user1.isConnected, sender.isConnected]); + await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "video" }); await sender.sfuClient.updateUpload(STREAM_TYPE.CAMERA, track); await once(user1.sfuClient, "update"); @@ -286,8 +293,9 @@ describe("Full network", () => { test("POC RECORDING", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; const user2 = await network.connect(channelUUID, 3); - await Promise.all([user1.isConnected, user2.isConnected]); + await user2.isConnected; expect(user2.sfuClient.availableFeatures.recording).toBe(true); const startResult = (await user2.sfuClient.startRecording()) as boolean; expect(startResult).toBe(true); @@ -297,8 +305,9 @@ describe("Full network", () => { test("POC TRANSCRIPTION", async () => { const channelUUID = await network.getChannelUUID(); const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; const user2 = await network.connect(channelUUID, 3); - await Promise.all([user1.isConnected, user2.isConnected]); + await user2.isConnected; expect(user2.sfuClient.availableFeatures.transcription).toBe(true); const startResult = (await user2.sfuClient.startTranscription()) as boolean; expect(startResult).toBe(true); @@ -309,8 +318,9 @@ describe("Full network", () => { const channelUUID = await network.getChannelUUID(); const channel = Channel.records.get(channelUUID); const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; const user2 = await network.connect(channelUUID, 3); - await Promise.all([user1.isConnected, user2.isConnected]); + await user2.isConnected; await user2.sfuClient.startTranscription(); await user1.sfuClient.startRecording(); const recorder = channel!.recorder!; From c202c675155d463ad23795bed3bb8f99ba0fd1d2 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 18 Nov 2025 14:27:17 +0100 Subject: [PATCH 24/73] [wip] session add/remove listeners on recorder --- src/models/recorder.ts | 47 ++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 4170798..f140768 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -60,26 +60,6 @@ export class Recorder extends EventEmitter { super(); this.channel = channel; this.metaData.uploadAddress = recordingAddress; - this.channel.on("sessionJoin", (id: SessionId) => { - if (!this.isActive) { - return; - } - const session = this.channel.sessions.get(id); - if (!session) { - return; - } - this.tasks.set( - session.id, - new RecordingTask(session, { audio: true, camera: true, screen: true }) - ); - }); - this.channel.on("sessionLeave", (id: SessionId) => { - const task = this.tasks.get(id); - if (task) { - task.stop(); - this.tasks.delete(id); - } - }); } async start() { @@ -123,6 +103,8 @@ export class Recorder extends EventEmitter { if (!this.isActive) { return; } + this.channel.off("sessionJoin", this.onSessionJoin); + this.channel.off("sessionLeave", this.onSessionLeave); this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; @@ -143,6 +125,29 @@ export class Recorder extends EventEmitter { this.state = RECORDER_STATE.STOPPED; } + private onSessionJoin(id: SessionId) { + const session = this.channel.sessions.get(id); + if (!session) { + return; + } + this.tasks.set( + session.id, + new RecordingTask(session, { + audio: this.isRecording || this.isTranscribing, + camera: this.isRecording, + screen: this.isRecording + }) + ); + } + + private onSessionLeave(id: SessionId) { + const task = this.tasks.get(id); + if (task) { + task.stop(); + this.tasks.delete(id); + } + } + private mark(tag: TIME_TAG) { logger.trace(`TO IMPLEMENT: mark ${tag}`); // TODO we basically add an entry to the timestamp object. @@ -185,6 +190,8 @@ export class Recorder extends EventEmitter { new RecordingTask(session, { audio: true, camera: true, screen: true }) ); } + this.channel.on("sessionJoin", this.onSessionJoin); + this.channel.on("sessionLeave", this.onSessionLeave); } private async stopTasks() { From 4040f1cbf4d52bf22bf54863c9654a09abba2dbb Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 18 Nov 2025 19:43:20 +0100 Subject: [PATCH 25/73] [wip] eslint for relative root dir --- .eslintrc.cjs | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++ .eslintrc.json | 82 ------------------------------------------ tsconfig.json | 16 ++++++--- 3 files changed, 109 insertions(+), 86 deletions(-) create mode 100644 .eslintrc.cjs delete mode 100644 .eslintrc.json diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..4ca0eee --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,97 @@ +module.exports = { + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:node/recommended", + "plugin:prettier/recommended" + ], + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + project: "./tsconfig.json", + tsconfigRootDir: __dirname + }, + env: { + node: true, + browser: false, + es2022: true + }, + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint"], + overrides: [ + { + files: [".eslintrc.cjs"], + parserOptions: { + project: null + } + }, + { + files: ["tests/utils/*.ts", "tests/*.ts"], + plugins: ["jest", "import"], + env: { + jest: true, + "jest/globals": true + }, + extends: ["plugin:jest/recommended"] + }, + { + files: ["src/client.ts"], + env: { + browser: true, + node: false + } + }, + { + files: ["src/shared/*.ts"], + env: { + browser: true, + node: true + } + } + ], + ignorePatterns: "dist/*", + rules: { + "prettier/prettier": [ + "error", + { + tabWidth: 4, + semi: true, + singleQuote: false, + printWidth: 100, + endOfLine: "auto", + trailingComma: "none" + } + ], + "node/no-unsupported-features/es-syntax": "off", + "node/no-missing-import": "off", + "comma-dangle": "off", + "no-console": "error", + "no-undef": "error", + "no-restricted-globals": ["error", "event", "self"], + "no-const-assign": ["error"], + "no-debugger": ["error"], + "no-dupe-class-members": ["error"], + "no-dupe-keys": ["error"], + "no-dupe-args": ["error"], + "no-dupe-else-if": ["error"], + "no-unsafe-negation": ["error"], + "no-duplicate-imports": ["error"], + "valid-typeof": ["error"], + "@typescript-eslint/no-unused-vars": [ + "error", + { vars: "all", args: "none", ignoreRestSiblings: false, caughtErrors: "all" } + ], + curly: ["error", "all"], + "no-restricted-syntax": ["error", "PrivateIdentifier"], + "prefer-const": [ + "error", + { + destructuring: "all", + ignoreReadBeforeAssign: true + } + ] + }, + globals: { + NodeJS: "readonly" + } +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index db98f56..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,82 +0,0 @@ -// Based on Odoo's .eslintrc.js -{ - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:node/recommended", - "plugin:prettier/recommended" - ], - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "env": { - "node": true, - "browser": false, - "es2022": true - }, - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], - "overrides": [ - { - "files": ["tests/utils/*.ts", "tests/*.ts"], - "plugins": ["jest", "import"], - "env": { - "jest": true, - "jest/globals": true - }, - "extends": ["plugin:jest/recommended"] - }, - { - "files": ["src/client.ts"], - "env": { - "browser": true, - "node": false - } - }, - { - "files": ["src/shared/*.ts"], - "env": { - "browser": true, - "node": true - } - } - ], - "ignorePatterns": "dist/*", - "rules": { - "prettier/prettier": ["error", { - "tabWidth": 4, - "semi": true, - "singleQuote": false, - "printWidth": 100, - "endOfLine": "auto", - "trailingComma": "none" - }], - "node/no-unsupported-features/es-syntax": "off", - "node/no-missing-import": "off", - "comma-dangle": "off", - "no-console": "error", - "no-undef": "error", - "no-restricted-globals": ["error", "event", "self"], - "no-const-assign": ["error"], - "no-debugger": ["error"], - "no-dupe-class-members": ["error"], - "no-dupe-keys": ["error"], - "no-dupe-args": ["error"], - "no-dupe-else-if": ["error"], - "no-unsafe-negation": ["error"], - "no-duplicate-imports": ["error"], - "valid-typeof": ["error"], - "@typescript-eslint/no-unused-vars": ["error", { "vars": "all", "args": "none", "ignoreRestSiblings": false, "caughtErrors": "all" }], - "curly": ["error", "all"], - "no-restricted-syntax": ["error", "PrivateIdentifier"], - "prefer-const": ["error", { - "destructuring": "all", - "ignoreReadBeforeAssign": true - }] - }, - "globals": { - "NodeJS": "readonly" - } -} diff --git a/tsconfig.json b/tsconfig.json index 43c116e..22e8df5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,13 +7,21 @@ "compilerOptions": { // Custom "target": "ES2022", - "lib": ["ES2022", "DOM"], + "lib": [ + "ES2022", + "DOM" + ], "module": "ESNext", "allowImportingTsExtensions": true, + "noEmit": true, "baseUrl": ".", "paths": { - "#src/*": ["./src/*"], - "#tests/*": ["./tests/*"] + "#src/*": [ + "./src/*" + ], + "#tests/*": [ + "./tests/*" + ] }, "strict": true, "forceConsistentCasingInFileNames": true, @@ -30,4 +38,4 @@ "resolveJsonModule": true, "preserveConstEnums": false, } -} +} \ No newline at end of file From f1fa76ecb2cd4d656c1b927fea439cbcaffa9b8d Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 19 Nov 2025 09:00:29 +0100 Subject: [PATCH 26/73] [wip] recorder/task Implement dynamic FFMPEG management for audio recording with explicit task events and standardized parameter handling. --- src/models/ffmpeg.ts | 4 ++++ src/models/recorder.ts | 30 +++++++++++++----------------- src/models/recording_task.ts | 30 ++++++++++++++++++++++++------ 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 167a43a..85bf2e6 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,8 +1,12 @@ import { EventEmitter } from "node:events"; +let currentId = 0; + export class FFMPEG extends EventEmitter { + id: number; constructor() { super(); + this.id = currentId++ % 999999; } async kill() {} diff --git a/src/models/recorder.ts b/src/models/recorder.ts index f140768..c72c91b 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { getFolder, type Folder } from "#src/services/resources.ts"; -import { RecordingTask } from "#src/models/recording_task.ts"; +import { RecordingTask, type RecordingParameters } from "#src/models/recording_task.ts"; import { Logger } from "#src/utils/utils.ts"; import type { Channel } from "#src/models/channel"; @@ -108,7 +108,6 @@ export class Recorder extends EventEmitter { this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; - // remove all listener from the channel // TODO name const name = "test-folder-name"; const results = await this.stopTasks(); @@ -130,14 +129,7 @@ export class Recorder extends EventEmitter { if (!session) { return; } - this.tasks.set( - session.id, - new RecordingTask(session, { - audio: this.isRecording || this.isTranscribing, - camera: this.isRecording, - screen: this.isRecording - }) - ); + this.tasks.set(session.id, new RecordingTask(session, this.getTaskParameters())); } private onSessionLeave(id: SessionId) { @@ -173,10 +165,9 @@ export class Recorder extends EventEmitter { } private async update() { + const params = this.getTaskParameters(); for (const task of this.tasks.values()) { - task.audio = this.isRecording || this.isTranscribing; - task.camera = this.isRecording; - task.screen = this.isRecording; + Object.assign(task, params); } } @@ -185,10 +176,7 @@ export class Recorder extends EventEmitter { this.folder = getFolder(); logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); for (const [sessionId, session] of this.channel.sessions) { - this.tasks.set( - sessionId, - new RecordingTask(session, { audio: true, camera: true, screen: true }) - ); + this.tasks.set(sessionId, new RecordingTask(session, this.getTaskParameters())); } this.channel.on("sessionJoin", this.onSessionJoin); this.channel.on("sessionLeave", this.onSessionLeave); @@ -202,4 +190,12 @@ export class Recorder extends EventEmitter { this.tasks.clear(); return Promise.allSettled(proms); } + + private getTaskParameters(): RecordingParameters { + return { + audio: this.isRecording || this.isTranscribing, + camera: this.isRecording, + screen: this.isRecording + }; + } } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 56abea6..1c15295 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -7,6 +7,21 @@ import { FFMPEG } from "#src/models/ffmpeg.ts"; import type { PlainTransport } from "mediasoup/node/lib/PlainTransportTypes"; +export type RecordingParameters = { + audio: boolean; + camera: boolean; + screen: boolean; +}; + +export enum RECORDING_TASK_EVENT { + AUDIO_STARTED = "audio-started", + AUDIO_STOPPED = "audio-stopped", + CAMERA_STARTED = "camera-started", + CAMERA_STOPPED = "camera-stopped", + SCREEN_STARTED = "screen-started", + SCREEN_STOPPED = "screen-stopped" +} + const logger = new Logger("RECORDING_TASK"); export class RecordingTask extends EventEmitter { @@ -37,8 +52,14 @@ export class RecordingTask extends EventEmitter { `TO IMPLEMENT: recording task for session ${this.session.id} - audio: ${value}` ); logger.debug(`rtp: ${this._audioRTP}, ffmpeg: ${this._audioFFFMPEG}`); - // await record(audio) - // if (this.isStopped) { cleanup(audio) }; + if (this._audio) { + this._audioFFFMPEG = new FFMPEG(); // should take RTP info as param + this.emit(RECORDING_TASK_EVENT.AUDIO_STARTED, this._audioFFFMPEG.id); + } else if (this._audioFFFMPEG) { + this.emit(RECORDING_TASK_EVENT.AUDIO_STOPPED, this._audioFFFMPEG.id); + this._audioFFFMPEG.kill(); + this._audioFFFMPEG = undefined; + } } set camera(value: boolean) { if (value === this._camera || this.isStopped) { @@ -61,10 +82,7 @@ export class RecordingTask extends EventEmitter { logger.debug(`rtp: ${this._screenRTP}, ffmpeg: ${this._screenFFMPEG}`); } - constructor( - session: Session, - { audio, camera, screen }: { audio?: boolean; camera?: boolean; screen?: boolean } = {} - ) { + constructor(session: Session, { audio, camera, screen }: RecordingParameters) { super(); this.session = session; this._audio = audio ?? false; From 656e2a1a8060b1a640e102ed66c9150db4a441db Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 20 Nov 2025 10:10:12 +0100 Subject: [PATCH 27/73] [wip] jest config file lint The `jest.config.cjs` file was causing ESLint parsing errors because it was not included in the TypeScript project configuration. It is now explicitly added to the `overrides` section in `.eslintrc.cjs` with `parserOptions: { project: null }` to bypass this requirement, ensuring linting passes correctly for configuration files. --- .eslintrc.cjs | 2 +- jest.config.cjs | 2 +- src/models/ffmpeg.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 4ca0eee..ef290c2 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -20,7 +20,7 @@ module.exports = { plugins: ["@typescript-eslint"], overrides: [ { - files: [".eslintrc.cjs"], + files: [".eslintrc.cjs", "jest.config.cjs"], parserOptions: { project: null } diff --git a/jest.config.cjs b/jest.config.cjs index 8246c99..0c9c1e1 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -6,5 +6,5 @@ module.exports = { maxWorkers: 4, preset: "ts-jest", testEnvironment: "node", - extensionsToTreatAsEsm: [".ts"], + extensionsToTreatAsEsm: [".ts"] }; diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 85bf2e6..306c073 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -6,7 +6,7 @@ export class FFMPEG extends EventEmitter { id: number; constructor() { super(); - this.id = currentId++ % 999999; + this.id = currentId++; } async kill() {} From 8e27ff17dd9d0fcdbb54ac7d4f02948e9e6bbf28 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 21 Nov 2025 09:28:26 +0100 Subject: [PATCH 28/73] [wip] fixup --- src/models/ffmpeg.ts | 3 ++- src/models/recorder.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 306c073..3027a24 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,9 +1,10 @@ +/* eslint-disable prettier/prettier */ import { EventEmitter } from "node:events"; let currentId = 0; export class FFMPEG extends EventEmitter { - id: number; + readonly id: number; constructor() { super(); this.id = currentId++; diff --git a/src/models/recorder.ts b/src/models/recorder.ts index c72c91b..ef759e1 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -43,11 +43,11 @@ export class Recorder extends EventEmitter { **/ isTranscribing: boolean = false; state: RECORDER_STATE = RECORDER_STATE.STOPPED; - private channel: Channel; private folder?: Folder; - private tasks = new Map(); + private readonly channel: Channel; + private readonly tasks = new Map(); /** Path to which the final recording will be uploaded to */ - private metaData: Metadata = { + private readonly metaData: Metadata = { uploadAddress: "", timeStamps: {} }; From 4616aaf204624349f80565e2deb1275e96a8d415 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 21 Nov 2025 10:32:30 +0100 Subject: [PATCH 29/73] [wip] private style --- src/models/recorder.ts | 52 ++++++++++++++++++------------------ src/models/recording_task.ts | 22 ++++++++------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index ef759e1..f059416 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -66,8 +66,8 @@ export class Recorder extends EventEmitter { // TODO: for the transcription, we should play with isRecording / isTranscribing to see whether to stop or start or just disabled one of the features if (!this.isRecording) { this.isRecording = true; - await this.refreshConfiguration(); - this.mark(TIME_TAG.RECORDING_STARTED); + await this._refreshConfiguration(); + this._mark(TIME_TAG.RECORDING_STARTED); } return this.isRecording; } @@ -75,8 +75,8 @@ export class Recorder extends EventEmitter { async stop() { if (this.isRecording) { this.isRecording = false; - await this.refreshConfiguration(); - this.mark(TIME_TAG.RECORDING_STOPPED); + await this._refreshConfiguration(); + this._mark(TIME_TAG.RECORDING_STOPPED); } return this.isRecording; } @@ -84,8 +84,8 @@ export class Recorder extends EventEmitter { async startTranscription() { if (!this.isTranscribing) { this.isTranscribing = true; - await this.refreshConfiguration(); - this.mark(TIME_TAG.TRANSCRIPTION_STARTED); + await this._refreshConfiguration(); + this._mark(TIME_TAG.TRANSCRIPTION_STARTED); } return this.isTranscribing; } @@ -93,8 +93,8 @@ export class Recorder extends EventEmitter { async stopTranscription() { if (this.isTranscribing) { this.isTranscribing = false; - await this.refreshConfiguration(); - this.mark(TIME_TAG.TRANSCRIPTION_STOPPED); + await this._refreshConfiguration(); + this._mark(TIME_TAG.TRANSCRIPTION_STOPPED); } return this.isTranscribing; } @@ -103,14 +103,14 @@ export class Recorder extends EventEmitter { if (!this.isActive) { return; } - this.channel.off("sessionJoin", this.onSessionJoin); - this.channel.off("sessionLeave", this.onSessionLeave); + this.channel.off("sessionJoin", this._onSessionJoin); + this.channel.off("sessionLeave", this._onSessionLeave); this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; // TODO name const name = "test-folder-name"; - const results = await this.stopTasks(); + const results = await this._stopTasks(); const hasFailure = results.some((r) => r.status === "rejected"); if (save && !hasFailure) { // TODO turn this.metadata to JSON, then add it as a file in the folder. @@ -124,15 +124,15 @@ export class Recorder extends EventEmitter { this.state = RECORDER_STATE.STOPPED; } - private onSessionJoin(id: SessionId) { + private _onSessionJoin(id: SessionId) { const session = this.channel.sessions.get(id); if (!session) { return; } - this.tasks.set(session.id, new RecordingTask(session, this.getTaskParameters())); + this.tasks.set(session.id, new RecordingTask(session, this._getTaskParameters())); } - private onSessionLeave(id: SessionId) { + private _onSessionLeave(id: SessionId) { const task = this.tasks.get(id); if (task) { task.stop(); @@ -140,20 +140,20 @@ export class Recorder extends EventEmitter { } } - private mark(tag: TIME_TAG) { + private _mark(tag: TIME_TAG) { logger.trace(`TO IMPLEMENT: mark ${tag}`); // TODO we basically add an entry to the timestamp object. } - private async refreshConfiguration() { + private async _refreshConfiguration() { if (this.isRecording || this.isTranscribing) { if (this.isActive) { - await this.update().catch(async () => { + await this._update().catch(async () => { logger.warn(`Failed to update recording or ${this.channel.name}`); await this.terminate(); }); } else { - await this.init().catch(async () => { + await this._init().catch(async () => { logger.error(`Failed to start recording or ${this.channel.name}`); await this.terminate(); }); @@ -164,25 +164,25 @@ export class Recorder extends EventEmitter { this.emit("update", { isRecording: this.isRecording, isTranscribing: this.isTranscribing }); } - private async update() { - const params = this.getTaskParameters(); + private async _update() { + const params = this._getTaskParameters(); for (const task of this.tasks.values()) { Object.assign(task, params); } } - private async init() { + private async _init() { this.state = RECORDER_STATE.STARTED; this.folder = getFolder(); logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); for (const [sessionId, session] of this.channel.sessions) { - this.tasks.set(sessionId, new RecordingTask(session, this.getTaskParameters())); + this.tasks.set(sessionId, new RecordingTask(session, this._getTaskParameters())); } - this.channel.on("sessionJoin", this.onSessionJoin); - this.channel.on("sessionLeave", this.onSessionLeave); + this.channel.on("sessionJoin", this._onSessionJoin); + this.channel.on("sessionLeave", this._onSessionLeave); } - private async stopTasks() { + private async _stopTasks() { const proms = []; for (const task of this.tasks.values()) { proms.push(task.stop()); @@ -191,7 +191,7 @@ export class Recorder extends EventEmitter { return Promise.allSettled(proms); } - private getTaskParameters(): RecordingParameters { + private _getTaskParameters(): RecordingParameters { return { audio: this.isRecording || this.isTranscribing, camera: this.isRecording, diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 1c15295..1f636aa 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -26,10 +26,10 @@ const logger = new Logger("RECORDING_TASK"); export class RecordingTask extends EventEmitter { /** - * Whether or not the recording process has been stopped. Used as termination/cleanup condition for async processes + * Whether or not the recording process has been stopped. Used as ok termination/cleanup condition for async processes */ isStopped = false; - private session: Session; + private _session: Session; private _audio: boolean = false; private _camera: boolean = false; private _screen: boolean = false; @@ -40,16 +40,18 @@ export class RecordingTask extends EventEmitter { private _cameraFFMPEG?: FFMPEG = undefined; private _screenFFMPEG?: FFMPEG = undefined; - // TODO when set, start/stop recording process (create a RTP, create FFMPEG/Gstreamer process, pipe RTP to FFMPEG/Gstreamer) - // The initialization process will likely be async and prone to race conditions, once the process has started, we should - // remember to check if this.isStopped, and if so, stop the process. + /** + * TODO when set, start/stop recording process (create a RTP, create FFMPEG/Gstreamer process, pipe RTP to FFMPEG/Gstreamer) + * The initialization process will likely be async and prone to race conditions, once the process has started, we should + * remember to check if this.isStopped, and if so, stop the process. + */ set audio(value: boolean) { if (value === this._audio || this.isStopped) { return; } this._audio = value; logger.trace( - `TO IMPLEMENT: recording task for session ${this.session.id} - audio: ${value}` + `TO IMPLEMENT: recording task for session ${this._session.id} - audio: ${value}` ); logger.debug(`rtp: ${this._audioRTP}, ffmpeg: ${this._audioFFFMPEG}`); if (this._audio) { @@ -67,7 +69,7 @@ export class RecordingTask extends EventEmitter { } this._camera = value; logger.trace( - `TO IMPLEMENT: recording task for session ${this.session.id} - camera: ${value}` + `TO IMPLEMENT: recording task for session ${this._session.id} - camera: ${value}` ); logger.debug(`rtp: ${this._cameraRTP}, ffmpeg: ${this._cameraFFMPEG}`); } @@ -77,19 +79,19 @@ export class RecordingTask extends EventEmitter { } this._screen = value; logger.trace( - `TO IMPLEMENT: recording task for session ${this.session.id} - screen: ${value}` + `TO IMPLEMENT: recording task for session ${this._session.id} - screen: ${value}` ); logger.debug(`rtp: ${this._screenRTP}, ffmpeg: ${this._screenFFMPEG}`); } constructor(session: Session, { audio, camera, screen }: RecordingParameters) { super(); - this.session = session; + this._session = session; this._audio = audio ?? false; this._camera = camera ?? false; this._screen = screen ?? false; logger.trace( - `TO IMPLEMENT: recording task for session ${this.session.id} - audio: ${this._audio}, camera: ${this._camera}, screen: ${this._screen}` + `TO IMPLEMENT: recording task for session ${this._session.id} - audio: ${this._audio}, camera: ${this._camera}, screen: ${this._screen}` ); } From fb3b524ee791829622fbb8ab4afe4ccf776d0eea Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 21 Nov 2025 13:29:45 +0100 Subject: [PATCH 30/73] [wip] recording_task/transport lifecycle --- src/config.ts | 9 +- src/models/ffmpeg.ts | 21 +++- src/models/recorder.ts | 4 + src/models/recording_task.ts | 211 ++++++++++++++++++++++++----------- src/models/session.ts | 4 + src/services/resources.ts | 2 +- 6 files changed, 183 insertions(+), 68 deletions(-) diff --git a/src/config.ts b/src/config.ts index 7376b1b..0a9b6bf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,8 @@ import os from "node:os"; import type { RtpCodecCapability, WorkerSettings, - WebRtcServerOptions + WebRtcServerOptions, + PlainTransportOptions } from "mediasoup/node/lib/types"; // eslint-disable-next-line node/no-unpublished-import import type { ProducerOptions } from "mediasoup-client/lib/Producer"; @@ -240,6 +241,7 @@ const baseProducerOptions: ProducerOptions = { export interface RtcConfig { readonly workerSettings: WorkerSettings; readonly rtcServerOptions: WebRtcServerOptions; + readonly plainTransportOptions: PlainTransportOptions; readonly rtcTransportOptions: { readonly maxSctpMessageSize: number; readonly sctpSendBufferSize: number; @@ -283,6 +285,11 @@ export const rtc: RtcConfig = Object.freeze({ maxSctpMessageSize: MAX_BUF_IN, sctpSendBufferSize: MAX_BUF_OUT }, + plainTransportOptions: { + listenIp: { ip: "0.0.0.0", announcedIp: PUBLIC_IP }, + rtcpMux: true, + comedia: false + }, producerOptionsByKind: { /** Audio producer options */ audio: baseProducerOptions, diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 3027a24..b916878 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,14 +1,31 @@ /* eslint-disable prettier/prettier */ import { EventEmitter } from "node:events"; +import { Logger } from "#src/utils/utils"; +import type { STREAM_TYPE } from "#src/shared/enums"; + +const logger = new Logger("FFMPEG"); + +// TODO may need to give more or less stuff here, will know later. +export type RtpData = { + payloadType: number; + clockRate: number; + codec: string; + channels: number | undefined; + type: STREAM_TYPE; +}; let currentId = 0; export class FFMPEG extends EventEmitter { readonly id: number; - constructor() { + private readonly rtp: RtpData; + constructor(rtp: RtpData) { super(); + this.rtp = rtp; this.id = currentId++; + logger.trace(`creating FFMPEG for ${this.id} on ${this.rtp.type}`); } - async kill() {} + async kill() { + } } diff --git a/src/models/recorder.ts b/src/models/recorder.ts index f059416..3b793ec 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -31,6 +31,10 @@ const logger = new Logger("RECORDER"); * accompanied with a metadata file describing the recording (timestamps, ids,...). * * These raw recordings can then be used for further processing (transcription, compilation,...). + * + * Recorder acts at the channel level, managing the creation and closure of sessions in that channel, + * whereas the recording_task acts at the session level, managing the recording of an individual session + * and following its producer lifecycle. */ export class Recorder extends EventEmitter { /** diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 1f636aa..ee9908a 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -1,11 +1,15 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { EventEmitter } from "node:events"; +import type { Producer, Consumer, PlainTransport } from "mediasoup/node/lib/types"; + import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; -import { FFMPEG } from "#src/models/ffmpeg.ts"; +import { FFMPEG, type RtpData } from "#src/models/ffmpeg.ts"; +import { rtc } from "#src/config"; +import { getPort, type DynamicPort } from "#src/services/resources"; -import type { PlainTransport } from "mediasoup/node/lib/PlainTransportTypes"; +import { STREAM_TYPE } from "#src/shared/enums"; export type RecordingParameters = { audio: boolean; @@ -14,88 +18,167 @@ export type RecordingParameters = { }; export enum RECORDING_TASK_EVENT { - AUDIO_STARTED = "audio-started", - AUDIO_STOPPED = "audio-stopped", - CAMERA_STARTED = "camera-started", - CAMERA_STOPPED = "camera-stopped", - SCREEN_STARTED = "screen-started", - SCREEN_STOPPED = "screen-stopped" + UPDATE = "update" } +type RecordingData = { + active: boolean; // active is different from boolean(ffmpeg) so we can flag synchronously and avoid race conditions + transport?: PlainTransport; + consumer?: Consumer; + ffmpeg?: FFMPEG; + port?: DynamicPort; + type: STREAM_TYPE; +}; + +type RecordingDataByStreamType = { + [STREAM_TYPE.AUDIO]: RecordingData; + [STREAM_TYPE.CAMERA]: RecordingData; + [STREAM_TYPE.SCREEN]: RecordingData; +}; + const logger = new Logger("RECORDING_TASK"); export class RecordingTask extends EventEmitter { - /** - * Whether or not the recording process has been stopped. Used as ok termination/cleanup condition for async processes - */ - isStopped = false; private _session: Session; - private _audio: boolean = false; - private _camera: boolean = false; - private _screen: boolean = false; - private _audioRTP?: PlainTransport = undefined; - private _cameraRTP?: PlainTransport = undefined; - private _screenRTP?: PlainTransport = undefined; - private _audioFFFMPEG?: FFMPEG = undefined; - private _cameraFFMPEG?: FFMPEG = undefined; - private _screenFFMPEG?: FFMPEG = undefined; - - /** - * TODO when set, start/stop recording process (create a RTP, create FFMPEG/Gstreamer process, pipe RTP to FFMPEG/Gstreamer) - * The initialization process will likely be async and prone to race conditions, once the process has started, we should - * remember to check if this.isStopped, and if so, stop the process. - */ + private readonly recordingDataByStreamType: RecordingDataByStreamType = { + [STREAM_TYPE.AUDIO]: { + active: false, + type: STREAM_TYPE.AUDIO + }, + [STREAM_TYPE.CAMERA]: { + active: false, + type: STREAM_TYPE.CAMERA + }, + [STREAM_TYPE.SCREEN]: { + active: false, + type: STREAM_TYPE.SCREEN + } + }; + set audio(value: boolean) { - if (value === this._audio || this.isStopped) { + this._setRecording(STREAM_TYPE.AUDIO, value); + } + set camera(value: boolean) { + this._setRecording(STREAM_TYPE.CAMERA, value); + } + set screen(value: boolean) { + this._setRecording(STREAM_TYPE.SCREEN, value); + } + + constructor(session: Session, { audio, camera, screen }: RecordingParameters) { + super(); + this._session = session; + this._session.on("producer", this._onSessionProducer); + this.audio = audio; + this.camera = camera; + this.screen = screen; + } + + private async _setRecording(type: STREAM_TYPE, state: boolean) { + const data = this.recordingDataByStreamType[type]; + if (data.active === state) { return; } - this._audio = value; - logger.trace( - `TO IMPLEMENT: recording task for session ${this._session.id} - audio: ${value}` - ); - logger.debug(`rtp: ${this._audioRTP}, ffmpeg: ${this._audioFFFMPEG}`); - if (this._audio) { - this._audioFFFMPEG = new FFMPEG(); // should take RTP info as param - this.emit(RECORDING_TASK_EVENT.AUDIO_STARTED, this._audioFFFMPEG.id); - } else if (this._audioFFFMPEG) { - this.emit(RECORDING_TASK_EVENT.AUDIO_STOPPED, this._audioFFFMPEG.id); - this._audioFFFMPEG.kill(); - this._audioFFFMPEG = undefined; + data.active = state; + const producer = this._session.producers[type]; + if (!producer) { + return; // will be handled later when the session starts producing } + this._updateProcess(data, producer); } - set camera(value: boolean) { - if (value === this._camera || this.isStopped) { + + private async _onSessionProducer({ + type, + producer + }: { + type: STREAM_TYPE; + producer: Producer; + }) { + const data = this.recordingDataByStreamType[type]; + if (!data.active) { return; } - this._camera = value; - logger.trace( - `TO IMPLEMENT: recording task for session ${this._session.id} - camera: ${value}` - ); - logger.debug(`rtp: ${this._cameraRTP}, ffmpeg: ${this._cameraFFMPEG}`); + this._clearData(type); // in case we already had a process for an outdated producer + this._updateProcess(data, producer); } - set screen(value: boolean) { - if (value === this._screen || this.isStopped) { - return; + + private async _updateProcess(data: RecordingData, producer: Producer) { + if (data.active) { + if (data.ffmpeg) { + return; + } + data.port = getPort(); + try { + data.ffmpeg = new FFMPEG(await this._createRtp(producer, data)); + if (data.active) { + if (data.ffmpeg) { + // TODO emit starting + } + logger.verbose( + `starting recording process for ${this._session.name} ${data.type}` + ); + return; + } + return; + } catch { + logger.warn( + `failed at starting the recording for ${this._session.name} ${data.type}` + ); + } } - this._screen = value; - logger.trace( - `TO IMPLEMENT: recording task for session ${this._session.id} - screen: ${value}` - ); - logger.debug(`rtp: ${this._screenRTP}, ffmpeg: ${this._screenFFMPEG}`); + // TODO emit ending + this._clearData(data.type); } - constructor(session: Session, { audio, camera, screen }: RecordingParameters) { - super(); - this._session = session; - this._audio = audio ?? false; - this._camera = camera ?? false; - this._screen = screen ?? false; - logger.trace( - `TO IMPLEMENT: recording task for session ${this._session.id} - audio: ${this._audio}, camera: ${this._camera}, screen: ${this._screen}` + async _createRtp(producer: Producer, data: RecordingData): Promise { + const transport = await this._session.router?.createPlainTransport( + rtc.plainTransportOptions ); + data.transport = transport; + if (!transport) { + throw new Error(`Failed at creating a plain transport for`); + } + transport.connect({ + ip: "0.0.0.0", + port: data.port!.number + }); + data.consumer = await transport.consume({ + producerId: producer.id, + rtpCapabilities: this._session.router!.rtpCapabilities, + paused: true + }); + // TODO may want to use producer.getStats() to get the codec info + // for val of producer.getStats().values() { if val.type === "codec": val.minetype, val.clockRate,... } + //const codecData = this._channel.router.rtpCapabilities.codecs.find( + // (codec) => codec.kind === producer.kind + //); + const codecData = producer.rtpParameters.codecs[0]; + return { + payloadType: codecData.payloadType, + clockRate: codecData.clockRate, + codec: codecData.mimeType.replace(`${producer.kind}`, ""), + channels: producer.kind === "audio" ? codecData.channels : undefined, + type: data.type + }; + } + + private _clearData(type: STREAM_TYPE) { + const data = this.recordingDataByStreamType[type]; + data.active = false; + data.ffmpeg?.kill(); + data.ffmpeg = undefined; + data.transport?.close(); + data.transport = undefined; + data.consumer?.close(); + data.consumer = undefined; + data.port?.release(); + data.port = undefined; } async stop() { - this.isStopped = true; + this._session.off("producer", this._onSessionProducer); + for (const type of Object.values(STREAM_TYPE)) { + this._clearData(type); + } } } diff --git a/src/models/session.ts b/src/models/session.ts index ce6083b..2d9a6e1 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -197,6 +197,10 @@ export class Session extends EventEmitter { return this._state; } + get router() { + return this._channel.router; + } + set state(state: SESSION_STATE) { this._state = state; /** diff --git a/src/services/resources.ts b/src/services/resources.ts index ef927bd..2491af4 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -112,7 +112,7 @@ export function getFolder(): Folder { return new Folder(`${tempDir}/${Date.now()}-${unique++}`); } -class DynamicPort { +export class DynamicPort { number: number; constructor(number: number) { From 081d53a1cd73042d7fd35c667e776834c9c35276 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 21 Nov 2025 15:10:12 +0100 Subject: [PATCH 31/73] [wip] some fixes --- README.md | 1 + src/models/ffmpeg.ts | 4 ++-- src/models/recorder.ts | 8 ++++++-- src/models/recording_task.ts | 7 ++++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0729781..da1a7a9 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The available environment variables are: - **MAX_BITRATE_OUT**: if set, limits the outgoing bitrate per session (user), defaults to 10mbps - **MAX_VIDEO_BITRATE**: if set, defines the `maxBitrate` of the highest encoding layer (simulcast), defaults to 4mbps - **CHANNEL_SIZE**: the maximum amount of users per channel, defaults to 100 +- **RECORDING**: enables the recording feature, defaults to false - **WORKER_LOG_LEVEL**: "none" | "error" | "warn" | "debug", will only work if `DEBUG` is properly set. - **LOG_LEVEL**: "none" | "error" | "warn" | "info" | "debug" | "verbose" - **LOG_TIMESTAMP**: adds a timestamp to the log lines, defaults to true, to disable it, set to "disable", "false", "none", "no" or "0" diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index b916878..9c1c715 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,7 +1,7 @@ /* eslint-disable prettier/prettier */ import { EventEmitter } from "node:events"; -import { Logger } from "#src/utils/utils"; -import type { STREAM_TYPE } from "#src/shared/enums"; +import { Logger } from "#src/utils/utils.ts"; +import type { STREAM_TYPE } from "#src/shared/enums.ts"; const logger = new Logger("FFMPEG"); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 3b793ec..6eef15d 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -62,6 +62,8 @@ export class Recorder extends EventEmitter { constructor(channel: Channel, recordingAddress: string) { super(); + this._onSessionJoin = this._onSessionJoin.bind(this); + this._onSessionLeave = this._onSessionLeave.bind(this); this.channel = channel; this.metaData.uploadAddress = recordingAddress; } @@ -116,11 +118,13 @@ export class Recorder extends EventEmitter { const name = "test-folder-name"; const results = await this._stopTasks(); const hasFailure = results.some((r) => r.status === "rejected"); + if (hasFailure) { + logger.warn("recording failed at saving files"); // TODO more info + } if (save && !hasFailure) { // TODO turn this.metadata to JSON, then add it as a file in the folder. await this.folder?.seal(name); } else { - logger.error(`failed at generating recording: ${name}`); await this.folder?.delete(); } this.folder = undefined; @@ -163,7 +167,7 @@ export class Recorder extends EventEmitter { }); } } else { - await this.terminate(); + await this.terminate({ save: true }); // todo check if we always want to save here } this.emit("update", { isRecording: this.isRecording, isTranscribing: this.isTranscribing }); } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index ee9908a..81010d1 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -6,10 +6,10 @@ import type { Producer, Consumer, PlainTransport } from "mediasoup/node/lib/type import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; import { FFMPEG, type RtpData } from "#src/models/ffmpeg.ts"; -import { rtc } from "#src/config"; -import { getPort, type DynamicPort } from "#src/services/resources"; +import { rtc } from "#src/config.ts"; +import { getPort, type DynamicPort } from "#src/services/resources.ts"; -import { STREAM_TYPE } from "#src/shared/enums"; +import { STREAM_TYPE } from "#src/shared/enums.ts"; export type RecordingParameters = { audio: boolean; @@ -67,6 +67,7 @@ export class RecordingTask extends EventEmitter { constructor(session: Session, { audio, camera, screen }: RecordingParameters) { super(); + this._onSessionProducer = this._onSessionProducer.bind(this); this._session = session; this._session.on("producer", this._onSessionProducer); this.audio = audio; From 8e2c1a381ca5d51c096d49eba1fe136960752117 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Sat, 22 Nov 2025 09:24:17 +0100 Subject: [PATCH 32/73] [wip] rtp abstraction --- src/models/ffmpeg.ts | 34 ++++++++------ src/models/recording_task.ts | 79 ++++++++------------------------ src/models/rtp.ts | 88 ++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 72 deletions(-) create mode 100644 src/models/rtp.ts diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 9c1c715..6c5020f 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,31 +1,39 @@ /* eslint-disable prettier/prettier */ import { EventEmitter } from "node:events"; import { Logger } from "#src/utils/utils.ts"; -import type { STREAM_TYPE } from "#src/shared/enums.ts"; +import { RTP } from "#src/models/rtp.ts"; const logger = new Logger("FFMPEG"); -// TODO may need to give more or less stuff here, will know later. -export type RtpData = { - payloadType: number; - clockRate: number; - codec: string; - channels: number | undefined; - type: STREAM_TYPE; -}; - let currentId = 0; export class FFMPEG extends EventEmitter { readonly id: number; - private readonly rtp: RtpData; - constructor(rtp: RtpData) { + private readonly rtp: RTP; + private _isClosed = false; + constructor(rtp: RTP) { super(); this.rtp = rtp; this.id = currentId++; logger.trace(`creating FFMPEG for ${this.id} on ${this.rtp.type}`); + this._init(); + } + + close() { + this._isClosed = true; + this._cleanup(); + } + + private async _init() { + await this.rtp.isReady; + if (this._isClosed) { + this._cleanup(); + return; + } + logger.trace(`FFMPEG ${this.id} is ready for ${this.rtp.type}`); } - async kill() { + private _cleanup() { + logger.trace(`FFMPEG ${this.id} closed for ${this.rtp.type}`); } } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 81010d1..1e3cae8 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -1,13 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { EventEmitter } from "node:events"; -import type { Producer, Consumer, PlainTransport } from "mediasoup/node/lib/types"; +import { RTP } from "#src/models/rtp.ts"; +import { Producer } from "mediasoup/node/lib/types"; import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; -import { FFMPEG, type RtpData } from "#src/models/ffmpeg.ts"; -import { rtc } from "#src/config.ts"; -import { getPort, type DynamicPort } from "#src/services/resources.ts"; +import { FFMPEG } from "#src/models/ffmpeg.ts"; import { STREAM_TYPE } from "#src/shared/enums.ts"; @@ -23,11 +22,9 @@ export enum RECORDING_TASK_EVENT { type RecordingData = { active: boolean; // active is different from boolean(ffmpeg) so we can flag synchronously and avoid race conditions - transport?: PlainTransport; - consumer?: Consumer; - ffmpeg?: FFMPEG; - port?: DynamicPort; type: STREAM_TYPE; + rtp?: RTP; + ffmpeg?: FFMPEG; }; type RecordingDataByStreamType = { @@ -85,7 +82,7 @@ export class RecordingTask extends EventEmitter { if (!producer) { return; // will be handled later when the session starts producing } - this._updateProcess(data, producer); + this._updateProcess(data, producer, type); } private async _onSessionProducer({ @@ -99,28 +96,23 @@ export class RecordingTask extends EventEmitter { if (!data.active) { return; } - this._clearData(type); // in case we already had a process for an outdated producer - this._updateProcess(data, producer); + this._updateProcess(data, producer, type); } - private async _updateProcess(data: RecordingData, producer: Producer) { + private async _updateProcess(data: RecordingData, producer: Producer, type: STREAM_TYPE) { if (data.active) { if (data.ffmpeg) { return; } - data.port = getPort(); try { - data.ffmpeg = new FFMPEG(await this._createRtp(producer, data)); + data.rtp = data.rtp || new RTP({ producer, router: this._session.router!, type }); + data.ffmpeg = new FFMPEG(data.rtp); if (data.active) { - if (data.ffmpeg) { - // TODO emit starting - } logger.verbose( `starting recording process for ${this._session.name} ${data.type}` ); return; } - return; } catch { logger.warn( `failed at starting the recording for ${this._session.name} ${data.type}` @@ -128,52 +120,21 @@ export class RecordingTask extends EventEmitter { } } // TODO emit ending - this._clearData(data.type); + this._clearData(data.type, { preserveRTP: true }); } - async _createRtp(producer: Producer, data: RecordingData): Promise { - const transport = await this._session.router?.createPlainTransport( - rtc.plainTransportOptions - ); - data.transport = transport; - if (!transport) { - throw new Error(`Failed at creating a plain transport for`); - } - transport.connect({ - ip: "0.0.0.0", - port: data.port!.number - }); - data.consumer = await transport.consume({ - producerId: producer.id, - rtpCapabilities: this._session.router!.rtpCapabilities, - paused: true - }); - // TODO may want to use producer.getStats() to get the codec info - // for val of producer.getStats().values() { if val.type === "codec": val.minetype, val.clockRate,... } - //const codecData = this._channel.router.rtpCapabilities.codecs.find( - // (codec) => codec.kind === producer.kind - //); - const codecData = producer.rtpParameters.codecs[0]; - return { - payloadType: codecData.payloadType, - clockRate: codecData.clockRate, - codec: codecData.mimeType.replace(`${producer.kind}`, ""), - channels: producer.kind === "audio" ? codecData.channels : undefined, - type: data.type - }; - } - - private _clearData(type: STREAM_TYPE) { + private _clearData( + type: STREAM_TYPE, + { preserveRTP }: { preserveRTP?: boolean } = { preserveRTP: false } + ) { const data = this.recordingDataByStreamType[type]; data.active = false; - data.ffmpeg?.kill(); + if (!preserveRTP) { + data.rtp?.close(); + data.rtp = undefined; + } + data.ffmpeg?.close(); data.ffmpeg = undefined; - data.transport?.close(); - data.transport = undefined; - data.consumer?.close(); - data.consumer = undefined; - data.port?.release(); - data.port = undefined; } async stop() { diff --git a/src/models/rtp.ts b/src/models/rtp.ts new file mode 100644 index 0000000..47555e4 --- /dev/null +++ b/src/models/rtp.ts @@ -0,0 +1,88 @@ +import type { Router, Producer, Consumer, PlainTransport } from "mediasoup/node/lib/types"; +import { getPort, type DynamicPort } from "#src/services/resources.ts"; +import { rtc } from "#src/config.ts"; +import { Deferred } from "#src/utils/utils.ts"; +import { STREAM_TYPE } from "#src/shared/enums.ts"; + +export class RTP { + isReady = new Deferred(); + payloadType?: number; + clockRate?: number; + codec?: string; + channels?: number; + type: STREAM_TYPE; + private _router: Router; + private _producer: Producer; + private _transport?: PlainTransport; + private _consumer?: Consumer; + private _port?: DynamicPort; + private _isClosed = false; + + get port() { + return this._port?.number; + } + + constructor({ + producer, + router, + type + }: { + producer: Producer; + router: Router; + type: STREAM_TYPE; + }) { + this._router = router; + this._producer = producer; + this.type = type; + this._init(); + } + + close() { + this._isClosed = true; + this._cleanup(); + } + + private async _init() { + try { + this._port = getPort(); + this._transport = await this._router?.createPlainTransport(rtc.plainTransportOptions); + if (!this._transport) { + throw new Error(`Failed at creating a plain transport for`); + } + this._transport.connect({ + ip: "0.0.0.0", + port: this._port.number + }); + this._consumer = await this._transport.consume({ + producerId: this._producer.id, + rtpCapabilities: this._router!.rtpCapabilities, + paused: true + }); + if (this._isClosed) { + // may be closed by the time the consume is created + this._cleanup(); + return; + } + // TODO may want to use producer.getStats() to get the codec info + // for val of producer.getStats().values() { if val.type === "codec": val.minetype, val.clockRate,... } + //const codecData = this._channel.router.rtpCapabilities.codecs.find( + // (codec) => codec.kind === producer.kind + //); + const codecData = this._producer.rtpParameters.codecs[0]; + this.payloadType = codecData.payloadType; + this.clockRate = codecData.clockRate; + this.codec = codecData.mimeType.replace(`${this._producer.kind}`, ""); + this.channels = this._producer.kind === "audio" ? codecData.channels : undefined; + this.isReady.resolve(); + } catch { + this.close(); + this.isReady.reject(new Error(`Failed at creating a plain transport for ${this.type}`)); + } + } + + private _cleanup() { + this._consumer?.close(); + this._transport?.close(); + this._port?.release(); + } +} From ad5e01cdb3b7a88e3e5f4ae8b28452e97e92e135 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Sat, 22 Nov 2025 09:40:48 +0100 Subject: [PATCH 33/73] [wip] folder creation --- src/models/recorder.ts | 2 +- src/services/resources.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 6eef15d..a393ef7 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -181,7 +181,7 @@ export class Recorder extends EventEmitter { private async _init() { this.state = RECORDER_STATE.STARTED; - this.folder = getFolder(); + this.folder = await getFolder(); logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); for (const [sessionId, session] of this.channel.sessions) { this.tasks.set(sessionId, new RecordingTask(session, this._getTaskParameters())); diff --git a/src/services/resources.ts b/src/services/resources.ts index 2491af4..a10ac2a 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -93,6 +93,11 @@ export async function getWorker(): Promise { export class Folder { path: string; + static async create(path: string) { + await fs.mkdir(path, { recursive: true }); + return new Folder(path); + } + constructor(path: string) { this.path = path; } @@ -108,10 +113,9 @@ export class Folder { } } -export function getFolder(): Folder { - return new Folder(`${tempDir}/${Date.now()}-${unique++}`); +export async function getFolder(): Promise { + return Folder.create(`${tempDir}/${Date.now()}-${unique++}`); } - export class DynamicPort { number: number; From e18dc9444667b1754447320b37dc93f724d372f8 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Sat, 22 Nov 2025 11:41:31 +0100 Subject: [PATCH 34/73] [wip] folder seal error handling --- src/services/resources.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/services/resources.ts b/src/services/resources.ts index a10ac2a..a9a2d2b 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -104,12 +104,21 @@ export class Folder { async seal(name: string) { const destinationPath = path.join(config.recording.directory, name); - await fs.rename(this.path, destinationPath); - this.path = destinationPath; - logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); + try { + await fs.rename(this.path, destinationPath); + this.path = destinationPath; + logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); + } catch (error) { + logger.error(`Failed to move folder from ${this.path} to ${destinationPath}: ${error}`); + } } async delete() { - logger.trace(`TO IMPLEMENT`); + try { + await fs.rm(this.path, { recursive: true }); + logger.verbose(`Deleted folder ${this.path}`); + } catch (error) { + logger.error(`Failed to delete folder ${this.path}: ${error}`); + } } } From eb92f0583dd0600066b656bb5503a3a3e3173eb1 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Mon, 24 Nov 2025 14:15:33 +0100 Subject: [PATCH 35/73] [wip] timetags --- src/models/recorder.ts | 27 +++++++++++++++------------ src/services/resources.ts | 33 ++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index a393ef7..b896573 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -1,5 +1,7 @@ import { EventEmitter } from "node:events"; +import path from "node:path"; +import { recording } from "#src/config.ts"; import { getFolder, type Folder } from "#src/services/resources.ts"; import { RecordingTask, type RecordingParameters } from "#src/models/recording_task.ts"; import { Logger } from "#src/utils/utils.ts"; @@ -20,7 +22,7 @@ export enum RECORDER_STATE { } export type Metadata = { uploadAddress: string; - timeStamps: object; + timeStamps: Record>; }; const logger = new Logger("RECORDER"); @@ -48,7 +50,7 @@ export class Recorder extends EventEmitter { isTranscribing: boolean = false; state: RECORDER_STATE = RECORDER_STATE.STOPPED; private folder?: Folder; - private readonly channel: Channel; + private readonly channel: Channel; // TODO rename with private prefix private readonly tasks = new Map(); /** Path to which the final recording will be uploaded to */ private readonly metaData: Metadata = { @@ -72,8 +74,8 @@ export class Recorder extends EventEmitter { // TODO: for the transcription, we should play with isRecording / isTranscribing to see whether to stop or start or just disabled one of the features if (!this.isRecording) { this.isRecording = true; - await this._refreshConfiguration(); this._mark(TIME_TAG.RECORDING_STARTED); + await this._refreshConfiguration(); } return this.isRecording; } @@ -81,8 +83,8 @@ export class Recorder extends EventEmitter { async stop() { if (this.isRecording) { this.isRecording = false; - await this._refreshConfiguration(); this._mark(TIME_TAG.RECORDING_STOPPED); + await this._refreshConfiguration(); } return this.isRecording; } @@ -90,8 +92,8 @@ export class Recorder extends EventEmitter { async startTranscription() { if (!this.isTranscribing) { this.isTranscribing = true; - await this._refreshConfiguration(); this._mark(TIME_TAG.TRANSCRIPTION_STARTED); + await this._refreshConfiguration(); } return this.isTranscribing; } @@ -99,8 +101,8 @@ export class Recorder extends EventEmitter { async stopTranscription() { if (this.isTranscribing) { this.isTranscribing = false; - await this._refreshConfiguration(); this._mark(TIME_TAG.TRANSCRIPTION_STOPPED); + await this._refreshConfiguration(); } return this.isTranscribing; } @@ -114,16 +116,16 @@ export class Recorder extends EventEmitter { this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; - // TODO name - const name = "test-folder-name"; const results = await this._stopTasks(); const hasFailure = results.some((r) => r.status === "rejected"); if (hasFailure) { logger.warn("recording failed at saving files"); // TODO more info } if (save && !hasFailure) { - // TODO turn this.metadata to JSON, then add it as a file in the folder. - await this.folder?.seal(name); + await this.folder?.add("metadata.json", JSON.stringify(this.metaData)); + await this.folder?.seal( + path.join(recording.directory, `${this.channel.name}_${Date.now()}`) + ); } else { await this.folder?.delete(); } @@ -149,8 +151,9 @@ export class Recorder extends EventEmitter { } private _mark(tag: TIME_TAG) { - logger.trace(`TO IMPLEMENT: mark ${tag}`); - // TODO we basically add an entry to the timestamp object. + const events = this.metaData.timeStamps[Date.now()] || []; + events.push(tag); + this.metaData.timeStamps[Date.now()] = events; } private async _refreshConfiguration() { diff --git a/src/services/resources.ts b/src/services/resources.ts index a9a2d2b..da5af49 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -10,6 +10,7 @@ import path from "node:path"; const availablePorts: Set = new Set(); let unique = 1; +// TODO instead of RtcWorker, try Worker export interface RtcWorker extends mediasoup.types.Worker { appData: { webRtcServer?: mediasoup.types.WebRtcServer; @@ -20,10 +21,14 @@ export interface RtcWorker extends mediasoup.types.Worker { const logger = new Logger("RESOURCES"); const workers = new Set(); -const tempDir = os.tmpdir() + "/ongoing_recordings"; +const directory = os.tmpdir() + "/open_sfu_resources"; export async function start(): Promise { logger.info("starting..."); + // any existing folders are deleted since they are unreachable + await fs.rm(directory, { recursive: true }).catch((error) => { + logger.verbose(`Nothing to remove at ${directory}: ${error}`); + }); for (let i = 0; i < config.NUM_WORKERS; ++i) { await makeWorker(); } @@ -44,6 +49,12 @@ export function close(): void { worker.appData.webRtcServer?.close(); worker.close(); } + for (const dir of Folder.usedDirs) { + fs.rm(dir, { recursive: true }).catch((error) => { + logger.error(`Failed to delete folder ${dir}: ${error}`); + }); + } + Folder.usedDirs.clear(); workers.clear(); } @@ -91,21 +102,29 @@ export async function getWorker(): Promise { } export class Folder { + static usedDirs: Set = new Set(); path: string; - static async create(path: string) { - await fs.mkdir(path, { recursive: true }); - return new Folder(path); + static async create(name: string) { + const p: string = path.join(directory, name); + await fs.mkdir(p, { recursive: true }); + return new Folder(p); } constructor(path: string) { this.path = path; + Folder.usedDirs.add(path); + } + + async add(name: string, content: string) { + await fs.writeFile(path.join(this.path, name), content); } - async seal(name: string) { - const destinationPath = path.join(config.recording.directory, name); + async seal(path: string) { + const destinationPath = path; try { await fs.rename(this.path, destinationPath); + Folder.usedDirs.delete(this.path); this.path = destinationPath; logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); } catch (error) { @@ -123,7 +142,7 @@ export class Folder { } export async function getFolder(): Promise { - return Folder.create(`${tempDir}/${Date.now()}-${unique++}`); + return Folder.create(`${Date.now()}-${unique++}`); } export class DynamicPort { number: number; From 8e07bfb9e1c8b26b813f6233df62ced8cb2ed328 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 25 Nov 2025 08:56:43 +0100 Subject: [PATCH 36/73] [wip] --- src/models/ffmpeg.ts | 6 ++++-- src/models/recorder.ts | 38 ++++++++++++++++++------------------ src/models/recording_task.ts | 2 +- src/models/rtp.ts | 21 +++++++++++--------- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 6c5020f..bb4f1c1 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -15,12 +15,13 @@ export class FFMPEG extends EventEmitter { super(); this.rtp = rtp; this.id = currentId++; - logger.trace(`creating FFMPEG for ${this.id} on ${this.rtp.type}`); + logger.verbose(`creating FFMPEG for ${this.id} on ${this.rtp.type}`); this._init(); } close() { this._isClosed = true; + this.emit("close", this.id); // maybe different event if fail/saved properly this._cleanup(); } @@ -30,7 +31,8 @@ export class FFMPEG extends EventEmitter { this._cleanup(); return; } - logger.trace(`FFMPEG ${this.id} is ready for ${this.rtp.type}`); + logger.trace(`To implement: FFMPEG start process ${this.id} for ${this.rtp.type}`); + // build FFMPEG params with rtp properties, start the process } private _cleanup() { diff --git a/src/models/recorder.ts b/src/models/recorder.ts index b896573..5d82fa4 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -49,11 +49,11 @@ export class Recorder extends EventEmitter { **/ isTranscribing: boolean = false; state: RECORDER_STATE = RECORDER_STATE.STOPPED; - private folder?: Folder; + private _folder?: Folder; private readonly channel: Channel; // TODO rename with private prefix - private readonly tasks = new Map(); + private readonly _tasks = new Map(); /** Path to which the final recording will be uploaded to */ - private readonly metaData: Metadata = { + private readonly _metaData: Metadata = { uploadAddress: "", timeStamps: {} }; @@ -67,7 +67,7 @@ export class Recorder extends EventEmitter { this._onSessionJoin = this._onSessionJoin.bind(this); this._onSessionLeave = this._onSessionLeave.bind(this); this.channel = channel; - this.metaData.uploadAddress = recordingAddress; + this._metaData.uploadAddress = recordingAddress; } async start() { @@ -122,15 +122,15 @@ export class Recorder extends EventEmitter { logger.warn("recording failed at saving files"); // TODO more info } if (save && !hasFailure) { - await this.folder?.add("metadata.json", JSON.stringify(this.metaData)); - await this.folder?.seal( + await this._folder?.add("metadata.json", JSON.stringify(this._metaData)); + await this._folder?.seal( path.join(recording.directory, `${this.channel.name}_${Date.now()}`) ); } else { - await this.folder?.delete(); + await this._folder?.delete(); } - this.folder = undefined; - this.metaData.timeStamps = {}; + this._folder = undefined; + this._metaData.timeStamps = {}; this.state = RECORDER_STATE.STOPPED; } @@ -139,21 +139,21 @@ export class Recorder extends EventEmitter { if (!session) { return; } - this.tasks.set(session.id, new RecordingTask(session, this._getTaskParameters())); + this._tasks.set(session.id, new RecordingTask(session, this._getTaskParameters())); } private _onSessionLeave(id: SessionId) { - const task = this.tasks.get(id); + const task = this._tasks.get(id); if (task) { task.stop(); - this.tasks.delete(id); + this._tasks.delete(id); } } private _mark(tag: TIME_TAG) { - const events = this.metaData.timeStamps[Date.now()] || []; + const events = this._metaData.timeStamps[Date.now()] || []; events.push(tag); - this.metaData.timeStamps[Date.now()] = events; + this._metaData.timeStamps[Date.now()] = events; } private async _refreshConfiguration() { @@ -177,17 +177,17 @@ export class Recorder extends EventEmitter { private async _update() { const params = this._getTaskParameters(); - for (const task of this.tasks.values()) { + for (const task of this._tasks.values()) { Object.assign(task, params); } } private async _init() { this.state = RECORDER_STATE.STARTED; - this.folder = await getFolder(); + this._folder = await getFolder(); logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); for (const [sessionId, session] of this.channel.sessions) { - this.tasks.set(sessionId, new RecordingTask(session, this._getTaskParameters())); + this._tasks.set(sessionId, new RecordingTask(session, this._getTaskParameters())); } this.channel.on("sessionJoin", this._onSessionJoin); this.channel.on("sessionLeave", this._onSessionLeave); @@ -195,10 +195,10 @@ export class Recorder extends EventEmitter { private async _stopTasks() { const proms = []; - for (const task of this.tasks.values()) { + for (const task of this._tasks.values()) { proms.push(task.stop()); } - this.tasks.clear(); + this._tasks.clear(); return Promise.allSettled(proms); } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 1e3cae8..134e763 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { RTP } from "#src/models/rtp.ts"; -import { Producer } from "mediasoup/node/lib/types"; +import type { Producer } from "mediasoup/node/lib/types"; import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; diff --git a/src/models/rtp.ts b/src/models/rtp.ts index 47555e4..205e78e 100644 --- a/src/models/rtp.ts +++ b/src/models/rtp.ts @@ -1,4 +1,10 @@ -import type { Router, Producer, Consumer, PlainTransport } from "mediasoup/node/lib/types"; +import type { + Router, + Producer, + Consumer, + PlainTransport, + MediaKind +} from "mediasoup/node/lib/types"; import { getPort, type DynamicPort } from "#src/services/resources.ts"; import { rtc } from "#src/config.ts"; import { Deferred } from "#src/utils/utils.ts"; @@ -9,6 +15,7 @@ export class RTP { payloadType?: number; clockRate?: number; codec?: string; + kind?: MediaKind; channels?: number; type: STREAM_TYPE; private _router: Router; @@ -59,20 +66,16 @@ export class RTP { paused: true }); if (this._isClosed) { - // may be closed by the time the consume is created + // may be closed by the time the consumer is created this._cleanup(); return; } - // TODO may want to use producer.getStats() to get the codec info - // for val of producer.getStats().values() { if val.type === "codec": val.minetype, val.clockRate,... } - //const codecData = this._channel.router.rtpCapabilities.codecs.find( - // (codec) => codec.kind === producer.kind - //); const codecData = this._producer.rtpParameters.codecs[0]; + this.kind = this._producer.kind; this.payloadType = codecData.payloadType; this.clockRate = codecData.clockRate; - this.codec = codecData.mimeType.replace(`${this._producer.kind}`, ""); - this.channels = this._producer.kind === "audio" ? codecData.channels : undefined; + this.codec = codecData.mimeType.replace(`${this.kind}`, ""); + this.channels = this.kind === "audio" ? codecData.channels : undefined; this.isReady.resolve(); } catch { this.close(); From 8b70d97f5c66e7c44719f4dfa1ee5ab8f0a72958 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Tue, 25 Nov 2025 14:54:38 +0100 Subject: [PATCH 37/73] [wip] media output model --- src/models/ffmpeg.ts | 118 ++++++++++++++++++++++--- src/models/{rtp.ts => media_output.ts} | 65 +++++++++----- src/models/recorder.ts | 51 +++++++---- src/models/recording_task.ts | 45 +++++----- 4 files changed, 203 insertions(+), 76 deletions(-) rename src/models/{rtp.ts => media_output.ts} (54%) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index bb4f1c1..913e097 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,41 +1,135 @@ /* eslint-disable prettier/prettier */ -import { EventEmitter } from "node:events"; +import { spawn, ChildProcess } from "node:child_process"; +import { Readable } from "node:stream"; import { Logger } from "#src/utils/utils.ts"; -import { RTP } from "#src/models/rtp.ts"; +import type { rtpData } from "#src/models/media_output"; const logger = new Logger("FFMPEG"); let currentId = 0; -export class FFMPEG extends EventEmitter { +export class FFMPEG { readonly id: number; - private readonly rtp: RTP; + private readonly rtp: rtpData; + private _process?: ChildProcess; private _isClosed = false; - constructor(rtp: RTP) { - super(); + private _filename: string; + + constructor(rtp: rtpData, filename: string) { this.rtp = rtp; this.id = currentId++; - logger.verbose(`creating FFMPEG for ${this.id} on ${this.rtp.type}`); + this._filename = filename; + logger.verbose(`creating FFMPEG for ${this.id}}`); this._init(); } close() { + if (this._isClosed) { + return; + } this._isClosed = true; - this.emit("close", this.id); // maybe different event if fail/saved properly + logger.verbose(`closing FFMPEG ${this.id}`); + if (this._process) { + this._process.kill("SIGINT"); + } this._cleanup(); } private async _init() { - await this.rtp.isReady; if (this._isClosed) { this._cleanup(); return; } - logger.trace(`To implement: FFMPEG start process ${this.id} for ${this.rtp.type}`); - // build FFMPEG params with rtp properties, start the process + + const sdpString = this._createSdpText(); + logger.trace(`FFMPEG ${this.id} SDP:\n${sdpString}`); + + const sdpStream = Readable.from([sdpString]); + const args = this._getCommandArgs(); + + logger.verbose(`spawning ffmpeg with args: ${args.join(" ")}`); + + this._process = spawn("ffmpeg", args); + + if (this._process.stderr) { + this._process.stderr.setEncoding("utf-8"); + this._process.stderr.on("data", (data) => { + logger.debug(`[ffmpeg ${this.id}] ${data}`); + }); + } + + if (this._process.stdout) { + this._process.stdout.setEncoding("utf-8"); + this._process.stdout.on("data", (data) => { + logger.debug(`[ffmpeg ${this.id} stdout] ${data}`); + }); + } + + this._process.on("error", (error) => { + logger.error(`ffmpeg ${this.id} error: ${error.message}`); + this.close(); + }); + + this._process.on("close", (code) => { + logger.verbose(`ffmpeg ${this.id} exited with code ${code}`); + this.close(); + }); + + sdpStream.on("error", (error) => { + logger.error(`sdpStream error: ${error.message}`); + }); + + if (this._process.stdin) { + sdpStream.pipe(this._process.stdin); + } } private _cleanup() { - logger.trace(`FFMPEG ${this.id} closed for ${this.rtp.type}`); + this._process = undefined; + logger.trace(`FFMPEG ${this.id} closed`); + } + + private _createSdpText(): string { + const { port, payloadType, codec, clockRate, channels, kind } = this.rtp; + + if (!port || !payloadType || !codec || !clockRate || !kind) { + throw new Error("RTP missing required properties for SDP generation"); + } + let sdp = `v=0 + o=- 0 0 IN IP4 127.0.0.1 + s=FFmpeg + c=IN IP4 127.0.0.1 + t=0 0 + m=${kind} ${port} RTP/AVP ${payloadType} + a=rtpmap:${payloadType} ${codec}/${clockRate}`; + + if (kind === "audio" && channels) { + sdp += `/${channels}`; + } + sdp += `\na=sendonly\n`; + return sdp; + } + + private _getCommandArgs(): string[] { + let args = [ + "-loglevel", "debug", // TODO remove + "-protocol_whitelist", "pipe,udp,rtp", + "-fflags", "+genpts", + "-f", "sdp", + "-i", "pipe:0" + ]; + if (this.rtp.kind === "audio") { + args = args.concat([ + "-map", "0:a:0", + "-c:a", "copy" + ]); + } else { + args = args.concat([ + "-map", "0:v:0", + "-c:v", "copy" + ]); + } + args.push(`${this._filename}.webm`); + return args; } } diff --git a/src/models/rtp.ts b/src/models/media_output.ts similarity index 54% rename from src/models/rtp.ts rename to src/models/media_output.ts index 205e78e..879dcc2 100644 --- a/src/models/rtp.ts +++ b/src/models/media_output.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import type { Router, Producer, @@ -7,21 +8,24 @@ import type { } from "mediasoup/node/lib/types"; import { getPort, type DynamicPort } from "#src/services/resources.ts"; import { rtc } from "#src/config.ts"; -import { Deferred } from "#src/utils/utils.ts"; -import { STREAM_TYPE } from "#src/shared/enums.ts"; +import { FFMPEG } from "#src/models/ffmpeg.ts"; -export class RTP { - isReady = new Deferred(); +export type rtpData = { payloadType?: number; clockRate?: number; codec?: string; kind?: MediaKind; channels?: number; - type: STREAM_TYPE; + port: number; +}; +export class MediaOutput extends EventEmitter { + name: string; private _router: Router; private _producer: Producer; private _transport?: PlainTransport; private _consumer?: Consumer; + private _ffmpeg?: FFMPEG; + private _rtpData?: rtpData; private _port?: DynamicPort; private _isClosed = false; @@ -29,18 +33,11 @@ export class RTP { return this._port?.number; } - constructor({ - producer, - router, - type - }: { - producer: Producer; - router: Router; - type: STREAM_TYPE; - }) { + constructor({ producer, router, name }: { producer: Producer; router: Router; name: string }) { + super(); this._router = router; this._producer = producer; - this.type = type; + this.name = name; this._init(); } @@ -71,19 +68,43 @@ export class RTP { return; } const codecData = this._producer.rtpParameters.codecs[0]; - this.kind = this._producer.kind; - this.payloadType = codecData.payloadType; - this.clockRate = codecData.clockRate; - this.codec = codecData.mimeType.replace(`${this.kind}`, ""); - this.channels = this.kind === "audio" ? codecData.channels : undefined; - this.isReady.resolve(); + this._rtpData = { + kind: this._producer.kind, + payloadType: codecData.payloadType, + clockRate: codecData.clockRate, + port: this._port.number, + codec: codecData.mimeType.split("/")[1], + channels: this._producer.kind === "audio" ? codecData.channels : undefined + }; + if (this._isClosed) { + this._cleanup(); + return; + } + const refreshProcess = this._refreshProcess.bind(this); + this._consumer.on("producerresume", refreshProcess); + this._consumer.on("producerpause", refreshProcess); + this._refreshProcess(); } catch { this.close(); - this.isReady.reject(new Error(`Failed at creating a plain transport for ${this.type}`)); + } + } + + private _refreshProcess() { + if (this._isClosed || !this._rtpData) { + return; + } + if (this._producer.paused) { + this._ffmpeg?.close(); + this._ffmpeg = undefined; + } else { + const fileName = `${this.name}-${Date.now()}`; + this._ffmpeg = new FFMPEG(this._rtpData, fileName); + this.emit("file", fileName); } } private _cleanup() { + this._ffmpeg?.close(); this._consumer?.close(); this._transport?.close(); this._port?.release(); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 5d82fa4..9432c7b 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -3,17 +3,18 @@ import path from "node:path"; import { recording } from "#src/config.ts"; import { getFolder, type Folder } from "#src/services/resources.ts"; -import { RecordingTask, type RecordingParameters } from "#src/models/recording_task.ts"; +import { RecordingTask, type RecordingStates } from "#src/models/recording_task.ts"; import { Logger } from "#src/utils/utils.ts"; import type { Channel } from "#src/models/channel"; import type { SessionId } from "#src/models/session.ts"; -enum TIME_TAG { +export enum TIME_TAG { RECORDING_STARTED = "recording_started", RECORDING_STOPPED = "recording_stopped", TRANSCRIPTION_STARTED = "transcription_started", - TRANSCRIPTION_STOPPED = "transcription_stopped" + TRANSCRIPTION_STOPPED = "transcription_stopped", + NEW_FILE = "new_file" } export enum RECORDER_STATE { STARTED = "started", @@ -22,7 +23,7 @@ export enum RECORDER_STATE { } export type Metadata = { uploadAddress: string; - timeStamps: Record>; + timeStamps: Record>; }; const logger = new Logger("RECORDER"); @@ -62,6 +63,10 @@ export class Recorder extends EventEmitter { return this.state === RECORDER_STATE.STARTED; } + get path(): string | undefined { + return this._folder?.path; + } + constructor(channel: Channel, recordingAddress: string) { super(); this._onSessionJoin = this._onSessionJoin.bind(this); @@ -74,7 +79,7 @@ export class Recorder extends EventEmitter { // TODO: for the transcription, we should play with isRecording / isTranscribing to see whether to stop or start or just disabled one of the features if (!this.isRecording) { this.isRecording = true; - this._mark(TIME_TAG.RECORDING_STARTED); + this.mark(TIME_TAG.RECORDING_STARTED); await this._refreshConfiguration(); } return this.isRecording; @@ -83,7 +88,7 @@ export class Recorder extends EventEmitter { async stop() { if (this.isRecording) { this.isRecording = false; - this._mark(TIME_TAG.RECORDING_STOPPED); + this.mark(TIME_TAG.RECORDING_STOPPED); await this._refreshConfiguration(); } return this.isRecording; @@ -92,7 +97,7 @@ export class Recorder extends EventEmitter { async startTranscription() { if (!this.isTranscribing) { this.isTranscribing = true; - this._mark(TIME_TAG.TRANSCRIPTION_STARTED); + this.mark(TIME_TAG.TRANSCRIPTION_STARTED); await this._refreshConfiguration(); } return this.isTranscribing; @@ -101,12 +106,25 @@ export class Recorder extends EventEmitter { async stopTranscription() { if (this.isTranscribing) { this.isTranscribing = false; - this._mark(TIME_TAG.TRANSCRIPTION_STOPPED); + this.mark(TIME_TAG.TRANSCRIPTION_STOPPED); await this._refreshConfiguration(); } return this.isTranscribing; } + mark(tag: TIME_TAG, value: object = {}) { + const events = this._metaData.timeStamps[Date.now()] || []; + events.push({ + tag, + value + }); + this._metaData.timeStamps[Date.now()] = events; + } + + /** + * @param param0 + * @param param0.save - whether to save the recording + */ async terminate({ save = false }: { save?: boolean } = {}) { if (!this.isActive) { return; @@ -139,7 +157,7 @@ export class Recorder extends EventEmitter { if (!session) { return; } - this._tasks.set(session.id, new RecordingTask(session, this._getTaskParameters())); + this._tasks.set(session.id, new RecordingTask(this, session, this._getRecordingStates())); } private _onSessionLeave(id: SessionId) { @@ -150,12 +168,6 @@ export class Recorder extends EventEmitter { } } - private _mark(tag: TIME_TAG) { - const events = this._metaData.timeStamps[Date.now()] || []; - events.push(tag); - this._metaData.timeStamps[Date.now()] = events; - } - private async _refreshConfiguration() { if (this.isRecording || this.isTranscribing) { if (this.isActive) { @@ -176,7 +188,7 @@ export class Recorder extends EventEmitter { } private async _update() { - const params = this._getTaskParameters(); + const params = this._getRecordingStates(); for (const task of this._tasks.values()) { Object.assign(task, params); } @@ -187,7 +199,10 @@ export class Recorder extends EventEmitter { this._folder = await getFolder(); logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); for (const [sessionId, session] of this.channel.sessions) { - this._tasks.set(sessionId, new RecordingTask(session, this._getTaskParameters())); + this._tasks.set( + sessionId, + new RecordingTask(this, session, this._getRecordingStates()) + ); } this.channel.on("sessionJoin", this._onSessionJoin); this.channel.on("sessionLeave", this._onSessionLeave); @@ -202,7 +217,7 @@ export class Recorder extends EventEmitter { return Promise.allSettled(proms); } - private _getTaskParameters(): RecordingParameters { + private _getRecordingStates(): RecordingStates { return { audio: this.isRecording || this.isTranscribing, camera: this.isRecording, diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 134e763..f267102 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -1,16 +1,16 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { EventEmitter } from "node:events"; -import { RTP } from "#src/models/rtp.ts"; +import { MediaOutput } from "#src/models/media_output"; import type { Producer } from "mediasoup/node/lib/types"; import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; -import { FFMPEG } from "#src/models/ffmpeg.ts"; +import { TIME_TAG, type Recorder } from "#src/models/recorder.ts"; import { STREAM_TYPE } from "#src/shared/enums.ts"; -export type RecordingParameters = { +export type RecordingStates = { audio: boolean; camera: boolean; screen: boolean; @@ -23,8 +23,7 @@ export enum RECORDING_TASK_EVENT { type RecordingData = { active: boolean; // active is different from boolean(ffmpeg) so we can flag synchronously and avoid race conditions type: STREAM_TYPE; - rtp?: RTP; - ffmpeg?: FFMPEG; + mediaOutput?: MediaOutput; }; type RecordingDataByStreamType = { @@ -37,6 +36,7 @@ const logger = new Logger("RECORDING_TASK"); export class RecordingTask extends EventEmitter { private _session: Session; + private _recorder: Recorder; private readonly recordingDataByStreamType: RecordingDataByStreamType = { [STREAM_TYPE.AUDIO]: { active: false, @@ -62,10 +62,11 @@ export class RecordingTask extends EventEmitter { this._setRecording(STREAM_TYPE.SCREEN, value); } - constructor(session: Session, { audio, camera, screen }: RecordingParameters) { + constructor(recorder: Recorder, session: Session, { audio, camera, screen }: RecordingStates) { super(); this._onSessionProducer = this._onSessionProducer.bind(this); this._session = session; + this._recorder = recorder; this._session.on("producer", this._onSessionProducer); this.audio = audio; this.camera = camera; @@ -101,16 +102,20 @@ export class RecordingTask extends EventEmitter { private async _updateProcess(data: RecordingData, producer: Producer, type: STREAM_TYPE) { if (data.active) { - if (data.ffmpeg) { + if (data.mediaOutput) { + // already recording return; } try { - data.rtp = data.rtp || new RTP({ producer, router: this._session.router!, type }); - data.ffmpeg = new FFMPEG(data.rtp); + data.mediaOutput = new MediaOutput({ + producer, + router: this._session.router!, + name: `${this._session.id}-${type}` + }); + data.mediaOutput.on("file", (filename: string) => { + this._recorder.mark(TIME_TAG.NEW_FILE, { filename, type }); + }); if (data.active) { - logger.verbose( - `starting recording process for ${this._session.name} ${data.type}` - ); return; } } catch { @@ -119,22 +124,14 @@ export class RecordingTask extends EventEmitter { ); } } - // TODO emit ending - this._clearData(data.type, { preserveRTP: true }); + this._clearData(data.type); } - private _clearData( - type: STREAM_TYPE, - { preserveRTP }: { preserveRTP?: boolean } = { preserveRTP: false } - ) { + private _clearData(type: STREAM_TYPE) { const data = this.recordingDataByStreamType[type]; data.active = false; - if (!preserveRTP) { - data.rtp?.close(); - data.rtp = undefined; - } - data.ffmpeg?.close(); - data.ffmpeg = undefined; + data.mediaOutput?.close(); + data.mediaOutput = undefined; } async stop() { From fb4c4b9621638263a4051b916b56d5f6cf1cdf83 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 26 Nov 2025 15:08:29 +0100 Subject: [PATCH 38/73] [wip] save file ext, routing interface,... --- src/config.ts | 3 ++- src/models/ffmpeg.ts | 34 +++++++++++++++++++++++++++++++--- src/models/media_output.ts | 4 ++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index 0a9b6bf..f8f1712 100644 --- a/src/config.ts +++ b/src/config.ts @@ -205,11 +205,12 @@ export const timeouts: TimeoutConfig = Object.freeze({ }); export const recording = Object.freeze({ + routingInterface: "0.0.0.0", directory: os.tmpdir() + "/recordings", enabled: RECORDING, maxDuration: 1000 * 60 * 60, // 1 hour, could be a env-var. fileTTL: 1000 * 60 * 60 * 24, // 24 hours - fileType: "mp4", + fileExtension: "mp4", videoCodec: "libx264", audioCodec: "aac", audioLimit: 20, diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 913e097..722816a 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -3,6 +3,7 @@ import { spawn, ChildProcess } from "node:child_process"; import { Readable } from "node:stream"; import { Logger } from "#src/utils/utils.ts"; import type { rtpData } from "#src/models/media_output"; +import { recording } from "#src/config.ts"; const logger = new Logger("FFMPEG"); @@ -96,9 +97,9 @@ export class FFMPEG { throw new Error("RTP missing required properties for SDP generation"); } let sdp = `v=0 - o=- 0 0 IN IP4 127.0.0.1 + o=- 0 0 IN IP4 ${recording.routingInterface} s=FFmpeg - c=IN IP4 127.0.0.1 + c=IN IP4 ${recording.routingInterface} t=0 0 m=${kind} ${port} RTP/AVP ${payloadType} a=rtpmap:${payloadType} ${codec}/${clockRate}`; @@ -110,6 +111,32 @@ export class FFMPEG { return sdp; } + private _getContainerExtension(): string { + const codec = this.rtp.codec?.toLowerCase(); + + switch (codec) { + case "h264": + case "h265": + return "mp4"; + + case "vp8": + case "vp9": + case "av1": + case "opus": + case "vorbis": + return "webm"; + + case "pcmu": + case "pcma": + // G.711 codecs - use WAV container for raw PCM audio + return "wav"; + + default: + logger.warn(`Unknown codec "${codec}", using .mkv container as fallback`); + return "mkv"; + } + } + private _getCommandArgs(): string[] { let args = [ "-loglevel", "debug", // TODO remove @@ -129,7 +156,8 @@ export class FFMPEG { "-c:v", "copy" ]); } - args.push(`${this._filename}.webm`); + const extension = this._getContainerExtension(); + args.push(`${this._filename}.${extension}`); return args; } } diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 879dcc2..1b11333 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -7,7 +7,7 @@ import type { MediaKind } from "mediasoup/node/lib/types"; import { getPort, type DynamicPort } from "#src/services/resources.ts"; -import { rtc } from "#src/config.ts"; +import { recording, rtc } from "#src/config.ts"; import { FFMPEG } from "#src/models/ffmpeg.ts"; export type rtpData = { @@ -54,7 +54,7 @@ export class MediaOutput extends EventEmitter { throw new Error(`Failed at creating a plain transport for`); } this._transport.connect({ - ip: "0.0.0.0", + ip: recording.routingInterface, port: this._port.number }); this._consumer = await this._transport.consume({ From a6ad85be2e5082858912c899800adc34ed41d6ca Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Wed, 26 Nov 2025 15:22:24 +0100 Subject: [PATCH 39/73] [wip] fixup --- src/models/ffmpeg.ts | 98 +++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 722816a..40c0b9b 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -20,7 +20,7 @@ export class FFMPEG { this.rtp = rtp; this.id = currentId++; this._filename = filename; - logger.verbose(`creating FFMPEG for ${this.id}}`); + logger.verbose(`creating FFMPEG for ${this.id}`); this._init(); } @@ -36,52 +36,57 @@ export class FFMPEG { this._cleanup(); } - private async _init() { + private _init() { if (this._isClosed) { this._cleanup(); return; } - const sdpString = this._createSdpText(); - logger.trace(`FFMPEG ${this.id} SDP:\n${sdpString}`); - - const sdpStream = Readable.from([sdpString]); - const args = this._getCommandArgs(); - - logger.verbose(`spawning ffmpeg with args: ${args.join(" ")}`); - - this._process = spawn("ffmpeg", args); - - if (this._process.stderr) { - this._process.stderr.setEncoding("utf-8"); - this._process.stderr.on("data", (data) => { - logger.debug(`[ffmpeg ${this.id}] ${data}`); + try { + const sdpString = this._createSdpText(); + logger.trace(`FFMPEG ${this.id} SDP:\n${sdpString}`); + + const sdpStream = Readable.from([sdpString]); + const args = this._getCommandArgs(); + + logger.verbose(`spawning ffmpeg with args: ${args.join(" ")}`); + + this._process = spawn("ffmpeg", args); + + if (this._process.stderr) { + this._process.stderr.setEncoding("utf-8"); + this._process.stderr.on("data", (data) => { + logger.debug(`[ffmpeg ${this.id}] ${data}`); + }); + } + + if (this._process.stdout) { + this._process.stdout.setEncoding("utf-8"); + this._process.stdout.on("data", (data) => { + logger.debug(`[ffmpeg ${this.id} stdout] ${data}`); + }); + } + + this._process.on("error", (error) => { + logger.error(`ffmpeg ${this.id} error: ${error.message}`); + this.close(); }); - } - - if (this._process.stdout) { - this._process.stdout.setEncoding("utf-8"); - this._process.stdout.on("data", (data) => { - logger.debug(`[ffmpeg ${this.id} stdout] ${data}`); - }); - } - this._process.on("error", (error) => { - logger.error(`ffmpeg ${this.id} error: ${error.message}`); - this.close(); - }); - - this._process.on("close", (code) => { - logger.verbose(`ffmpeg ${this.id} exited with code ${code}`); - this.close(); - }); + this._process.on("close", (code) => { + logger.verbose(`ffmpeg ${this.id} exited with code ${code}`); + this.close(); + }); - sdpStream.on("error", (error) => { - logger.error(`sdpStream error: ${error.message}`); - }); + sdpStream.on("error", (error) => { + logger.error(`sdpStream error: ${error.message}`); + }); - if (this._process.stdin) { - sdpStream.pipe(this._process.stdin); + if (this._process.stdin) { + sdpStream.pipe(this._process.stdin); + } + } catch (error) { + logger.error(`Failed to initialize FFMPEG ${this.id}: ${error}`); + this.close(); } } @@ -96,18 +101,19 @@ export class FFMPEG { if (!port || !payloadType || !codec || !clockRate || !kind) { throw new Error("RTP missing required properties for SDP generation"); } - let sdp = `v=0 - o=- 0 0 IN IP4 ${recording.routingInterface} - s=FFmpeg - c=IN IP4 ${recording.routingInterface} - t=0 0 - m=${kind} ${port} RTP/AVP ${payloadType} - a=rtpmap:${payloadType} ${codec}/${clockRate}`; + + let sdp = `v=0\n`; + sdp += `o=- 0 0 IN IP4 ${recording.routingInterface}\n`; + sdp += `s=FFmpeg\n`; + sdp += `c=IN IP4 ${recording.routingInterface}\n`; + sdp += `t=0 0\n`; + sdp += `m=${kind} ${port} RTP/AVP ${payloadType}\n`; + sdp += `a=rtpmap:${payloadType} ${codec}/${clockRate}`; if (kind === "audio" && channels) { sdp += `/${channels}`; } - sdp += `\na=sendonly\n`; + sdp += `\na=recvonly\n`; return sdp; } From 323c6c574e64e9e6acc4118d82b9ed3072379dba Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 27 Nov 2025 13:40:21 +0100 Subject: [PATCH 40/73] [wip] recording path --- README.md | 1 + src/config.ts | 9 +++++++-- src/services/resources.ts | 8 ++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da1a7a9..77bfac2 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ The available environment variables are: - **MAX_VIDEO_BITRATE**: if set, defines the `maxBitrate` of the highest encoding layer (simulcast), defaults to 4mbps - **CHANNEL_SIZE**: the maximum amount of users per channel, defaults to 100 - **RECORDING**: enables the recording feature, defaults to false +- **RECORDING_PATH**: the path where the recordings will be saved, defaults to `${tmpDir}/recordings`. - **WORKER_LOG_LEVEL**: "none" | "error" | "warn" | "debug", will only work if `DEBUG` is properly set. - **LOG_LEVEL**: "none" | "error" | "warn" | "info" | "debug" | "verbose" - **LOG_TIMESTAMP**: adds a timestamp to the log lines, defaults to true, to disable it, set to "disable", "false", "none", "no" or "0" diff --git a/src/config.ts b/src/config.ts index f8f1712..8d6627a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import os from "node:os"; +import path from "node:path"; import type { RtpCodecCapability, @@ -13,6 +14,7 @@ const FALSY_INPUT = new Set(["disable", "false", "none", "no", "0"]); type LogLevel = "none" | "error" | "warn" | "info" | "debug" | "verbose"; type WorkerLogLevel = "none" | "error" | "warn" | "debug"; const testingMode = Boolean(process.env.JEST_WORKER_ID); +export const tmpDir = path.join(os.tmpdir(), "odoo_sfu"); // ------------------------------------------------------------ // ------------------ ENV VARIABLES ----------------------- @@ -70,7 +72,10 @@ export const PORT: number = Number(process.env.PORT) || 8070; * Whether the recording feature is enabled, false by default. */ export const RECORDING: boolean = Boolean(process.env.RECORDING) || testingMode; - +/** + * The path where the recordings will be saved, defaults to `${tmpDir}/recordings`. + */ +export const RECORDING_PATH: string = process.env.RECORDING_PATH || path.join(tmpDir, "recordings"); /** * The number of workers to spawn (up to core limits) to manage RTC servers. * 0 < NUM_WORKERS <= os.availableParallelism() @@ -206,7 +211,7 @@ export const timeouts: TimeoutConfig = Object.freeze({ export const recording = Object.freeze({ routingInterface: "0.0.0.0", - directory: os.tmpdir() + "/recordings", + directory: RECORDING_PATH, enabled: RECORDING, maxDuration: 1000 * 60 * 60, // 1 hour, could be a env-var. fileTTL: 1000 * 60 * 60 * 24, // 24 hours diff --git a/src/services/resources.ts b/src/services/resources.ts index da5af49..498b857 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -1,11 +1,11 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + import * as mediasoup from "mediasoup"; import * as config from "#src/config.ts"; import { Logger } from "#src/utils/utils.ts"; import { PortLimitReachedError } from "#src/utils/errors.ts"; -import os from "node:os"; -import fs from "node:fs/promises"; -import path from "node:path"; const availablePorts: Set = new Set(); let unique = 1; @@ -21,7 +21,7 @@ export interface RtcWorker extends mediasoup.types.Worker { const logger = new Logger("RESOURCES"); const workers = new Set(); -const directory = os.tmpdir() + "/open_sfu_resources"; +const directory = path.join(config.tmpDir, "resources"); export async function start(): Promise { logger.info("starting..."); From 65c4ea2396303dd9d98e22beb6fc2a30d153d20c Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 08:08:49 +0100 Subject: [PATCH 41/73] [wip] version update --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92e333a..fb4a2ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "odoo-sfu", "description": "Odoo's SFU server", - "version": "1.3.2", + "version": "1.4.0", "author": "Odoo", "license": "LGPL-3.0", "type": "module", From 3b223251ec419c806014b68edaddcae6a696185e Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 08:10:32 +0100 Subject: [PATCH 42/73] [wip] fixup --- src/models/recording_task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index f267102..467abfa 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { EventEmitter } from "node:events"; -import { MediaOutput } from "#src/models/media_output"; import type { Producer } from "mediasoup/node/lib/types"; +import { MediaOutput } from "#src/models/media_output.ts"; import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; import { TIME_TAG, type Recorder } from "#src/models/recorder.ts"; From ed8fc34d8a7c4e3c3d6eda60b419027c36f1eb70 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 09:20:31 +0100 Subject: [PATCH 43/73] f --- src/config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 8d6627a..2aea0fd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -210,7 +210,7 @@ export const timeouts: TimeoutConfig = Object.freeze({ }); export const recording = Object.freeze({ - routingInterface: "0.0.0.0", + routingInterface: "127.0.0.1", directory: RECORDING_PATH, enabled: RECORDING, maxDuration: 1000 * 60 * 60, // 1 hour, could be a env-var. @@ -261,7 +261,9 @@ export interface RtcConfig { export const rtc: RtcConfig = Object.freeze({ // https://mediasoup.org/documentation/v3/mediasoup/api/#WorkerSettings workerSettings: { - logLevel: WORKER_LOG_LEVEL + logLevel: WORKER_LOG_LEVEL, + rtcMinPort: RTC_MIN_PORT, + rtcMaxPort: RTC_MAX_PORT }, // https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcServer-dictionaries rtcServerOptions: { From c71d03af53cdf265e8e65c3da7d05875690636cd Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 09:54:18 +0100 Subject: [PATCH 44/73] [wip] --- src/models/ffmpeg.ts | 2 +- src/models/media_output.ts | 26 ++++++++++++++++++++++++-- src/models/recording_task.ts | 3 ++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 40c0b9b..328a83f 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -92,7 +92,7 @@ export class FFMPEG { private _cleanup() { this._process = undefined; - logger.trace(`FFMPEG ${this.id} closed`); + logger.verbose(`FFMPEG ${this.id} closed`); } private _createSdpText(): string { diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 1b11333..36b8a75 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -1,4 +1,6 @@ import { EventEmitter } from "node:events"; +import path from "node:path"; + import type { Router, Producer, @@ -6,9 +8,13 @@ import type { PlainTransport, MediaKind } from "mediasoup/node/lib/types"; + import { getPort, type DynamicPort } from "#src/services/resources.ts"; import { recording, rtc } from "#src/config.ts"; import { FFMPEG } from "#src/models/ffmpeg.ts"; +import { Logger } from "#src/utils/utils.ts"; + +const logger = new Logger("MEDIA_OUTPUT"); export type rtpData = { payloadType?: number; @@ -28,16 +34,28 @@ export class MediaOutput extends EventEmitter { private _rtpData?: rtpData; private _port?: DynamicPort; private _isClosed = false; + private _directory: string; get port() { return this._port?.number; } - constructor({ producer, router, name }: { producer: Producer; router: Router; name: string }) { + constructor({ + producer, + router, + name, + directory + }: { + producer: Producer; + router: Router; + name: string; + directory: string; + }) { super(); this._router = router; this._producer = producer; this.name = name; + this._directory = directory; this._init(); } @@ -94,11 +112,15 @@ export class MediaOutput extends EventEmitter { return; } if (this._producer.paused) { + this._consumer?.pause(); this._ffmpeg?.close(); this._ffmpeg = undefined; } else { const fileName = `${this.name}-${Date.now()}`; - this._ffmpeg = new FFMPEG(this._rtpData, fileName); + logger.verbose(`writing ${fileName} at ${this._directory}`); + const fullName = path.join(this._directory, fileName); + this._ffmpeg = new FFMPEG(this._rtpData, fullName); + this._consumer?.resume(); this.emit("file", fileName); } } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 467abfa..fe323b4 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -110,7 +110,8 @@ export class RecordingTask extends EventEmitter { data.mediaOutput = new MediaOutput({ producer, router: this._session.router!, - name: `${this._session.id}-${type}` + name: `${this._session.id}-${type}`, + directory: this._recorder.path! }); data.mediaOutput.on("file", (filename: string) => { this._recorder.mark(TIME_TAG.NEW_FILE, { filename, type }); From 77d7d97649b3634835c20e4ac41bbbcb1172bb85 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 10:36:40 +0100 Subject: [PATCH 45/73] [wip] recording saved --- src/config.ts | 2 ++ src/models/ffmpeg.ts | 42 ++++++++++++++++-------------------- src/models/media_output.ts | 15 +++++++------ src/models/recorder.ts | 1 + src/models/recording_task.ts | 10 +++++---- src/services/resources.ts | 2 +- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2aea0fd..b16d7a9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import os from "node:os"; import path from "node:path"; +import fs from "node:fs"; import type { RtpCodecCapability, @@ -76,6 +77,7 @@ export const RECORDING: boolean = Boolean(process.env.RECORDING) || testingMode; * The path where the recordings will be saved, defaults to `${tmpDir}/recordings`. */ export const RECORDING_PATH: string = process.env.RECORDING_PATH || path.join(tmpDir, "recordings"); +fs.mkdirSync(RECORDING_PATH, { recursive: true }); /** * The number of workers to spawn (up to core limits) to manage RTC servers. * 0 < NUM_WORKERS <= os.availableParallelism() diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 328a83f..1274f7e 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -1,5 +1,6 @@ /* eslint-disable prettier/prettier */ import { spawn, ChildProcess } from "node:child_process"; +import fs from "node:fs"; import { Readable } from "node:stream"; import { Logger } from "#src/utils/utils.ts"; import type { rtpData } from "#src/models/media_output"; @@ -15,6 +16,7 @@ export class FFMPEG { private _process?: ChildProcess; private _isClosed = false; private _filename: string; + private _logStream?: fs.WriteStream; constructor(rtp: rtpData, filename: string) { this.rtp = rtp; @@ -24,24 +26,24 @@ export class FFMPEG { this._init(); } - close() { + async close() { if (this._isClosed) { return; } this._isClosed = true; + this._logStream?.end(); logger.verbose(`closing FFMPEG ${this.id}`); - if (this._process) { - this._process.kill("SIGINT"); + if (this._process && !this._process.killed) { + logger.debug(`FFMPEG ${this.id} is still running, sending SIGINT`); + await new Promise((resolve) => { + this._process!.kill("SIGINT"); + resolve(true); + }); + logger.debug(`FFMPEG ${this.id} closed`); } - this._cleanup(); } private _init() { - if (this._isClosed) { - this._cleanup(); - return; - } - try { const sdpString = this._createSdpText(); logger.trace(`FFMPEG ${this.id} SDP:\n${sdpString}`); @@ -52,19 +54,15 @@ export class FFMPEG { logger.verbose(`spawning ffmpeg with args: ${args.join(" ")}`); this._process = spawn("ffmpeg", args); - + + this._logStream = fs.createWriteStream(`${this._filename}.log`); + if (this._process.stderr) { - this._process.stderr.setEncoding("utf-8"); - this._process.stderr.on("data", (data) => { - logger.debug(`[ffmpeg ${this.id}] ${data}`); - }); + this._process.stderr.pipe(this._logStream, { end: false }); } - + if (this._process.stdout) { - this._process.stdout.setEncoding("utf-8"); - this._process.stdout.on("data", (data) => { - logger.debug(`[ffmpeg ${this.id} stdout] ${data}`); - }); + this._process.stdout.pipe(this._logStream, { end: false }); } this._process.on("error", (error) => { @@ -90,11 +88,6 @@ export class FFMPEG { } } - private _cleanup() { - this._process = undefined; - logger.verbose(`FFMPEG ${this.id} closed`); - } - private _createSdpText(): string { const { port, payloadType, codec, clockRate, channels, kind } = this.rtp; @@ -113,6 +106,7 @@ export class FFMPEG { if (kind === "audio" && channels) { sdp += `/${channels}`; } + sdp += `\na=rtcp-mux`; sdp += `\na=recvonly\n`; return sdp; } diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 36b8a75..c33428f 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -59,9 +59,9 @@ export class MediaOutput extends EventEmitter { this._init(); } - close() { + async close() { this._isClosed = true; - this._cleanup(); + await this._cleanup(); } private async _init() { @@ -85,7 +85,7 @@ export class MediaOutput extends EventEmitter { this._cleanup(); return; } - const codecData = this._producer.rtpParameters.codecs[0]; + const codecData = this._consumer.rtpParameters.codecs[0]; this._rtpData = { kind: this._producer.kind, payloadType: codecData.payloadType, @@ -107,26 +107,27 @@ export class MediaOutput extends EventEmitter { } } - private _refreshProcess() { + private async _refreshProcess() { if (this._isClosed || !this._rtpData) { return; } if (this._producer.paused) { this._consumer?.pause(); - this._ffmpeg?.close(); + await this._ffmpeg?.close(); this._ffmpeg = undefined; } else { const fileName = `${this.name}-${Date.now()}`; logger.verbose(`writing ${fileName} at ${this._directory}`); const fullName = path.join(this._directory, fileName); this._ffmpeg = new FFMPEG(this._rtpData, fullName); + logger.verbose(`resuming consumer ${this._consumer?.id}`); this._consumer?.resume(); this.emit("file", fileName); } } - private _cleanup() { - this._ffmpeg?.close(); + private async _cleanup() { + await this._ffmpeg?.close(); this._consumer?.close(); this._transport?.close(); this._port?.release(); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 9432c7b..a471298 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -129,6 +129,7 @@ export class Recorder extends EventEmitter { if (!this.isActive) { return; } + logger.debug("terminating recorder"); this.channel.off("sessionJoin", this._onSessionJoin); this.channel.off("sessionLeave", this._onSessionLeave); this.isRecording = false; diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index fe323b4..d4b056a 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -125,20 +125,22 @@ export class RecordingTask extends EventEmitter { ); } } - this._clearData(data.type); + await this._clearData(data.type); } - private _clearData(type: STREAM_TYPE) { + private async _clearData(type: STREAM_TYPE) { const data = this.recordingDataByStreamType[type]; data.active = false; - data.mediaOutput?.close(); + await data.mediaOutput?.close(); data.mediaOutput = undefined; } async stop() { this._session.off("producer", this._onSessionProducer); + const proms = []; for (const type of Object.values(STREAM_TYPE)) { - this._clearData(type); + proms.push(this._clearData(type)); } + await Promise.all(proms); } } diff --git a/src/services/resources.ts b/src/services/resources.ts index 498b857..a630b20 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -124,9 +124,9 @@ export class Folder { const destinationPath = path; try { await fs.rename(this.path, destinationPath); + logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); Folder.usedDirs.delete(this.path); this.path = destinationPath; - logger.verbose(`Moved folder from ${this.path} to ${destinationPath}`); } catch (error) { logger.error(`Failed to move folder from ${this.path} to ${destinationPath}: ${error}`); } From 78beff0d811068a1cdd81cb97df12b5df1241d39 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 11:32:05 +0100 Subject: [PATCH 46/73] [wip] recording raw ready --- src/models/ffmpeg.ts | 9 +++------ src/models/media_output.ts | 8 ++++---- src/models/recorder.ts | 14 ++++---------- src/models/recording_task.ts | 12 +++++------- src/services/resources.ts | 5 ++++- 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 1274f7e..be96eb8 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -2,6 +2,7 @@ import { spawn, ChildProcess } from "node:child_process"; import fs from "node:fs"; import { Readable } from "node:stream"; + import { Logger } from "#src/utils/utils.ts"; import type { rtpData } from "#src/models/media_output"; import { recording } from "#src/config.ts"; @@ -26,7 +27,7 @@ export class FFMPEG { this._init(); } - async close() { + close() { if (this._isClosed) { return; } @@ -35,10 +36,7 @@ export class FFMPEG { logger.verbose(`closing FFMPEG ${this.id}`); if (this._process && !this._process.killed) { logger.debug(`FFMPEG ${this.id} is still running, sending SIGINT`); - await new Promise((resolve) => { - this._process!.kill("SIGINT"); - resolve(true); - }); + this._process!.kill("SIGINT"); logger.debug(`FFMPEG ${this.id} closed`); } } @@ -72,7 +70,6 @@ export class FFMPEG { this._process.on("close", (code) => { logger.verbose(`ffmpeg ${this.id} exited with code ${code}`); - this.close(); }); sdpStream.on("error", (error) => { diff --git a/src/models/media_output.ts b/src/models/media_output.ts index c33428f..777781f 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -59,9 +59,9 @@ export class MediaOutput extends EventEmitter { this._init(); } - async close() { + close() { this._isClosed = true; - await this._cleanup(); + this._cleanup(); } private async _init() { @@ -126,8 +126,8 @@ export class MediaOutput extends EventEmitter { } } - private async _cleanup() { - await this._ffmpeg?.close(); + private _cleanup() { + this._ffmpeg?.close(); this._consumer?.close(); this._transport?.close(); this._port?.release(); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index a471298..5df59ab 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -135,12 +135,8 @@ export class Recorder extends EventEmitter { this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; - const results = await this._stopTasks(); - const hasFailure = results.some((r) => r.status === "rejected"); - if (hasFailure) { - logger.warn("recording failed at saving files"); // TODO more info - } - if (save && !hasFailure) { + this._stopTasks(); + if (save) { await this._folder?.add("metadata.json", JSON.stringify(this._metaData)); await this._folder?.seal( path.join(recording.directory, `${this.channel.name}_${Date.now()}`) @@ -209,13 +205,11 @@ export class Recorder extends EventEmitter { this.channel.on("sessionLeave", this._onSessionLeave); } - private async _stopTasks() { - const proms = []; + private _stopTasks() { for (const task of this._tasks.values()) { - proms.push(task.stop()); + task.stop(); } this._tasks.clear(); - return Promise.allSettled(proms); } private _getRecordingStates(): RecordingStates { diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index d4b056a..8c74acf 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -125,22 +125,20 @@ export class RecordingTask extends EventEmitter { ); } } - await this._clearData(data.type); + this._clearData(data.type); } - private async _clearData(type: STREAM_TYPE) { + private _clearData(type: STREAM_TYPE) { const data = this.recordingDataByStreamType[type]; data.active = false; - await data.mediaOutput?.close(); + data.mediaOutput?.close(); data.mediaOutput = undefined; } - async stop() { + stop() { this._session.off("producer", this._onSessionProducer); - const proms = []; for (const type of Object.values(STREAM_TYPE)) { - proms.push(this._clearData(type)); + this._clearData(type); } - await Promise.all(proms); } } diff --git a/src/services/resources.ts b/src/services/resources.ts index a630b20..820f54d 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -36,7 +36,10 @@ export async function start(): Promise { logger.info( `transport(RTC) layer at ${config.PUBLIC_IP}:${config.RTC_MIN_PORT}-${config.RTC_MAX_PORT}` ); - for (let i = config.dynamicPorts.min; i <= config.dynamicPorts.max; i++) { + /** + * Moving ports in steps of 2 because FFMPEG may use their allocated port + 1 for RTCP + */ + for (let i = config.dynamicPorts.min; i <= config.dynamicPorts.max; i += 2) { availablePorts.add(i); } logger.info( From 7a8611fe5bc8b6fd432a9ee0ae2b04f761ef8a6b Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 11:47:39 +0100 Subject: [PATCH 47/73] [wip] cleanup --- src/models/ffmpeg.ts | 5 ++--- src/models/recorder.ts | 27 ++++++++++++++------------- src/models/session.ts | 4 ++-- src/server.ts | 3 +++ src/services/resources.ts | 4 +++- src/utils/utils.ts | 5 +++-- 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index be96eb8..7f07717 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -35,16 +35,15 @@ export class FFMPEG { this._logStream?.end(); logger.verbose(`closing FFMPEG ${this.id}`); if (this._process && !this._process.killed) { - logger.debug(`FFMPEG ${this.id} is still running, sending SIGINT`); this._process!.kill("SIGINT"); - logger.debug(`FFMPEG ${this.id} closed`); + logger.verbose(`FFMPEG ${this.id} SIGINT sent`); } } private _init() { try { const sdpString = this._createSdpText(); - logger.trace(`FFMPEG ${this.id} SDP:\n${sdpString}`); + logger.verbose(`FFMPEG ${this.id} SDP:\n${sdpString}`); const sdpStream = Readable.from([sdpString]); const args = this._getCommandArgs(); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 5df59ab..6b42051 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -51,7 +51,7 @@ export class Recorder extends EventEmitter { isTranscribing: boolean = false; state: RECORDER_STATE = RECORDER_STATE.STOPPED; private _folder?: Folder; - private readonly channel: Channel; // TODO rename with private prefix + private readonly _channel: Channel; // TODO rename with private prefix private readonly _tasks = new Map(); /** Path to which the final recording will be uploaded to */ private readonly _metaData: Metadata = { @@ -71,7 +71,7 @@ export class Recorder extends EventEmitter { super(); this._onSessionJoin = this._onSessionJoin.bind(this); this._onSessionLeave = this._onSessionLeave.bind(this); - this.channel = channel; + this._channel = channel; this._metaData.uploadAddress = recordingAddress; } @@ -118,6 +118,7 @@ export class Recorder extends EventEmitter { tag, value }); + logger.debug(`Marking ${tag} for channel ${this._channel.name}`); this._metaData.timeStamps[Date.now()] = events; } @@ -129,9 +130,9 @@ export class Recorder extends EventEmitter { if (!this.isActive) { return; } - logger.debug("terminating recorder"); - this.channel.off("sessionJoin", this._onSessionJoin); - this.channel.off("sessionLeave", this._onSessionLeave); + logger.verbose(`terminating recorder for channel ${this._channel.name}`); + this._channel.off("sessionJoin", this._onSessionJoin); + this._channel.off("sessionLeave", this._onSessionLeave); this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; @@ -139,7 +140,7 @@ export class Recorder extends EventEmitter { if (save) { await this._folder?.add("metadata.json", JSON.stringify(this._metaData)); await this._folder?.seal( - path.join(recording.directory, `${this.channel.name}_${Date.now()}`) + path.join(recording.directory, `${this._channel.name}_${Date.now()}`) ); } else { await this._folder?.delete(); @@ -150,7 +151,7 @@ export class Recorder extends EventEmitter { } private _onSessionJoin(id: SessionId) { - const session = this.channel.sessions.get(id); + const session = this._channel.sessions.get(id); if (!session) { return; } @@ -169,12 +170,12 @@ export class Recorder extends EventEmitter { if (this.isRecording || this.isTranscribing) { if (this.isActive) { await this._update().catch(async () => { - logger.warn(`Failed to update recording or ${this.channel.name}`); + logger.warn(`Failed to update recording or ${this._channel.name}`); await this.terminate(); }); } else { await this._init().catch(async () => { - logger.error(`Failed to start recording or ${this.channel.name}`); + logger.error(`Failed to start recording or ${this._channel.name}`); await this.terminate(); }); } @@ -194,15 +195,15 @@ export class Recorder extends EventEmitter { private async _init() { this.state = RECORDER_STATE.STARTED; this._folder = await getFolder(); - logger.trace(`TO IMPLEMENT: recording channel ${this.channel.name}`); - for (const [sessionId, session] of this.channel.sessions) { + logger.verbose(`Initializing recorder for channel: ${this._channel.name}`); + for (const [sessionId, session] of this._channel.sessions) { this._tasks.set( sessionId, new RecordingTask(this, session, this._getRecordingStates()) ); } - this.channel.on("sessionJoin", this._onSessionJoin); - this.channel.on("sessionLeave", this._onSessionLeave); + this._channel.on("sessionJoin", this._onSessionJoin); + this._channel.on("sessionLeave", this._onSessionLeave); } private _stopTasks() { diff --git a/src/models/session.ts b/src/models/session.ts index 2d9a6e1..d59c870 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -603,7 +603,7 @@ export class Session extends EventEmitter { if (!producer) { return; } - logger.debug(`[${this.name}] ${type} ${active ? "on" : "off"}`); + logger.verbose(`[${this.name}] ${type} ${active ? "on" : "off"}`); if (active) { await producer.resume(); @@ -688,7 +688,7 @@ export class Session extends EventEmitter { this.info.isCameraOn = true; } const codec = producer.rtpParameters.codecs[0]; - logger.debug(`[${this.name}] producing ${type}: ${codec?.mimeType}`); + logger.verbose(`[${this.name}] producing ${type}: ${codec?.mimeType}`); this._updateRemoteConsumers(); this._broadcastInfo(); /** diff --git a/src/server.ts b/src/server.ts index 2d36a96..f5ad961 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,9 @@ async function run(): Promise { await resources.start(); await http.start(); logger.info(`ready - PID: ${process.pid}`); + logger.debug(`TO IMPLEMENT: `); + logger.debug(`* get session labels from the odoo server`); + logger.debug(`* write tests for the recorder`); } function cleanup(): void { diff --git a/src/services/resources.ts b/src/services/resources.ts index 820f54d..613fb6a 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -100,7 +100,7 @@ export async function getWorker(): Promise { if (!leastUsedWorker) { throw new Error("No mediasoup workers available"); } - logger.debug(`worker ${leastUsedWorker!.pid} with ${lowestUsage} ru_maxrss was selected`); + logger.verbose(`worker ${leastUsedWorker!.pid} with ${lowestUsage} ru_maxrss was selected`); return leastUsedWorker; } @@ -153,10 +153,12 @@ export class DynamicPort { constructor(number: number) { availablePorts.delete(number); this.number = number; + logger.verbose(`Acquired port ${this.number}`); } release() { availablePorts.add(this.number); + logger.verbose(`Released port ${this.number}`); } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f0590e6..46e654c 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -13,7 +13,8 @@ const ASCII = { yellow: "\x1b[33m", white: "\x1b[37m", cyan: "\x1b[36m", - default: "\x1b[0m" + default: "\x1b[0m", + pink: "\x1b[35m" } } as const; @@ -122,7 +123,7 @@ export class Logger { this._log(console.log, ":INFO:", text, ASCII.color.green); } debug(text: string): void { - this._log(console.log, ":DEBUG:", text); + this._log(console.log, ":DEBUG:", text, ASCII.color.pink); } verbose(text: string): void { this._log(console.log, ":VERBOSE:", text, ASCII.color.white); From ba4721309639ed275b9afd2367c3f7e7537eb2ea Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 11:58:29 +0100 Subject: [PATCH 48/73] [wip] directories --- src/config.ts | 6 ++++++ src/services/resources.ts | 9 ++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index b16d7a9..6cf0f3b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -78,6 +78,12 @@ export const RECORDING: boolean = Boolean(process.env.RECORDING) || testingMode; */ export const RECORDING_PATH: string = process.env.RECORDING_PATH || path.join(tmpDir, "recordings"); fs.mkdirSync(RECORDING_PATH, { recursive: true }); +/** + * The path use by the resources service for temporary files, defaults to `${tmpDir}/resources`, + * can be used for debugging. + */ +export const RESOURCES_PATH: string = process.env.RESOURCES_PATH || path.join(tmpDir, "resources"); +fs.mkdirSync(RESOURCES_PATH, { recursive: true }); /** * The number of workers to spawn (up to core limits) to manage RTC servers. * 0 < NUM_WORKERS <= os.availableParallelism() diff --git a/src/services/resources.ts b/src/services/resources.ts index 613fb6a..6d5b8e4 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -21,13 +21,12 @@ export interface RtcWorker extends mediasoup.types.Worker { const logger = new Logger("RESOURCES"); const workers = new Set(); -const directory = path.join(config.tmpDir, "resources"); export async function start(): Promise { logger.info("starting..."); - // any existing folders are deleted since they are unreachable - await fs.rm(directory, { recursive: true }).catch((error) => { - logger.verbose(`Nothing to remove at ${directory}: ${error}`); + logger.info(`cleaning resources folder (${config.RESOURCES_PATH})...`); + await fs.rm(config.RESOURCES_PATH, { recursive: true }).catch((error) => { + logger.verbose(`Nothing to remove at ${config.RESOURCES_PATH}: ${error}`); }); for (let i = 0; i < config.NUM_WORKERS; ++i) { await makeWorker(); @@ -109,7 +108,7 @@ export class Folder { path: string; static async create(name: string) { - const p: string = path.join(directory, name); + const p: string = path.join(config.RESOURCES_PATH, name); await fs.mkdir(p, { recursive: true }); return new Folder(p); } From 2bbed496a4ba5a174bf17510881a58aef607ec9c Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 12:31:40 +0100 Subject: [PATCH 49/73] [wip] producer lifecycle --- src/models/media_output.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 777781f..8c401c3 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -112,17 +112,19 @@ export class MediaOutput extends EventEmitter { return; } if (this._producer.paused) { + logger.debug(`pausing consumer ${this._consumer?.id}`); this._consumer?.pause(); - await this._ffmpeg?.close(); - this._ffmpeg = undefined; } else { - const fileName = `${this.name}-${Date.now()}`; - logger.verbose(`writing ${fileName} at ${this._directory}`); - const fullName = path.join(this._directory, fileName); - this._ffmpeg = new FFMPEG(this._rtpData, fullName); - logger.verbose(`resuming consumer ${this._consumer?.id}`); + logger.debug(`resuming consumer ${this._consumer?.id}`); + if (!this._ffmpeg) { + const fileName = `${this.name}-${Date.now()}`; + logger.verbose(`writing ${fileName} at ${this._directory}`); + const fullName = path.join(this._directory, fileName); + this._ffmpeg = new FFMPEG(this._rtpData, fullName); + logger.verbose(`resuming consumer ${this._consumer?.id}`); + this.emit("file", fileName); + } this._consumer?.resume(); - this.emit("file", fileName); } } From 16f9e74bf5c47ac7fda59801d33094bae0818cf3 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 13:03:27 +0100 Subject: [PATCH 50/73] [wip] cleanup --- src/config.ts | 3 ++- src/models/ffmpeg.ts | 31 +++++++++++++------------------ src/models/media_output.ts | 6 ++++-- src/services/resources.ts | 25 ++++++++++--------------- 4 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/config.ts b/src/config.ts index 6cf0f3b..3cfc20a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -80,7 +80,8 @@ export const RECORDING_PATH: string = process.env.RECORDING_PATH || path.join(tm fs.mkdirSync(RECORDING_PATH, { recursive: true }); /** * The path use by the resources service for temporary files, defaults to `${tmpDir}/resources`, - * can be used for debugging. + * Keeping the default is fine as this is only used for temporary files used for internal process, but it can + * be changed for debugging. */ export const RESOURCES_PATH: string = process.env.RESOURCES_PATH || path.join(tmpDir, "resources"); fs.mkdirSync(RESOURCES_PATH, { recursive: true }); diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 7f07717..992a228 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -8,22 +8,17 @@ import type { rtpData } from "#src/models/media_output"; import { recording } from "#src/config.ts"; const logger = new Logger("FFMPEG"); - -let currentId = 0; - export class FFMPEG { - readonly id: number; - private readonly rtp: rtpData; + private readonly _rtp: rtpData; private _process?: ChildProcess; private _isClosed = false; - private _filename: string; + private readonly _filename: string; private _logStream?: fs.WriteStream; constructor(rtp: rtpData, filename: string) { - this.rtp = rtp; - this.id = currentId++; + this._rtp = rtp; this._filename = filename; - logger.verbose(`creating FFMPEG for ${this.id}`); + logger.verbose(`creating FFMPEG for ${this._filename}`); this._init(); } @@ -33,17 +28,17 @@ export class FFMPEG { } this._isClosed = true; this._logStream?.end(); - logger.verbose(`closing FFMPEG ${this.id}`); + logger.verbose(`closing FFMPEG ${this._filename}`); if (this._process && !this._process.killed) { this._process!.kill("SIGINT"); - logger.verbose(`FFMPEG ${this.id} SIGINT sent`); + logger.verbose(`FFMPEG ${this._filename} SIGINT sent`); } } private _init() { try { const sdpString = this._createSdpText(); - logger.verbose(`FFMPEG ${this.id} SDP:\n${sdpString}`); + logger.verbose(`FFMPEG ${this._filename} SDP:\n${sdpString}`); const sdpStream = Readable.from([sdpString]); const args = this._getCommandArgs(); @@ -63,12 +58,12 @@ export class FFMPEG { } this._process.on("error", (error) => { - logger.error(`ffmpeg ${this.id} error: ${error.message}`); + logger.error(`ffmpeg ${this._filename} error: ${error.message}`); this.close(); }); this._process.on("close", (code) => { - logger.verbose(`ffmpeg ${this.id} exited with code ${code}`); + logger.verbose(`ffmpeg ${this._filename} exited with code ${code}`); }); sdpStream.on("error", (error) => { @@ -79,13 +74,13 @@ export class FFMPEG { sdpStream.pipe(this._process.stdin); } } catch (error) { - logger.error(`Failed to initialize FFMPEG ${this.id}: ${error}`); + logger.error(`Failed to initialize FFMPEG ${this._filename}: ${error}`); this.close(); } } private _createSdpText(): string { - const { port, payloadType, codec, clockRate, channels, kind } = this.rtp; + const { port, payloadType, codec, clockRate, channels, kind } = this._rtp; if (!port || !payloadType || !codec || !clockRate || !kind) { throw new Error("RTP missing required properties for SDP generation"); @@ -108,7 +103,7 @@ export class FFMPEG { } private _getContainerExtension(): string { - const codec = this.rtp.codec?.toLowerCase(); + const codec = this._rtp.codec?.toLowerCase(); switch (codec) { case "h264": @@ -141,7 +136,7 @@ export class FFMPEG { "-f", "sdp", "-i", "pipe:0" ]; - if (this.rtp.kind === "audio") { + if (this._rtp.kind === "audio") { args = args.concat([ "-map", "0:a:0", "-c:a", "copy" diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 8c401c3..7525f4d 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -9,7 +9,7 @@ import type { MediaKind } from "mediasoup/node/lib/types"; -import { getPort, type DynamicPort } from "#src/services/resources.ts"; +import { DynamicPort } from "#src/services/resources.ts"; import { recording, rtc } from "#src/config.ts"; import { FFMPEG } from "#src/models/ffmpeg.ts"; import { Logger } from "#src/utils/utils.ts"; @@ -66,7 +66,7 @@ export class MediaOutput extends EventEmitter { private async _init() { try { - this._port = getPort(); + this._port = new DynamicPort(); this._transport = await this._router?.createPlainTransport(rtc.plainTransportOptions); if (!this._transport) { throw new Error(`Failed at creating a plain transport for`); @@ -114,6 +114,7 @@ export class MediaOutput extends EventEmitter { if (this._producer.paused) { logger.debug(`pausing consumer ${this._consumer?.id}`); this._consumer?.pause(); + logger.debug("TODO notify pause"); } else { logger.debug(`resuming consumer ${this._consumer?.id}`); if (!this._ffmpeg) { @@ -123,6 +124,7 @@ export class MediaOutput extends EventEmitter { this._ffmpeg = new FFMPEG(this._rtpData, fullName); logger.verbose(`resuming consumer ${this._consumer?.id}`); this.emit("file", fileName); + logger.debug("TODO notify resume"); } this._consumer?.resume(); } diff --git a/src/services/resources.ts b/src/services/resources.ts index 6d5b8e4..f189ad4 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -7,7 +7,7 @@ import * as config from "#src/config.ts"; import { Logger } from "#src/utils/utils.ts"; import { PortLimitReachedError } from "#src/utils/errors.ts"; -const availablePorts: Set = new Set(); +const availablePorts: number[] = []; let unique = 1; // TODO instead of RtcWorker, try Worker @@ -39,10 +39,10 @@ export async function start(): Promise { * Moving ports in steps of 2 because FFMPEG may use their allocated port + 1 for RTCP */ for (let i = config.dynamicPorts.min; i <= config.dynamicPorts.max; i += 2) { - availablePorts.add(i); + availablePorts.push(i); } logger.info( - `${availablePorts.size} dynamic ports available [${config.dynamicPorts.min}-${config.dynamicPorts.max}]` + `${availablePorts.length} dynamic ports available [${config.dynamicPorts.min}-${config.dynamicPorts.max}]` ); } @@ -149,22 +149,17 @@ export async function getFolder(): Promise { export class DynamicPort { number: number; - constructor(number: number) { - availablePorts.delete(number); - this.number = number; + constructor() { + const maybeNum = availablePorts.shift(); + if (!maybeNum) { + throw new PortLimitReachedError(); + } + this.number = maybeNum; logger.verbose(`Acquired port ${this.number}`); } release() { - availablePorts.add(this.number); + availablePorts.push(this.number); logger.verbose(`Released port ${this.number}`); } } - -export function getPort(): DynamicPort { - const number = availablePorts.values().next().value; - if (!number) { - throw new PortLimitReachedError(); - } - return new DynamicPort(number); -} From 1df9313b5e1f438904ab5f07a73433063325ec3f Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 28 Nov 2025 13:34:19 +0100 Subject: [PATCH 51/73] [wip] fixup, only mkdir if recording --- src/config.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3cfc20a..ddb5fb3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,14 +77,18 @@ export const RECORDING: boolean = Boolean(process.env.RECORDING) || testingMode; * The path where the recordings will be saved, defaults to `${tmpDir}/recordings`. */ export const RECORDING_PATH: string = process.env.RECORDING_PATH || path.join(tmpDir, "recordings"); -fs.mkdirSync(RECORDING_PATH, { recursive: true }); +if (RECORDING) { + fs.mkdirSync(RECORDING_PATH, { recursive: true }); +} /** * The path use by the resources service for temporary files, defaults to `${tmpDir}/resources`, * Keeping the default is fine as this is only used for temporary files used for internal process, but it can * be changed for debugging. */ export const RESOURCES_PATH: string = process.env.RESOURCES_PATH || path.join(tmpDir, "resources"); -fs.mkdirSync(RESOURCES_PATH, { recursive: true }); +if (RECORDING) { + fs.mkdirSync(RESOURCES_PATH, { recursive: true }); +} /** * The number of workers to spawn (up to core limits) to manage RTC servers. * 0 < NUM_WORKERS <= os.availableParallelism() From 9b896f47ef74610eeadfb339449bebede985525b Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Mon, 1 Dec 2025 10:24:52 +0100 Subject: [PATCH 52/73] [wip] fixup --- README.md | 1 + src/models/ffmpeg.ts | 16 +++++++++------- src/models/recorder.ts | 2 +- src/models/recording_task.ts | 1 + src/server.ts | 2 ++ 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 77bfac2..5a47ecb 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ the SFU and a [client bundle/library](#client-api-bundle) to connect to it. ## Prerequisites - [Node.js 22.16.0 (LTS)](https://nodejs.org/en/download) +- [FFmpeg 8](https://ffmpeg.org/download.html) (if using the recording feature) ## Before deployment diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 992a228..947877b 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -45,17 +45,17 @@ export class FFMPEG { logger.verbose(`spawning ffmpeg with args: ${args.join(" ")}`); + /** + * while testing spawn should be mocked so that we don't make subprocesses and save files + * just test if the args are correct (ffmpeg / sdp compliant) + */ + logger.debug(`TODO: mock spawn`); this._process = spawn("ffmpeg", args); this._logStream = fs.createWriteStream(`${this._filename}.log`); - if (this._process.stderr) { - this._process.stderr.pipe(this._logStream, { end: false }); - } - - if (this._process.stdout) { - this._process.stdout.pipe(this._logStream, { end: false }); - } + this._process.stderr?.pipe(this._logStream, { end: false }); + this._process.stdout?.pipe(this._logStream, { end: false }); this._process.on("error", (error) => { logger.error(`ffmpeg ${this._filename} error: ${error.message}`); @@ -80,6 +80,7 @@ export class FFMPEG { } private _createSdpText(): string { + // TODO docstring on sdp text const { port, payloadType, codec, clockRate, channels, kind } = this._rtp; if (!port || !payloadType || !codec || !clockRate || !kind) { @@ -129,6 +130,7 @@ export class FFMPEG { } private _getCommandArgs(): string[] { + // TODO docstring on command args let args = [ "-loglevel", "debug", // TODO remove "-protocol_whitelist", "pipe,udp,rtp", diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 6b42051..1ece1b1 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -136,7 +136,7 @@ export class Recorder extends EventEmitter { this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; - this._stopTasks(); + this._stopTasks(); // may want to make it async (resolve on child process close/exit) so we can wait for the end of ffmpeg, when files are no longer written on. to check. if (save) { await this._folder?.add("metadata.json", JSON.stringify(this._metaData)); await this._folder?.seal( diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 8c74acf..4c5db03 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -34,6 +34,7 @@ type RecordingDataByStreamType = { const logger = new Logger("RECORDING_TASK"); +// TODO docstring export class RecordingTask extends EventEmitter { private _session: Session; private _recorder: Recorder; diff --git a/src/server.ts b/src/server.ts index f5ad961..7ef39d6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,8 @@ async function run(): Promise { logger.debug(`TO IMPLEMENT: `); logger.debug(`* get session labels from the odoo server`); logger.debug(`* write tests for the recorder`); + logger.debug(`* use Promise.withResolvers() instead of Deferred`); + logger.debug(`* tests with mocked spawn`); } function cleanup(): void { From 6af1c741438b179de8f45de961df419676a677c8 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 4 Dec 2025 06:16:20 +0100 Subject: [PATCH 53/73] [wip] timestamps plain array --- src/models/recorder.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 1ece1b1..050be36 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -23,7 +23,7 @@ export enum RECORDER_STATE { } export type Metadata = { uploadAddress: string; - timeStamps: Record>; + timeStamps: Array<{ tag: TIME_TAG; timestamp: number; value: object }>; }; const logger = new Logger("RECORDER"); @@ -56,7 +56,7 @@ export class Recorder extends EventEmitter { /** Path to which the final recording will be uploaded to */ private readonly _metaData: Metadata = { uploadAddress: "", - timeStamps: {} + timeStamps: [] }; get isActive(): boolean { @@ -113,13 +113,11 @@ export class Recorder extends EventEmitter { } mark(tag: TIME_TAG, value: object = {}) { - const events = this._metaData.timeStamps[Date.now()] || []; - events.push({ + this._metaData.timeStamps.push({ tag, + timestamp: Date.now(), value }); - logger.debug(`Marking ${tag} for channel ${this._channel.name}`); - this._metaData.timeStamps[Date.now()] = events; } /** @@ -146,7 +144,7 @@ export class Recorder extends EventEmitter { await this._folder?.delete(); } this._folder = undefined; - this._metaData.timeStamps = {}; + this._metaData.timeStamps = []; this.state = RECORDER_STATE.STOPPED; } From 0ccc9ff82939bfbe833723bcfa844fc982c247ba Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 4 Dec 2025 09:12:42 +0100 Subject: [PATCH 54/73] [wip] test with custom env --- src/config.ts | 2 +- tests/network.test.ts | 48 ------------------------ tests/recording.test.ts | 82 +++++++++++++++++++++++++++++++++++++++++ tests/utils/utils.ts | 29 +++++++++++++++ 4 files changed, 112 insertions(+), 49 deletions(-) create mode 100644 tests/recording.test.ts diff --git a/src/config.ts b/src/config.ts index ddb5fb3..4edeeb2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,7 +72,7 @@ export const PORT: number = Number(process.env.PORT) || 8070; /** * Whether the recording feature is enabled, false by default. */ -export const RECORDING: boolean = Boolean(process.env.RECORDING) || testingMode; +export const RECORDING: boolean = Boolean(process.env.RECORDING); /** * The path where the recordings will be saved, defaults to `${tmpDir}/recordings`. */ diff --git a/tests/network.test.ts b/tests/network.test.ts index a1e5701..3342175 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -11,7 +11,6 @@ import { timeouts } from "#src/config"; import { LocalNetwork } from "#tests/utils/network"; import { delay } from "#tests/utils/utils.ts"; -import { RECORDER_STATE } from "#src/models/recorder.ts"; const HTTP_INTERFACE = "0.0.0.0"; const PORT = 61254; @@ -290,51 +289,4 @@ describe("Full network", () => { expect(event1.detail.payload.message).toBe(message); expect(event2.detail.payload.message).toBe(message); }); - test("POC RECORDING", async () => { - const channelUUID = await network.getChannelUUID(); - const user1 = await network.connect(channelUUID, 1); - await user1.isConnected; - const user2 = await network.connect(channelUUID, 3); - await user2.isConnected; - expect(user2.sfuClient.availableFeatures.recording).toBe(true); - const startResult = (await user2.sfuClient.startRecording()) as boolean; - expect(startResult).toBe(true); - const stopResult = (await user2.sfuClient.stopRecording()) as boolean; - expect(stopResult).toBe(false); - }); - test("POC TRANSCRIPTION", async () => { - const channelUUID = await network.getChannelUUID(); - const user1 = await network.connect(channelUUID, 1); - await user1.isConnected; - const user2 = await network.connect(channelUUID, 3); - await user2.isConnected; - expect(user2.sfuClient.availableFeatures.transcription).toBe(true); - const startResult = (await user2.sfuClient.startTranscription()) as boolean; - expect(startResult).toBe(true); - const stopResult = (await user2.sfuClient.stopTranscription()) as boolean; - expect(stopResult).toBe(false); - }); - test("POC COMBINED TRANSCRIPTION/RECORDING", async () => { - const channelUUID = await network.getChannelUUID(); - const channel = Channel.records.get(channelUUID); - const user1 = await network.connect(channelUUID, 1); - await user1.isConnected; - const user2 = await network.connect(channelUUID, 3); - await user2.isConnected; - await user2.sfuClient.startTranscription(); - await user1.sfuClient.startRecording(); - const recorder = channel!.recorder!; - expect(recorder.isRecording).toBe(true); - expect(recorder.isTranscribing).toBe(true); - expect(recorder.state).toBe(RECORDER_STATE.STARTED); - await user1.sfuClient.stopRecording(); - // stopping the recording while a transcription is active should not stop the transcription - expect(recorder.isRecording).toBe(false); - expect(recorder.isTranscribing).toBe(true); - expect(recorder.state).toBe(RECORDER_STATE.STARTED); - await user2.sfuClient.stopTranscription(); - expect(recorder.isRecording).toBe(false); - expect(recorder.isTranscribing).toBe(false); - expect(recorder.state).toBe(RECORDER_STATE.STOPPED); - }); }); diff --git a/tests/recording.test.ts b/tests/recording.test.ts new file mode 100644 index 0000000..f411f10 --- /dev/null +++ b/tests/recording.test.ts @@ -0,0 +1,82 @@ +import { describe, expect } from "@jest/globals"; + +import { setConfig } from "./utils/utils"; +import { RECORDER_STATE } from "#src/models/recorder.ts"; + +async function recordingSetup(config: Record) { + const restoreConfig = setConfig(config); + const { LocalNetwork } = await import("#tests/utils/network"); + const { Channel } = await import("#src/models/channel"); + const network = new LocalNetwork(); + await network.start("0.0.0.0", 61254); + return { + restore: () => { + restoreConfig(); + network.close(); + }, + getChannel: (uuid: string) => Channel.records.get(uuid), + network + }; +} + +describe("Recording & Transcription", () => { + test("Does not record when the feature is disabled", async () => { + const { restore } = await recordingSetup({ RECORDING: "" }); + const config = await import("#src/config"); + expect(config.recording.enabled).toBe(false); + restore(); + }); + test("can record", async () => { + const { restore, network } = await recordingSetup({ RECORDING: "true" }); + const channelUUID = await network.getChannelUUID(); + const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; + const user2 = await network.connect(channelUUID, 3); + await user2.isConnected; + expect(user2.sfuClient.availableFeatures.recording).toBe(true); + const startResult = (await user2.sfuClient.startRecording()) as boolean; + expect(startResult).toBe(true); + const stopResult = (await user2.sfuClient.stopRecording()) as boolean; + expect(stopResult).toBe(false); + restore(); + }); + test("can transcribe", async () => { + const { restore, network } = await recordingSetup({ RECORDING: "enabled" }); + const channelUUID = await network.getChannelUUID(); + const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; + const user2 = await network.connect(channelUUID, 3); + await user2.isConnected; + expect(user2.sfuClient.availableFeatures.transcription).toBe(true); + const startResult = (await user2.sfuClient.startTranscription()) as boolean; + expect(startResult).toBe(true); + const stopResult = (await user2.sfuClient.stopTranscription()) as boolean; + expect(stopResult).toBe(false); + restore(); + }); + test("can record and transcribe simultaneously", async () => { + const { restore, network, getChannel } = await recordingSetup({ RECORDING: "true" }); + const channelUUID = await network.getChannelUUID(); + const channel = getChannel(channelUUID); + const user1 = await network.connect(channelUUID, 1); + await user1.isConnected; + const user2 = await network.connect(channelUUID, 3); + await user2.isConnected; + await user2.sfuClient.startTranscription(); + await user1.sfuClient.startRecording(); + const recorder = channel!.recorder!; + expect(recorder.isRecording).toBe(true); + expect(recorder.isTranscribing).toBe(true); + expect(recorder.state).toBe(RECORDER_STATE.STARTED); + await user1.sfuClient.stopRecording(); + // stopping the recording while a transcription is active should not stop the transcription + expect(recorder.isRecording).toBe(false); + expect(recorder.isTranscribing).toBe(true); + expect(recorder.state).toBe(RECORDER_STATE.STARTED); + await user2.sfuClient.stopTranscription(); + expect(recorder.isRecording).toBe(false); + expect(recorder.isTranscribing).toBe(false); + expect(recorder.state).toBe(RECORDER_STATE.STOPPED); + restore(); + }); +}); diff --git a/tests/utils/utils.ts b/tests/utils/utils.ts index ced5754..afec51d 100644 --- a/tests/utils/utils.ts +++ b/tests/utils/utils.ts @@ -1,3 +1,32 @@ +import { jest } from "@jest/globals"; + +/** + * Sets the environment variables and resets the modules to force a reload of the configuration. + * Returns a function to restore the environment variables and reset the modules again. + * + * @param config - The environment variables to set. Pass `undefined` to unset a variable. + * @returns A function to restore the environment. + */ +export const setConfig = (config: Record): (() => void) => { + const originalEnv = { ...process.env }; + for (const [key, value] of Object.entries(config)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + jest.resetModules(); + + return () => { + for (const key in process.env) { + delete process.env[key]; + } + Object.assign(process.env, originalEnv); + jest.resetModules(); + }; +}; + export function delay(ms = 0): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } From ff41634eb4e65e564eefff0987da039aaf612137 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 4 Dec 2025 10:48:30 +0100 Subject: [PATCH 55/73] [wip] fixup --- src/models/recorder.ts | 12 ++++++++---- tests/recording.test.ts | 8 ++++---- tests/utils/utils.ts | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 050be36..c2c3c2e 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -22,7 +22,7 @@ export enum RECORDER_STATE { STOPPED = "stopped" } export type Metadata = { - uploadAddress: string; + forwardAddress: string; timeStamps: Array<{ tag: TIME_TAG; timestamp: number; value: object }>; }; @@ -55,7 +55,7 @@ export class Recorder extends EventEmitter { private readonly _tasks = new Map(); /** Path to which the final recording will be uploaded to */ private readonly _metaData: Metadata = { - uploadAddress: "", + forwardAddress: "", timeStamps: [] }; @@ -67,12 +67,16 @@ export class Recorder extends EventEmitter { return this._folder?.path; } - constructor(channel: Channel, recordingAddress: string) { + /** + * @param channel - the channel to record + * @param forwardAddress - the address to which the recording will be forwarded + */ + constructor(channel: Channel, forwardAddress: string) { super(); this._onSessionJoin = this._onSessionJoin.bind(this); this._onSessionLeave = this._onSessionLeave.bind(this); this._channel = channel; - this._metaData.uploadAddress = recordingAddress; + this._metaData.forwardAddress = forwardAddress; } async start() { diff --git a/tests/recording.test.ts b/tests/recording.test.ts index f411f10..dd5c36b 100644 --- a/tests/recording.test.ts +++ b/tests/recording.test.ts @@ -1,17 +1,17 @@ import { describe, expect } from "@jest/globals"; -import { setConfig } from "./utils/utils"; +import { withMockEnv } from "./utils/utils"; import { RECORDER_STATE } from "#src/models/recorder.ts"; -async function recordingSetup(config: Record) { - const restoreConfig = setConfig(config); +async function recordingSetup(env: Record) { + const restoreEnv = withMockEnv(env); const { LocalNetwork } = await import("#tests/utils/network"); const { Channel } = await import("#src/models/channel"); const network = new LocalNetwork(); await network.start("0.0.0.0", 61254); return { restore: () => { - restoreConfig(); + restoreEnv(); network.close(); }, getChannel: (uuid: string) => Channel.records.get(uuid), diff --git a/tests/utils/utils.ts b/tests/utils/utils.ts index afec51d..a47b65d 100644 --- a/tests/utils/utils.ts +++ b/tests/utils/utils.ts @@ -7,7 +7,7 @@ import { jest } from "@jest/globals"; * @param config - The environment variables to set. Pass `undefined` to unset a variable. * @returns A function to restore the environment. */ -export const setConfig = (config: Record): (() => void) => { +export function withMockEnv(config: Record): () => void { const originalEnv = { ...process.env }; for (const [key, value] of Object.entries(config)) { if (value === undefined) { @@ -25,7 +25,7 @@ export const setConfig = (config: Record): (() => vo Object.assign(process.env, originalEnv); jest.resetModules(); }; -}; +} export function delay(ms = 0): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); From 7de74fcbf0fdd5ad3f8f07076c746f0398bb6e79 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 4 Dec 2025 12:31:25 +0100 Subject: [PATCH 56/73] [wip] test down the ffpmeg layer --- src/models/ffmpeg.ts | 2 +- tests/recording.test.ts | 105 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 947877b..7665073 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -49,7 +49,7 @@ export class FFMPEG { * while testing spawn should be mocked so that we don't make subprocesses and save files * just test if the args are correct (ffmpeg / sdp compliant) */ - logger.debug(`TODO: mock spawn`); + this._process = spawn("ffmpeg", args); this._logStream = fs.createWriteStream(`${this._filename}.log`); diff --git a/tests/recording.test.ts b/tests/recording.test.ts index dd5c36b..0426151 100644 --- a/tests/recording.test.ts +++ b/tests/recording.test.ts @@ -1,4 +1,35 @@ -import { describe, expect } from "@jest/globals"; +import type { SpawnOptions, ChildProcess } from "node:child_process"; + +import { describe, expect, jest, test } from "@jest/globals"; +import { PassThrough } from "node:stream"; +import { EventEmitter } from "node:events"; +import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; +import { STREAM_TYPE } from "#src/shared/enums.ts"; + +type ChildProcessLike = { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + kill: (signal?: number | string) => boolean; + killed: boolean; + pid: number; +} & ChildProcess; + +const mockSpawn = jest.fn(); +jest.mock("node:child_process", () => { + const original = jest.requireActual("node:child_process") as { + spawn: (command: string, args: string[], options: SpawnOptions) => ChildProcessLike; + }; + return { + ...original, + spawn: (command: string, args: string[], options: SpawnOptions): ChildProcessLike => { + if (command === "ffmpeg") { + return mockSpawn(command, args, options) as ChildProcessLike; + } + return original.spawn(command, args, options); + } + }; +}); import { withMockEnv } from "./utils/utils"; import { RECORDER_STATE } from "#src/models/recorder.ts"; @@ -79,4 +110,76 @@ describe("Recording & Transcription", () => { expect(recorder.state).toBe(RECORDER_STATE.STOPPED); restore(); }); + + test("Spawns FFMPEG for both audio and video streams", async () => { + mockSpawn.mockImplementation(() => { + const mp = new EventEmitter() as ChildProcessLike; + mp.stdin = new PassThrough(); + mp.stdout = new PassThrough(); + mp.stderr = new PassThrough(); + mp.kill = jest.fn() as (signal?: number | string) => boolean; + mp.killed = false; + return mp; + }); + + const { restore, network } = await recordingSetup({ RECORDING: "true" }); + + try { + const channelUUID = await network.getChannelUUID(); + const user = await network.connect(channelUUID, 1); + await user.isConnected; + await user.sfuClient.startRecording(); + + const audioTrack = new FakeMediaStreamTrack({ kind: "audio" }); + await user.sfuClient.updateUpload(STREAM_TYPE.AUDIO, audioTrack); + + const videoTrack = new FakeMediaStreamTrack({ kind: "video" }); + await user.sfuClient.updateUpload(STREAM_TYPE.CAMERA, videoTrack); + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (mockSpawn.mock.calls.length >= 2) { + clearInterval(interval); + resolve(); + } + }, 100); + }); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + + const results = mockSpawn.mock.results as Array<{ value: ChildProcessLike }>; + const process1 = results[0].value; + const process2 = results[1].value; + + const readSdp = (proc: ChildProcessLike) => + new Promise((resolve) => { + if (proc.stdin!.readableLength > 0) { + resolve(proc.stdin!.read().toString()); + } else { + proc.stdin!.once("data", (chunk: Buffer) => resolve(chunk.toString())); + } + }); + + const sdp1 = await readSdp(process1); + const sdp2 = await readSdp(process2); + + const sdps = [sdp1, sdp2]; + const audioSdp = sdps.find((s) => s.includes("m=audio")); + const videoSdp = sdps.find((s) => s.includes("m=video")); + + expect(audioSdp).toBeDefined(); + expect(audioSdp).toContain("s=FFmpeg"); + expect(videoSdp).toBeDefined(); + expect(videoSdp).toContain("s=FFmpeg"); + + const callArgs = mockSpawn.mock.calls.map((c) => c[1] as string[]); + const audioArgs = callArgs.find((args) => args.includes("-c:a")); + const videoArgs = callArgs.find((args) => args.includes("-c:v")); + + expect(audioArgs).toBeDefined(); + expect(videoArgs).toBeDefined(); + } finally { + restore(); + } + }); }); From aa25deeb1bc57d3251d819bcc376143ba5f35f38 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 4 Dec 2025 12:45:46 +0100 Subject: [PATCH 57/73] [imp] tests with temp directory --- tests/recording.test.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/recording.test.ts b/tests/recording.test.ts index 0426151..02aa97d 100644 --- a/tests/recording.test.ts +++ b/tests/recording.test.ts @@ -1,11 +1,18 @@ import type { SpawnOptions, ChildProcess } from "node:child_process"; - -import { describe, expect, jest, test } from "@jest/globals"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { PassThrough } from "node:stream"; import { EventEmitter } from "node:events"; + +import { describe, expect, jest, test } from "@jest/globals"; + +import { RECORDER_STATE } from "#src/models/recorder.ts"; import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; import { STREAM_TYPE } from "#src/shared/enums.ts"; +import { withMockEnv } from "./utils/utils"; + type ChildProcessLike = { stdin: PassThrough; stdout: PassThrough; @@ -31,11 +38,17 @@ jest.mock("node:child_process", () => { }; }); -import { withMockEnv } from "./utils/utils"; -import { RECORDER_STATE } from "#src/models/recorder.ts"; - async function recordingSetup(env: Record) { - const restoreEnv = withMockEnv(env); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sfu-test-")); + const resourcesPath = path.join(tmpDir, "resources"); + const recordingPath = path.join(tmpDir, "recordings"); + + // making sure that during the tests, we don't clog the resources and recordings directories + const restoreEnv = withMockEnv({ + RESOURCES_PATH: resourcesPath, + RECORDING_PATH: recordingPath, + ...env + }); const { LocalNetwork } = await import("#tests/utils/network"); const { Channel } = await import("#src/models/channel"); const network = new LocalNetwork(); @@ -44,6 +57,7 @@ async function recordingSetup(env: Record) { restore: () => { restoreEnv(); network.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); }, getChannel: (uuid: string) => Channel.records.get(uuid), network From c5c7a3ba094172d593e907787d87fc398f12594d Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 4 Dec 2025 13:25:05 +0100 Subject: [PATCH 58/73] [wip] more http test --- tests/http.test.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/http.test.ts b/tests/http.test.ts index 665ba1e..8aadc3a 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -6,6 +6,7 @@ import * as config from "#src/config"; import { API_VERSION } from "#src/services/http"; import { LocalNetwork, makeJwt } from "#tests/utils/network"; +import { withMockEnv } from "#tests/utils/utils"; import { once } from "node:events"; import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; import { STREAM_TYPE } from "#src/shared/enums.ts"; @@ -165,3 +166,77 @@ describe("HTTP", () => { expect(response2.status).toBe(404); }); }); + +describe("HTTP PROXY", () => { + let network: LocalNetwork; + + afterEach(() => { + network?.close(); + jest.useRealTimers(); + }); + + test("headers are ignored when PROXY is not set", async () => { + network = new LocalNetwork(); + await network.start(HTTP_INTERFACE, PORT); + + const response = await fetch(`http://${HTTP_INTERFACE}:${PORT}/v${API_VERSION}/channel`, { + method: "GET", + headers: { + Authorization: "jwt " + makeJwt({ iss: `http://${HTTP_INTERFACE}:${PORT}/` }), + "X-Forwarded-Host": "proxy-host", + "X-Forwarded-Proto": "https", + "X-Forwarded-For": "1.2.3.4" + } + }); + expect(response.ok).toBe(true); + const { url } = await response.json(); + expect(url).toBe(`http://${config.PUBLIC_IP}:${config.PORT}`); + }); + + test("headers are used when PROXY is set", async () => { + const restore = withMockEnv({ PROXY: "true" }); + const { LocalNetwork: LocalNetworkProxy } = await import("#tests/utils/network"); + + network = new LocalNetworkProxy(); + await network.start(HTTP_INTERFACE, PORT); + + const response = await fetch(`http://${HTTP_INTERFACE}:${PORT}/v${API_VERSION}/channel`, { + method: "GET", + headers: { + Authorization: "jwt " + makeJwt({ iss: `http://${HTTP_INTERFACE}:${PORT}/` }), + "X-Forwarded-Host": "proxy-host", + "X-Forwarded-Proto": "https" + } + }); + expect(response.ok).toBe(true); + const { url } = await response.json(); + expect(url).toBe("https://proxy-host"); + + restore(); + }); + + test("X-Forwarded-For updates remoteAddress", async () => { + const restore = withMockEnv({ PROXY: "true" }); + const { LocalNetwork: LocalNetworkProxy } = await import("#tests/utils/network"); + const { Channel: ChannelProxy } = await import("#src/models/channel"); + + network = new LocalNetworkProxy(); + await network.start(HTTP_INTERFACE, PORT); + + const response = await fetch(`http://${HTTP_INTERFACE}:${PORT}/v${API_VERSION}/channel`, { + method: "GET", + headers: { + Authorization: "jwt " + makeJwt({ iss: `http://${HTTP_INTERFACE}:${PORT}/` }), + "X-Forwarded-For": "1.2.3.4" + } + }); + expect(response.ok).toBe(true); + const { uuid } = await response.json(); + + const channel = ChannelProxy.records.get(uuid); + expect(channel).toBeDefined(); + expect(channel!.remoteAddress).toBe("1.2.3.4"); + + restore(); + }); +}); From 35cf3f465c9596f346febbbcffbac75f3ed50d53 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Thu, 4 Dec 2025 13:34:21 +0100 Subject: [PATCH 59/73] [wip] test some route edge cases --- tests/http.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/http.test.ts b/tests/http.test.ts index 8aadc3a..b8ba31f 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -75,6 +75,21 @@ describe("HTTP", () => { expect(Channel.records.get(uuid)).toBeDefined(); expect(url).toBe(`http://${config.PUBLIC_IP}:${config.PORT}`); }); + test("/channel fails without authorization header", async () => { + const response = await fetch(`http://${HTTP_INTERFACE}:${PORT}/v${API_VERSION}/channel`, { + method: "GET" + }); + expect(response.status).toBe(401); + }); + test("/channel fails without issuer claim", async () => { + const response = await fetch(`http://${HTTP_INTERFACE}:${PORT}/v${API_VERSION}/channel`, { + method: "GET", + headers: { + Authorization: "jwt " + makeJwt({}) + } + }); + expect(response.status).toBe(403); + }); test("/noop", async () => { const response = await fetch(`http://${HTTP_INTERFACE}:${PORT}/v${API_VERSION}/noop`, { method: "GET" From ff8116224cf30fdb24b011fd63b23a2ef7d908a4 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 07:41:22 +0100 Subject: [PATCH 60/73] [wip] cleanup --- src/models/recording_task.ts | 2 - src/services/http.ts | 2 +- tests/auth.test.ts | 1 + tests/http.test.ts | 104 ++++++++++++++++++++++++++++++++--- tests/recording.test.ts | 4 +- 5 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 4c5db03..03d1605 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { EventEmitter } from "node:events"; import type { Producer } from "mediasoup/node/lib/types"; @@ -7,7 +6,6 @@ import { MediaOutput } from "#src/models/media_output.ts"; import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; import { TIME_TAG, type Recorder } from "#src/models/recorder.ts"; - import { STREAM_TYPE } from "#src/shared/enums.ts"; export type RecordingStates = { diff --git a/src/services/http.ts b/src/services/http.ts index bb20ef3..e636436 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -182,7 +182,7 @@ function setupRoutes(routeListener: RouteListener): void { }); } -class RouteListener { +export class RouteListener { private readonly GETs = new Map(); private readonly POSTs = new Map(); private readonly OPTIONs = new Map(); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 058efc8..950f718 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,4 +1,5 @@ import { describe, beforeEach, afterEach, expect } from "@jest/globals"; + import * as auth from "#src/services/auth"; import { AuthenticationError } from "#src/utils/errors"; diff --git a/tests/http.test.ts b/tests/http.test.ts index b8ba31f..e5aa25a 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,15 +1,17 @@ -import { afterEach, beforeEach, describe, expect, jest } from "@jest/globals"; +import http from "node:http"; +import { once } from "node:events"; + +import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; +import { STREAM_TYPE } from "#src/shared/enums.ts"; import { SESSION_STATE } from "#src/models/session"; import { Channel } from "#src/models/channel"; import * as config from "#src/config"; -import { API_VERSION } from "#src/services/http"; - +import { API_VERSION, RouteListener } from "#src/services/http"; import { LocalNetwork, makeJwt } from "#tests/utils/network"; + import { withMockEnv } from "#tests/utils/utils"; -import { once } from "node:events"; -import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; -import { STREAM_TYPE } from "#src/shared/enums.ts"; const HTTP_INTERFACE = "0.0.0.0"; const PORT = 6971; @@ -182,7 +184,7 @@ describe("HTTP", () => { }); }); -describe("HTTP PROXY", () => { +describe("HTTP Proxy", () => { let network: LocalNetwork; afterEach(() => { @@ -255,3 +257,91 @@ describe("HTTP PROXY", () => { restore(); }); }); + +describe("Route listener implementation", () => { + let server: http.Server; + let port: number; + let routeListener: RouteListener; + + beforeEach(async () => { + routeListener = new RouteListener(); + server = http.createServer(routeListener.listen); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (typeof address === "object" && address) { + port = address?.port; + } + resolve(); + }); + }); + }); + + afterEach(() => { + server.close(); + }); + + test("GET route", async () => { + routeListener.get("/test", { + callback: (req, res) => { + res.statusCode = 200; + return res.end("ok"); + } + }); + const response = await fetch(`http://127.0.0.1:${port}/test`); + expect(response.ok).toBe(true); + expect(await response.text()).toBe("ok"); + }); + + test("POST route", async () => { + routeListener.post("/test", { + callback: (req, res) => { + res.statusCode = 201; + return res.end("created"); + } + }); + const response = await fetch(`http://127.0.0.1:${port}/test`, { method: "POST" }); + expect(response.status).toBe(201); + expect(await response.text()).toBe("created"); + }); + + test("GET/CORS", async () => { + routeListener.get("/cors", { + cors: "*", + callback: (req, res) => { + res.statusCode = 200; + return res.end("cors"); + } + }); + + const optionsResponse = await fetch(`http://127.0.0.1:${port}/cors`, { method: "OPTIONS" }); + expect(optionsResponse.status).toBe(202); + expect(optionsResponse.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(optionsResponse.headers.get("Access-Control-Allow-Methods")).toBe("GET, OPTIONS"); + + const getResponse = await fetch(`http://127.0.0.1:${port}/cors`); + expect(getResponse.ok).toBe(true); + expect(getResponse.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + test("POST/CORS", async () => { + routeListener.post("/cors-post", { + cors: "*", + callback: (req, res) => { + res.statusCode = 201; + return res.end("cors-post"); + } + }); + + const optionsResponse = await fetch(`http://127.0.0.1:${port}/cors-post`, { + method: "OPTIONS" + }); + expect(optionsResponse.status).toBe(202); + expect(optionsResponse.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(optionsResponse.headers.get("Access-Control-Allow-Methods")).toBe("POST, OPTIONS"); + + const postResponse = await fetch(`http://127.0.0.1:${port}/cors-post`, { method: "POST" }); + expect(postResponse.status).toBe(201); + expect(postResponse.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/tests/recording.test.ts b/tests/recording.test.ts index 02aa97d..0caf1d3 100644 --- a/tests/recording.test.ts +++ b/tests/recording.test.ts @@ -6,12 +6,12 @@ import { PassThrough } from "node:stream"; import { EventEmitter } from "node:events"; import { describe, expect, jest, test } from "@jest/globals"; +import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; import { RECORDER_STATE } from "#src/models/recorder.ts"; -import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; import { STREAM_TYPE } from "#src/shared/enums.ts"; -import { withMockEnv } from "./utils/utils"; +import { withMockEnv } from "#tests/utils/utils"; type ChildProcessLike = { stdin: PassThrough; From 2f69e6d035954bf38381eccfef10c8dd7474d45f Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 07:54:52 +0100 Subject: [PATCH 61/73] [wip] test api when not connected --- tests/recording.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/recording.test.ts b/tests/recording.test.ts index 0caf1d3..1bcd347 100644 --- a/tests/recording.test.ts +++ b/tests/recording.test.ts @@ -71,6 +71,16 @@ describe("Recording & Transcription", () => { expect(config.recording.enabled).toBe(false); restore(); }); + + test("Returns false when calling start/stop recording/transcription when not connected", async () => { + const { SfuClient } = await import("#src/client"); + const client = new SfuClient(); + + expect(await client.startRecording()).toBe(false); + expect(await client.stopRecording()).toBe(false); + expect(await client.startTranscription()).toBe(false); + expect(await client.stopTranscription()).toBe(false); + }); test("can record", async () => { const { restore, network } = await recordingSetup({ RECORDING: "true" }); const channelUUID = await network.getChannelUUID(); From 63a67becfb6b154e1f9c1e15e45bda83d39a72f0 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 08:12:03 +0100 Subject: [PATCH 62/73] [wip] remove arbitrary delay in test --- src/client.ts | 23 ++++++++++++++++------- src/models/session.ts | 2 ++ tests/network.test.ts | 15 ++++----------- tests/utils/utils.ts | 4 ---- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/client.ts b/src/client.ts index 0b3a5d3..bec80fb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -407,13 +407,7 @@ export class SfuClient extends EventTarget { appData: { type } }); } catch (error) { - this.errors.push(error as Error); - // if we reach the max error count, we restart the whole connection from scratch - if (this.errors.length > MAX_ERRORS) { - // not awaited - this._handleConnectionEnd(); - return; - } + this._handleError(error as Error); // retry after some delay this._recoverProducerTimeouts[type] = setTimeout(async () => { await this.updateUpload(type, track); @@ -458,6 +452,21 @@ export class SfuClient extends EventTarget { this._bus.onRequest = this._handleRequest; } + private _handleError(error: Error) { + this.errors.push(error); + this.dispatchEvent( + new CustomEvent("handledError", { + detail: { error } + }) + ); + // if we reach the max error count, we restart the whole connection from scratch + if (this.errors.length > MAX_ERRORS) { + // not awaited + this._handleConnectionEnd(); + return; + } + } + private _close(cause?: string): void { this._clear(); const state = SfuClientState.CLOSED; diff --git a/src/models/session.ts b/src/models/session.ts index d59c870..c421dab 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -118,6 +118,7 @@ const logger = new Logger("SESSION"); * @fires Session#stateChange - Emitted when session state changes * @fires Session#close - Emitted when session is closed * @fires Session#producer - Emitted when a new producer is created + * @fires Session#handledError - Emitted when an error is handled */ export class Session extends EventEmitter { /** Communication bus for WebSocket messaging */ @@ -537,6 +538,7 @@ export class Session extends EventEmitter { */ private _handleError(error: Error): void { this.errors.push(error); + this.emit("handledError", error); logger.error( `[${this.name}] handling error (${this.errors.length}): ${error.message} : ${error.stack}` ); diff --git a/tests/network.test.ts b/tests/network.test.ts index 3342175..e50ef83 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -10,7 +10,6 @@ import { SFU_CLIENT_STATE } from "#src/client"; import { timeouts } from "#src/config"; import { LocalNetwork } from "#tests/utils/network"; -import { delay } from "#tests/utils/utils.ts"; const HTTP_INTERFACE = "0.0.0.0"; const PORT = 61254; @@ -170,10 +169,12 @@ describe("Full network", () => { const sender = await network.connect(channelUUID, 3); await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); + const errorPromise = once(sender.sfuClient, "handledError"); // closing the transport so the `updateUpload` should fail. // @ts-expect-error accessing private property for testing purposes sender.sfuClient._ctsTransport.close(); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); + await errorPromise; expect(sender.sfuClient.errors.length).toBe(1); expect(sender.sfuClient.state).toBe(SFU_CLIENT_STATE.CONNECTED); }); @@ -184,12 +185,12 @@ describe("Full network", () => { const sender = await network.connect(channelUUID, 3); await sender.isConnected; const track = new FakeMediaStreamTrack({ kind: "audio" }); + const errorProm = once(user.session, "handledError"); // closing the transport so the consumption should fail. // @ts-expect-error accessing private property for testing purposes user.session._stcTransport.close(); await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); - // not ideal but we have to wait a tick for the websocket message to go through - await delay(); + await errorProm; expect(user.session.errors.length).toBe(1); expect(user.session.state).toBe(SESSION_STATE.CONNECTED); }); @@ -218,15 +219,7 @@ describe("Full network", () => { await sender.sfuClient.updateUpload(STREAM_TYPE.AUDIO, track); await once(user1.sfuClient, "update"); user1.sfuClient.updateDownload(sender.session.id, { audio: false }); - // waiting for the websocket message to go through - await delay(); user1.sfuClient.updateDownload(sender.session.id, { audio: true }); - await new Promise((resolve) => { - // this 100ms is not ideal, but it prevents a race condition where the worker is closed right - // when the consumer is updated, which prevents the main process to send that message to the worker, - // this is not a problem in production as it is normal that workers that are closed do not send messages. - setTimeout(resolve, 100); - }); expect(user1.sfuClient.state).toBe(SFU_CLIENT_STATE.CONNECTED); expect(user1.session.state).toBe(SESSION_STATE.CONNECTED); }); diff --git a/tests/utils/utils.ts b/tests/utils/utils.ts index a47b65d..b0c742b 100644 --- a/tests/utils/utils.ts +++ b/tests/utils/utils.ts @@ -26,7 +26,3 @@ export function withMockEnv(config: Record): () => v jest.resetModules(); }; } - -export function delay(ms = 0): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} From cb9d79d2f299dd700faeba1e0d5aa6ea9585625d Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 08:26:04 +0100 Subject: [PATCH 63/73] [wip] test disconnect disallowed --- tests/http.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/http.test.ts b/tests/http.test.ts index e5aa25a..23ce8eb 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -152,6 +152,29 @@ describe("HTTP", () => { }); expect(user1.session.state).toBe(SESSION_STATE.CLOSED); }); + test("/disconnect does not execute for unowned channel", async () => { + const remoteAddress = "test.other-owner.net"; + const channel = await Channel.create(remoteAddress, "issuer"); + const sessionId = 5; + const user1 = await network.connect(channel.uuid, sessionId); + + const response = await fetch( + `http://${HTTP_INTERFACE}:${PORT}/v${API_VERSION}/disconnect`, + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: makeJwt({ + sessionIdsByChannel: { + [channel.uuid]: [sessionId] + } + }) + } + ); + expect(response.status).toBe(200); + expect(user1.session.state).not.toBe(SESSION_STATE.CLOSED); + }); test("/disconnect fails with an incorrect JWT", async () => { const channelUUID = await network.getChannelUUID(); const sessionId = 5; From df52a63ee0aa684f5de83ca6c2fe6884d30bb192 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 10:21:20 +0100 Subject: [PATCH 64/73] [wip] test worker recovery --- src/services/resources.ts | 5 ++++- tests/recording.test.ts | 5 ++--- tests/rtc.test.ts | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/services/resources.ts b/src/services/resources.ts index f189ad4..29134ad 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -20,7 +20,10 @@ export interface RtcWorker extends mediasoup.types.Worker { // TODO maybe write some docstring, file used to manage resources such as folders, workers, ports const logger = new Logger("RESOURCES"); -const workers = new Set(); +/** + * Exported for testing purposes + */ +export const workers = new Set(); export async function start(): Promise { logger.info("starting..."); diff --git a/tests/recording.test.ts b/tests/recording.test.ts index 1bcd347..39c0ad7 100644 --- a/tests/recording.test.ts +++ b/tests/recording.test.ts @@ -38,7 +38,7 @@ jest.mock("node:child_process", () => { }; }); -async function recordingSetup(env: Record) { +async function recordingSetup(env: Record) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sfu-test-")); const resourcesPath = path.join(tmpDir, "resources"); const recordingPath = path.join(tmpDir, "recordings"); @@ -66,12 +66,11 @@ async function recordingSetup(env: Record) { describe("Recording & Transcription", () => { test("Does not record when the feature is disabled", async () => { - const { restore } = await recordingSetup({ RECORDING: "" }); + const { restore } = await recordingSetup({ RECORDING: undefined }); const config = await import("#src/config"); expect(config.recording.enabled).toBe(false); restore(); }); - test("Returns false when calling start/stop recording/transcription when not connected", async () => { const { SfuClient } = await import("#src/client"); const client = new SfuClient(); diff --git a/tests/rtc.test.ts b/tests/rtc.test.ts index 37caa6f..83f8b25 100644 --- a/tests/rtc.test.ts +++ b/tests/rtc.test.ts @@ -31,4 +31,25 @@ describe("rtc service", () => { } expect(usedWorkers.size).toBe(config.NUM_WORKERS); }); + test("worker should be replaced if it dies", async () => { + const worker = await resources.getWorker(); + const pid = worker.pid; + process.kill(pid, "SIGTERM"); + + await new Promise((resolve) => { + const interval = setInterval(() => { + if ( + resources.workers.size === config.NUM_WORKERS && + !resources.workers.has(worker) + ) { + clearInterval(interval); + resolve(); + } + }, 10); + }); + + const newWorker = await resources.getWorker(); + expect(newWorker.pid).not.toBe(pid); + expect(resources.workers.size).toBe(config.NUM_WORKERS); + }); }); From 1547254dc7ef7767a746b87ad15cdcef50b4cca5 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 10:25:14 +0100 Subject: [PATCH 65/73] [wip] fixup --- src/server.ts | 3 --- src/utils/utils.ts | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index 7ef39d6..7d47a5a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,9 +13,6 @@ async function run(): Promise { logger.info(`ready - PID: ${process.pid}`); logger.debug(`TO IMPLEMENT: `); logger.debug(`* get session labels from the odoo server`); - logger.debug(`* write tests for the recorder`); - logger.debug(`* use Promise.withResolvers() instead of Deferred`); - logger.debug(`* tests with mocked spawn`); } function cleanup(): void { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 46e654c..c170c81 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -50,6 +50,9 @@ export interface ParseBodyOptions { json?: boolean; } +/** + * @deprecated Use Promise.withResolvers() when available + */ export class Deferred { private readonly _promise: Promise; public resolve!: (value: T | PromiseLike) => void; From 2e305c7b9e2a846cb45accfb603dcbb63d0a784d Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 10:50:15 +0100 Subject: [PATCH 66/73] [wip] async recording termination --- src/models/ffmpeg.ts | 18 +++++++---------- src/models/media_output.ts | 9 +++++---- src/models/recorder.ts | 39 ++++++++++++++++++++++++++---------- src/models/recording_task.ts | 10 +++++---- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 7665073..7a1768a 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -22,7 +22,7 @@ export class FFMPEG { this._init(); } - close() { + async close() { if (this._isClosed) { return; } @@ -30,8 +30,13 @@ export class FFMPEG { this._logStream?.end(); logger.verbose(`closing FFMPEG ${this._filename}`); if (this._process && !this._process.killed) { + const closed = new Promise((resolve) => { + this._process!.on("close", resolve); + logger.verbose(`FFMPEG ${this._filename} closed`); + }); this._process!.kill("SIGINT"); logger.verbose(`FFMPEG ${this._filename} SIGINT sent`); + await closed; } } @@ -39,21 +44,12 @@ export class FFMPEG { try { const sdpString = this._createSdpText(); logger.verbose(`FFMPEG ${this._filename} SDP:\n${sdpString}`); - const sdpStream = Readable.from([sdpString]); const args = this._getCommandArgs(); - - logger.verbose(`spawning ffmpeg with args: ${args.join(" ")}`); - - /** - * while testing spawn should be mocked so that we don't make subprocesses and save files - * just test if the args are correct (ffmpeg / sdp compliant) - */ - + logger.debug(`spawning ffmpeg with args: ${args.join(" ")}`); this._process = spawn("ffmpeg", args); this._logStream = fs.createWriteStream(`${this._filename}.log`); - this._process.stderr?.pipe(this._logStream, { end: false }); this._process.stdout?.pipe(this._logStream, { end: false }); diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 7525f4d..8c8be8c 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -59,9 +59,9 @@ export class MediaOutput extends EventEmitter { this._init(); } - close() { + async close() { this._isClosed = true; - this._cleanup(); + await this._cleanup(); } private async _init() { @@ -130,10 +130,11 @@ export class MediaOutput extends EventEmitter { } } - private _cleanup() { - this._ffmpeg?.close(); + private async _cleanup() { + const prom = this._ffmpeg?.close(); this._consumer?.close(); this._transport?.close(); this._port?.release(); + await prom; } } diff --git a/src/models/recorder.ts b/src/models/recorder.ts index c2c3c2e..10e98de 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -138,15 +138,30 @@ export class Recorder extends EventEmitter { this.isRecording = false; this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; - this._stopTasks(); // may want to make it async (resolve on child process close/exit) so we can wait for the end of ffmpeg, when files are no longer written on. to check. - if (save) { - await this._folder?.add("metadata.json", JSON.stringify(this._metaData)); - await this._folder?.seal( - path.join(recording.directory, `${this._channel.name}_${Date.now()}`) - ); - } else { - await this._folder?.delete(); - } + const currentFolder = this._folder; + /** + * Not awaiting this._stopRecordingTasks() as FFMPEG can take arbitrarily long to complete (several seconds, or more), + * and we don't want to block the termination of the recorder as a new recording can be started + * straight away, independently of the saving process of the previous recording. And the input delay for the user + * would also be too long. + */ + this._stopRecordingTasks() + .then((results) => { + const failed = results.some((result) => result.status === "rejected"); + if (save && !failed) { + currentFolder?.add("metadata.json", JSON.stringify(this._metaData)); + currentFolder?.seal( + path.join(recording.directory, `${this._channel.name}_${Date.now()}`) + ); + } else { + currentFolder?.delete(); + } + }) + .catch((error) => { + logger.error( + `Failed to save recording for channel ${this._channel.name}: ${error}` + ); + }); this._folder = undefined; this._metaData.timeStamps = []; this.state = RECORDER_STATE.STOPPED; @@ -208,11 +223,13 @@ export class Recorder extends EventEmitter { this._channel.on("sessionLeave", this._onSessionLeave); } - private _stopTasks() { + private async _stopRecordingTasks() { + const proms = []; for (const task of this._tasks.values()) { - task.stop(); + proms.push(task.stop()); } this._tasks.clear(); + return Promise.allSettled(proms); } private _getRecordingStates(): RecordingStates { diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index 03d1605..f69a689 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -127,17 +127,19 @@ export class RecordingTask extends EventEmitter { this._clearData(data.type); } - private _clearData(type: STREAM_TYPE) { + private async _clearData(type: STREAM_TYPE) { const data = this.recordingDataByStreamType[type]; data.active = false; - data.mediaOutput?.close(); + await data.mediaOutput?.close(); data.mediaOutput = undefined; } - stop() { + async stop() { this._session.off("producer", this._onSessionProducer); + const proms = []; for (const type of Object.values(STREAM_TYPE)) { - this._clearData(type); + proms.push(this._clearData(type)); } + return Promise.all(proms); } } From 5ca38e4338469d23351c45a69cd1e2ea122d43a5 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 10:54:49 +0100 Subject: [PATCH 67/73] [wip] testing codec env variables --- tests/rtc.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/rtc.test.ts b/tests/rtc.test.ts index 83f8b25..c568ddb 100644 --- a/tests/rtc.test.ts +++ b/tests/rtc.test.ts @@ -52,4 +52,25 @@ describe("rtc service", () => { expect(newWorker.pid).not.toBe(pid); expect(resources.workers.size).toBe(config.NUM_WORKERS); }); + + test("getAllowedCodecs should respect environment variables", async () => { + const { withMockEnv } = await import("./utils/utils"); + const restore = withMockEnv({ + AUDIO_CODECS: "opus,PCMU", + VIDEO_CODECS: "VP8,H264" + }); + + const { getAllowedCodecs } = await import("#src/utils/utils"); + const codecs = getAllowedCodecs(); + + expect(codecs).toHaveLength(4); + expect(codecs.map((c) => c.mimeType)).toEqual([ + "audio/opus", + "audio/PCMU", + "video/VP8", + "video/H264" + ]); + + restore(); + }); }); From b935561cc0d0c66c016fe60656fc24cd1a899d77 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 12:01:26 +0100 Subject: [PATCH 68/73] [fix] early return in handle management --- src/client.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index bec80fb..f179549 100644 --- a/src/client.ts +++ b/src/client.ts @@ -407,7 +407,10 @@ export class SfuClient extends EventTarget { appData: { type } }); } catch (error) { - this._handleError(error as Error); + const exit = this._handleError(error as Error); + if (exit) { + return; + } // retry after some delay this._recoverProducerTimeouts[type] = setTimeout(async () => { await this.updateUpload(type, track); @@ -452,7 +455,10 @@ export class SfuClient extends EventTarget { this._bus.onRequest = this._handleRequest; } - private _handleError(error: Error) { + /** + * Handles an error and returns true if the connection should be closed. + */ + private _handleError(error: Error): boolean { this.errors.push(error); this.dispatchEvent( new CustomEvent("handledError", { @@ -463,8 +469,9 @@ export class SfuClient extends EventTarget { if (this.errors.length > MAX_ERRORS) { // not awaited this._handleConnectionEnd(); - return; + return true; } + return false; } private _close(cause?: string): void { From c5ef92e50bf6dd0964fac8f287bcb528bbda5347 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 13:00:18 +0100 Subject: [PATCH 69/73] [wip] timestamps for file state --- src/models/ffmpeg.ts | 29 ++++++++++++++++------------- src/models/media_output.ts | 14 ++++++++------ src/models/recorder.ts | 20 +++++++++++++++----- src/models/recording_task.ts | 13 ++++++++++--- 4 files changed, 49 insertions(+), 27 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index 7a1768a..d5eba1b 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -2,6 +2,7 @@ import { spawn, ChildProcess } from "node:child_process"; import fs from "node:fs"; import { Readable } from "node:stream"; +import path from "node:path"; import { Logger } from "#src/utils/utils.ts"; import type { rtpData } from "#src/models/media_output"; @@ -9,16 +10,18 @@ import { recording } from "#src/config.ts"; const logger = new Logger("FFMPEG"); export class FFMPEG { + readonly filename: string; private readonly _rtp: rtpData; private _process?: ChildProcess; private _isClosed = false; - private readonly _filename: string; private _logStream?: fs.WriteStream; + private readonly _directory: string; - constructor(rtp: rtpData, filename: string) { + constructor(rtp: rtpData, directory: string, filename: string) { this._rtp = rtp; - this._filename = filename; - logger.verbose(`creating FFMPEG for ${this._filename}`); + this.filename = filename; + this._directory = directory; + logger.verbose(`creating FFMPEG for ${this.filename}`); this._init(); } @@ -28,14 +31,14 @@ export class FFMPEG { } this._isClosed = true; this._logStream?.end(); - logger.verbose(`closing FFMPEG ${this._filename}`); + logger.verbose(`closing FFMPEG ${this.filename}`); if (this._process && !this._process.killed) { const closed = new Promise((resolve) => { this._process!.on("close", resolve); - logger.verbose(`FFMPEG ${this._filename} closed`); + logger.verbose(`FFMPEG ${this.filename} closed`); }); this._process!.kill("SIGINT"); - logger.verbose(`FFMPEG ${this._filename} SIGINT sent`); + logger.verbose(`FFMPEG ${this.filename} SIGINT sent`); await closed; } } @@ -43,23 +46,23 @@ export class FFMPEG { private _init() { try { const sdpString = this._createSdpText(); - logger.verbose(`FFMPEG ${this._filename} SDP:\n${sdpString}`); + logger.verbose(`FFMPEG ${this.filename} SDP:\n${sdpString}`); const sdpStream = Readable.from([sdpString]); const args = this._getCommandArgs(); logger.debug(`spawning ffmpeg with args: ${args.join(" ")}`); this._process = spawn("ffmpeg", args); - this._logStream = fs.createWriteStream(`${this._filename}.log`); + this._logStream = fs.createWriteStream(`${this.filename}.log`); this._process.stderr?.pipe(this._logStream, { end: false }); this._process.stdout?.pipe(this._logStream, { end: false }); this._process.on("error", (error) => { - logger.error(`ffmpeg ${this._filename} error: ${error.message}`); + logger.error(`ffmpeg ${this.filename} error: ${error.message}`); this.close(); }); this._process.on("close", (code) => { - logger.verbose(`ffmpeg ${this._filename} exited with code ${code}`); + logger.verbose(`ffmpeg ${this.filename} exited with code ${code}`); }); sdpStream.on("error", (error) => { @@ -70,7 +73,7 @@ export class FFMPEG { sdpStream.pipe(this._process.stdin); } } catch (error) { - logger.error(`Failed to initialize FFMPEG ${this._filename}: ${error}`); + logger.error(`Failed to initialize FFMPEG ${this.filename}: ${error}`); this.close(); } } @@ -146,7 +149,7 @@ export class FFMPEG { ]); } const extension = this._getContainerExtension(); - args.push(`${this._filename}.${extension}`); + args.push(`${path.join(this._directory, this.filename)}.${extension}`); return args; } } diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 8c8be8c..2c03604 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -1,5 +1,4 @@ import { EventEmitter } from "node:events"; -import path from "node:path"; import type { Router, @@ -114,23 +113,26 @@ export class MediaOutput extends EventEmitter { if (this._producer.paused) { logger.debug(`pausing consumer ${this._consumer?.id}`); this._consumer?.pause(); - logger.debug("TODO notify pause"); + if (this._ffmpeg) { + this.emit("fileStateChange", { active: false, filename: this._ffmpeg.filename }); + } } else { logger.debug(`resuming consumer ${this._consumer?.id}`); if (!this._ffmpeg) { const fileName = `${this.name}-${Date.now()}`; logger.verbose(`writing ${fileName} at ${this._directory}`); - const fullName = path.join(this._directory, fileName); - this._ffmpeg = new FFMPEG(this._rtpData, fullName); + this._ffmpeg = new FFMPEG(this._rtpData, this._directory, fileName); logger.verbose(`resuming consumer ${this._consumer?.id}`); - this.emit("file", fileName); - logger.debug("TODO notify resume"); } this._consumer?.resume(); + this.emit("fileStateChange", { active: true, filename: this._ffmpeg.filename }); } } private async _cleanup() { + if (this._ffmpeg) { + this.emit("fileStateChange", { active: false, filename: this._ffmpeg.filename }); + } const prom = this._ffmpeg?.close(); this._consumer?.close(); this._transport?.close(); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 10e98de..51f5a62 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -8,28 +8,38 @@ import { Logger } from "#src/utils/utils.ts"; import type { Channel } from "#src/models/channel"; import type { SessionId } from "#src/models/session.ts"; +import { STREAM_TYPE } from "#src/shared/enums"; export enum TIME_TAG { RECORDING_STARTED = "recording_started", RECORDING_STOPPED = "recording_stopped", TRANSCRIPTION_STARTED = "transcription_started", TRANSCRIPTION_STOPPED = "transcription_stopped", - NEW_FILE = "new_file" + FILE_STATE_CHANGE = "file_state_change" } export enum RECORDER_STATE { STARTED = "started", STOPPING = "stopping", STOPPED = "stopped" } +export type TimeTagInfo = { + filename: string; + type: STREAM_TYPE; + /** + * The file lasts for the whole duration of the client producer, which means that it can represent a sequence of streams, + * with periods of inactivity (no packets). active is set to true when the stream is active, which means that the producer is + * actively broadcasting data, and false when it is not. + */ + active: boolean; +}; export type Metadata = { forwardAddress: string; - timeStamps: Array<{ tag: TIME_TAG; timestamp: number; value: object }>; + timeStamps: Array<{ tag: TIME_TAG; timestamp: number; info?: TimeTagInfo }>; }; const logger = new Logger("RECORDER"); /** - * TODO some docstring * The recorder generates a "raw" file bundle, of recordings of individual audio and video streams, * accompanied with a metadata file describing the recording (timestamps, ids,...). * @@ -116,11 +126,11 @@ export class Recorder extends EventEmitter { return this.isTranscribing; } - mark(tag: TIME_TAG, value: object = {}) { + mark(tag: TIME_TAG, info?: TimeTagInfo) { this._metaData.timeStamps.push({ tag, timestamp: Date.now(), - value + info }); } diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index f69a689..d387a3f 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -112,9 +112,16 @@ export class RecordingTask extends EventEmitter { name: `${this._session.id}-${type}`, directory: this._recorder.path! }); - data.mediaOutput.on("file", (filename: string) => { - this._recorder.mark(TIME_TAG.NEW_FILE, { filename, type }); - }); + data.mediaOutput.on( + "fileStateChange", + ({ active, filename }: { active: boolean; filename: string }) => { + this._recorder.mark(TIME_TAG.FILE_STATE_CHANGE, { + active, + filename, + type + }); + } + ); if (data.active) { return; } From a1037b797b2cd64cb84e191c44ab7c1765c2e573 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 13:08:14 +0100 Subject: [PATCH 70/73] [wip] overload better typing --- src/models/recorder.ts | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 51f5a62..f88683a 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -6,9 +6,9 @@ import { getFolder, type Folder } from "#src/services/resources.ts"; import { RecordingTask, type RecordingStates } from "#src/models/recording_task.ts"; import { Logger } from "#src/utils/utils.ts"; -import type { Channel } from "#src/models/channel"; +import type { Channel } from "#src/models/channel.ts"; import type { SessionId } from "#src/models/session.ts"; -import { STREAM_TYPE } from "#src/shared/enums"; +import { STREAM_TYPE } from "#src/shared/enums.ts"; export enum TIME_TAG { RECORDING_STARTED = "recording_started", @@ -34,7 +34,10 @@ export type TimeTagInfo = { }; export type Metadata = { forwardAddress: string; - timeStamps: Array<{ tag: TIME_TAG; timestamp: number; info?: TimeTagInfo }>; + timeStamps: Array< + | { tag: TIME_TAG.FILE_STATE_CHANGE; timestamp: number; info: TimeTagInfo } + | { tag: Exclude; timestamp: number } + >; }; const logger = new Logger("RECORDER"); @@ -125,13 +128,25 @@ export class Recorder extends EventEmitter { } return this.isTranscribing; } - + /* eslint-disable no-dupe-class-members */ // overloads + mark(tag: TIME_TAG.FILE_STATE_CHANGE, info: TimeTagInfo): void; + mark(tag: Exclude): void; mark(tag: TIME_TAG, info?: TimeTagInfo) { - this._metaData.timeStamps.push({ - tag, - timestamp: Date.now(), - info - }); + if (tag === TIME_TAG.FILE_STATE_CHANGE) { + if (!info) { + throw new Error("Info is required for FILE_STATE_CHANGE"); + } + this._metaData.timeStamps.push({ + tag, + timestamp: Date.now(), + info + }); + } else { + this._metaData.timeStamps.push({ + tag: tag as Exclude, + timestamp: Date.now() + }); + } } /** From da57b844f9738a49214ef146cdbcd4aa7a225b15 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 13:22:12 +0100 Subject: [PATCH 71/73] [fix] log location --- src/models/ffmpeg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index d5eba1b..c01c31d 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -52,7 +52,7 @@ export class FFMPEG { logger.debug(`spawning ffmpeg with args: ${args.join(" ")}`); this._process = spawn("ffmpeg", args); - this._logStream = fs.createWriteStream(`${this.filename}.log`); + this._logStream = fs.createWriteStream(`${path.join(this._directory, this.filename)}.log`); this._process.stderr?.pipe(this._logStream, { end: false }); this._process.stdout?.pipe(this._logStream, { end: false }); From a607887984d9b86706cb1c5e44a11e41072ae5f8 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 14:25:10 +0100 Subject: [PATCH 72/73] [wip] cleanup --- src/models/ffmpeg.ts | 6 +----- src/models/media_output.ts | 5 +---- src/models/recorder.ts | 5 ++--- src/models/recording_task.ts | 16 ++++++++++++---- src/services/resources.ts | 5 ++--- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/models/ffmpeg.ts b/src/models/ffmpeg.ts index c01c31d..f3ced11 100644 --- a/src/models/ffmpeg.ts +++ b/src/models/ffmpeg.ts @@ -21,7 +21,6 @@ export class FFMPEG { this._rtp = rtp; this.filename = filename; this._directory = directory; - logger.verbose(`creating FFMPEG for ${this.filename}`); this._init(); } @@ -31,14 +30,11 @@ export class FFMPEG { } this._isClosed = true; this._logStream?.end(); - logger.verbose(`closing FFMPEG ${this.filename}`); if (this._process && !this._process.killed) { const closed = new Promise((resolve) => { this._process!.on("close", resolve); - logger.verbose(`FFMPEG ${this.filename} closed`); }); this._process!.kill("SIGINT"); - logger.verbose(`FFMPEG ${this.filename} SIGINT sent`); await closed; } } @@ -46,7 +42,7 @@ export class FFMPEG { private _init() { try { const sdpString = this._createSdpText(); - logger.verbose(`FFMPEG ${this.filename} SDP:\n${sdpString}`); + logger.debug(`FFMPEG ${this.filename} SDP:\n${sdpString}`); const sdpStream = Readable.from([sdpString]); const args = this._getCommandArgs(); logger.debug(`spawning ffmpeg with args: ${args.join(" ")}`); diff --git a/src/models/media_output.ts b/src/models/media_output.ts index 2c03604..94bbc06 100644 --- a/src/models/media_output.ts +++ b/src/models/media_output.ts @@ -111,18 +111,15 @@ export class MediaOutput extends EventEmitter { return; } if (this._producer.paused) { - logger.debug(`pausing consumer ${this._consumer?.id}`); this._consumer?.pause(); if (this._ffmpeg) { this.emit("fileStateChange", { active: false, filename: this._ffmpeg.filename }); } } else { - logger.debug(`resuming consumer ${this._consumer?.id}`); if (!this._ffmpeg) { const fileName = `${this.name}-${Date.now()}`; - logger.verbose(`writing ${fileName} at ${this._directory}`); + logger.verbose(`new recording file${this._directory}/${fileName}`); this._ffmpeg = new FFMPEG(this._rtpData, this._directory, fileName); - logger.verbose(`resuming consumer ${this._consumer?.id}`); } this._consumer?.resume(); this.emit("fileStateChange", { active: true, filename: this._ffmpeg.filename }); diff --git a/src/models/recorder.ts b/src/models/recorder.ts index f88683a..41e72e4 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -166,9 +166,8 @@ export class Recorder extends EventEmitter { const currentFolder = this._folder; /** * Not awaiting this._stopRecordingTasks() as FFMPEG can take arbitrarily long to complete (several seconds, or more), - * and we don't want to block the termination of the recorder as a new recording can be started - * straight away, independently of the saving process of the previous recording. And the input delay for the user - * would also be too long. + * and we don't want to block the termination of the recorder as a new recording can be started straight away, + * independently of the saving process of the previous recording. The input delay for the user would also be too long. */ this._stopRecordingTasks() .then((results) => { diff --git a/src/models/recording_task.ts b/src/models/recording_task.ts index d387a3f..ea3a36d 100644 --- a/src/models/recording_task.ts +++ b/src/models/recording_task.ts @@ -7,6 +7,7 @@ import { Session } from "#src/models/session.ts"; import { Logger } from "#src/utils/utils.ts"; import { TIME_TAG, type Recorder } from "#src/models/recorder.ts"; import { STREAM_TYPE } from "#src/shared/enums.ts"; +import { PortLimitReachedError } from "#src/utils/errors.ts"; export type RecordingStates = { audio: boolean; @@ -125,10 +126,17 @@ export class RecordingTask extends EventEmitter { if (data.active) { return; } - } catch { - logger.warn( - `failed at starting the recording for ${this._session.name} ${data.type}` - ); + } catch (error) { + if (error instanceof PortLimitReachedError) { + logger.warn( + `no port available for recording ${this._session.name} ${data.type}` + ); + // TODO: accepting partial recoding, or the whole recording should be discarded? + } else { + logger.error( + `failed at starting the recording for ${this._session.name} ${data.type} - error: ${error}` + ); + } } } this._clearData(data.type); diff --git a/src/services/resources.ts b/src/services/resources.ts index 29134ad..0e79820 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -39,7 +39,8 @@ export async function start(): Promise { `transport(RTC) layer at ${config.PUBLIC_IP}:${config.RTC_MIN_PORT}-${config.RTC_MAX_PORT}` ); /** - * Moving ports in steps of 2 because FFMPEG may use their allocated port + 1 for RTCP + * FIXME: Moving ports in steps of 2 because FFMPEG may use their allocated port + 1 for RTCP, + * need to verify if FFMPEG can be configured to use muxed ports */ for (let i = config.dynamicPorts.min; i <= config.dynamicPorts.max; i += 2) { availablePorts.push(i); @@ -158,11 +159,9 @@ export class DynamicPort { throw new PortLimitReachedError(); } this.number = maybeNum; - logger.verbose(`Acquired port ${this.number}`); } release() { availablePorts.push(this.number); - logger.verbose(`Released port ${this.number}`); } } From 55d37a6db448fed3959aad6c14ca54b91331e2a3 Mon Sep 17 00:00:00 2001 From: ThanhDodeurOdoo Date: Fri, 5 Dec 2025 15:26:30 +0100 Subject: [PATCH 73/73] [fix] preserve metadata --- src/models/recorder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/models/recorder.ts b/src/models/recorder.ts index 41e72e4..e30d897 100644 --- a/src/models/recorder.ts +++ b/src/models/recorder.ts @@ -164,6 +164,7 @@ export class Recorder extends EventEmitter { this.isTranscribing = false; this.state = RECORDER_STATE.STOPPING; const currentFolder = this._folder; + const metadata = JSON.stringify(this._metaData); /** * Not awaiting this._stopRecordingTasks() as FFMPEG can take arbitrarily long to complete (several seconds, or more), * and we don't want to block the termination of the recorder as a new recording can be started straight away, @@ -173,7 +174,7 @@ export class Recorder extends EventEmitter { .then((results) => { const failed = results.some((result) => result.status === "rejected"); if (save && !failed) { - currentFolder?.add("metadata.json", JSON.stringify(this._metaData)); + currentFolder?.add("metadata.json", metadata); currentFolder?.seal( path.join(recording.directory, `${this._channel.name}_${Date.now()}`) );