@bililive-tools/manager 1.6.1 → 1.9.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 +23 -14
- package/lib/common.d.ts +1 -2
- package/lib/common.js +0 -1
- package/lib/index.d.ts +3 -0
- package/lib/index.js +7 -0
- package/lib/manager.d.ts +7 -2
- package/lib/manager.js +20 -15
- package/lib/recorder/BililiveRecorder.d.ts +50 -0
- package/lib/recorder/BililiveRecorder.js +195 -0
- package/lib/recorder/FFMPEGRecorder.d.ts +7 -2
- package/lib/recorder/FFMPEGRecorder.js +31 -11
- package/lib/recorder/IRecorder.d.ts +18 -5
- 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 +20 -22
- package/lib/recorder/streamManager.d.ts +1 -1
- package/lib/recorder/streamManager.js +31 -2
- package/lib/recorder.d.ts +14 -5
- package/lib/utils.d.ts +14 -1
- package/lib/utils.js +26 -3
- package/lib/xml_stream_controller.d.ts +1 -1
- package/lib/xml_stream_controller.js +26 -6
- 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,32 +78,38 @@ 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 占位符参数
|
|
88
94
|
|
|
89
95
|
默认值为 `{platform}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}`
|
|
90
96
|
|
|
91
|
-
| 值
|
|
92
|
-
|
|
|
93
|
-
| {platform}
|
|
94
|
-
| {channelId}
|
|
95
|
-
| {remarks}
|
|
96
|
-
| {owner}
|
|
97
|
-
| {title}
|
|
98
|
-
| {year}
|
|
99
|
-
| {month}
|
|
100
|
-
| {date}
|
|
101
|
-
| {hour}
|
|
102
|
-
| {min}
|
|
103
|
-
| {sec}
|
|
97
|
+
| 值 | 标签 |
|
|
98
|
+
| ----------------- | ------------------------------------------ |
|
|
99
|
+
| {platform} | 平台 |
|
|
100
|
+
| {channelId} | 房间号 |
|
|
101
|
+
| {remarks} | 备注 |
|
|
102
|
+
| {owner} | 主播名 |
|
|
103
|
+
| {title} | 标题 |
|
|
104
|
+
| {year} | 年 |
|
|
105
|
+
| {month} | 月 |
|
|
106
|
+
| {date} | 日 |
|
|
107
|
+
| {hour} | 时 |
|
|
108
|
+
| {min} | 分 |
|
|
109
|
+
| {sec} | 秒 |
|
|
110
|
+
| {startTime} | 分段开始时间,Date对象 |
|
|
111
|
+
| {recordStartTime} | 录制开始时间,Date对象 |
|
|
112
|
+
| {liveStartTime} | 直播开始时间,Date对象,抖音同录制开始时间 |
|
|
104
113
|
|
|
105
114
|
## 事件
|
|
106
115
|
|
package/lib/common.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { AnyObject, UnknownObject } from "./utils.js";
|
|
2
2
|
export type ChannelId = string;
|
|
3
3
|
export declare const Qualities: readonly ["lowest", "low", "medium", "high", "highest"];
|
|
4
|
-
export declare const BiliQualities: readonly [30000, 20000, 10000, 400, 250, 150, 80];
|
|
5
4
|
export declare const DouyuQualities: readonly [0, 2, 3, 4, 8];
|
|
6
5
|
export declare const HuYaQualities: readonly [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500, -1];
|
|
7
6
|
export declare const DouYinQualities: readonly ["origin", "uhd", "hd", "sd", "ld", "ao", "real_origin"];
|
|
8
|
-
export type Quality =
|
|
7
|
+
export type Quality = string | number;
|
|
9
8
|
export interface MessageSender<E extends AnyObject = UnknownObject> {
|
|
10
9
|
uid?: string;
|
|
11
10
|
name: string;
|
package/lib/common.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export const Qualities = ["lowest", "low", "medium", "high", "highest"];
|
|
2
|
-
export const BiliQualities = [30000, 20000, 10000, 400, 250, 150, 80];
|
|
3
2
|
export const DouyuQualities = [0, 2, 3, 4, 8];
|
|
4
3
|
// 14100: 2K HDR;14000:2K;4200:HDR(10M);0:原画;8000:蓝光8M;4000:蓝光4M;2000:超清;500:流畅
|
|
5
4
|
export const HuYaQualities = [
|
package/lib/index.d.ts
CHANGED
|
@@ -21,4 +21,7 @@ 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;
|
|
27
|
+
export type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
|
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;
|
|
@@ -100,7 +103,9 @@ export declare function createRecorderManager<ME extends AnyObject = UnknownObje
|
|
|
100
103
|
export declare function genSavePathFromRule<ME extends AnyObject, P extends RecorderProvider<AnyObject>, PE extends AnyObject, E extends AnyObject>(manager: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>, extData: {
|
|
101
104
|
owner: string;
|
|
102
105
|
title: string;
|
|
103
|
-
startTime
|
|
106
|
+
startTime: number;
|
|
107
|
+
liveStartTime: Date;
|
|
108
|
+
recordStartTime: Date;
|
|
104
109
|
}): string;
|
|
105
110
|
export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
|
|
106
111
|
export { StreamManager, Cache };
|
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)
|
|
@@ -306,12 +312,12 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
306
312
|
// TODO: 这里随便写的,后面再优化
|
|
307
313
|
const provider = manager.providers.find((p) => p.id === recorder.toJSON().providerId);
|
|
308
314
|
const now = extData?.startTime ? new Date(extData.startTime) : new Date();
|
|
309
|
-
const owner = (extData?.owner ?? "").replaceAll("%", "_");
|
|
310
|
-
const title = (extData?.title ?? "").replaceAll("%", "_");
|
|
315
|
+
const owner = removeSystemReservedChars((extData?.owner ?? "").replaceAll("%", "_"));
|
|
316
|
+
const title = removeSystemReservedChars((extData?.title ?? "").replaceAll("%", "_"));
|
|
317
|
+
const remarks = removeSystemReservedChars((recorder.remarks ?? "").replaceAll("%", "_"));
|
|
318
|
+
const channelId = removeSystemReservedChars(String(recorder.channelId));
|
|
311
319
|
const params = {
|
|
312
320
|
platform: provider?.name ?? "unknown",
|
|
313
|
-
channelId: recorder.channelId,
|
|
314
|
-
remarks: recorder.remarks ?? "",
|
|
315
321
|
year: formatDate(now, "yyyy"),
|
|
316
322
|
month: formatDate(now, "MM"),
|
|
317
323
|
date: formatDate(now, "dd"),
|
|
@@ -319,20 +325,19 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
319
325
|
min: formatDate(now, "mm"),
|
|
320
326
|
sec: formatDate(now, "ss"),
|
|
321
327
|
...extData,
|
|
328
|
+
startTime: now,
|
|
322
329
|
owner: owner,
|
|
323
330
|
title: title,
|
|
331
|
+
remarks: remarks,
|
|
332
|
+
channelId,
|
|
324
333
|
};
|
|
325
|
-
if (manager.autoRemoveSystemReservedChars) {
|
|
326
|
-
for (const key in params) {
|
|
327
|
-
params[key] = removeSystemReservedChars(String(params[key])).trim();
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
334
|
let savePathRule = manager.savePathRule;
|
|
331
335
|
try {
|
|
332
336
|
savePathRule = ejs.render(savePathRule, params);
|
|
337
|
+
console.log("解析后保存路径模板:", savePathRule, params);
|
|
333
338
|
}
|
|
334
339
|
catch (error) {
|
|
335
|
-
console.error("模板解析错误", error);
|
|
340
|
+
console.error("模板解析错误", error, savePathRule, params);
|
|
336
341
|
}
|
|
337
342
|
return formatTemplate(savePathRule, params);
|
|
338
343
|
}
|
|
@@ -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,195 @@
|
|
|
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
|
+
this.process = spawn(bililiveExecutable, args, {
|
|
47
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
48
|
+
});
|
|
49
|
+
if (this.process.stdout) {
|
|
50
|
+
this.process.stdout.on("data", (data) => {
|
|
51
|
+
const output = data.toString();
|
|
52
|
+
// console.log(output);
|
|
53
|
+
this.emit("stderr", output);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (this.process.stderr) {
|
|
57
|
+
this.process.stderr.on("data", (data) => {
|
|
58
|
+
const output = data.toString();
|
|
59
|
+
// console.error(output);
|
|
60
|
+
this.emit("stderr", output);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
this.process.on("error", (error) => {
|
|
64
|
+
this.emit("error", error);
|
|
65
|
+
});
|
|
66
|
+
[];
|
|
67
|
+
this.process.on("close", (code) => {
|
|
68
|
+
if (code === 0) {
|
|
69
|
+
this.emit("end");
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
this.emit("error", new Error(`bililive process exited with code ${code}`));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
kill(signal = "SIGTERM") {
|
|
77
|
+
if (this.process) {
|
|
78
|
+
this.process.kill(signal);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Factory function similar to createFFMPEGBuilder
|
|
83
|
+
export const createBililiveBuilder = () => {
|
|
84
|
+
return new BililiveRecorderCommand();
|
|
85
|
+
};
|
|
86
|
+
export class BililiveRecorder extends EventEmitter {
|
|
87
|
+
onEnd;
|
|
88
|
+
onUpdateLiveInfo;
|
|
89
|
+
type = "bililive";
|
|
90
|
+
command;
|
|
91
|
+
streamManager;
|
|
92
|
+
hasSegment;
|
|
93
|
+
getSavePath;
|
|
94
|
+
segment;
|
|
95
|
+
inputOptions = [];
|
|
96
|
+
disableDanma = false;
|
|
97
|
+
url;
|
|
98
|
+
debugLevel = "none";
|
|
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
|
+
this.debugLevel = opts.debugLevel ?? "none";
|
|
107
|
+
let videoFormat = "flv";
|
|
108
|
+
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "bililive", videoFormat, {
|
|
109
|
+
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
110
|
+
});
|
|
111
|
+
this.hasSegment = hasSegment;
|
|
112
|
+
this.getSavePath = opts.getSavePath;
|
|
113
|
+
this.inputOptions = [];
|
|
114
|
+
this.url = opts.url;
|
|
115
|
+
this.segment = opts.segment;
|
|
116
|
+
this.headers = opts.headers;
|
|
117
|
+
this.command = this.createCommand();
|
|
118
|
+
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
|
|
119
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename, title });
|
|
120
|
+
});
|
|
121
|
+
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
122
|
+
this.emit("videoFileCompleted", { filename });
|
|
123
|
+
});
|
|
124
|
+
this.streamManager.on("DebugLog", (data) => {
|
|
125
|
+
this.emit("DebugLog", data);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
createCommand() {
|
|
129
|
+
const inputOptions = [
|
|
130
|
+
...this.inputOptions,
|
|
131
|
+
"-h",
|
|
132
|
+
"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",
|
|
133
|
+
];
|
|
134
|
+
if (this.debugLevel === "verbose") {
|
|
135
|
+
inputOptions.push("-l", "Debug");
|
|
136
|
+
}
|
|
137
|
+
if (this.headers) {
|
|
138
|
+
Object.entries(this.headers).forEach(([key, value]) => {
|
|
139
|
+
if (!value)
|
|
140
|
+
return;
|
|
141
|
+
inputOptions.push("-h", `${key}: ${value}`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (this.hasSegment) {
|
|
145
|
+
inputOptions.push("-d", `${this.segment}`);
|
|
146
|
+
}
|
|
147
|
+
const command = createBililiveBuilder()
|
|
148
|
+
.input(this.url)
|
|
149
|
+
.inputOptions(inputOptions)
|
|
150
|
+
.output(this.streamManager.videoFilePath)
|
|
151
|
+
.on("error", this.onEnd)
|
|
152
|
+
.on("end", () => this.onEnd("finished"))
|
|
153
|
+
.on("stderr", async (stderrLine) => {
|
|
154
|
+
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
155
|
+
await this.streamManager.handleVideoStarted(stderrLine);
|
|
156
|
+
const info = this.formatLine(stderrLine);
|
|
157
|
+
if (info) {
|
|
158
|
+
this.emit("progress", info);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return command;
|
|
162
|
+
}
|
|
163
|
+
formatLine(line) {
|
|
164
|
+
if (!line.includes("下载进度:")) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
let time = null;
|
|
168
|
+
const timeMatch = line.match(/录制时长:\s*([0-9:]+)\s/);
|
|
169
|
+
if (timeMatch) {
|
|
170
|
+
time = timeMatch[1];
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
time,
|
|
174
|
+
};
|
|
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,8 +1,11 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import { IRecorder, FFMPEGRecorderOptions } from "./IRecorder.js";
|
|
3
|
+
import { FormatName } from "./index.js";
|
|
4
|
+
import type { VideoFormat } from "../index.js";
|
|
3
5
|
export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
|
|
4
6
|
private onEnd;
|
|
5
7
|
private onUpdateLiveInfo;
|
|
8
|
+
type: "ffmpeg";
|
|
6
9
|
private command;
|
|
7
10
|
private streamManager;
|
|
8
11
|
private timeoutChecker;
|
|
@@ -17,8 +20,9 @@ export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
|
|
|
17
20
|
readonly isHls: boolean;
|
|
18
21
|
readonly disableDanma: boolean;
|
|
19
22
|
readonly url: string;
|
|
20
|
-
formatName:
|
|
21
|
-
videoFormat:
|
|
23
|
+
formatName: FormatName;
|
|
24
|
+
videoFormat: VideoFormat;
|
|
25
|
+
readonly debugLevel: "none" | "basic" | "verbose";
|
|
22
26
|
readonly headers: {
|
|
23
27
|
[key: string]: string | undefined;
|
|
24
28
|
} | undefined;
|
|
@@ -27,6 +31,7 @@ export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
|
|
|
27
31
|
cover?: string;
|
|
28
32
|
}>);
|
|
29
33
|
createCommand(): import("@renmu/fluent-ffmpeg").FfmpegCommand;
|
|
34
|
+
buildOutputOptions(): string[];
|
|
30
35
|
formatLine(line: string): {
|
|
31
36
|
time: string | null;
|
|
32
37
|
} | null;
|
|
@@ -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
|
}
|
|
@@ -38,7 +37,7 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
38
37
|
let videoFormat = opts.videoFormat ?? "auto";
|
|
39
38
|
if (videoFormat === "auto") {
|
|
40
39
|
if (!this.hasSegment) {
|
|
41
|
-
videoFormat = "
|
|
40
|
+
videoFormat = "m4s";
|
|
42
41
|
if (this.formatName === "ts") {
|
|
43
42
|
videoFormat = "ts";
|
|
44
43
|
}
|
|
@@ -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, title }) => {
|
|
63
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename, title });
|
|
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]) => {
|
|
@@ -90,10 +95,11 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
90
95
|
inputOptions.push("-headers", headers.join("\\r\\n"));
|
|
91
96
|
}
|
|
92
97
|
}
|
|
98
|
+
const outputOptions = this.buildOutputOptions();
|
|
93
99
|
const command = createFFMPEGBuilder()
|
|
94
100
|
.input(this.url)
|
|
95
101
|
.inputOptions(inputOptions)
|
|
96
|
-
.outputOptions(
|
|
102
|
+
.outputOptions(outputOptions)
|
|
97
103
|
.output(this.streamManager.videoFilePath)
|
|
98
104
|
.on("error", this.onEnd)
|
|
99
105
|
.on("end", () => this.onEnd("finished"))
|
|
@@ -111,10 +117,24 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
111
117
|
}
|
|
112
118
|
})
|
|
113
119
|
.on("stderr", this.timeoutChecker?.update);
|
|
120
|
+
return command;
|
|
121
|
+
}
|
|
122
|
+
buildOutputOptions() {
|
|
123
|
+
const options = [];
|
|
124
|
+
options.push(...this.ffmpegOutputOptions);
|
|
125
|
+
options.push("-c", "copy", "-movflags", "+frag_keyframe+empty_moov+separate_moof", "-fflags", "+genpts+igndts", "-min_frag_duration", "10000000");
|
|
114
126
|
if (this.hasSegment) {
|
|
115
|
-
|
|
127
|
+
options.push("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
|
|
128
|
+
if (this.videoFormat === "m4s") {
|
|
129
|
+
options.push("-segment_format", "mp4");
|
|
130
|
+
}
|
|
116
131
|
}
|
|
117
|
-
|
|
132
|
+
else {
|
|
133
|
+
if (this.videoFormat === "m4s") {
|
|
134
|
+
options.push("-f", "mp4");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return options;
|
|
118
138
|
}
|
|
119
139
|
formatLine(line) {
|
|
120
140
|
if (!line.includes("time=")) {
|