视频会议保存

This commit is contained in:
duoaohui
2026-05-18 19:42:55 +08:00
parent 315884685f
commit ac8ec4c903
41 changed files with 5069 additions and 39 deletions

View File

@@ -8,6 +8,7 @@ import { internalAuthPlugin } from './middlewares/internal-auth.js';
import { closeWorker, getWorkerSnapshot, startWorker } from './mediasoup/worker.js';
import { consumerRoutes } from './routes/consumer.route.js';
import { producerRoutes } from './routes/producer.route.js';
import { recordingRoutes } from './routes/recording.route.js';
import { routerRoutes } from './routes/router.route.js';
import { transportRoutes } from './routes/transport.route.js';
import { getRouterStats } from './services/router.service.js';
@@ -78,6 +79,7 @@ export async function buildApp() {
await scope.register(transportRoutes);
await scope.register(producerRoutes);
await scope.register(consumerRoutes);
await scope.register(recordingRoutes);
},
{ prefix: '/internal/v1' },
);

View File

@@ -0,0 +1,44 @@
import type { FastifyInstance } from 'fastify';
import {
recordingIdParamSchema,
startRecordingBodySchema,
} from '../schemas/recording.schema.js';
import {
getRecording,
startRecording,
stopRecording,
} from '../services/recording.service.js';
/**
* 录制 REST 路由(挂载在 /internal/v1 前缀下)
*
* - POST /recordings 启动录制;返回 recordingId + 已绑定的 tracks
* - DELETE /recordings/:id 停止录制;返回文件大小、时长、退出码、失败原因
* - GET /recordings/:id 查询正在进行中的录制状态(结束后返 404
*
* 这些都是受 internalAuthPlugin 保护的内部接口,不直接暴露给客户端
*/
export async function recordingRoutes(app: FastifyInstance): Promise<void> {
app.post('/recordings', async (request, reply) => {
const body = startRecordingBodySchema.parse(request.body);
const result = await startRecording({
routerId: body.routerId,
roomCode: body.roomCode,
producerIds: body.producerIds,
});
reply.code(201);
return result;
});
app.delete('/recordings/:id', async (request) => {
const { id } = recordingIdParamSchema.parse(request.params);
const result = await stopRecording(id);
return result;
});
app.get('/recordings/:id', async (request) => {
const { id } = recordingIdParamSchema.parse(request.params);
return getRecording(id);
});
}

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
import { idStringSchema } from './common.js';
/**
* 录制启动请求 schema
*
* 字段约束:
* - routerId: 必传media-server 通过它定位 mediasoup Router
* - roomCode: 必传;用于日志聚合 / Go 侧关联
* - producerIds: 至少 1 个Phase B1 通常是 host 的 audio + video 两个;
* Phase B3 扩展为屏幕共享 + 多人音频时,最大值由 RECORDING_MAX_TRACKS 限制
*/
export const startRecordingBodySchema = z.object({
routerId: idStringSchema,
roomCode: z.string().min(1).max(64),
producerIds: z.array(idStringSchema).min(1).max(8),
});
export const recordingIdParamSchema = z.object({
id: idStringSchema,
});
export type StartRecordingBody = z.infer<typeof startRecordingBodySchema>;
export type RecordingIdParam = z.infer<typeof recordingIdParamSchema>;

View File

