@bililive-tools/manager 1.4.1 → 1.6.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.
package/README.md CHANGED
@@ -72,14 +72,16 @@ manager.startCheckLoop();
72
72
 
73
73
  手动开启录制
74
74
 
75
- ### setFFMPEGPath
76
-
77
- 设置ffmpeg可执行路径
75
+ ### setFFMPEGPath & setMesioPath
78
76
 
79
77
  ```ts
80
- import { setFFMPEGPath } from "@bililive-tools/manager";
78
+ import { setFFMPEGPath, setMesioPath } from "@bililive-tools/manager";
81
79
 
80
+ // 设置ffmpeg可执行路径
82
81
  setFFMPEGPath("ffmpeg.exe");
82
+
83
+ // 设置mesio可执行文件路径
84
+ setMesioPath("mesio.exe");
83
85
  ```
84
86
 
85
87
  ## savePathRule 占位符参数
package/lib/cache.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export declare class Cache {
2
+ private static instance;
3
+ private data;
4
+ private constructor();
5
+ static getInstance(): Cache;
6
+ set(key: string, value: any): void;
7
+ get(key: string): any;
8
+ has(key: string): boolean;
9
+ delete(key: string): boolean;
10
+ clear(): void;
11
+ get size(): number;
12
+ keys(): IterableIterator<string>;
13
+ values(): IterableIterator<any>;
14
+ entries(): IterableIterator<[string, any]>;
15
+ forEach(callbackfn: (value: any, key: string, map: Map<string, any>) => void, thisArg?: any): void;
16
+ [Symbol.iterator](): IterableIterator<[string, any]>;
17
+ }
package/lib/cache.js ADDED
@@ -0,0 +1,47 @@
1
+ export class Cache {
2
+ static instance;
3
+ data;
4
+ constructor() {
5
+ this.data = new Map();
6
+ }
7
+ static getInstance() {
8
+ if (!Cache.instance) {
9
+ Cache.instance = new Cache();
10
+ }
11
+ return Cache.instance;
12
+ }
13
+ set(key, value) {
14
+ this.data.set(key, value);
15
+ }
16
+ get(key) {
17
+ return this.data.get(key);
18
+ }
19
+ has(key) {
20
+ return this.data.has(key);
21
+ }
22
+ delete(key) {
23
+ return this.data.delete(key);
24
+ }
25
+ clear() {
26
+ this.data.clear();
27
+ }
28
+ get size() {
29
+ return this.data.size;
30
+ }
31
+ keys() {
32
+ return this.data.keys();
33
+ }
34
+ values() {
35
+ return this.data.values();
36
+ }
37
+ entries() {
38
+ return this.data.entries();
39
+ }
40
+ forEach(callbackfn, thisArg) {
41
+ this.data.forEach(callbackfn, thisArg);
42
+ }
43
+ // 实现 Symbol.iterator 接口,支持 for...of 循环
44
+ [Symbol.iterator]() {
45
+ return this.data[Symbol.iterator]();
46
+ }
47
+ }
package/lib/common.d.ts CHANGED
@@ -3,7 +3,7 @@ export type ChannelId = string;
3
3
  export declare const Qualities: readonly ["lowest", "low", "medium", "high", "highest"];
4
4
  export declare const BiliQualities: readonly [30000, 20000, 10000, 400, 250, 150, 80];
5
5
  export declare const DouyuQualities: readonly [0, 2, 3, 4, 8];
6
- export declare const HuYaQualities: readonly [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500];
6
+ export declare const HuYaQualities: readonly [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500, -1];
7
7
  export declare const DouYinQualities: readonly ["origin", "uhd", "hd", "sd", "ld", "ao", "real_origin"];
8
8
  export type Quality = (typeof Qualities)[number] | (typeof BiliQualities)[number] | (typeof DouyuQualities)[number] | (typeof HuYaQualities)[number] | (typeof DouYinQualities)[number];
