@bililive-tools/manager 1.17.0 → 1.17.2

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.
@@ -4,6 +4,12 @@ import type { FormatName } from "./index.js";
4
4
  import type { XmlStreamController } from "../xml_stream_controller.js";
5
5
  import type { VideoFileCompletedPayload } from "./streamManager.js";
6
6
  export type Segment = number | string | undefined;
7
+ export interface VideoFileCreatedPayload {
8
+ filename: string;
9
+ cover?: string;
10
+ rawFilename?: string;
11
+ title?: string;
12
+ }
7
13
  /**
8
14
  * 录制器构造函数选项的基础接口
9
15
  */
@@ -51,12 +57,7 @@ export interface IDownloader extends EventEmitter {
51
57
  getExtraDataController(): XmlStreamController | null;
52
58
  createCommand(): any;
53
59
  get videoFilePath(): string;
54
- on(event: "videoFileCreated", listener: (data: {
55
- filename: string;
56
- cover?: string;
57
- rawFilename?: string;
58
- title?: string;
59
- }) => void): this;
60
+ on(event: "videoFileCreated", listener: (data: VideoFileCreatedPayload) => void): this;
60
61
  on(event: "videoFileCompleted", listener: (data: VideoFileCompletedPayload) => void): this;
61
62
  on(event: "DebugLog", listener: (data: {
62
63
  type: string;
@@ -64,12 +65,7 @@ export interface IDownloader extends EventEmitter {
64
65
  }) => void): this;
65
66
  on(event: "progress", listener: (info: any) => void): this;
66
67
  on(event: string, listener: (...args: any[]) => void): this;
67
- emit(event: "videoFileCreated", data: {
68
- filename: string;
69
- cover?: string;
70
- rawFilename?: string;
71
- title?: string;
72
- }): boolean;
68
+ emit(event: "videoFileCreated", data: VideoFileCreatedPayload): boolean;
73
69
  emit(event: "videoFileCompleted", data: VideoFileCompletedPayload): boolean;
74
70
  emit(event: "DebugLog", data: {
75
71
  type: string;
@@ -146,6 +146,7 @@ export class StreamManager extends EventEmitter {
146
146
  recordSavePath = getSavePath({ startTime, extraMs: true });
147
147
  }
148
148
  this.recordSavePath = recordSavePath;
149
+ ensureFolderExist(recordSavePath);
149
150
  if (hasSegment) {
150
151
  this.segment = new Segment(getSavePath, disableDanma, this.videoExt, {
151
152
  firstStartTime: startTime,
@@ -161,7 +162,6 @@ export class StreamManager extends EventEmitter {
161
162
  });
162
163
  }
163
164
  else {
164
- ensureFolderExist(recordSavePath);
165
165
  const extraDataSavePath = `${recordSavePath}.xml`;
166
166
  if (!disableDanma) {
167
167
  this.extraDataController = createRecordExtraDataController(extraDataSavePath);
package/lib/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./manager.js";
9
9
  export * from "./cache.js";
10
10
  export * from "./downloader/FFmpegDownloader.js";
11
11
  export { createDownloader } from "./downloader/index.js";
12
+ export type { VideoFileCreatedPayload } from "./downloader/IDownloader.js";
12
13
  export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
13
14
  export { utils };
14
15
  /**
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.d.ts CHANGED
@@ -13,12 +13,16 @@ export interface ProviderCheckConfig {
13
13
  /** 每次检查之间的等待时间(毫秒) */
14
14
  waitTime?: number;
15
15
  }
16
+ export interface ResolveChannelOptions {
17
+ /** 解析频道信息时使用的网络代理 */
18
+ proxy?: string;
19
+ }
16
20
  export interface RecorderProvider<E extends AnyObject> {
17
21
  id: string;
18
22
  name: string;
19
23
  siteURL: string;
20
24
  matchURL: (this: RecorderProvider<E>, channelURL: string) => boolean;
21
- resolveChannelInfoFromURL: (this: RecorderProvider<E>, channelURL: string) => Promise<{
25
+ resolveChannelInfoFromURL: (this: RecorderProvider<E>, channelURL: string, options?: ResolveChannelOptions) => Promise<{
22
26
  id: ChannelId;
23
27
  title: string;
24
28
  owner: string;
@@ -62,6 +66,9 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
62
66
  RecoderLiveStart: {
63
67
  recorder: Recorder<E>;
64
68
  };
69
+ RecoderChargeLive: {
70
+ recorder: Recorder<E>;
71
+ };
65
72
  RecordStop: {
66
73
  recorder: SerializedRecorder<E>;
67
74
  recordHandle: RecordHandle;
package/lib/manager.js CHANGED
@@ -5,7 +5,7 @@ import { omit, range } from "lodash-es";
5
5
  import { parseArgsStringToArgv } from "string-argv";
6
6
  import { RecorderCacheImpl, MemoryCacheStore } from "./cache.js";
7
7
  import { getBiliStatusInfoByRoomIds } from "./api.js";
8
- import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, } from "./utils.js";
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
11
  function normalizeTimelineText(text) {
@@ -43,6 +43,7 @@ function getStateTimelineText(state, msg) {
43
43
  "stopping-record": "停止录制中",
44
44
  "check-error": "检查失败",
45
45
  "title-blocked": "标题命中过滤规则",
46
+ "charge-skipped": "特殊直播(付费/权限/加密),无法录制,已跳过",
46
47
  };
47
48
  return stateTextMap[state];
48
49
  }
@@ -142,6 +143,8 @@ export function createRecorderManager(opts) {
142
143
  const tempBanObj = {};
143
144
  // 用于是否触发LiveStart事件,不要重复触发
144
145
  const liveStartObj = {};
146
+ // 用于是否触发ChargeLive(充电直播)事件,按场次去重,不要每个检查周期重复触发
147
+ const chargeLiveObj = {};
145
148
  // 用于记录触发重试直播场次的次数
146
149
  const retryCountObj = {};
147
150
  const manager = {
@@ -254,6 +257,15 @@ export function createRecorderManager(opts) {
254
257
  liveStartObj[key] = true;
255
258
  this.emit("RecoderLiveStart", { recorder: recorder });
256
259
  });
260
+ recorder.on("ChargeLive", () => {
261
+ // 充电直播按场次去重,避免每个检查周期重复推送
262
+ const liveId = recorder.liveInfo?.liveId;
263
+ const key = `${recorder.channelId}-${liveId ?? ""}`;
264
+ if (chargeLiveObj[key])
265
+ return;
266
+ chargeLiveObj[key] = true;
267
+ this.emit("RecoderChargeLive", { recorder: recorder });
268
+ });
257
269
  recorder.on("stateChange", ({ state, msg }) => {
258
270
  recorder.state = state;
259
271
  if (state !== "idle") {
@@ -274,6 +286,16 @@ export function createRecorderManager(opts) {
274
286
  recorder.recordHandle?.stop("remove recorder");
275
287
  this.recorders.splice(idx, 1);
276
288
  delete tempBanObj[recorder.channelId];
289
+ // 清理该房间的 liveStart/chargeLive 去重标记,否则删除后重新添加同一场直播不会再次推送通知
290
+ const keyPrefix = `${recorder.channelId}-`;
291
+ Object.keys(liveStartObj).forEach((k) => {
292
+ if (k.startsWith(keyPrefix))
293
+ delete liveStartObj[k];
294
+ });
295
+ Object.keys(chargeLiveObj).forEach((k) => {
296
+ if (k.startsWith(keyPrefix))
297
+ delete chargeLiveObj[k];
298
+ });
277
299
  this.emit("RecorderRemoved", recorder.toJSON());
278
300
  },
279
301
  getRecorder(id) {
@@ -444,6 +466,7 @@ export function genSavePathFromRule(manager, recorder, extData) {
444
466
  title: title,
445
467
  remarks: remarks,
446
468
  channelId,
469
+ replaceFourByteUnicode,
447
470
  };
448
471
  let savePathRule = manager.savePathRule;
449
472
  if (extData?.extraMs) {
package/lib/recorder.d.ts CHANGED
@@ -5,8 +5,8 @@ import { AnyObject, PickRequired, UnknownObject } from "./utils.js";
5
5
  import type { NamespacedCache } from "./cache.js";
6
6
  import type { DownloaderType } from "./downloader/index.js";
7
7
  import type { XmlStreamStats } from "./xml_stream_controller.js";
8
- type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
9
- type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
8
+ export type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
9
+ export type CodecName = "auto" | "avc" | "hevc" | "av1" | "avc_only" | "hevc_only" | "av1_only";
10
10
  export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
11
11
  providerId: RecorderProvider<E>["id"];
12
12
  channelId: ChannelId;
@@ -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,9 +72,9 @@ 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">;
74
- /** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单 */
75
- export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked";
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">;
76
+ /** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单,charge-skipped: 充电直播(DRM加密)已跳过 */
77
+ export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked" | "charge-skipped";
76
78
  export type Progress = {
77
79
  time: string | null;
78
80
  };
@@ -133,6 +135,9 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
133
135
  Updated: (string | keyof Recorder)[];
134
136
  Message: Message;
135
137
  DebugLog: DebugLog;
138
+ ChargeLive: {
139
+ channelId: string;
140
+ };
136
141
  }>, RecorderCreateOpts<E> {
137
142
  id: string;
138
143
  extra: Partial<E>;
@@ -153,6 +158,9 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
153
158
  liveId?: string;
154
159
  recordStartTime: Date;
155
160
  area?: string;
161
+ liveType?: string;
162
+ liveTypeDesc?: string;
163
+ canRecord?: boolean;
156
164
  };
157
165
  tempStopIntervalCheck?: boolean;
158
166
  timeline?: RecorderTimelineItem[];
@@ -177,10 +185,12 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
177
185
  liveStartTime: Date;
178
186
  area: string;
179
187
  }>;
180
- getStream: (this: Recorder<E>) => Promise<{
188
+ getStream: (this: Recorder<E>, opts?: {
189
+ formatName?: FormatName;
190
+ formatPriorities?: Array<"flv" | "hls">;
191
+ }) => Promise<{
181
192
  source: string;
182
193
  name: string;
183
194
  url: string;
184
195
  }>;
185
196
  }
186
- export {};
package/lib/utils.d.ts CHANGED
@@ -36,6 +36,10 @@ export declare function assertNumberType(data: unknown, msg?: string): asserts d
36
36
  export declare function assertObjectType(data: unknown, msg?: string): asserts data is object;
37
37
  export declare function formatDate(date: Date, format: string): string;
38
38
  export declare function removeSystemReservedChars(str: string): string;
39
+ /**
40
+ * 替换四字节 Unicode 字符(如部分 emoji 表情)为指定字符。
41
+ */
42
+ export declare function replaceFourByteUnicode(value: unknown, replacement?: string): string;
39
43
  export declare function isFfmpegStartSegment(line: string): boolean;
40
44
  export declare function isMesioStartSegment(line: string): boolean;
41
45
  export declare function isBililiveStartSegment(line: string): boolean;
@@ -57,6 +61,8 @@ export declare function createTimeoutChecker(onTimeout: () => void, time: number
57
61
  start: () => void;
58
62
  };
59
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}`;
60
66
  /**
61
67
  * 根据指定的顺序对对象数组进行排序
62
68
  * @param objects 要排序的对象数组
@@ -125,18 +131,19 @@ declare const _default: {
125
131
  assertObjectType: typeof assertObjectType;
126
132
  asyncThrottle: typeof asyncThrottle;
127
133
  isFfmpegStartSegment: typeof isFfmpegStartSegment;
134
+ replaceFourByteUnicode: typeof replaceFourByteUnicode;
128
135
  createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
129
136
  createTimeoutChecker: typeof createTimeoutChecker;
130
137
  downloadImage: typeof downloadImage;
131
- md5: (str: string) => string;
132
- uuid: () => `${string}-${string}-${string}-${string}-${string}`;
138
+ md5: typeof md5;
139
+ uuid: typeof uuid;
133
140
  sortByKeyOrder: typeof sortByKeyOrder;
134
141
  retry: typeof retry;
135
- isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
142
+ isBetweenTimeRange: typeof isBetweenTimeRange;
136
143
  hasBlockedTitleKeywords: typeof hasBlockedTitleKeywords;
137
144
  shouldCheckTitleKeywords: typeof shouldCheckTitleKeywords;
138
145
  shouldUseStrictQuality: typeof shouldUseStrictQuality;
139
- sleep: (ms: number) => Promise<unknown>;
146
+ sleep: typeof sleep;
140
147
  checkTitleKeywordsWhileRecording: typeof checkTitleKeywordsWhileRecording;
141
148
  checkTitleKeywordsBeforeRecord: typeof checkTitleKeywordsBeforeRecord;
142
149
  };
package/lib/utils.js CHANGED
@@ -124,6 +124,12 @@ export function formatDate(date, format) {
124
124
  export function removeSystemReservedChars(str) {
125
125
  return filenamify(str, { replacement: "_" });
126
126
  }
127
+ /**
128
+ * 替换四字节 Unicode 字符(如部分 emoji 表情)为指定字符。
129
+ */
130
+ export function replaceFourByteUnicode(value, replacement = "_") {
131
+ return String(value ?? "").replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, replacement);
132
+ }
127
133
  export function isFfmpegStartSegment(line) {
128
134
  return line.includes("Opening ") && line.includes("for writing");
129
135
  }
@@ -507,6 +513,7 @@ export default {
507
513
  assertObjectType,
508
514
  asyncThrottle,
509
515
  isFfmpegStartSegment,
516
+ replaceFourByteUnicode,
510
517
  createFFmpegInvalidStreamChecker,
511
518
  createTimeoutChecker,
512
519
  downloadImage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.17.0",
3
+ "version": "1.17.2",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -33,14 +33,17 @@
33
33
  "license": "LGPL",
34
34
  "dependencies": {
35
35
  "@renmu/fluent-ffmpeg": "2.3.3",
36
- "fast-xml-parser": "^4.5.0",
37
- "filenamify": "^7.0.0",
38
- "mitt": "^3.0.1",
39
- "string-argv": "^0.3.2",
40
- "lodash-es": "^4.17.21",
41
36
  "axios": "^1.15.0",
37
+ "ejs": "^3.1.10",
38
+ "fast-xml-parser": "^5.9.3",
39
+ "filenamify": "^7.0.0",
42
40
  "fs-extra": "^11.2.0",
43
- "ejs": "^3.1.10"
41
+ "lodash-es": "^4.17.21",
42
+ "mitt": "^3.0.1",
43
+ "string-argv": "^0.3.2"
44
+ },
45
+ "devDependencies": {
46
+ "@types/ejs": "^3.1.5"
44
47
  },
45
48
  "scripts": {
46
49
  "build": "pnpm run test && tsc",