@bililive-tools/manager 1.12.0 → 1.14.1

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
@@ -12,12 +12,13 @@
12
12
 
13
13
  ## 支持的平台
14
14
 
15
- | 平台 | 包名 |
16
- | ---- | ----------------------------------- |
17
- | B站 | `@bililive-tools/bilibili-recorder` |
18
- | 斗鱼 | `@bililive-tools/douyu-recorder` |
19
- | 虎牙 | `@bililive-tools/huya-recorder` |
20
- | 抖音 | `@bililive-tools/douyin-recorder` |
15
+ | 平台 | 包名 |
16
+ | ------ | ----------------------------------- |
17
+ | B站 | `@bililive-tools/bilibili-recorder` |
18
+ | 斗鱼 | `@bililive-tools/douyu-recorder` |
19
+ | 虎牙 | `@bililive-tools/huya-recorder` |
20
+ | 抖音 | `@bililive-tools/douyin-recorder` |
21
+ | 小红书 | `@bililive-tools/xhs-recorder` |
21
22
 
22
23
  # 使用
23
24
 
@@ -32,9 +33,17 @@ const manager = createRecorderManager({
32
33
  savePathRule: "D:\\录制\\{platforme}}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}", // 保存路径,占位符见文档,支持 [ejs](https://ejs.co/) 模板引擎
33
34
  autoCheckInterval: 1000 * 60, // 自动检查间隔,单位秒
34
35
  maxThreadCount: 3, // 检查并发数
35
- waitTime: 0, // 检查后等待时间
36
+ waitTime: 0, // 检查后等待时间,单位毫秒
36
37
  autoRemoveSystemReservedChars: true, // 移除系统非法字符串
37
38
  biliBatchQuery: false, // B站检查使用批量接口
39
+ providerCheckConfig: {
40
+ // Bilibili 配置:高频检查,更多线程
41
+ Bilibili: {
42
+ autoCheckInterval: 5000, // 5秒检查一次
43
+ maxThreadCount: 5, // 最多5个并发线程
44
+ waitTime: 500, // 每次检查间隔0.5秒
45
+ },
46
+ },
38
47
  });
39
48
 
40
49
  // 不同provider支持的参数不尽相同,具体见相关文档
@@ -107,6 +116,7 @@ setBililivePath("BililiveRecorder.Cli.exe");
107
116
  | {hour} | 时 |
108
117
  | {min} | 分 |
109
118
  | {sec} | 秒 |
119
+ | {ms} | 毫秒 |
110
120
  | {startTime} | 分段开始时间,Date对象 |
111
121
  | {recordStartTime} | 录制开始时间,Date对象 |
112
122
  | {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
@@ -4,6 +4,14 @@ import { RecorderCache } from "./cache.js";
4
4
  import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog, Progress } from "./recorder.js";
5
5
  import { AnyObject, UnknownObject } from "./utils.js";
6
6
  import { StreamManager } from "./downloader/streamManager.js";
7
+ export interface ProviderCheckConfig {
8
+ /** 检查循环间隔(毫秒) */
9
+ autoCheckInterval?: number;
10
+ /** 最大并发检查线程数 */
11
+ maxThreadCount?: number;
12
+ /** 每次检查之间的等待时间(毫秒) */
13
+ waitTime?: number;
14
+ }
7
15
  export interface RecorderProvider<E extends AnyObject> {
8
16
  id: string;
9
17
  name: string;
@@ -20,7 +28,7 @@ export interface RecorderProvider<E extends AnyObject> {
20
28
  fromJSON: <T extends SerializedRecorder<E>>(this: RecorderProvider<E>, json: T) => Recorder<E>;
21
29
  setFFMPEGOutputArgs: (this: RecorderProvider<E>, args: string[]) => void;
22
30
  }
23
- declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "maxThreadCount", "waitTime", "ffmpegOutputArgs", "biliBatchQuery", "recordRetryImmediately"];
31
+ declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "maxThreadCount", "waitTime", "ffmpegOutputArgs", "biliBatchQuery", "recordRetryImmediately", "providerCheckConfig"];
24
32
  type ConfigurableProp = (typeof configurableProps)[number];
