@bililive-tools/manager 1.12.0 → 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,6 +36,8 @@ 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
  /** 流编码 */
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;
@@ -125,7 +125,7 @@ declare const _default: {
125
125
  assertObjectType: typeof assertObjectType;
126
126
  asyncThrottle: typeof asyncThrottle;
127
127
  isFfmpegStartSegment: typeof isFfmpegStartSegment;
128
- createInvalidStreamChecker: typeof createInvalidStreamChecker;
128
+ createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
129
129
  createTimeoutChecker: typeof createTimeoutChecker;
130
130
  downloadImage: typeof downloadImage;
131
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) => {
@@ -504,7 +505,7 @@ export default {
504
505
  assertObjectType,
505
506
  asyncThrottle,
506
507
  isFfmpegStartSegment,
507
- createInvalidStreamChecker,
508
+ createFFmpegInvalidStreamChecker,
508
509
  createTimeoutChecker,
509
510
  downloadImage,
510
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<?xml-stylesheet type="text/xsl" href="#s"?>\n<i>\n<!--METADATA_PLACEHOLDER-->\n<BililiveRecorderXmlStyle><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></BililiveRecorderXmlStyle>`;
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.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",