@bililive-tools/manager 1.2.1 → 1.4.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 +1 -1
- package/lib/FFMPEGRecorder.d.ts +14 -2
- package/lib/FFMPEGRecorder.js +46 -17
- package/lib/common.d.ts +1 -1
- package/lib/common.js +1 -1
- package/lib/manager.d.ts +2 -0
- package/lib/manager.js +26 -7
- package/lib/record_extra_data_controller.js +18 -6
- package/lib/recorder.d.ts +12 -5
- package/lib/streamManager.d.ts +14 -2
- package/lib/streamManager.js +28 -9
- package/lib/utils.d.ts +11 -1
- package/lib/utils.js +29 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ import { provider } from "@bililive-tools/bilibili-recorder";
|
|
|
28
28
|
|
|
29
29
|
const manager = createRecorderManager({
|
|
30
30
|
providers: [provider],
|
|
31
|
-
savePathRule: "D:\\录制\\{platforme}}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}", //
|
|
31
|
+
savePathRule: "D:\\录制\\{platforme}}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}", // 保存路径,占位符见文档,支持 [ejs](https://ejs.co/) 模板引擎
|
|
32
32
|
autoCheckInterval: 1000 * 60, // 自动检查间隔,单位秒
|
|
33
33
|
autoRemoveSystemReservedChars: true, // 移除系统非法字符串
|
|
34
34
|
biliBatchQuery: false, // B站检查使用批量接口
|
package/lib/FFMPEGRecorder.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
export declare class FFMPEGRecorder extends EventEmitter {
|
|
3
3
|
private onEnd;
|
|
4
|
+
private onUpdateLiveInfo;
|
|
4
5
|
private command;
|
|
5
6
|
private streamManager;
|
|
6
7
|
private timeoutChecker;
|
|
7
8
|
hasSegment: boolean;
|
|
8
9
|
getSavePath: (data: {
|
|
9
10
|
startTime: number;
|
|
11
|
+
title?: string;
|
|
10
12
|
}) => string;
|
|
11
13
|
segment: number;
|
|
12
14
|
ffmpegOutputOptions: string[];
|
|
@@ -14,10 +16,14 @@ export declare class FFMPEGRecorder extends EventEmitter {
|
|
|
14
16
|
isHls: boolean;
|
|
15
17
|
disableDanma: boolean;
|
|
16
18
|
url: string;
|
|
19
|
+
headers: {
|
|
20
|
+
[key: string]: string | undefined;
|
|
21
|
+
} | undefined;
|
|
17
22
|
constructor(opts: {
|
|
18
23
|
url: string;
|
|
19
24
|
getSavePath: (data: {
|
|
20
25
|
startTime: number;
|
|
26
|
+
title?: string;
|
|
21
27
|
}) => string;
|
|
22
28
|
segment: number;
|
|
23
29
|
outputOptions: string[];
|
|
@@ -25,8 +31,14 @@ export declare class FFMPEGRecorder extends EventEmitter {
|
|
|
25
31
|
isHls?: boolean;
|
|
26
32
|
disableDanma?: boolean;
|
|
27
33
|
videoFormat?: "auto" | "ts" | "mkv";
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
headers?: {
|
|
35
|
+
[key: string]: string | undefined;
|
|
36
|
+
};
|
|
37
|
+
}, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
38
|
+
title?: string;
|
|
39
|
+
cover?: string;
|
|
40
|
+
}>);
|
|
41
|
+
createCommand(): import("@renmu/fluent-ffmpeg").FfmpegCommand;
|
|
30
42
|
formatLine(line: string): {
|
|
31
43
|
time: string | null;
|
|
32
44
|
} | null;
|
package/lib/FFMPEGRecorder.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createFFMPEGBuilder, StreamManager, utils } from "./index.js";
|
|
|
3
3
|
import { createInvalidStreamChecker, assert } from "./utils.js";
|
|
4
4
|
export class FFMPEGRecorder extends EventEmitter {
|
|
5
5
|
onEnd;
|
|
6
|
+
onUpdateLiveInfo;
|
|
6
7
|
command;
|
|
7
8
|
streamManager;
|
|
8
9
|
timeoutChecker;
|
|
@@ -11,26 +12,36 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
11
12
|
segment;
|
|
12
13
|
ffmpegOutputOptions = [];
|
|
13
14
|
inputOptions = [];
|
|
14
|
-
isHls
|
|
15
|
+
isHls;
|
|
15
16
|
disableDanma = false;
|
|
16
17
|
url;
|
|
17
|
-
|
|
18
|
+
headers;
|
|
19
|
+
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
18
20
|
super();
|
|
19
21
|
this.onEnd = onEnd;
|
|
22
|
+
this.onUpdateLiveInfo = onUpdateLiveInfo;
|
|
20
23
|
const hasSegment = !!opts.segment;
|
|
21
24
|
this.disableDanma = opts.disableDanma ?? false;
|
|
22
|
-
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, opts.videoFormat
|
|
23
|
-
|
|
25
|
+
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, opts.videoFormat, {
|
|
26
|
+
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
27
|
+
});
|
|
28
|
+
this.timeoutChecker = utils.createTimeoutChecker(() => this.onEnd("ffmpeg timeout"), 3 * 10e3, false);
|
|
24
29
|
this.hasSegment = hasSegment;
|
|
25
30
|
this.getSavePath = opts.getSavePath;
|
|
26
31
|
this.ffmpegOutputOptions = opts.outputOptions;
|
|
27
32
|
this.inputOptions = opts.inputOptions ?? [];
|
|
28
33
|
this.url = opts.url;
|
|
29
34
|
this.segment = opts.segment;
|
|
30
|
-
this.
|
|
35
|
+
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
|
+
}
|
|
31
42
|
this.command = this.createCommand();
|
|
32
|
-
this.streamManager.on("videoFileCreated", ({ filename }) => {
|
|
33
|
-
this.emit("videoFileCreated", { filename });
|
|
43
|
+
this.streamManager.on("videoFileCreated", ({ filename, cover }) => {
|
|
44
|
+
this.emit("videoFileCreated", { filename, cover });
|
|
34
45
|
});
|
|
35
46
|
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
36
47
|
this.emit("videoFileCompleted", { filename });
|
|
@@ -40,15 +51,28 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
40
51
|
});
|
|
41
52
|
}
|
|
42
53
|
createCommand() {
|
|
43
|
-
|
|
54
|
+
this.timeoutChecker?.start();
|
|
55
|
+
const invalidCount = this.isHls ? 35 : 18;
|
|
44
56
|
const isInvalidStream = createInvalidStreamChecker(invalidCount);
|
|
45
|
-
const
|
|
46
|
-
.input(this.url)
|
|
47
|
-
.inputOptions([
|
|
57
|
+
const inputOptions = [
|
|
48
58
|
...this.inputOptions,
|
|
49
59
|
"-user_agent",
|
|
50
60
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36",
|
|
51
|
-
]
|
|
61
|
+
];
|
|
62
|
+
if (this.headers) {
|
|
63
|
+
const headers = [];
|
|
64
|
+
Object.entries(this.headers).forEach(([key, value]) => {
|
|
65
|
+
if (!value)
|
|
66
|
+
return;
|
|
67
|
+
headers.push(`${key}:${value}`);
|
|
68
|
+
});
|
|
69
|
+
if (headers.length) {
|
|
70
|
+
inputOptions.push("-headers", headers.join("\\r\\n"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const command = createFFMPEGBuilder()
|
|
74
|
+
.input(this.url)
|
|
75
|
+
.inputOptions(inputOptions)
|
|
52
76
|
.outputOptions(this.ffmpegOutputOptions)
|
|
53
77
|
.output(this.streamManager.videoFilePath)
|
|
54
78
|
.on("error", this.onEnd)
|
|
@@ -56,7 +80,6 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
56
80
|
.on("stderr", async (stderrLine) => {
|
|
57
81
|
assert(typeof stderrLine === "string");
|
|
58
82
|
await this.streamManager.handleVideoStarted(stderrLine);
|
|
59
|
-
// TODO:解析时间
|
|
60
83
|
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
61
84
|
const info = this.formatLine(stderrLine);
|
|
62
85
|
if (info) {
|
|
@@ -66,7 +89,7 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
66
89
|
this.onEnd("invalid stream");
|
|
67
90
|
}
|
|
68
91
|
})
|
|
69
|
-
.on("stderr", this.timeoutChecker
|
|
92
|
+
.on("stderr", this.timeoutChecker?.update);
|
|
70
93
|
if (this.hasSegment) {
|
|
71
94
|
command.outputOptions("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
|
|
72
95
|
}
|
|
@@ -94,12 +117,18 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
94
117
|
async stop() {
|
|
95
118
|
this.timeoutChecker.stop();
|
|
96
119
|
try {
|
|
97
|
-
//
|
|
98
|
-
this.
|
|
120
|
+
// ts文件使用write("q")需要十来秒进行处理,直接中断,其他格式使用sigint会导致缺少数据
|
|
121
|
+
if (this.streamManager.videoFilePath.endsWith(".ts")) {
|
|
122
|
+
this.command.kill("SIGINT");
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// @ts-ignore
|
|
126
|
+
this.command.ffmpegProc?.stdin?.write("q");
|
|
127
|
+
}
|
|
99
128
|
await this.streamManager.handleVideoCompleted();
|
|
100
129
|
}
|
|
101
130
|
catch (err) {
|
|
102
|
-
this.emit("DebugLog", { type: "
|
|
131
|
+
this.emit("DebugLog", { type: "error", text: String(err) });
|
|
103
132
|
}
|
|
104
133
|
}
|
|
105
134
|
getExtraDataController() {
|
package/lib/common.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare const Qualities: readonly ["lowest", "low", "medium", "high", "hi
|
|
|
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
6
|
export declare const HuYaQualities: readonly [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500];
|
|
7
|
-
export declare const DouYinQualities: readonly ["origin", "uhd", "hd", "sd", "ld", "ao"];
|
|
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> {
|
|
10
10
|
uid?: string;
|
package/lib/common.js
CHANGED
|
@@ -3,4 +3,4 @@ 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
5
|
export const HuYaQualities = [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500];
|
|
6
|
-
export const DouYinQualities = ["origin", "uhd", "hd", "sd", "ld", "ao"];
|
|
6
|
+
export const DouYinQualities = ["origin", "uhd", "hd", "sd", "ld", "ao", "real_origin"];
|
package/lib/manager.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
36
36
|
videoFileCreated: {
|
|
37
37
|
recorder: Recorder<E>;
|
|
38
38
|
filename: string;
|
|
39
|
+
cover?: string;
|
|
39
40
|
};
|
|
40
41
|
videoFileCompleted: {
|
|
41
42
|
recorder: Recorder<E>;
|
|
@@ -75,6 +76,7 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
75
76
|
removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
|
|
76
77
|
startRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
77
78
|
stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
79
|
+
cutRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
78
80
|
autoCheckInterval: number;
|
|
79
81
|
isCheckLoopRunning: boolean;
|
|
80
82
|
startCheckLoop: (this: RecorderManager<ME, P, PE, E>) => void;
|
package/lib/manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import mitt from "mitt";
|
|
3
|
+
import ejs from "ejs";
|
|
3
4
|
import { omit, range } from "lodash-es";
|
|
4
5
|
import { parseArgsStringToArgv } from "string-argv";
|
|
5
6
|
import { getBiliStatusInfoByRoomIds } from "./api.js";
|
|
@@ -104,10 +105,10 @@ export function createRecorderManager(opts) {
|
|
|
104
105
|
this.recorders.push(recorder);
|
|
105
106
|
recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder, recordHandle }));
|
|
106
107
|
recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder, recordHandle }));
|
|
107
|
-
recorder.on("videoFileCreated", ({ filename }) => {
|
|
108
|
+
recorder.on("videoFileCreated", ({ filename, cover }) => {
|
|
108
109
|
if (recorder.saveCover && recorder?.liveInfo?.cover) {
|
|
109
110
|
const coverPath = replaceExtName(filename, ".jpg");
|
|
110
|
-
downloadImage(recorder?.liveInfo?.cover, coverPath);
|
|
111
|
+
downloadImage(cover ?? recorder?.liveInfo?.cover, coverPath);
|
|
111
112
|
}
|
|
112
113
|
this.emit("videoFileCreated", { recorder, filename });
|
|
113
114
|
});
|
|
@@ -119,8 +120,10 @@ export function createRecorderManager(opts) {
|
|
|
119
120
|
recorder.on("progress", (progress) => {
|
|
120
121
|
this.emit("RecorderProgress", { recorder, progress });
|
|
121
122
|
});
|
|
122
|
-
recorder.on("
|
|
123
|
-
|
|
123
|
+
recorder.on("videoFileCreated", () => {
|
|
124
|
+
if (!recorder.liveInfo?.liveId)
|
|
125
|
+
return;
|
|
126
|
+
const key = `${recorder.channelId}-${recorder.liveInfo?.liveId}`;
|
|
124
127
|
if (liveStartObj[key])
|
|
125
128
|
return;
|
|
126
129
|
liveStartObj[key] = true;
|
|
@@ -168,6 +171,15 @@ export function createRecorderManager(opts) {
|
|
|
168
171
|
}
|
|
169
172
|
return recorder;
|
|
170
173
|
},
|
|
174
|
+
async cutRecord(id) {
|
|
175
|
+
const recorder = this.recorders.find((item) => item.id === id);
|
|
176
|
+
if (recorder == null)
|
|
177
|
+
return;
|
|
178
|
+
if (recorder.recordHandle == null)
|
|
179
|
+
return;
|
|
180
|
+
await recorder.recordHandle.cut();
|
|
181
|
+
return recorder;
|
|
182
|
+
},
|
|
171
183
|
autoCheckInterval: opts.autoCheckInterval ?? 1000,
|
|
172
184
|
isCheckLoopRunning: false,
|
|
173
185
|
startCheckLoop() {
|
|
@@ -221,7 +233,7 @@ export function createRecorderManager(opts) {
|
|
|
221
233
|
*
|
|
222
234
|
* TODO: 如果浏览器行为无法优化,并且想进一步优化加载速度,可以考虑录制时使用 fmp4,录制完成后再转一次普通 mp4。
|
|
223
235
|
*/
|
|
224
|
-
" -min_frag_duration
|
|
236
|
+
" -min_frag_duration 10000000",
|
|
225
237
|
};
|
|
226
238
|
const setProvidersFFMPEGOutputArgs = (ffmpegOutputArgs) => {
|
|
227
239
|
const args = parseArgsStringToArgv(ffmpegOutputArgs);
|
|
@@ -260,9 +272,16 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
260
272
|
};
|
|
261
273
|
if (manager.autoRemoveSystemReservedChars) {
|
|
262
274
|
for (const key in params) {
|
|
263
|
-
params[key] = removeSystemReservedChars(String(params[key]));
|
|
275
|
+
params[key] = removeSystemReservedChars(String(params[key])).trim();
|
|
264
276
|
}
|
|
265
277
|
}
|
|
266
|
-
|
|
278
|
+
let savePathRule = manager.savePathRule;
|
|
279
|
+
try {
|
|
280
|
+
savePathRule = ejs.render(savePathRule, params);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
console.error("模板解析错误", error);
|
|
284
|
+
}
|
|
285
|
+
return formatTemplate(savePathRule, params);
|
|
267
286
|
}
|
|
268
287
|
export { StreamManager };
|
|
@@ -14,6 +14,7 @@ export function createRecordExtraDataController(savePath) {
|
|
|
14
14
|
},
|
|
15
15
|
messages: [],
|
|
16
16
|
};
|
|
17
|
+
let hasCompleted = false;
|
|
17
18
|
const scheduleSave = asyncThrottle(() => save(), 30e3, {
|
|
18
19
|
immediateRunWhenEndOfDefer: true,
|
|
19
20
|
});
|
|
@@ -22,10 +23,14 @@ export function createRecordExtraDataController(savePath) {
|
|
|
22
23
|
};
|
|
23
24
|
// TODO: 将所有数据存放在内存中可能存在问题
|
|
24
25
|
const addMessage = (comment) => {
|
|
26
|
+
if (hasCompleted)
|
|
27
|
+
return;
|
|
25
28
|
data.messages.push(comment);
|
|
26
29
|
scheduleSave();
|
|
27
30
|
};
|
|
28
31
|
const setMeta = (meta) => {
|
|
32
|
+
if (hasCompleted)
|
|
33
|
+
return;
|
|
29
34
|
data.meta = {
|
|
30
35
|
...data.meta,
|
|
31
36
|
...meta,
|
|
@@ -33,13 +38,16 @@ export function createRecordExtraDataController(savePath) {
|
|
|
33
38
|
scheduleSave();
|
|
34
39
|
};
|
|
35
40
|
const flush = async () => {
|
|
36
|
-
|
|
41
|
+
if (hasCompleted)
|
|
42
|
+
return;
|
|
43
|
+
hasCompleted = true;
|
|
37
44
|
scheduleSave.cancel();
|
|
38
45
|
const xmlContent = convert2Xml(data);
|
|
39
46
|
const parsedPath = path.parse(savePath);
|
|
40
47
|
const xmlPath = path.join(parsedPath.dir, parsedPath.name + ".xml");
|
|
41
48
|
await fs.promises.writeFile(xmlPath, xmlContent);
|
|
42
49
|
await fs.promises.rm(savePath);
|
|
50
|
+
data.messages = [];
|
|
43
51
|
};
|
|
44
52
|
return {
|
|
45
53
|
data,
|
|
@@ -62,7 +70,7 @@ export function convert2Xml(data) {
|
|
|
62
70
|
const comments = data.messages
|
|
63
71
|
.filter((item) => item.type === "comment")
|
|
64
72
|
.map((ele) => {
|
|
65
|
-
const progress = (ele.timestamp - metadata
|
|
73
|
+
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
66
74
|
const data = {
|
|
67
75
|
"@@p": "",
|
|
68
76
|
"@@progress": progress,
|
|
@@ -76,6 +84,7 @@ export function convert2Xml(data) {
|
|
|
76
84
|
"@@weight": String(0),
|
|
77
85
|
"@@user": String(ele.sender?.name),
|
|
78
86
|
"@@uid": String(ele?.sender?.uid),
|
|
87
|
+
"@@timestamp": String(ele.timestamp),
|
|
79
88
|
};
|
|
80
89
|
data["@@p"] = [
|
|
81
90
|
data["@@progress"],
|
|
@@ -88,12 +97,12 @@ export function convert2Xml(data) {
|
|
|
88
97
|
data["@@uid"],
|
|
89
98
|
data["@@weight"],
|
|
90
99
|
].join(",");
|
|
91
|
-
return pick(data, ["@@p", "#text", "@@user", "@@uid"]);
|
|
100
|
+
return pick(data, ["@@p", "#text", "@@user", "@@uid", "@@timestamp"]);
|
|
92
101
|
});
|
|
93
102
|
const gifts = data.messages
|
|
94
103
|
.filter((item) => item.type === "give_gift")
|
|
95
104
|
.map((ele) => {
|
|
96
|
-
const progress = (ele.timestamp - metadata
|
|
105
|
+
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
97
106
|
const data = {
|
|
98
107
|
"@@ts": progress,
|
|
99
108
|
"@@giftname": String(ele.name),
|
|
@@ -101,6 +110,7 @@ export function convert2Xml(data) {
|
|
|
101
110
|
"@@price": String(ele.price * 1000),
|
|
102
111
|
"@@user": String(ele.sender?.name),
|
|
103
112
|
"@@uid": String(ele?.sender?.uid),
|
|
113
|
+
"@@timestamp": String(ele.timestamp),
|
|
104
114
|
// "@@raw": JSON.stringify(ele),
|
|
105
115
|
};
|
|
106
116
|
return data;
|
|
@@ -108,13 +118,14 @@ export function convert2Xml(data) {
|
|
|
108
118
|
const superChats = data.messages
|
|
109
119
|
.filter((item) => item.type === "super_chat")
|
|
110
120
|
.map((ele) => {
|
|
111
|
-
const progress = (ele.timestamp - metadata
|
|
121
|
+
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
112
122
|
const data = {
|
|
113
123
|
"@@ts": progress,
|
|
114
124
|
"@@price": String(ele.price * 1000),
|
|
115
125
|
"#text": String(ele.text),
|
|
116
126
|
"@@user": String(ele.sender?.name),
|
|
117
127
|
"@@uid": String(ele?.sender?.uid),
|
|
128
|
+
"@@timestamp": String(ele.timestamp),
|
|
118
129
|
// "@@raw": JSON.stringify(ele),
|
|
119
130
|
};
|
|
120
131
|
return data;
|
|
@@ -122,7 +133,7 @@ export function convert2Xml(data) {
|
|
|
122
133
|
const guardGift = data.messages
|
|
123
134
|
.filter((item) => item.type === "guard")
|
|
124
135
|
.map((ele) => {
|
|
125
|
-
const progress = (ele.timestamp - metadata
|
|
136
|
+
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
126
137
|
const data = {
|
|
127
138
|
"@@ts": progress,
|
|
128
139
|
"@@price": String(ele.price * 1000),
|
|
@@ -131,6 +142,7 @@ export function convert2Xml(data) {
|
|
|
131
142
|
"@@level": String(ele.level),
|
|
132
143
|
"@@user": String(ele.sender?.name),
|
|
133
144
|
"@@uid": String(ele?.sender?.uid),
|
|
145
|
+
"@@timestamp": String(ele.timestamp),
|
|
134
146
|
// "@@raw": JSON.stringify(ele),
|
|
135
147
|
};
|
|
136
148
|
return data;
|
package/lib/recorder.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
14
14
|
quality: Quality;
|
|
15
15
|
streamPriorities: string[];
|
|
16
16
|
sourcePriorities: string[];
|
|
17
|
-
formatPriorities?:
|
|
17
|
+
formatPriorities?: Array<"flv" | "hls">;
|
|
18
18
|
source?: string;
|
|
19
19
|
segment?: number;
|
|
20
20
|
saveGiftDanma?: boolean;
|
|
@@ -27,6 +27,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
27
27
|
uid?: number;
|
|
28
28
|
/** 画质匹配重试次数 */
|
|
29
29
|
qualityRetry?: number;
|
|
30
|
+
/** 抖音是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true */
|
|
31
|
+
doubleScreen?: boolean;
|
|
30
32
|
/** B站是否使用m3u8代理 */
|
|
31
33
|
useM3U8Proxy?: boolean;
|
|
32
34
|
/**B站m3u8代理url */
|
|
@@ -41,6 +43,12 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
41
43
|
titleKeywords?: string;
|
|
42
44
|
/** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
|
|
43
45
|
videoFormat?: "auto" | "ts" | "mkv";
|
|
46
|
+
/** 流格式优先级 */
|
|
47
|
+
formatriorities?: Array<"flv" | "hls">;
|
|
48
|
+
/** 只录制音频 */
|
|
49
|
+
onlyAudio?: boolean;
|
|
50
|
+
/** 控制弹幕是否使用服务端时间戳 */
|
|
51
|
+
useServerTimestamp?: boolean;
|
|
44
52
|
extra?: Partial<E>;
|
|
45
53
|
}
|
|
46
54
|
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id">;
|
|
@@ -57,9 +65,10 @@ export interface RecordHandle {
|
|
|
57
65
|
progress?: Progress;
|
|
58
66
|
savePath: string;
|
|
59
67
|
stop: (this: RecordHandle, reason?: string, tempStopIntervalCheck?: boolean) => Promise<void>;
|
|
68
|
+
cut: (this: RecordHandle) => Promise<void>;
|
|
60
69
|
}
|
|
61
70
|
export interface DebugLog {
|
|
62
|
-
type: string | "common" | "ffmpeg";
|
|
71
|
+
type: string | "common" | "ffmpeg" | "error";
|
|
63
72
|
text: string;
|
|
64
73
|
}
|
|
65
74
|
export type GetSavePath = (data: {
|
|
@@ -72,14 +81,12 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
72
81
|
RecordSegment?: RecordHandle;
|
|
73
82
|
videoFileCreated: {
|
|
74
83
|
filename: string;
|
|
84
|
+
cover?: string;
|
|
75
85
|
};
|
|
76
86
|
videoFileCompleted: {
|
|
77
87
|
filename: string;
|
|
78
88
|
};
|
|
79
89
|
progress: Progress;
|
|
80
|
-
LiveStart: {
|
|
81
|
-
liveId: string;
|
|
82
|
-
};
|
|
83
90
|
RecordStop: {
|
|
84
91
|
recordHandle: RecordHandle;
|
|
85
92
|
reason?: string;
|
package/lib/streamManager.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import EventEmitter from "node:events";
|
|
|
2
2
|
import { createRecordExtraDataController } from "./record_extra_data_controller.js";
|
|
3
3
|
export type GetSavePath = (data: {
|
|
4
4
|
startTime: number;
|
|
5
|
+
title?: string;
|
|
5
6
|
}) => string;
|
|
6
7
|
export declare class Segment extends EventEmitter {
|
|
7
8
|
extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
|
|
@@ -15,7 +16,12 @@ export declare class Segment extends EventEmitter {
|
|
|
15
16
|
videoExt: "ts" | "mkv" | "mp4";
|
|
16
17
|
constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: "ts" | "mkv" | "mp4");
|
|
17
18
|
handleSegmentEnd(): Promise<void>;
|
|
18
|
-
onSegmentStart(stderrLine: string
|
|
19
|
+
onSegmentStart(stderrLine: string, callBack?: {
|
|
20
|
+
onUpdateLiveInfo: () => Promise<{
|
|
21
|
+
title?: string;
|
|
22
|
+
cover?: string;
|
|
23
|
+
}>;
|
|
24
|
+
}): Promise<void>;
|
|
19
25
|
get outputFilePath(): string;
|
|
20
26
|
}
|
|
21
27
|
export declare class StreamManager extends EventEmitter {
|
|
@@ -25,7 +31,13 @@ export declare class StreamManager extends EventEmitter {
|
|
|
25
31
|
recordStartTime?: number;
|
|
26
32
|
hasSegment: boolean;
|
|
27
33
|
private videoFormat?;
|
|
28
|
-
|
|
34
|
+
private callBack?;
|
|
35
|
+
constructor(getSavePath: GetSavePath, hasSegment: boolean, disableDanma: boolean, videoFormat?: "auto" | "ts" | "mkv", callBack?: {
|
|
36
|
+
onUpdateLiveInfo: () => Promise<{
|
|
37
|
+
title?: string;
|
|
38
|
+
cover?: string;
|
|
39
|
+
}>;
|
|
40
|
+
});
|
|
29
41
|
handleVideoStarted(stderrLine: string): Promise<void>;
|
|
30
42
|
handleVideoCompleted(): Promise<void>;
|
|
31
43
|
getExtraDataController(): import("./record_extra_data_controller.js").RecordExtraDataController | null;
|
package/lib/streamManager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
|
-
import fs from "fs
|
|
2
|
+
import fs from "fs/promises";
|
|
3
3
|
import { createRecordExtraDataController } from "./record_extra_data_controller.js";
|
|
4
|
-
import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isFfmpegStart } from "./utils.js";
|
|
4
|
+
import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isFfmpegStart, retry, } from "./utils.js";
|
|
5
5
|
export class Segment extends EventEmitter {
|
|
6
6
|
extraDataController = null;
|
|
7
7
|
init = true;
|
|
@@ -21,33 +21,46 @@ export class Segment extends EventEmitter {
|
|
|
21
21
|
async handleSegmentEnd() {
|
|
22
22
|
if (!this.outputVideoFilePath) {
|
|
23
23
|
this.emit("DebugLog", {
|
|
24
|
-
type: "
|
|
24
|
+
type: "error",
|
|
25
25
|
text: "Should call onSegmentStart first",
|
|
26
26
|
});
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
try {
|
|
30
30
|
await Promise.all([
|
|
31
|
-
fs.rename(this.rawRecordingVideoPath, this.outputFilePath),
|
|
31
|
+
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath), 10, 2000),
|
|
32
32
|
this.extraDataController?.flush(),
|
|
33
33
|
]);
|
|
34
34
|
this.emit("videoFileCompleted", { filename: this.outputFilePath });
|
|
35
35
|
}
|
|
36
36
|
catch (err) {
|
|
37
37
|
this.emit("DebugLog", {
|
|
38
|
-
type: "
|
|
38
|
+
type: "error",
|
|
39
39
|
text: "videoFileCompleted error " + String(err),
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
async onSegmentStart(stderrLine) {
|
|
43
|
+
async onSegmentStart(stderrLine, callBack) {
|
|
44
44
|
if (!this.init) {
|
|
45
45
|
await this.handleSegmentEnd();
|
|
46
46
|
}
|
|
47
47
|
this.init = false;
|
|
48
48
|
const startTime = Date.now();
|
|
49
|
+
let liveInfo = { title: "", cover: "" };
|
|
50
|
+
if (callBack?.onUpdateLiveInfo) {
|
|
51
|
+
try {
|
|
52
|
+
liveInfo = await callBack.onUpdateLiveInfo();
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
this.emit("DebugLog", {
|
|
56
|
+
type: "error",
|
|
57
|
+
text: "onUpdateLiveInfo error " + String(err),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
49
61
|
this.outputVideoFilePath = this.getSavePath({
|
|
50
62
|
startTime: startTime,
|
|
63
|
+
title: liveInfo?.title,
|
|
51
64
|
});
|
|
52
65
|
ensureFolderExist(this.outputVideoFilePath);
|
|
53
66
|
if (!this.disableDanma) {
|
|
@@ -58,7 +71,11 @@ export class Segment extends EventEmitter {
|
|
|
58
71
|
if (match) {
|
|
59
72
|
const filename = match[1];
|
|
60
73
|
this.rawRecordingVideoPath = filename;
|
|
61
|
-
this.emit("videoFileCreated", {
|
|
74
|
+
this.emit("videoFileCreated", {
|
|
75
|
+
filename: this.outputFilePath,
|
|
76
|
+
title: liveInfo?.title,
|
|
77
|
+
cover: liveInfo?.cover,
|
|
78
|
+
});
|
|
62
79
|
}
|
|
63
80
|
else {
|
|
64
81
|
this.emit("DebugLog", { type: "ffmpeg", text: "No match found" });
|
|
@@ -75,12 +92,14 @@ export class StreamManager extends EventEmitter {
|
|
|
75
92
|
recordStartTime;
|
|
76
93
|
hasSegment;
|
|
77
94
|
videoFormat;
|
|
78
|
-
|
|
95
|
+
callBack;
|
|
96
|
+
constructor(getSavePath, hasSegment, disableDanma, videoFormat, callBack) {
|
|
79
97
|
super();
|
|
80
98
|
const recordSavePath = getSavePath({ startTime: Date.now() });
|
|
81
99
|
this.recordSavePath = recordSavePath;
|
|
82
100
|
this.videoFormat = videoFormat;
|
|
83
101
|
this.hasSegment = hasSegment;
|
|
102
|
+
this.callBack = callBack;
|
|
84
103
|
if (hasSegment) {
|
|
85
104
|
this.segment = new Segment(getSavePath, disableDanma, this.videoExt);
|
|
86
105
|
this.segment.on("DebugLog", (data) => {
|
|
@@ -103,7 +122,7 @@ export class StreamManager extends EventEmitter {
|
|
|
103
122
|
async handleVideoStarted(stderrLine) {
|
|
104
123
|
if (this.segment) {
|
|
105
124
|
if (isFfmpegStartSegment(stderrLine)) {
|
|
106
|
-
await this.segment.onSegmentStart(stderrLine);
|
|
125
|
+
await this.segment.onSegmentStart(stderrLine, this.callBack);
|
|
107
126
|
}
|
|
108
127
|
}
|
|
109
128
|
else {
|
package/lib/utils.d.ts
CHANGED
|
@@ -39,9 +39,10 @@ export declare function isFfmpegStartSegment(line: string): boolean;
|
|
|
39
39
|
export declare function isFfmpegStart(line: string): boolean;
|
|
40
40
|
export declare const formatTemplate: (string: string, ...args: any[]) => string;
|
|
41
41
|
export declare function createInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => boolean;
|
|
42
|
-
export declare function createTimeoutChecker(onTimeout: () => void, time: number): {
|
|
42
|
+
export declare function createTimeoutChecker(onTimeout: () => void, time: number, autoStart?: boolean): {
|
|
43
43
|
update: () => void;
|
|
44
44
|
stop: () => void;
|
|
45
|
+
start: () => void;
|
|
45
46
|
};
|
|
46
47
|
export declare function downloadImage(imageUrl: string, savePath: string): Promise<void>;
|
|
47
48
|
/**
|
|
@@ -52,6 +53,14 @@ export declare function downloadImage(imageUrl: string, savePath: string): Promi
|
|
|
52
53
|
* @returns 排序后的对象数组
|
|
53
54
|
*/
|
|
54
55
|
export declare function sortByKeyOrder<T, K extends keyof T>(objects: T[], order: T[K][], key: K): T[];
|
|
56
|
+
/**
|
|
57
|
+
* 重试执行异步函数
|
|
58
|
+
* @param fn 要重试的异步函数
|
|
59
|
+
* @param retries 重试次数,默认为3次
|
|
60
|
+
* @param delay 重试延迟时间(毫秒),默认为1000ms
|
|
61
|
+
* @returns Promise
|
|
62
|
+
*/
|
|
63
|
+
export declare function retry<T>(fn: () => Promise<T>, retries?: number, delay?: number): Promise<T>;
|
|
55
64
|
declare const _default: {
|
|
56
65
|
replaceExtName: typeof replaceExtName;
|
|
57
66
|
singleton: typeof singleton;
|
|
@@ -69,5 +78,6 @@ declare const _default: {
|
|
|
69
78
|
md5: (str: string) => string;
|
|
70
79
|
uuid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
71
80
|
sortByKeyOrder: typeof sortByKeyOrder;
|
|
81
|
+
retry: typeof retry;
|
|
72
82
|
};
|
|
73
83
|
export default _default;
|
package/lib/utils.js
CHANGED
|
@@ -177,7 +177,7 @@ export function createInvalidStreamChecker(count = 15) {
|
|
|
177
177
|
return false;
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
|
-
export function createTimeoutChecker(onTimeout, time) {
|
|
180
|
+
export function createTimeoutChecker(onTimeout, time, autoStart = true) {
|
|
181
181
|
let timer = null;
|
|
182
182
|
let stopped = false;
|
|
183
183
|
const update = () => {
|
|
@@ -190,7 +190,13 @@ export function createTimeoutChecker(onTimeout, time) {
|
|
|
190
190
|
onTimeout();
|
|
191
191
|
}, time);
|
|
192
192
|
};
|
|
193
|
-
|
|
193
|
+
const start = () => {
|
|
194
|
+
stopped = false;
|
|
195
|
+
update();
|
|
196
|
+
};
|
|
197
|
+
if (autoStart) {
|
|
198
|
+
start();
|
|
199
|
+
}
|
|
194
200
|
return {
|
|
195
201
|
update,
|
|
196
202
|
stop() {
|
|
@@ -199,6 +205,7 @@ export function createTimeoutChecker(onTimeout, time) {
|
|
|
199
205
|
clearTimeout(timer);
|
|
200
206
|
timer = null;
|
|
201
207
|
},
|
|
208
|
+
start,
|
|
202
209
|
};
|
|
203
210
|
}
|
|
204
211
|
export async function downloadImage(imageUrl, savePath) {
|
|
@@ -231,6 +238,25 @@ export function sortByKeyOrder(objects, order, key) {
|
|
|
231
238
|
return indexA - indexB;
|
|
232
239
|
});
|
|
233
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* 重试执行异步函数
|
|
243
|
+
* @param fn 要重试的异步函数
|
|
244
|
+
* @param retries 重试次数,默认为3次
|
|
245
|
+
* @param delay 重试延迟时间(毫秒),默认为1000ms
|
|
246
|
+
* @returns Promise
|
|
247
|
+
*/
|
|
248
|
+
export async function retry(fn, retries = 3, delay = 1000) {
|
|
249
|
+
try {
|
|
250
|
+
return await fn();
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
if (retries <= 0) {
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
257
|
+
return retry(fn, retries - 1, delay);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
234
260
|
export default {
|
|
235
261
|
replaceExtName,
|
|
236
262
|
singleton,
|
|
@@ -248,4 +274,5 @@ export default {
|
|
|
248
274
|
md5,
|
|
249
275
|
uuid,
|
|
250
276
|
sortByKeyOrder,
|
|
277
|
+
retry,
|
|
251
278
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/manager",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Batch scheduling recorders",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"string-argv": "^0.3.2",
|
|
40
40
|
"lodash-es": "^4.17.21",
|
|
41
41
|
"axios": "^1.7.8",
|
|
42
|
-
"fs-extra": "^11.2.0"
|
|
42
|
+
"fs-extra": "^11.2.0",
|
|
43
|
+
"ejs": "^3.1.10"
|
|
43
44
|
},
|
|
44
45
|
"scripts": {
|
|
45
46
|
"build": "pnpm run test && tsc",
|