@bililive-tools/manager 1.17.1 → 1.17.3

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/lib/index.js CHANGED
@@ -38,6 +38,7 @@ export function defaultToJSON(provider, recorder) {
38
38
  "liveInfo",
39
39
  "uid",
40
40
  "titleKeywords",
41
+ "segmentOnTitleChange",
41
42
  // "recordHandle",
42
43
  ]),
43
44
  };
package/lib/manager.js CHANGED
@@ -8,6 +8,14 @@ import { getBiliStatusInfoByRoomIds } from "./api.js";
8
8
  import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, replaceFourByteUnicode, } from "./utils.js";
9
9
  import { StreamManager } from "./downloader/streamManager.js";
10
10
  const RECORDER_TIMELINE_LIMIT = 40;
11
+ const SHORT_RECORDING_THRESHOLD = 12 * 1000;
12
+ const SHORT_RECORDING_RETRY_COUNT = 2;
13
+ const SHORT_RECORDING_REASON_BLACKLIST = new Set([
14
+ "manual stop",
15
+ "remove recorder",
16
+ "直播间标题包含关键词",
17
+ "标题变更分段",
18
+ ]);
11
19
  function normalizeTimelineText(text) {
12
20
  return text.trim();
13
21
  }
@@ -63,6 +71,18 @@ function isConfigurableProp(prop) {
63
71
  }
64
72
  export function createRecorderManager(opts) {
65
73
  const recorders = [];
74
+ // 短录制检测仅用于当前进程,不需要持久化。
75
+ const recordAttempts = new Map();
76
+ const shortRecordingStates = new Map();
77
+ const getStreamRetryHint = (recorder) => {
78
+ const state = shortRecordingStates.get(recorder.id);
79
+ if (!state?.preferAlternativeStream)
80
+ return undefined;
81
+ return {
82
+ liveId: state.liveId,
83
+ preferAlternativeStream: true,
84
+ };
85
+ };
66
86
  // 存储每个 provider 的 timer,key 为 providerId
67
87
  const checkLoopTimers = new Map();
68
88
  const multiThreadCheck = async (manager, providerId, providerConfig) => {
@@ -78,6 +98,7 @@ export function createRecorderManager(opts) {
78
98
  return genSavePathFromRule(manager, recorder, data);
79
99
  },
80
100
  banLiveId: tempBanObj[recorder.channelId],
101
+ streamRetryHint: getStreamRetryHint(recorder),
81
102
  });
82
103
  }
83
104
  }
@@ -120,6 +141,7 @@ export function createRecorderManager(opts) {
120
141
  return genSavePathFromRule(manager, recorder, data);
121
142
  },
122
143
  banLiveId,
144
+ streamRetryHint: getStreamRetryHint(recorder),
123
145
  });
124
146
  };
