@bililive-tools/manager 1.8.0 → 1.10.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.
@@ -1,27 +1,30 @@
1
- import { FFMPEGRecorder } from "./FFMPEGRecorder.js";
2
- import { MesioRecorder } from "./mesioRecorder.js";
3
- import { BililiveRecorder } from "./BililiveRecorder.js";
4
- export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
5
- export { MesioRecorder } from "./mesioRecorder.js";
6
- export { BililiveRecorder } from "./BililiveRecorder.js";
1
+ import { FFmpegDownloader } from "./FFmpegDownloader.js";
2
+ import { mesioDownloader } from "./mesioDownloader.js";
3
+ import { BililiveDownloader } from "./BililiveDownloader.js";
4
+ import { parseSizeToBytes } from "../utils.js";
5
+ export { FFmpegDownloader } from "./FFmpegDownloader.js";
6
+ export { mesioDownloader } from "./mesioDownloader.js";
7
+ export { BililiveDownloader } from "./BililiveDownloader.js";
7
8
  /**
8
9
  * 创建录制器的工厂函数
9
10
  */
10
- export function createRecorder(type, opts, onEnd, onUpdateLiveInfo) {
11
+ export function createBaseDownloader(type, opts, onEnd, onUpdateLiveInfo) {
12
+ const segment = parseSizeToBytes(String(opts.segment));
13
+ const newOpts = { ...opts, segment };
11
14
  if (type === "ffmpeg") {
12
- return new FFMPEGRecorder(opts, onEnd, onUpdateLiveInfo);
15
+ return new FFmpegDownloader(newOpts, onEnd, onUpdateLiveInfo);
13
16
  }
14
17
  else if (type === "mesio") {
15
- return new MesioRecorder(opts, onEnd, onUpdateLiveInfo);
18
+ return new mesioDownloader(newOpts, onEnd, onUpdateLiveInfo);
16
19
  }
17
20
  else if (type === "bililive") {
18
21
  if (opts.formatName === "flv") {
19
22
  // 录播姬引擎不支持只录音频
20
23
  if (!opts.onlyAudio) {
21
- return new BililiveRecorder(opts, onEnd, onUpdateLiveInfo);
24
+ return new BililiveDownloader(newOpts, onEnd, onUpdateLiveInfo);
22
25
  }
23
26
  }
24
- return new FFMPEGRecorder(opts, onEnd, onUpdateLiveInfo);
27
+ return new FFmpegDownloader(newOpts, onEnd, onUpdateLiveInfo);
25
28
  }
26
29
  else {
27
30
  throw new Error(`Unsupported recorder type: ${type}`);
@@ -71,8 +74,8 @@ export function getSourceFormatName(streamUrl, formatName) {
71
74
  /**
72
75
  * 创建录制器的工厂函数
73
76
  */
74
- export function createBaseRecorder(type, opts, onEnd, onUpdateLiveInfo) {
77
+ export function createDownloader(type, opts, onEnd, onUpdateLiveInfo) {
75
78
  const recorderType = selectRecorder(type);
76
79
  const sourceFormatName = getSourceFormatName(opts.url, opts.formatName);
77
- return createRecorder(recorderType, { ...opts, formatName: sourceFormatName }, onEnd, onUpdateLiveInfo);
80
+ return createBaseDownloader(recorderType, { ...opts, formatName: sourceFormatName }, onEnd, onUpdateLiveInfo);
78
81
  }
@@ -1,5 +1,5 @@
1
1
  import EventEmitter from "node:events";
2
- import { IRecorder, MesioRecorderOptions } from "./IRecorder.js";
2
+ import { IDownloader, MesioRecorderOptions, Segment } from "./IDownloader.js";
3
3
  declare class MesioCommand extends EventEmitter {
4
4
  private _input;
5
5
  private _output;
@@ -15,7 +15,7 @@ declare class MesioCommand extends EventEmitter {
15
15
  kill(signal?: NodeJS.Signals): void;
16
16
  }
17
17
  export declare const createMesioBuilder: () => MesioCommand;
18
- export declare class MesioRecorder extends EventEmitter implements IRecorder {
18
+ export declare class mesioDownloader extends EventEmitter implements IDownloader {
19
19
  private onEnd;
20
20
  private onUpdateLiveInfo;
21
21
  type: "mesio";
@@ -26,9 +26,8 @@ export declare class MesioRecorder extends EventEmitter implements IRecorder {
26
26
  startTime: number;
27
27
  title?: string;
28
28
  }) => string;
29
- readonly segment: number;
29
+ readonly segment: Segment;
30
30
  readonly inputOptions: string[];
31
- readonly disableDanma: boolean;
32
31
  readonly url: string;
33
32
  readonly debugLevel: "none" | "basic" | "verbose";
34
33
  readonly headers: {
@@ -43,5 +42,6 @@ export declare class MesioRecorder extends EventEmitter implements IRecorder {
43
42
  getArguments(): string[];
44
43
  stop(): Promise<void>;
45
44
  getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
45
+ get videoFilePath(): string;
46
46
  }
47
47
  export {};
@@ -84,7 +84,7 @@ class MesioCommand extends EventEmitter {
84
84
  export const createMesioBuilder = () => {
85
85
  return new MesioCommand();
86
86
  };
87
- export class MesioRecorder extends EventEmitter {
87
+ export class mesioDownloader extends EventEmitter {
88
88
  onEnd;
89
89
  onUpdateLiveInfo;
90
90
  type = "mesio";
@@ -94,7 +94,6 @@ export class MesioRecorder extends EventEmitter {
94
94
  getSavePath;
95
95
  segment;
96
96
  inputOptions = [];
97
- disableDanma = false;
98
97
  url;
99
98
  debugLevel = "none";
100
99
  headers;
@@ -102,8 +101,9 @@ export class MesioRecorder extends EventEmitter {
102
101
  super();
103
102
  this.onEnd = onEnd;
104
103
  this.onUpdateLiveInfo = onUpdateLiveInfo;
104
+ // 存在自动分段,永远为true
105
105
  const hasSegment = true;
106
- this.disableDanma = opts.disableDanma ?? false;
106
+ this.hasSegment = hasSegment;
107
107
  this.debugLevel = opts.debugLevel ?? "none";
108
108
  let videoFormat = "flv";
109
109
  if (opts.url.includes(".m3u8")) {
@@ -118,18 +118,17 @@ export class MesioRecorder extends EventEmitter {
118
118
  else if (opts.formatName === "flv") {
119
119
  videoFormat = "flv";
120
120
  }
121
- this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "mesio", videoFormat, {
121
+ this.streamManager = new StreamManager(opts.getSavePath, hasSegment, "mesio", videoFormat, {
122
122
  onUpdateLiveInfo: this.onUpdateLiveInfo,
123
123
  });
124
- this.hasSegment = hasSegment;
125
124
  this.getSavePath = opts.getSavePath;
126
125
  this.inputOptions = [];
127
126
  this.url = opts.url;
128
127
  this.segment = opts.segment;
129
128
  this.headers = opts.headers;
130
129
  this.command = this.createCommand();
131
- this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
132
- this.emit("videoFileCreated", { filename, cover, rawFilename });
130
+ this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
131
+ this.emit("videoFileCreated", { filename, cover, rawFilename, title });
133
132
  });
134
133
  this.streamManager.on("videoFileCompleted", ({ filename }) => {
135
134
  this.emit("videoFileCompleted", { filename });
@@ -144,6 +143,7 @@ export class MesioRecorder extends EventEmitter {
144
143
  "--fix",
145
144
  "-H",
146
145
  "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36",
146
+ "--no-proxy",
147
147
  ];
148
148
  if (this.debugLevel === "verbose") {
149
149
  inputOptions.push("-v");
@@ -155,8 +155,13 @@ export class MesioRecorder extends EventEmitter {
155
155
  inputOptions.push("-H", `${key}: ${value}`);
156
156
  });
157
157
  }
158
- if (this.hasSegment) {
159
- inputOptions.push("-d", `${this.segment * 60}s`);
158
+ if (this.segment) {
159
+ if (typeof this.segment === "number") {
160
+ inputOptions.push("-d", `${this.segment * 60}s`);
161
+ }
162
+ else if (typeof this.segment === "string") {
163
+ inputOptions.push("-m", this.segment);
164
+ }
160
165
  }
161
166
  const command = createMesioBuilder()
162
167
  .input(this.url)
@@ -189,4 +194,7 @@ export class MesioRecorder extends EventEmitter {
189
194
  getExtraDataController() {
190
195
  return this.streamManager?.getExtraDataController();
191
196
  }
197
+ get videoFilePath() {
198
+ return this.streamManager.videoFilePath;
199
+ }
192
200
  }
@@ -1,12 +1,12 @@
1
1
  import EventEmitter from "node:events";
2
2
  import { createRecordExtraDataController } from "../xml_stream_controller.js";
3
3
  import type { RecorderCreateOpts } from "../recorder.js";
4
+ import type { TrueVideoFormat } from "../index.js";
4
5
  export type GetSavePath = (data: {
5
6
  startTime: number;
6
7
  title?: string;
7
8
  }) => string;
8
9
  type RecorderType = Exclude<RecorderCreateOpts["recorderType"], undefined | "auto">;
9
- type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
10
10
  export declare class Segment extends EventEmitter {
11
11
  extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
12
12
  init: boolean;
@@ -15,9 +15,8 @@ export declare class Segment extends EventEmitter {
15
15
  rawRecordingVideoPath: string;
16
16
  /** 输出文件名名,不包含拓展名 */
17
17
  outputVideoFilePath: string;
18
- disableDanma: boolean;
19
- videoExt: VideoFormat;
20
- constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: VideoFormat);
18
+ videoExt: TrueVideoFormat;
19
+ constructor(getSavePath: GetSavePath, videoExt: TrueVideoFormat);
21
20
  handleSegmentEnd(): Promise<void>;
22
21
  onSegmentStart(stderrLine: string, callBack?: {
23
22
  onUpdateLiveInfo: () => Promise<{
@@ -36,7 +35,7 @@ export declare class StreamManager extends EventEmitter {
36
35
  recorderType: RecorderType;
37
36
  private videoFormat;
38
37
  private callBack?;
39
- constructor(getSavePath: GetSavePath, hasSegment: boolean, disableDanma: boolean, recorderType: RecorderType, videoFormat: VideoFormat, callBack?: {
38
+ constructor(getSavePath: GetSavePath, hasSegment: boolean, recorderType: RecorderType, videoFormat: TrueVideoFormat, callBack?: {
40
39
  onUpdateLiveInfo: () => Promise<{
41
40
  title?: string;
42
41
  cover?: string;
@@ -45,7 +44,7 @@ export declare class StreamManager extends EventEmitter {
45
44
  handleVideoStarted(stderrLine: string): Promise<void>;
46
45
  handleVideoCompleted(): Promise<void>;
47
46
  getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
48
- get videoExt(): VideoFormat;
47
+ get videoExt(): TrueVideoFormat;
49
48
  get videoFilePath(): string;
50
49
  }
51
50
  export {};
@@ -10,12 +10,10 @@ export class Segment extends EventEmitter {
10
10
  rawRecordingVideoPath;
11
11
  /** 输出文件名名,不包含拓展名 */
12
12
  outputVideoFilePath;
13
- disableDanma;
14
13
  videoExt;
15
- constructor(getSavePath, disableDanma, videoExt) {
14
+ constructor(getSavePath, videoExt) {
16
15
  super();
17
16
  this.getSavePath = getSavePath;
18
- this.disableDanma = disableDanma;
19
17
  this.videoExt = videoExt;
20
18
  }
21
19
  async handleSegmentEnd() {
@@ -69,9 +67,7 @@ export class Segment extends EventEmitter {
69
67
  title: liveInfo?.title,
70
68
  });
71
69
  ensureFolderExist(this.outputVideoFilePath);
72
- if (!this.disableDanma) {
73
- this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
74
- }
70
+ this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
75
71
  // 支持两种格式的正则表达式
76
72
  // 1. FFmpeg格式: Opening 'filename' for writing
77
73
  // 2. Mesio格式: Opening FLV segment path=filename Processing
@@ -107,19 +103,19 @@ export class StreamManager extends EventEmitter {
107
103
  recordSavePath;
108
104
  recordStartTime;
109
105
  hasSegment;
110
- recorderType = "ffmpeg";
106
+ recorderType;
111
107
  videoFormat;
112
108
  callBack;
113
- constructor(getSavePath, hasSegment, disableDanma, recorderType, videoFormat, callBack) {
109
+ constructor(getSavePath, hasSegment, recorderType, videoFormat, callBack) {
114
110
  super();
115
111
  const recordSavePath = getSavePath({ startTime: Date.now() });
116
112
  this.recordSavePath = recordSavePath;
117
- this.videoFormat = videoFormat ?? "auto";
113
+ this.videoFormat = videoFormat;
118
114
  this.recorderType = recorderType;
119
115
  this.hasSegment = hasSegment;
120
116
  this.callBack = callBack;
121
117
  if (hasSegment) {
122
- this.segment = new Segment(getSavePath, disableDanma, this.videoExt);
118
+ this.segment = new Segment(getSavePath, this.videoExt);
123
119
  this.segment.on("DebugLog", (data) => {
124
120
  this.emit("DebugLog", data);
125
121
  });
@@ -132,9 +128,7 @@ export class StreamManager extends EventEmitter {
132
128
  }
133
129
  else {
134
130
  const extraDataSavePath = replaceExtName(recordSavePath, ".xml");
135
- if (!disableDanma) {
136
- this.extraDataController = createRecordExtraDataController(extraDataSavePath);
137
- }
131
+ this.extraDataController = createRecordExtraDataController(extraDataSavePath);
138
132
  }
139
133
  }
140
134
  async handleVideoStarted(stderrLine) {
@@ -196,18 +190,7 @@ export class StreamManager extends EventEmitter {
196
190
  return this.segment?.extraDataController || this.extraDataController;
197
191
  }
198
192
  get videoExt() {
199
- if (this.recorderType === "ffmpeg") {
200
- return this.videoFormat;
201
- }
202
- else if (this.recorderType === "mesio") {
203
- return this.videoFormat;
204
- }
205
- else if (this.recorderType === "bililive") {
206
- return "flv";
207
- }
208
- else {
209
- throw new Error("Unknown recorderType");
210
- }
193
+ return this.videoFormat;
211
194
  }
212
195
  get videoFilePath() {
213
196
  if (this.recorderType === "ffmpeg") {
package/lib/index.d.ts CHANGED
@@ -6,9 +6,11 @@ import utils from "./utils.js";
6
6
  export * from "./common.js";
7
7
  export * from "./recorder.js";
8
8
  export * from "./manager.js";
9
+ export * from "./cache.js";
9
10
  export * from "./record_extra_data_controller.js";
10
- export * from "./recorder/FFMPEGRecorder.js";
11
- export { createBaseRecorder } from "./recorder/index.js";
11
+ export * from "./downloader/FFmpegDownloader.js";
12
+ export { createDownloader } from "./downloader/index.js";
13
+ export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
12
14
  export { utils };
13
15
  /**
14
16
  * 提供一些 utils
@@ -24,3 +26,5 @@ export declare function getMesioPath(): string;
24
26
  export declare function setBililivePath(newPath: string): void;
25
27
  export declare function getBililivePath(): string;
26
28
  export declare function getDataFolderPath<E extends AnyObject>(provider: RecorderProvider<E>): string;
29
+ export type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
30
+ export type TrueVideoFormat = Exclude<VideoFormat, "auto">;
package/lib/index.js CHANGED
@@ -5,9 +5,11 @@ import utils from "./utils.js";
5
5
  export * from "./common.js";
6
6
  export * from "./recorder.js";
7
7
  export * from "./manager.js";
8
+ export * from "./cache.js";
8
9
  export * from "./record_extra_data_controller.js";
9
- export * from "./recorder/FFMPEGRecorder.js";
10
- export { createBaseRecorder } from "./recorder/index.js";
10
+ export * from "./downloader/FFmpegDownloader.js";
11
+ export { createDownloader } from "./downloader/index.js";
12
+ export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
11
13
  export { utils };
12
14
  /**
13
15
  * 提供一些 utils
package/lib/manager.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Emitter } from "mitt";
2
2
  import { ChannelId, Message } from "./common.js";
3
+ import { RecorderCache } from "./cache.js";
3
4
  import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog, Progress } from "./recorder.js";
4
5
  import { AnyObject, UnknownObject } from "./utils.js";
5
- import { StreamManager } from "./recorder/streamManager.js";
6
- import { Cache } from "./cache.js";
6
+ import { StreamManager } from "./downloader/streamManager.js";
7
7
  export interface RecorderProvider<E extends AnyObject> {
8
8
  id: string;
9
9
  name: string;
@@ -75,7 +75,7 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
75
75
  providers: P[];
76
76
  getChannelURLMatchedRecorderProviders: (this: RecorderManager<ME, P, PE, E>, channelURL: string) => P[];
77
77
  recorders: Recorder<E>[];
78
- addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: Omit<RecorderCreateOpts<E>, "cache">) => Recorder<E>;
78
+ addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: RecorderCreateOpts<E>) => Recorder<E>;
79
79
  removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
80
80
  startRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
81
81
  stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
@@ -91,19 +91,23 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
91
91
  ffmpegOutputArgs: string;
92
92
  /** b站使用批量查询接口 */
93
93
  biliBatchQuery: boolean;
94
- /** 测试:录制错误立即重试 */
94
+ /** 下播延迟检查 */
95
95
  recordRetryImmediately: boolean;
96
- /** 缓存实例 */
97
- cache: Cache;
96
+ /** 缓存系统 */
97
+ cache: RecorderCache;
98
98
  }
99
99
  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>> & {
100
100
  providers: P[];
101
+ /** 自定义缓存实现,不提供则使用默认的内存缓存 */
102
+ cache?: RecorderCache;
101
103
  };
102
104
  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>;
103
105
  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: {
104
106
  owner: string;
105
107
  title: string;
106
- startTime?: number;
108
+ startTime: number;
109
+ liveStartTime: Date;
110
+ recordStartTime: Date;
107
111
  }): string;
108
112
  export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
109
- export { StreamManager, Cache };
113
+ export { StreamManager };
package/lib/manager.js CHANGED
@@ -3,10 +3,10 @@ import mitt from "mitt";
3
3
  import ejs from "ejs";
4
4
  import { omit, range } from "lodash-es";
5
5
  import { parseArgsStringToArgv } from "string-argv";
6
+ import { RecorderCacheImpl, MemoryCacheStore } from "./cache.js";
6
7
  import { getBiliStatusInfoByRoomIds } from "./api.js";
7
8
  import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, } from "./utils.js";
8
- import { StreamManager } from "./recorder/streamManager.js";
9
- import { Cache } from "./cache.js";
9
+ import { StreamManager } from "./downloader/streamManager.js";
10
10
  const configurableProps = [
11
11
  "savePathRule",
12
12
  "autoRemoveSystemReservedChars",
@@ -97,8 +97,6 @@ export function createRecorderManager(opts) {
97
97
  const liveStartObj = {};
98
98
  // 用于记录触发重试直播场次的次数
99
99
  const retryCountObj = {};
100
- // 获取缓存单例
101
- const cache = Cache.getInstance();
102
100
  const manager = {
103
101
  // @ts-ignore
104
102
  ...mitt(),
@@ -115,8 +113,10 @@ export function createRecorderManager(opts) {
115
113
  // provider.createRecorder 能返回 Recorder<PE> 才能进一步优化。
116
114
  const recorder = provider.createRecorder({
117
115
  ...omit(opts, ["providerId"]),
118
- cache,
116
+ // cache,
119
117
  });
118
+ // 为录制器注入独立的缓存命名空间
119
+ recorder.cache = this.cache.createNamespace(recorder.id);
120
120
  this.recorders.push(recorder);
121
121
  recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle }));
122
122
  recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
@@ -133,29 +133,32 @@ export function createRecorderManager(opts) {
133
133
  recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
134
134
  recorder.on("RecordStop", ({ recordHandle, reason }) => {
135
135
  this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
136
- // 如果reason中存在"invalid stream",说明直播由于某些原因中断了,虽然会在下一次周期检查中继续,但是会遗漏一段时间。
137
- // 这时候可以触发一次检查,但出于直播可能抽风的原因,为避免风控,一场直播最多触发五次。
138
- // 测试阶段,还需要一个开关,默认关闭,几个版本后转正使用
136
+ const maxRetryCount = 10;
137
+ // 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
138
+ // 如果开启了该选项,且录制开始时间与结束时间相差在一分钟以上(某些平台下播会扔会有重复流),那么会立即进行一次检查。
139
139
  // 也许之后还能链接复用,但也会引入更多复杂度,需要谨慎考虑
140
140
  // 虎牙直播结束后可能额外触发导致错误,忽略虎牙直播间:https://www.huya.com/910323
141
141
  if (manager.recordRetryImmediately &&
142
- recorder.providerId !== "HuYa" &&
143
- reason &&
144
- reason.includes("invalid stream") &&
145
- recorder?.liveInfo?.liveId) {
142
+ recorder?.liveInfo?.liveId &&
143
+ reason !== "manual stop") {
146
144
  const key = `${recorder.channelId}-${recorder.liveInfo?.liveId}`;
147
- if (retryCountObj[key] > 5)
145
+ const recordStartTime = recorder.liveInfo?.recordStartTime.getTime() ?? 0;
146
+ const recordStopTime = Date.now();
147
+ // 录制时间差在一分钟以上
148
+ if (recordStopTime - recordStartTime < 60 * 1000)
149
+ return;
150
+ if (retryCountObj[key] > maxRetryCount)
148
151
  return;
149
152
  if (!retryCountObj[key]) {
150
153
  retryCountObj[key] = 0;
151
154
  }
152
- if (retryCountObj[key] < 5) {
155
+ if (retryCountObj[key] < maxRetryCount) {
153
156
  retryCountObj[key]++;
154
157
  }
155
158
  this.emit("RecorderDebugLog", {
156
159
  recorder,
157
160
  type: "common",
158
- text: `录制${recorder?.channelId}因“${reason}”中断,触发重试直播(${retryCountObj[key]})`,
161
+ text: `录制${recorder.channelId}中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
159
162
  });
160
163
  // 触发一次检查,等待一秒使状态清理完毕
161
164
  setTimeout(() => {
@@ -214,7 +217,7 @@ export function createRecorderManager(opts) {
214
217
  if (recorder.recordHandle == null)
215
218
  return;
216
219
  const liveId = recorder.liveInfo?.liveId;
217
- await recorder.recordHandle.stop("manual stop", true);
220
+ await recorder.recordHandle.stop("manual stop");
218
221
  if (liveId) {
219
222
  tempBanObj[recorder.channelId] = liveId;
220
223
  recorder.tempStopIntervalCheck = true;
@@ -269,6 +272,7 @@ export function createRecorderManager(opts) {
269
272
  autoRemoveSystemReservedChars: opts.autoRemoveSystemReservedChars ?? true,
270
273
  biliBatchQuery: opts.biliBatchQuery ?? false,
271
274
  recordRetryImmediately: opts.recordRetryImmediately ?? false,
275
+ cache: opts.cache ?? new RecorderCacheImpl(new MemoryCacheStore()),
272
276
  ffmpegOutputArgs: opts.ffmpegOutputArgs ??
273
277
  "-c copy" +
274
278
  /**
@@ -276,18 +280,7 @@ export function createRecorderManager(opts) {
276
280
  * 最后一个片段,而 FLV 格式如果录制中 KILL 了需要手动修复下 keyframes。所以默认使用 fmp4 格式。
277
281
  */
278
282
  " -movflags faststart+frag_keyframe+empty_moov" +
279
- /**
280
- * 浏览器加载 FragmentMP4 会需要先把它所有的 moof boxes 都加载完成后才能播放,
281
- * 默认的分段时长很小,会产生大量的 moof,导致加载很慢,所以这里设置一个分段的最小时长。
282
- *
283
- * TODO: 这个浏览器行为或许是可以优化的,比如试试给 fmp4 在录制完成后设置或者录制过程中实时更新 mvhd.duration。
284
- * https://stackoverflow.com/questions/55887980/how-to-use-media-source-extension-mse-low-latency-mode
285
- * https://stackoverflow.com/questions/61803136/ffmpeg-fragmented-mp4-takes-long-time-to-start-playing-on-chrome
286
- *
287
- * TODO: 如果浏览器行为无法优化,并且想进一步优化加载速度,可以考虑录制时使用 fmp4,录制完成后再转一次普通 mp4。
288
- */
289
283
  " -min_frag_duration 10000000",
290
- cache,
291
284
  };
292
285
  const setProvidersFFMPEGOutputArgs = (ffmpegOutputArgs) => {
293
286
  const args = parseArgsStringToArgv(ffmpegOutputArgs);
@@ -312,12 +305,12 @@ export function genSavePathFromRule(manager, recorder, extData) {
312
305
  // TODO: 这里随便写的,后面再优化
313
306
  const provider = manager.providers.find((p) => p.id === recorder.toJSON().providerId);
314
307
  const now = extData?.startTime ? new Date(extData.startTime) : new Date();
315
- const owner = (extData?.owner ?? "").replaceAll("%", "_");
316
- const title = (extData?.title ?? "").replaceAll("%", "_");
308
+ const owner = removeSystemReservedChars((extData?.owner ?? "").replaceAll("%", "_"));
309
+ const title = removeSystemReservedChars((extData?.title ?? "").replaceAll("%", "_"));
310
+ const remarks = removeSystemReservedChars((recorder.remarks ?? "").replaceAll("%", "_"));
311
+ const channelId = removeSystemReservedChars(String(recorder.channelId));
317
312
  const params = {
318
313
  platform: provider?.name ?? "unknown",
319
- channelId: recorder.channelId,
320
- remarks: recorder.remarks ?? "",
321
314
  year: formatDate(now, "yyyy"),
322
315
  month: formatDate(now, "MM"),
323
316
  date: formatDate(now, "dd"),
@@ -325,21 +318,19 @@ export function genSavePathFromRule(manager, recorder, extData) {
325
318
  min: formatDate(now, "mm"),
326
319
  sec: formatDate(now, "ss"),
327
320
  ...extData,
321
+ startTime: now,
328
322
  owner: owner,
329
323
  title: title,
324
+ remarks: remarks,
325
+ channelId,
330
326
  };
331
- if (manager.autoRemoveSystemReservedChars) {
332
- for (const key in params) {
333
- params[key] = removeSystemReservedChars(String(params[key])).trim();
334
- }
335
- }
336
327
  let savePathRule = manager.savePathRule;
337
328
  try {
338
329
  savePathRule = ejs.render(savePathRule, params);
339
330
  }
340
331
  catch (error) {
341
- console.error("模板解析错误", error);
332
+ console.error("模板解析错误", error, savePathRule, params);
342
333
  }
343
334
  return formatTemplate(savePathRule, params);
344
335
  }
345
- export { StreamManager, Cache };
336
+ export { StreamManager };
@@ -5,7 +5,7 @@ export interface RecordExtraData {
5
5
  recordStartTimestamp: number;
6
6
  recordStopTimestamp?: number;
7
7
  liveStartTimestamp?: number;
8
- ffmpegArgs?: string[];
8
+ downloaderArgs?: string[];
9
9
  platform?: string;
10
10
  user_name?: string;
11
11
  room_id?: string;
package/lib/recorder.d.ts CHANGED
@@ -2,7 +2,8 @@ import { Emitter } from "mitt";
2
2
  import { ChannelId, Message, Quality } from "./common.js";
3
3
  import { RecorderProvider } from "./manager.js";
4
4
  import { AnyObject, PickRequired, UnknownObject } from "./utils.js";
5
- import { Cache } from "./cache.js";
5
+ import type { NamespacedCache } from "./cache.js";
6
+ import type { DownloaderType } from "./downloader/index.js";
6
7
  type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
7
8
  type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
8
9
  export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
@@ -18,7 +19,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
18
19
  sourcePriorities: string[];
19
20
  formatPriorities?: Array<"flv" | "hls">;
20
21
  source?: string;
21
- segment?: number;
22
+ segment?: string;
22
23
  saveGiftDanma?: boolean;
23
24
  saveSCDanma?: boolean;
24
25
  /** 保存封面 */
@@ -44,7 +45,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
44
45
  /** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制(仅对斗鱼有效),多个关键词用英文逗号分隔 */
45
46
  titleKeywords?: string;
46
47
  /** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
47
- videoFormat?: "auto" | "ts" | "mkv";
48
+ videoFormat?: "auto" | "ts" | "mkv" | "flv";
48
49
  /** 录制类型 */
49
50
  recorderType?: "auto" | "ffmpeg" | "mesio" | "bililive";
50
51
  /** 流格式优先级 */
@@ -58,8 +59,6 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
58
59
  extra?: Partial<E>;
59
60
  /** 调试等级 */
60
61
  debugLevel?: "none" | "basic" | "verbose";
61
- /** 缓存 */
62
- cache: Cache;
63
62
  }
64
63
  export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords">;
65
64
  /** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单 */
@@ -71,11 +70,12 @@ export interface RecordHandle {
71
70
  id: string;
72
71
  stream: string;
73
72
  source: string;
73
+ recorderType?: DownloaderType;
74
74
  url: string;
75
- ffmpegArgs?: string[];
75
+ downloaderArgs?: string[];
76
76
  progress?: Progress;
77
77
  savePath: string;
78
- stop: (this: RecordHandle, reason?: string, tempStopIntervalCheck?: boolean) => Promise<void>;
78
+ stop: (this: RecordHandle, reason?: string) => Promise<void>;
79
79
  cut: (this: RecordHandle) => Promise<void>;
80
80
  }
81
81
  export interface DebugLog {
@@ -85,7 +85,9 @@ export interface DebugLog {
85
85
  export type GetSavePath = (data: {
86
86
  owner: string;
87
87
  title: string;
88
- startTime?: number;
88
+ startTime: number;
89
+ liveStartTime: Date;
90
+ recordStartTime: Date;
89
91
  }) => string;
90
92
  export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
91
93
  RecordStart: RecordHandle;
@@ -114,21 +116,21 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
114
116
  usedStream?: string;
115
117
  usedSource?: string;
116
118
  state: RecorderState;
117
- qualityMaxRetry: number;
118
119
  qualityRetry: number;
119
120
  uid?: number | string;
120
121
  liveInfo?: {
121
122
  living: boolean;
122
123
  owner: string;
123
124
  title: string;
124
- startTime?: Date;
125
+ liveStartTime: Date;
125
126
  avatar: string;
126
127
  cover: string;
127
128
  liveId?: string;
129
+ recordStartTime: Date;
128
130
  };
129
131
  tempStopIntervalCheck?: boolean;
130
- /** 缓存实例引用,由 manager 设置 */
131
- cache: Cache;
132
+ /** 缓存实例(命名空间) */
133
+ cache: NamespacedCache;
132
134
  getChannelURL: (this: Recorder<E>) => string;
133
135
  checkLiveStatusAndRecord: (this: Recorder<E>, opts: {
134
136
  getSavePath: GetSavePath;
@@ -144,7 +146,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
144
146
  cover: string;
145
147
  channelId: ChannelId;
146
148
  living: boolean;
147
- startTime: Date;
149
+ liveStartTime: Date;
148
150
  }>;
149
151
  getStream: (this: Recorder<E>) => Promise<{
150
152
  source: string;