@@ -0,0 +1,781 @@
/**
* 录制服务:基于 mediasoup PlainTransport + ffmpeg 子进程
*
* 录制管线:
* ┌─────────────┐ RTP/RTCP ┌──────────────┐ SDP+stdin ┌────────┐ fs ┌─────────┐
* │ Producer X │ ───────────> │ PlainTransport│ ───────────────> │ ffmpeg │ ──────> │ *.mp4 │
* │ (audio/video)│ │ + Consumer │ (UDP loopback) │ (子进程)│ └─────────┘
* └─────────────┘ └──────────────┘ └────────┘
*
* 启动顺序(关键):
* 1) 为每个 Producer 创建 Consumer 拿到 negotiated rtpParameters
* Consumer 立即 pause避免 RTP 在 ffmpeg 起来前空打)
* 2) 给每个 Consumer 分配一对 UDP 端口ffmpeg 监听端)
* 3) 写 SDP 文件描述所有流
* 4) spawn ffmpeg 进程listen 在那些端口
* 5) 创建对应 PlainTransportrtcpMux=false, comedia=false
* 并 connect 到 ffmpeg 监听端口
* 6) resume ConsumerRTP 开始流向 ffmpeg
*
* 停止顺序:
* 1) SIGINT ffmpeg让 ffmpeg 关闭 mp4 moov boxSIGKILL 会导致文件损坏)
* 2) 等 ffmpeg 进程退出(超时 5s 兜底 SIGKILL
* 3) close Consumer / PlainTransport
* 4) 释放端口
* 5) stat 输出文件,返回大小 + 时长
*
* 关键约束:
* - PlainTransport 用 rtcpMux=false避免 RTCP 与 RTP 同端口ffmpeg 解析更可靠
* - comedia=false由 server 主动 connectIP/Port 是已知的本地回环
* - 不开启 SRTPloopback 不需要加密,降低 CPU 与配置复杂度)
* - Consumer 必须设置 paused=true 创建,否则 RTP 会先于 ffmpeg 启动到达
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type {
Consumer,
PlainTransport,
RtpCodecParameters,
RtpParameters,
} from 'mediasoup/types';
import { config } from '../config.js';
import { AppError, notFound } from '../utils/errors.js';
import { childLogger } from '../utils/logger.js';
import { allocatePortPair, releasePortPair } from '../utils/port-pool.js';
import { assertTestOnly } from '../utils/test-guard.js';
import { getProducer } from './producer.service.js';
import { getRouter } from './router.service.js';
const log = childLogger({ module: 'services.recording' });
// ============ 类型 ============
interface RecordingTrack {
/** 来源 producer ID供日志/排障) */
producerId: string;
/** Consumer ID停止时关闭 */
consumerId: string;
/** PlainTransport ID停止时关闭 */
transportId: string;
/** ffmpeg 监听端口RTP */
ffmpegRtpPort: number;
/** ffmpeg 监听端口RTCP */
ffmpegRtcpPort: number;
kind: 'audio' | 'video';
/** payload type用于 SDP / 跨进程串联 */
payloadType: number;
/** Codec mime例如 audio/opus / video/VP8 */
codecMime: string;
/** Codec clock rate */
clockRate: number;
/** Codec channels音频专用 */
channels?: number;
/** Codec sdpFmtp 字段(如 minptime=10;useinbandfec=1 */
sdpFmtp?: string;
}
interface RecordingEntry {
id: string;
roomCode: string;
routerId: string;
startedAt: number;
/** 各路 producer 对应的 track */
tracks: RecordingTrack[];
/** mediasoup Consumer 句柄(停止时 close */
consumers: Consumer[];
/** mediasoup PlainTransport 句柄(停止时 close */
transports: PlainTransport[];
/** ffmpeg 子进程 */
ffmpeg: ChildProcess;
/** SDP 临时文件 */
sdpFilePath: string;
/** 输出 mp4 文件 */
outputPath: string;
/** ffmpeg 退出 promisestop 时 await */
ffmpegExited: Promise<void>;
/** 停止时回填,避免重复 stop */
stopping: boolean;
/** 退出码stop 后填充供 callback 返回 */
exitCode: number | null;
/** 失败原因spawn 阶段或 stop 超时),供 finalize 上报 */
failureReason: string | null;
/** ffmpeg 是否在 stop 之前异常退出watchdog 标记,避免重复触发回调) */
unexpectedExit: boolean;
}
// ==================== Watchdog / 失败回调 ====================
/**
* ffmpeg 异常退出回调签名
*
* 触发条件ffmpeg 在 stop 调用之前自行退出(非零退出码或 spawn 失败)
* 调用方可注册 webhook、上报告警、写入 DB 标记失败等
*
* 注意:回调在 ffmpeg 'exit' 事件后异步触发setImmediate不会阻塞退出处理
* 实现方需保证内部错误不抛出到 Node 主循环,否则会被全局 unhandledRejection 接住。
*/
export type RecordingFailureCallback = (info: {
recordingId: string;
roomCode: string;
routerId: string;
exitCode: number | null;
failureReason: string;
}) => void | Promise<void>;
let failureCallback: RecordingFailureCallback | null = null;
/**
* 注册录制失败回调(覆盖式:只保留最近一次注册)
* 传 null 取消注册
*/
export function setRecordingFailureCallback(cb: RecordingFailureCallback | null): void {
failureCallback = cb;
}
/**
* 默认 webhook 实现:当 process.env.RECORDING_FAILURE_WEBHOOK_URL 设置时,
* 通过 fetch POST JSON 上报失败。Node 18+ 自带 fetch
*/
async function defaultWebhookCallback(info: Parameters<RecordingFailureCallback>[0]): Promise<void> {
const url = process.env.RECORDING_FAILURE_WEBHOOK_URL;
if (!url) return;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info),
});
if (!res.ok) {
log.warn(
{ recordingId: info.recordingId, status: res.status },
'recording failure webhook returned non-2xx',
);
}
} catch (err) {
log.warn(
{ recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
'recording failure webhook POST failed',
);
}
}
/**
* 内部触发失败回调:异步、错误吞掉
* 同时尝试用户注册的回调和默认 webhook两者独立
*/
function fireFailureCallback(entry: RecordingEntry): void {
if (entry.unexpectedExit) return; // 幂等:每条录制最多触发一次
entry.unexpectedExit = true;
const info = {
recordingId: entry.id,
roomCode: entry.roomCode,
routerId: entry.routerId,
exitCode: entry.exitCode,
failureReason: entry.failureReason ?? 'ffmpeg exited unexpectedly',
};
setImmediate(() => {
void defaultWebhookCallback(info);
if (failureCallback) {
try {
const r = failureCallback(info);
if (r && typeof (r as Promise<void>).catch === 'function') {
(r as Promise<void>).catch((err) => {
log.warn(
{ recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
'recording failure callback rejected',
);
});
}
} catch (err) {
log.warn(
{ recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
'recording failure callback threw',
);
}
}
});
}
const recordingMap = new Map<string, RecordingEntry>();
// 默认输出目录;由 RECORDING_OUTPUT_DIR 环境变量覆盖,便于 docker volume 挂载
const OUTPUT_DIR = process.env.RECORDING_OUTPUT_DIR
? process.env.RECORDING_OUTPUT_DIR
: join(tmpdir(), 'echochat-recordings');
const STOP_GRACE_MS = 5000;
// ============ 工具函数 ============
async function ensureOutputDir(): Promise<void> {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
}
function recordingId(): string {
// crypto.randomUUID 在 node 20+ 可用移除连字符以让文件名更紧凑、URL-safe
return randomUUID().replace(/-/g, '');
}
/**
* 从 Consumer 的 rtpParameters 提取录制需要的关键字段
* - 取第一个 codecmediasoup consumer 默认每个 Consumer 只协商一个主 codec
* - sdpFmtp把 codec.parameters 拼成 SDP a=fmtp 行格式
*/
function pickCodec(rtpParameters: RtpParameters): {
payloadType: number;
codecMime: string;
clockRate: number;
channels?: number;
sdpFmtp?: string;
} {
const codec: RtpCodecParameters | undefined = rtpParameters.codecs[0];
if (!codec) {
throw new AppError('MEDIASOUP_ERROR', 'consumer has no codecs in rtpParameters');
}
const params = codec.parameters || {};
const fmtpParts = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${k}=${typeof v === 'boolean' ? (v ? 1 : 0) : String(v)}`);
return {
payloadType: codec.payloadType,
codecMime: codec.mimeType,
clockRate: codec.clockRate,
channels: codec.channels,
sdpFmtp: fmtpParts.length > 0 ? fmtpParts.join(';') : undefined,
};
}
/**
* 把 mimeaudio/opus / video/VP8转成 SDP rtpmap 编码名
* SDP a=rtpmap 不接受 `video/` 前缀,要单独取编码名
*/
function mimeToCodecName(mime: string): string {
const idx = mime.indexOf('/');
return idx >= 0 ? mime.slice(idx + 1) : mime;
}
/**
* 生成 SDP 内容
*
* 注意 SDP 是从 ffmpeg 视角写的c= 行的地址、m= 行的端口都是 ffmpeg 监听处。
* mediasoup PlainTransport 之后 connect({ ip, port: ffmpegPort, rtcpPort }) 把流送过来。
*
* 一份 SDP 描述了一个会话里 1~N 条流(每条对应一个 producer 的 consumer
*/
function buildSdp(tracks: RecordingTrack[], localIp: string): string {
const lines: string[] = [
'v=0',
`o=- 0 0 IN IP4 ${localIp}`,
's=echochat-recording',
`c=IN IP4 ${localIp}`,
't=0 0',
];
for (const t of tracks) {
const codecName = mimeToCodecName(t.codecMime);
const rtpmapValue =
t.kind === 'audio'
? `${codecName}/${t.clockRate}/${t.channels ?? 2}`
: `${codecName}/${t.clockRate}`;
lines.push(`m=${t.kind} ${t.ffmpegRtpPort} RTP/AVP ${t.payloadType}`);
lines.push(`a=rtcp:${t.ffmpegRtcpPort}`);
lines.push(`a=rtpmap:${t.payloadType} ${rtpmapValue}`);
if (t.sdpFmtp) {
lines.push(`a=fmtp:${t.payloadType} ${t.sdpFmtp}`);
}
lines.push('a=recvonly');
}
// SDP 必须以 \r\n 分隔ffmpeg 解析行为更稳定
return lines.join('\r\n') + '\r\n';
}
/**
* 构造 ffmpeg 命令行参数
*
* -protocol_whitelist file,udp,rtp默认只允许 file需要显式开 udp/rtp
* -fflags +genpts从 RTP 时间戳生成 PTS
* -use_wallclock_as_timestamps 1避免 RTP 时间戳跳变导致 mp4 时长异常
* -c copy不重编码直接复用 RTP 载荷
* - 优点CPU 占用极低
* - 缺点:浏览器侧 VP8 进 mp4 容器在某些播放器不支持;后续可改 libx264
* -y覆盖已存在文件防止 recordingId 碰撞)
*/
function buildFfmpegArgs(sdpFilePath: string, outputPath: string): string[] {
return [
'-loglevel',
'info',
'-protocol_whitelist',
'file,udp,rtp',
'-fflags',
'+genpts',
'-use_wallclock_as_timestamps',
'1',
'-i',
sdpFilePath,
'-map',
'0:a?',
'-map',
'0:v?',
'-c',
'copy',
'-y',
outputPath,
];
}
// ============ 对外 API ============
export interface StartRecordingInput {
routerId: string;
roomCode: string;
/**
* 要录制的 producer ID 列表。
* 通常是 host 的 audio + video或者屏幕共享 video。
* Phase B1 阶段只录这些指定的 producer不混合所有成员后续 Phase B3 再扩展。
*/
producerIds: string[];
}
export interface StartRecordingOutput {
recordingId: string;
outputPath: string;
tracks: Array<{
producerId: string;
kind: 'audio' | 'video';
}>;
}
/**
* 启动录制
*
* 失败处理:
* - 任一步骤失败都会回滚已创建的资源(端口 / Transport / Consumer / ffmpeg 进程)
* - 不在 recordingMap 中留下"半态"条目,调用方可直接重试
*/
export async function startRecording(input: StartRecordingInput): Promise<StartRecordingOutput> {
const router = getRouter(input.routerId);
if (input.producerIds.length === 0) {
throw new AppError('CONFLICT', 'producerIds is empty');
}
await ensureOutputDir();
const id = recordingId();
const startedAt = Date.now();
const localIp = '127.0.0.1';
const outputPath = join(OUTPUT_DIR, `${id}.mp4`);
const sdpFilePath = join(OUTPUT_DIR, `${id}.sdp`);
// 已分配资源,用于失败时回滚
const allocatedPortPairs: Array<{ rtp: number; rtcp: number }> = [];
const createdTransports: PlainTransport[] = [];
const createdConsumers: Consumer[] = [];
let ffmpegProc: ChildProcess | null = null;
const rollback = async () => {
for (const c of createdConsumers) {
try {
c.close();
} catch {
/* ignored */
}
}
for (const t of createdTransports) {
try {
t.close();
} catch {
/* ignored */
}
}
for (const p of allocatedPortPairs) {
releasePortPair(p.rtp, p.rtcp);
}
if (ffmpegProc && ffmpegProc.exitCode === null) {
try {
ffmpegProc.kill('SIGKILL');
} catch {
/* ignored */
}
}
try {
await fs.unlink(sdpFilePath);
} catch {
/* ignored */
}
try {
await fs.unlink(outputPath);
} catch {
/* ignored */
}
};
try {
// ---------- Step 1: 预校验所有 producer 存在且可消费 ----------
// 不在这里建 ConsumerConsumer 必须依附 PlainTransport留到 Step 2 一起做
// 但 canConsume 失败要尽早抛错,避免分配资源后再失败导致回滚开销
const tracks: RecordingTrack[] = [];
for (const producerId of input.producerIds) {
// getProducer 不存在会抛 notFound等价于校验 producerId 合法
getProducer(producerId);
if (!router.canConsume({ producerId, rtpCapabilities: router.rtpCapabilities })) {
throw new AppError(
'CONFLICT',
`router cannot consume producer ${producerId} with its own rtpCapabilities`,
);
}
}
// ---------- Step 2: 为每个 producer 分配端口对 + 创建 PlainTransport ----------
for (const producerId of input.producerIds) {
const producer = getProducer(producerId);
const portPair = await allocatePortPair();
allocatedPortPairs.push(portPair);
const transport = await router.createPlainTransport({
listenIp: { ip: config.mediasoup.listenIp, announcedIp: undefined },
rtcpMux: false,
comedia: false,
enableSrtp: false,
appData: {
purpose: 'recording',
recordingId: id,
producerId,
},
});
createdTransports.push(transport);
// 创建 consumer必须传 router 自己的 rtpCapabilitiesconsumer 会与 producer 协商
const consumer = await transport.consume({
producerId,
rtpCapabilities: router.rtpCapabilities,
paused: true,
});
createdConsumers.push(consumer);
const codec = pickCodec(consumer.rtpParameters);
tracks.push({
producerId,
consumerId: consumer.id,
transportId: transport.id,
ffmpegRtpPort: portPair.rtp,
ffmpegRtcpPort: portPair.rtcp,
kind: producer.kind as 'audio' | 'video',
...codec,
});
}
// ---------- Step 3: 写 SDP 文件 ----------
const sdp = buildSdp(tracks, localIp);
await fs.writeFile(sdpFilePath, sdp, 'utf-8');
log.info({ recordingId: id, sdp }, 'SDP file generated');
// ---------- Step 4: spawn ffmpeg ----------
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const args = buildFfmpegArgs(sdpFilePath, outputPath);
ffmpegProc = spawn(ffmpegBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const exited = new Promise<void>((resolve) => {
ffmpegProc!.once('exit', (code, signal) => {
const entry = recordingMap.get(id);
if (entry) {
entry.exitCode = code;
if (!entry.stopping && code !== 0) {
entry.failureReason = `ffmpeg exited unexpectedly code=${code} signal=${signal ?? ''}`;
log.error(
{ recordingId: id, code, signal },
'ffmpeg exited unexpectedly before stop',
);
// Phase B watchdog异步触发失败回调webhook + 用户回调),不阻塞 exit 处理
fireFailureCallback(entry);
}
}
resolve();
});
});
ffmpegProc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf-8');
// ffmpeg 默认大部分日志走 stderr只在 debug 级别落,避免日志爆炸
log.debug({ recordingId: id }, `[ffmpeg] ${text.trim()}`);
});
ffmpegProc.once('error', (err) => {
log.error({ recordingId: id, err: err.message }, 'ffmpeg spawn error');
});
// 等 ffmpeg 进入监听就绪:实践经验 200ms 足够 ffmpeg 解析 SDP + 绑定端口
// 这里加一个 250ms 的小延迟,避免过早 connect 导致前几个 RTP 包丢失
await new Promise((r) => setTimeout(r, 250));
if (ffmpegProc.exitCode !== null) {
throw new AppError(
'MEDIASOUP_ERROR',
`ffmpeg exited prematurely with code ${ffmpegProc.exitCode}`,
);
}
// ---------- Step 5: PlainTransport.connect 到 ffmpeg ----------
for (let i = 0; i < input.producerIds.length; i++) {
const transport = createdTransports[i];
const t = tracks[i];
await transport.connect({
ip: localIp,
port: t.ffmpegRtpPort,
rtcpPort: t.ffmpegRtcpPort,
});
}
// ---------- Step 6: resume consumersRTP 开始流向 ffmpeg ----------
for (const c of createdConsumers) {
await c.resume();
}
const entry: RecordingEntry = {
id,
roomCode: input.roomCode,
routerId: input.routerId,
startedAt,
tracks,
consumers: createdConsumers,
transports: createdTransports,
ffmpeg: ffmpegProc,
sdpFilePath,
outputPath,
ffmpegExited: exited,
stopping: false,
exitCode: null,
failureReason: null,
unexpectedExit: false,
};
recordingMap.set(id, entry);
log.info(
{
recordingId: id,
roomCode: input.roomCode,
producerCount: input.producerIds.length,
outputPath,
},
'recording started',
);
return {
recordingId: id,
outputPath,
tracks: tracks.map((t) => ({ producerId: t.producerId, kind: t.kind })),
};
} catch (err) {
log.error(
{ recordingId: id, err: err instanceof Error ? err.message : String(err) },
'startRecording failed, rolling back',
);
await rollback();
if (err instanceof AppError) throw err;
throw new AppError(
'MEDIASOUP_ERROR',
`startRecording failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
export interface StopRecordingOutput {
recordingId: string;
outputPath: string;
sizeBytes: number;
durationMs: number;
exitCode: number | null;
failureReason: string | null;
}
/**
* 停止录制
*
* 幂等:重复调用同一 recordingId 仅第一次执行清理,后续返回 notFound
*
* 时间线:
* - t=0: SIGINT ffmpeg
* - t≤5s: ffmpeg 优雅退出,写完 moov box
* - t>5s: 强制 SIGKILL文件可能损坏罕见记录 failureReason
* - 之后 close consumers/transports/释放端口
*/
export async function stopRecording(id: string): Promise<StopRecordingOutput> {
const entry = recordingMap.get(id);
if (!entry) {
throw notFound('recording', id);
}
if (entry.stopping) {
// 并发重复 stop返回当前快照避免重复 SIGINT
log.warn({ recordingId: id }, 'stopRecording called while already stopping');
}
entry.stopping = true;
// 1) SIGINT ffmpeg
let killed = false;
if (entry.ffmpeg.exitCode === null) {
try {
entry.ffmpeg.kill('SIGINT');
killed = true;
} catch (err) {
log.warn(
{ recordingId: id, err: err instanceof Error ? err.message : String(err) },
'failed to send SIGINT to ffmpeg',
);
}
}
// 2) 等 ffmpeg 退出 + 超时兜底
if (killed) {
const timeout = new Promise<void>((resolve) => {
setTimeout(() => {
if (entry.ffmpeg.exitCode === null) {
try {
entry.ffmpeg.kill('SIGKILL');
} catch {
/* ignored */
}
entry.failureReason = `ffmpeg did not exit within ${STOP_GRACE_MS}ms, forced SIGKILL`;
log.error({ recordingId: id }, entry.failureReason);
}
resolve();
}, STOP_GRACE_MS);
});
await Promise.race([entry.ffmpegExited, timeout]);
// race 后再 await exited 拿到最终退出码
await entry.ffmpegExited;
}
// 3) close consumers + transports
for (const c of entry.consumers) {
try {
c.close();
} catch {
/* ignored */
}
}
for (const t of entry.transports) {
try {
t.close();
} catch {
/* ignored */
}
}
// 4) 释放端口
for (const track of entry.tracks) {
releasePortPair(track.ffmpegRtpPort, track.ffmpegRtcpPort);
}
// 5) 删除 SDP 临时文件(保留 mp4
try {
await fs.unlink(entry.sdpFilePath);
} catch {
/* ignored */
}
// 6) stat 输出文件
let sizeBytes = 0;
try {
const st = await fs.stat(entry.outputPath);
sizeBytes = st.size;
} catch (err) {
if (!entry.failureReason) {
entry.failureReason = `output file missing: ${err instanceof Error ? err.message : String(err)}`;
}
}
const durationMs = Date.now() - entry.startedAt;
recordingMap.delete(id);
log.info(
{
recordingId: id,
durationMs,
sizeBytes,
exitCode: entry.exitCode,
failureReason: entry.failureReason,
},
'recording stopped',
);
return {
recordingId: id,
outputPath: entry.outputPath,
sizeBytes,
durationMs,
exitCode: entry.exitCode,
failureReason: entry.failureReason,
};
}
export interface RecordingInfo {
recordingId: string;
roomCode: string;
routerId: string;
startedAt: number;
durationMs: number;
outputPath: string;
trackCount: number;
ffmpegAlive: boolean;
}
export function getRecording(id: string): RecordingInfo {
const entry = recordingMap.get(id);
if (!entry) {
throw notFound('recording', id);
}
return {
recordingId: entry.id,
roomCode: entry.roomCode,
routerId: entry.routerId,
startedAt: entry.startedAt,
durationMs: Date.now() - entry.startedAt,
outputPath: entry.outputPath,
trackCount: entry.tracks.length,
ffmpegAlive: entry.ffmpeg.exitCode === null,
};
}
export function getRecordingStats(): { total: number } {
return { total: recordingMap.size };
}
/**
* 列出指定 roomCode 下的所有活跃录制(一般 ≤1
* 供 Go 后端 EndRoom / 资源清理时反查
*/
export function listRecordingsByRoom(roomCode: string): RecordingInfo[] {
const out: RecordingInfo[] = [];
for (const entry of recordingMap.values()) {
if (entry.roomCode === roomCode) {
out.push({
recordingId: entry.id,
roomCode: entry.roomCode,
routerId: entry.routerId,
startedAt: entry.startedAt,
durationMs: Date.now() - entry.startedAt,
outputPath: entry.outputPath,
trackCount: entry.tracks.length,
ffmpegAlive: entry.ffmpeg.exitCode === null,
});
}
}
return out;
}
/** 测试专用:复位 recordingMap停止所有进行中的录制 */
export async function _clearRecordings(): Promise<void> {
assertTestOnly('_clearRecordings');
const ids = [...recordingMap.keys()];
for (const id of ids) {
try {
await stopRecording(id);
} catch {
/* ignored */
}
}
recordingMap.clear();
}

View File

@@ -0,0 +1,112 @@
/**
* UDP 端口池:用于为录制 ffmpeg 子进程分配可用 RTP/RTCP 端口对
*
* 设计要点:
* - 端口范围与 mediasoup RTC 端口范围(默认 40000-40199必须不相交否则会与
* mediasoup 自身的 PlainTransport 本地绑定端口冲突
* - 通过 dgram.createSocket('udp4') 试探绑定来确认端口未被占用,不依赖 lsof / 系统接口
* - 一次申请通常成对返回 [rtp, rtcp],习惯上 rtp = even, rtcp = rtp+1RFC 3550 §11
* - 失败重试上限 ALLOC_RETRY超过则抛错此时通常意味着系统并发录制数过多
*
* 释放策略:
* - 调用方在录制 stop 时调 release(rtp, rtcp) 主动释放,避免端口长期被占
* - 若调用方忘记 release进程退出时端口自然回收OS 层),不会泄漏到下次启动
*
* 并发:
* - reservedPorts 是单进程内存集合,配合 dgram 试探绑定双重防御并发竞态
*/
import dgram from 'node:dgram';
import { childLogger } from './logger.js';
const log = childLogger({ module: 'utils.port-pool' });
const RECORDING_PORT_MIN = 50000;
const RECORDING_PORT_MAX = 59998;
const ALLOC_RETRY = 64;
const reservedPorts = new Set<number>();
function isInRange(port: number): boolean {
return port >= RECORDING_PORT_MIN && port <= RECORDING_PORT_MAX;
}
/**
* 试探指定端口是否可用:
* - 创建 UDP socket 尝试 bind
* - 成功立即 close 释放,返回 true
* - 失败EADDRINUSE / EACCES 等)返回 false
*/
function probeUdpPort(port: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = dgram.createSocket('udp4');
let resolved = false;
const finalize = (ok: boolean) => {
if (resolved) return;
resolved = true;
try {
socket.close();
} catch {
// already closed
}
resolve(ok);
};
socket.once('error', () => finalize(false));
try {
socket.bind(port, '0.0.0.0', () => finalize(true));
} catch {
finalize(false);
}
});
}
/**
* 分配一对 (rtp, rtcp) 端口rtp 为偶数rtcp = rtp + 1
*
* @returns { rtp, rtcp } 已通过试探确认空闲;调用方使用前不再二次试探
* 注意试探与实际使用之间存在 TOCTOU 窗口,调用方需对 ffmpeg listen 失败做兜底
* @throws 重试 ALLOC_RETRY 次仍失败时抛错
*/
export async function allocatePortPair(): Promise<{ rtp: number; rtcp: number }> {
for (let i = 0; i < ALLOC_RETRY; i++) {
// 在 [MIN, MAX-1] 内随机偶数;保证 +1 后 rtcp 仍 ≤ MAX+1
// RTP 端口必须偶数RTCP 必须 RTP+1mediasoup 默认行为)
const span = (RECORDING_PORT_MAX - RECORDING_PORT_MIN) >> 1;
const offset = Math.floor(Math.random() * span) * 2;
const rtp = RECORDING_PORT_MIN + offset;
const rtcp = rtp + 1;
if (!isInRange(rtp) || !isInRange(rtcp)) continue;
if (reservedPorts.has(rtp) || reservedPorts.has(rtcp)) continue;
// 双端口都试探
const [okRtp, okRtcp] = await Promise.all([probeUdpPort(rtp), probeUdpPort(rtcp)]);
if (!okRtp || !okRtcp) continue;
reservedPorts.add(rtp);
reservedPorts.add(rtcp);
return { rtp, rtcp };
}
throw new Error(`port-pool: failed to allocate UDP port pair after ${ALLOC_RETRY} retries`);
}
/**
* 释放端口对。幂等:未在 reservedPorts 中的端口忽略
*/
export function releasePortPair(rtp: number, rtcp: number): void {
reservedPorts.delete(rtp);
reservedPorts.delete(rtcp);
log.debug({ rtp, rtcp, reservedSize: reservedPorts.size }, 'port pair released');
}
export function getPortPoolStats(): { reserved: number; min: number; max: number } {
return {
reserved: reservedPorts.size,
min: RECORDING_PORT_MIN,
max: RECORDING_PORT_MAX,
};
}
/** 测试专用:复位端口集合 */
export function _clearPortPool(): void {
reservedPorts.clear();
}