视频会议保存
This commit is contained in:
781
media-server/src/services/recording.service.ts
Normal file
781
media-server/src/services/recording.service.ts
Normal 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) 创建对应 PlainTransport(rtcpMux=false, comedia=false)
|
||||
* 并 connect 到 ffmpeg 监听端口
|
||||
* 6) resume Consumer,RTP 开始流向 ffmpeg
|
||||
*
|
||||
* 停止顺序:
|
||||
* 1) SIGINT ffmpeg(让 ffmpeg 关闭 mp4 moov box;SIGKILL 会导致文件损坏)
|
||||
* 2) 等 ffmpeg 进程退出(超时 5s 兜底 SIGKILL)
|
||||
* 3) close Consumer / PlainTransport
|
||||
* 4) 释放端口
|
||||
* 5) stat 输出文件,返回大小 + 时长
|
||||
*
|
||||
* 关键约束:
|
||||
* - PlainTransport 用 rtcpMux=false:避免 RTCP 与 RTP 同端口,ffmpeg 解析更可靠
|
||||
* - comedia=false:由 server 主动 connect,IP/Port 是已知的本地回环
|
||||
* - 不开启 SRTP(loopback 不需要加密,降低 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 退出 promise,stop 时 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 提取录制需要的关键字段
|
||||
* - 取第一个 codec:mediasoup 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 mime(audio/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 存在且可消费 ----------
|
||||
// 不在这里建 Consumer:Consumer 必须依附 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 自己的 rtpCapabilities,consumer 会与 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 consumers,RTP 开始流向 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();
|
||||
}
|
||||
Reference in New Issue
Block a user