9
9
  export interface MessageSender<E extends AnyObject = UnknownObject> {
package/lib/common.js CHANGED
@@ -2,5 +2,7 @@ export const Qualities = ["lowest", "low", "medium", "high", "highest"];
2
2
  export const BiliQualities = [30000, 20000, 10000, 400, 250, 150, 80];
3
3
  export const DouyuQualities = [0, 2, 3, 4, 8];
4
4
  // 14100: 2K HDR;14000:2K;4200:HDR(10M);0:原画;8000:蓝光8M;4000:蓝光4M;2000:超清;500:流畅
5
- export const HuYaQualities = [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500];
5
+ export const HuYaQualities = [
6
+ 0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500, -1,
7
+ ];
6
8
  export const DouYinQualities = ["origin", "uhd", "hd", "sd", "ld", "ao", "real_origin"];
package/lib/index.d.ts CHANGED
@@ -7,7 +7,8 @@ export * from "./common.js";
7
7
  export * from "./recorder.js";
8
8
  export * from "./manager.js";
9
9
  export * from "./record_extra_data_controller.js";
10
- export * from "./FFMPEGRecorder.js";
10
+ export * from "./recorder/FFMPEGRecorder.js";
11
+ export { createBaseRecorder } from "./recorder/index.js";
11
12
  export { utils };
12
13
  /**
13
14
  * 提供一些 utils
@@ -18,4 +19,6 @@ export declare function genRecorderUUID(): Recorder["id"];
18
19
  export declare function genRecordUUID(): RecordHandle["id"];
19
20
  export declare function setFFMPEGPath(newPath: string): void;
20
21
  export declare const createFFMPEGBuilder: (input?: string | import("stream").Readable | undefined, options?: ffmpeg.FfmpegCommandOptions | undefined) => ffmpeg.FfmpegCommand;
22
+ export declare function setMesioPath(newPath: string): void;
23
+ export declare function getMesioPath(): string;
21
24
  export declare function getDataFolderPath<E extends AnyObject>(provider: RecorderProvider<E>): string;
package/lib/index.js CHANGED
@@ -6,7 +6,8 @@ export * from "./common.js";
6
6
  export * from "./recorder.js";
7
7
  export * from "./manager.js";
8
8
  export * from "./record_extra_data_controller.js";
9
- export * from "./FFMPEGRecorder.js";
9
+ export * from "./recorder/FFMPEGRecorder.js";
10
+ export { createBaseRecorder } from "./recorder/index.js";
10
11
  export { utils };
11
12
  /**
12
13
  * 提供一些 utils
@@ -21,7 +22,6 @@ export function defaultToJSON(provider, recorder) {
21
22
  ...pick(recorder, [
22
23
  "id",
23
24
  "channelId",
24
- "owner",
25
25
  "remarks",
26
26
  "disableAutoCheck",
27
27
  "quality",
@@ -36,6 +36,7 @@ export function defaultToJSON(provider, recorder) {
36
36
  "liveInfo",
37
37
  "uid",
38
38
  "titleKeywords",
39
+ // "recordHandle",
39
40
  ]),
40
41
  };
41
42
  }
@@ -55,6 +56,14 @@ export const createFFMPEGBuilder = (...args) => {
55
56
  ffmpeg.setFfmpegPath(ffmpegPath);
56
57
  return ffmpeg(...args);
57
58
  };
59
+ // Mesio path management
60
+ let mesioPath = "mesio";
61
+ export function setMesioPath(newPath) {
62
+ mesioPath = newPath;
63
+ }
64
+ export function getMesioPath() {
65
+ return mesioPath;
66
+ }
58
67
  export function getDataFolderPath(provider) {
59
68
  return "./" + provider.id;
60
69
  }
package/lib/manager.d.ts CHANGED
@@ -2,7 +2,8 @@ import { Emitter } from "mitt";
2
2
  import { ChannelId, Message } from "./common.js";
3
3
  import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog, Progress } from "./recorder.js";
4
4
  import { AnyObject, UnknownObject } from "./utils.js";
5
- import { StreamManager } from "./streamManager.js";
5
+ import { StreamManager } from "./recorder/streamManager.js";
6
+ import { Cache } from "./cache.js";
6
7
  export interface RecorderProvider<E extends AnyObject> {
7
8
  id: string;
8
9
  name: string;
@@ -19,7 +20,7 @@ export interface RecorderProvider<E extends AnyObject> {
19
20
  fromJSON: <T extends SerializedRecorder<E>>(this: RecorderProvider<E>, json: T) => Recorder<E>;
20
21
  setFFMPEGOutputArgs: (this: RecorderProvider<E>, args: string[]) => void;
21
22
  }
22
- declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "ffmpegOutputArgs", "biliBatchQuery"];
23
+ declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "ffmpegOutputArgs", "biliBatchQuery", "recordRetryImmediately"];
23
24
  type ConfigurableProp = (typeof configurableProps)[number];
24
25
  export interface RecorderManager<ME extends UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE> extends Emitter<{
25
26
  error: {
@@ -27,44 +28,44 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
27
28
  err: unknown;
28
29
  };
29
30
  RecordStart: {
30
- recorder: Recorder<E>;
31
+ recorder: SerializedRecorder<E>;
31
32
  recordHandle: RecordHandle;
32
33
  };
33
34
  RecordSegment: {
34
- recorder: Recorder<E>;
35
+ recorder: SerializedRecorder<E>;
35
36
  recordHandle?: RecordHandle;
36
37
  };
37
38
  videoFileCreated: {
38
- recorder: Recorder<E>;
39
+ recorder: SerializedRecorder<E>;
39
40
  filename: string;
40
41
  cover?: string;
41
42
  };
42
43
  videoFileCompleted: {
43
- recorder: Recorder<E>;
44
+ recorder: SerializedRecorder<E>;
44
45
  filename: string;
45
46
  };
46
47
  RecorderProgress: {
47
- recorder: Recorder<E>;
48
+ recorder: SerializedRecorder<E>;
48
49
  progress: Progress;
49
50
  };
50
51
  RecoderLiveStart: {
51
52
  recorder: Recorder<E>;
52
53
  };
53
54
  RecordStop: {
54
- recorder: Recorder<E>;
55
+ recorder: SerializedRecorder<E>;
55
56
  recordHandle: RecordHandle;
56
57
  reason?: string;
57
58
  };
58
59
  Message: {
59
- recorder: Recorder<E>;
60
+ recorder: SerializedRecorder<E>;
60
61
  message: Message;
61
62
  };
62
63
  RecorderUpdated: {
63
- recorder: Recorder<E>;
64
+ recorder: SerializedRecorder<E>;
64
65
  keys: (string | keyof Recorder<E>)[];
65
66
  };
66
- RecorderAdded: Recorder<E>;
67
- RecorderRemoved: Recorder<E>;
67
+ RecorderAdded: SerializedRecorder<E>;
68
+ RecorderRemoved: SerializedRecorder<E>;
68
69
  RecorderDebugLog: DebugLog & {
69
70
  recorder: Recorder<E>;
70
71
  };
@@ -73,7 +74,7 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
73
74
  providers: P[];
74
75
  getChannelURLMatchedRecorderProviders: (this: RecorderManager<ME, P, PE, E>, channelURL: string) => P[];
75
76
  recorders: Recorder<E>[];
76
- addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: RecorderCreateOpts<E>) => Recorder<E>;
77
+ addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: Omit<RecorderCreateOpts<E>, "cache">) => Recorder<E>;
77
78
  removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
78
79
  startRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
79
80
  stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
@@ -87,6 +88,10 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
87
88
  ffmpegOutputArgs: string;
88
89
  /** b站使用批量查询接口 */
89
90
  biliBatchQuery: boolean;
91
+ /** 测试:录制错误立即重试 */
92
+ recordRetryImmediately: boolean;
93
+ /** 缓存实例 */
94
+ cache: Cache;
90
95
  }
91
96
  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>> & {
92
97
  providers: P[];
@@ -98,4 +103,4 @@ export declare function genSavePathFromRule<ME extends AnyObject, P extends Reco
98
103
  startTime?: number;
99
104
  }): string;
100
105
  export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
101
- export { StreamManager };
106
+ export { StreamManager, Cache };
package/lib/manager.js CHANGED
@@ -5,13 +5,15 @@ import { omit, range } from "lodash-es";
5
5
  import { parseArgsStringToArgv } from "string-argv";
6
6
  import { getBiliStatusInfoByRoomIds } from "./api.js";
7
7
  import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, } from "./utils.js";
8
- import { StreamManager } from "./streamManager.js";
8
+ import { StreamManager } from "./recorder/streamManager.js";
9
+ import { Cache } from "./cache.js";
9
10
  const configurableProps = [
10
11
  "savePathRule",
11
12
  "autoRemoveSystemReservedChars",
12
13
  "autoCheckInterval",
13
14
  "ffmpegOutputArgs",
14
15
  "biliBatchQuery",
16
+ "recordRetryImmediately",
15
17
  ];
