@bililive-tools/manager 1.0.1 → 1.1.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 +10 -0
- package/lib/FFMPEGRecorder.d.ts +36 -0
- package/lib/FFMPEGRecorder.js +108 -0
- package/lib/api.d.ts +1 -1
- package/lib/api.js +11 -7
- package/lib/common.d.ts +3 -1
- package/lib/common.js +3 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +2 -1
- package/lib/manager.d.ts +9 -3
- package/lib/manager.js +36 -25
- package/lib/recorder.d.ts +14 -1
- package/lib/streamManager.d.ts +12 -13
- package/lib/streamManager.js +52 -43
- package/lib/utils.d.ts +12 -2
- package/lib/utils.js +22 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
# 安装
|
|
8
8
|
|
|
9
|
+
**建议所有录制器和manager包都升级到最新版,我不会对兼容性做过多考虑**
|
|
10
|
+
|
|
9
11
|
`npm i @bililive-tools/manager`
|
|
10
12
|
|
|
11
13
|
## 支持的平台
|
|
@@ -124,6 +126,14 @@ setFFMPEGPath("ffmpeg.exe");
|
|
|
124
126
|
|
|
125
127
|
录制文件结束
|
|
126
128
|
|
|
129
|
+
### RecorderProgress
|
|
130
|
+
|
|
131
|
+
ffmpeg录制相关数据
|
|
132
|
+
|
|
133
|
+
### RecoderLiveStart
|
|
134
|
+
|
|
135
|
+
直播开始,**并非录制开始,同一场直播不会重复触发**
|
|
136
|
+
|
|
127
137
|
# 协议
|
|
128
138
|
|
|
129
139
|
与原项目保存一致为 LGPL
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
export declare class FFMPEGRecorder extends EventEmitter {
|
|
3
|
+
private onEnd;
|
|
4
|
+
private command;
|
|
5
|
+
private streamManager;
|
|
6
|
+
private timeoutChecker;
|
|
7
|
+
hasSegment: boolean;
|
|
8
|
+
getSavePath: (data: {
|
|
9
|
+
startTime: number;
|
|
10
|
+
}) => string;
|
|
11
|
+
segment: number;
|
|
12
|
+
ffmpegOutputOptions: string[];
|
|
13
|
+
inputOptions: string[];
|
|
14
|
+
isHls: boolean;
|
|
15
|
+
disableDanma: boolean;
|
|
16
|
+
url: string;
|
|
17
|
+
constructor(opts: {
|
|
18
|
+
url: string;
|
|
19
|
+
getSavePath: (data: {
|
|
20
|
+
startTime: number;
|
|
21
|
+
}) => string;
|
|
22
|
+
segment: number;
|
|
23
|
+
outputOptions: string[];
|
|
24
|
+
inputOptions?: string[];
|
|
25
|
+
isHls?: boolean;
|
|
26
|
+
disableDanma?: boolean;
|
|
27
|
+
}, onEnd: (...args: unknown[]) => void);
|
|
28
|
+
private createCommand;
|
|
29
|
+
formatLine(line: string): {
|
|
30
|
+
time: string | null;
|
|
31
|
+
} | null;
|
|
32
|
+
run(): void;
|
|
33
|
+
getArguments(): string[];
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
getExtraDataController(): import("./record_extra_data_controller.js").RecordExtraDataController | null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import { createFFMPEGBuilder, StreamManager, utils } from "./index.js";
|
|
3
|
+
import { createInvalidStreamChecker, assert } from "./utils.js";
|
|
4
|
+
export class FFMPEGRecorder extends EventEmitter {
|
|
5
|
+
onEnd;
|
|
6
|
+
command;
|
|
7
|
+
streamManager;
|
|
8
|
+
timeoutChecker;
|
|
9
|
+
hasSegment;
|
|
10
|
+
getSavePath;
|
|
11
|
+
segment;
|
|
12
|
+
ffmpegOutputOptions = [];
|
|
13
|
+
inputOptions = [];
|
|
14
|
+
isHls = false;
|
|
15
|
+
disableDanma = false;
|
|
16
|
+
url;
|
|
17
|
+
constructor(opts, onEnd) {
|
|
18
|
+
super();
|
|
19
|
+
this.onEnd = onEnd;
|
|
20
|
+
const hasSegment = !!opts.segment;
|
|
21
|
+
this.disableDanma = opts.disableDanma ?? false;
|
|
22
|
+
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma);
|
|
23
|
+
this.timeoutChecker = utils.createTimeoutChecker(() => this.onEnd("ffmpeg timeout"), 3 * 10e3);
|
|
24
|
+
this.hasSegment = hasSegment;
|
|
25
|
+
this.getSavePath = opts.getSavePath;
|
|
26
|
+
this.ffmpegOutputOptions = opts.outputOptions;
|
|
27
|
+
this.inputOptions = opts.inputOptions ?? [];
|
|
28
|
+
this.url = opts.url;
|
|
29
|
+
this.segment = opts.segment;
|
|
30
|
+
this.isHls = opts.isHls ?? false;
|
|
31
|
+
this.command = this.createCommand();
|
|
32
|
+
this.streamManager.on("videoFileCreated", ({ filename }) => {
|
|
33
|
+
this.emit("videoFileCreated", { filename });
|
|
34
|
+
});
|
|
35
|
+
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
36
|
+
this.emit("videoFileCompleted", { filename });
|
|
37
|
+
});
|
|
38
|
+
this.streamManager.on("DebugLog", (data) => {
|
|
39
|
+
this.emit("DebugLog", data);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
createCommand() {
|
|
43
|
+
const invalidCount = this.isHls ? 35 : 15;
|
|
44
|
+
const isInvalidStream = createInvalidStreamChecker(invalidCount);
|
|
45
|
+
const command = createFFMPEGBuilder()
|
|
46
|
+
.input(this.url)
|
|
47
|
+
.inputOptions([
|
|
48
|
+
...this.inputOptions,
|
|
49
|
+
"-user_agent",
|
|
50
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36",
|
|
51
|
+
])
|
|
52
|
+
.outputOptions(this.ffmpegOutputOptions)
|
|
53
|
+
.output(this.streamManager.videoFilePath)
|
|
54
|
+
.on("error", this.onEnd)
|
|
55
|
+
.on("end", () => this.onEnd("finished"))
|
|
56
|
+
.on("stderr", async (stderrLine) => {
|
|
57
|
+
assert(typeof stderrLine === "string");
|
|
58
|
+
await this.streamManager.handleVideoStarted(stderrLine);
|
|
59
|
+
// TODO:解析时间
|
|
60
|
+
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
61
|
+
const info = this.formatLine(stderrLine);
|
|
62
|
+
if (info) {
|
|
63
|
+
this.emit("progress", info);
|
|
64
|
+
}
|
|
65
|
+
if (isInvalidStream(stderrLine)) {
|
|
66
|
+
this.onEnd("invalid stream");
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.on("stderr", this.timeoutChecker.update);
|
|
70
|
+
if (this.hasSegment) {
|
|
71
|
+
command.outputOptions("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
|
|
72
|
+
}
|
|
73
|
+
return command;
|
|
74
|
+
}
|
|
75
|
+
formatLine(line) {
|
|
76
|
+
if (!line.includes("time=")) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
let time = null;
|
|
80
|
+
const timeMatch = line.match(/time=([0-9:.]+)/);
|
|
81
|
+
if (timeMatch) {
|
|
82
|
+
time = timeMatch[1];
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
time,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
run() {
|
|
89
|
+
this.command.run();
|
|
90
|
+
}
|
|
91
|
+
getArguments() {
|
|
92
|
+
return this.command._getArguments();
|
|
93
|
+
}
|
|
94
|
+
async stop() {
|
|
95
|
+
this.timeoutChecker.stop();
|
|
96
|
+
try {
|
|
97
|
+
// @ts-ignore
|
|
98
|
+
this.command.ffmpegProc?.stdin?.write("q");
|
|
99
|
+
await this.streamManager.handleVideoCompleted();
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
this.emit("DebugLog", { type: "common", text: String(err) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
getExtraDataController() {
|
|
106
|
+
return this.streamManager?.getExtraDataController();
|
|
107
|
+
}
|
|
108
|
+
}
|
package/lib/api.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function getBiliStatusInfoByRoomIds<RoomId extends number>(RoomIds: RoomId[]): Promise<Record<string, boolean>>;
|
package/lib/api.js
CHANGED
|
@@ -6,15 +6,19 @@ const requester = axios.create({
|
|
|
6
6
|
// 但会导致请求报错 "Client network socket disconnected before secure TLS connection was established"。
|
|
7
7
|
proxy: false,
|
|
8
8
|
});
|
|
9
|
-
export async function
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
});
|
|
9
|
+
export async function getBiliStatusInfoByRoomIds(RoomIds) {
|
|
10
|
+
const roomParams = `${RoomIds.map((id) => `room_ids=${id}`).join("&")}`;
|
|
11
|
+
const res = await requester.get(`https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo?${roomParams}&req_biz=web_room_componet`);
|
|
13
12
|
assert(res.data.code === 0, `Unexpected resp, code ${res.data.code}, msg ${res.data.message}`);
|
|
14
13
|
const obj = {};
|
|
15
|
-
for (const
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
for (const roomId of RoomIds) {
|
|
15
|
+
try {
|
|
16
|
+
const data = res.data.data.by_room_ids[roomId];
|
|
17
|
+
obj[roomId] = data?.live_status === 1;
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
18
22
|
}
|
|
19
23
|
return obj;
|
|
20
24
|
}
|
package/lib/common.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ export type ChannelId = string;
|
|
|
3
3
|
export declare const Qualities: readonly ["lowest", "low", "medium", "high", "highest"];
|
|
4
4
|
export declare const BiliQualities: readonly [30000, 20000, 10000, 400, 250, 150, 80];
|
|
5
5
|
export declare const DouyuQualities: readonly [0, 2, 3, 4, 8];
|
|
6
|
-
export
|
|
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"];
|
|
8
|
+
export type Quality = (typeof Qualities)[number] | (typeof BiliQualities)[number] | (typeof DouyuQualities)[number] | (typeof HuYaQualities)[number] | (typeof DouYinQualities)[number];
|
|
7
9
|
export interface MessageSender<E extends AnyObject = UnknownObject> {
|
|
8
10
|
uid?: string;
|
|
9
11
|
name: string;
|
package/lib/common.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export const Qualities = ["lowest", "low", "medium", "high", "highest"];
|
|
2
2
|
export const BiliQualities = [30000, 20000, 10000, 400, 250, 150, 80];
|
|
3
3
|
export const DouyuQualities = [0, 2, 3, 4, 8];
|
|
4
|
+
// 14100: 2K HDR;14000:2K;4200:HDR(10M);0:原画;8000:蓝光8M;4000:蓝光4M;2000:超清;500:流畅
|
|
5
|
+
export const HuYaQualities = [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500];
|
|
6
|
+
export const DouYinQualities = ["origin", "uhd", "hd", "sd", "ld", "ao"];
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./common.js";
|
|
|
6
6
|
export * from "./recorder.js";
|
|
7
7
|
export * from "./manager.js";
|
|
8
8
|
export * from "./record_extra_data_controller.js";
|
|
9
|
+
export * from "./FFMPEGRecorder.js";
|
|
9
10
|
export { utils };
|
|
10
11
|
/**
|
|
11
12
|
* 提供一些 utils
|
|
@@ -34,6 +35,7 @@ export function defaultToJSON(provider, recorder) {
|
|
|
34
35
|
"disableProvideCommentsWhenRecording",
|
|
35
36
|
"liveInfo",
|
|
36
37
|
"uid",
|
|
38
|
+
"titleKeywords",
|
|
37
39
|
]),
|
|
38
40
|
};
|
|
39
41
|
}
|
|
@@ -54,6 +56,5 @@ export const createFFMPEGBuilder = (...args) => {
|
|
|
54
56
|
return ffmpeg(...args);
|
|
55
57
|
};
|
|
56
58
|
export function getDataFolderPath(provider) {
|
|
57
|
-
// TODO: 改成 AppData 之类的目录
|
|
58
59
|
return "./" + provider.id;
|
|
59
60
|
}
|
package/lib/manager.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Emitter } from "mitt";
|
|
2
2
|
import { ChannelId, Message } from "./common.js";
|
|
3
|
-
import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog } from "./recorder.js";
|
|
3
|
+
import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog, Progress } from "./recorder.js";
|
|
4
4
|
import { AnyObject, UnknownObject } from "./utils.js";
|
|
5
5
|
import { StreamManager } from "./streamManager.js";
|
|
6
6
|
export interface RecorderProvider<E extends AnyObject> {
|
|
@@ -18,7 +18,7 @@ export interface RecorderProvider<E extends AnyObject> {
|
|
|
18
18
|
fromJSON: <T extends SerializedRecorder<E>>(this: RecorderProvider<E>, json: T) => Recorder<E>;
|
|
19
19
|
setFFMPEGOutputArgs: (this: RecorderProvider<E>, args: string[]) => void;
|
|
20
20
|
}
|
|
21
|
-
declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "
|
|
21
|
+
declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "ffmpegOutputArgs", "biliBatchQuery"];
|
|
22
22
|
type ConfigurableProp = (typeof configurableProps)[number];
|
|
23
23
|
export interface RecorderManager<ME extends UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE> extends Emitter<{
|
|
24
24
|
error: {
|
|
@@ -41,6 +41,13 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
41
41
|
recorder: Recorder<E>;
|
|
42
42
|
filename: string;
|
|
43
43
|
};
|
|
44
|
+
RecorderProgress: {
|
|
45
|
+
recorder: Recorder<E>;
|
|
46
|
+
progress: Progress;
|
|
47
|
+
};
|
|
48
|
+
RecoderLiveStart: {
|
|
49
|
+
recorder: Recorder<E>;
|
|
50
|
+
};
|
|
44
51
|
RecordStop: {
|
|
45
52
|
recorder: Recorder<E>;
|
|
46
53
|
recordHandle: RecordHandle;
|
|
@@ -68,7 +75,6 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
68
75
|
removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
|
|
69
76
|
startRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
70
77
|
stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
71
|
-
autoCheckLiveStatusAndRecord: boolean;
|
|
72
78
|
autoCheckInterval: number;
|
|
73
79
|
isCheckLoopRunning: boolean;
|
|
74
80
|
startCheckLoop: (this: RecorderManager<ME, P, PE, E>) => void;
|
package/lib/manager.js
CHANGED
|
@@ -2,13 +2,12 @@ import path from "node:path";
|
|
|
2
2
|
import mitt from "mitt";
|
|
3
3
|
import { omit, range } from "lodash-es";
|
|
4
4
|
import { parseArgsStringToArgv } from "string-argv";
|
|
5
|
-
import {
|
|
6
|
-
import { formatDate, removeSystemReservedChars, formatTemplate, } from "./utils.js";
|
|
5
|
+
import { getBiliStatusInfoByRoomIds } from "./api.js";
|
|
6
|
+
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, } from "./utils.js";
|
|
7
7
|
import { StreamManager } from "./streamManager.js";
|
|
8
8
|
const configurableProps = [
|
|
9
9
|
"savePathRule",
|
|
10
10
|
"autoRemoveSystemReservedChars",
|
|
11
|
-
"autoCheckLiveStatusAndRecord",
|
|
12
11
|
"autoCheckInterval",
|
|
13
12
|
"ffmpegOutputArgs",
|
|
14
13
|
"biliBatchQuery",
|
|
@@ -21,11 +20,12 @@ export function createRecorderManager(opts) {
|
|
|
21
20
|
let checkLoopTimer;
|
|
22
21
|
const multiThreadCheck = async (manager) => {
|
|
23
22
|
const handleBatchQuery = async (obj) => {
|
|
24
|
-
for (const recorder of recorders
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const isLive = obj[recorder.
|
|
28
|
-
|
|
23
|
+
for (const recorder of recorders
|
|
24
|
+
.filter((r) => !r.disableAutoCheck)
|
|
25
|
+
.filter((r) => r.providerId === "Bilibili")) {
|
|
26
|
+
const isLive = obj[recorder.channelId];
|
|
27
|
+
// 如果是undefined,说明这个接口查不到相关信息,使用录制器内的再查一次
|
|
28
|
+
if (isLive === true || isLive === undefined) {
|
|
29
29
|
await recorder.checkLiveStatusAndRecord({
|
|
30
30
|
getSavePath(data) {
|
|
31
31
|
return genSavePathFromRule(manager, recorder, data);
|
|
@@ -41,25 +41,19 @@ export function createRecorderManager(opts) {
|
|
|
41
41
|
let needCheckRecorders = recorders.filter((r) => !r.disableAutoCheck);
|
|
42
42
|
let threads = [];
|
|
43
43
|
if (manager.biliBatchQuery) {
|
|
44
|
-
const biliNeedCheckRecorders = needCheckRecorders
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return true;
|
|
50
|
-
return false;
|
|
51
|
-
});
|
|
52
|
-
const uids = biliNeedCheckRecorders.map((r) => r.extra?.recorderUid);
|
|
53
|
-
// console.log("uids", uids);
|
|
44
|
+
const biliNeedCheckRecorders = needCheckRecorders
|
|
45
|
+
.filter((r) => r.providerId === "Bilibili")
|
|
46
|
+
.filter((r) => r.recordHandle == null);
|
|
47
|
+
needCheckRecorders = needCheckRecorders.filter((r) => r.providerId !== "Bilibili");
|
|
48
|
+
const roomIds = biliNeedCheckRecorders.map((r) => r.channelId).map(Number);
|
|
54
49
|
try {
|
|
55
|
-
if (
|
|
56
|
-
const biliStatus = await
|
|
57
|
-
// console.log("biliStatus", biliStatus);
|
|
50
|
+
if (roomIds.length !== 0) {
|
|
51
|
+
const biliStatus = await getBiliStatusInfoByRoomIds(roomIds);
|
|
58
52
|
threads.push(handleBatchQuery(biliStatus));
|
|
59
53
|
}
|
|
60
54
|
}
|
|
61
55
|
catch (err) {
|
|
62
|
-
manager.emit("error", { source: "
|
|
56
|
+
manager.emit("error", { source: "getBiliStatusInfoByRoomIds", err });
|
|
63
57
|
}
|
|
64
58
|
}
|
|
65
59
|
const checkOnce = async () => {
|
|
@@ -88,6 +82,8 @@ export function createRecorderManager(opts) {
|
|
|
88
82
|
};
|
|
89
83
|
// 用于记录暂时被 ban 掉的直播间
|
|
90
84
|
const tempBanObj = {};
|
|
85
|
+
// 用于是否触发LiveStart事件,不要重复触发
|
|
86
|
+
const liveStartObj = {};
|
|
91
87
|
const manager = {
|
|
92
88
|
// @ts-ignore
|
|
93
89
|
...mitt(),
|
|
@@ -106,12 +102,28 @@ export function createRecorderManager(opts) {
|
|
|
106
102
|
this.recorders.push(recorder);
|
|
107
103
|
recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder, recordHandle }));
|
|
108
104
|
recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder, recordHandle }));
|
|
109
|
-
recorder.on("videoFileCreated", ({ filename }) =>
|
|
105
|
+
recorder.on("videoFileCreated", ({ filename }) => {
|
|
106
|
+
if (recorder.saveCover && recorder?.liveInfo?.cover) {
|
|
107
|
+
const coverPath = replaceExtName(filename, ".jpg");
|
|
108
|
+
downloadImage(recorder?.liveInfo?.cover, coverPath);
|
|
109
|
+
}
|
|
110
|
+
this.emit("videoFileCreated", { recorder, filename });
|
|
111
|
+
});
|
|
110
112
|
recorder.on("videoFileCompleted", ({ filename }) => this.emit("videoFileCompleted", { recorder, filename }));
|
|
111
113
|
recorder.on("RecordStop", ({ recordHandle, reason }) => this.emit("RecordStop", { recorder, recordHandle, reason }));
|
|
112
114
|
recorder.on("Message", (message) => this.emit("Message", { recorder, message }));
|
|
113
115
|
recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder, keys }));
|
|
114
116
|
recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder, ...log }));
|
|
117
|
+
recorder.on("progress", (progress) => {
|
|
118
|
+
this.emit("RecorderProgress", { recorder, progress });
|
|
119
|
+
});
|
|
120
|
+
recorder.on("LiveStart", ({ liveId }) => {
|
|
121
|
+
const key = `${recorder.channelId}-${liveId}`;
|
|
122
|
+
if (liveStartObj[key])
|
|
123
|
+
return;
|
|
124
|
+
liveStartObj[key] = true;
|
|
125
|
+
this.emit("RecoderLiveStart", { recorder });
|
|
126
|
+
});
|
|
115
127
|
this.emit("RecorderAdded", recorder);
|
|
116
128
|
return recorder;
|
|
117
129
|
},
|
|
@@ -134,7 +146,7 @@ export function createRecorderManager(opts) {
|
|
|
134
146
|
getSavePath(data) {
|
|
135
147
|
return genSavePathFromRule(manager, recorder, data);
|
|
136
148
|
},
|
|
137
|
-
|
|
149
|
+
isManualStart: true,
|
|
138
150
|
});
|
|
139
151
|
delete tempBanObj[recorder.channelId];
|
|
140
152
|
recorder.tempStopIntervalCheck = false;
|
|
@@ -154,7 +166,6 @@ export function createRecorderManager(opts) {
|
|
|
154
166
|
}
|
|
155
167
|
return recorder;
|
|
156
168
|
},
|
|
157
|
-
autoCheckLiveStatusAndRecord: opts.autoCheckLiveStatusAndRecord ?? true,
|
|
158
169
|
autoCheckInterval: opts.autoCheckInterval ?? 1000,
|
|
159
170
|
isCheckLoopRunning: false,
|
|
160
171
|
startCheckLoop() {
|
package/lib/recorder.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
14
14
|
quality: Quality;
|
|
15
15
|
streamPriorities: string[];
|
|
16
16
|
sourcePriorities: string[];
|
|
17
|
+
formatPriorities?: string[];
|
|
17
18
|
segment?: number;
|
|
18
19
|
saveGiftDanma?: boolean;
|
|
19
20
|
saveSCDanma?: boolean;
|
|
@@ -33,16 +34,24 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
33
34
|
formatName?: FormatName;
|
|
34
35
|
/** 流编码 */
|
|
35
36
|
codecName?: CodecName;
|
|
37
|
+
/** 选择使用的api,虎牙支持 */
|
|
38
|
+
api?: "auto" | "web" | "mp";
|
|
39
|
+
/** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制(仅对斗鱼有效),多个关键词用英文逗号分隔 */
|
|
40
|
+
titleKeywords?: string;
|
|
36
41
|
extra?: Partial<E>;
|
|
37
42
|
}
|
|
38
43
|
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id">;
|
|
39
44
|
export type RecorderState = "idle" | "recording" | "stopping-record";
|
|
45
|
+
export type Progress = {
|
|
46
|
+
time: string | null;
|
|
47
|
+
};
|
|
40
48
|
export interface RecordHandle {
|
|
41
49
|
id: string;
|
|
42
50
|
stream: string;
|
|
43
51
|
source: string;
|
|
44
52
|
url: string;
|
|
45
53
|
ffmpegArgs?: string[];
|
|
54
|
+
progress?: Progress;
|
|
46
55
|
savePath: string;
|
|
47
56
|
stop: (this: RecordHandle, reason?: string, tempStopIntervalCheck?: boolean) => Promise<void>;
|
|
48
57
|
}
|
|
@@ -64,6 +73,10 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
64
73
|
videoFileCompleted: {
|
|
65
74
|
filename: string;
|
|
66
75
|
};
|
|
76
|
+
progress: Progress;
|
|
77
|
+
LiveStart: {
|
|
78
|
+
liveId: string;
|
|
79
|
+
};
|
|
67
80
|
RecordStop: {
|
|
68
81
|
recordHandle: RecordHandle;
|
|
69
82
|
reason?: string;
|
|
@@ -95,8 +108,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
95
108
|
getChannelURL: (this: Recorder<E>) => string;
|
|
96
109
|
checkLiveStatusAndRecord: (this: Recorder<E>, opts: {
|
|
97
110
|
getSavePath: GetSavePath;
|
|
98
|
-
qualityRetry?: number;
|
|
99
111
|
banLiveId?: string;
|
|
112
|
+
isManualStart?: boolean;
|
|
100
113
|
}) => Promise<RecordHandle | null>;
|
|
101
114
|
recordHandle?: RecordHandle;
|
|
102
115
|
toJSON: (this: Recorder<E>) => SerializedRecorder<E>;
|
package/lib/streamManager.d.ts
CHANGED
|
@@ -1,29 +1,28 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
1
2
|
import { createRecordExtraDataController } from "./record_extra_data_controller.js";
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
export type GetSavePath = (data: {
|
|
4
|
+
startTime: number;
|
|
5
|
+
}) => string;
|
|
6
|
+
export declare class Segment extends EventEmitter {
|
|
4
7
|
extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
|
|
5
8
|
init: boolean;
|
|
6
9
|
getSavePath: GetSavePath;
|
|
7
|
-
owner: string;
|
|
8
|
-
title: string;
|
|
9
|
-
recorder: Recorder;
|
|
10
10
|
/** 原始的ffmpeg文件名,用于重命名 */
|
|
11
11
|
rawRecordingVideoPath: string;
|
|
12
12
|
/** 输出文件名名,不包含拓展名 */
|
|
13
13
|
outputVideoFilePath: string;
|
|
14
|
-
|
|
14
|
+
disableDanma: boolean;
|
|
15
|
+
constructor(getSavePath: GetSavePath, disableDanma: boolean);
|
|
15
16
|
handleSegmentEnd(): Promise<void>;
|
|
16
17
|
onSegmentStart(stderrLine: string): Promise<void>;
|
|
17
18
|
}
|
|
18
|
-
export declare class StreamManager {
|
|
19
|
-
private
|
|
19
|
+
export declare class StreamManager extends EventEmitter {
|
|
20
|
+
private segment;
|
|
20
21
|
private extraDataController;
|
|
21
|
-
recorder: Recorder;
|
|
22
|
-
owner: string;
|
|
23
|
-
title: string;
|
|
24
22
|
recordSavePath: string;
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
recordStartTime?: number;
|
|
24
|
+
constructor(getSavePath: GetSavePath, hasSegment: boolean, disableDanma: boolean);
|
|
25
|
+
handleVideoStarted(stderrLine: string): Promise<void>;
|
|
27
26
|
handleVideoCompleted(): Promise<void>;
|
|
28
27
|
getExtraDataController(): import("./record_extra_data_controller.js").RecordExtraDataController | null;
|
|
29
28
|
get videoFilePath(): string;
|
package/lib/streamManager.js
CHANGED
|
@@ -1,41 +1,38 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
1
2
|
import fs from "fs-extra";
|
|
2
3
|
import { createRecordExtraDataController } from "./record_extra_data_controller.js";
|
|
3
|
-
import { replaceExtName, ensureFolderExist } from "./utils.js";
|
|
4
|
-
export class Segment {
|
|
4
|
+
import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isFfmpegStart } from "./utils.js";
|
|
5
|
+
export class Segment extends EventEmitter {
|
|
5
6
|
extraDataController = null;
|
|
6
7
|
init = true;
|
|
7
8
|
getSavePath;
|
|
8
|
-
owner;
|
|
9
|
-
title;
|
|
10
|
-
recorder;
|
|
11
9
|
/** 原始的ffmpeg文件名,用于重命名 */
|
|
12
10
|
rawRecordingVideoPath;
|
|
13
11
|
/** 输出文件名名,不包含拓展名 */
|
|
14
12
|
outputVideoFilePath;
|
|
15
|
-
|
|
13
|
+
disableDanma;
|
|
14
|
+
constructor(getSavePath, disableDanma) {
|
|
15
|
+
super();
|
|
16
16
|
this.getSavePath = getSavePath;
|
|
17
|
-
this.
|
|
18
|
-
this.title = title;
|
|
19
|
-
this.recorder = recorder;
|
|
17
|
+
this.disableDanma = disableDanma;
|
|
20
18
|
}
|
|
21
19
|
async handleSegmentEnd() {
|
|
22
20
|
if (!this.outputVideoFilePath) {
|
|
23
|
-
this.
|
|
21
|
+
this.emit("DebugLog", {
|
|
24
22
|
type: "common",
|
|
25
23
|
text: "Should call onSegmentStart first",
|
|
26
24
|
});
|
|
27
25
|
return;
|
|
28
26
|
}
|
|
29
|
-
this.extraDataController?.setMeta({ recordStopTimestamp: Date.now() });
|
|
30
27
|
try {
|
|
31
28
|
await Promise.all([
|
|
32
29
|
fs.rename(this.rawRecordingVideoPath, `${this.outputVideoFilePath}.ts`),
|
|
33
30
|
this.extraDataController?.flush(),
|
|
34
31
|
]);
|
|
35
|
-
this.
|
|
32
|
+
this.emit("videoFileCompleted", { filename: `${this.outputVideoFilePath}.ts` });
|
|
36
33
|
}
|
|
37
34
|
catch (err) {
|
|
38
|
-
this.
|
|
35
|
+
this.emit("DebugLog", {
|
|
39
36
|
type: "common",
|
|
40
37
|
text: "videoFileCompleted error " + String(err),
|
|
41
38
|
});
|
|
@@ -48,71 +45,83 @@ export class Segment {
|
|
|
48
45
|
this.init = false;
|
|
49
46
|
const startTime = Date.now();
|
|
50
47
|
this.outputVideoFilePath = this.getSavePath({
|
|
51
|
-
owner: this.owner,
|
|
52
|
-
title: this.title,
|
|
53
48
|
startTime: startTime,
|
|
54
49
|
});
|
|
55
50
|
ensureFolderExist(this.outputVideoFilePath);
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
if (!this.disableDanma) {
|
|
52
|
+
this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.json`);
|
|
53
|
+
}
|
|
58
54
|
const regex = /'([^']+)'/;
|
|
59
55
|
const match = stderrLine.match(regex);
|
|
60
56
|
if (match) {
|
|
61
57
|
const filename = match[1];
|
|
62
58
|
this.rawRecordingVideoPath = filename;
|
|
63
|
-
this.
|
|
59
|
+
this.emit("videoFileCreated", { filename: `${this.outputVideoFilePath}.ts` });
|
|
64
60
|
}
|
|
65
61
|
else {
|
|
66
|
-
this.
|
|
62
|
+
this.emit("DebugLog", { type: "ffmpeg", text: "No match found" });
|
|
67
63
|
}
|
|
68
64
|
}
|
|
69
65
|
}
|
|
70
|
-
export class StreamManager {
|
|
71
|
-
|
|
66
|
+
export class StreamManager extends EventEmitter {
|
|
67
|
+
segment = null;
|
|
72
68
|
extraDataController = null;
|
|
73
|
-
recorder;
|
|
74
|
-
owner;
|
|
75
|
-
title;
|
|
76
69
|
recordSavePath;
|
|
77
|
-
|
|
70
|
+
recordStartTime;
|
|
71
|
+
constructor(getSavePath, hasSegment, disableDanma) {
|
|
72
|
+
super();
|
|
73
|
+
const recordSavePath = getSavePath({ startTime: Date.now() });
|
|
78
74
|
this.recordSavePath = recordSavePath;
|
|
79
|
-
this.recorder = recorder;
|
|
80
|
-
this.owner = owner;
|
|
81
|
-
this.title = title;
|
|
82
75
|
if (hasSegment) {
|
|
83
|
-
this.
|
|
76
|
+
this.segment = new Segment(getSavePath, disableDanma);
|
|
77
|
+
this.segment.on("DebugLog", (data) => {
|
|
78
|
+
this.emit("DebugLog", data);
|
|
79
|
+
});
|
|
80
|
+
this.segment.on("videoFileCreated", (data) => {
|
|
81
|
+
this.emit("videoFileCreated", data);
|
|
82
|
+
});
|
|
83
|
+
this.segment.on("videoFileCompleted", (data) => {
|
|
84
|
+
this.emit("videoFileCompleted", data);
|
|
85
|
+
});
|
|
84
86
|
}
|
|
85
87
|
else {
|
|
86
88
|
const extraDataSavePath = replaceExtName(recordSavePath, ".json");
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
if (!disableDanma) {
|
|
90
|
+
this.extraDataController = createRecordExtraDataController(extraDataSavePath);
|
|
91
|
+
}
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
async handleVideoStarted(stderrLine) {
|
|
93
|
-
if (this.
|
|
94
|
-
if (stderrLine) {
|
|
95
|
-
await this.
|
|
95
|
+
if (this.segment) {
|
|
96
|
+
if (isFfmpegStartSegment(stderrLine)) {
|
|
97
|
+
await this.segment.onSegmentStart(stderrLine);
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
else {
|
|
99
|
-
|
|
101
|
+
// 不能直接在onStart回调进行判断,在某些情况下会链接无法录制的情况
|
|
102
|
+
if (isFfmpegStart(stderrLine)) {
|
|
103
|
+
if (this.recordStartTime)
|
|
104
|
+
return;
|
|
105
|
+
this.recordStartTime = Date.now();
|
|
106
|
+
this.emit("videoFileCreated", { filename: this.videoFilePath });
|
|
107
|
+
}
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
110
|
async handleVideoCompleted() {
|
|
103
|
-
if (this.
|
|
104
|
-
await this.
|
|
111
|
+
if (this.segment) {
|
|
112
|
+
await this.segment.handleSegmentEnd();
|
|
105
113
|
}
|
|
106
114
|
else {
|
|
107
|
-
this.
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
if (this.recordStartTime) {
|
|
116
|
+
await this.getExtraDataController()?.flush();
|
|
117
|
+
this.emit("videoFileCompleted", { filename: this.videoFilePath });
|
|
118
|
+
}
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
getExtraDataController() {
|
|
113
|
-
return this.
|
|
122
|
+
return this.segment?.extraDataController || this.extraDataController;
|
|
114
123
|
}
|
|
115
124
|
get videoFilePath() {
|
|
116
|
-
return this.
|
|
125
|
+
return this.segment ? `${this.recordSavePath}-PART%03d.ts` : `${this.recordSavePath}.ts`;
|
|
117
126
|
}
|
|
118
127
|
}
|
package/lib/utils.d.ts
CHANGED
|
@@ -36,13 +36,22 @@ 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 isFfmpegStart(line: string): boolean;
|
|
39
40
|
export declare const formatTemplate: (string: string, ...args: any[]) => string;
|
|
40
|
-
export declare function createInvalidStreamChecker(): (ffmpegLogLine: string) => boolean;
|
|
41
|
+
export declare function createInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => boolean;
|
|
41
42
|
export declare function createTimeoutChecker(onTimeout: () => void, time: number): {
|
|
42
43
|
update: () => void;
|
|
43
44
|
stop: () => void;
|
|
44
45
|
};
|
|
45
|
-
declare function downloadImage(imageUrl: string, savePath: string): Promise<void>;
|
|
46
|
+
export declare function downloadImage(imageUrl: string, savePath: string): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* 根据指定的顺序对对象数组进行排序
|
|
49
|
+
* @param objects 要排序的对象数组
|
|
50
|
+
* @param order 指定的顺序
|
|
51
|
+
* @param key 用于排序的键
|
|
52
|
+
* @returns 排序后的对象数组
|
|
53
|
+
*/
|
|
54
|
+
export declare function sortByKeyOrder<T, K extends keyof T>(objects: T[], order: T[K][], key: K): T[];
|
|
46
55
|
declare const _default: {
|
|
47
56
|
replaceExtName: typeof replaceExtName;
|
|
48
57
|
singleton: typeof singleton;
|
|
@@ -59,5 +68,6 @@ declare const _default: {
|
|
|
59
68
|
downloadImage: typeof downloadImage;
|
|
60
69
|
md5: (str: string) => string;
|
|
61
70
|
uuid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
71
|
+
sortByKeyOrder: typeof sortByKeyOrder;
|
|
62
72
|
};
|
|
63
73
|
export default _default;
|
package/lib/utils.js
CHANGED
|
@@ -126,6 +126,9 @@ 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 isFfmpegStart(line) {
|
|
130
|
+
return line.includes("frame=") && line.includes("fps=");
|
|
131
|
+
}
|
|
129
132
|
export const formatTemplate = function template(string, ...args) {
|
|
130
133
|
const nargs = /\{([0-9a-zA-Z_]+)\}/g;
|
|
131
134
|
let params;
|
|
@@ -152,7 +155,7 @@ export const formatTemplate = function template(string, ...args) {
|
|
|
152
155
|
}
|
|
153
156
|
});
|
|
154
157
|
};
|
|
155
|
-
export function createInvalidStreamChecker() {
|
|
158
|
+
export function createInvalidStreamChecker(count = 15) {
|
|
156
159
|
let prevFrame = 0;
|
|
157
160
|
let frameUnchangedCount = 0;
|
|
158
161
|
return (ffmpegLogLine) => {
|
|
@@ -161,7 +164,7 @@ export function createInvalidStreamChecker() {
|
|
|
161
164
|
const [, frameText] = streamInfo;
|
|
162
165
|
const frame = Number(frameText);
|
|
163
166
|
if (frame === prevFrame) {
|
|
164
|
-
if (++frameUnchangedCount >=
|
|
167
|
+
if (++frameUnchangedCount >= count) {
|
|
165
168
|
return true;
|
|
166
169
|
}
|
|
167
170
|
}
|
|
@@ -171,9 +174,6 @@ export function createInvalidStreamChecker() {
|
|
|
171
174
|
}
|
|
172
175
|
return false;
|
|
173
176
|
}
|
|
174
|
-
if (ffmpegLogLine.includes("HTTP error 404 Not Found")) {
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
177
|
return false;
|
|
178
178
|
};
|
|
179
179
|
}
|
|
@@ -201,7 +201,7 @@ export function createTimeoutChecker(onTimeout, time) {
|
|
|
201
201
|
},
|
|
202
202
|
};
|
|
203
203
|
}
|
|
204
|
-
async function downloadImage(imageUrl, savePath) {
|
|
204
|
+
export async function downloadImage(imageUrl, savePath) {
|
|
205
205
|
const res = await fetch(imageUrl);
|
|
206
206
|
if (!res.body) {
|
|
207
207
|
throw new Error("No body in response");
|
|
@@ -216,6 +216,21 @@ const md5 = (str) => {
|
|
|
216
216
|
const uuid = () => {
|
|
217
217
|
return crypto.randomUUID();
|
|
218
218
|
};
|
|
219
|
+
/**
|
|
220
|
+
* 根据指定的顺序对对象数组进行排序
|
|
221
|
+
* @param objects 要排序的对象数组
|
|
222
|
+
* @param order 指定的顺序
|
|
223
|
+
* @param key 用于排序的键
|
|
224
|
+
* @returns 排序后的对象数组
|
|
225
|
+
*/
|
|
226
|
+
export function sortByKeyOrder(objects, order, key) {
|
|
227
|
+
const orderMap = new Map(order.map((value, index) => [value, index]));
|
|
228
|
+
return [...objects].sort((a, b) => {
|
|
229
|
+
const indexA = orderMap.get(a[key]) ?? Number.MAX_VALUE;
|
|
230
|
+
const indexB = orderMap.get(b[key]) ?? Number.MAX_VALUE;
|
|
231
|
+
return indexA - indexB;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
219
234
|
export default {
|
|
220
235
|
replaceExtName,
|
|
221
236
|
singleton,
|
|
@@ -232,4 +247,5 @@ export default {
|
|
|
232
247
|
downloadImage,
|
|
233
248
|
md5,
|
|
234
249
|
uuid,
|
|
250
|
+
sortByKeyOrder,
|
|
235
251
|
};
|