25
33
  export interface RecorderManager<ME extends UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE> extends Emitter<{
26
34
  error: {
@@ -78,7 +86,9 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
78
86
  addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: RecorderCreateOpts<E>) => Recorder<E>;
79
87
  removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
80
88
  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>;
89
+ startRecord: (this: RecorderManager<ME, P, PE, E>, id: string, opts?: {
90
+ ignoreDataLimit?: boolean;
91
+ }) => Promise<Recorder<E> | undefined>;
82
92
  stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
83
93
  cutRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
84
94
  autoCheckInterval: number;
@@ -96,11 +106,17 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
96
106
  recordRetryImmediately: boolean;
97
107
  /** 缓存系统 */
98
108
  cache: RecorderCache;
109
+ /** 每个 provider 的检查配置 */
110
+ providerCheckConfig: Record<string, ProviderCheckConfig>;
111
+ /** 获取指定 provider 的检查配置(自动 fallback 到全局配置) */
112
+ getProviderCheckConfig: (this: RecorderManager<ME, P, PE, E>, providerId: string) => Required<ProviderCheckConfig>;
99
113
  }
100
114
  export type RecorderManagerCreateOpts<ME extends AnyObject = UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE> = Partial<Pick<RecorderManager<ME, P, PE, E>, ConfigurableProp>> & {
101
115
  providers: P[];
102
116
  /** 自定义缓存实现,不提供则使用默认的内存缓存 */
103
117
  cache?: RecorderCache;
118
+ /** 每个 provider 的检查配置,key 为 provider.id */
119
+ providerCheckConfig?: Record<string, ProviderCheckConfig>;
104
120
  };
105
121
  export declare function createRecorderManager<ME extends AnyObject = UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE>(opts: RecorderManagerCreateOpts<ME, P, PE, E>): RecorderManager<ME, P, PE, E>;
