@bililive-tools/manager 1.16.2 → 1.17.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.
@@ -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
  }
@@ -23,6 +23,7 @@ export interface BaseRecorderOptions {
23
23
  [key: string]: string | undefined;
24
24
  };
25
25
  videoFormat?: VideoFormat;
26
+ proxy?: string;
26
27
  }
27
28
  /**
28
29
  * 录制器接口定义
@@ -37,6 +38,7 @@ export interface IDownloader extends EventEmitter {
37
38
  readonly headers: {
38
39
  [key: string]: string | undefined;
39
40
  } | undefined;
41
+ readonly proxy?: string;
40
42
  readonly getSavePath: (data: {
41
43
  startTime: number;
42
44
  title?: 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
  }
package/lib/manager.js CHANGED
@@ -7,6 +7,45 @@ import { RecorderCacheImpl, MemoryCacheStore } from "./cache.js";
7
7
  import { getBiliStatusInfoByRoomIds } from "./api.js";
8
8
  import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, } 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
+ };
47
+ return stateTextMap[state];
48
+ }
10
49
  const configurableProps = [
11
50
  "savePathRule",
12
51
  "autoRemoveSystemReservedChars",
@@ -25,7 +64,7 @@ export function createRecorderManager(opts) {
25
64
  const recorders = [];
26
65
  // 存储每个 provider 的 timer,key 为 providerId
27
66
  const checkLoopTimers = new Map();
28
- const multiThreadCheck = async (manager, providerId) => {
67
+ const multiThreadCheck = async (manager, providerId, providerConfig) => {
29
68
  const handleBatchQuery = async (obj) => {
30
69
  for (const recorder of recorders
31
70
  .filter((r) => !r.disableAutoCheck)
@@ -48,7 +87,6 @@ export function createRecorderManager(opts) {
48
87
  .filter((r) => !r.disableAutoCheck)
49
88
  .filter((r) => isBetweenTimeRange(r.handleTime))
50
89
  .filter((r) => r.providerId === providerId);
51
- const providerConfig = manager.getProviderCheckConfig(providerId);
52
90
  const threads = [];
53
91
  // Bilibili 批量查询特殊处理
54
92
  if (providerId === "Bilibili" && manager.biliBatchQuery) {
@@ -87,13 +125,15 @@ export function createRecorderManager(opts) {
87
125
  while (needCheckRecorders.length > 0) {
88
126
  try {
89
127
  await checkOnce();
90
- if (providerConfig.waitTime > 0) {
91
- await sleep(providerConfig.waitTime);
92
- }
93
128
  }
94
129
  catch (err) {
95
130
  manager.emit("error", { source: "checkOnceInThread", err });
96
131
  }
132
+ finally {
133
+ if (providerConfig.waitTime > 0) {
134
+ await sleep(providerConfig.waitTime);
135
+ }
136
+ }
97
137
  }
98
138
  }));
99
139
  await Promise.all(threads);
@@ -124,21 +164,42 @@ export function createRecorderManager(opts) {
124
164
  });
125
165
  // 为录制器注入独立的缓存命名空间
126
166
  recorder.cache = this.cache.createNamespace(recorder.id);
167
+ ensureRecorderTimeline(recorder);
127
168
  this.recorders.push(recorder);
128
- recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle }));
169
+ recorder.on("RecordStart", (recordHandle) => {
170
+ recorder.appendTimeline?.({
171
+ startTime: recorder.liveInfo?.recordStartTime?.getTime() ?? Date.now(),
172
+ text: `开始录制:${recordHandle.stream}/${recordHandle.source}/${recordHandle.recorderType}/${recordHandle.url}`,
173
+ });
174
+ this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle });
175
+ });
129
176
  recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
130
177
  recorder.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
131
178
  if (recorder.saveCover && recorder?.liveInfo?.cover) {
132
179
  const coverPath = replaceExtName(filename, ".jpg");
133
180
  downloadImage(cover ?? recorder?.liveInfo?.cover, coverPath);
134
181
  }
182
+ recorder.appendTimeline?.({
183
+ startTime: Date.now(),
184
+ text: `视频文件创建:${filename}`,
185
+ });
135
186
  this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename, rawFilename });
136
187
  });
137
- recorder.on("videoFileCompleted", ({ filename, stats }) => this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename, stats }));
188
+ recorder.on("videoFileCompleted", ({ filename, stats }) => {
189
+ recorder.appendTimeline?.({
190
+ startTime: Date.now(),
191
+ text: `视频文件创建完成:${filename}`,
192
+ });
193
+ this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename, stats });
194
+ });
138
195
  recorder.on("Message", (message) => this.emit("Message", { recorder: recorder.toJSON(), message }));
139
196
  recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
140
197
  recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
141
198
  recorder.on("RecordStop", ({ recordHandle, reason }) => {
199
+ recorder.appendTimeline?.({
200
+ startTime: Date.now(),
201
+ text: reason ? `停止录制:${reason}` : "停止录制",
202
+ });
142
203
  this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
143
204
  const maxRetryCount = 10;
144
205
  // 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
@@ -167,6 +228,10 @@ export function createRecorderManager(opts) {
167
228
  type: "common",
168
229
  text: `录制${recorder.channelId}中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
169
230
  });
231
+ recorder.appendTimeline?.({
232
+ startTime: Date.now(),
233
+ text: `录制中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
234
+ });
170
235
  // 触发一次检查,等待一秒使状态清理完毕
171
236
  setTimeout(() => {
172
237
  recorder.checkLiveStatusAndRecord({
@@ -189,6 +254,15 @@ export function createRecorderManager(opts) {
189
254
  liveStartObj[key] = true;
190
255
  this.emit("RecoderLiveStart", { recorder: recorder });
191
256
  });
257
+ recorder.on("stateChange", ({ state, msg }) => {
258
+ recorder.state = state;
259
+ if (state !== "idle") {
260
+ recorder.appendTimeline?.({
261
+ startTime: Date.now(),
262
+ text: `状态变更:${getStateTimelineText(state, msg)}`,
263
+ });
264
+ }
265
+ });
192
266
  this.emit("RecorderAdded", recorder.toJSON());
193
267
  // startCheckLoop 会为所有注册的 provider 启动检查循环,无需在此处额外处理
194
268
  return recorder;
@@ -260,11 +334,11 @@ export function createRecorderManager(opts) {
260
334
  // TODO: emit updated event
261
335
  // 为每个 provider 创建独立的检查循环
262
336
  const startProviderCheckLoop = (providerId) => {
263
- const providerConfig = this.getProviderCheckConfig(providerId);
264
337
  const checkLoop = async () => {
338
+ const providerConfig = this.getProviderCheckConfig(providerId);
265
339
  try {
266
340
  // 只检查当前 provider 的 recorders
267
- await multiThreadCheck(this, providerId);
341
+ await multiThreadCheck(this, providerId, providerConfig);
268
342
  }
269
343
  catch (err) {
270
344
  this.emit("error", { source: "multiThreadCheck", err });
package/lib/recorder.d.ts CHANGED
@@ -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,6 +65,8 @@ 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
  }
@@ -74,6 +76,15 @@ export type RecorderState = "idle" | "recording" | "stopping-record" | "check-er
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;
@@ -140,6 +155,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
140
155
  area?: string;
141
156
  };
142
157
  tempStopIntervalCheck?: boolean;
158
+ timeline?: RecorderTimelineItem[];
159
+ appendTimeline: (args: AppendRecorderTimelineArgs) => RecorderTimelineItem[];
143
160
  /** 缓存实例(命名空间) */
