@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 CHANGED
@@ -107,6 +107,7 @@ setBililivePath("BililiveRecorder.Cli.exe");
107
107
  | {hour} | 时 |
108
108
  | {min} | 分 |
109
109
  | {sec} | 秒 |
110
+ | {ms} | 毫秒 |
110
111
  | {startTime} | 分段开始时间,Date对象 |
111
112
  | {recordStartTime} | 录制开始时间,Date对象 |
112
113
  | {liveStartTime} | 直播开始时间,Date对象,抖音同录制开始时间 |
@@ -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
- kill() {
78
+ stop() {
79
79
  if (this.process) {
80
80
  this.process.stdin?.write("q\n");
81
- // this.process.kill("SIGTERM");
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
- // 直接发送SIGINT信号,会导致数据丢失
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 { createInvalidStreamChecker, assert } from "../utils.js";
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 = createInvalidStreamChecker(invalidCount);
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) => Promise<Recorder<E> | undefined>;
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 createInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => [boolean, string];
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
- createInvalidStreamChecker: typeof createInvalidStreamChecker;
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 createInvalidStreamChecker(count = 15) {
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
- const keywords = (titleKeywords ?? "")
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
- createInvalidStreamChecker,
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=\`&lt;a target=_blank rel="nofollow noreferrer" "&gt;\u0024{p[6]}&lt;/a&gt;\`})</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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.11.1",
3
+ "version": "1.13.0",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",