106
122
  export declare function genSavePathFromRule<ME extends AnyObject, P extends RecorderProvider<AnyObject>, PE extends AnyObject, E extends AnyObject>(manager: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>, extData: {
package/lib/manager.js CHANGED
@@ -16,14 +16,16 @@ const configurableProps = [
16
16
  "ffmpegOutputArgs",
17
17
  "biliBatchQuery",
18
18
  "recordRetryImmediately",
19
+ "providerCheckConfig",
19
20
  ];
20
21
  function isConfigurableProp(prop) {
21
22
  return configurableProps.includes(prop);
22
23
  }
23
24
  export function createRecorderManager(opts) {
24
25
  const recorders = [];
25
- let checkLoopTimer;
26
- const multiThreadCheck = async (manager) => {
26
+ // 存储每个 provider 的 timer,key 为 providerId
27
+ const checkLoopTimers = new Map();
28
+ const multiThreadCheck = async (manager, providerId) => {
27
29
  const handleBatchQuery = async (obj) => {
28
30
  for (const recorder of recorders
29
31
  .filter((r) => !r.disableAutoCheck)
@@ -42,15 +44,16 @@ export function createRecorderManager(opts) {
42
44
  };
43
45
  // 这里暂时不打算用 state == recording 来过滤,provider 必须内部自己处理录制过程中的 check,
44
46
  // 这样可以防止一些意外调用 checkLiveStatusAndRecord 时出现重复录制。
45
- let needCheckRecorders = recorders
47
+ const needCheckRecorders = recorders
46
48
  .filter((r) => !r.disableAutoCheck)
47
- .filter((r) => isBetweenTimeRange(r.handleTime));
48
- let threads = [];
49
- if (manager.biliBatchQuery) {
50
- const biliNeedCheckRecorders = needCheckRecorders
51
- .filter((r) => r.providerId === "Bilibili")
52
- .filter((r) => r.recordHandle == null);
53
- needCheckRecorders = needCheckRecorders.filter((r) => r.providerId !== "Bilibili");
49
+ .filter((r) => isBetweenTimeRange(r.handleTime))
50
+ .filter((r) => r.providerId === providerId);
51
+ const providerConfig = manager.getProviderCheckConfig(providerId);
52
+ const threads = [];
53
+ // Bilibili 批量查询特殊处理
54
+ if (providerId === "Bilibili" && manager.biliBatchQuery) {
55
+ const biliNeedCheckRecorders = needCheckRecorders.filter((r) => r.recordHandle == null);
56
+ // const biliRecordingRecorders = needCheckRecorders.filter((r) => r.recordHandle != null);
54
57
  const roomIds = biliNeedCheckRecorders.map((r) => r.channelId).map(Number);
55
58
  try {
56
59
  if (roomIds.length !== 0) {
@@ -61,9 +64,13 @@ export function createRecorderManager(opts) {
61
64
  catch (err) {
62
65
  manager.emit("error", { source: "getBiliStatusInfoByRoomIds", err });
63
66
  // 如果批量查询失败,则使用单个查询
64
- needCheckRecorders = needCheckRecorders.concat(biliNeedCheckRecorders);
67
+ needCheckRecorders.push(...biliNeedCheckRecorders);
65
68
  }
69
+ // 正在录制的也需要检查(放回队列)
70
+ // needCheckRecorders.length = 0;
71
+ // needCheckRecorders.push(...biliRecordingRecorders);
66
72
  }
73
+ // 为当前 provider 创建线程池
67
74
  const checkOnce = async () => {
68
75
  const recorder = needCheckRecorders.shift();
69
76
  if (recorder == null)
@@ -76,12 +83,12 @@ export function createRecorderManager(opts) {
76
83
  banLiveId,
77
84
  });
78
85
  };
79
- threads = threads.concat(range(0, manager.maxThreadCount).map(async () => {
86
+ threads.push(...range(0, providerConfig.maxThreadCount).map(async () => {
80
87
  while (needCheckRecorders.length > 0) {
81
88
  try {
82
89
  await checkOnce();
83
- if (manager.waitTime > 0) {
84
- await sleep(manager.waitTime);
90
+ if (providerConfig.waitTime > 0) {
91
+ await sleep(providerConfig.waitTime);
85
92
  }
86
93
  }
87
94
  catch (err) {
@@ -183,6 +190,7 @@ export function createRecorderManager(opts) {
183
190
  this.emit("RecoderLiveStart", { recorder: recorder });
184
191
  });
185
192
  this.emit("RecorderAdded", recorder.toJSON());
193
+ // startCheckLoop 会为所有注册的 provider 启动检查循环,无需在此处额外处理
186
194
  return recorder;
187
195
  },
188
196
  removeRecorder(recorder) {
@@ -198,12 +206,16 @@ export function createRecorderManager(opts) {
198
206
  const recorder = this.recorders.find((item) => item.id === id);
199
207
  return recorder ?? null;
200
208
  },
201
- async startRecord(id) {
209
+ async startRecord(id, iOpts = {}) {
210
+ const { ignoreDataLimit = true } = iOpts;
202
211
  const recorder = this.recorders.find((item) => item.id === id);
203
212
  if (recorder == null)
204
213
  return;
205
214
  if (recorder.recordHandle != null)
206
215
  return;
216
+ // 如果手动开启且需要判断限制时间,再时间限制内时,就不启动录制
217
+ if (!ignoreDataLimit && isBetweenTimeRange(recorder.handleTime))
218
+ return;
207
219
  await recorder.checkLiveStatusAndRecord({
208
220
  getSavePath(data) {
209
221
  return genSavePathFromRule(manager, recorder, data);
@@ -237,7 +249,7 @@ export function createRecorderManager(opts) {
237
249
  await recorder.recordHandle.cut();
238
250
  return recorder;
239
251
  },
240
- autoCheckInterval: opts.autoCheckInterval ?? 1000,
252
+ autoCheckInterval: opts.autoCheckInterval ?? 60000,
241
253
  maxThreadCount: opts.maxThreadCount ?? 3,
242
254
  waitTime: opts.waitTime ?? 0,
243
255
  isCheckLoopRunning: false,
@@ -246,30 +258,60 @@ export function createRecorderManager(opts) {
246
258
  return;
247
259
  this.isCheckLoopRunning = true;
248
260
  // TODO: emit updated event
249
- const checkLoop = async () => {
250
- try {
251
- await multiThreadCheck(this);
252
- }
253
- catch (err) {
254
- this.emit("error", { source: "multiThreadCheck", err });
255
- }
256
- finally {
257
- if (!this.isCheckLoopRunning) {
258
- // do nothing
261
+ // 为每个 provider 创建独立的检查循环
262
+ const startProviderCheckLoop = (providerId) => {
263
+ const providerConfig = this.getProviderCheckConfig(providerId);
264
+ const checkLoop = async () => {
265
+ try {
266
+ // 只检查当前 provider recorders
267
+ await multiThreadCheck(this, providerId);
259
268
  }
260
- else {
261
- checkLoopTimer = setTimeout(checkLoop, this.autoCheckInterval);
269
+ catch (err) {
270
+ this.emit("error", { source: "multiThreadCheck", err });
262
271
  }
263
- }
272
+ finally {
273
+ if (!this.isCheckLoopRunning) {
274
+ // 停止了,清理 timer
275
+ const timer = checkLoopTimers.get(providerId);
276
+ if (timer) {
277
+ clearTimeout(timer);
278
+ checkLoopTimers.delete(providerId);
279
+ }
280
+ }
281
+ else {
282
+ // 检查该 provider 是否还有 recorder
283
+ const hasRecorders = this.recorders.some((r) => r.providerId === providerId);
284
+ if (hasRecorders) {
285
+ // 继续循环
286
+ const timer = setTimeout(checkLoop, providerConfig.autoCheckInterval);
287
+ checkLoopTimers.set(providerId, timer);
288
+ }
289
+ else {
290
+ // 没有 recorder 了,停止该 provider 的检查循环
291
+ // TODO: 也许不需要删除定时器
292
+ checkLoopTimers.delete(providerId);
293
+ }
294
+ }
295
+ }
296
+ };
297
+ void checkLoop();
264
298
  };
265
- void checkLoop();
299
+ // 直接从注册的 provider 获取所有 provider IDs
300
+ const providerIds = this.providers.map((p) => p.id);
301
+ for (const providerId of providerIds) {
302
+ startProviderCheckLoop(providerId);
303
+ }
266
304
  },
267
305
  stopCheckLoop() {
268
306
  if (!this.isCheckLoopRunning)
269
307
  return;
270
308
  this.isCheckLoopRunning = false;
271
309
  // TODO: emit updated event
272
- clearTimeout(checkLoopTimer);
310
+ // 清理所有 provider 的 timer
311
+ for (const timer of checkLoopTimers.values()) {
312
+ clearTimeout(timer);
313
+ }
314
+ checkLoopTimers.clear();
273
315
  },
274
316
  savePathRule: opts.savePathRule ??
275
317
  path.join(process.cwd(), "{platform}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}"),
@@ -277,6 +319,15 @@ export function createRecorderManager(opts) {
277
319
  biliBatchQuery: opts.biliBatchQuery ?? false,
278
320
  recordRetryImmediately: opts.recordRetryImmediately ?? false,
279
321
  cache: opts.cache ?? new RecorderCacheImpl(new MemoryCacheStore()),
322
+ providerCheckConfig: opts.providerCheckConfig ?? {},
323
+ getProviderCheckConfig(providerId) {
324
+ const providerConfig = this.providerCheckConfig[providerId];
325
+ return {
326
+ autoCheckInterval: providerConfig?.autoCheckInterval ?? this.autoCheckInterval,
327
+ maxThreadCount: providerConfig?.maxThreadCount ?? this.maxThreadCount,
328
+ waitTime: providerConfig?.waitTime ?? this.waitTime,
329
+ };
330
+ },
280
331
  ffmpegOutputArgs: opts.ffmpegOutputArgs ??
281
332
  "-c copy" +
282
333
  /**
@@ -321,6 +372,7 @@ export function genSavePathFromRule(manager, recorder, extData) {
321
372
  hour: formatDate(now, "HH"),
322
373
  min: formatDate(now, "mm"),
323
374
  sec: formatDate(now, "ss"),
375
+ ms: formatDate(now, "SSS"),
324
376
  ...extData,
325
377
  startTime: now,
326
378
  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
  /** 流编码 */
@@ -130,6 +132,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
130
132
  cover: string;
131
133
  liveId?: string;
132
134
  recordStartTime: Date;
135
+ area?: string;
133
136
  };
134
137
  tempStopIntervalCheck?: boolean;
135
138
  /** 缓存实例(命名空间) */
@@ -150,6 +153,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
150
153
  channelId: ChannelId;
151
154
  living: boolean;
152
155
  liveStartTime: Date;
156
+ area: string;
153
157
  }>;
154
158
  getStream: (this: Recorder<E>) => Promise<{
155
159
  source: string;
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.14.1",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "mitt": "^3.0.1",
39
39
  "string-argv": "^0.3.2",
40
40
  "lodash-es": "^4.17.21",
41
- "axios": "^1.7.8",
41
+ "axios": "^1.15.0",
42
42
  "fs-extra": "^11.2.0",
43
43
  "ejs": "^3.1.10"
44
44
  },