【m】修改为服务器录屏

This commit is contained in:
2026-06-02 02:34:40 +08:00
parent d74a0c8121
commit 66d6f92d1e
21 changed files with 4053 additions and 32 deletions

View File

@@ -6,6 +6,10 @@ import Offer from './offer';
import Answer from './answer';
import Candidate from './candidate';
import { log, LogLevel } from '../log';
import { RecordingSession, listRecordingSessions, stopRecordingSession } from '../recording/session-manager';
import { registerRecordingPeerCandidate, registerRecordingPeerOffer, stopRecordingAgent } from '../recording/agent';
import { startRecordingCompositionJob } from '../recording/composer';
import { acceptRecordingOffer, addRecordingIceCandidate, stopRecordingPeer } from '../recording/werift-adapter';
/**
* 是否为私有模式
@@ -68,6 +72,18 @@ interface RoomSnapshot {
userCount: number;
}
type RecordingBroadcastPayload = {
type: 'recording-started' | 'recording-stopped' | 'recording-status' | 'recording-peer-request';
connectionId: string;
recordingId: string;
status: string;
layout?: string;
format?: string;
startedAt?: string;
stoppedAt?: string;
mediaMode?: string;
};
interface StoredRoom {
roomId: string;
connectionId: string;
@@ -317,6 +333,58 @@ function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: an
}
}
function sendToEntireGroup(connectionId: string, message: any): boolean {
const group = connectionGroup.get(connectionId);
if (!group) {
return false;
}
safeSend(group.host, message);
group.participants.forEach(participantWs => {
safeSend(participantWs, message);
});
return true;
}
function getActiveRecordingSessions(connectionId: string): RecordingSession[] {
return listRecordingSessions(connectionId).filter((session) => session.status === 'recording');
}
function stopRecordingPeersForSocket(ws: WebSocket, connectionId: string): void {
const participantId = getParticipantId(ws);
if (!participantId) {
return;
}
getActiveRecordingSessions(connectionId).forEach((session) => {
stopRecordingPeer(session.id, participantId).catch((error) => {
log(LogLevel.warn, 'Failed to stop participant recording peer:', error);
});
});
}
function stopActiveRecordingSessions(connectionId: string): void {
getActiveRecordingSessions(connectionId).forEach((session) => {
const stoppedSession = stopRecordingSession(session.id);
if (stoppedSession) {
broadcastRecordingStopped(stoppedSession);
stopRecordingAgent(stoppedSession.id);
}
stopRecordingPeer(session.id)
.then(() => {
startRecordingCompositionJob({
meetingId: session.connectionId,
recordingId: session.id,
layout: session.layout,
format: session.format
});
})
.catch((error) => {
log(LogLevel.warn, 'Failed to stop room recording peers:', error);
});
});
}
/**
* 移除WebSocket连接
* @param ws WebSocket连接实例
@@ -329,12 +397,14 @@ function remove(ws: WebSocket): void {
const group = connectionGroup.get(connectionId);
if (group) {
if (group.host === ws) {
stopActiveRecordingSessions(connectionId);
group.participants.forEach(participantWs => {
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
});
rooms.delete(connectionId);
connectionGroup.delete(connectionId);
} else {
stopRecordingPeersForSocket(ws, connectionId);
group.participants.delete(ws);
removeRoomMember(ws, connectionId);
// 包含participantId让host能识别是哪个participant离开
@@ -379,6 +449,7 @@ function onConnect(ws: WebSocket, connectionId: string): void {
const role = polite ? 'participant' : 'host';
saveRoomMember(ws, connectionId);
safeSend(ws, { type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId });
sendActiveRecordingRequests(ws, connectionId);
}
/**
@@ -399,6 +470,7 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
if (group) {
if (group.host === ws) {
// host断开连接通知所有participants房间已关闭并删除连接组
stopActiveRecordingSessions(connectionId);
group.participants.forEach(participantWs => {
safeSend(participantWs, { type: "disconnect", connectionId: connectionId, reason: "host-left" });
});
@@ -407,6 +479,7 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
log(LogLevel.log, `Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`);
} else {
// participant断开连接从组中移除并通知host使用participant-left类型host不会关闭房间
stopRecordingPeersForSocket(ws, connectionId);
group.participants.delete(ws);
removeRoomMember(ws, connectionId);
safeSend(group.host, { type: "participant-left", connectionId: connectionId, participantId: getParticipantId(ws) });
@@ -677,6 +750,164 @@ function onBroadcast(ws: WebSocket, message: any): void {
}
}
function toRecordingBroadcastPayload(type: RecordingBroadcastPayload['type'], session: RecordingSession): RecordingBroadcastPayload {
return {
type,
connectionId: session.connectionId,
recordingId: session.id,
status: session.status,
layout: session.layout,
format: session.format,
startedAt: session.startedAt,
stoppedAt: session.stoppedAt
};
}
function broadcastRecordingStarted(session: RecordingSession): boolean {
return sendToEntireGroup(
session.connectionId,
toRecordingBroadcastPayload('recording-started', session)
);
}
function broadcastRecordingPeerRequest(session: RecordingSession): boolean {
const payload = toRecordingBroadcastPayload('recording-peer-request', session);
payload.mediaMode = 'webrtc-sendonly';
return sendToEntireGroup(session.connectionId, payload);
}
function broadcastRecordingStopped(session: RecordingSession): boolean {
return sendToEntireGroup(
session.connectionId,
toRecordingBroadcastPayload('recording-stopped', session)
);
}
function sendActiveRecordingRequests(ws: WebSocket, connectionId: string): void {
const activeSessions = getActiveRecordingSessions(connectionId);
activeSessions.forEach((session) => {
safeSend(ws, toRecordingBroadcastPayload('recording-started', session));
safeSend(ws, {
...toRecordingBroadcastPayload('recording-peer-request', session),
mediaMode: 'webrtc-sendonly'
});
});
}
async function onRecordingOffer(ws: WebSocket, message: any): Promise<void> {
const recordingId = typeof message.recordingId === 'string' ? message.recordingId : '';
const connectionId = typeof message.connectionId === 'string' ? message.connectionId : '';
const sdp = typeof message.sdp === 'string' ? message.sdp : '';
if (!recordingId || !connectionId || !sdp) {
safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' });
return;
}
const offer = registerRecordingPeerOffer({
recordingId,
connectionId,
sdp,
participantId: getParticipantId(ws) || 'unknown'
});
if (!offer) {
safeSend(ws, {
type: 'recording-status',
recordingId,
connectionId,
status: 'recorder-unavailable',
participantId: getParticipantId(ws)
});
return;
}
try {
const participantId = getParticipantId(ws) || 'unknown';
const role = getSocketRoleInRoom(ws, connectionId);
const answerSdp = await acceptRecordingOffer({
recordingId,
connectionId,
sdp,
participantId,
role,
onLocalCandidate: (candidate) => {
const json = typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
safeSend(ws, {
type: 'recording-candidate',
recordingId,
connectionId,
participantId,
candidate: json.candidate,
sdpMid: json.sdpMid,
sdpMLineIndex: json.sdpMLineIndex
});
}
});
safeSend(ws, {
type: 'recording-answer',
recordingId,
connectionId,
participantId,
sdp: answerSdp
});
} catch (error) {
log(LogLevel.error, 'Failed to accept recording offer:', error);
safeSend(ws, {
type: 'recording-status',
recordingId,
connectionId,
status: 'offer-failed',
participantId: getParticipantId(ws)
});
return;
}
safeSend(ws, {
type: 'recording-status',
recordingId,
connectionId,
status: 'offer-received',
participantId: getParticipantId(ws)
});
}
async function onRecordingCandidate(ws: WebSocket, message: any): Promise<void> {
const recordingId = typeof message.recordingId === 'string' ? message.recordingId : '';
const connectionId = typeof message.connectionId === 'string' ? message.connectionId : '';
const candidateText = typeof message.candidate === 'string' ? message.candidate : '';
if (!recordingId || !connectionId || !candidateText) {
return;
}
const candidate = registerRecordingPeerCandidate({
recordingId,
connectionId,
candidate: candidateText,
participantId: getParticipantId(ws) || message.participantId || 'unknown',
sdpMid: typeof message.sdpMid === 'string' ? message.sdpMid : undefined,
sdpMLineIndex: typeof message.sdpMLineIndex === 'number' ? message.sdpMLineIndex : undefined
});
if (!candidate) {
safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' });
return;
}
try {
await addRecordingIceCandidate({
recordingId,
participantId: candidate.participantId,
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex
});
} catch (error) {
log(LogLevel.warn, 'Failed to add recording ICE candidate:', error);
safeSend(ws, { type: 'recording-status', recordingId, connectionId, status: 'candidate-rejected' });
}
}
function AddHeartbeat(ws: WebSocket, connectionId: string) {
// 初始化心跳检测
asAppWebSocket(ws).lastActivity = Date.now();
@@ -833,4 +1064,5 @@ function onMessage(ws: WebSocket, message: any): void {
*/
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId,
onBroadcast, onGetAllConnectionIds, onGetOnlineUsers, onGetRooms, AddHeartbeat, RemoveHeartbeat, onMessage, isHost,
broadcastToGroup, connectionGroup, onHostUserInfo, onInviteCall };
broadcastToGroup, broadcastRecordingStarted, broadcastRecordingPeerRequest, broadcastRecordingStopped, connectionGroup,
onHostUserInfo, onInviteCall, onRecordingCandidate, onRecordingOffer };