16
18
  function isConfigurableProp(prop) {
17
19
  return configurableProps.includes(prop);
@@ -89,6 +91,10 @@ export function createRecorderManager(opts) {
89
91
  const tempBanObj = {};
90
92
  // 用于是否触发LiveStart事件,不要重复触发
91
93
  const liveStartObj = {};
94
+ // 用于记录触发重试直播场次的次数
95
+ const retryCountObj = {};
96
+ // 获取缓存单例
97
+ const cache = Cache.getInstance();
92
98
  const manager = {
93
99
  // @ts-ignore
94
100
  ...mitt(),
@@ -103,24 +109,62 @@ export function createRecorderManager(opts) {
103
109
  throw new Error("Cant find provider " + opts.providerId);
104
110
  // TODO: 因为泛型函数内部是不持有具体泛型的,这里被迫用了 as,没什么好的思路处理,除非
105
111
  // provider.createRecorder 能返回 Recorder<PE> 才能进一步优化。
106
- const recorder = provider.createRecorder(omit(opts, ["providerId"]));
112
+ const recorder = provider.createRecorder({
113
+ ...omit(opts, ["providerId"]),
114
+ cache,
115
+ });
107
116
  this.recorders.push(recorder);
108
- recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder, recordHandle }));
109
- recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder, recordHandle }));
117
+ recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle }));
118
+ recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
110
119
  recorder.on("videoFileCreated", ({ filename, cover }) => {
111
120
  if (recorder.saveCover && recorder?.liveInfo?.cover) {
112
121
  const coverPath = replaceExtName(filename, ".jpg");
113
122
  downloadImage(cover ?? recorder?.liveInfo?.cover, coverPath);
114
123
  }
115
- this.emit("videoFileCreated", { recorder, filename });
124
+ this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename });
125
+ });
126
+ recorder.on("videoFileCompleted", ({ filename }) => this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename }));
127
+ recorder.on("Message", (message) => this.emit("Message", { recorder: recorder.toJSON(), message }));
128
+ recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
129
+ recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
130
+ recorder.on("RecordStop", ({ recordHandle, reason }) => {
131
+ this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
132
+ // 如果reason中存在"invalid stream",说明直播由于某些原因中断了,虽然会在下一次周期检查中继续,但是会遗漏一段时间。
133
+ // 这时候可以触发一次检查,但出于直播可能抽风的原因,为避免风控,一场直播最多触发五次。
134
+ // 测试阶段,还需要一个开关,默认关闭,几个版本后转正使用
135
+ // 也许之后还能链接复用,但也会引入更多复杂度,需要谨慎考虑
136
+ // 虎牙直播结束后可能额外触发导致错误,忽略虎牙直播间:https://www.huya.com/910323
137
+ if (manager.recordRetryImmediately &&
138
+ recorder.providerId !== "HuYa" &&
139
+ reason &&
140
+ reason.includes("invalid stream") &&
141
+ recorder?.liveInfo?.liveId) {
142
+ const key = `${recorder.channelId}-${recorder.liveInfo?.liveId}`;
143
+ if (retryCountObj[key] > 5)
144
+ return;
145
+ if (!retryCountObj[key]) {
146
+ retryCountObj[key] = 0;
147
+ }
148
+ if (retryCountObj[key] < 5) {
149
+ retryCountObj[key]++;
150
+ }
151
+ this.emit("RecorderDebugLog", {
152
+ recorder,
153
+ type: "common",
154
+ text: `录制${recorder?.channelId}因“${reason}”中断,触发重试直播(${retryCountObj[key]})`,
155
+ });
156
+ // 触发一次检查,等待一秒使状态清理完毕
157
+ setTimeout(() => {
158
+ recorder.checkLiveStatusAndRecord({
159
+ getSavePath(data) {
160
+ return genSavePathFromRule(manager, recorder, data);
161
+ },
162
+ });
163
+ }, 1000);
164
+ }
116
165
  });
117
- recorder.on("videoFileCompleted", ({ filename }) => this.emit("videoFileCompleted", { recorder, filename }));
118
- recorder.on("RecordStop", ({ recordHandle, reason }) => this.emit("RecordStop", { recorder, recordHandle, reason }));
119
- recorder.on("Message", (message) => this.emit("Message", { recorder, message }));
120
- recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder, keys }));
121
- recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder, ...log }));
122
166
  recorder.on("progress", (progress) => {
123
- this.emit("RecorderProgress", { recorder, progress });
167
+ this.emit("RecorderProgress", { recorder: recorder.toJSON(), progress });
124
168
  });
125
169
  recorder.on("videoFileCreated", () => {
126
170
  if (!recorder.liveInfo?.liveId)
@@ -129,9 +173,9 @@ export function createRecorderManager(opts) {
129
173
  if (liveStartObj[key])
130
174
  return;
131
175
  liveStartObj[key] = true;
132
- this.emit("RecoderLiveStart", { recorder });
176
+ this.emit("RecoderLiveStart", { recorder: recorder });
133
177
  });
134
- this.emit("RecorderAdded", recorder);
178
+ this.emit("RecorderAdded", recorder.toJSON());
135
179
  return recorder;
136
180
  },
