@bililive-tools/manager 1.6.1 → 1.8.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 +7 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.js +7 -0
- package/lib/manager.d.ts +4 -1
- package/lib/manager.js +11 -5
- package/lib/recorder/BililiveRecorder.d.ts +50 -0
- package/lib/recorder/BililiveRecorder.js +196 -0
- package/lib/recorder/FFMPEGRecorder.d.ts +4 -1
- package/lib/recorder/FFMPEGRecorder.js +12 -7
- package/lib/recorder/IRecorder.d.ts +13 -4
- package/lib/recorder/index.d.ts +30 -8
- package/lib/recorder/index.js +64 -4
- package/lib/recorder/mesioRecorder.d.ts +3 -2
- package/lib/recorder/mesioRecorder.js +19 -22
- package/lib/recorder/streamManager.js +31 -2
- package/lib/recorder.d.ts +7 -2
- package/lib/utils.d.ts +13 -0
- package/lib/utils.js +24 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
| B站 | `@bililive-tools/bilibili-recorder` |
|
|
18
18
|
| 斗鱼 | `@bililive-tools/douyu-recorder` |
|
|
19
19
|
| 虎牙 | `@bililive-tools/huya-recorder` |
|
|
20
|
+
| 抖音 | `@bililive-tools/douyin-recorder` |
|
|
20
21
|
|
|
21
22
|
# 使用
|
|
22
23
|
|
|
@@ -30,6 +31,8 @@ const manager = createRecorderManager({
|
|
|
30
31
|
providers: [provider],
|
|
31
32
|
savePathRule: "D:\\录制\\{platforme}}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}", // 保存路径,占位符见文档,支持 [ejs](https://ejs.co/) 模板引擎
|
|
32
33
|
autoCheckInterval: 1000 * 60, // 自动检查间隔,单位秒
|
|
34
|
+
maxThreadCount: 3, // 检查并发数
|
|
35
|
+
waitTime: 0, // 检查后等待时间
|
|
33
36
|
autoRemoveSystemReservedChars: true, // 移除系统非法字符串
|
|
34
37
|
biliBatchQuery: false, // B站检查使用批量接口
|
|
35
38
|
});
|
|
@@ -75,13 +78,16 @@ manager.startCheckLoop();
|
|
|
75
78
|
### setFFMPEGPath & setMesioPath
|
|
76
79
|
|
|
77
80
|
```ts
|
|
78
|
-
import { setFFMPEGPath, setMesioPath } from "@bililive-tools/manager";
|
|
81
|
+
import { setFFMPEGPath, setMesioPath, setBililivePath } from "@bililive-tools/manager";
|
|
79
82
|
|
|
80
83
|
// 设置ffmpeg可执行路径
|
|
81
84
|
setFFMPEGPath("ffmpeg.exe");
|
|
82
85
|
|
|
83
86
|
// 设置mesio可执行文件路径
|
|
84
87
|
setMesioPath("mesio.exe");
|
|
88
|
+
|
|
89
|
+
// 设置录播姬录制器的可执行文件路径
|
|
90
|
+
setBililivePath("BililiveRecorder.Cli.exe");
|
|
85
91
|
```
|
|
86
92
|
|
|
87
93
|
## savePathRule 占位符参数
|
package/lib/index.d.ts
CHANGED
|
@@ -21,4 +21,6 @@ export declare function setFFMPEGPath(newPath: string): void;
|
|
|
21
21
|
export declare const createFFMPEGBuilder: (...args: Parameters<typeof ffmpeg>) => ffmpeg.FfmpegCommand;
|
|
22
22
|
export declare function setMesioPath(newPath: string): void;
|
|
23
23
|
export declare function getMesioPath(): string;
|
|
24
|
+
export declare function setBililivePath(newPath: string): void;
|
|
25
|
+
export declare function getBililivePath(): string;
|
|
24
26
|
export declare function getDataFolderPath<E extends AnyObject>(provider: RecorderProvider<E>): string;
|
package/lib/index.js
CHANGED
|
@@ -64,6 +64,13 @@ export function setMesioPath(newPath) {
|
|
|
64
64
|
export function getMesioPath() {
|
|
65
65
|
return mesioPath;
|
|
66
66
|
}
|
|
67
|
+
let bililivePath = "BililiveRecorder.Cli";
|
|
68
|
+
export function setBililivePath(newPath) {
|
|
69
|
+
bililivePath = newPath;
|
|
70
|
+
}
|
|
71
|
+
export function getBililivePath() {
|
|
72
|
+
return bililivePath;
|
|
73
|
+
}
|
|
67
74
|
export function getDataFolderPath(provider) {
|
|
68
75
|
return "./" + provider.id;
|
|
69
76
|
}
|
package/lib/manager.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface RecorderProvider<E extends AnyObject> {
|
|
|
20
20
|
fromJSON: <T extends SerializedRecorder<E>>(this: RecorderProvider<E>, json: T) => Recorder<E>;
|
|
21
21
|
setFFMPEGOutputArgs: (this: RecorderProvider<E>, args: string[]) => void;
|
|
22
22
|
}
|
|
23
|
-
declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "ffmpegOutputArgs", "biliBatchQuery", "recordRetryImmediately"];
|
|
23
|
+
declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "maxThreadCount", "waitTime", "ffmpegOutputArgs", "biliBatchQuery", "recordRetryImmediately"];
|
|
24
24
|
type ConfigurableProp = (typeof configurableProps)[number];
|
|
25
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<{
|
|
26
26
|
error: {
|
|
@@ -39,6 +39,7 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
39
39
|
recorder: SerializedRecorder<E>;
|
|
40
40
|
filename: string;
|
|
41
41
|
cover?: string;
|
|
42
|
+
rawFilename?: string;
|
|
42
43
|
};
|
|
43
44
|
videoFileCompleted: {
|
|
44
45
|
recorder: SerializedRecorder<E>;
|
|
@@ -80,6 +81,8 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
80
81
|
stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
81
82
|
cutRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
82
83
|
autoCheckInterval: number;
|
|
84
|
+
maxThreadCount: number;
|
|
85
|
+
waitTime: number;
|
|
83
86
|
isCheckLoopRunning: boolean;
|
|
84
87
|
startCheckLoop: (this: RecorderManager<ME, P, PE, E>) => void;
|
|
85
88
|
stopCheckLoop: (this: RecorderManager<ME, P, PE, E>) => void;
|
package/lib/manager.js
CHANGED
|
@@ -4,13 +4,15 @@ import ejs from "ejs";
|
|
|
4
4
|
import { omit, range } from "lodash-es";
|
|
5
5
|
import { parseArgsStringToArgv } from "string-argv";
|
|
6
6
|
import { getBiliStatusInfoByRoomIds } from "./api.js";
|
|
7
|
-
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, } from "./utils.js";
|
|
7
|
+
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, } from "./utils.js";
|
|
8
8
|
import { StreamManager } from "./recorder/streamManager.js";
|
|
9
9
|
import { Cache } from "./cache.js";
|
|
10
10
|
const configurableProps = [
|
|
11
11
|
"savePathRule",
|
|
12
12
|
"autoRemoveSystemReservedChars",
|
|
13
13
|
"autoCheckInterval",
|
|
14
|
+
"maxThreadCount",
|
|
15
|
+
"waitTime",
|
|
14
16
|
"ffmpegOutputArgs",
|
|
15
17
|
"biliBatchQuery",
|
|
16
18
|
"recordRetryImmediately",
|
|
@@ -38,7 +40,6 @@ export function createRecorderManager(opts) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
};
|
|
41
|
-
const maxThreadCount = 3;
|
|
42
43
|
// 这里暂时不打算用 state == recording 来过滤,provider 必须内部自己处理录制过程中的 check,
|
|
43
44
|
// 这样可以防止一些意外调用 checkLiveStatusAndRecord 时出现重复录制。
|
|
44
45
|
let needCheckRecorders = recorders
|
|
@@ -75,10 +76,13 @@ export function createRecorderManager(opts) {
|
|
|
75
76
|
banLiveId,
|
|
76
77
|
});
|
|
77
78
|
};
|
|
78
|
-
threads = threads.concat(range(0, maxThreadCount).map(async () => {
|
|
79
|
+
threads = threads.concat(range(0, manager.maxThreadCount).map(async () => {
|
|
79
80
|
while (needCheckRecorders.length > 0) {
|
|
80
81
|
try {
|
|
81
82
|
await checkOnce();
|
|
83
|
+
if (manager.waitTime > 0) {
|
|
84
|
+
await sleep(manager.waitTime);
|
|
85
|
+
}
|
|
82
86
|
}
|
|
83
87
|
catch (err) {
|
|
84
88
|
manager.emit("error", { source: "checkOnceInThread", err });
|
|
@@ -116,12 +120,12 @@ export function createRecorderManager(opts) {
|
|
|
116
120
|
this.recorders.push(recorder);
|
|
117
121
|
recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle }));
|
|
118
122
|
recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
|
|
119
|
-
recorder.on("videoFileCreated", ({ filename, cover }) => {
|
|
123
|
+
recorder.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
|
|
120
124
|
if (recorder.saveCover && recorder?.liveInfo?.cover) {
|
|
121
125
|
const coverPath = replaceExtName(filename, ".jpg");
|
|
122
126
|
downloadImage(cover ?? recorder?.liveInfo?.cover, coverPath);
|
|
123
127
|
}
|
|
124
|
-
this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename });
|
|
128
|
+
this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename, rawFilename });
|
|
125
129
|
});
|
|
126
130
|
recorder.on("videoFileCompleted", ({ filename }) => this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename }));
|
|
127
131
|
recorder.on("Message", (message) => this.emit("Message", { recorder: recorder.toJSON(), message }));
|
|
@@ -227,6 +231,8 @@ export function createRecorderManager(opts) {
|
|
|
227
231
|
return recorder;
|
|
228
232
|
},
|
|
229
233
|
autoCheckInterval: opts.autoCheckInterval ?? 1000,
|
|
234
|
+
maxThreadCount: opts.maxThreadCount ?? 3,
|
|
235
|
+
waitTime: opts.waitTime ?? 0,
|
|
230
236
|
isCheckLoopRunning: false,
|
|
231
237
|
startCheckLoop() {
|
|
232
238
|
if (this.isCheckLoopRunning)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import { IRecorder, BililiveRecorderOptions } from "./IRecorder.js";
|
|
3
|
+
declare class BililiveRecorderCommand extends EventEmitter {
|
|
4
|
+
private _input;
|
|
5
|
+
private _output;
|
|
6
|
+
private _inputOptions;
|
|
7
|
+
private process;
|
|
8
|
+
constructor();
|
|
9
|
+
input(source: string): BililiveRecorderCommand;
|
|
10
|
+
output(target: string): BililiveRecorderCommand;
|
|
11
|
+
inputOptions(options: string[]): BililiveRecorderCommand;
|
|
12
|
+
inputOptions(...options: string[]): BililiveRecorderCommand;
|
|
13
|
+
_getArguments(): string[];
|
|
14
|
+
run(): void;
|
|
15
|
+
kill(signal?: NodeJS.Signals): void;
|
|
16
|
+
}
|
|
17
|
+
export declare const createBililiveBuilder: () => BililiveRecorderCommand;
|
|
18
|
+
export declare class BililiveRecorder extends EventEmitter implements IRecorder {
|
|
19
|
+
private onEnd;
|
|
20
|
+
private onUpdateLiveInfo;
|
|
21
|
+
type: "bililive";
|
|
22
|
+
private command;
|
|
23
|
+
private streamManager;
|
|
24
|
+
readonly hasSegment: boolean;
|
|
25
|
+
readonly getSavePath: (data: {
|
|
26
|
+
startTime: number;
|
|
27
|
+
title?: string;
|
|
28
|
+
}) => string;
|
|
29
|
+
readonly segment: number;
|
|
30
|
+
readonly inputOptions: string[];
|
|
31
|
+
readonly disableDanma: boolean;
|
|
32
|
+
readonly url: string;
|
|
33
|
+
readonly debugLevel: "none" | "basic" | "verbose";
|
|
34
|
+
readonly headers: {
|
|
35
|
+
[key: string]: string | undefined;
|
|
36
|
+
} | undefined;
|
|
37
|
+
constructor(opts: BililiveRecorderOptions, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
38
|
+
title?: string;
|
|
39
|
+
cover?: string;
|
|
40
|
+
}>);
|
|
41
|
+
createCommand(): BililiveRecorderCommand;
|
|
42
|
+
formatLine(line: string): {
|
|
43
|
+
time: string | null;
|
|
44
|
+
} | null;
|
|
45
|
+
run(): void;
|
|
46
|
+
getArguments(): string[];
|
|
47
|
+
stop(): Promise<void>;
|
|
48
|
+
getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
|
|
49
|
+
}
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { StreamManager, getBililivePath } from "../index.js";
|
|
4
|
+
// Bililive command builder class similar to ffmpeg
|
|
5
|
+
class BililiveRecorderCommand extends EventEmitter {
|
|
6
|
+
_input = "";
|
|
7
|
+
_output = "";
|
|
8
|
+
_inputOptions = [];
|
|
9
|
+
process = null;
|
|
10
|
+
constructor() {
|
|
11
|
+
super();
|
|
12
|
+
}
|
|
13
|
+
input(source) {
|
|
14
|
+
this._input = source;
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
output(target) {
|
|
18
|
+
this._output = target;
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
inputOptions(...options) {
|
|
22
|
+
const opts = Array.isArray(options[0]) ? options[0] : options;
|
|
23
|
+
this._inputOptions.push(...opts);
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
_getArguments() {
|
|
27
|
+
const args = ["downloader", "-p"];
|
|
28
|
+
// Add input source
|
|
29
|
+
if (this._input) {
|
|
30
|
+
args.push(this._input);
|
|
31
|
+
}
|
|
32
|
+
// Add input options first
|
|
33
|
+
args.push(...this._inputOptions);
|
|
34
|
+
// Add output target
|
|
35
|
+
if (this._output) {
|
|
36
|
+
// const { dir, name } = path.parse(this._output);
|
|
37
|
+
// args.push("-o", dir);
|
|
38
|
+
args.push(this._output);
|
|
39
|
+
}
|
|
40
|
+
// args.push("-v");
|
|
41
|
+
return args;
|
|
42
|
+
}
|
|
43
|
+
run() {
|
|
44
|
+
const args = this._getArguments();
|
|
45
|
+
const bililiveExecutable = getBililivePath();
|
|
46
|
+
console.log("Starting BililiveRecorder with args:", bililiveExecutable, args);
|
|
47
|
+
this.process = spawn(bililiveExecutable, 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
|
+
[];
|
|
68
|
+
this.process.on("close", (code) => {
|
|
69
|
+
if (code === 0) {
|
|
70
|
+
this.emit("end");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.emit("error", new Error(`bililive process exited with code ${code}`));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
kill(signal = "SIGTERM") {
|
|
78
|
+
if (this.process) {
|
|
79
|
+
this.process.kill(signal);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Factory function similar to createFFMPEGBuilder
|
|
84
|
+
export const createBililiveBuilder = () => {
|
|
85
|
+
return new BililiveRecorderCommand();
|
|
86
|
+
};
|
|
87
|
+
export class BililiveRecorder extends EventEmitter {
|
|
88
|
+
onEnd;
|
|
89
|
+
onUpdateLiveInfo;
|
|
90
|
+
type = "bililive";
|
|
91
|
+
command;
|
|
92
|
+
streamManager;
|
|
93
|
+
hasSegment;
|
|
94
|
+
getSavePath;
|
|
95
|
+
segment;
|
|
96
|
+
inputOptions = [];
|
|
97
|
+
disableDanma = false;
|
|
98
|
+
url;
|
|
99
|
+
debugLevel = "none";
|
|
100
|
+
headers;
|
|
101
|
+
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
102
|
+
super();
|
|
103
|
+
this.onEnd = onEnd;
|
|
104
|
+
this.onUpdateLiveInfo = onUpdateLiveInfo;
|
|
105
|
+
const hasSegment = true;
|
|
106
|
+
this.disableDanma = opts.disableDanma ?? false;
|
|
107
|
+
this.debugLevel = opts.debugLevel ?? "none";
|
|
108
|
+
let videoFormat = "flv";
|
|
109
|
+
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "bililive", videoFormat, {
|
|
110
|
+
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
111
|
+
});
|
|
112
|
+
this.hasSegment = hasSegment;
|
|
113
|
+
this.getSavePath = opts.getSavePath;
|
|
114
|
+
this.inputOptions = [];
|
|
115
|
+
this.url = opts.url;
|
|
116
|
+
this.segment = opts.segment;
|
|
117
|
+
this.headers = opts.headers;
|
|
118
|
+
this.command = this.createCommand();
|
|
119
|
+
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
|
|
120
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
121
|
+
});
|
|
122
|
+
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
123
|
+
this.emit("videoFileCompleted", { filename });
|
|
124
|
+
});
|
|
125
|
+
this.streamManager.on("DebugLog", (data) => {
|
|
126
|
+
this.emit("DebugLog", data);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
createCommand() {
|
|
130
|
+
const inputOptions = [
|
|
131
|
+
...this.inputOptions,
|
|
132
|
+
"-h",
|
|
133
|
+
"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",
|
|
134
|
+
];
|
|
135
|
+
if (this.debugLevel === "verbose") {
|
|
136
|
+
inputOptions.push("-l", "Debug");
|
|
137
|
+
}
|
|
138
|
+
if (this.headers) {
|
|
139
|
+
Object.entries(this.headers).forEach(([key, value]) => {
|
|
140
|
+
if (!value)
|
|
141
|
+
return;
|
|
142
|
+
inputOptions.push("-h", `${key}: ${value}`);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (this.hasSegment) {
|
|
146
|
+
inputOptions.push("-d", `${this.segment}`);
|
|
147
|
+
}
|
|
148
|
+
const command = createBililiveBuilder()
|
|
149
|
+
.input(this.url)
|
|
150
|
+
.inputOptions(inputOptions)
|
|
151
|
+
.output(this.streamManager.videoFilePath)
|
|
152
|
+
.on("error", this.onEnd)
|
|
153
|
+
.on("end", () => this.onEnd("finished"))
|
|
154
|
+
.on("stderr", async (stderrLine) => {
|
|
155
|
+
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
156
|
+
await this.streamManager.handleVideoStarted(stderrLine);
|
|
157
|
+
const info = this.formatLine(stderrLine);
|
|
158
|
+
if (info) {
|
|
159
|
+
this.emit("progress", info);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return command;
|
|
163
|
+
}
|
|
164
|
+
formatLine(line) {
|
|
165
|
+
if (!line.includes("下载进度:")) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
let time = null;
|
|
169
|
+
const timeMatch = line.match(/录制时长:\s*([0-9:]+)\s/);
|
|
170
|
+
if (timeMatch) {
|
|
171
|
+
time = timeMatch[1];
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
time,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
run() {
|
|
178
|
+
this.command.run();
|
|
179
|
+
}
|
|
180
|
+
getArguments() {
|
|
181
|
+
return this.command._getArguments();
|
|
182
|
+
}
|
|
183
|
+
async stop() {
|
|
184
|
+
try {
|
|
185
|
+
// 直接发送SIGINT信号,会导致数据丢失
|
|
186
|
+
this.command.kill("SIGINT");
|
|
187
|
+
await this.streamManager.handleVideoCompleted();
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
this.emit("DebugLog", { type: "error", text: String(err) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
getExtraDataController() {
|
|
194
|
+
return this.streamManager?.getExtraDataController();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import { IRecorder, FFMPEGRecorderOptions } from "./IRecorder.js";
|
|
3
|
+
import type { FormatName } from "./index.js";
|
|
3
4
|
export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
|
|
4
5
|
private onEnd;
|
|
5
6
|
private onUpdateLiveInfo;
|
|
7
|
+
type: "ffmpeg";
|
|
6
8
|
private command;
|
|
7
9
|
private streamManager;
|
|
8
10
|
private timeoutChecker;
|
|
@@ -17,8 +19,9 @@ export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
|
|
|
17
19
|
readonly isHls: boolean;
|
|
18
20
|
readonly disableDanma: boolean;
|
|
19
21
|
readonly url: string;
|
|
20
|
-
formatName:
|
|
22
|
+
formatName: FormatName;
|
|
21
23
|
videoFormat: "ts" | "mkv" | "mp4";
|
|
24
|
+
readonly debugLevel: "none" | "basic" | "verbose";
|
|
22
25
|
readonly headers: {
|
|
23
26
|
[key: string]: string | undefined;
|
|
24
27
|
} | undefined;
|
|
@@ -4,6 +4,7 @@ import { createInvalidStreamChecker, assert } from "../utils.js";
|
|
|
4
4
|
export class FFMPEGRecorder extends EventEmitter {
|
|
5
5
|
onEnd;
|
|
6
6
|
onUpdateLiveInfo;
|
|
7
|
+
type = "ffmpeg";
|
|
7
8
|
command;
|
|
8
9
|
streamManager;
|
|
9
10
|
timeoutChecker;
|
|
@@ -17,6 +18,7 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
17
18
|
url;
|
|
18
19
|
formatName;
|
|
19
20
|
videoFormat;
|
|
21
|
+
debugLevel = "none";
|
|
20
22
|
headers;
|
|
21
23
|
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
22
24
|
super();
|
|
@@ -24,11 +26,8 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
24
26
|
this.onUpdateLiveInfo = onUpdateLiveInfo;
|
|
25
27
|
const hasSegment = !!opts.segment;
|
|
26
28
|
this.hasSegment = hasSegment;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
formatName = "ts";
|
|
30
|
-
}
|
|
31
|
-
this.formatName = opts.formatName ?? formatName;
|
|
29
|
+
this.debugLevel = opts.debugLevel ?? "none";
|
|
30
|
+
this.formatName = opts.formatName;
|
|
32
31
|
if (this.formatName === "fmp4" || this.formatName === "ts") {
|
|
33
32
|
this.isHls = true;
|
|
34
33
|
}
|
|
@@ -60,8 +59,8 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
60
59
|
this.segment = opts.segment;
|
|
61
60
|
this.headers = opts.headers;
|
|
62
61
|
this.command = this.createCommand();
|
|
63
|
-
this.streamManager.on("videoFileCreated", ({ filename, cover }) => {
|
|
64
|
-
this.emit("videoFileCreated", { filename, cover });
|
|
62
|
+
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
|
|
63
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
65
64
|
});
|
|
66
65
|
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
67
66
|
this.emit("videoFileCompleted", { filename });
|
|
@@ -79,6 +78,12 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
79
78
|
"-user_agent",
|
|
80
79
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36",
|
|
81
80
|
];
|
|
81
|
+
if (this.isHls) {
|
|
82
|
+
inputOptions.push(...["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "3"]);
|
|
83
|
+
}
|
|
84
|
+
if (this.debugLevel === "verbose") {
|
|
85
|
+
inputOptions.push("-loglevel", "debug");
|
|
86
|
+
}
|
|
82
87
|
if (this.headers) {
|
|
83
88
|
const headers = [];
|
|
84
89
|
Object.entries(this.headers).forEach(([key, value]) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { FormatName } from "./index.js";
|
|
2
3
|
/**
|
|
3
4
|
* 录制器构造函数选项的基础接口
|
|
4
5
|
*/
|
|
@@ -11,19 +12,21 @@ export interface BaseRecorderOptions {
|
|
|
11
12
|
segment: number;
|
|
12
13
|
inputOptions?: string[];
|
|
13
14
|
disableDanma?: boolean;
|
|
14
|
-
formatName
|
|
15
|
+
formatName: FormatName;
|
|
16
|
+
debugLevel?: "none" | "basic" | "verbose";
|
|
15
17
|
headers?: {
|
|
16
18
|
[key: string]: string | undefined;
|
|
17
19
|
};
|
|
20
|
+
videoFormat?: "auto" | "ts" | "mkv" | "mp4";
|
|
18
21
|
}
|
|
19
22
|
/**
|
|
20
23
|
* 录制器接口定义
|
|
21
24
|
*/
|
|
22
25
|
export interface IRecorder extends EventEmitter {
|
|
26
|
+
type: "ffmpeg" | "mesio" | "bililive";
|
|
23
27
|
readonly hasSegment: boolean;
|
|
24
28
|
readonly segment: number;
|
|
25
29
|
readonly inputOptions: string[];
|
|
26
|
-
readonly isHls: boolean;
|
|
27
30
|
readonly disableDanma: boolean;
|
|
28
31
|
readonly url: string;
|
|
29
32
|
readonly headers: {
|
|
@@ -41,6 +44,7 @@ export interface IRecorder extends EventEmitter {
|
|
|
41
44
|
on(event: "videoFileCreated", listener: (data: {
|
|
42
45
|
filename: string;
|
|
43
46
|
cover?: string;
|
|
47
|
+
rawFilename?: string;
|
|
44
48
|
}) => void): this;
|
|
45
49
|
on(event: "videoFileCompleted", listener: (data: {
|
|
46
50
|
filename: string;
|
|
@@ -54,6 +58,7 @@ export interface IRecorder extends EventEmitter {
|
|
|
54
58
|
emit(event: "videoFileCreated", data: {
|
|
55
59
|
filename: string;
|
|
56
60
|
cover?: string;
|
|
61
|
+
rawFilename?: string;
|
|
57
62
|
}): boolean;
|
|
58
63
|
emit(event: "videoFileCompleted", data: {
|
|
59
64
|
filename: string;
|
|
@@ -70,12 +75,16 @@ export interface IRecorder extends EventEmitter {
|
|
|
70
75
|
*/
|
|
71
76
|
export interface FFMPEGRecorderOptions extends BaseRecorderOptions {
|
|
72
77
|
outputOptions: string[];
|
|
73
|
-
videoFormat?: "auto" | "ts" | "mkv" | "mp4";
|
|
74
78
|
}
|
|
75
79
|
/**
|
|
76
80
|
* Mesio录制器特定选项
|
|
77
81
|
*/
|
|
78
82
|
export interface MesioRecorderOptions extends BaseRecorderOptions {
|
|
79
83
|
outputOptions?: string[];
|
|
80
|
-
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Bililive录制器特定选项
|
|
87
|
+
*/
|
|
88
|
+
export interface BililiveRecorderOptions extends BaseRecorderOptions {
|
|
89
|
+
outputOptions?: string[];
|
|
81
90
|
}
|
package/lib/recorder/index.d.ts
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
import { FFMPEGRecorder } from "./FFMPEGRecorder.js";
|
|
2
|
-
import {
|
|
2
|
+
import { MesioRecorder } from "./mesioRecorder.js";
|
|
3
|
+
import { BililiveRecorder } from "./BililiveRecorder.js";
|
|
3
4
|
export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
|
|
4
|
-
export {
|
|
5
|
-
|
|
5
|
+
export { MesioRecorder } from "./mesioRecorder.js";
|
|
6
|
+
export { BililiveRecorder } from "./BililiveRecorder.js";
|
|
7
|
+
import type { IRecorder, FFMPEGRecorderOptions, MesioRecorderOptions, BililiveRecorderOptions } from "./IRecorder.js";
|
|
6
8
|
/**
|
|
7
9
|
* 录制器类型
|
|
8
10
|
*/
|
|
9
|
-
export type RecorderType = "ffmpeg" | "mesio";
|
|
11
|
+
export type RecorderType = "ffmpeg" | "mesio" | "bililive";
|
|
12
|
+
export type FormatName = "flv" | "ts" | "fmp4";
|
|
10
13
|
/**
|
|
11
14
|
* 根据录制器类型获取对应的配置选项类型
|
|
12
15
|
*/
|
|
13
|
-
export type RecorderOptions<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorderOptions : MesioRecorderOptions;
|
|
16
|
+
export type RecorderOptions<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorderOptions : T extends "mesio" ? MesioRecorderOptions : BililiveRecorderOptions;
|
|
14
17
|
/**
|
|
15
18
|
* 根据录制器类型获取对应的录制器实例类型
|
|
16
19
|
*/
|
|
17
|
-
export type RecorderInstance<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorder :
|
|
20
|
+
export type RecorderInstance<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorder : T extends "mesio" ? MesioRecorder : BililiveRecorder;
|
|
21
|
+
type RecorderOpts = FFMPEGRecorderOptions | MesioRecorderOptions | BililiveRecorderOptions;
|
|
18
22
|
/**
|
|
19
23
|
* 创建录制器的工厂函数
|
|
20
24
|
*/
|
|
21
|
-
export declare function
|
|
22
|
-
|
|
25
|
+
export declare function createRecorder<T extends RecorderType>(type: T, opts: RecorderOptions<T> & {
|
|
26
|
+
onlyAudio?: boolean;
|
|
27
|
+
}, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
28
|
+
title?: string;
|
|
29
|
+
cover?: string;
|
|
30
|
+
}>): IRecorder;
|
|
31
|
+
/**
|
|
32
|
+
* 选择录制器
|
|
33
|
+
*/
|
|
34
|
+
export declare function selectRecorder(preferredRecorder: "auto" | RecorderType | undefined): RecorderType;
|
|
35
|
+
/**
|
|
36
|
+
* 判断原始录制流格式,flv, ts, m4s
|
|
37
|
+
*/
|
|
38
|
+
export declare function getSourceFormatName(streamUrl: string, formatName: FormatName | undefined): FormatName;
|
|
39
|
+
type PickPartial<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & Partial<Pick<T, K>>;
|
|
40
|
+
/**
|
|
41
|
+
* 创建录制器的工厂函数
|
|
42
|
+
*/
|
|
43
|
+
export declare function createBaseRecorder(type: "auto" | RecorderType | undefined, opts: PickPartial<RecorderOpts, "formatName"> & {
|
|
44
|
+
onlyAudio?: boolean;
|
|
23
45
|
}, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
24
46
|
title?: string;
|
|
25
47
|
cover?: string;
|
package/lib/recorder/index.js
CHANGED
|
@@ -1,18 +1,78 @@
|
|
|
1
1
|
import { FFMPEGRecorder } from "./FFMPEGRecorder.js";
|
|
2
|
-
import {
|
|
2
|
+
import { MesioRecorder } from "./mesioRecorder.js";
|
|
3
|
+
import { BililiveRecorder } from "./BililiveRecorder.js";
|
|
3
4
|
export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
|
|
4
|
-
export {
|
|
5
|
+
export { MesioRecorder } from "./mesioRecorder.js";
|
|
6
|
+
export { BililiveRecorder } from "./BililiveRecorder.js";
|
|
5
7
|
/**
|
|
6
8
|
* 创建录制器的工厂函数
|
|
7
9
|
*/
|
|
8
|
-
export function
|
|
10
|
+
export function createRecorder(type, opts, onEnd, onUpdateLiveInfo) {
|
|
9
11
|
if (type === "ffmpeg") {
|
|
10
12
|
return new FFMPEGRecorder(opts, onEnd, onUpdateLiveInfo);
|
|
11
13
|
}
|
|
12
14
|
else if (type === "mesio") {
|
|
13
|
-
return new
|
|
15
|
+
return new MesioRecorder(opts, onEnd, onUpdateLiveInfo);
|
|
16
|
+
}
|
|
17
|
+
else if (type === "bililive") {
|
|
18
|
+
if (opts.formatName === "flv") {
|
|
19
|
+
// 录播姬引擎不支持只录音频
|
|
20
|
+
if (!opts.onlyAudio) {
|
|
21
|
+
return new BililiveRecorder(opts, onEnd, onUpdateLiveInfo);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return new FFMPEGRecorder(opts, onEnd, onUpdateLiveInfo);
|
|
14
25
|
}
|
|
15
26
|
else {
|
|
16
27
|
throw new Error(`Unsupported recorder type: ${type}`);
|
|
17
28
|
}
|
|
18
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* 选择录制器
|
|
32
|
+
*/
|
|
33
|
+
export function selectRecorder(preferredRecorder) {
|
|
34
|
+
let recorderType;
|
|
35
|
+
if (preferredRecorder === "auto") {
|
|
36
|
+
// 默认优先使用ffmpeg录制器
|
|
37
|
+
recorderType = "ffmpeg";
|
|
38
|
+
}
|
|
39
|
+
else if (preferredRecorder === "ffmpeg") {
|
|
40
|
+
recorderType = "ffmpeg";
|
|
41
|
+
}
|
|
42
|
+
else if (preferredRecorder === "mesio") {
|
|
43
|
+
recorderType = "mesio";
|
|
44
|
+
}
|
|
45
|
+
else if (preferredRecorder === "bililive") {
|
|
46
|
+
recorderType = "bililive";
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
recorderType = "ffmpeg";
|
|
50
|
+
}
|
|
51
|
+
return recorderType;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 判断原始录制流格式,flv, ts, m4s
|
|
55
|
+
*/
|
|
56
|
+
export function getSourceFormatName(streamUrl, formatName) {
|
|
57
|
+
if (formatName) {
|
|
58
|
+
return formatName;
|
|
59
|
+
}
|
|
60
|
+
if (streamUrl.includes(".m3u8")) {
|
|
61
|
+
return "ts";
|
|
62
|
+
}
|
|
63
|
+
else if (streamUrl.includes(".m4s")) {
|
|
64
|
+
// TODO: 使用b站的流进行测试
|
|
65
|
+
return "fmp4";
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
return "flv";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 创建录制器的工厂函数
|
|
73
|
+
*/
|
|
74
|
+
export function createBaseRecorder(type, opts, onEnd, onUpdateLiveInfo) {
|
|
75
|
+
const recorderType = selectRecorder(type);
|
|
76
|
+
const sourceFormatName = getSourceFormatName(opts.url, opts.formatName);
|
|
77
|
+
return createRecorder(recorderType, { ...opts, formatName: sourceFormatName }, onEnd, onUpdateLiveInfo);
|
|
78
|
+
}
|
|
@@ -15,9 +15,10 @@ declare class MesioCommand extends EventEmitter {
|
|
|
15
15
|
kill(signal?: NodeJS.Signals): void;
|
|
16
16
|
}
|
|
17
17
|
export declare const createMesioBuilder: () => MesioCommand;
|
|
18
|
-
export declare class
|
|
18
|
+
export declare class MesioRecorder extends EventEmitter implements IRecorder {
|
|
19
19
|
private onEnd;
|
|
20
20
|
private onUpdateLiveInfo;
|
|
21
|
+
type: "mesio";
|
|
21
22
|
private command;
|
|
22
23
|
private streamManager;
|
|
23
24
|
readonly hasSegment: boolean;
|
|
@@ -27,9 +28,9 @@ export declare class mesioRecorder extends EventEmitter implements IRecorder {
|
|
|
27
28
|
}) => string;
|
|
28
29
|
readonly segment: number;
|
|
29
30
|
readonly inputOptions: string[];
|
|
30
|
-
readonly isHls: boolean;
|
|
31
31
|
readonly disableDanma: boolean;
|
|
32
32
|
readonly url: string;
|
|
33
|
+
readonly debugLevel: "none" | "basic" | "verbose";
|
|
33
34
|
readonly headers: {
|
|
34
35
|
[key: string]: string | undefined;
|
|
35
36
|
} | undefined;
|
|
@@ -84,18 +84,19 @@ class MesioCommand extends EventEmitter {
|
|
|
84
84
|
export const createMesioBuilder = () => {
|
|
85
85
|
return new MesioCommand();
|
|
86
86
|
};
|
|
87
|
-
export class
|
|
87
|
+
export class MesioRecorder extends EventEmitter {
|
|
88
88
|
onEnd;
|
|
89
89
|
onUpdateLiveInfo;
|
|
90
|
+
type = "mesio";
|
|
90
91
|
command;
|
|
91
92
|
streamManager;
|
|
92
93
|
hasSegment;
|
|
93
94
|
getSavePath;
|
|
94
95
|
segment;
|
|
95
96
|
inputOptions = [];
|
|
96
|
-
isHls;
|
|
97
97
|
disableDanma = false;
|
|
98
98
|
url;
|
|
99
|
+
debugLevel = "none";
|
|
99
100
|
headers;
|
|
100
101
|
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
101
102
|
super();
|
|
@@ -103,39 +104,32 @@ export class mesioRecorder extends EventEmitter {
|
|
|
103
104
|
this.onUpdateLiveInfo = onUpdateLiveInfo;
|
|
104
105
|
const hasSegment = true;
|
|
105
106
|
this.disableDanma = opts.disableDanma ?? false;
|
|
107
|
+
this.debugLevel = opts.debugLevel ?? "none";
|
|
106
108
|
let videoFormat = "flv";
|
|
107
109
|
if (opts.url.includes(".m3u8")) {
|
|
108
110
|
videoFormat = "ts";
|
|
109
111
|
}
|
|
110
|
-
if (opts.formatName) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
videoFormat = "flv";
|
|
119
|
-
}
|
|
112
|
+
if (opts.formatName === "fmp4") {
|
|
113
|
+
videoFormat = "m4s";
|
|
114
|
+
}
|
|
115
|
+
else if (opts.formatName === "ts") {
|
|
116
|
+
videoFormat = "ts";
|
|
117
|
+
}
|
|
118
|
+
else if (opts.formatName === "flv") {
|
|
119
|
+
videoFormat = "flv";
|
|
120
120
|
}
|
|
121
121
|
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "mesio", videoFormat, {
|
|
122
122
|
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
123
123
|
});
|
|
124
124
|
this.hasSegment = hasSegment;
|
|
125
125
|
this.getSavePath = opts.getSavePath;
|
|
126
|
-
this.inputOptions =
|
|
126
|
+
this.inputOptions = [];
|
|
127
127
|
this.url = opts.url;
|
|
128
128
|
this.segment = opts.segment;
|
|
129
129
|
this.headers = opts.headers;
|
|
130
|
-
if (opts.isHls === undefined) {
|
|
131
|
-
this.isHls = this.url.includes("m3u8");
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
this.isHls = opts.isHls;
|
|
135
|
-
}
|
|
136
130
|
this.command = this.createCommand();
|
|
137
|
-
this.streamManager.on("videoFileCreated", ({ filename, cover }) => {
|
|
138
|
-
this.emit("videoFileCreated", { filename, cover });
|
|
131
|
+
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
|
|
132
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
139
133
|
});
|
|
140
134
|
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
141
135
|
this.emit("videoFileCompleted", { filename });
|
|
@@ -151,6 +145,9 @@ export class mesioRecorder extends EventEmitter {
|
|
|
151
145
|
"-H",
|
|
152
146
|
"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",
|
|
153
147
|
];
|
|
148
|
+
if (this.debugLevel === "verbose") {
|
|
149
|
+
inputOptions.push("-v");
|
|
150
|
+
}
|
|
154
151
|
if (this.headers) {
|
|
155
152
|
Object.entries(this.headers).forEach(([key, value]) => {
|
|
156
153
|
if (!value)
|
|
@@ -168,8 +165,8 @@ export class mesioRecorder extends EventEmitter {
|
|
|
168
165
|
.on("error", this.onEnd)
|
|
169
166
|
.on("end", () => this.onEnd("finished"))
|
|
170
167
|
.on("stderr", async (stderrLine) => {
|
|
171
|
-
await this.streamManager.handleVideoStarted(stderrLine);
|
|
172
168
|
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
169
|
+
await this.streamManager.handleVideoStarted(stderrLine);
|
|
173
170
|
});
|
|
174
171
|
return command;
|
|
175
172
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import fs from "fs/promises";
|
|
3
3
|
import { createRecordExtraDataController } from "../xml_stream_controller.js";
|
|
4
|
-
import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isMesioStartSegment, isFfmpegStart, retry, cleanTerminalText, } from "../utils.js";
|
|
4
|
+
import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isMesioStartSegment, isBililiveStartSegment, isFfmpegStart, retry, cleanTerminalText, } from "../utils.js";
|
|
5
5
|
export class Segment extends EventEmitter {
|
|
6
6
|
extraDataController = null;
|
|
7
7
|
init = true;
|
|
@@ -27,8 +27,12 @@ export class Segment extends EventEmitter {
|
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
try {
|
|
30
|
+
this.emit("DebugLog", {
|
|
31
|
+
type: "info",
|
|
32
|
+
text: `Renaming segment file: ${this.rawRecordingVideoPath} -> ${this.outputFilePath}`,
|
|
33
|
+
});
|
|
30
34
|
await Promise.all([
|
|
31
|
-
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath),
|
|
35
|
+
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath), 20, 1000),
|
|
32
36
|
this.extraDataController?.flush(),
|
|
33
37
|
]);
|
|
34
38
|
this.emit("videoFileCompleted", { filename: this.outputFilePath });
|
|
@@ -38,6 +42,8 @@ export class Segment extends EventEmitter {
|
|
|
38
42
|
type: "error",
|
|
39
43
|
text: "videoFileCompleted error " + String(err),
|
|
40
44
|
});
|
|
45
|
+
// 虽然重命名失败了,但是也当作完成处理,避免卡住录制流程
|
|
46
|
+
this.emit("videoFileCompleted", { filename: this.outputFilePath });
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
async onSegmentStart(stderrLine, callBack) {
|
|
@@ -75,14 +81,17 @@ export class Segment extends EventEmitter {
|
|
|
75
81
|
if (!match) {
|
|
76
82
|
match = cleanTerminalText(stderrLine).match(mesioRegex);
|
|
77
83
|
}
|
|
84
|
+
this.emit("DebugLog", { type: "ffmpeg", text: `Segment start line: ${stderrLine}` });
|
|
78
85
|
if (match) {
|
|
79
86
|
const filename = match[1];
|
|
80
87
|
this.rawRecordingVideoPath = filename;
|
|
81
88
|
this.emit("videoFileCreated", {
|
|
89
|
+
rawFilename: filename,
|
|
82
90
|
filename: this.outputFilePath,
|
|
83
91
|
title: liveInfo?.title,
|
|
84
92
|
cover: liveInfo?.cover,
|
|
85
93
|
});
|
|
94
|
+
this.emit("DebugLog", { type: "ffmpeg", text: JSON.stringify(match, null, 2) });
|
|
86
95
|
}
|
|
87
96
|
else {
|
|
88
97
|
this.emit("DebugLog", { type: "ffmpeg", text: "No match found" });
|
|
@@ -147,6 +156,15 @@ export class StreamManager extends EventEmitter {
|
|
|
147
156
|
}
|
|
148
157
|
else if (this.recorderType === "mesio") {
|
|
149
158
|
if (this.segment && isMesioStartSegment(stderrLine)) {
|
|
159
|
+
for (let line of stderrLine.split("\n")) {
|
|
160
|
+
if (isMesioStartSegment(line)) {
|
|
161
|
+
await this.segment.onSegmentStart(line, this.callBack);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (this.recorderType === "bililive") {
|
|
167
|
+
if (this.segment && isBililiveStartSegment(stderrLine)) {
|
|
150
168
|
await this.segment.onSegmentStart(stderrLine, this.callBack);
|
|
151
169
|
}
|
|
152
170
|
}
|
|
@@ -168,6 +186,11 @@ export class StreamManager extends EventEmitter {
|
|
|
168
186
|
await this.segment.handleSegmentEnd();
|
|
169
187
|
}
|
|
170
188
|
}
|
|
189
|
+
else if (this.recorderType === "bililive") {
|
|
190
|
+
if (this.segment) {
|
|
191
|
+
await this.segment.handleSegmentEnd();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
171
194
|
}
|
|
172
195
|
getExtraDataController() {
|
|
173
196
|
return this.segment?.extraDataController || this.extraDataController;
|
|
@@ -179,6 +202,9 @@ export class StreamManager extends EventEmitter {
|
|
|
179
202
|
else if (this.recorderType === "mesio") {
|
|
180
203
|
return this.videoFormat;
|
|
181
204
|
}
|
|
205
|
+
else if (this.recorderType === "bililive") {
|
|
206
|
+
return "flv";
|
|
207
|
+
}
|
|
182
208
|
else {
|
|
183
209
|
throw new Error("Unknown recorderType");
|
|
184
210
|
}
|
|
@@ -192,6 +218,9 @@ export class StreamManager extends EventEmitter {
|
|
|
192
218
|
else if (this.recorderType === "mesio") {
|
|
193
219
|
return `${this.recordSavePath}-PART%i.${this.videoExt}`;
|
|
194
220
|
}
|
|
221
|
+
else if (this.recorderType === "bililive") {
|
|
222
|
+
return `${this.recordSavePath}.${this.videoExt}`;
|
|
223
|
+
}
|
|
195
224
|
return `${this.recordSavePath}.${this.videoExt}`;
|
|
196
225
|
}
|
|
197
226
|
}
|
package/lib/recorder.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
46
46
|
/** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
|
|
47
47
|
videoFormat?: "auto" | "ts" | "mkv";
|
|
48
48
|
/** 录制类型 */
|
|
49
|
-
recorderType?: "auto" | "ffmpeg" | "mesio";
|
|
49
|
+
recorderType?: "auto" | "ffmpeg" | "mesio" | "bililive";
|
|
50
50
|
/** 流格式优先级 */
|
|
51
51
|
formatriorities?: Array<"flv" | "hls">;
|
|
52
52
|
/** 只录制音频 */
|
|
@@ -56,10 +56,14 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
56
56
|
/** 控制弹幕是否使用服务端时间戳 */
|
|
57
57
|
useServerTimestamp?: boolean;
|
|
58
58
|
extra?: Partial<E>;
|
|
59
|
+
/** 调试等级 */
|
|
60
|
+
debugLevel?: "none" | "basic" | "verbose";
|
|
61
|
+
/** 缓存 */
|
|
59
62
|
cache: Cache;
|
|
60
63
|
}
|
|
61
64
|
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">;
|
|
62
|
-
|
|
65
|
+
/** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单 */
|
|
66
|
+
export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked";
|
|
63
67
|
export type Progress = {
|
|
64
68
|
time: string | null;
|
|
65
69
|
};
|
|
@@ -89,6 +93,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
89
93
|
videoFileCreated: {
|
|
90
94
|
filename: string;
|
|
91
95
|
cover?: string;
|
|
96
|
+
rawFilename?: string;
|
|
92
97
|
};
|
|
93
98
|
videoFileCompleted: {
|
|
94
99
|
filename: string;
|
package/lib/utils.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ 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
39
|
export declare function isMesioStartSegment(line: string): boolean;
|
|
40
|
+
export declare function isBililiveStartSegment(line: string): boolean;
|
|
40
41
|
export declare function isFfmpegStart(line: string): boolean;
|
|
41
42
|
export declare function cleanTerminalText(text: string): string;
|
|
42
43
|
export declare const formatTemplate: (string: string, ...args: any[]) => string;
|
|
@@ -72,6 +73,15 @@ export declare function sortByKeyOrder<T, K extends keyof T>(objects: T[], order
|
|
|
72
73
|
*/
|
|
73
74
|
export declare function retry<T>(fn: () => Promise<T>, retries?: number, delay?: number): Promise<T>;
|
|
74
75
|
export declare const isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
|
|
76
|
+
export declare const sleep: (ms: number) => Promise<unknown>;
|
|
77
|
+
/**
|
|
78
|
+
* 检查标题是否包含黑名单关键词
|
|
79
|
+
*/
|
|
80
|
+
declare function hasBlockedTitleKeywords(title: string, titleKeywords: string | undefined): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* 检查是否需要进行标题关键词检查
|
|
83
|
+
*/
|
|
84
|
+
declare function shouldCheckTitleKeywords(isManualStart: boolean | undefined, titleKeywords: string | undefined): boolean;
|
|
75
85
|
declare const _default: {
|
|
76
86
|
replaceExtName: typeof replaceExtName;
|
|
77
87
|
singleton: typeof singleton;
|
|
@@ -91,5 +101,8 @@ declare const _default: {
|
|
|
91
101
|
sortByKeyOrder: typeof sortByKeyOrder;
|
|
92
102
|
retry: typeof retry;
|
|
93
103
|
isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
|
|
104
|
+
hasBlockedTitleKeywords: typeof hasBlockedTitleKeywords;
|
|
105
|
+
shouldCheckTitleKeywords: typeof shouldCheckTitleKeywords;
|
|
106
|
+
sleep: (ms: number) => Promise<unknown>;
|
|
94
107
|
};
|
|
95
108
|
export default _default;
|
package/lib/utils.js
CHANGED
|
@@ -127,7 +127,10 @@ export function isFfmpegStartSegment(line) {
|
|
|
127
127
|
return line.includes("Opening ") && line.includes("for writing");
|
|
128
128
|
}
|
|
129
129
|
export function isMesioStartSegment(line) {
|
|
130
|
-
return line.includes("Opening
|
|
130
|
+
return line.includes("Opening segment");
|
|
131
|
+
}
|
|
132
|
+
export function isBililiveStartSegment(line) {
|
|
133
|
+
return line.includes("创建录制文件");
|
|
131
134
|
}
|
|
132
135
|
export function isFfmpegStart(line) {
|
|
133
136
|
return ((line.includes("frame=") && line.includes("fps=")) ||
|
|
@@ -323,6 +326,23 @@ function isBetweenTime(currentTime, timeRange) {
|
|
|
323
326
|
}
|
|
324
327
|
return start <= current && current <= end;
|
|
325
328
|
}
|
|
329
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
330
|
+
/**
|
|
331
|
+
* 检查标题是否包含黑名单关键词
|
|
332
|
+
*/
|
|
333
|
+
function hasBlockedTitleKeywords(title, titleKeywords) {
|
|
334
|
+
const keywords = (titleKeywords ?? "")
|
|
335
|
+
.split(",")
|
|
336
|
+
.map((k) => k.trim())
|
|
337
|
+
.filter((k) => k);
|
|
338
|
+
return keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* 检查是否需要进行标题关键词检查
|
|
342
|
+
*/
|
|
343
|
+
function shouldCheckTitleKeywords(isManualStart, titleKeywords) {
|
|
344
|
+
return (!isManualStart && !!titleKeywords && typeof titleKeywords === "string" && !!titleKeywords.trim());
|
|
345
|
+
}
|
|
326
346
|
export default {
|
|
327
347
|
replaceExtName,
|
|
328
348
|
singleton,
|
|
@@ -342,4 +362,7 @@ export default {
|
|
|
342
362
|
sortByKeyOrder,
|
|
343
363
|
retry,
|
|
344
364
|
isBetweenTimeRange,
|
|
365
|
+
hasBlockedTitleKeywords,
|
|
366
|
+
shouldCheckTitleKeywords,
|
|
367
|
+
sleep,
|
|
345
368
|
};
|