UNPKG

@bililive-tools/manager

Version:
267 lines (266 loc) 12.3 kB
import path from "node:path"; import mitt from "mitt"; import { omit, range } from "lodash-es"; import { parseArgsStringToArgv } from "string-argv"; import { getBiliStatusInfoByRoomIds } from "./api.js"; import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, } from "./utils.js"; import { StreamManager } from "./streamManager.js"; const configurableProps = [ "savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "ffmpegOutputArgs", "biliBatchQuery", ]; function isConfigurableProp(prop) { return configurableProps.includes(prop); } export function createRecorderManager(opts) { const recorders = []; let checkLoopTimer; const multiThreadCheck = async (manager) => { const handleBatchQuery = async (obj) => { for (const recorder of recorders .filter((r) => !r.disableAutoCheck) .filter((r) => r.providerId === "Bilibili")) { const isLive = obj[recorder.channelId]; // 如果是undefined,说明这个接口查不到相关信息,使用录制器内的再查一次 if (isLive === true || isLive === undefined) { await recorder.checkLiveStatusAndRecord({ getSavePath(data) { return genSavePathFromRule(manager, recorder, data); }, banLiveId: tempBanObj[recorder.channelId], }); } } }; const maxThreadCount = 3; // 这里暂时不打算用 state == recording 来过滤,provider 必须内部自己处理录制过程中的 check, // 这样可以防止一些意外调用 checkLiveStatusAndRecord 时出现重复录制。 let needCheckRecorders = recorders.filter((r) => !r.disableAutoCheck); let threads = []; if (manager.biliBatchQuery) { const biliNeedCheckRecorders = needCheckRecorders .filter((r) => r.providerId === "Bilibili") .filter((r) => r.recordHandle == null); needCheckRecorders = needCheckRecorders.filter((r) => r.providerId !== "Bilibili"); const roomIds = biliNeedCheckRecorders.map((r) => r.channelId).map(Number); try { if (roomIds.length !== 0) { const biliStatus = await getBiliStatusInfoByRoomIds(roomIds); threads.push(handleBatchQuery(biliStatus)); } } catch (err) { manager.emit("error", { source: "getBiliStatusInfoByRoomIds", err }); } } const checkOnce = async () => { const recorder = needCheckRecorders.shift(); if (recorder == null) return; const banLiveId = tempBanObj[recorder.channelId]; await recorder.checkLiveStatusAndRecord({ getSavePath(data) { return genSavePathFromRule(manager, recorder, data); }, banLiveId, }); }; threads = threads.concat(range(0, maxThreadCount).map(async () => { while (needCheckRecorders.length > 0) { try { await checkOnce(); } catch (err) { manager.emit("error", { source: "checkOnceInThread", err }); } } })); await Promise.all(threads); }; // 用于记录暂时被 ban 掉的直播间 const tempBanObj = {}; // 用于是否触发LiveStart事件,不要重复触发 const liveStartObj = {}; const manager = { // @ts-ignore ...mitt(), providers: opts.providers, getChannelURLMatchedRecorderProviders(channelURL) { return this.providers.filter((p) => p.matchURL(channelURL)); }, recorders, addRecorder(opts) { const provider = this.providers.find((p) => p.id === opts.providerId); if (provider == null) throw new Error("Cant find provider " + opts.providerId); // TODO: 因为泛型函数内部是不持有具体泛型的,这里被迫用了 as,没什么好的思路处理,除非 // provider.createRecorder 能返回 Recorder<PE> 才能进一步优化。 const recorder = provider.createRecorder(omit(opts, ["providerId"])); this.recorders.push(recorder); recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder, recordHandle })); recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder, recordHandle })); recorder.on("videoFileCreated", ({ filename }) => { if (recorder.saveCover && recorder?.liveInfo?.cover) { const coverPath = replaceExtName(filename, ".jpg"); downloadImage(recorder?.liveInfo?.cover, coverPath); } this.emit("videoFileCreated", { recorder, filename }); }); recorder.on("videoFileCompleted", ({ filename }) => this.emit("videoFileCompleted", { recorder, filename })); recorder.on("RecordStop", ({ recordHandle, reason }) => this.emit("RecordStop", { recorder, recordHandle, reason })); recorder.on("Message", (message) => this.emit("Message", { recorder, message })); recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder, keys })); recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder, ...log })); recorder.on("progress", (progress) => { this.emit("RecorderProgress", { recorder, progress }); }); recorder.on("LiveStart", ({ liveId }) => { const key = `${recorder.channelId}-${liveId}`; if (liveStartObj[key]) return; liveStartObj[key] = true; this.emit("RecoderLiveStart", { recorder }); }); this.emit("RecorderAdded", recorder); return recorder; }, removeRecorder(recorder) { const idx = this.recorders.findIndex((item) => item === recorder); if (idx === -1) return; recorder.recordHandle?.stop("remove recorder"); this.recorders.splice(idx, 1); delete tempBanObj[recorder.channelId]; this.emit("RecorderRemoved", recorder); }, async startRecord(id) { const recorder = this.recorders.find((item) => item.id === id); if (recorder == null) return; if (recorder.recordHandle != null) return; await recorder.checkLiveStatusAndRecord({ getSavePath(data) { return genSavePathFromRule(manager, recorder, data); }, isManualStart: true, }); delete tempBanObj[recorder.channelId]; recorder.tempStopIntervalCheck = false; return recorder; }, async stopRecord(id) { const recorder = this.recorders.find((item) => item.id === id); if (recorder == null) return; if (recorder.recordHandle == null) return; const liveId = recorder.liveInfo?.liveId; await recorder.recordHandle.stop("manual stop", true); if (liveId) { tempBanObj[recorder.channelId] = liveId; recorder.tempStopIntervalCheck = true; } return recorder; }, autoCheckInterval: opts.autoCheckInterval ?? 1000, isCheckLoopRunning: false, startCheckLoop() { if (this.isCheckLoopRunning) return; this.isCheckLoopRunning = true; // TODO: emit updated event const checkLoop = async () => { try { await multiThreadCheck(this); } catch (err) { this.emit("error", { source: "multiThreadCheck", err }); } finally { if (!this.isCheckLoopRunning) { // do nothing } else { checkLoopTimer = setTimeout(checkLoop, this.autoCheckInterval); } } }; void checkLoop(); }, stopCheckLoop() { if (!this.isCheckLoopRunning) return; this.isCheckLoopRunning = false; // TODO: emit updated event clearTimeout(checkLoopTimer); }, savePathRule: opts.savePathRule ?? path.join(process.cwd(), "{platform}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}"), autoRemoveSystemReservedChars: opts.autoRemoveSystemReservedChars ?? true, biliBatchQuery: opts.biliBatchQuery ?? false, ffmpegOutputArgs: opts.ffmpegOutputArgs ?? "-c copy" + /** * FragmentMP4 可以边录边播(浏览器原生支持),具有一定的抗损坏能力,录制中 KILL 只会丢失 * 最后一个片段,而 FLV 格式如果录制中 KILL 了需要手动修复下 keyframes。所以默认使用 fmp4 格式。 */ " -movflags faststart+frag_keyframe+empty_moov" + /** * 浏览器加载 FragmentMP4 会需要先把它所有的 moof boxes 都加载完成后才能播放, * 默认的分段时长很小,会产生大量的 moof,导致加载很慢,所以这里设置一个分段的最小时长。 * * TODO: 这个浏览器行为或许是可以优化的,比如试试给 fmp4 在录制完成后设置或者录制过程中实时更新 mvhd.duration。 * https://stackoverflow.com/questions/55887980/how-to-use-media-source-extension-mse-low-latency-mode * https://stackoverflow.com/questions/61803136/ffmpeg-fragmented-mp4-takes-long-time-to-start-playing-on-chrome * * TODO: 如果浏览器行为无法优化,并且想进一步优化加载速度,可以考虑录制时使用 fmp4,录制完成后再转一次普通 mp4。 */ " -min_frag_duration 60000000", }; const setProvidersFFMPEGOutputArgs = (ffmpegOutputArgs) => { const args = parseArgsStringToArgv(ffmpegOutputArgs); manager.providers.forEach((p) => p.setFFMPEGOutputArgs(args)); }; // setProvidersFFMPEGOutputArgs(manager.ffmpegOutputArgs); const proxyManager = new Proxy(manager, { set(obj, prop, value) { Reflect.set(obj, prop, value); if (prop === "ffmpegOutputArgs") { setProvidersFFMPEGOutputArgs(value); } if (isConfigurableProp(prop)) { obj.emit("Updated", [prop]); } return true; }, }); return proxyManager; } export function genSavePathFromRule(manager, recorder, extData) { // TODO: 这里随便写的,后面再优化 const provider = manager.providers.find((p) => p.id === recorder.toJSON().providerId); const now = extData?.startTime ? new Date(extData.startTime) : new Date(); const params = { platform: provider?.name ?? "unknown", channelId: recorder.channelId, remarks: recorder.remarks ?? "", year: formatDate(now, "yyyy"), month: formatDate(now, "MM"), date: formatDate(now, "dd"), hour: formatDate(now, "HH"), min: formatDate(now, "mm"), sec: formatDate(now, "ss"), ...extData, }; if (manager.autoRemoveSystemReservedChars) { for (const key in params) { params[key] = removeSystemReservedChars(String(params[key])); } } return formatTemplate(manager.savePathRule, params); } export { StreamManager };