Files
EchoChat/media-server/src/services/recording.service.ts
2026-05-19 14:15:53 +08:00

858 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 录制服务:基于 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;
// go-service 端校验 X-Internal-Secret 与其 INTERNAL_WEBHOOK_SECRET 是否一致,
// 缺失/不匹配会直接 403。生产部署务必两侧使用相同值
const secret = process.env.INTERNAL_WEBHOOK_SECRET ?? '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (secret) headers['X-Internal-Secret'] = secret;
try {
const res = await fetch(url, {
method: 'POST',
headers,
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',
);
}
}
/**
* 实际异步派发失败回调:同时跑 default webhook + 用户注册的 callback
* 抽出此函数是为了让 spawn 阶段entry 还没进 recordingMap也能复用
*/
function dispatchFailureInfo(info: Parameters<RecordingFailureCallback>[0]): void {
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',
);
}
}
});
}
/**
* 内部触发失败回调:异步、错误吞掉
* 入口语义"已 active 的 ffmpeg 进程异常退出",依赖已落到 recordingMap 的 entry
*/
function fireFailureCallback(entry: RecordingEntry): void {
if (entry.unexpectedExit) return; // 幂等:每条录制最多触发一次
entry.unexpectedExit = true;
dispatchFailureInfo({
recordingId: entry.id,
roomCode: entry.roomCode,
routerId: entry.routerId,
exitCode: entry.exitCode,
failureReason: entry.failureReason ?? 'ffmpeg exited unexpectedly',
});
}
/**
* spawn 阶段失败兜底ffmpeg 二进制找不到 / 无权限 / 启动后立刻退出
* 此时 entry 可能还没入 recordingMapstartRecording 在末尾才插入),
* 依旧需要把失败上报给 go-service否则 startRecording 同步 throw 之后
* 万一上层 DB 行已经存在(异常时序、重试场景)就会卡在 recording 状态。
*
* 与 fireFailureCallback 互斥:只要其中一个跑过就标记 entry.unexpectedExit=true如果有 entry
*/
function fireSpawnFailure(args: {
recordingId: string;
roomCode: string;
routerId: string;
exitCode: number | null;
failureReason: string;
}): void {
const entry = recordingMap.get(args.recordingId);
if (entry) {
if (entry.unexpectedExit) return;
entry.unexpectedExit = true;
if (!entry.failureReason) entry.failureReason = args.failureReason;
if (entry.exitCode === null) entry.exitCode = args.exitCode;
}
dispatchFailureInfo(args);
}
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'] });
// ffmpeg stderr 环形缓冲:正常运行时 debug 级 + 不落盘,
// 进程异常退出时把最后 N KB stderr 一次性以 error 级 dump 出来,方便定位 code=1 类问题
// 上限 16 KB 足以覆盖典型 ffmpeg 启动失败诊断codec/端口/SDP 解析等),且容量可控
const STDERR_TAIL_LIMIT = 16 * 1024;
let stderrTail = '';
const appendStderrTail = (text: string) => {
stderrTail += text;
if (stderrTail.length > STDERR_TAIL_LIMIT) {
stderrTail = stderrTail.slice(stderrTail.length - STDERR_TAIL_LIMIT);
}
};
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 ?? ''}`;
// 异常退出:把 stderr tail 落 error 级,便于线上排查 ffmpeg 失败根因
log.error(
{ recordingId: id, code, signal, stderrTail: stderrTail.trim() },
'ffmpeg exited unexpectedly before stop',
);
// Phase B watchdog异步触发失败回调webhook + 用户回调),不阻塞 exit 处理
fireFailureCallback(entry);
}
} else if (code !== 0) {
// entry 还没入 mapstartRecording 末尾才插入)→ 走 spawn 失败兜底,避免 webhook 漏报
// startRecording 同步路径会自己 throw + rollback这里上报只为防止上层 DB 行已存在的边缘场景
log.error(
{ recordingId: id, code, signal, stderrTail: stderrTail.trim() },
'ffmpeg exited before recording entry registered',
);
fireSpawnFailure({
recordingId: id,
roomCode: input.roomCode,
routerId: input.routerId,
exitCode: code,
failureReason: `ffmpeg exited during spawn code=${code} signal=${signal ?? ''}; stderr_tail=${stderrTail.trim().slice(-512)}`,
});
}
resolve();
});
});
ffmpegProc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf-8');
// 1) 永远写入环形缓冲,供进程异常退出时 dump
appendStderrTail(text);
// 2) 正常实时日志仍走 debug 级,避免高频日志爆炸
log.debug({ recordingId: id }, `[ffmpeg] ${text.trim()}`);
});
ffmpegProc.once('error', (err) => {
// spawn 阶段错误ffmpeg 二进制找不到 / 无权限 / OS 资源耗尽等
// 此前仅记日志go-service 完全感知不到。这里立刻触发 failure webhook 兜底,
// 让上层(即使 DB 已写入 recording 行)能把状态推进到 failed
log.error({ recordingId: id, err: err.message }, 'ffmpeg spawn error');
fireSpawnFailure({
recordingId: id,
roomCode: input.roomCode,
routerId: input.routerId,
exitCode: null,
failureReason: `ffmpeg spawn error: ${err.message}`,
});
});
// 等 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();
}