144
161
  cache: NamespacedCache;
145
162
  getChannelURL: (this: Recorder<E>) => string;
package/lib/utils.js CHANGED
@@ -460,10 +460,13 @@ export async function checkTitleKeywordsWhileRecording(recorder, isManualStart,
460
460
  const { title } = liveInfo;
461
461
  // 检查标题是否包含关键词
462
462
  if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
463
- recorder.state = "title-blocked";
463
+ recorder.emit("stateChange", {
464
+ state: "title-blocked",
465
+ msg: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
466
+ });
464
467
  recorder.emit("DebugLog", {
465
468
  type: "common",
466
- text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
469
+ text: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
467
470
  });
468
471
  // 停止录制
469
472
  await recorder?.recordHandle?.stop("直播间标题包含关键词");
@@ -485,10 +488,9 @@ export function checkTitleKeywordsBeforeRecord(title, recorder, isManualStart) {
485
488
  return false;
486
489
  }
487
490
  if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
488
- recorder.state = "title-blocked";
489
- recorder.emit("DebugLog", {
490
- type: "common",
491
- text: `跳过录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
491
+ recorder.emit("stateChange", {
492
+ state: "title-blocked",
493
+ msg: `跳过录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
492
494
  });
493
495
  return true;
494
496
  }
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.0",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",