125
147
  threads.push(...range(0, providerConfig.maxThreadCount).map(async () => {
@@ -170,6 +192,21 @@ export function createRecorderManager(opts) {
170
192
  ensureRecorderTimeline(recorder);
171
193
  this.recorders.push(recorder);
172
194
  recorder.on("RecordStart", (recordHandle) => {
195
+ const liveId = recorder.liveInfo?.liveId;
196
+ recordAttempts.set(recordHandle.id, {
197
+ recorderId: recorder.id,
198
+ liveId,
199
+ startedAt: Date.now(),
200
+ });
201
+ const shortRecordingState = shortRecordingStates.get(recorder.id);
202
+ if (shortRecordingState) {
203
+ if (shortRecordingState.liveId === liveId) {
204
+ shortRecordingState.preferAlternativeStream = false;
205
+ }
206
+ else {
207
+ shortRecordingStates.delete(recorder.id);
208
+ }
209
+ }
173
210
  recorder.appendTimeline?.({
174
211
  startTime: recorder.liveInfo?.recordStartTime?.getTime() ?? Date.now(),
175
212
  text: `开始录制:${recordHandle.stream}/${recordHandle.source}/${recordHandle.recorderType}/${recordHandle.url}`,
@@ -199,10 +236,36 @@ export function createRecorderManager(opts) {
199
236
  recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
200
237
  recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
201
238
  recorder.on("RecordStop", ({ recordHandle, reason }) => {
239
+ const stoppedAt = Date.now();
202
240
  recorder.appendTimeline?.({
203
- startTime: Date.now(),
241
+ startTime: stoppedAt,
204
242
  text: reason ? `停止录制:${reason}` : "停止录制",
205
243
  });
244
+ const attempt = recordAttempts.get(recordHandle.id);
245
+ recordAttempts.delete(recordHandle.id);
246
+ if (attempt?.recorderId === recorder.id && attempt.liveId) {
247
+ const isReasonBlacklisted = reason != null && SHORT_RECORDING_REASON_BLACKLIST.has(reason);
248
+ const duration = stoppedAt - attempt.startedAt;
249
+ const previousState = shortRecordingStates.get(recorder.id);
250
+ if (isReasonBlacklisted || duration >= SHORT_RECORDING_THRESHOLD) {
251
+ shortRecordingStates.delete(recorder.id);
252
+ }
253
+ else {
254
+ const count = previousState?.liveId === attempt.liveId ? previousState.count + 1 : 1;
255
+ const preferAlternativeStream = count >= SHORT_RECORDING_RETRY_COUNT;
256
+ shortRecordingStates.set(recorder.id, {
257
+ liveId: attempt.liveId,
258
+ count,
259
+ preferAlternativeStream,
260
+ });
261
+ if (preferAlternativeStream && !previousState?.preferAlternativeStream) {
262
+ recorder.appendTimeline?.({
263
+ startTime: stoppedAt,
264
+ text: `连续 ${SHORT_RECORDING_RETRY_COUNT} 次录制在 ${SHORT_RECORDING_THRESHOLD / 1000} 秒内结束,下次可能尝试切换流格式(仅抖音)`,
265
+ });
266
+ }
267
+ }
268
+ }
206
269
  this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
207
270
  const maxRetryCount = 10;
208
271
  // 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
@@ -241,6 +304,7 @@ export function createRecorderManager(opts) {
241
304
  getSavePath(data) {
242
305
  return genSavePathFromRule(manager, recorder, data);
243
306
  },
307
+ streamRetryHint: getStreamRetryHint(recorder),
244
308
  });
245
309
  }, 1000);
246
310
  }
@@ -296,6 +360,11 @@ export function createRecorderManager(opts) {
296
360
  if (k.startsWith(keyPrefix))
297
361
  delete chargeLiveObj[k];
298
362
  });
363
+ shortRecordingStates.delete(recorder.id);
364
+ for (const [recordHandleId, attempt] of recordAttempts) {
365
+ if (attempt.recorderId === recorder.id)
366
+ recordAttempts.delete(recordHandleId);
367
+ }
299
368
  this.emit("RecorderRemoved", recorder.toJSON());
300
369
  },
301
370
  getRecorder(id) {
@@ -317,6 +386,7 @@ export function createRecorderManager(opts) {
317
386
  return genSavePathFromRule(manager, recorder, data);
318
387
  },
319
388
  isManualStart: true,
389
+ streamRetryHint: getStreamRetryHint(recorder),
320
390
  });
321
391
  delete tempBanObj[recorder.channelId];
322
392
  recorder.tempStopIntervalCheck = false;
package/lib/recorder.d.ts CHANGED
@@ -52,6 +52,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
52
52
  * 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
53
53
  */
54
54
  titleKeywords?: string;
55
+ /** B站直播间标题变更时分段 */
56
+ segmentOnTitleChange?: boolean;
55
57
  /** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
56
58
  videoFormat?: "auto" | "ts" | "mkv" | "flv";
57
59
  /** 录制类型 */
@@ -70,7 +72,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
70
72
  /** 调试等级 */
71
73
  debugLevel?: "none" | "basic" | "verbose";
72
74
  }
73
- export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "convert2Mp4" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords">;
75
+ export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "convert2Mp4" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords" | "segmentOnTitleChange">;
74
76
  /** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单,charge-skipped: 充电直播(DRM加密)已跳过 */
75
77
  export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked" | "charge-skipped";
76
78
  export type Progress = {
@@ -85,6 +87,10 @@ export interface AppendRecorderTimelineArgs {
85
87
  startTime?: number;
86
88
  text: string;
87
89
  }
90
+ export interface StreamRetryHint {
91
+ liveId: string;
92
+ preferAlternativeStream: boolean;
93
+ }
88
94
  export interface RecordHandle {
89
95
  id: string;
90
96
  stream: string;
@@ -170,6 +176,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
170
176
  getSavePath: GetSavePath;
171
177
  banLiveId?: string;
172
178
  isManualStart?: boolean;
179
+ /** 上一次使用的流疑似不可用,录制器可在同一场直播中优先尝试备用流 */
180
+ streamRetryHint?: StreamRetryHint;
173
181
  }) => Promise<RecordHandle | null>;
174
182
  recordHandle?: RecordHandle;
175
183
  toJSON: (this: Recorder<E>) => SerializedRecorder<E>;
package/lib/utils.d.ts CHANGED
@@ -61,6 +61,8 @@ export declare function createTimeoutChecker(onTimeout: () => void, time: number
61
61
  start: () => void;
62
62
  };
63
63
  export declare function downloadImage(imageUrl: string, savePath: string): Promise<void>;
64
+ declare const md5: (str: string) => string;
65
+ declare const uuid: () => `${string}-${string}-${string}-${string}-${string}`;
64
66
  /**
65
67
  * 根据指定的顺序对对象数组进行排序
66
68
  * @param objects 要排序的对象数组
@@ -133,15 +135,15 @@ declare const _default: {
133
135
  createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
134
136
  createTimeoutChecker: typeof createTimeoutChecker;
135
137
  downloadImage: typeof downloadImage;
136
- md5: (str: string) => string;
137
- uuid: () => `${string}-${string}-${string}-${string}-${string}`;
138
+ md5: typeof md5;
139
+ uuid: typeof uuid;
138
140
  sortByKeyOrder: typeof sortByKeyOrder;
139
141
  retry: typeof retry;
140
- isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
142
+ isBetweenTimeRange: typeof isBetweenTimeRange;
141
143
  hasBlockedTitleKeywords: typeof hasBlockedTitleKeywords;
142
144
  shouldCheckTitleKeywords: typeof shouldCheckTitleKeywords;
143
145
  shouldUseStrictQuality: typeof shouldUseStrictQuality;
144
- sleep: (ms: number) => Promise<unknown>;
146
+ sleep: typeof sleep;
145
147
  checkTitleKeywordsWhileRecording: typeof checkTitleKeywordsWhileRecording;
146
148
  checkTitleKeywordsBeforeRecord: typeof checkTitleKeywordsBeforeRecord;
147
149
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.17.1",
3
+ "version": "1.17.3",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",