@bililive-tools/manager 1.5.0 → 1.6.1
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 +5 -2
- package/lib/index.js +11 -2
- package/lib/manager.d.ts +20 -15
- package/lib/manager.js +65 -15
- package/lib/recorder/FFMPEGRecorder.d.ts +37 -0
- package/lib/{FFMPEGRecorder.js → recorder/FFMPEGRecorder.js} +35 -14
- package/lib/recorder/IRecorder.d.ts +81 -0
- package/lib/recorder/IRecorder.js +1 -0
- package/lib/recorder/index.d.ts +26 -0
- package/lib/recorder/index.js +18 -0
- package/lib/recorder/mesioRecorder.d.ts +46 -0
- package/lib/recorder/mesioRecorder.js +195 -0
- package/lib/{streamManager.d.ts → recorder/streamManager.d.ts} +13 -8
- package/lib/{streamManager.js → recorder/streamManager.js} +64 -35
- package/lib/recorder.d.ts +13 -6
- package/lib/utils.d.ts +11 -1
- package/lib/utils.js +22 -4
- package/lib/xml_stream_controller.d.ts +23 -0
- package/lib/xml_stream_controller.js +240 -0
- package/package.json +3 -3
- package/lib/FFMPEGRecorder.d.ts +0 -49
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
import type { IRecorder, FFMPEGRecorderOptions, MesioRecorderOptions } from "./IRecorder.js";
|
|
6
|
+
/**
|
|
7
|
+
* 录制器类型
|
|
8
|
+
*/
|
|
9
|
+
export type RecorderType = "ffmpeg" | "mesio";
|
|
10
|
+
/**
|
|
11
|
+
* 根据录制器类型获取对应的配置选项类型
|
|
12
|
+
*/
|
|
13
|
+
export type RecorderOptions<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorderOptions : MesioRecorderOptions;
|
|
14
|
+
/**
|
|
15
|
+
* 根据录制器类型获取对应的录制器实例类型
|
|
16
|
+
*/
|
|
17
|
+
export type RecorderInstance<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorder : mesioRecorder;
|
|
18
|
+
/**
|
|
19
|
+
* 创建录制器的工厂函数
|
|
20
|
+
*/
|
|
21
|
+
export declare function createBaseRecorder<T extends RecorderType>(type: T, opts: RecorderOptions<T> & {
|
|
22
|
+
mesioOptions?: string[];
|
|
23
|
+
}, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
24
|
+
title?: string;
|
|
25
|
+
cover?: string;
|
|
26
|
+
}>): IRecorder;
|
|
@@ -0,0 +1,18 @@
|
|
|
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 function createBaseRecorder(type, opts, onEnd, onUpdateLiveInfo) {
|
|
9
|
+
if (type === "ffmpeg") {
|
|
10
|
+
return new FFMPEGRecorder(opts, onEnd, onUpdateLiveInfo);
|
|
11
|
+
}
|
|
12
|
+
else if (type === "mesio") {
|
|
13
|
+
return new mesioRecorder({ ...opts, inputOptions: opts.mesioOptions ?? [] }, onEnd, onUpdateLiveInfo);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
throw new Error(`Unsupported recorder type: ${type}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import { IRecorder, MesioRecorderOptions } from "./IRecorder.js";
|
|
3
|
+
declare class MesioCommand extends EventEmitter {
|
|
4
|
+
private _input;
|
|
5
|
+
private _output;
|
|
6
|
+
private _inputOptions;
|
|
7
|
+
private process;
|
|
8
|
+
constructor();
|
|
9
|
+
input(source: string): MesioCommand;
|
|
10
|
+
output(target: string): MesioCommand;
|
|
11
|
+
inputOptions(options: string[]): MesioCommand;
|
|
12
|
+
inputOptions(...options: string[]): MesioCommand;
|
|
13
|
+
_getArguments(): string[];
|
|
14
|
+
run(): void;
|
|
15
|
+
kill(signal?: NodeJS.Signals): void;
|
|
16
|
+
}
|
|
17
|
+
export declare const createMesioBuilder: () => MesioCommand;
|
|
18
|
+
export declare class mesioRecorder extends EventEmitter implements IRecorder {
|
|
19
|
+
private onEnd;
|
|
20
|
+
private onUpdateLiveInfo;
|
|
21
|
+
private command;
|
|
22
|
+
private streamManager;
|
|
23
|
+
readonly hasSegment: boolean;
|
|
24
|
+
readonly getSavePath: (data: {
|
|
25
|
+
startTime: number;
|
|
26
|
+
title?: string;
|
|
27
|
+
}) => string;
|
|
28
|
+
readonly segment: number;
|
|
29
|
+
readonly inputOptions: string[];
|
|
30
|
+
readonly isHls: boolean;
|
|
31
|
+
readonly disableDanma: boolean;
|
|
32
|
+
readonly url: string;
|
|
33
|
+
readonly headers: {
|
|
34
|
+
[key: string]: string | undefined;
|
|
35
|
+
} | undefined;
|
|
36
|
+
constructor(opts: MesioRecorderOptions, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
37
|
+
title?: string;
|
|
38
|
+
cover?: string;
|
|
39
|
+
}>);
|
|
40
|
+
createCommand(): MesioCommand;
|
|
41
|
+
run(): void;
|
|
42
|
+
getArguments(): string[];
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
|
|
45
|
+
}
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
[];
|
|
68
|
+
this.process.on("close", (code) => {
|
|
69
|
+
if (code === 0) {
|
|
70
|
+
this.emit("end");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.emit("error", new Error(`mesio 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 createMesioBuilder = () => {
|
|
85
|
+
return new MesioCommand();
|
|
86
|
+
};
|
|
87
|
+
export class mesioRecorder extends EventEmitter {
|
|
88
|
+
onEnd;
|
|
89
|
+
onUpdateLiveInfo;
|
|
90
|
+
command;
|
|
91
|
+
streamManager;
|
|
92
|
+
hasSegment;
|
|
93
|
+
getSavePath;
|
|
94
|
+
segment;
|
|
95
|
+
inputOptions = [];
|
|
96
|
+
isHls;
|
|
97
|
+
disableDanma = false;
|
|
98
|
+
url;
|
|
99
|
+
headers;
|
|
100
|
+
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
101
|
+
super();
|
|
102
|
+
this.onEnd = onEnd;
|
|
103
|
+
this.onUpdateLiveInfo = onUpdateLiveInfo;
|
|
104
|
+
const hasSegment = true;
|
|
105
|
+
this.disableDanma = opts.disableDanma ?? false;
|
|
106
|
+
let videoFormat = "flv";
|
|
107
|
+
if (opts.url.includes(".m3u8")) {
|
|
108
|
+
videoFormat = "ts";
|
|
109
|
+
}
|
|
110
|
+
if (opts.formatName) {
|
|
111
|
+
if (opts.formatName === "fmp4") {
|
|
112
|
+
videoFormat = "m4s";
|
|
113
|
+
}
|
|
114
|
+
else if (opts.formatName === "ts") {
|
|
115
|
+
videoFormat = "ts";
|
|
116
|
+
}
|
|
117
|
+
else if (opts.formatName === "flv") {
|
|
118
|
+
videoFormat = "flv";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "mesio", videoFormat, {
|
|
122
|
+
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
123
|
+
});
|
|
124
|
+
this.hasSegment = hasSegment;
|
|
125
|
+
this.getSavePath = opts.getSavePath;
|
|
126
|
+
this.inputOptions = opts.inputOptions ?? [];
|
|
127
|
+
this.url = opts.url;
|
|
128
|
+
this.segment = opts.segment;
|
|
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
|
+
this.command = this.createCommand();
|
|
137
|
+
this.streamManager.on("videoFileCreated", ({ filename, cover }) => {
|
|
138
|
+
this.emit("videoFileCreated", { filename, cover });
|
|
139
|
+
});
|
|
140
|
+
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
141
|
+
this.emit("videoFileCompleted", { filename });
|
|
142
|
+
});
|
|
143
|
+
this.streamManager.on("DebugLog", (data) => {
|
|
144
|
+
this.emit("DebugLog", data);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
createCommand() {
|
|
148
|
+
const inputOptions = [
|
|
149
|
+
...this.inputOptions,
|
|
150
|
+
"--fix",
|
|
151
|
+
"-H",
|
|
152
|
+
"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
|
+
];
|
|
154
|
+
if (this.headers) {
|
|
155
|
+
Object.entries(this.headers).forEach(([key, value]) => {
|
|
156
|
+
if (!value)
|
|
157
|
+
return;
|
|
158
|
+
inputOptions.push("-H", `${key}: ${value}`);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (this.hasSegment) {
|
|
162
|
+
inputOptions.push("-d", `${this.segment * 60}s`);
|
|
163
|
+
}
|
|
164
|
+
const command = createMesioBuilder()
|
|
165
|
+
.input(this.url)
|
|
166
|
+
.inputOptions(inputOptions)
|
|
167
|
+
.output(this.streamManager.videoFilePath)
|
|
168
|
+
.on("error", this.onEnd)
|
|
169
|
+
.on("end", () => this.onEnd("finished"))
|
|
170
|
+
.on("stderr", async (stderrLine) => {
|
|
171
|
+
await this.streamManager.handleVideoStarted(stderrLine);
|
|
172
|
+
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
173
|
+
});
|
|
174
|
+
return command;
|
|
175
|
+
}
|
|
176
|
+
run() {
|
|
177
|
+
this.command.run();
|
|
178
|
+
}
|
|
179
|
+
getArguments() {
|
|
180
|
+
return this.command._getArguments();
|
|
181
|
+
}
|
|
182
|
+
async stop() {
|
|
183
|
+
try {
|
|
184
|
+
// 直接发送SIGINT信号,会导致数据丢失
|
|
185
|
+
this.command.kill("SIGINT");
|
|
186
|
+
await this.streamManager.handleVideoCompleted();
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
this.emit("DebugLog", { type: "error", text: String(err) });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
getExtraDataController() {
|
|
193
|
+
return this.streamManager?.getExtraDataController();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
|
-
import { createRecordExtraDataController } from "
|
|
2
|
+
import { createRecordExtraDataController } from "../xml_stream_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("../xml_stream_controller.js").XmlStreamController | 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 "../xml_stream_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;
|
|
@@ -64,10 +64,17 @@ export class Segment extends EventEmitter {
|
|
|
64
64
|
});
|
|
65
65
|
ensureFolderExist(this.outputVideoFilePath);
|
|
66
66
|
if (!this.disableDanma) {
|
|
67
|
-
this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.
|
|
67
|
+
this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
|
|
68
|
+
}
|
|
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=(.+?\.(?:flv|ts|m4s))/is;
|
|
74
|
+
let match = stderrLine.match(ffmpegRegex);
|
|
75
|
+
if (!match) {
|
|
76
|
+
match = cleanTerminalText(stderrLine).match(mesioRegex);
|
|
68
77
|
}
|
|
69
|
-
const regex = /'([^']+)'/;
|
|
70
|
-
const match = stderrLine.match(regex);
|
|
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) {
|
|
@@ -113,36 +122,50 @@ export class StreamManager extends EventEmitter {
|
|
|
113
122
|
});
|
|
114
123
|
}
|
|
115
124
|
else {
|
|
116
|
-
const extraDataSavePath = replaceExtName(recordSavePath, ".
|
|
125
|
+
const extraDataSavePath = replaceExtName(recordSavePath, ".xml");
|
|
117
126
|
if (!disableDanma) {
|
|
118
127
|
this.extraDataController = createRecordExtraDataController(extraDataSavePath);
|
|
119
128
|
}
|
|
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> {
|
|
@@ -9,6 +10,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
9
10
|
channelId: ChannelId;
|
|
10
11
|
id?: string;
|
|
11
12
|
remarks?: string;
|
|
13
|
+
weight?: number;
|
|
12
14
|
disableAutoCheck?: boolean;
|
|
13
15
|
disableProvideCommentsWhenRecording?: boolean;
|
|
14
16
|
quality: Quality;
|
|
@@ -24,7 +26,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
24
26
|
/** 身份验证 */
|
|
25
27
|
auth?: string;
|
|
26
28
|
/** cookie所有者uid,B站弹幕录制 */
|
|
27
|
-
uid?: number;
|
|
29
|
+
uid?: number | string;
|
|
28
30
|
/** 画质匹配重试次数 */
|
|
29
31
|
qualityRetry?: number;
|
|
30
32
|
/** 抖音是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true */
|
|
@@ -37,12 +39,14 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
37
39
|
formatName?: FormatName;
|
|
38
40
|
/** 流编码 */
|
|
39
41
|
codecName?: CodecName;
|
|
40
|
-
/** 选择使用的api
|
|
41
|
-
api?: "auto" | "web" | "mp";
|
|
42
|
+
/** 选择使用的api,虎牙支持: auto,web,mp,抖音支持:web,webHTML,mobile,userHTML */
|
|
43
|
+
api?: "auto" | "web" | "mp" | "webHTML" | "mobile" | "userHTML";
|
|
42
44
|
/** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制(仅对斗鱼有效),多个关键词用英文逗号分隔 */
|
|
43
45
|
titleKeywords?: string;
|
|
44
46
|
/** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
|
|
45
47
|
videoFormat?: "auto" | "ts" | "mkv";
|
|
48
|
+
/** 录制类型 */
|
|
49
|
+
recorderType?: "auto" | "ffmpeg" | "mesio";
|
|
46
50
|
/** 流格式优先级 */
|
|
47
51
|
formatriorities?: Array<"flv" | "hls">;
|
|
48
52
|
/** 只录制音频 */
|
|
@@ -52,9 +56,10 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
52
56
|
/** 控制弹幕是否使用服务端时间戳 */
|
|
53
57
|
useServerTimestamp?: boolean;
|
|
54
58
|
extra?: Partial<E>;
|
|
59
|
+
cache: Cache;
|
|
55
60
|
}
|
|
56
|
-
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id">;
|
|
57
|
-
export type RecorderState = "idle" | "recording" | "stopping-record";
|
|
61
|
+
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
|
+
export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error";
|
|
58
63
|
export type Progress = {
|
|
59
64
|
time: string | null;
|
|
60
65
|
};
|
|
@@ -106,7 +111,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
106
111
|
state: RecorderState;
|
|
107
112
|
qualityMaxRetry: number;
|
|
108
113
|
qualityRetry: number;
|
|
109
|
-
uid?: number;
|
|
114
|
+
uid?: number | string;
|
|
110
115
|
liveInfo?: {
|
|
111
116
|
living: boolean;
|
|
112
117
|
owner: string;
|
|
@@ -117,6 +122,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
117
122
|
liveId?: string;
|
|
118
123
|
};
|
|
119
124
|
tempStopIntervalCheck?: boolean;
|
|
125
|
+
/** 缓存实例引用,由 manager 设置 */
|
|
126
|
+
cache: Cache;
|
|
120
127
|
getChannelURL: (this: Recorder<E>) => string;
|
|
121
128
|
checkLiveStatusAndRecord: (this: Recorder<E>, opts: {
|
|
122
129
|
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) {
|