@bililive-tools/manager 1.16.2 → 1.17.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.
@@ -34,6 +34,7 @@ export declare class BililiveDownloader extends EventEmitter implements IDownloa
34
34
  readonly disableDanma: boolean;
35
35
  readonly url: string;
36
36
  readonly debugLevel: "none" | "basic" | "verbose";
37
+ readonly proxy?: string;
37
38
  readonly headers: {
38
39
  [key: string]: string | undefined;
39
40
  } | undefined;
@@ -109,6 +109,7 @@ export class BililiveDownloader extends EventEmitter {
109
109
  disableDanma = false;
110
110
  url;
111
111
  debugLevel = "none";
112
+ proxy;
112
113
  headers;
113
114
  constructor(opts, onEnd, onUpdateLiveInfo) {
114
115
  super();
@@ -119,6 +120,7 @@ export class BililiveDownloader extends EventEmitter {
119
120
  this.hasSegment = hasSegment;
120
121
  this.disableDanma = opts.disableDanma ?? false;
121
122
  this.debugLevel = opts.debugLevel ?? "none";
123
+ this.proxy = opts.proxy;
122
124
  let videoFormat = "flv";
123
125
  this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "bililive", videoFormat, {
124
126
  onUpdateLiveInfo: this.onUpdateLiveInfo,
@@ -153,6 +155,9 @@ export class BililiveDownloader extends EventEmitter {
153
155
  if (this.debugLevel === "verbose") {
154
156
  inputOptions.push("-l", "Debug");
155
157
  }
158
+ if (this.proxy) {
159
+ inputOptions.push(...["--proxy", this.proxy]);
160
+ }
156
161
  if (this.headers) {
157
162
  Object.entries(this.headers).forEach(([key, value]) => {
158
163
  if (!value)
@@ -23,6 +23,7 @@ export declare class FFmpegDownloader extends EventEmitter implements IDownloade
23
23
  formatName: FormatName;
24
24
  videoFormat: VideoFormat;
25
25
  readonly debugLevel: "none" | "basic" | "verbose";
26
+ readonly proxy?: string;
26
27
  readonly headers: {
27
28
  [key: string]: string | undefined;
28
29
  } | undefined;
@@ -20,6 +20,7 @@ export class FFmpegDownloader extends EventEmitter {
20
20
  formatName;
21
21
  videoFormat;
22
22
  debugLevel = "none";
23
+ proxy;
23
24
  headers;
24
25
  constructor(opts, onEnd, onUpdateLiveInfo) {
25
26
  super();
@@ -63,6 +64,7 @@ export class FFmpegDownloader extends EventEmitter {
63
64
  this.url = opts.url;
64
65
  this.segment = opts.segment;
65
66
  this.headers = opts.headers;
67
+ this.proxy = opts.proxy;
66
68
  this.command = this.createCommand();
67
69
  this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
68
70
  this.emit("videoFileCreated", { filename, cover, rawFilename, title });
@@ -86,6 +88,9 @@ export class FFmpegDownloader extends EventEmitter {
86
88
  if (this.isHls) {
87
89
  inputOptions.push(...["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "3"]);
88
90
  }
91
+ if (this.proxy) {
92
+ inputOptions.push(...["-http_proxy", this.proxy]);
93
+ }
89
94
  if (this.debugLevel === "verbose") {
90
95
  inputOptions.push("-loglevel", "debug");
91
96
  }
@@ -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
  */
@@ -23,6 +29,7 @@ export interface BaseRecorderOptions {
23
29
  [key: string]: string | undefined;
24
30
  };
25
31
  videoFormat?: VideoFormat;
32
+ proxy?: string;
26
33
  }
27
34
  /**
28
35
  * 录制器接口定义
@@ -37,6 +44,7 @@ export interface IDownloader extends EventEmitter {
37
44
  readonly headers: {
38
45
  [key: string]: string | undefined;
39
46
  } | undefined;
47
+ readonly proxy?: string;
40
48
  readonly getSavePath: (data: {
41
49
  startTime: number;
42
50
  title?: string;
@@ -49,12 +57,7 @@ export interface IDownloader extends EventEmitter {
49
57
  getExtraDataController(): XmlStreamController | null;
50
58
  createCommand(): any;
51
59
  get videoFilePath(): string;
52
- on(event: "videoFileCreated", listener: (data: {
53
- filename: string;
54
- cover?: string;
55
- rawFilename?: string;
56
- title?: string;
57
- }) => void): this;
60
+ on(event: "videoFileCreated", listener: (data: VideoFileCreatedPayload) => void): this;
58
61
  on(event: "videoFileCompleted", listener: (data: VideoFileCompletedPayload) => void): this;
59
62
  on(event: "DebugLog", listener: (data: {
60
63
  type: string;
@@ -62,12 +65,7 @@ export interface IDownloader extends EventEmitter {
62
65
  }) => void): this;
63
66
  on(event: "progress", listener: (info: any) => void): this;
64
67
  on(event: string, listener: (...args: any[]) => void): this;
65
- emit(event: "videoFileCreated", data: {
66
- filename: string;
67
- cover?: string;
68
- rawFilename?: string;
69
- title?: string;
70
- }): boolean;
68
+ emit(event: "videoFileCreated", data: VideoFileCreatedPayload): boolean;
71
69
  emit(event: "videoFileCompleted", data: VideoFileCompletedPayload): boolean;
72
70
  emit(event: "DebugLog", data: {
73
71
  type: string;
@@ -31,6 +31,7 @@ export declare class mesioDownloader extends EventEmitter implements IDownloader
31
31
  readonly disableDanma: boolean;
32
32
  readonly url: string;
33
33
  readonly debugLevel: "none" | "basic" | "verbose";
34
+ readonly proxy?: string;
34
35
  readonly headers: {
35
36
  [key: string]: string | undefined;
36
37
  } | undefined;
@@ -99,6 +99,7 @@ export class mesioDownloader extends EventEmitter {
99
99
  disableDanma = false;
100
100
  url;
101
101
  debugLevel = "none";
102
+ proxy;
102
103
  headers;
103
104
  constructor(opts, onEnd, onUpdateLiveInfo) {
104
105
  super();
@@ -109,6 +110,7 @@ export class mesioDownloader extends EventEmitter {
109
110
  this.hasSegment = hasSegment;
110
111
  this.disableDanma = opts.disableDanma ?? false;
111
112
  this.debugLevel = opts.debugLevel ?? "none";
113
+ this.proxy = opts.proxy;
112
114
  let videoFormat = "flv";
113
115
  if (opts.url.includes(".m3u8")) {
114
116
  videoFormat = "ts";
@@ -145,7 +147,29 @@ export class mesioDownloader extends EventEmitter {
145
147
  });
146
148
  }
147
149
  createCommand() {
148
- const inputOptions = [...this.inputOptions, "--fix", "--no-proxy", "--disable-log-file"];
150
+ const inputOptions = [...this.inputOptions, "--fix", "--disable-log-file"];
151
+ if (this.proxy) {
152
+ try {
153
+ const { protocol, username, password, origin, search, pathname } = new URL(this.proxy);
154
+ if (protocol.startsWith("https")) {
155
+ inputOptions.push("--proxy-type", "https");
156
+ }
157
+ else if (protocol.startsWith("socks5")) {
158
+ inputOptions.push("--proxy-type", "socks5");
159
+ }
160
+ if (username && password) {
161
+ inputOptions.push("--proxy-user", `${username}`);
162
+ inputOptions.push("--proxy-pass", `${password}`);
163
+ }
164
+ inputOptions.push("--proxy", origin + pathname + search);
165
+ }
166
+ catch (err) {
167
+ this.emit("DebugLog", { type: "error", text: `Invalid proxy URL: ${this.proxy}` });
168
+ }
169
+ }
170
+ else {
171
+ inputOptions.push("--no-proxy");
172
+ }
149
173
  if (this.debugLevel === "verbose") {
150
174
  inputOptions.push("-v");
151
175
  }
@@ -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/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,8 +5,48 @@ 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
+ const RECORDER_TIMELINE_LIMIT = 40;
11
+ function normalizeTimelineText(text) {
12
+ return text.trim();
13
+ }
14
+ function appendRecorderTimeline(recorder, args) {
15
+ const text = normalizeTimelineText(args.text);
16
+ if (!text) {
17
+ return recorder.timeline ?? [];
18
+ }
19
+ const startTime = args.startTime ?? Date.now();
20
+ const timeline = recorder.timeline ?? [];
21
+ const lastItem = timeline[timeline.length - 1];
22
+ if (lastItem && lastItem.text === text) {
23
+ lastItem.endTime = Math.max(lastItem.endTime ?? lastItem.startTime, startTime);
24
+ recorder.timeline = [...timeline];
25
+ return recorder.timeline;
26
+ }
27
+ const nextTimeline = [...timeline, { startTime, text }].slice(-RECORDER_TIMELINE_LIMIT);
28
+ recorder.timeline = nextTimeline;
29
+ return nextTimeline;
30
+ }
31
+ function ensureRecorderTimeline(recorder) {
32
+ recorder.timeline = Array.isArray(recorder.timeline) ? recorder.timeline : [];
33
+ recorder.appendTimeline = (args) => appendRecorderTimeline(recorder, args);
34
+ }
35
+ function getStateTimelineText(state, msg) {
36
+ const text = normalizeTimelineText(msg ?? "");
37
+ if (text) {
38
+ return text;
39
+ }
40
+ const stateTextMap = {
41
+ idle: "空闲中",
42
+ recording: "录制中",
43
+ "stopping-record": "停止录制中",
44
+ "check-error": "检查失败",
45
+ "title-blocked": "标题命中过滤规则",
46
+ "charge-skipped": "特殊直播(付费/权限/加密),无法录制,已跳过",
47
+ };
48
+ return stateTextMap[state];
49
+ }
10
50
  const configurableProps = [
11
51
  "savePathRule",
12
52
  "autoRemoveSystemReservedChars",
@@ -25,7 +65,7 @@ export function createRecorderManager(opts) {
25
65
  const recorders = [];
26
66
  // 存储每个 provider 的 timer,key 为 providerId
27
67
  const checkLoopTimers = new Map();
28
- const multiThreadCheck = async (manager, providerId) => {
68
+ const multiThreadCheck = async (manager, providerId, providerConfig) => {
29
69
  const handleBatchQuery = async (obj) => {
30
70
  for (const recorder of recorders
31
71
  .filter((r) => !r.disableAutoCheck)
@@ -48,7 +88,6 @@ export function createRecorderManager(opts) {
48
88
  .filter((r) => !r.disableAutoCheck)
49
89
  .filter((r) => isBetweenTimeRange(r.handleTime))
50
90
  .filter((r) => r.providerId === providerId);
51
- const providerConfig = manager.getProviderCheckConfig(providerId);
52
91
  const threads = [];
53
92
  // Bilibili 批量查询特殊处理
54
93
  if (providerId === "Bilibili" && manager.biliBatchQuery) {
@@ -87,13 +126,15 @@ export function createRecorderManager(opts) {
87
126
  while (needCheckRecorders.length > 0) {
88
127
  try {
89
128
  await checkOnce();
90
- if (providerConfig.waitTime > 0) {
91
- await sleep(providerConfig.waitTime);
92
- }
93
129
  }
94
130
  catch (err) {
95
131
  manager.emit("error", { source: "checkOnceInThread", err });
96
132
  }
133
+ finally {
134
+ if (providerConfig.waitTime > 0) {
135
+ await sleep(providerConfig.waitTime);
136
+ }
137
+ }
97
138
  }
98
139
  }));
99
140
  await Promise.all(threads);
@@ -102,6 +143,8 @@ export function createRecorderManager(opts) {
102
143
  const tempBanObj = {};
103
144
  // 用于是否触发LiveStart事件,不要重复触发
104
145
  const liveStartObj = {};
146
+ // 用于是否触发ChargeLive(充电直播)事件,按场次去重,不要每个检查周期重复触发
147
+ const chargeLiveObj = {};
105
148
  // 用于记录触发重试直播场次的次数
106
149
  const retryCountObj = {};
107
150
  const manager = {
@@ -124,21 +167,42 @@ export function createRecorderManager(opts) {
124
167
  });
125
168
  // 为录制器注入独立的缓存命名空间
126
169
  recorder.cache = this.cache.createNamespace(recorder.id);
170
+ ensureRecorderTimeline(recorder);
127
171
  this.recorders.push(recorder);
128
- recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle }));
172
+ recorder.on("RecordStart", (recordHandle) => {
173
+ recorder.appendTimeline?.({
174
+ startTime: recorder.liveInfo?.recordStartTime?.getTime() ?? Date.now(),
175
+ text: `开始录制:${recordHandle.stream}/${recordHandle.source}/${recordHandle.recorderType}/${recordHandle.url}`,
176
+ });
177
+ this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle });
178
+ });
129
179
  recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
130
180
  recorder.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
131
181
  if (recorder.saveCover && recorder?.liveInfo?.cover) {
132
182
  const coverPath = replaceExtName(filename, ".jpg");
133
183
  downloadImage(cover ?? recorder?.liveInfo?.cover, coverPath);
134
184
  }
185
+ recorder.appendTimeline?.({
186
+ startTime: Date.now(),
187
+ text: `视频文件创建:${filename}`,
188
+ });
135
189
  this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename, rawFilename });
136
190
  });
137
- recorder.on("videoFileCompleted", ({ filename, stats }) => this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename, stats }));
191
+ recorder.on("videoFileCompleted", ({ filename, stats }) => {
192
+ recorder.appendTimeline?.({
193
+ startTime: Date.now(),
194
+ text: `视频文件创建完成:${filename}`,
195
+ });
196
+ this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename, stats });
197
+ });
138
198
  recorder.on("Message", (message) => this.emit("Message", { recorder: recorder.toJSON(), message }));
139
199
  recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
140
200
  recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
141
201
  recorder.on("RecordStop", ({ recordHandle, reason }) => {
202
+ recorder.appendTimeline?.({
203
+ startTime: Date.now(),
204
+ text: reason ? `停止录制:${reason}` : "停止录制",
205
+ });
142
206
  this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
143
207
  const maxRetryCount = 10;
144
208
  // 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
@@ -167,6 +231,10 @@ export function createRecorderManager(opts) {
167
231
  type: "common",
168
232
  text: `录制${recorder.channelId}中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
169
233
  });
234
+ recorder.appendTimeline?.({
235
+ startTime: Date.now(),
236
+ text: `录制中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
237
+ });
170
238
  // 触发一次检查,等待一秒使状态清理完毕
171
239
  setTimeout(() => {
172
240
  recorder.checkLiveStatusAndRecord({
@@ -189,6 +257,24 @@ export function createRecorderManager(opts) {
189
257
  liveStartObj[key] = true;
190
258
  this.emit("RecoderLiveStart", { recorder: recorder });
191
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
+ });
269
+ recorder.on("stateChange", ({ state, msg }) => {
270
+ recorder.state = state;
271
+ if (state !== "idle") {
272
+ recorder.appendTimeline?.({
273
+ startTime: Date.now(),
274
+ text: `状态变更:${getStateTimelineText(state, msg)}`,
275
+ });
276
+ }
277
+ });
192
278
  this.emit("RecorderAdded", recorder.toJSON());
193
279
  // startCheckLoop 会为所有注册的 provider 启动检查循环,无需在此处额外处理
194
280
  return recorder;
@@ -200,6 +286,16 @@ export function createRecorderManager(opts) {
200
286
  recorder.recordHandle?.stop("remove recorder");
201
287
  this.recorders.splice(idx, 1);
202
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
+ });
203
299
  this.emit("RecorderRemoved", recorder.toJSON());
204
300
  },
205
301
  getRecorder(id) {
@@ -260,11 +356,11 @@ export function createRecorderManager(opts) {
260
356
  // TODO: emit updated event
261
357
  // 为每个 provider 创建独立的检查循环
262
358
  const startProviderCheckLoop = (providerId) => {
263
- const providerConfig = this.getProviderCheckConfig(providerId);
264
359
  const checkLoop = async () => {
360
+ const providerConfig = this.getProviderCheckConfig(providerId);
265
361
  try {
266
362
  // 只检查当前 provider 的 recorders
267
- await multiThreadCheck(this, providerId);
363
+ await multiThreadCheck(this, providerId, providerConfig);
268
364
  }
269
365
  catch (err) {
270
366
  this.emit("error", { source: "multiThreadCheck", err });
@@ -370,6 +466,7 @@ export function genSavePathFromRule(manager, recorder, extData) {
370
466
  title: title,
371
467
  remarks: remarks,
372
468
  channelId,
469
+ replaceFourByteUnicode,
373
470
  };
374
471
  let savePathRule = manager.savePathRule;
375
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;
@@ -46,7 +46,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
46
46
  /** 流编码 */
47
47
  codecName?: CodecName;
48
48
  /** 选择使用的api,虎牙支持: auto,web,mp,wup,抖音支持:web,webHTML,mobile,userHTML */
49
- api?: "auto" | "web" | "mp" | "wup" | "webHTML" | "mobile" | "userHTML" | "balance" | "random" | string;
49
+ api?: "auto" | "web" | "mp" | "wup" | "webHTML" | "mobile" | "userHTML" | "balance" | "random" | "newAPI" | "oldAPI" | string;
50
50
  /** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制,支持两种格式:
51
51
  * 1. 逗号分隔的关键词:'回放,录播,重播'
52
52
  * 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
@@ -65,15 +65,26 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
65
65
  /** 控制弹幕是否使用服务端时间戳 */
66
66
  useServerTimestamp?: boolean;
67
67
  extra?: Partial<E>;
68
+ /** 代理 如http://127.0.0.1:7890 */
69
+ proxy?: string;
68
70
  /** 调试等级 */
69
71
  debugLevel?: "none" | "basic" | "verbose";
70
72
  }
71
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">;
72
- /** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单 */
73
- export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked";
74
+ /** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单,charge-skipped: 充电直播(DRM加密)已跳过 */
75
+ export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked" | "charge-skipped";
74
76
  export type Progress = {
75
77
  time: string | null;
76
78
  };
79
+ export interface RecorderTimelineItem {
80
+ startTime: number;
81
+ text: string;
82
+ endTime?: number;
83
+ }
84
+ export interface AppendRecorderTimelineArgs {
85
+ startTime?: number;
86
+ text: string;
87
+ }
77
88
  export interface RecordHandle {
78
89
  id: string;
79
90
  stream: string;
@@ -106,6 +117,10 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
106
117
  cover?: string;
107
118
  rawFilename?: string;
108
119
  };
120
+ stateChange: {
121
+ state: RecorderState;
122
+ msg?: string;
123
+ };
109
124
  videoFileCompleted: {
110
125
  filename: string;
111
126
  stats?: XmlStreamStats;
@@ -118,6 +133,9 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
118
133
  Updated: (string | keyof Recorder)[];
119
134
  Message: Message;
120
135
  DebugLog: DebugLog;
136
+ ChargeLive: {
137
+ channelId: string;
138
+ };
121
139
  }>, RecorderCreateOpts<E> {
122
140
  id: string;
123
141
  extra: Partial<E>;
@@ -138,8 +156,13 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
138
156
  liveId?: string;
139
157
  recordStartTime: Date;
140
158
  area?: string;
159
+ liveType?: string;
160
+ liveTypeDesc?: string;
161
+ canRecord?: boolean;
141
162
  };
142
163
  tempStopIntervalCheck?: boolean;
164
+ timeline?: RecorderTimelineItem[];
165
+ appendTimeline: (args: AppendRecorderTimelineArgs) => RecorderTimelineItem[];
143
166
  /** 缓存实例(命名空间) */
144
167
  cache: NamespacedCache;
145
168
  getChannelURL: (this: Recorder<E>) => string;
@@ -160,10 +183,12 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
160
183
  liveStartTime: Date;
161
184
  area: string;
162
185
  }>;
163
- getStream: (this: Recorder<E>) => Promise<{
186
+ getStream: (this: Recorder<E>, opts?: {
187
+ formatName?: FormatName;
188
+ formatPriorities?: Array<"flv" | "hls">;
189
+ }) => Promise<{
164
190
  source: string;
165
191
  name: string;
166
192
  url: string;
167
193
  }>;
168
194
  }
169
- 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;
@@ -125,6 +129,7 @@ declare const _default: {
125
129
  assertObjectType: typeof assertObjectType;
126
130
  asyncThrottle: typeof asyncThrottle;
127
131
  isFfmpegStartSegment: typeof isFfmpegStartSegment;
132
+ replaceFourByteUnicode: typeof replaceFourByteUnicode;
128
133
  createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
129
134
  createTimeoutChecker: typeof createTimeoutChecker;
130
135
  downloadImage: typeof downloadImage;
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
  }
@@ -460,10 +466,13 @@ export async function checkTitleKeywordsWhileRecording(recorder, isManualStart,
460
466
  const { title } = liveInfo;
461
467
  // 检查标题是否包含关键词
462
468
  if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
463
- recorder.state = "title-blocked";
469
+ recorder.emit("stateChange", {
470
+ state: "title-blocked",
471
+ msg: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
472
+ });
464
473
  recorder.emit("DebugLog", {
465
474
  type: "common",
466
- text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
475
+ text: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
467
476
  });
468
477
  // 停止录制
469
478
  await recorder?.recordHandle?.stop("直播间标题包含关键词");
@@ -485,10 +494,9 @@ export function checkTitleKeywordsBeforeRecord(title, recorder, isManualStart) {
485
494
  return false;
486
495
  }
487
496
  if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
488
- recorder.state = "title-blocked";
489
- recorder.emit("DebugLog", {
490
- type: "common",
491
- text: `跳过录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
497
+ recorder.emit("stateChange", {
498
+ state: "title-blocked",
499
+ msg: `跳过录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
492
500
  });
493
501
  return true;
494
502
  }
@@ -505,6 +513,7 @@ export default {
505
513
  assertObjectType,
506
514
  asyncThrottle,
507
515
  isFfmpegStartSegment,
516
+ replaceFourByteUnicode,
508
517
  createFFmpegInvalidStreamChecker,
509
518
  createTimeoutChecker,
510
519
  downloadImage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.16.2",
3
+ "version": "1.17.1",
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",