UNPKG

@bililive-tools/manager

Version:
523 lines (522 loc) 17.9 kB
import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { Readable } from "node:stream"; import { finished } from "node:stream/promises"; import { throttle, range } from "lodash-es"; import filenamify from "filenamify"; export function asyncThrottle(fn, time, opts = {}) { let savingPromise = null; let hasDeferred = false; const wrappedWithAllowDefer = () => { if (savingPromise != null) { hasDeferred = true; return; } savingPromise = fn().finally(() => { savingPromise = null; if (hasDeferred) { hasDeferred = false; if (opts.immediateRunWhenEndOfDefer) { wrappedWithAllowDefer(); } else { throttled(); } } }); }; const throttled = throttle(wrappedWithAllowDefer, time); return throttled; } export function replaceExtName(filePath, newExtName) { return path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath)) + newExtName); } /** * 接收 fn ,返回一个和 fn 签名一致的函数 fn'。当已经有一个 fn' 在运行时,再调用 * fn' 会直接返回运行中 fn' 的 Promise,直到 Promise 结束 pending 状态 */ export function singleton(fn) { let latestPromise = null; return function (...args) { if (latestPromise) return latestPromise; // @ts-ignore const promise = fn.apply(this, args).finally(() => { if (promise === latestPromise) { latestPromise = null; } }); latestPromise = promise; return promise; }; } /** * 从数组中按照特定算法提取一些值(允许同个索引重复提取)。 * 算法的行为类似 flex 的 space-between。 * * examples: * ``` * console.log(getValuesFromArrayLikeFlexSpaceBetween([1, 2, 3, 4, 5, 6, 7], 1)) * // [1] * console.log(getValuesFromArrayLikeFlexSpaceBetween([1, 2, 3, 4, 5, 6, 7], 3)) * // [1, 4, 7] * console.log(getValuesFromArrayLikeFlexSpaceBetween([1, 2, 3, 4, 5, 6, 7], 4)) * // [1, 3, 5, 7] * console.log(getValuesFromArrayLikeFlexSpaceBetween([1, 2, 3, 4, 5, 6, 7], 11)) * // [1, 1, 2, 3, 3, 4, 5, 5, 6, 7, 7] * ``` */ export function getValuesFromArrayLikeFlexSpaceBetween(array, columnCount) { if (columnCount < 1) return []; if (columnCount === 1) return [array[0]]; const spacingCount = columnCount - 1; const spacingLength = array.length / spacingCount; const columns = range(1, columnCount + 1); const columnValues = columns.map((column, idx, columns) => { // 首个和最后的列是特殊的,因为它们不在范围内,而是在两端 if (idx === 0) { return array[0]; } else if (idx === columns.length - 1) { return array[array.length - 1]; } const beforeSpacingCount = column - 1; const colPos = beforeSpacingCount * spacingLength; return array[Math.floor(colPos)]; }); return columnValues; } export function ensureFolderExist(fileOrFolderPath) { const folder = path.dirname(fileOrFolderPath); if (!fs.existsSync(folder)) { fs.mkdirSync(folder, { recursive: true }); } } export function assert(assertion, msg) { if (!assertion) { throw new Error(msg); } } export function assertStringType(data, msg) { assert(typeof data === "string", msg); } export function assertNumberType(data, msg) { assert(typeof data === "number", msg); } export function assertObjectType(data, msg) { assert(typeof data === "object", msg); } export function formatDate(date, format) { const map = { yyyy: date.getFullYear().toString(), MM: (date.getMonth() + 1).toString().padStart(2, "0"), dd: date.getDate().toString().padStart(2, "0"), HH: date.getHours().toString().padStart(2, "0"), mm: date.getMinutes().toString().padStart(2, "0"), ss: date.getSeconds().toString().padStart(2, "0"), SSS: date.getMilliseconds().toString().padStart(3, "0"), }; return format.replace(/yyyy|MM|dd|HH|mm|ss|SSS/g, (matched) => map[matched]); } export function removeSystemReservedChars(str) { return filenamify(str, { replacement: "_" }); } export function isFfmpegStartSegment(line) { return line.includes("Opening ") && line.includes("for writing"); } export function isMesioStartSegment(line) { return line.includes("Opening segment"); } export function isBililiveStartSegment(line) { return line.includes("创建录制文件"); } export function isFfmpegStart(line) { return ((line.includes("frame=") && line.includes("fps=")) || (line.includes("speed=") && line.includes("time="))); } export function cleanTerminalText(text) { return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "").replace(/[\x00-\x1F\x7F]/g, ""); } export const formatTemplate = function template(string, ...args) { const nargs = /\{([0-9a-zA-Z_]+)\}/g; let params; if (args.length === 1 && typeof args[0] === "object") { params = args[0]; } else { params = args; } if (!params || !params.hasOwnProperty) { params = {}; } return string.replace(nargs, function replaceArg(match, i, index) { let result; if (string[index - 1] === "{" && string[index + match.length] === "}") { return i; } else { result = Object.hasOwn(params, i) ? params[i] : null; if (result === null || result === undefined) { return ""; } return result; } }); }; /** * 检查ffmpeg无效流 * @param count 连续多少次帧数不变就判定为无效流 * @returns * "receive repart stream": b站最后的无限流 * "receive invalid aac stream": ADTS无法被解析的flv流 * "invalid stream": 一段时间内帧数不变 */ export function createFFmpegInvalidStreamChecker(count = 15) { let prevFrame = 0; let frameUnchangedCount = 0; return (ffmpegLogLine) => { // B站某些cdn在直播结束后仍会返回一些数据 https://github.com/renmu123/biliLive-tools/issues/123 if (ffmpegLogLine.includes("New subtitle stream with index")) { return [true, "receive repart stream"]; } // 虎牙某些cdn会返回无法解析ADTS的flv流 https://github.com/renmu123/biliLive-tools/issues/150 if (ffmpegLogLine.includes("AAC bitstream not in ADTS format and extradata missing")) { return [true, "receive invalid aac stream"]; } const streamInfo = ffmpegLogLine.match(/frame=\s*(\d+) fps=.*? q=.*? size=.*? time=.*? bitrate=.*? speed=.*?/); if (streamInfo != null) { const [, frameText] = streamInfo; const frame = Number(frameText); if (frame === prevFrame) { if (++frameUnchangedCount >= count) { return [true, "invalid stream"]; } } else { prevFrame = frame; frameUnchangedCount = 0; } return [false, ""]; } return [false, ""]; }; } export function createTimeoutChecker(onTimeout, time, autoStart = true) { let timer = null; let stopped = false; const update = () => { if (stopped) return; if (timer != null) clearTimeout(timer); timer = setTimeout(() => { timer = null; onTimeout(); }, time); }; const start = () => { stopped = false; update(); }; if (autoStart) { start(); } return { update, stop() { stopped = true; if (timer != null) clearTimeout(timer); timer = null; }, start, }; } export async function downloadImage(imageUrl, savePath) { const res = await fetch(imageUrl); if (!res.body) { throw new Error("No body in response"); } const fileStream = fs.createWriteStream(savePath, { flags: "wx" }); // @ts-ignore await finished(Readable.fromWeb(res.body).pipe(fileStream)); } const md5 = (str) => { return crypto.createHash("md5").update(str).digest("hex"); }; const uuid = () => { return crypto.randomUUID(); }; /** * 根据指定的顺序对对象数组进行排序 * @param objects 要排序的对象数组 * @param order 指定的顺序 * @param key 用于排序的键 * @returns 排序后的对象数组 */ export function sortByKeyOrder(objects, order, key) { const orderMap = new Map(order.map((value, index) => [value, index])); return [...objects].sort((a, b) => { const indexA = orderMap.get(a[key]) ?? Number.MAX_VALUE; const indexB = orderMap.get(b[key]) ?? Number.MAX_VALUE; return indexA - indexB; }); } /** * 重试执行异步函数 * @param fn 要重试的异步函数 * @param retries 重试次数,默认为3次 * @param delay 重试延迟时间(毫秒),默认为1000ms * @returns Promise */ export async function retry(fn, retries = 3, delay = 1000) { try { return await fn(); } catch (err) { if (retries <= 0) { throw err; } await new Promise((resolve) => setTimeout(resolve, delay)); return retry(fn, retries - 1, delay); } } export const isBetweenTimeRange = (range) => { if (!range) return true; if (range.length !== 2) return true; if (range[0] === null || range[1] === null) return true; try { const status = isBetweenTime(new Date(), range); return status; } catch (error) { return true; } }; /** * 当前时间是否在两个时间'HH:mm:ss'之间,如果是["22:00:00","05:00:00"],当前时间是凌晨3点,返回true * @param {string} currentTime 当前时间 * @param {string[]} timeRange 时间范围 */ function isBetweenTime(currentTime, timeRange) { const [startTime, endTime] = timeRange; if (!startTime || !endTime) return true; const [startHour, startMinute, startSecond] = startTime.split(":").map(Number); const [endHour, endMinute, endSecond] = endTime.split(":").map(Number); const [currentHour, currentMinute, currentSecond] = [ currentTime.getHours(), currentTime.getMinutes(), currentTime.getSeconds(), ]; const start = startHour * 3600 + startMinute * 60 + startSecond; let end = endHour * 3600 + endMinute * 60 + endSecond; let current = currentHour * 3600 + currentMinute * 60 + currentSecond; // 如果结束时间小于开始时间,说明跨越了午夜 if (end < start) { end += 24 * 3600; // 将结束时间加上24小时 if (current < start) { current += 24 * 3600; // 如果当前时间小于开始时间,也加上24小时 } } return start <= current && current <= end; } export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** * 判断是否应该使用严格画质模式 * @param qualityRetryLeft 剩余的画质重试次数 * @param qualityRetry 初始画质重试次数配置 * @param isManualStart 是否手动启动 * @returns 是否使用严格画质模式 */ export function shouldUseStrictQuality(qualityRetryLeft, qualityRetry, isManualStart) { // 手动启动时不使用严格模式 if (isManualStart) { return false; } // 如果配置为0,不使用严格模式 if (qualityRetry === 0) { return false; } // 如果还有重试次数,使用严格模式 if (qualityRetryLeft > 0) { return true; } // 如果配置为负数(无限重试),使用严格模式 if (qualityRetry < 0) { return true; } return false; } /** * 检查标题是否包含黑名单关键词 * @param title 直播间标题 * @param titleKeywords 关键词配置,支持两种格式: * 1. 逗号分隔的关键词:'关键词1,关键词2,关键词3' * 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i') * @returns 如果标题包含关键词返回 true,否则返回 false */ function hasBlockedTitleKeywords(title, titleKeywords) { if (!titleKeywords || !titleKeywords.trim()) { return false; } const trimmedKeywords = titleKeywords.trim(); // 检测是否为正则表达式格式 /pattern/flags const regexMatch = trimmedKeywords.match(/^\/(.+?)\/([gimsuvy]*)$/); if (regexMatch) { try { const [, pattern, flags] = regexMatch; const regex = new RegExp(pattern, flags); return regex.test(title); } catch (error) { // 正则表达式无效,降级到普通匹配,并记录日志 console.warn(`Invalid regex pattern: ${trimmedKeywords}, falling back to normal matching`, error); // 继续使用普通匹配逻辑 } } // 普通关键词匹配(逗号分隔) const keywords = trimmedKeywords .split(",") .map((k) => k.trim()) .filter((k) => k); return keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase())); } /** * 检查是否需要进行标题关键词检查 */ function shouldCheckTitleKeywords(isManualStart, titleKeywords) { return (!isManualStart && !!titleKeywords && typeof titleKeywords === "string" && !!titleKeywords.trim()); } /** * 逆向格式化"xxxB", "xxxKB", "xxxMB", "xxxGB"为字节数,如果值为空返回0,如果为数字则直接返回数字,如果带单位则转换为字节数 * @param sizeStr 大小字符串 * @returns 字节数 */ export function parseSizeToBytes(sizeStr) { if (!sizeStr) { return 0; } // 字符类型的数字 if (!isNaN(Number(sizeStr))) { return Number(sizeStr); } const sizePattern = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/i; const match = sizeStr.toUpperCase().trim().match(sizePattern); if (match) { const size = parseFloat(match[1]); const unit = match[2]; switch (unit) { case "B": return String(size); case "KB": return String(size * 1024); case "MB": return String(size * 1024 * 1024); case "GB": return String(size * 1024 * 1024 * 1024); default: return 0; } } else { return 0; } } export const byte2MB = (bytes) => { return bytes / (1024 * 1024); }; /* * 检查录制中的标题关键词 * @param recorder 录制器实例 * @param isManualStart 是否手动启动 * @param getInfo 获取直播间信息的函数 * @returns 如果标题包含关键词返回 true(需要停止),否则返回 false */ export async function checkTitleKeywordsWhileRecording(recorder, isManualStart, getInfo) { // 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息 if (!shouldCheckTitleKeywords(isManualStart, recorder.titleKeywords)) { return false; } const now = Date.now(); // 每5分钟检查一次标题变化 const titleCheckInterval = 5 * 60 * 1000; // 5分钟 // 获取上次检查时间 const lastCheckTime = await recorder.cache.get("lastTitleCheckTime"); // 如果距离上次检查时间不足指定间隔,则跳过检查 if (lastCheckTime && now - lastCheckTime < titleCheckInterval) { return false; } // 更新检查时间 await recorder.cache.set("lastTitleCheckTime", now); // 获取直播间信息 const liveInfo = await getInfo(recorder.channelId); const { title } = liveInfo; // 检查标题是否包含关键词 if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) { recorder.state = "title-blocked"; recorder.emit("DebugLog", { type: "common", text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`, }); // 停止录制 await recorder?.recordHandle?.stop("直播间标题包含关键词"); return true; } return false; } /** * 检查开始录制前的标题关键词 * @param title 直播间标题 * @param recorder 录制器实例 * @param isManualStart 是否手动启动 * @returns 如果标题包含关键词返回 true(不应录制),否则返回 false */ export function checkTitleKeywordsBeforeRecord(title, recorder, isManualStart) { // 检查标题是否包含关键词,如果包含则不自动录制 // 手动开始录制时不检查标题关键词 if (!shouldCheckTitleKeywords(isManualStart, recorder.titleKeywords)) { return false; } if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) { recorder.state = "title-blocked"; recorder.emit("DebugLog", { type: "common", text: `跳过录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`, }); return true; } return false; } export default { replaceExtName, singleton, getValuesFromArrayLikeFlexSpaceBetween, ensureFolderExist, assert, assertStringType, assertNumberType, assertObjectType, asyncThrottle, isFfmpegStartSegment, createFFmpegInvalidStreamChecker, createTimeoutChecker, downloadImage, md5, uuid, sortByKeyOrder, retry, isBetweenTimeRange, hasBlockedTitleKeywords, shouldCheckTitleKeywords, shouldUseStrictQuality, sleep, checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord, };