视频保存
This commit is contained in:
@@ -145,10 +145,15 @@ export function setRecordingFailureCallback(cb: RecordingFailureCallback | null)
|
||||
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: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify(info),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -166,19 +171,10 @@ async function defaultWebhookCallback(info: Parameters<RecordingFailureCallback>
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部触发失败回调:异步、错误吞掉
|
||||
* 同时尝试用户注册的回调和默认 webhook(两者独立)
|
||||
* 实际异步派发失败回调:同时跑 default webhook + 用户注册的 callback
|
||||
* 抽出此函数是为了让 spawn 阶段(entry 还没进 recordingMap)也能复用
|
||||
*/
|
||||
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',
|
||||
};
|
||||
function dispatchFailureInfo(info: Parameters<RecordingFailureCallback>[0]): void {
|
||||
setImmediate(() => {
|
||||
void defaultWebhookCallback(info);
|
||||
if (failureCallback) {
|
||||
@@ -202,6 +198,47 @@ function fireFailureCallback(entry: RecordingEntry): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部触发失败回调:异步、错误吞掉
|
||||
* 入口语义"已 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 可能还没入 recordingMap(startRecording 在末尾才插入),
|
||||
* 依旧需要把失败上报给 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 挂载
|
||||
@@ -492,6 +529,20 @@ export async function startRecording(input: StartRecordingInput): Promise<StartR
|
||||
// Phase B watchdog:异步触发失败回调(webhook + 用户回调),不阻塞 exit 处理
|
||||
fireFailureCallback(entry);
|
||||
}
|
||||
} else if (code !== 0) {
|
||||
// entry 还没入 map(startRecording 末尾才插入)→ 走 spawn 失败兜底,避免 webhook 漏报
|
||||
// startRecording 同步路径会自己 throw + rollback,这里上报只为防止上层 DB 行已存在的边缘场景
|
||||
log.error(
|
||||
{ recordingId: id, code, signal },
|
||||
'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 ?? ''}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
@@ -504,7 +555,17 @@ export async function startRecording(input: StartRecordingInput): Promise<StartR
|
||||
});
|
||||
|
||||
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 + 绑定端口
|
||||
|
||||
Reference in New Issue
Block a user