@bililive-tools/manager 1.5.0 → 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 +6 -4
- package/lib/cache.d.ts +17 -0
- package/lib/cache.js +47 -0
- package/lib/common.d.ts +1 -1
- package/lib/common.js +3 -1
- package/lib/index.d.ts +4 -1
- package/lib/index.js +11 -2
- package/lib/manager.d.ts +19 -14
- package/lib/manager.js +65 -15
- package/lib/{FFMPEGRecorder.d.ts → recorder/FFMPEGRecorder.d.ts} +5 -3
- package/lib/{FFMPEGRecorder.js → recorder/FFMPEGRecorder.js} +35 -14
- package/lib/recorder/index.d.ts +80 -0
- package/lib/recorder/index.js +44 -0
- package/lib/recorder/mesioRecorder.d.ts +60 -0
- package/lib/recorder/mesioRecorder.js +194 -0
- package/lib/{streamManager.d.ts → recorder/streamManager.d.ts} +13 -8
- package/lib/{streamManager.js → recorder/streamManager.js} +62 -33
- package/lib/recorder.d.ts +9 -3
- package/lib/utils.d.ts +11 -1
- package/lib/utils.js +22 -4
- package/package.json +2 -2
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 = [
|
|
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:
|
|
31
|
+
recorder: SerializedRecorder<E>;
|
|
31
32
|
recordHandle: RecordHandle;
|
|
32
33
|
};
|
|
33
34
|
RecordSegment: {
|
|
34
|
-
recorder:
|
|
35
|
+
recorder: SerializedRecorder<E>;
|
|
35
36
|
recordHandle?: RecordHandle;
|
|
36
37
|
};
|
|
37
38
|
videoFileCreated: {
|
|
38
|
-
recorder:
|
|
39
|
+
recorder: SerializedRecorder<E>;
|
|
39
40
|
filename: string;
|
|
40
41
|
cover?: string;
|
|
41
42
|
};
|
|
42
43
|
videoFileCompleted: {
|
|
43
|
-
recorder:
|
|
44
|
+
recorder: SerializedRecorder<E>;
|
|
44
45
|
filename: string;
|
|
45
46
|
};
|
|
46
47
|
RecorderProgress: {
|
|
47
|
-
recorder:
|
|
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:
|
|
55
|
+
recorder: SerializedRecorder<E>;
|
|
55
56
|
recordHandle: RecordHandle;
|
|
56
57
|
reason?: string;
|
|
57
58
|
};
|
|
58
59
|
Message: {
|
|
59
|
-
recorder:
|
|
60
|
+
recorder: SerializedRecorder<E>;
|
|
60
61
|
message: Message;
|
|
61
62
|
};
|
|
62
63
|
RecorderUpdated: {
|
|
63
|
-
recorder:
|
|
64
|
+
recorder: SerializedRecorder<E>;
|
|
64
65
|
keys: (string | keyof Recorder<E>)[];
|
|
65
66
|
};
|
|
66
|
-
RecorderAdded:
|
|
67
|
-
RecorderRemoved:
|
|
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(
|
|
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("
|
|
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 "
|
|
3
|
-
import { createInvalidStreamChecker, assert } from "
|
|
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,
|
|
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 "
|
|
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
|
-
/**
|
|
14
|
+
/** 原始的文件名,用于重命名 */
|
|
12
15
|
rawRecordingVideoPath: string;
|
|
13
16
|
/** 输出文件名名,不包含拓展名 */
|
|
14
17
|
outputVideoFilePath: string;
|
|
15
18
|
disableDanma: boolean;
|
|
16
|
-
videoExt:
|
|
17
|
-
constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt:
|
|
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
|
-
|
|
36
|
+
recorderType: RecorderType;
|
|
37
|
+
private videoFormat;
|
|
34
38
|
private callBack?;
|
|
35
|
-
constructor(getSavePath: GetSavePath, hasSegment: boolean, disableDanma: boolean,
|
|
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("
|
|
44
|
-
get videoExt():
|
|
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 "
|
|
4
|
-
import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isFfmpegStart, retry, } from "
|
|
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
|
-
/**
|
|
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
|
-
|
|
70
|
-
|
|
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.
|
|
124
|
-
if (
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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.
|
|
140
|
-
|
|
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.
|
|
144
|
-
await this.
|
|
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.
|
|
154
|
-
return
|
|
176
|
+
if (this.recorderType === "ffmpeg") {
|
|
177
|
+
return this.videoFormat;
|
|
155
178
|
}
|
|
156
|
-
else if (this.
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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,10 +126,16 @@ 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
133
|
return ((line.includes("frame=") && line.includes("fps=")) ||
|
|
131
134
|
(line.includes("speed=") && line.includes("time=")));
|
|
132
135
|
}
|
|
136
|
+
export function cleanTerminalText(text) {
|
|
137
|
+
return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "").replace(/[\x00-\x1F\x7F]/g, "");
|
|
138
|
+
}
|
|
133
139
|
export const formatTemplate = function template(string, ...args) {
|
|
134
140
|
const nargs = /\{([0-9a-zA-Z_]+)\}/g;
|
|
135
141
|
let params;
|
|
@@ -156,13 +162,25 @@ export const formatTemplate = function template(string, ...args) {
|
|
|
156
162
|
}
|
|
157
163
|
});
|
|
158
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
|
+
*/
|
|
159
173
|
export function createInvalidStreamChecker(count = 15) {
|
|
160
174
|
let prevFrame = 0;
|
|
161
175
|
let frameUnchangedCount = 0;
|
|
162
176
|
return (ffmpegLogLine) => {
|
|
163
177
|
// B站某些cdn在直播结束后仍会返回一些数据 https://github.com/renmu123/biliLive-tools/issues/123
|
|
164
178
|
if (ffmpegLogLine.includes("New subtitle stream with index")) {
|
|
165
|
-
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"];
|
|
166
184
|
}
|
|
167
185
|
const streamInfo = ffmpegLogLine.match(/frame=\s*(\d+) fps=.*? q=.*? size=.*? time=.*? bitrate=.*? speed=.*?/);
|
|
168
186
|
if (streamInfo != null) {
|
|
@@ -170,16 +188,16 @@ export function createInvalidStreamChecker(count = 15) {
|
|
|
170
188
|
const frame = Number(frameText);
|
|
171
189
|
if (frame === prevFrame) {
|
|
172
190
|
if (++frameUnchangedCount >= count) {
|
|
173
|
-
return true;
|
|
191
|
+
return [true, "invalid stream"];
|
|
174
192
|
}
|
|
175
193
|
}
|
|
176
194
|
else {
|
|
177
195
|
prevFrame = frame;
|
|
178
196
|
frameUnchangedCount = 0;
|
|
179
197
|
}
|
|
180
|
-
return false;
|
|
198
|
+
return [false, ""];
|
|
181
199
|
}
|
|
182
|
-
return false;
|
|
200
|
+
return [false, ""];
|
|
183
201
|
};
|
|
184
202
|
}
|
|
185
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.
|
|
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.
|
|
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",
|