Files
EchoChat/media-server/src/services/recording.service.ts
2026-05-18 19:42:55 +08:00

782 lines
24 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;
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();
}