137
181
  removeRecorder(recorder) {
@@ -141,7 +185,7 @@ export function createRecorderManager(opts) {
141
185
  recorder.recordHandle?.stop("remove recorder");
142
186
  this.recorders.splice(idx, 1);
143
187
  delete tempBanObj[recorder.channelId];
144
- this.emit("RecorderRemoved", recorder);
188
+ this.emit("RecorderRemoved", recorder.toJSON());
145
189
  },
146
190
  async startRecord(id) {
147
191
  const recorder = this.recorders.find((item) => item.id === id);
@@ -218,6 +262,7 @@ export function createRecorderManager(opts) {
218
262
  path.join(process.cwd(), "{platform}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}"),
219
263
  autoRemoveSystemReservedChars: opts.autoRemoveSystemReservedChars ?? true,
220
264
  biliBatchQuery: opts.biliBatchQuery ?? false,
265
+ recordRetryImmediately: opts.recordRetryImmediately ?? false,
221
266
  ffmpegOutputArgs: opts.ffmpegOutputArgs ??
222
267
  "-c copy" +
223
268
  /**
@@ -236,6 +281,7 @@ export function createRecorderManager(opts) {
236
281
  * TODO: 如果浏览器行为无法优化,并且想进一步优化加载速度,可以考虑录制时使用 fmp4,录制完成后再转一次普通 mp4。
237
282
  */
238
283
  " -min_frag_duration 10000000",
284
+ cache,
239
285
  };
240
286
  const setProvidersFFMPEGOutputArgs = (ffmpegOutputArgs) => {
241
287
  const args = parseArgsStringToArgv(ffmpegOutputArgs);
@@ -260,6 +306,8 @@ export function genSavePathFromRule(manager, recorder, extData) {
260
306
  // TODO: 这里随便写的,后面再优化
261
307
  const provider = manager.providers.find((p) => p.id === recorder.toJSON().providerId);
262
308
  const now = extData?.startTime ? new Date(extData.startTime) : new Date();
309
+ const owner = (extData?.owner ?? "").replaceAll("%", "_");
310
+ const title = (extData?.title ?? "").replaceAll("%", "_");
263
311
  const params = {
264
312
  platform: provider?.name ?? "unknown",
265
313
  channelId: recorder.channelId,
@@ -271,6 +319,8 @@ export function genSavePathFromRule(manager, recorder, extData) {
271
319
  min: formatDate(now, "mm"),
272
320
  sec: formatDate(now, "ss"),
273
321
  ...extData,
322
+ owner: owner,
323
+ title: title,
274
324
  };
275
325
  if (manager.autoRemoveSystemReservedChars) {
276
326
  for (const key in params) {
@@ -286,4 +336,4 @@ export function genSavePathFromRule(manager, recorder, extData) {
286
336
  }
287
337
  return formatTemplate(savePathRule, params);
288
338
  }
289
- export { StreamManager };
339
+ export { StreamManager, Cache };
@@ -16,6 +16,8 @@ export declare class FFMPEGRecorder extends EventEmitter {
16
16
  isHls: boolean;
17
17
  disableDanma: boolean;
18
18
  url: string;
19
+ formatName: "flv" | "ts" | "fmp4";
20
+ videoFormat: "ts" | "mkv" | "mp4";
19
21
  headers: {
20
22
  [key: string]: string | undefined;
21
23
  } | undefined;
@@ -28,9 +30,9 @@ export declare class FFMPEGRecorder extends EventEmitter {
28
30
  segment: number;
29
31
  outputOptions: string[];
30
32
  inputOptions?: string[];
31
- isHls?: boolean;
32
33
  disableDanma?: boolean;
33
- videoFormat?: "auto" | "ts" | "mkv";
34
+ videoFormat?: "auto" | "ts" | "mkv" | "mp4";
35
+ formatName?: "flv" | "ts" | "fmp4";
34
36
  headers?: {
35
37
  [key: string]: string | undefined;
36
38
  };
@@ -45,5 +47,5 @@ export declare class FFMPEGRecorder extends EventEmitter {
45
47
  run(): void;
46
48
  getArguments(): string[];
47
49
  stop(): Promise<void>;
48
- getExtraDataController(): import("./record_extra_data_controller.js").RecordExtraDataController | null;
50
+ getExtraDataController(): import("../record_extra_data_controller.js").RecordExtraDataController | null;
49
51
  }
@@ -1,6 +1,6 @@
1
1
  import EventEmitter from "node:events";
2
- import { createFFMPEGBuilder, StreamManager, utils } from "./index.js";
3
- import { createInvalidStreamChecker, assert } from "./utils.js";
2
+ import { createFFMPEGBuilder, StreamManager, utils } from "../index.js";
3
+ import { createInvalidStreamChecker, assert } from "../utils.js";
4
4
  export class FFMPEGRecorder extends EventEmitter {
5
5
  onEnd;
6
6
  onUpdateLiveInfo;
@@ -15,30 +15,50 @@ export class FFMPEGRecorder extends EventEmitter {
15
15
  isHls;
16
16
  disableDanma = false;
17
17
  url;
18
+ formatName;
19
+ videoFormat;
18
20
  headers;
19
21
  constructor(opts, onEnd, onUpdateLiveInfo) {
20
22
  super();
21
23
  this.onEnd = onEnd;
22
24
  this.onUpdateLiveInfo = onUpdateLiveInfo;
23
25
  const hasSegment = !!opts.segment;
26
+ this.hasSegment = hasSegment;
27
+ let formatName = "flv";
28
+ if (opts.url.includes(".m3u8")) {
29
+ formatName = "ts";
30
+ }
31
+ this.formatName = opts.formatName ?? formatName;
32
+ if (this.formatName === "fmp4" || this.formatName === "ts") {
33
+ this.isHls = true;
34
+ }
35
+ else {
36
+ this.isHls = false;
37
+ }
38
+ let videoFormat = opts.videoFormat ?? "auto";
39
+ if (videoFormat === "auto") {
40
+ if (!this.hasSegment) {
41
+ videoFormat = "mp4";
42
+ if (this.formatName === "ts") {
43
+ videoFormat = "ts";
44
+ }
45
+ }
46
+ else {
47
+ videoFormat = "ts";
48
+ }
49
+ }
50
+ this.videoFormat = videoFormat;
24
51
  this.disableDanma = opts.disableDanma ?? false;
25
- this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, opts.videoFormat, {
52
+ this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "ffmpeg", this.videoFormat, {
26
53
  onUpdateLiveInfo: this.onUpdateLiveInfo,
27
54
  });
28
55
  this.timeoutChecker = utils.createTimeoutChecker(() => this.onEnd("ffmpeg timeout"), 3 * 10e3, false);
29
- this.hasSegment = hasSegment;
30
56
  this.getSavePath = opts.getSavePath;
31
57
  this.ffmpegOutputOptions = opts.outputOptions;
32
58
  this.inputOptions = opts.inputOptions ?? [];
33
59
  this.url = opts.url;
34
60
  this.segment = opts.segment;
35
61
  this.headers = opts.headers;
36
- if (opts.isHls === undefined) {
37
- this.isHls = this.url.includes("m3u8");
38
- }
39
- else {
40
- this.isHls = opts.isHls;
41
- }
42
62
  this.command = this.createCommand();
43
63
  this.streamManager.on("videoFileCreated", ({ filename, cover }) => {
44
64
  this.emit("videoFileCreated", { filename, cover });
@@ -79,15 +99,16 @@ export class FFMPEGRecorder extends EventEmitter {
79
99
  .on("end", () => this.onEnd("finished"))
80
100
  .on("stderr", async (stderrLine) => {
81
101
  assert(typeof stderrLine === "string");
82
- await this.streamManager.handleVideoStarted(stderrLine);
83
102
  this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
103
+ const [isInvalid, reason] = isInvalidStream(stderrLine);
104
+ if (isInvalid) {
105
+ this.onEnd(reason);
106
+ }
107
+ await this.streamManager.handleVideoStarted(stderrLine);
84
108
  const info = this.formatLine(stderrLine);
85
109
  if (info) {
86
110
  this.emit("progress", info);
87
111
  }
88
- if (isInvalidStream(stderrLine)) {
89
- this.onEnd("invalid stream");
90
- }
91
112
  })
92
113
  .on("stderr", this.timeoutChecker?.update);
93
114
  if (this.hasSegment) {
@@ -0,0 +1,80 @@
1
+ import { FFMPEGRecorder } from "./FFMPEGRecorder.js";
2
+ import { mesioRecorder } from "./mesioRecorder.js";
3
+ export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
4
+ export { mesioRecorder } from "./mesioRecorder.js";
5
+ /**
6
+ * 录制器类型
7
+ */
8
+ export type RecorderType = "ffmpeg" | "mesio";
9
+ /**
10
+ * 录制器基础配置选项
11
+ */
12
+ export interface BaseRecorderOptions {
13
+ url: string;
14
+ getSavePath: (data: {
15
+ startTime: number;
16
+ title?: string;
17
+ }) => string;
18
+ segment: number;
19
+ inputOptions?: string[];
20
+ disableDanma?: boolean;
21
+ videoFormat?: "auto" | "ts" | "mkv";
22
+ formatName?: "flv" | "ts" | "fmp4";
23
+ headers?: {
24
+ [key: string]: string | undefined;
25
+ };
26
+ }
27
+ /**
28
+ * FFMPEG录制器配置选项
29
+ */
30
+ export interface FFMPEGRecorderOptions extends BaseRecorderOptions {
31
+ outputOptions: string[];
32
+ }
33
+ /**
34
+ * Mesio录制器配置选项
35
+ */
36
+ export interface MesioRecorderOptions extends BaseRecorderOptions {
37
+ }
38
+ /**
39
+ * 根据录制器类型获取对应的配置选项类型
40
+ */
41
+ export type RecorderOptions<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorderOptions : MesioRecorderOptions;
42
+ /**
43
+ * 根据录制器类型获取对应的录制器实例类型
44
+ */
45
+ export type RecorderInstance<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorder : mesioRecorder;
46
+ /**
47
+ * 创建录制器的工厂函数
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * // 创建 FFMPEG 录制器
52
+ * const ffmpegRecorder = createRecorder("ffmpeg", {
53
+ * url: "https://example.com/stream.m3u8",
54
+ * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
55
+ * segment: 30,
56
+ * outputOptions: ["-c", "copy"],
57
+ * inputOptions: ["-user_agent", "Custom-Agent"]
58
+ * }, onEnd, onUpdateLiveInfo);
59
+ *
60
+ * // 创建 Mesio 录制器
61
+ * const mesioRecorder = createRecorder("mesio", {
62
+ * url: "https://example.com/stream.m3u8",
63
+ * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
64
+ * segment: 30,
65
+ * inputOptions: ["--fix"]
66
+ * }, onEnd, onUpdateLiveInfo);
67
+ * ```
68
+ *
69
+ * @param type 录制器类型
70
+ * @param opts 录制器配置选项
71
+ * @param onEnd 录制结束回调
72
+ * @param onUpdateLiveInfo 更新直播信息回调
73
+ * @returns 对应类型的录制器实例
74
+ */
75
+ export declare function createBaseRecorder<T extends RecorderType>(type: T, opts: RecorderOptions<T> & {
76
+ mesioOptions?: string[];
77
+ }, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
78
+ title?: string;
79
+ cover?: string;
80
+ }>): RecorderInstance<T>;
@@ -0,0 +1,44 @@
1
+ import { FFMPEGRecorder } from "./FFMPEGRecorder.js";
2
+ import { mesioRecorder } from "./mesioRecorder.js";
3
+ export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
4
+ export { mesioRecorder } from "./mesioRecorder.js";
5
+ /**
6
+ * 创建录制器的工厂函数
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // 创建 FFMPEG 录制器
11
+ * const ffmpegRecorder = createRecorder("ffmpeg", {
12
+ * url: "https://example.com/stream.m3u8",
13
+ * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
14
+ * segment: 30,
15
+ * outputOptions: ["-c", "copy"],
16
+ * inputOptions: ["-user_agent", "Custom-Agent"]
17
+ * }, onEnd, onUpdateLiveInfo);
18
+ *
19
+ * // 创建 Mesio 录制器
20
+ * const mesioRecorder = createRecorder("mesio", {
21
+ * url: "https://example.com/stream.m3u8",
22
+ * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
23
+ * segment: 30,
24
+ * inputOptions: ["--fix"]
25
+ * }, onEnd, onUpdateLiveInfo);
26
+ * ```
27
+ *
28
+ * @param type 录制器类型
29
+ * @param opts 录制器配置选项
30
+ * @param onEnd 录制结束回调
31
+ * @param onUpdateLiveInfo 更新直播信息回调
32
+ * @returns 对应类型的录制器实例
33
+ */
34
+ export function createBaseRecorder(type, opts, onEnd, onUpdateLiveInfo) {
35
+ if (type === "ffmpeg") {
36
+ return new FFMPEGRecorder(opts, onEnd, onUpdateLiveInfo);
37
+ }
38
+ else if (type === "mesio") {
39
+ return new mesioRecorder({ ...opts, inputOptions: opts.mesioOptions ?? [] }, onEnd, onUpdateLiveInfo);
40
+ }
41
+ else {
42
+ throw new Error(`Unsupported recorder type: ${type}`);
43
+ }
44
+ }
@@ -0,0 +1,60 @@
1
+ import EventEmitter from "node:events";
2
+ declare class MesioCommand extends EventEmitter {
3
+ private _input;
4
+ private _output;
5
+ private _inputOptions;
6
+ private process;
7
+ constructor();
8
+ input(source: string): MesioCommand;
9
+ output(target: string): MesioCommand;
10
+ inputOptions(options: string[]): MesioCommand;
11
+ inputOptions(...options: string[]): MesioCommand;
12
+ _getArguments(): string[];
13
+ run(): void;
14
+ kill(signal?: NodeJS.Signals): void;
15
+ }
16
+ export declare const createMesioBuilder: () => MesioCommand;
17
+ export declare class mesioRecorder extends EventEmitter {
18
+ private onEnd;
19
+ private onUpdateLiveInfo;
20
+ private command;
21
+ private streamManager;
22
+ hasSegment: boolean;
23
+ getSavePath: (data: {
24
+ startTime: number;
25
+ title?: string;
26
+ }) => string;
27
+ segment: number;
28
+ inputOptions: string[];
29
+ isHls: boolean;
30
+ disableDanma: boolean;
31
+ url: string;
32
+ headers: {
33
+ [key: string]: string | undefined;
34
+ } | undefined;
35
+ constructor(opts: {
36
+ url: string;
37
+ getSavePath: (data: {
38
+ startTime: number;
39
+ title?: string;
40
+ }) => string;
41
+ segment: number;
42
+ outputOptions?: string[];
43
+ inputOptions?: string[];
44
+ isHls?: boolean;
45
+ disableDanma?: boolean;
46
+ formatName?: "flv" | "ts" | "fmp4";
47
+ headers?: {
48
+ [key: string]: string | undefined;
49
+ };
50
+ }, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
51
+ title?: string;
52
+ cover?: string;
53
+ }>);
54
+ createCommand(): MesioCommand;
55
+ run(): void;
56
+ getArguments(): string[];
57
+ stop(): Promise<void>;
58
+ getExtraDataController(): import("../record_extra_data_controller.js").RecordExtraDataController | null;
59
+ }
60
+ export {};
@@ -0,0 +1,194 @@
1
+ import path from "node:path";
2
+ import EventEmitter from "node:events";
3
+ import { spawn } from "node:child_process";
4
+ import { StreamManager, getMesioPath } from "../index.js";
5
+ // Mesio command builder class similar to ffmpeg
6
+ class MesioCommand extends EventEmitter {
7
+ _input = "";
8
+ _output = "";
9
+ _inputOptions = [];
10
+ process = null;
11
+ constructor() {
12
+ super();
13
+ }
14
+ input(source) {
15
+ this._input = source;
16
+ return this;
17
+ }
18
+ output(target) {
19
+ this._output = target;
20
+ return this;
21
+ }
22
+ inputOptions(...options) {
23
+ const opts = Array.isArray(options[0]) ? options[0] : options;
24
+ this._inputOptions.push(...opts);
25
+ return this;
26
+ }
27
+ _getArguments() {
28
+ const args = [];
29
+ // Add input options first
30
+ args.push(...this._inputOptions);
31
+ // Add output target
32
+ if (this._output) {
33
+ const { dir, name } = path.parse(this._output);
34
+ args.push("-o", dir);
35
+ args.push("-n", name);
36
+ }
37
+ // args.push("-v");
38
+ // Add input source
39
+ if (this._input) {
40
+ args.push(this._input);
41
+ }
42
+ return args;
43
+ }
44
+ run() {
45
+ const args = this._getArguments();
46
+ const mesioExecutable = getMesioPath();
47
+ this.process = spawn(mesioExecutable, args, {
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ });
50
+ if (this.process.stdout) {
51
+ this.process.stdout.on("data", (data) => {
52
+ const output = data.toString();
53
+ // console.log(output);
54
+ this.emit("stderr", output);
55
+ });
56
+ }
57
+ if (this.process.stderr) {
58
+ this.process.stderr.on("data", (data) => {
59
+ const output = data.toString();
60
+ // console.error(output);
61
+ this.emit("stderr", output);
62
+ });
63
+ }
64
+ this.process.on("error", (error) => {
65
+ this.emit("error", error);
66
+ });
67
+ this.process.on("close", (code) => {
68
+ if (code === 0) {
69
+ this.emit("end");
70
+ }
71
+ else {
72
+ this.emit("error", new Error(`mesio process exited with code ${code}`));
73
+ }
74
+ });
75
+ }
76
+ kill(signal = "SIGTERM") {
77
+ if (this.process) {
78
+ this.process.kill(signal);
79
+ }
80
+ }
81
+ }
82
+ // Factory function similar to createFFMPEGBuilder
83
+ export const createMesioBuilder = () => {
84
+ return new MesioCommand();
85
+ };
86
+ export class mesioRecorder extends EventEmitter {
87
+ onEnd;
88
+ onUpdateLiveInfo;
89
+ command;
90
+ streamManager;
91
+ hasSegment;
92
+ getSavePath;
93
+ segment;
94
+ inputOptions = [];
95
+ isHls;
96
+ disableDanma = false;
97
+ url;
98
+ headers;
99
+ constructor(opts, onEnd, onUpdateLiveInfo) {
100
+ super();
101
+ this.onEnd = onEnd;
102
+ this.onUpdateLiveInfo = onUpdateLiveInfo;
103
+ const hasSegment = true;
104
+ this.disableDanma = opts.disableDanma ?? false;
105
+ let videoFormat = "flv";
106
+ if (opts.url.includes(".m3u8")) {
107
+ videoFormat = "ts";
108
+ }
109
+ if (opts.formatName) {
110
+ if (opts.formatName === "fmp4") {
111
+ videoFormat = "m4s";
112
+ }
113
+ else if (opts.formatName === "ts") {
114
+ videoFormat = "ts";
115
+ }
116
+ else if (opts.formatName === "flv") {
117
+ videoFormat = "flv";
118
+ }
119
+ }
120
+ this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "mesio", videoFormat, {
121
+ onUpdateLiveInfo: this.onUpdateLiveInfo,
122
+ });
123
+ this.hasSegment = hasSegment;
124
+ this.getSavePath = opts.getSavePath;
125
+ this.inputOptions = opts.inputOptions ?? [];
126
+ this.url = opts.url;
127
+ this.segment = opts.segment;
128
+ this.headers = opts.headers;
129
+ if (opts.isHls === undefined) {
130
+ this.isHls = this.url.includes("m3u8");
131
+ }
132
+ else {
133
+ this.isHls = opts.isHls;
134
+ }
135
+ this.command = this.createCommand();
136
+ this.streamManager.on("videoFileCreated", ({ filename, cover }) => {
137
+ this.emit("videoFileCreated", { filename, cover });
138
+ });
139
+ this.streamManager.on("videoFileCompleted", ({ filename }) => {
140
+ this.emit("videoFileCompleted", { filename });
141
+ });
142
+ this.streamManager.on("DebugLog", (data) => {
143
+ this.emit("DebugLog", data);
144
+ });
145
+ }
146
+ createCommand() {
147
+ const inputOptions = [
148
+ ...this.inputOptions,
149
+ "--fix",
150
+ "-H",
151
+ "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",
152
+ ];
153
+ if (this.headers) {
154
+ Object.entries(this.headers).forEach(([key, value]) => {
155
+ if (!value)
156
+ return;
157
+ inputOptions.push("-H", `${key}: ${value}`);
158
+ });
159
+ }
160
+ if (this.hasSegment) {
161
+ inputOptions.push("-d", `${this.segment * 60}s`);
162
+ }
163
+ const command = createMesioBuilder()
164
+ .input(this.url)
165
+ .inputOptions(inputOptions)
166
+ .output(this.streamManager.videoFilePath)
167
+ .on("error", this.onEnd)
168
+ .on("end", () => this.onEnd("finished"))
169
+ .on("stderr", async (stderrLine) => {
170
+ await this.streamManager.handleVideoStarted(stderrLine);
171
+ this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
172
+ });
173
+ return command;
174
+ }
175
+ run() {
176
+ this.command.run();
177
+ }
178
+ getArguments() {
179
+ return this.command._getArguments();
180
+ }
181
+ async stop() {
182
+ try {
183
+ // 直接发送SIGINT信号,会导致数据丢失
184
+ this.command.kill("SIGINT");
185
+ await this.streamManager.handleVideoCompleted();
186
+ }
187
+ catch (err) {
188
+ this.emit("DebugLog", { type: "error", text: String(err) });
189
+ }
190
+ }
191
+ getExtraDataController() {
192
+ return this.streamManager?.getExtraDataController();
193
+ }
194
+ }
@@ -1,20 +1,23 @@
1
1
  import EventEmitter from "node:events";
2
- import { createRecordExtraDataController } from "./record_extra_data_controller.js";
2
+ import { createRecordExtraDataController } from "../record_extra_data_controller.js";
3
+ import type { RecorderCreateOpts } from "../recorder.js";
3
4
  export type GetSavePath = (data: {
4
5
  startTime: number;
5
6
  title?: string;
6
7
  }) => string;
8
+ type RecorderType = Exclude<RecorderCreateOpts["recorderType"], undefined | "auto">;
9
+ type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
7
10
  export declare class Segment extends EventEmitter {
8
11
  extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
9
12
  init: boolean;
10
13
  getSavePath: GetSavePath;
11
- /** 原始的ffmpeg文件名,用于重命名 */
14
+ /** 原始的文件名,用于重命名 */
12
15
  rawRecordingVideoPath: string;
13
16
  /** 输出文件名名,不包含拓展名 */
14
17
  outputVideoFilePath: string;
15
18
  disableDanma: boolean;
16
- videoExt: "ts" | "mkv" | "mp4";
17
- constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: "ts" | "mkv" | "mp4");
19
+ videoExt: VideoFormat;
20
+ constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: VideoFormat);
18
21
  handleSegmentEnd(): Promise<void>;
19
22
  onSegmentStart(stderrLine: string, callBack?: {
20
23
  onUpdateLiveInfo: () => Promise<{
@@ -30,9 +33,10 @@ export declare class StreamManager extends EventEmitter {
30
33
  recordSavePath: string;
31
34
  recordStartTime?: number;
32
35
  hasSegment: boolean;
33
- private videoFormat?;
36
+ recorderType: RecorderType;
37
+ private videoFormat;
34
38
  private callBack?;
35
- constructor(getSavePath: GetSavePath, hasSegment: boolean, disableDanma: boolean, videoFormat?: "auto" | "ts" | "mkv", callBack?: {
39
+ constructor(getSavePath: GetSavePath, hasSegment: boolean, disableDanma: boolean, recorderType: RecorderType, videoFormat: VideoFormat, callBack?: {
36
40
  onUpdateLiveInfo: () => Promise<{
37
41
  title?: string;
38
42
  cover?: string;
@@ -40,7 +44,8 @@ export declare class StreamManager extends EventEmitter {
40
44
  });
41
45
  handleVideoStarted(stderrLine: string): Promise<void>;
42
46
  handleVideoCompleted(): Promise<void>;
43
- getExtraDataController(): import("./record_extra_data_controller.js").RecordExtraDataController | null;
44
- get videoExt(): "ts" | "mkv" | "mp4";
47
+ getExtraDataController(): import("../record_extra_data_controller.js").RecordExtraDataController | null;
48
+ get videoExt(): VideoFormat;
45
49
  get videoFilePath(): string;
46
50
  }
51
+ export {};
@@ -1,12 +1,12 @@
1
1
  import EventEmitter from "node:events";
2
2
  import fs from "fs/promises";
3
- import { createRecordExtraDataController } from "./record_extra_data_controller.js";
4
- import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isFfmpegStart, retry, } from "./utils.js";
3
+ import { createRecordExtraDataController } from "../record_extra_data_controller.js";
4
+ import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isMesioStartSegment, isFfmpegStart, retry, cleanTerminalText, } from "../utils.js";
5
5
  export class Segment extends EventEmitter {
6
6
  extraDataController = null;
7
7
  init = true;
8
8
  getSavePath;
9
- /** 原始的ffmpeg文件名,用于重命名 */
9
+ /** 原始的文件名,用于重命名 */
10
10
  rawRecordingVideoPath;
11
11
  /** 输出文件名名,不包含拓展名 */
12
12
  outputVideoFilePath;
@@ -66,8 +66,15 @@ export class Segment extends EventEmitter {
66
66
  if (!this.disableDanma) {
67
67
  this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.json`);
68
68
  }
69
- const regex = /'([^']+)'/;
70
- const match = stderrLine.match(regex);
69
+ // 支持两种格式的正则表达式
70
+ // 1. FFmpeg格式: Opening 'filename' for writing
71
+ // 2. Mesio格式: Opening FLV segment path=filename Processing
72
+ const ffmpegRegex = /'([^']+)'/;
73
+ const mesioRegex = /segment path=([^\n]*)/i;
74
+ let match = stderrLine.match(ffmpegRegex);
75
+ if (!match) {
76
+ match = cleanTerminalText(stderrLine).match(mesioRegex);
77
+ }
71
78
  if (match) {
72
79
  const filename = match[1];
73
80
  this.rawRecordingVideoPath = filename;
@@ -91,13 +98,15 @@ export class StreamManager extends EventEmitter {
91
98
  recordSavePath;
92
99
  recordStartTime;
93
100
  hasSegment;
101
+ recorderType = "ffmpeg";
94
102
  videoFormat;
95
103
  callBack;
96
- constructor(getSavePath, hasSegment, disableDanma, videoFormat, callBack) {
104
+ constructor(getSavePath, hasSegment, disableDanma, recorderType, videoFormat, callBack) {
97
105
  super();
98
106
  const recordSavePath = getSavePath({ startTime: Date.now() });
99
107
  this.recordSavePath = recordSavePath;
100
- this.videoFormat = videoFormat;
108
+ this.videoFormat = videoFormat ?? "auto";
109
+ this.recorderType = recorderType;
101
110
  this.hasSegment = hasSegment;
102
111
  this.callBack = callBack;
103
112
  if (hasSegment) {
@@ -120,29 +129,43 @@ export class StreamManager extends EventEmitter {
120
129
  }
121
130
  }
122
131
  async handleVideoStarted(stderrLine) {
123
- if (this.segment) {
124
- if (isFfmpegStartSegment(stderrLine)) {
125
- await this.segment.onSegmentStart(stderrLine, this.callBack);
132
+ if (this.recorderType === "ffmpeg") {
133
+ if (this.segment) {
134
+ if (isFfmpegStartSegment(stderrLine)) {
135
+ await this.segment.onSegmentStart(stderrLine, this.callBack);
136
+ }
137
+ }
138
+ else {
139
+ // 不能直接在onStart回调进行判断,在某些情况下会链接无法录制的情况
140
+ if (isFfmpegStart(stderrLine)) {
141
+ if (this.recordStartTime)
142
+ return;
143
+ this.recordStartTime = Date.now();
144
+ this.emit("videoFileCreated", { filename: this.videoFilePath });
145
+ }
126
146
  }
127
147
  }
128
- else {
129
- // 不能直接在onStart回调进行判断,在某些情况下会链接无法录制的情况
130
- if (isFfmpegStart(stderrLine)) {
131
- if (this.recordStartTime)
132
- return;
133
- this.recordStartTime = Date.now();
134
- this.emit("videoFileCreated", { filename: this.videoFilePath });
148
+ else if (this.recorderType === "mesio") {
149
+ if (this.segment && isMesioStartSegment(stderrLine)) {
150
+ await this.segment.onSegmentStart(stderrLine, this.callBack);
135
151
  }
136
152
  }
137
153
  }
138
154
  async handleVideoCompleted() {
139
- if (this.segment) {
140
- await this.segment.handleSegmentEnd();
155
+ if (this.recorderType === "ffmpeg") {
156
+ if (this.segment) {
157
+ await this.segment.handleSegmentEnd();
158
+ }
159
+ else {
160
+ if (this.recordStartTime) {
161
+ await this.getExtraDataController()?.flush();
162
+ this.emit("videoFileCompleted", { filename: this.videoFilePath });
163
+ }
164
+ }
141
165
  }
142
- else {
143
- if (this.recordStartTime) {
144
- await this.getExtraDataController()?.flush();
145
- this.emit("videoFileCompleted", { filename: this.videoFilePath });
166
+ else if (this.recorderType === "mesio") {
167
+ if (this.segment) {
168
+ await this.segment.handleSegmentEnd();
146
169
  }
147
170
  }
148
171
  }
@@ -150,19 +173,25 @@ export class StreamManager extends EventEmitter {
150
173
  return this.segment?.extraDataController || this.extraDataController;
151
174
  }
152
175
  get videoExt() {
153
- if (this.videoFormat === "mkv") {
154
- return "mkv";
176
+ if (this.recorderType === "ffmpeg") {
177
+ return this.videoFormat;
155
178
  }
156
- else if (this.videoFormat === "auto") {
157
- if (!this.hasSegment) {
158
- return "mp4";
159
- }
179
+ else if (this.recorderType === "mesio") {
180
+ return this.videoFormat;
181
+ }
182
+ else {
183
+ throw new Error("Unknown recorderType");
160
184
  }
161
- return "ts";
162
185
  }
163
186
  get videoFilePath() {
164
- return this.segment
165
- ? `${this.recordSavePath}-PART%03d.${this.videoExt}`
166
- : `${this.recordSavePath}.${this.videoExt}`;
187
+ if (this.recorderType === "ffmpeg") {
188
+ return this.segment
189
+ ? `${this.recordSavePath}-PART%03d.${this.videoExt}`
190
+ : `${this.recordSavePath}.${this.videoExt}`;
191
+ }
192
+ else if (this.recorderType === "mesio") {
193
+ return `${this.recordSavePath}-PART%i.${this.videoExt}`;
194
+ }
195
+ return `${this.recordSavePath}.${this.videoExt}`;
167
196
  }
168
197
  }
package/lib/recorder.d.ts CHANGED
@@ -2,6 +2,7 @@ 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
6
  type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
6
7
  type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
7
8
  export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
@@ -37,12 +38,14 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
37
38
  formatName?: FormatName;
38
39
  /** 流编码 */
39
40
  codecName?: CodecName;
40
- /** 选择使用的api,虎牙支持 */
41
- api?: "auto" | "web" | "mp";
41
+ /** 选择使用的api,虎牙支持: auto,web,mp,抖音支持:web,webHTML */
42
+ api?: "auto" | "web" | "mp" | "webHTML";
42
43
  /** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制(仅对斗鱼有效),多个关键词用英文逗号分隔 */
43
44
  titleKeywords?: string;
44
45
  /** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
45
46
  videoFormat?: "auto" | "ts" | "mkv";
47
+ /** 录制类型 */
48
+ recorderType?: "auto" | "ffmpeg" | "mesio";
46
49
  /** 流格式优先级 */
47
50
  formatriorities?: Array<"flv" | "hls">;
48
51
  /** 只录制音频 */
@@ -52,8 +55,9 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
52
55
  /** 控制弹幕是否使用服务端时间戳 */
53
56
  useServerTimestamp?: boolean;
54
57
  extra?: Partial<E>;
58
+ cache: Cache;
55
59
  }
56
- export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id">;
60
+ 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">;
57
61
  export type RecorderState = "idle" | "recording" | "stopping-record";
58
62
  export type Progress = {
59
63
  time: string | null;
@@ -117,6 +121,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
117
121
  liveId?: string;
118
122
  };
119
123
  tempStopIntervalCheck?: boolean;
124
+ /** 缓存实例引用,由 manager 设置 */
125
+ cache: Cache;
120
126
  getChannelURL: (this: Recorder<E>) => string;
121
127
  checkLiveStatusAndRecord: (this: Recorder<E>, opts: {
122
128
  getSavePath: GetSavePath;
package/lib/utils.d.ts CHANGED
@@ -36,9 +36,19 @@ export declare function assertObjectType(data: unknown, msg?: string): asserts d
36
36
  export declare function formatDate(date: Date, format: string): string;
37
37
  export declare function removeSystemReservedChars(filename: string): string;
38
38
  export declare function isFfmpegStartSegment(line: string): boolean;
39
+ export declare function isMesioStartSegment(line: string): boolean;
39
40
  export declare function isFfmpegStart(line: string): boolean;
41
+ export declare function cleanTerminalText(text: string): string;
40
42
  export declare const formatTemplate: (string: string, ...args: any[]) => string;
41
- export declare function createInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => boolean;
43
+ /**
44
+ * 检查ffmpeg无效流
45
+ * @param count 连续多少次帧数不变就判定为无效流
46
+ * @returns
47
+ * "receive repart stream": b站最后的无限流
48
+ * "receive invalid aac stream": ADTS无法被解析的flv流
49
+ * "invalid stream": 一段时间内帧数不变
50
+ */
51
+ export declare function createInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => [boolean, string];
42
52
  export declare function createTimeoutChecker(onTimeout: () => void, time: number, autoStart?: boolean): {
43
53
  update: () => void;
44
54
  stop: () => void;
package/lib/utils.js CHANGED
@@ -126,8 +126,15 @@ export function removeSystemReservedChars(filename) {
126
126
  export function isFfmpegStartSegment(line) {
127
127
  return line.includes("Opening ") && line.includes("for writing");
128
128
  }
129
+ export function isMesioStartSegment(line) {
130
+ return line.includes("Opening ") && line.includes("Opening segment");
131
+ }
129
132
  export function isFfmpegStart(line) {
130
- return line.includes("frame=") && line.includes("fps=");
133
+ return ((line.includes("frame=") && line.includes("fps=")) ||
134
+ (line.includes("speed=") && line.includes("time=")));
135
+ }
136
+ export function cleanTerminalText(text) {
137
+ return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "").replace(/[\x00-\x1F\x7F]/g, "");
131
138
  }
132
139
  export const formatTemplate = function template(string, ...args) {
133
140
  const nargs = /\{([0-9a-zA-Z_]+)\}/g;
@@ -155,13 +162,25 @@ export const formatTemplate = function template(string, ...args) {
155
162
  }
156
163
  });
157
164
  };
165
+ /**
166
+ * 检查ffmpeg无效流
167
+ * @param count 连续多少次帧数不变就判定为无效流
168
+ * @returns
169
+ * "receive repart stream": b站最后的无限流
170
+ * "receive invalid aac stream": ADTS无法被解析的flv流
171
+ * "invalid stream": 一段时间内帧数不变
172
+ */
158
173
  export function createInvalidStreamChecker(count = 15) {
159
174
  let prevFrame = 0;
160
175
  let frameUnchangedCount = 0;
161
176
  return (ffmpegLogLine) => {
162
177
  // B站某些cdn在直播结束后仍会返回一些数据 https://github.com/renmu123/biliLive-tools/issues/123
163
178
  if (ffmpegLogLine.includes("New subtitle stream with index")) {
164
- return true;
179
+ return [true, "receive repart stream"];
180
+ }
181
+ // 虎牙某些cdn会返回无法解析ADTS的flv流 https://github.com/renmu123/biliLive-tools/issues/150
182
+ if (ffmpegLogLine.includes("AAC bitstream not in ADTS format and extradata missing")) {
183
+ return [true, "receive invalid aac stream"];
165
184
  }
166
185
  const streamInfo = ffmpegLogLine.match(/frame=\s*(\d+) fps=.*? q=.*? size=.*? time=.*? bitrate=.*? speed=.*?/);
167
186
  if (streamInfo != null) {
@@ -169,16 +188,16 @@ export function createInvalidStreamChecker(count = 15) {
169
188
  const frame = Number(frameText);
170
189
  if (frame === prevFrame) {
171
190
  if (++frameUnchangedCount >= count) {
172
- return true;
191
+ return [true, "invalid stream"];
173
192
  }
174
193
  }
175
194
  else {
176
195
  prevFrame = frame;
177
196
  frameUnchangedCount = 0;
178
197
  }
179
- return false;
198
+ return [false, ""];
180
199
  }
181
- return false;
200
+ return [false, ""];
182
201
  };
183
202
  }
184
203
  export function createTimeoutChecker(onTimeout, time, autoStart = true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "author": "renmu123",
33
33
  "license": "LGPL",
34
34
  "dependencies": {
35
- "@renmu/fluent-ffmpeg": "2.3.2",
35
+ "@renmu/fluent-ffmpeg": "2.3.3",
36
36
  "fast-xml-parser": "^4.5.0",
37
37
  "filenamify": "^6.0.0",
38
38
  "mitt": "^3.0.1",