226
src/recording/agent.ts Normal file
View File

@@ -0,0 +1,226 @@
import { RecordingSession } from './session-manager';
export type RecordingAgentStatus = 'awaiting-media-adapter' | 'negotiating' | 'receiving-media' | 'stopped';
export type RecordingPeerOffer = {
recordingId: string;
connectionId: string;
participantId: string;
sdp: string;
receivedAt: string;
};
export type RecordingPeerCandidate = {
recordingId: string;
connectionId: string;
participantId: string;
candidate: string;
sdpMid?: string;
sdpMLineIndex?: number;
receivedAt: string;
};
export type RecordingPeerAnswer = {
recordingId: string;
connectionId: string;
participantId: string;
sdp: string;
createdAt: string;
};
export type RecordingPeerTrack = {
recordingId: string;
connectionId: string;
participantId: string;
kind: string;
trackId: string;
receivedAt: string;
rtpPackets: number;
};
export type RecordingAgent = {
id: string;
recordingId: string;
connectionId: string;
status: RecordingAgentStatus;
mediaMode: 'webrtc-sendonly';
createdAt: string;
updatedAt: string;
stoppedAt?: string;
peerOffers: Map<string, RecordingPeerOffer>;
peerAnswers: Map<string, RecordingPeerAnswer>;
peerCandidates: Map<string, RecordingPeerCandidate[]>;
peerTracks: Map<string, RecordingPeerTrack[]>;
};
const agents: Map<string, RecordingAgent> = new Map<string, RecordingAgent>();
function nowIso(): string {
return new Date().toISOString();
}
export function startRecordingAgent(session: RecordingSession): RecordingAgent {
const timestamp = nowIso();
const agent: RecordingAgent = {
id: `recorder_${session.id}`,
recordingId: session.id,
connectionId: session.connectionId,
status: 'awaiting-media-adapter',
mediaMode: 'webrtc-sendonly',
createdAt: timestamp,
updatedAt: timestamp,
peerOffers: new Map<string, RecordingPeerOffer>(),
peerAnswers: new Map<string, RecordingPeerAnswer>(),
peerCandidates: new Map<string, RecordingPeerCandidate[]>(),
peerTracks: new Map<string, RecordingPeerTrack[]>()
};
agents.set(session.id, agent);
return agent;
}
export function getRecordingAgent(recordingId: string): RecordingAgent | null {
return agents.get(recordingId) || null;
}
export function stopRecordingAgent(recordingId: string): RecordingAgent | null {
const agent = agents.get(recordingId);
if (!agent) {
return null;
}
const timestamp = nowIso();
agent.status = 'stopped';
agent.updatedAt = timestamp;
agent.stoppedAt = timestamp;
return agent;
}
export function registerRecordingPeerOffer(input: {
recordingId: string;
connectionId: string;
participantId: string;
sdp: string;
}): RecordingPeerOffer | null {
const agent = agents.get(input.recordingId);
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
return null;
}
const offer: RecordingPeerOffer = {
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
sdp: input.sdp,
receivedAt: nowIso()
};
agent.peerOffers.set(input.participantId, offer);
agent.status = 'negotiating';
agent.updatedAt = offer.receivedAt;
return offer;
}
export function registerRecordingPeerAnswer(input: {
recordingId: string;
connectionId: string;
participantId: string;
sdp: string;
}): RecordingPeerAnswer | null {
const agent = agents.get(input.recordingId);
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
return null;
}
const answer: RecordingPeerAnswer = {
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
sdp: input.sdp,
createdAt: nowIso()
};
agent.peerAnswers.set(input.participantId, answer);
agent.updatedAt = answer.createdAt;
return answer;
}
export function registerRecordingPeerCandidate(input: {
recordingId: string;
connectionId: string;
participantId: string;
candidate: string;
sdpMid?: string;
sdpMLineIndex?: number;
}): RecordingPeerCandidate | null {
const agent = agents.get(input.recordingId);
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
return null;
}
const candidate: RecordingPeerCandidate = {
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
candidate: input.candidate,
sdpMid: input.sdpMid,
sdpMLineIndex: input.sdpMLineIndex,
receivedAt: nowIso()
};
const participantCandidates = agent.peerCandidates.get(input.participantId) || [];
participantCandidates.push(candidate);
agent.peerCandidates.set(input.participantId, participantCandidates);
agent.updatedAt = candidate.receivedAt;
return candidate;
}
export function registerRecordingPeerTrack(input: {
recordingId: string;
connectionId: string;
participantId: string;
kind: string;
trackId: string;
}): RecordingPeerTrack | null {
const agent = agents.get(input.recordingId);
if (!agent || agent.connectionId !== input.connectionId || agent.status === 'stopped') {
return null;
}
const track: RecordingPeerTrack = {
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
kind: input.kind,
trackId: input.trackId,
receivedAt: nowIso(),
rtpPackets: 0
};
const participantTracks = agent.peerTracks.get(input.participantId) || [];
participantTracks.push(track);
agent.peerTracks.set(input.participantId, participantTracks);
agent.status = 'receiving-media';
agent.updatedAt = track.receivedAt;
return track;
}
export function incrementRecordingTrackPackets(input: {
recordingId: string;
participantId: string;
trackId: string;
}): void {
const agent = agents.get(input.recordingId);
if (!agent || agent.status === 'stopped') {
return;
}
const participantTracks = agent.peerTracks.get(input.participantId) || [];
const track = participantTracks.find((item) => item.trackId === input.trackId);
if (!track) {
return;
}
track.rtpPackets += 1;
agent.updatedAt = nowIso();
}
export function resetRecordingAgents(): void {
agents.clear();
}

