@bililive-tools/manager
Version:
Batch scheduling recorders
252 lines (251 loc) • 9.8 kB
JavaScript
import EventEmitter from "node:events";
import fs from "fs/promises";
import fsSync from "fs";
import { createRecordExtraDataController } from "../xml_stream_controller.js";
import { ensureFolderExist, isFfmpegStartSegment, isMesioStartSegment, isBililiveStartSegment, isFfmpegStart, retry, cleanTerminalText, } from "../utils.js";
export class Segment extends EventEmitter {
extraDataController = null;
init = true;
getSavePath;
/** 原始的文件名,用于重命名 */
rawRecordingVideoPath;
/** 输出文件名名,不包含拓展名 */
outputVideoFilePath;
disableDanma;
videoExt;
options;
constructor(getSavePath, disableDanma, videoExt, options) {
super();
this.getSavePath = getSavePath;
this.disableDanma = disableDanma;
this.videoExt = videoExt;
this.options = options;
}
getVideoFileCompletedPayload() {
return {
filename: this.outputFilePath,
stats: this.extraDataController?.getStats(),
};
}
async handleSegmentEnd() {
if (!this.outputVideoFilePath) {
this.emit("DebugLog", {
type: "error",
text: "Should call onSegmentStart first",
});
return;
}
const data = this.getVideoFileCompletedPayload();
try {
this.emit("DebugLog", {
type: "info",
text: `Renaming segment file: ${this.rawRecordingVideoPath} -> ${this.outputFilePath}`,
});
await Promise.all([
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath), 20, 1000),
this.extraDataController?.flush(),
]);
this.emit("videoFileCompleted", data);
}
catch (err) {
this.emit("DebugLog", {
type: "error",
text: "videoFileCompleted error " + String(err),
});
// 虽然重命名失败了,但是也当作完成处理,避免卡住录制流程
this.emit("videoFileCompleted", data);
}
}
async onSegmentStart(stderrLine, callBack) {
if (!this.init) {
await this.handleSegmentEnd();
}
// 首次创建使用上次的时间戳,后续创建使用当前时间戳
const startTime = this.init ? (this.options?.firstStartTime ?? Date.now()) : Date.now();
this.init = false;
let liveInfo = { title: "", cover: "" };
if (callBack?.onUpdateLiveInfo) {
try {
// TODO:这里存在bug,当调用onUpdateLiveInfo并在等待时,handleSegmentEnd被调用,那么会造成竞态导致数据错误,后续需要优化,需要保存segment状态
liveInfo = await callBack.onUpdateLiveInfo();
}
catch (err) {
this.emit("DebugLog", {
type: "error",
text: "onUpdateLiveInfo error " + String(err),
});
}
}
let recordSavePath = this.getSavePath({
startTime: startTime,
title: liveInfo?.title ? liveInfo.title : undefined,
});
// 文件重复判断
if (fsSync.existsSync(recordSavePath + "." + this.videoExt)) {
recordSavePath = this.getSavePath({
startTime: startTime,
title: liveInfo?.title,
extraMs: true,
});
}
this.outputVideoFilePath = recordSavePath;
ensureFolderExist(this.outputVideoFilePath);
if (!this.disableDanma) {
this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
}
// 支持两种格式的正则表达式
// 1. FFmpeg格式: Opening 'filename' for writing
// 2. Mesio格式: Opening FLV segment path=filename Processing
const ffmpegRegex = /'([^']+)'/;
const mesioRegex = /segment path=(.+?\.(?:flv|ts|m4s))/is;
let match = stderrLine.match(ffmpegRegex);
if (!match) {
match = cleanTerminalText(stderrLine).match(mesioRegex);
}
this.emit("DebugLog", { type: "ffmpeg", text: `Segment start line: ${stderrLine}` });
if (match) {
const filename = match[1];
this.rawRecordingVideoPath = filename;
this.emit("videoFileCreated", {
rawFilename: filename,
filename: this.outputFilePath,
title: liveInfo?.title,
cover: liveInfo?.cover,
});
this.emit("DebugLog", { type: "ffmpeg", text: JSON.stringify(match, null, 2) });
}
else {
this.emit("DebugLog", { type: "ffmpeg", text: "No match found" });
}
}
get outputFilePath() {
return `${this.outputVideoFilePath}.${this.videoExt}`;
}
}
export class StreamManager extends EventEmitter {
segment = null;
extraDataController = null;
recordSavePath;
recordStartTime;
hasSegment;
recorderType;
videoFormat;
callBack;
constructor(getSavePath, hasSegment, disableDanma, recorderType, videoFormat, callBack) {
super();
const startTime = Date.now();
let recordSavePath = getSavePath({ startTime });
this.videoFormat = videoFormat;
this.recorderType = recorderType;
this.hasSegment = hasSegment;
this.callBack = callBack;
console.log("Initial recordSavePath:", recordSavePath);
// 文件重复判断
if (fsSync.existsSync(recordSavePath + "." + videoFormat)) {
console.log("File already exists, generating new save path with extraMs");
recordSavePath = getSavePath({ startTime, extraMs: true });
}
this.recordSavePath = recordSavePath;
if (hasSegment) {
this.segment = new Segment(getSavePath, disableDanma, this.videoExt, {
firstStartTime: startTime,
});
this.segment.on("DebugLog", (data) => {
this.emit("DebugLog", data);
});
this.segment.on("videoFileCreated", (data) => {
this.emit("videoFileCreated", data);
});
this.segment.on("videoFileCompleted", (data) => {
this.emit("videoFileCompleted", data);
});
}
else {
ensureFolderExist(recordSavePath);
const extraDataSavePath = `${recordSavePath}.xml`;
if (!disableDanma) {
this.extraDataController = createRecordExtraDataController(extraDataSavePath);
}
}
}
async handleVideoStarted(stderrLine) {
if (this.recorderType === "ffmpeg") {
if (this.segment) {
if (isFfmpegStartSegment(stderrLine)) {
await this.segment.onSegmentStart(stderrLine, this.callBack);
}
}
else {
// 不能直接在onStart回调进行判断,在某些情况下会链接无法录制的情况
if (isFfmpegStart(stderrLine)) {
if (this.recordStartTime)
return;
this.recordStartTime = Date.now();
this.emit("videoFileCreated", { filename: this.videoFilePath });
}
}
}
else if (this.recorderType === "mesio") {
if (this.segment && isMesioStartSegment(stderrLine)) {
for (let line of stderrLine.split("\n")) {
if (isMesioStartSegment(line)) {
await this.segment.onSegmentStart(line, this.callBack);
}
}
}
}
else if (this.recorderType === "bililive") {
if (this.segment && isBililiveStartSegment(stderrLine)) {
await this.segment.onSegmentStart(stderrLine, this.callBack);
}
}
}
async handleVideoCompleted() {
if (this.recorderType === "ffmpeg") {
if (this.segment) {
await this.segment.handleSegmentEnd();
}
else {
if (this.recordStartTime) {
const stats = this.extraDataController?.getStats();
const extraDataController = this.getExtraDataController();
await extraDataController?.flush();
this.emit("videoFileCompleted", {
filename: this.videoFilePath,
stats: stats,
});
}
}
}
else if (this.recorderType === "mesio") {
if (this.segment) {
await this.segment.handleSegmentEnd();
}
}
else if (this.recorderType === "bililive") {
if (this.segment) {
await this.segment.handleSegmentEnd();
}
}
}
getExtraDataController() {
return this.segment?.extraDataController || this.extraDataController;
}
get videoExt() {
return this.videoFormat;
}
get videoFilePath() {
if (this.recorderType === "ffmpeg") {
return this.segment
? `${this.recordSavePath}-PART%03d.${this.videoExt}`
: `${this.recordSavePath}.${this.videoExt}`;
}
else if (this.recorderType === "mesio") {
return `${this.recordSavePath}-PART%i.${this.videoExt}`;
}
else if (this.recorderType === "bililive") {
return `${this.recordSavePath}.${this.videoExt}`;
}
return `${this.recordSavePath}.${this.videoExt}`;
}
}