UNPKG

@bililive-tools/manager

Version:
232 lines (231 loc) 7.39 kB
import EventEmitter from "node:events"; import { spawn } from "node:child_process"; import { DEFAULT_USER_AGENT } from "./index.js"; import { StreamManager, getBililivePath, utils } from "../index.js"; import { byte2MB } from "../utils.js"; // Bililive command builder class similar to ffmpeg class BililiveRecorderCommand extends EventEmitter { _input = ""; _output = ""; _inputOptions = []; process = null; constructor() { super(); } input(source) { this._input = source; return this; } output(target) { this._output = target; return this; } inputOptions(...options) { const opts = Array.isArray(options[0]) ? options[0] : options; this._inputOptions.push(...opts); return this; } _getArguments() { const args = ["downloader", "-p"]; // Add input source if (this._input) { args.push(this._input); } // Add input options first args.push(...this._inputOptions); // Add output target if (this._output) { // const { dir, name } = path.parse(this._output); // args.push("-o", dir); args.push(this._output); } // args.push("-v"); return args; } run() { const args = this._getArguments(); const bililiveExecutable = getBililivePath(); this.process = spawn(bililiveExecutable, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); if (this.process.stdout) { this.process.stdout.on("data", (data) => { const output = data.toString(); // console.log(output); this.emit("stderr", output); }); } if (this.process.stderr) { this.process.stderr.on("data", (data) => { const output = data.toString(); // console.error(output); this.emit("stderr", output); }); } this.process.on("error", (error) => { this.emit("error", error); }); this.process.on("close", (code) => { if (code === 0) { this.emit("end"); } else { this.emit("error", new Error(`bililive process exited with code ${code}`)); } }); } stop() { if (this.process) { this.process.stdin?.write("q\n"); } } kill() { if (this.process) { this.process.kill("SIGINT"); } } cut() { if (this.process) { this.process.stdin?.write("s\n"); } } } // Factory function similar to createFFMPEGBuilder export const createBililiveBuilder = () => { return new BililiveRecorderCommand(); }; export class BililiveDownloader extends EventEmitter { onEnd; onUpdateLiveInfo; type = "bililive"; command; streamManager; timeoutChecker; hasSegment; getSavePath; segment; inputOptions = []; disableDanma = false; url; debugLevel = "none"; headers; constructor(opts, onEnd, onUpdateLiveInfo) { super(); this.onEnd = onEnd; this.onUpdateLiveInfo = onUpdateLiveInfo; // 存在自动分段,永远为true const hasSegment = true; this.hasSegment = hasSegment; this.disableDanma = opts.disableDanma ?? false; this.debugLevel = opts.debugLevel ?? "none"; let videoFormat = "flv"; this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "bililive", videoFormat, { onUpdateLiveInfo: this.onUpdateLiveInfo, }); this.timeoutChecker = utils.createTimeoutChecker(() => { this.emit("DebugLog", { type: "error", text: "bililive timeout, killing process" }); this.command?.kill(); this.onEnd("bililive timeout"); }, 20 * 1000, false); this.getSavePath = opts.getSavePath; this.inputOptions = []; this.url = opts.url; this.segment = opts.segment; this.headers = { "User-Agent": DEFAULT_USER_AGENT, ...(opts.headers || {}), }; this.command = this.createCommand(); this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => { this.emit("videoFileCreated", { filename, cover, rawFilename, title }); }); this.streamManager.on("videoFileCompleted", (data) => { this.emit("videoFileCompleted", data); }); this.streamManager.on("DebugLog", (data) => { this.emit("DebugLog", data); }); } createCommand() { this.timeoutChecker?.start(); const inputOptions = [...this.inputOptions, "--disable-log-file", "true"]; if (this.debugLevel === "verbose") { inputOptions.push("-l", "Debug"); } if (this.headers) { Object.entries(this.headers).forEach(([key, value]) => { if (!value) return; inputOptions.push("-h", `${key}: ${value}`); }); } if (this.segment) { if (typeof this.segment === "number") { inputOptions.push("-d", `${this.segment}`); } else if (typeof this.segment === "string") { inputOptions.push("-m", byte2MB(Number(this.segment)).toFixed(2)); } } const command = createBililiveBuilder() .input(this.url) .inputOptions(inputOptions) .output(this.streamManager.videoFilePath) .on("error", this.onEnd) .on("end", () => this.onEnd("finished")) .on("stderr", async (stderrLine) => { this.timeoutChecker?.update(); this.emit("DebugLog", { type: "ffmpeg", text: stderrLine }); await this.streamManager.handleVideoStarted(stderrLine); const info = this.formatLine(stderrLine); if (info) { this.emit("progress", info); } }); return command; } formatLine(line) { if (!line.includes("下载进度:")) { return null; } let time = null; const timeMatch = line.match(/录制时长:\s*([0-9:]+)\s/); if (timeMatch) { time = timeMatch[1]; } const spaceMath = line.match(/下载进度:\s*([\d.]+\s*MB)\s*/); if (spaceMath) { const space = spaceMath[1]; time = time ? `${time} ${space}` : space; } return { time, }; } run() { this.command.run(); } getArguments() { return this.command._getArguments(); } async stop() { this.timeoutChecker?.stop(); try { this.command.stop(); await this.streamManager.handleVideoCompleted(); } catch (err) { this.emit("DebugLog", { type: "error", text: String(err) }); } } getExtraDataController() { return this.streamManager?.getExtraDataController(); } get videoFilePath() { return this.streamManager.videoFilePath; } cut() { this.command.cut(); } }