324
src/recording/composer.ts Normal file
View File

@@ -0,0 +1,324 @@
import { spawn } from 'child_process';
import { v4 as uuid } from 'uuid';
import {
ServerTrackRecordingFile,
ServerTrackRecordingTarget,
createComposedRecordingTarget,
deleteServerTrackRecordingFiles,
listServerTrackRecordingFiles,
writeComposedRecordingMetadata
} from './storage';
export type RecordingCompositionStatus = 'queued' | 'running' | 'completed' | 'failed';
export type RecordingCompositionJob = {
id: string;
recordingId: string;
meetingId: string;
status: RecordingCompositionStatus;
layout: string;
format: string;
createdAt: string;
updatedAt: string;
startedAt?: string;
completedAt?: string;
failedAt?: string;
error?: string;
inputFiles: string[];
deletedInputFiles?: string[];
output?: {
meetingId: string;
filename: string;
filePath: string;
metadataPath: string;
downloadUrl: string;
streamUrl: string;
};
};
type StartCompositionInput = {
meetingId: string;
recordingId: string;
layout?: string;
format?: string;
};
type CompositionInputSets = {
videoInputs: ServerTrackRecordingFile[];
audioInputs: ServerTrackRecordingFile[];
};
const jobs: Map<string, RecordingCompositionJob> = new Map<string, RecordingCompositionJob>();
function nowIso(): string {
return new Date().toISOString();
}
function normalizeOption(value: unknown, fallback: string): string {
if (typeof value !== 'string') {
return fallback;
}
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, 40) : fallback;
}
function normalizeFormat(value: unknown): string {
return normalizeOption(value, 'webm') === 'mp4' ? 'mp4' : 'webm';
}
function getFfmpegPath(): string {
return process.env.FFMPEG_PATH || 'ffmpeg';
}
function sortInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
return files.slice().sort((a, b) => {
const participantCompare = a.participantId.localeCompare(b.participantId);
if (participantCompare !== 0) {
return participantCompare;
}
return Date.parse(a.uploadedAt) - Date.parse(b.uploadedAt);
});
}
function getInputSets(input: StartCompositionInput): CompositionInputSets {
const files = listServerTrackRecordingFiles({
meetingId: input.meetingId,
recordingId: input.recordingId
});
return {
videoInputs: sortInputs(files.filter((file) => file.trackKind === 'video')),
audioInputs: sortInputs(files.filter((file) => file.trackKind === 'audio'))
};
}
function isHostInput(file: ServerTrackRecordingFile): boolean {
if (file.metadata && file.metadata.role === 'host') {
return true;
}
const firstParticipant = file.metadata && Array.isArray(file.metadata.participants)
? file.metadata.participants[0]
: null;
return Boolean(firstParticipant && firstParticipant.role === 'host');
}
function orderVideoInputsForComposition(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
const hostIndex = files.findIndex(isHostInput);
if (hostIndex <= 0) {
return files.slice();
}
return [
files[hostIndex],
...files.slice(0, hostIndex),
...files.slice(hostIndex + 1)
];
}
function getBottomTileWidth(index: number, inputCount: number, outputWidth: number): number {
const sideCount = inputCount - 1;
if (sideCount <= 1) {
return outputWidth;
}
const rawWidth = Math.floor(outputWidth / sideCount);
const tileWidth = rawWidth % 2 === 0 ? rawWidth : rawWidth - 1;
return index === sideCount - 1 ? outputWidth - (tileWidth * index) : tileWidth;
}
function createHostBottomLayout(inputCount: number, outputWidth: number, hostHeight: number): string {
const positions = ['0_0'];
const sideCount = inputCount - 1;
let x = 0;
for (let sideIndex = 0; sideIndex < sideCount; sideIndex += 1) {
positions.push(`${x}_${hostHeight}`);
x += getBottomTileWidth(sideIndex, inputCount, outputWidth);
}
return positions.join('|');
}
export function buildFfmpegCompositionArgs(input: {
videoInputs: ServerTrackRecordingFile[];
audioInputs: ServerTrackRecordingFile[];
outputPath: string;
format: string;
}): string[] {
const outputWidth = 1280;
const outputHeight = 720;
const hostHeight = 540;
const bottomHeight = outputHeight - hostHeight;
const videoInputs = orderVideoInputsForComposition(input.videoInputs);
const args = ['-y'];
const orderedInputs = videoInputs.concat(input.audioInputs);
orderedInputs.forEach((file) => {
args.push('-i', file.filePath);
});
const filters: string[] = [];
videoInputs.forEach((_file, index) => {
const width = videoInputs.length === 1
? outputWidth
: index === 0 ? outputWidth : getBottomTileWidth(index - 1, videoInputs.length, outputWidth);
const height = videoInputs.length === 1
? outputHeight
: index === 0 ? hostHeight : bottomHeight;
filters.push(`[${index}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v${index}]`);
});
if (videoInputs.length === 1) {
filters.push('[v0]fps=30,format=yuv420p[vout]');
} else {
const videoLabels = videoInputs.map((_file, index) => `[v${index}]`).join('');
filters.push(`${videoLabels}xstack=inputs=${videoInputs.length}:layout=${createHostBottomLayout(videoInputs.length, outputWidth, hostHeight)}:fill=black,fps=30,format=yuv420p[vout]`);
}
if (input.audioInputs.length === 1) {
const audioInputIndex = videoInputs.length;
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0[aout]`);
} else if (input.audioInputs.length > 1) {
const audioLabels = input.audioInputs
.map((_file, index) => `[${videoInputs.length + index}:a]`)
.join('');
filters.push(`${audioLabels}amix=inputs=${input.audioInputs.length}:duration=longest:dropout_transition=2[aout]`);
}
args.push('-filter_complex', filters.join(';'), '-map', '[vout]');
if (input.audioInputs.length > 0) {
args.push('-map', '[aout]');
}
if (input.format === 'mp4') {
args.push('-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p');
if (input.audioInputs.length > 0) {
args.push('-c:a', 'aac');
}
} else {
args.push('-c:v', 'libvpx-vp9', '-deadline', 'realtime', '-cpu-used', '4');
if (input.audioInputs.length > 0) {
args.push('-c:a', 'libopus');
}
}
args.push('-shortest', input.outputPath);
return args;
}
function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(getFfmpegPath(), args, { windowsHide: true });
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error: any) => {
if (error && error.code === 'ENOENT') {
reject(new Error('ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH.'));
return;
}
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr || `ffmpeg exited with code ${code}`));
});
});
}
function toOutput(job: RecordingCompositionJob, target: ServerTrackRecordingTarget): RecordingCompositionJob['output'] {
return {
meetingId: target.meetingId,
filename: target.filename,
filePath: target.filePath,
metadataPath: target.metadataPath,
downloadUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/download`,
streamUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/stream`
};
}
async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise<RecordingCompositionJob> {
const timestamp = nowIso();
job.status = 'running';
job.startedAt = timestamp;
job.updatedAt = timestamp;
try {
const inputSets = getInputSets(job);
if (inputSets.videoInputs.length === 0) {
throw new Error('No server-side video track files are available for composition.');
}
const target = createComposedRecordingTarget({
meetingId: job.meetingId,
recordingId: job.recordingId,
format: job.format
});
const compositionInputs = inputSets.videoInputs.concat(inputSets.audioInputs);
const args = buildFfmpegCompositionArgs({
...inputSets,
outputPath: target.filePath,
format: job.format
});
await runFfmpeg(args);
writeComposedRecordingMetadata({
target,
recordingId: job.recordingId,
inputs: compositionInputs,
layout: job.layout,
format: job.format
});
const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs);
const completedAt = nowIso();
job.status = 'completed';
job.completedAt = completedAt;
job.updatedAt = completedAt;
job.inputFiles = compositionInputs.map((file) => file.filename);
job.deletedInputFiles = deletedInputFiles;
job.output = toOutput(job, target);
} catch (error) {
const failedAt = nowIso();
job.status = 'failed';
job.failedAt = failedAt;
job.updatedAt = failedAt;
job.error = error instanceof Error ? error.message : String(error);
}
return job;
}
export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob {
const timestamp = nowIso();
const inputSets = getInputSets(input);
const job: RecordingCompositionJob = {
id: uuid(),
recordingId: normalizeOption(input.recordingId, ''),
meetingId: normalizeOption(input.meetingId, ''),
status: 'queued',
layout: normalizeOption(input.layout, 'grid'),
format: normalizeFormat(input.format),
createdAt: timestamp,
updatedAt: timestamp,
inputFiles: inputSets.videoInputs.concat(inputSets.audioInputs).map((file) => file.filename)
};
jobs.set(job.id, job);
runRecordingCompositionJob(job);
return job;
}
export function getRecordingCompositionJob(jobId: string): RecordingCompositionJob | null {
return jobs.get(jobId) || null;
}
export function listRecordingCompositionJobs(meetingId?: string): RecordingCompositionJob[] {
const allJobs = Array.from(jobs.values());
return meetingId
? allJobs.filter((job) => job.meetingId === meetingId)
: allJobs;
}
export function resetRecordingCompositionJobs(): void {
jobs.clear();
}

View File

@@ -0,0 +1,92 @@
import { v4 as uuid } from 'uuid';
export type RecordingSessionStatus = 'recording' | 'stopped' | 'failed';
export type RecordingSession = {
id: string;
connectionId: string;
status: RecordingSessionStatus;
layout: string;
format: string;
createdAt: string;
startedAt: string;
updatedAt: string;
stoppedAt?: string;
error?: string;
};
export type StartRecordingSessionInput = {
connectionId: string;
layout?: string;
format?: string;
};
const sessions: Map<string, RecordingSession> = new Map<string, RecordingSession>();
function nowIso(): string {
return new Date().toISOString();
}
function normalizeOption(value: unknown, fallback: string): string {
if (typeof value !== 'string') {
return fallback;
}
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, 40) : fallback;
}
export function startRecordingSession(input: StartRecordingSessionInput): RecordingSession {
const connectionId = normalizeOption(input.connectionId, '');
if (!connectionId) {
throw new Error('connectionId is required');
}
const timestamp = nowIso();
const session: RecordingSession = {
id: uuid(),
connectionId,
status: 'recording',
layout: normalizeOption(input.layout, 'grid'),
format: normalizeOption(input.format, 'webm'),
createdAt: timestamp,
startedAt: timestamp,
updatedAt: timestamp
};
sessions.set(session.id, session);
return session;
}
export function stopRecordingSession(recordingId: string): RecordingSession | null {
const session = sessions.get(recordingId);
if (!session) {
return null;
}
const timestamp = nowIso();
const nextSession: RecordingSession = {
...session,
status: 'stopped',
stoppedAt: timestamp,
updatedAt: timestamp
};
sessions.set(recordingId, nextSession);
return nextSession;
}
export function getRecordingSession(recordingId: string): RecordingSession | null {
return sessions.get(recordingId) || null;
}
export function listRecordingSessions(connectionId?: string): RecordingSession[] {
const allSessions = Array.from(sessions.values());
return connectionId
? allSessions.filter((session) => session.connectionId === connectionId)
: allSessions;
}
export function resetRecordingSessions(): void {
sessions.clear();
}

257
src/recording/storage.ts Normal file
View File

@@ -0,0 +1,257 @@
import * as fs from 'fs';
import * as path from 'path';
export type ServerTrackRecordingTarget = {
meetingId: string;
directory: string;
filename: string;
filePath: string;
metadataPath: string;
};
export type ServerTrackRecordingFile = ServerTrackRecordingTarget & {
recordingId: string;
participantId: string;
trackId: string;
trackKind: string;
uploadedAt: string;
metadata: any;
};
type CreateTargetInput = {
recordingId: string;
connectionId: string;
participantId: string;
role?: string;
kind: string;
trackId: string;
};
type CreateComposedTargetInput = {
recordingId: string;
meetingId: string;
format?: string;
};
type WriteMetadataInput = CreateTargetInput & {
target: ServerTrackRecordingTarget;
};
type WriteComposedMetadataInput = {
target: ServerTrackRecordingTarget;
recordingId: string;
inputs: ServerTrackRecordingFile[];
layout: string;
format: string;
};
export function getRecordingRoot(): string {
return path.resolve(process.env.RECORDING_DIR || path.join(process.cwd(), 'recordings'));
}
export function sanitizeRecordingPathSegment(value: string | undefined, fallback: string): string {
const sanitized = (value || fallback)
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/^\.+/, '_')
.slice(0, 120);
return sanitized || fallback;
}
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative.length === 0 || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function timestampForFilename(): string {
return new Date().toISOString().replace(/[:.]/g, '-');
}
export function createServerTrackRecordingTarget(input: CreateTargetInput): ServerTrackRecordingTarget {
const recordingRoot = getRecordingRoot();
const meetingId = sanitizeRecordingPathSegment(input.connectionId, 'unknown');
const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording');
const participantId = sanitizeRecordingPathSegment(input.participantId, 'participant');
const kind = sanitizeRecordingPathSegment(input.kind, 'media');
const trackId = sanitizeRecordingPathSegment(input.trackId, 'track');
const directory = path.join(recordingRoot, meetingId);
const filename = `${timestampForFilename()}-${recordingId}-${participantId}-${kind}-${trackId}.webm`;
const filePath = path.join(directory, filename);
const metadataPath = path.join(directory, `${filename}.json`);
if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) {
throw new Error('Invalid server recording path');
}
fs.mkdirSync(directory, { recursive: true });
return { meetingId, directory, filename, filePath, metadataPath };
}
export function createComposedRecordingTarget(input: CreateComposedTargetInput): ServerTrackRecordingTarget {
const recordingRoot = getRecordingRoot();
const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown');
const recordingId = sanitizeRecordingPathSegment(input.recordingId, 'recording');
const format = input.format === 'mp4' ? 'mp4' : 'webm';
const directory = path.join(recordingRoot, meetingId);
const filename = `${timestampForFilename()}-${recordingId}-composed.${format}`;
const filePath = path.join(directory, filename);
const metadataPath = path.join(directory, `${filename}.json`);
if (!isPathInside(recordingRoot, filePath) || !isPathInside(recordingRoot, metadataPath)) {
throw new Error('Invalid composed recording path');
}
fs.mkdirSync(directory, { recursive: true });
return { meetingId, directory, filename, filePath, metadataPath };
}
export function writeServerTrackRecordingMetadata(input: WriteMetadataInput): void {
const now = new Date().toISOString();
const role = input.role === 'host' ? 'host' : 'participant';
const metadata = {
id: `${input.recordingId}-${input.participantId}-${input.kind}-${input.trackId}`,
meetingId: input.target.meetingId,
filename: input.target.filename,
originalFilename: `server-recording-${input.participantId}-${input.kind}.webm`,
mimetype: 'video/webm',
size: 0,
userId: 'server-recorder',
host: {
userId: 'server-recorder',
id: 'server-recorder',
name: 'Server Recorder',
role: 'recorder'
},
participants: [
{
participantId: input.participantId,
id: input.participantId,
role
}
],
uploadedAt: now,
updatedAt: now,
recordingSource: 'server',
recordingId: input.recordingId,
participantId: input.participantId,
role,
trackId: input.trackId,
trackKind: input.kind
};
fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2));
}
export function updateServerTrackRecordingMetadataSize(target: ServerTrackRecordingTarget): void {
if (!fs.existsSync(target.metadataPath) || !fs.existsSync(target.filePath)) {
return;
}
const metadata = JSON.parse(fs.readFileSync(target.metadataPath, 'utf8'));
metadata.size = fs.statSync(target.filePath).size;
metadata.updatedAt = new Date().toISOString();
fs.writeFileSync(target.metadataPath, JSON.stringify(metadata, null, 2));
}
export function listServerTrackRecordingFiles(input: {
meetingId: string;
recordingId?: string;
trackKind?: string;
}): ServerTrackRecordingFile[] {
const recordingRoot = getRecordingRoot();
const meetingId = sanitizeRecordingPathSegment(input.meetingId, 'unknown');
const directory = path.join(recordingRoot, meetingId);
if (!fs.existsSync(directory)) {
return [];
}
return fs.readdirSync(directory)
.filter((filename) => path.extname(filename).toLowerCase() === '.webm')
.map((filename) => {
const filePath = path.join(directory, filename);
const metadataPath = path.join(directory, `${filename}.json`);
if (!fs.statSync(filePath).isFile() || !fs.existsSync(metadataPath)) {
return null;
}
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
if (metadata.recordingSource !== 'server') {
return null;
}
if (input.recordingId && metadata.recordingId !== input.recordingId) {
return null;
}
if (input.trackKind && metadata.trackKind !== input.trackKind) {
return null;
}
return {
meetingId,
directory,
filename,
filePath,
metadataPath,
metadata,
recordingId: metadata.recordingId || '',
participantId: metadata.participantId || '',
trackId: metadata.trackId || '',
trackKind: metadata.trackKind || '',
uploadedAt: metadata.uploadedAt || fs.statSync(filePath).birthtime.toISOString()
};
})
.filter((file) => Boolean(file)) as ServerTrackRecordingFile[];
}
export function deleteServerTrackRecordingFiles(files: ServerTrackRecordingFile[]): string[] {
const deletedFiles: string[] = [];
files.forEach((file) => {
if (fs.existsSync(file.filePath)) {
fs.unlinkSync(file.filePath);
deletedFiles.push(file.filename);
}
if (fs.existsSync(file.metadataPath)) {
fs.unlinkSync(file.metadataPath);
deletedFiles.push(`${file.filename}.json`);
}
});
return deletedFiles;
}
export function writeComposedRecordingMetadata(input: WriteComposedMetadataInput): void {
const now = new Date().toISOString();
const participantsById: { [participantId: string]: any } = {};
input.inputs.forEach((file) => {
if (!file.participantId || participantsById[file.participantId]) {
return;
}
participantsById[file.participantId] = {
participantId: file.participantId,
id: file.participantId,
role: 'participant'
};
});
const metadata = {
id: `${input.recordingId}-composed`,
meetingId: input.target.meetingId,
filename: input.target.filename,
originalFilename: `server-recording-${input.recordingId}-composed.${input.format}`,
mimetype: input.format === 'mp4' ? 'video/mp4' : 'video/webm',
size: fs.existsSync(input.target.filePath) ? fs.statSync(input.target.filePath).size : 0,
userId: 'server-recorder',
host: {
userId: 'server-recorder',
id: 'server-recorder',
name: 'Server Recorder',
role: 'recorder'
},
participants: Object.keys(participantsById).map((participantId) => participantsById[participantId]),
uploadedAt: now,
updatedAt: now,
recordingSource: 'server-composed',
recordingId: input.recordingId,
layout: input.layout,
inputFiles: input.inputs.map((file) => file.filename)
};
fs.writeFileSync(input.target.metadataPath, JSON.stringify(metadata, null, 2));
}

View File

@@ -0,0 +1,284 @@
import {
incrementRecordingTrackPackets,
registerRecordingPeerAnswer,
registerRecordingPeerTrack
} from './agent';
import { log, LogLevel } from '../log';
import {
ServerTrackRecordingTarget,
createServerTrackRecordingTarget,
updateServerTrackRecordingMetadataSize,
writeServerTrackRecordingMetadata
} from './storage';
type RecordingPeerKey = string;
type RecordingTrackRecorderKey = string;
type WeriftPeerConnection = any;
type WeriftMediaRecorder = any;
const werift = require('werift');
const RTCPeerConnection = werift.RTCPeerConnection;
const weriftNonstandard = require('werift/nonstandard');
const MediaRecorder = weriftNonstandard.MediaRecorder;
type RecordingPeerState = {
pc: WeriftPeerConnection;
recordingId: string;
connectionId: string;
participantId: string;
pendingCandidates: Array<{
candidate: string;
sdpMid?: string;
sdpMLineIndex?: number;
}>;
};
type RecordingTrackRecorderState = {
recorder: WeriftMediaRecorder;
target: ServerTrackRecordingTarget;
recordingId: string;
participantId: string;
trackId: string;
};
type AcceptOfferInput = {
recordingId: string;
connectionId: string;
participantId: string;
role?: string;
sdp: string;
onLocalCandidate?: (candidate: any) => void;
};
const peers: Map<RecordingPeerKey, RecordingPeerState> = new Map<RecordingPeerKey, RecordingPeerState>();
const trackRecorders: Map<RecordingTrackRecorderKey, RecordingTrackRecorderState> = new Map<RecordingTrackRecorderKey, RecordingTrackRecorderState>();
function peerKey(recordingId: string, participantId: string): RecordingPeerKey {
return `${recordingId}:${participantId}`;
}
function trackRecorderKey(recordingId: string, participantId: string, trackId: string): RecordingTrackRecorderKey {
return `${recordingId}:${participantId}:${trackId}`;
}
function getPeer(recordingId: string, participantId: string): RecordingPeerState | null {
return peers.get(peerKey(recordingId, participantId)) || null;
}
function createPeer(input: AcceptOfferInput): RecordingPeerState {
const pc = new RTCPeerConnection({
iceUseIpv4: true,
iceUseIpv6: false
});
const state: RecordingPeerState = {
pc,
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
pendingCandidates: []
};
pc.onicecandidate = (event) => {
if (event.candidate && input.onLocalCandidate) {
input.onLocalCandidate(event.candidate);
}
};
pc.ontrack = (event) => {
const trackId = event.track.id || event.track.uuid || `${event.track.kind}-${Date.now()}`;
registerRecordingPeerTrack({
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
kind: event.track.kind,
trackId
});
startTrackRecorder({
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
role: input.role,
kind: event.track.kind,
trackId,
track: event.track
});
event.track.onReceiveRtp.subscribe(() => {
incrementRecordingTrackPackets({
recordingId: input.recordingId,
participantId: input.participantId,
trackId
});
});
};
peers.set(peerKey(input.recordingId, input.participantId), state);
return state;
}
async function flushPendingCandidates(state: RecordingPeerState): Promise<void> {
if (!state.pc.remoteDescription || state.pendingCandidates.length === 0) {
return;
}
const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length);
for (const candidate of pendingCandidates) {
await state.pc.addIceCandidate(candidate);
}
}
function startTrackRecorder(input: {
recordingId: string;
connectionId: string;
participantId: string;
role?: string;
kind: string;
trackId: string;
track: any;
}): void {
const key = trackRecorderKey(input.recordingId, input.participantId, input.trackId);
if (trackRecorders.has(key)) {
return;
}
try {
const target = createServerTrackRecordingTarget(input);
writeServerTrackRecordingMetadata({ ...input, target });
const recorder = new MediaRecorder({
path: target.filePath,
tracks: [input.track],
width: 1280,
height: 720,
disableLipSync: true,
defaultDuration: 24 * 60 * 60
});
if (recorder.onError && typeof recorder.onError.subscribe === 'function') {
recorder.onError.subscribe((error: Error) => {
log(LogLevel.warn, 'Server recording writer failed:', error);
});
}
trackRecorders.set(key, {
recorder,
target,
recordingId: input.recordingId,
participantId: input.participantId,
trackId: input.trackId
});
} catch (error) {
log(LogLevel.error, 'Failed to start server track recorder:', error);
}
}
async function stopTrackRecorders(recordingId: string, participantId?: string): Promise<void> {
const keys = Array.from(trackRecorders.keys()).filter((key) => {
if (!recordingId) {
return true;
}
if (participantId) {
return key.startsWith(`${recordingId}:${participantId}:`);
}
return key.startsWith(`${recordingId}:`);
});
for (const key of keys) {
const state = trackRecorders.get(key);
if (!state) {
continue;
}
try {
await state.recorder.stop();
updateServerTrackRecordingMetadataSize(state.target);
} catch (error) {
log(LogLevel.warn, 'Failed to stop server track recorder:', error);
} finally {
trackRecorders.delete(key);
}
}
}
export async function acceptRecordingOffer(input: AcceptOfferInput): Promise<string> {
const existing = getPeer(input.recordingId, input.participantId);
if (existing) {
await stopTrackRecorders(input.recordingId, input.participantId);
await existing.pc.close();
peers.delete(peerKey(input.recordingId, input.participantId));
}
const state = createPeer(input);
await state.pc.setRemoteDescription({
type: 'offer',
sdp: input.sdp
});
await flushPendingCandidates(state);
const answer = await state.pc.createAnswer();
await state.pc.setLocalDescription(answer);
const sdp = state.pc.localDescription ? state.pc.localDescription.sdp : answer.sdp;
registerRecordingPeerAnswer({
recordingId: input.recordingId,
connectionId: input.connectionId,
participantId: input.participantId,
sdp
});
return sdp;
}
export async function addRecordingIceCandidate(input: {
recordingId: string;
participantId: string;
candidate: string;
sdpMid?: string;
sdpMLineIndex?: number;
}): Promise<boolean> {
const state = getPeer(input.recordingId, input.participantId);
if (!state) {
return false;
}
const candidate = {
candidate: input.candidate,
sdpMid: input.sdpMid,
sdpMLineIndex: input.sdpMLineIndex
};
if (!state.pc.remoteDescription) {
state.pendingCandidates.push(candidate);
return true;
}
await state.pc.addIceCandidate(candidate);
return true;
}
export async function stopRecordingPeer(recordingId: string, participantId?: string): Promise<void> {
await stopTrackRecorders(recordingId, participantId);
const keys = Array.from(peers.keys()).filter((key) => {
if (participantId) {
return key === peerKey(recordingId, participantId);
}
return key.startsWith(`${recordingId}:`);
});
for (const key of keys) {
const state = peers.get(key);
if (state) {
await state.pc.close();
peers.delete(key);
}
}
}
export async function resetRecordingPeers(): Promise<void> {
await stopTrackRecorders('');
const keys = Array.from(peers.keys());
for (const key of keys) {
const state = peers.get(key);
if (state) {
await state.pc.close();
}
peers.delete(key);
}
}

View File

@@ -0,0 +1,131 @@
# Server Recording Plan
## Goal
Move meeting recording from browser-only `MediaRecorder` to a server-side recorder. The server creates a recording session, asks every client in the room to publish local media to a dedicated recorder peer, and stores received media under the existing recordings directory.
## Current Implementation
### HTTP APIs
- `GET /api/recording-sessions`
- Lists active and historical in-memory recording sessions.
- Optional query: `connectionId`.
- `GET /api/recording-sessions/:recordingId`
- Returns a session plus its recorder agent state.
- `POST /api/recording-sessions`
- Body: `{ "connectionId": "...", "layout": "grid", "format": "webm" }`
- Creates one active server recording session for a room.
- Broadcasts `recording-started` and `recording-peer-request`.
- `DELETE /api/recording-sessions/:recordingId`
- Stops the session, closes recorder peers, finalizes recorder metadata, and broadcasts `recording-stopped`.
- Starts a background composition job by default. Use `?compose=false` to skip composition.
- `GET /api/recording-compositions`
- Lists background composition jobs.
- Optional query: `meetingId`.
- `GET /api/recording-compositions/:compositionId`
- Returns a single composition job.
- `POST /api/recording-compositions`
- Body: `{ "meetingId": "...", "recordingId": "...", "layout": "grid", "format": "webm" }`
- Starts a background composition job manually.
### WebSocket Messages
- Server to clients:
- `recording-started`
- `recording-peer-request`
- `recording-stopped`
- `recording-answer`
- `recording-candidate`
- `recording-status`
- Client to server:
- `recording-offer`
- `recording-candidate`
- `recording-status`
### Media Flow
1. Host clicks the existing recording button.
2. Client calls `POST /api/recording-sessions`.
3. Server broadcasts a recorder peer request to the whole room.
4. Each client creates an independent `RTCPeerConnection` with local tracks as `sendonly`.
5. Server accepts each offer through `werift`.
6. Each received track is written as an individual WebM file with a metadata JSON file.
7. When recording stops, the server starts a composition job.
8. The composition job uses FFmpeg to create one grid video and mixed audio artifact.
9. Existing `/api/recordings` list, stream, download, patch, and delete APIs can see both raw track files and composed files.
### Candidate Ordering
Recorder peer candidates can arrive before the browser has applied the server answer. The client recorder peer buffers those candidates until `setRemoteDescription(answer)` completes, then flushes them in order. This avoids `InvalidStateError: remote description was null`.
## Storage
Default root:
```text
recordings/
```
Override with:
```text
RECORDING_DIR=/absolute/path/to/recordings
```
Current server-side files are stored as:
```text
recordings/<connectionId>/<timestamp>-<recordingId>-<participantId>-<kind>-<trackId>.webm
recordings/<connectionId>/<timestamp>-<recordingId>-<participantId>-<kind>-<trackId>.webm.json
```
Composed files are stored as:
```text
recordings/<connectionId>/<timestamp>-<recordingId>-composed.webm
recordings/<connectionId>/<timestamp>-<recordingId>-composed.webm.json
```
## Composition Runtime
Composition requires FFmpeg on the server.
Configure either:
```text
FFMPEG_PATH=/absolute/path/to/ffmpeg
```
or put `ffmpeg` on the server `PATH`.
The default output format is WebM:
- Video codec: `libvpx-vp9`
- Audio codec: `libopus`
MP4 output is also supported:
- Video codec: `libx264`
- Audio codec: `aac`
## Lifecycle Rules
- A room can have only one active server recording session.
- New clients joining a room with an active recording immediately receive a recorder peer request.
- If a participant leaves, only that participant's recorder peer is closed.
- If the host leaves, active recording sessions for that room are stopped and all recorder peers are closed.
- If local media changes while recording is active, the client restarts its recorder peer so the server receives the latest track set.
- After a composition job completes successfully, the raw per-track `.webm` files and their `.webm.json` metadata files are deleted. Failed composition jobs keep raw files so they can be retried.
## Current Limitation
The first composition layout is a deterministic grid plus audio mix. It does not yet support active-speaker switching, custom branding, timestamp overlays, or per-user name plates. Raw track files are still kept so failed composition jobs can be retried.
## Validation
```text
npm.cmd run build
npm.cmd test -- --runInBand
npm.cmd run lint
```

View File

@@ -7,6 +7,29 @@ import signaling from './signaling';
import { log, LogLevel } from './log';
import Options from './class/options';
import { reset as resetHandler } from './class/httphandler';
import {
broadcastRecordingPeerRequest,
broadcastRecordingStarted,
broadcastRecordingStopped,
onGetRooms as getWebSocketRooms
} from './class/websockethandler';
import {
getRecordingAgent,
startRecordingAgent,
stopRecordingAgent
} from './recording/agent';
import {
getRecordingSession,
listRecordingSessions,
startRecordingSession,
stopRecordingSession
} from './recording/session-manager';
import {
getRecordingCompositionJob,
listRecordingCompositionJobs,
startRecordingCompositionJob
} from './recording/composer';
import { stopRecordingPeer } from './recording/werift-adapter';
import { initSwagger } from './swagger';
const cors = require('cors');
@@ -139,7 +162,15 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
return '';
}
return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength);
return String(value)
.split('')
.filter((character) => {
const code = character.charCodeAt(0);
return code >= 32 && code !== 127;
})
.join('')
.trim()
.slice(0, maxLength);
}
function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
@@ -285,6 +316,16 @@ function removeEmptyDirectory(directory: string): void {
}
}
function getActiveRecordingSession(connectionId: string) {
const sessions = listRecordingSessions(connectionId);
for (const session of sessions) {
if (session.status === 'recording') {
return session;
}
}
return null;
}
export const createServer = (config: Options): express.Express => {
const app: express.Express = express();
resetHandler(config.mode);
@@ -452,6 +493,120 @@ export const createServer = (config: Options): express.Express => {
}
});
app.get('/api/recording-sessions', (req: express.Request, res: express.Response) => {
const connectionId = typeof req.query.connectionId === 'string'
? sanitizeMetadataString(req.query.connectionId, 120)
: undefined;
const sessions = listRecordingSessions(connectionId);
res.json({ success: true, sessions, totalCount: sessions.length });
});
app.get('/api/recording-sessions/:recordingId', (req: express.Request, res: express.Response) => {
const session = getRecordingSession(req.params.recordingId);
if (!session) {
res.status(404).json({ success: false, message: 'Recording session not found' });
return;
}
res.json({ success: true, session, agent: getRecordingAgent(session.id) });
});
app.post('/api/recording-sessions', (req: express.Request, res: express.Response) => {
const connectionId = sanitizeMetadataString(req.body.connectionId, 120);
if (!connectionId) {
res.status(400).json({ success: false, message: 'connectionId is required' });
return;
}
if (config.type === 'websocket' && getWebSocketRooms(connectionId).length === 0) {
res.status(404).json({ success: false, message: 'Active WebSocket room not found' });
return;
}
const activeSession = getActiveRecordingSession(connectionId);
if (activeSession) {
res.status(409).json({ success: false, message: 'Recording is already running', session: activeSession });
return;
}
try {
const session = startRecordingSession({
connectionId,
layout: req.body.layout,
format: req.body.format
});
const agent = startRecordingAgent(session);
const notified = broadcastRecordingStarted(session);
const peerRequestNotified = broadcastRecordingPeerRequest(session);
res.status(201).json({ success: true, session, agent, notified, peerRequestNotified });
} catch (error) {
log(LogLevel.error, 'Failed to start recording session:', error);
res.status(500).json({ success: false, message: 'Failed to start recording session' });
}
});
app.delete('/api/recording-sessions/:recordingId', async (req: express.Request, res: express.Response) => {
const session = stopRecordingSession(req.params.recordingId);
if (!session) {
res.status(404).json({ success: false, message: 'Recording session not found' });
return;
}
const notified = broadcastRecordingStopped(session);
const agent = stopRecordingAgent(session.id);
try {
await stopRecordingPeer(session.id);
} catch (error) {
log(LogLevel.warn, 'Failed to stop recording peer:', error);
}
const shouldCompose = req.query.compose !== 'false';
const compositionJob = shouldCompose
? startRecordingCompositionJob({
meetingId: session.connectionId,
recordingId: session.id,
layout: session.layout,
format: session.format
})
: null;
res.json({ success: true, session, agent, notified, compositionJob });
});
app.get('/api/recording-compositions', (req: express.Request, res: express.Response) => {
const meetingId = typeof req.query.meetingId === 'string'
? sanitizePathSegment(req.query.meetingId, 'unknown')
: undefined;
const jobs = listRecordingCompositionJobs(meetingId);
res.json({ success: true, jobs, totalCount: jobs.length });
});
app.get('/api/recording-compositions/:compositionId', (req: express.Request, res: express.Response) => {
const job = getRecordingCompositionJob(req.params.compositionId);
if (!job) {
res.status(404).json({ success: false, message: 'Recording composition job not found' });
return;
}
res.json({ success: true, job });
});
app.post('/api/recording-compositions', (req: express.Request, res: express.Response) => {
const meetingId = sanitizeMetadataString(req.body.meetingId, 120);
const recordingId = sanitizeMetadataString(req.body.recordingId, 120);
if (!meetingId || !recordingId) {
res.status(400).json({ success: false, message: 'meetingId and recordingId are required' });
return;
}
const job = startRecordingCompositionJob({
meetingId,
recordingId,
layout: req.body.layout,
format: req.body.format
});
res.status(202).json({ success: true, job });
});
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
try {
const recordings = listRecordings(recordingRoot);

View File

@@ -17,6 +17,9 @@ const VALID_MESSAGE_TYPES = new Set([
"host-userInfo",
"invite-call",
"on-message",
"recording-offer",
"recording-candidate",
"recording-status",
]);
function sendJson(ws: WebSocket, payload: unknown): void {
@@ -204,6 +207,16 @@ export default class WSSignaling {
if (msg.from) msg.data.connectionId = msg.from;
handler.onMessage(ws, msg.data);
break;
case 'recording-offer':
if (!hasData(msg)) return;
handler.onRecordingOffer(ws, msg.data);
break;
case 'recording-candidate':
if (!hasData(msg)) return;
handler.onRecordingCandidate(ws, msg.data);
break;
case 'recording-status':
break;
default:
break;
}