@bililive-tools/manager 1.13.0 → 1.16.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
@@ -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支持的参数不尽相同,具体见相关文档
@@ -194,6 +194,11 @@ export class BililiveDownloader extends EventEmitter {
194
194
  if (timeMatch) {
195
195
  time = timeMatch[1];
196
196
  }
197
+ const spaceMath = line.match(/下载进度:\s*([\d.]+\s*MB)\s*/);
198
+ if (spaceMath) {
199
+ const space = spaceMath[1];
200
+ time = time ? `${time} ${space}` : space;
201
+ }
197
202
  return {
198
203
  time,
199
204
  };
@@ -154,7 +154,7 @@ export class FFmpegDownloader extends EventEmitter {
154
154
  let time = null;
155
155
  const timeMatch = line.match(/time=([0-9:.]+)/);
156
156
  if (timeMatch) {
157
- time = timeMatch[1];
157
+ time = timeMatch[1].split(".")[0];
158
158
  }
159
159
  return {
160
160
  time,
@@ -11,6 +11,7 @@ export interface BaseRecorderOptions {
11
11
  getSavePath: (data: {
12
12
  startTime: number;
13
13
  title?: string;
14
+ extraMs?: boolean;
14
15
  }) => string;
15
16
  segment: Segment;
16
17
  inputOptions?: string[];
@@ -38,6 +39,7 @@ export interface IDownloader extends EventEmitter {
38
39
  readonly getSavePath: (data: {
39
40
  startTime: number;
40
41
  title?: string;
42
+ extraMs?: boolean;
41
43
  }) => string;
42
44
  run(): void;
43
45
  stop(): Promise<void>;
@@ -5,6 +5,7 @@ import type { TrueVideoFormat } from "../index.js";
5
5
  export type GetSavePath = (data: {
6
6
  startTime: number;
7
7
  title?: string;
8
+ extraMs?: boolean;
8
9
  }) => string;
9
10
  type RecorderType = Exclude<RecorderCreateOpts["recorderType"], undefined | "auto">;
10
11
  export declare class Segment extends EventEmitter {
@@ -1,5 +1,6 @@
1
1
  import EventEmitter from "node:events";
2
2
  import fs from "fs/promises";
3
+ import fsSync from "fs";
3
4
  import { createRecordExtraDataController } from "../xml_stream_controller.js";
4
5
  import { ensureFolderExist, isFfmpegStartSegment, isMesioStartSegment, isBililiveStartSegment, isFfmpegStart, retry, cleanTerminalText, } from "../utils.js";
5
6
  export class Segment extends EventEmitter {
@@ -64,10 +65,19 @@ export class Segment extends EventEmitter {
64
65
  });
65
66
  }
66
67
  }
67
- this.outputVideoFilePath = this.getSavePath({
68
+ let recordSavePath = this.getSavePath({
68
69
  startTime: startTime,
69
- title: liveInfo?.title,
70
+ title: liveInfo?.title ? liveInfo.title : undefined,
70
71
  });
72
+ // 文件重复判断
73
+ if (fsSync.existsSync(recordSavePath + "." + this.videoExt)) {
74
+ recordSavePath = this.getSavePath({
75
+ startTime: startTime,
76
+ title: liveInfo?.title,
77
+ extraMs: true,
78
+ });
79
+ }
80
+ this.outputVideoFilePath = recordSavePath;
71
81
  ensureFolderExist(this.outputVideoFilePath);
72
82
  if (!this.disableDanma) {
73
83
  this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
@@ -112,12 +122,19 @@ export class StreamManager extends EventEmitter {
112
122
  callBack;
113
123
  constructor(getSavePath, hasSegment, disableDanma, recorderType, videoFormat, callBack) {
114
124
  super();
115
- const recordSavePath = getSavePath({ startTime: Date.now() });
116
- this.recordSavePath = recordSavePath;
125
+ const startTime = Date.now();
126
+ let recordSavePath = getSavePath({ startTime });
117
127
  this.videoFormat = videoFormat;
118
128
  this.recorderType = recorderType;
119
129
  this.hasSegment = hasSegment;
120
130
  this.callBack = callBack;
131
+ console.log("Initial recordSavePath:", recordSavePath);
132
+ // 文件重复判断
133
+ if (fsSync.existsSync(recordSavePath + "." + videoFormat)) {
134
+ console.log("File already exists, generating new save path with extraMs");
135
+ recordSavePath = getSavePath({ startTime, extraMs: true });
136
+ }
137
+ this.recordSavePath = recordSavePath;
121
138
  if (hasSegment) {
122
139
  this.segment = new Segment(getSavePath, disableDanma, this.videoExt);
123
140
  this.segment.on("DebugLog", (data) => {
@@ -131,6 +148,7 @@ export class StreamManager extends EventEmitter {
131
148
  });
132
149
  }
133
150
  else {
151
+ ensureFolderExist(recordSavePath);
134
152
  const extraDataSavePath = `${recordSavePath}.xml`;
135
153
  if (!disableDanma) {
136
154
  this.extraDataController = createRecordExtraDataController(extraDataSavePath);
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: {
@@ -98,11 +106,17 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
98
106
  recordRetryImmediately: boolean;
99
107
  /** 缓存系统 */
100
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>;
101
113
  }
102
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>> & {
103
115
  providers: P[];
104
116
  /** 自定义缓存实现,不提供则使用默认的内存缓存 */
105
117
  cache?: RecorderCache;
118
+ /** 每个 provider 的检查配置,key 为 provider.id */
119
+ providerCheckConfig?: Record<string, ProviderCheckConfig>;
106
120
  };
107
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>;
108
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: {
@@ -111,6 +125,7 @@ export declare function genSavePathFromRule<ME extends AnyObject, P extends Reco
111
125
  startTime: number;
112
126
  liveStartTime: Date;
113
127
  recordStartTime: Date;
128
+ extraMs?: boolean;
114
129
  }): string;
115
130
  export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
116
131
  export { StreamManager };
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) {
@@ -241,7 +249,7 @@ export function createRecorderManager(opts) {
241
249
  await recorder.recordHandle.cut();
242
250
  return recorder;
243
251
  },
244
- autoCheckInterval: opts.autoCheckInterval ?? 1000,
252
+ autoCheckInterval: opts.autoCheckInterval ?? 60000,
245
253
  maxThreadCount: opts.maxThreadCount ?? 3,
246
254
  waitTime: opts.waitTime ?? 0,
247
255
  isCheckLoopRunning: false,
@@ -250,30 +258,60 @@ export function createRecorderManager(opts) {
250
258
  return;
251
259
  this.isCheckLoopRunning = true;
252
260
  // TODO: emit updated event
253
- const checkLoop = async () => {
254
- try {
255
- await multiThreadCheck(this);
256
- }
257
- catch (err) {
258
- this.emit("error", { source: "multiThreadCheck", err });
259
- }
260
- finally {
261
- if (!this.isCheckLoopRunning) {
262
- // 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);
263
268
  }
264
- else {
265
- checkLoopTimer = setTimeout(checkLoop, this.autoCheckInterval);
269
+ catch (err) {
270
+ this.emit("error", { source: "multiThreadCheck", err });
266
271
  }
267
- }
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();
268
298
  };
269
- 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
+ }
270
304
  },
271
305
  stopCheckLoop() {
272
306
  if (!this.isCheckLoopRunning)
273
307
  return;
274
308
  this.isCheckLoopRunning = false;
275
309
  // TODO: emit updated event
276
- clearTimeout(checkLoopTimer);
310
+ // 清理所有 provider 的 timer
311
+ for (const timer of checkLoopTimers.values()) {
312
+ clearTimeout(timer);
313
+ }
314
+ checkLoopTimers.clear();
277
315
  },
278
316
  savePathRule: opts.savePathRule ??
279
317
  path.join(process.cwd(), "{platform}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}"),
@@ -281,6 +319,15 @@ export function createRecorderManager(opts) {
281
319
  biliBatchQuery: opts.biliBatchQuery ?? false,
282
320
  recordRetryImmediately: opts.recordRetryImmediately ?? false,
283
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
+ },
284
331
  ffmpegOutputArgs: opts.ffmpegOutputArgs ??
285
332
  "-c copy" +
286
333
  /**
@@ -334,6 +381,9 @@ export function genSavePathFromRule(manager, recorder, extData) {
334
381
  channelId,
335
382
  };
336
383
  let savePathRule = manager.savePathRule;
384
+ if (extData?.extraMs) {
385
+ savePathRule += "_{ms}";
386
+ }
337
387
  try {
338
388
  savePathRule = ejs.render(savePathRule, params);
339
389
  }
package/lib/recorder.d.ts CHANGED
@@ -93,6 +93,7 @@ export type GetSavePath = (data: {
93
93
  startTime: number;
94
94
  liveStartTime: Date;
95
95
  recordStartTime: Date;
96
+ extraMs?: boolean;
96
97
  }) => string;
97
98
  export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
98
99
  RecordStart: RecordHandle;
@@ -132,6 +133,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
132
133
  cover: string;
133
134
  liveId?: string;
134
135
  recordStartTime: Date;
136
+ area?: string;
135
137
  };
136
138
  tempStopIntervalCheck?: boolean;
137
139
  /** 缓存实例(命名空间) */
@@ -152,6 +154,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
152
154
  channelId: ChannelId;
153
155
  living: boolean;
154
156
  liveStartTime: Date;
157
+ area: string;
155
158
  }>;
156
159
  getStream: (this: Recorder<E>) => Promise<{
157
160
  source: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.13.0",
3
+ "version": "1.16.0",
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
  },