@bililive-tools/manager 1.11.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/lib/downloader/BililiveDownloader.d.ts +2 -0
- package/lib/downloader/BililiveDownloader.js +17 -5
- package/lib/downloader/FFmpegDownloader.js +2 -2
- package/lib/manager.d.ts +3 -1
- package/lib/manager.js +6 -1
- package/lib/recorder.d.ts +6 -1
- package/lib/utils.d.ts +7 -2
- package/lib/utils.js +29 -4
- package/lib/xml_stream_controller.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ declare class BililiveRecorderCommand extends EventEmitter {
|
|
|
12
12
|
inputOptions(...options: string[]): BililiveRecorderCommand;
|
|
13
13
|
_getArguments(): string[];
|
|
14
14
|
run(): void;
|
|
15
|
+
stop(): void;
|
|
15
16
|
kill(): void;
|
|
16
17
|
cut(): void;
|
|
17
18
|
}
|
|
@@ -22,6 +23,7 @@ export declare class BililiveDownloader extends EventEmitter implements IDownloa
|
|
|
22
23
|
type: "bililive";
|
|
23
24
|
private command;
|
|
24
25
|
private streamManager;
|
|
26
|
+
private timeoutChecker;
|
|
25
27
|
readonly hasSegment: boolean;
|
|
26
28
|
readonly getSavePath: (data: {
|
|
27
29
|
startTime: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { DEFAULT_USER_AGENT } from "./index.js";
|
|
4
|
-
import { StreamManager, getBililivePath } from "../index.js";
|
|
4
|
+
import { StreamManager, getBililivePath, utils } from "../index.js";
|
|
5
5
|
import { byte2MB } from "../utils.js";
|
|
6
6
|
// Bililive command builder class similar to ffmpeg
|
|
7
7
|
class BililiveRecorderCommand extends EventEmitter {
|
|
@@ -75,10 +75,14 @@ class BililiveRecorderCommand extends EventEmitter {
|
|
|
75
75
|
}
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
stop() {
|
|
79
79
|
if (this.process) {
|
|
80
80
|
this.process.stdin?.write("q\n");
|
|
81
|
-
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
kill() {
|
|
84
|
+
if (this.process) {
|
|
85
|
+
this.process.kill("SIGINT");
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
cut() {
|
|
@@ -97,6 +101,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
97
101
|
type = "bililive";
|
|
98
102
|
command;
|
|
99
103
|
streamManager;
|
|
104
|
+
timeoutChecker;
|
|
100
105
|
hasSegment;
|
|
101
106
|
getSavePath;
|
|
102
107
|
segment;
|
|
@@ -118,6 +123,11 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
118
123
|
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "bililive", videoFormat, {
|
|
119
124
|
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
120
125
|
});
|
|
126
|
+
this.timeoutChecker = utils.createTimeoutChecker(() => {
|
|
127
|
+
this.emit("DebugLog", { type: "error", text: "bililive timeout, killing process" });
|
|
128
|
+
this.command?.kill();
|
|
129
|
+
this.onEnd("bililive timeout");
|
|
130
|
+
}, 20 * 1000, false);
|
|
121
131
|
this.getSavePath = opts.getSavePath;
|
|
122
132
|
this.inputOptions = [];
|
|
123
133
|
this.url = opts.url;
|
|
@@ -138,6 +148,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
138
148
|
});
|
|
139
149
|
}
|
|
140
150
|
createCommand() {
|
|
151
|
+
this.timeoutChecker?.start();
|
|
141
152
|
const inputOptions = [...this.inputOptions, "--disable-log-file", "true"];
|
|
142
153
|
if (this.debugLevel === "verbose") {
|
|
143
154
|
inputOptions.push("-l", "Debug");
|
|
@@ -164,6 +175,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
164
175
|
.on("error", this.onEnd)
|
|
165
176
|
.on("end", () => this.onEnd("finished"))
|
|
166
177
|
.on("stderr", async (stderrLine) => {
|
|
178
|
+
this.timeoutChecker?.update();
|
|
167
179
|
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
168
180
|
await this.streamManager.handleVideoStarted(stderrLine);
|
|
169
181
|
const info = this.formatLine(stderrLine);
|
|
@@ -193,9 +205,9 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
193
205
|
return this.command._getArguments();
|
|
194
206
|
}
|
|
195
207
|
async stop() {
|
|
208
|
+
this.timeoutChecker?.stop();
|
|
196
209
|
try {
|
|
197
|
-
|
|
198
|
-
this.command.kill();
|
|
210
|
+
this.command.stop();
|
|
199
211
|
await this.streamManager.handleVideoCompleted();
|
|
200
212
|
}
|
|
201
213
|
catch (err) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import { createFFMPEGBuilder, StreamManager, utils } from "../index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createFFmpegInvalidStreamChecker, assert } from "../utils.js";
|
|
4
4
|
import { DEFAULT_USER_AGENT } from "./index.js";
|
|
5
5
|
export class FFmpegDownloader extends EventEmitter {
|
|
6
6
|
onEnd;
|
|
@@ -77,7 +77,7 @@ export class FFmpegDownloader extends EventEmitter {
|
|
|
77
77
|
createCommand() {
|
|
78
78
|
this.timeoutChecker?.start();
|
|
79
79
|
const invalidCount = this.isHls ? 35 : 18;
|
|
80
|
-
const isInvalidStream =
|
|
80
|
+
const isInvalidStream = createFFmpegInvalidStreamChecker(invalidCount);
|
|
81
81
|
const inputOptions = [
|
|
82
82
|
...this.inputOptions,
|
|
83
83
|
"-user_agent",
|
package/lib/manager.d.ts
CHANGED
|
@@ -78,7 +78,9 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
78
78
|
addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: RecorderCreateOpts<E>) => Recorder<E>;
|
|
79
79
|
removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
|
|
80
80
|
getRecorder: (this: RecorderManager<ME, P, PE, E>, id: string) => Recorder<E> | null;
|
|
81
|
-
startRecord: (this: RecorderManager<ME, P, PE, E>, id: string
|
|
81
|
+
startRecord: (this: RecorderManager<ME, P, PE, E>, id: string, opts?: {
|
|
82
|
+
ignoreDataLimit?: boolean;
|
|
83
|
+
}) => Promise<Recorder<E> | undefined>;
|
|
82
84
|
stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
83
85
|
cutRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
84
86
|
autoCheckInterval: number;
|
package/lib/manager.js
CHANGED
|
@@ -198,12 +198,16 @@ export function createRecorderManager(opts) {
|
|
|
198
198
|
const recorder = this.recorders.find((item) => item.id === id);
|
|
199
199
|
return recorder ?? null;
|
|
200
200
|
},
|
|
201
|
-
async startRecord(id) {
|
|
201
|
+
async startRecord(id, iOpts = {}) {
|
|
202
|
+
const { ignoreDataLimit = true } = iOpts;
|
|
202
203
|
const recorder = this.recorders.find((item) => item.id === id);
|
|
203
204
|
if (recorder == null)
|
|
204
205
|
return;
|
|
205
206
|
if (recorder.recordHandle != null)
|
|
206
207
|
return;
|
|
208
|
+
// 如果手动开启且需要判断限制时间,再时间限制内时,就不启动录制
|
|
209
|
+
if (!ignoreDataLimit && isBetweenTimeRange(recorder.handleTime))
|
|
210
|
+
return;
|
|
207
211
|
await recorder.checkLiveStatusAndRecord({
|
|
208
212
|
getSavePath(data) {
|
|
209
213
|
return genSavePathFromRule(manager, recorder, data);
|
|
@@ -321,6 +325,7 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
321
325
|
hour: formatDate(now, "HH"),
|
|
322
326
|
min: formatDate(now, "mm"),
|
|
323
327
|
sec: formatDate(now, "ss"),
|
|
328
|
+
ms: formatDate(now, "SSS"),
|
|
324
329
|
...extData,
|
|
325
330
|
startTime: now,
|
|
326
331
|
owner: owner,
|
package/lib/recorder.d.ts
CHANGED
|
@@ -36,13 +36,18 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
36
36
|
useM3U8Proxy?: boolean;
|
|
37
37
|
/**B站m3u8代理url */
|
|
38
38
|
m3u8ProxyUrl?: string;
|
|
39
|
+
/** B站自定义Host,用于替换直播流链接中的host */
|
|
40
|
+
customHost?: string;
|
|
39
41
|
/** 流格式 */
|
|
40
42
|
formatName?: FormatName;
|
|
41
43
|
/** 流编码 */
|
|
42
44
|
codecName?: CodecName;
|
|
43
45
|
/** 选择使用的api,虎牙支持: auto,web,mp,wup,抖音支持:web,webHTML,mobile,userHTML */
|
|
44
46
|
api?: "auto" | "web" | "mp" | "wup" | "webHTML" | "mobile" | "userHTML" | "balance" | "random" | string;
|
|
45
|
-
/**
|
|
47
|
+
/** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制,支持两种格式:
|
|
48
|
+
* 1. 逗号分隔的关键词:'回放,录播,重播'
|
|
49
|
+
* 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
|
|
50
|
+
*/
|
|
46
51
|
titleKeywords?: string;
|
|
47
52
|
/** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
|
|
48
53
|
videoFormat?: "auto" | "ts" | "mkv" | "flv";
|
package/lib/utils.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export declare const formatTemplate: (string: string, ...args: any[]) => string;
|
|
|
50
50
|
* "receive invalid aac stream": ADTS无法被解析的flv流
|
|
51
51
|
* "invalid stream": 一段时间内帧数不变
|
|
52
52
|
*/
|
|
53
|
-
export declare function
|
|
53
|
+
export declare function createFFmpegInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => [boolean, string];
|
|
54
54
|
export declare function createTimeoutChecker(onTimeout: () => void, time: number, autoStart?: boolean): {
|
|
55
55
|
update: () => void;
|
|
56
56
|
stop: () => void;
|
|
@@ -85,6 +85,11 @@ export declare const sleep: (ms: number) => Promise<unknown>;
|
|
|
85
85
|
export declare function shouldUseStrictQuality(qualityRetryLeft: number, qualityRetry: number, isManualStart?: boolean): boolean;
|
|
86
86
|
/**
|
|
87
87
|
* 检查标题是否包含黑名单关键词
|
|
88
|
+
* @param title 直播间标题
|
|
89
|
+
* @param titleKeywords 关键词配置,支持两种格式:
|
|
90
|
+
* 1. 逗号分隔的关键词:'关键词1,关键词2,关键词3'
|
|
91
|
+
* 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
|
|
92
|
+
* @returns 如果标题包含关键词返回 true,否则返回 false
|
|
88
93
|
*/
|
|
89
94
|
declare function hasBlockedTitleKeywords(title: string, titleKeywords: string | undefined): boolean;
|
|
90
95
|
/**
|
|
@@ -120,7 +125,7 @@ declare const _default: {
|
|
|
120
125
|
assertObjectType: typeof assertObjectType;
|
|
121
126
|
asyncThrottle: typeof asyncThrottle;
|
|
122
127
|
isFfmpegStartSegment: typeof isFfmpegStartSegment;
|
|
123
|
-
|
|
128
|
+
createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
|
|
124
129
|
createTimeoutChecker: typeof createTimeoutChecker;
|
|
125
130
|
downloadImage: typeof downloadImage;
|
|
126
131
|
md5: (str: string) => string;
|
package/lib/utils.js
CHANGED
|
@@ -117,8 +117,9 @@ export function formatDate(date, format) {
|
|
|
117
117
|
HH: date.getHours().toString().padStart(2, "0"),
|
|
118
118
|
mm: date.getMinutes().toString().padStart(2, "0"),
|
|
119
119
|
ss: date.getSeconds().toString().padStart(2, "0"),
|
|
120
|
+
SSS: date.getMilliseconds().toString().padStart(3, "0"),
|
|
120
121
|
};
|
|
121
|
-
return format.replace(/yyyy|MM|dd|HH|mm|ss/g, (matched) => map[matched]);
|
|
122
|
+
return format.replace(/yyyy|MM|dd|HH|mm|ss|SSS/g, (matched) => map[matched]);
|
|
122
123
|
}
|
|
123
124
|
export function removeSystemReservedChars(str) {
|
|
124
125
|
return filenamify(str, { replacement: "_" });
|
|
@@ -173,7 +174,7 @@ export const formatTemplate = function template(string, ...args) {
|
|
|
173
174
|
* "receive invalid aac stream": ADTS无法被解析的flv流
|
|
174
175
|
* "invalid stream": 一段时间内帧数不变
|
|
175
176
|
*/
|
|
176
|
-
export function
|
|
177
|
+
export function createFFmpegInvalidStreamChecker(count = 15) {
|
|
177
178
|
let prevFrame = 0;
|
|
178
179
|
let frameUnchangedCount = 0;
|
|
179
180
|
return (ffmpegLogLine) => {
|
|
@@ -355,9 +356,33 @@ export function shouldUseStrictQuality(qualityRetryLeft, qualityRetry, isManualS
|
|
|
355
356
|
}
|
|
356
357
|
/**
|
|
357
358
|
* 检查标题是否包含黑名单关键词
|
|
359
|
+
* @param title 直播间标题
|
|
360
|
+
* @param titleKeywords 关键词配置,支持两种格式:
|
|
361
|
+
* 1. 逗号分隔的关键词:'关键词1,关键词2,关键词3'
|
|
362
|
+
* 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
|
|
363
|
+
* @returns 如果标题包含关键词返回 true,否则返回 false
|
|
358
364
|
*/
|
|
359
365
|
function hasBlockedTitleKeywords(title, titleKeywords) {
|
|
360
|
-
|
|
366
|
+
if (!titleKeywords || !titleKeywords.trim()) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
const trimmedKeywords = titleKeywords.trim();
|
|
370
|
+
// 检测是否为正则表达式格式 /pattern/flags
|
|
371
|
+
const regexMatch = trimmedKeywords.match(/^\/(.+?)\/([gimsuvy]*)$/);
|
|
372
|
+
if (regexMatch) {
|
|
373
|
+
try {
|
|
374
|
+
const [, pattern, flags] = regexMatch;
|
|
375
|
+
const regex = new RegExp(pattern, flags);
|
|
376
|
+
return regex.test(title);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
// 正则表达式无效,降级到普通匹配,并记录日志
|
|
380
|
+
console.warn(`Invalid regex pattern: ${trimmedKeywords}, falling back to normal matching`, error);
|
|
381
|
+
// 继续使用普通匹配逻辑
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// 普通关键词匹配(逗号分隔)
|
|
385
|
+
const keywords = trimmedKeywords
|
|
361
386
|
.split(",")
|
|
362
387
|
.map((k) => k.trim())
|
|
363
388
|
.filter((k) => k);
|
|
@@ -480,7 +505,7 @@ export default {
|
|
|
480
505
|
assertObjectType,
|
|
481
506
|
asyncThrottle,
|
|
482
507
|
isFfmpegStartSegment,
|
|
483
|
-
|
|
508
|
+
createFFmpegInvalidStreamChecker,
|
|
484
509
|
createTimeoutChecker,
|
|
485
510
|
downloadImage,
|
|
486
511
|
md5,
|
|
@@ -23,7 +23,7 @@ export function createRecordExtraDataController(savePath) {
|
|
|
23
23
|
isInitialized = true;
|
|
24
24
|
try {
|
|
25
25
|
// 创建XML文件头,使用占位符预留metadata位置
|
|
26
|
-
const header = `<?xml version="1.0" encoding="utf-8"?>\n<i>\n<!--METADATA_PLACEHOLDER-->\n
|
|
26
|
+
const header = `<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet type="text/xsl" href="#s"?>\n<i>\n<!--METADATA_PLACEHOLDER-->\n<RecorderXmlStyle><z:stylesheet version="1.0" id="s" xml:id="s" xmlns:z="http://www.w3.org/1999/XSL/Transform"><z:output method="html"/><z:template match="/"><html><meta name="viewport" content="width=device-width"/><title>弹幕文件 <z:value-of select="/i/metadata/user_name/text()"/></title><style>body{margin:0}h1,h2,p,table{margin-left:5px}table{border-spacing:0}td,th{border:1px solid grey;padding:1px 5px}th{position:sticky;top:0;background:#4098de}tr:hover{background:#d9f4ff}div{overflow:auto;max-height:80vh;max-width:100vw;width:fit-content}</style><h1>弹幕XML文件</h1><p>本文件不支持在 IE 浏览器里预览,请使用 Chrome Firefox Edge 等浏览器。</p><p>文件用法参考文档 <a href="https://rec.danmuji.org/user/danmaku/">https://rec.danmuji.org/user/danmaku/</a></p><table><tr><td>房间号</td><td><z:value-of select="/i/metadata/room_id/text()"/></td></tr><tr><td>主播名</td><td><z:value-of select="/i/metadata/user_name/text()"/></td></tr><tr><td><a href="#d">弹幕</a></td><td>共<z:value-of select="count(/i/d)"/>条记录</td></tr><tr><td><a href="#guard">上船</a></td><td>共<z:value-of select="count(/i/guard)"/>条记录</td></tr><tr><td><a href="#sc">SC</a></td><td>共<z:value-of select="count(/i/sc)"/>条记录</td></tr><tr><td><a href="#gift">礼物</a></td><td>共<z:value-of select="count(/i/gift)"/>条记录</td></tr></table><h2 id="d">弹幕</h2><div id="dm"><table><tr><th>用户名</th><th>出现时间</th><th>用户ID</th><th>弹幕</th><th>参数</th></tr><z:for-each select="/i/d"><tr><td><z:value-of select="@user"/></td><td></td><td></td><td><z:value-of select="."/></td><td><z:value-of select="@p"/></td></tr></z:for-each></table></div><script>Array.from(document.querySelectorAll('#dm tr')).slice(1).map(t=>t.querySelectorAll('td')).forEach(t=>{let p=t[4].textContent.split(','),a=p[0];t[1].textContent=\`\u0024{(Math.floor(a/60/60)+'').padStart(2,0)}:\u0024{(Math.floor(a/60%60)+'').padStart(2,0)}:\u0024{(a%60).toFixed(3).padStart(6,0)}\`;t[2].innerHTML=\`<a target=_blank rel="nofollow noreferrer" ">\u0024{p[6]}</a>\`})</script><h2 id="guard">舰长购买</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>舰长等级</th><th>购买数量</th><th>出现时间</th></tr><z:for-each select="/i/guard"><tr><td><z:value-of select="@user"/></td><td><a rel="nofollow noreferrer"><z:attribute name="href"><z:text></z:text><z:value-of select="@uid" /></z:attribute><z:value-of select="@uid"/></a></td><td><z:value-of select="@level"/></td><td><z:value-of select="@count"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div><h2 id="sc">SuperChat 醒目留言</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>内容</th><th>显示时长</th><th>价格</th><th>出现时间</th></tr><z:for-each select="/i/sc"><tr><td><z:value-of select="@user"/></td><td><a rel="nofollow noreferrer"><z:attribute name="href"><z:text></z:text><z:value-of select="@uid" /></z:attribute><z:value-of select="@uid"/></a></td><td><z:value-of select="."/></td><td><z:value-of select="@time"/></td><td><z:value-of select="@price"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div><h2 id="gift">礼物</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>礼物名</th><th>礼物数量</th><th>出现时间</th></tr><z:for-each select="/i/gift"><tr><td><z:value-of select="@user"/></td><td><span rel="nofollow noreferrer"><z:attribute name="href"></z:attribute><z:value-of select="@uid"/></span></td><td><z:value-of select="@giftname"/></td><td><z:value-of select="@giftcount"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div></html></z:template></z:stylesheet></RecorderXmlStyle>`;
|
|
27
27
|
await fs.promises.writeFile(savePath, header);
|
|
28
28
|
}
|
|
29
29
|
catch (error) {
|