@bililive-tools/manager 1.8.0 → 1.10.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 +16 -13
- package/lib/cache.d.ts +55 -16
- package/lib/cache.js +61 -33
- package/lib/common.d.ts +1 -2
- package/lib/common.js +0 -1
- package/lib/{recorder/BililiveRecorder.d.ts → downloader/BililiveDownloader.d.ts} +4 -4
- package/lib/{recorder/BililiveRecorder.js → downloader/BililiveDownloader.js} +17 -10
- package/lib/{recorder/FFMPEGRecorder.d.ts → downloader/FFmpegDownloader.d.ts} +8 -6
- package/lib/{recorder/FFMPEGRecorder.js → downloader/FFmpegDownloader.js} +38 -12
- package/lib/{recorder/IRecorder.d.ts → downloader/IDownloader.d.ts} +11 -7
- package/lib/downloader/index.d.ts +48 -0
- package/lib/{recorder → downloader}/index.js +16 -13
- package/lib/{recorder/mesioRecorder.d.ts → downloader/mesioDownloader.d.ts} +4 -4
- package/lib/{recorder/mesioRecorder.js → downloader/mesioDownloader.js} +17 -9
- package/lib/{recorder → downloader}/streamManager.d.ts +5 -6
- package/lib/{recorder → downloader}/streamManager.js +8 -25
- package/lib/index.d.ts +6 -2
- package/lib/index.js +4 -2
- package/lib/manager.d.ts +12 -8
- package/lib/manager.js +29 -38
- package/lib/record_extra_data_controller.d.ts +1 -1
- package/lib/recorder.d.ts +15 -13
- package/lib/utils.d.ts +31 -1
- package/lib/utils.js +131 -2
- package/lib/xml_stream_controller.d.ts +2 -2
- package/lib/xml_stream_controller.js +26 -6
- package/package.json +1 -1
- package/lib/recorder/index.d.ts +0 -48
- /package/lib/{recorder/IRecorder.js → downloader/IDownloader.js} +0 -0
|
@@ -1,27 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
1
|
+
import { FFmpegDownloader } from "./FFmpegDownloader.js";
|
|
2
|
+
import { mesioDownloader } from "./mesioDownloader.js";
|
|
3
|
+
import { BililiveDownloader } from "./BililiveDownloader.js";
|
|
4
|
+
import { parseSizeToBytes } from "../utils.js";
|
|
5
|
+
export { FFmpegDownloader } from "./FFmpegDownloader.js";
|
|
6
|
+
export { mesioDownloader } from "./mesioDownloader.js";
|
|
7
|
+
export { BililiveDownloader } from "./BililiveDownloader.js";
|
|
7
8
|
/**
|
|
8
9
|
* 创建录制器的工厂函数
|
|
9
10
|
*/
|
|
10
|
-
export function
|
|
11
|
+
export function createBaseDownloader(type, opts, onEnd, onUpdateLiveInfo) {
|
|
12
|
+
const segment = parseSizeToBytes(String(opts.segment));
|
|
13
|
+
const newOpts = { ...opts, segment };
|
|
11
14
|
if (type === "ffmpeg") {
|
|
12
|
-
return new
|
|
15
|
+
return new FFmpegDownloader(newOpts, onEnd, onUpdateLiveInfo);
|
|
13
16
|
}
|
|
14
17
|
else if (type === "mesio") {
|
|
15
|
-
return new
|
|
18
|
+
return new mesioDownloader(newOpts, onEnd, onUpdateLiveInfo);
|
|
16
19
|
}
|
|
17
20
|
else if (type === "bililive") {
|
|
18
21
|
if (opts.formatName === "flv") {
|
|
19
22
|
// 录播姬引擎不支持只录音频
|
|
20
23
|
if (!opts.onlyAudio) {
|
|
21
|
-
return new
|
|
24
|
+
return new BililiveDownloader(newOpts, onEnd, onUpdateLiveInfo);
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
|
-
return new
|
|
27
|
+
return new FFmpegDownloader(newOpts, onEnd, onUpdateLiveInfo);
|
|
25
28
|
}
|
|
26
29
|
else {
|
|
27
30
|
throw new Error(`Unsupported recorder type: ${type}`);
|
|
@@ -71,8 +74,8 @@ export function getSourceFormatName(streamUrl, formatName) {
|
|
|
71
74
|
/**
|
|
72
75
|
* 创建录制器的工厂函数
|
|
73
76
|
*/
|
|
74
|
-
export function
|
|
77
|
+
export function createDownloader(type, opts, onEnd, onUpdateLiveInfo) {
|
|
75
78
|
const recorderType = selectRecorder(type);
|
|
76
79
|
const sourceFormatName = getSourceFormatName(opts.url, opts.formatName);
|
|
77
|
-
return
|
|
80
|
+
return createBaseDownloader(recorderType, { ...opts, formatName: sourceFormatName }, onEnd, onUpdateLiveInfo);
|
|
78
81
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
|
-
import {
|
|
2
|
+
import { IDownloader, MesioRecorderOptions, Segment } from "./IDownloader.js";
|
|
3
3
|
declare class MesioCommand extends EventEmitter {
|
|
4
4
|
private _input;
|
|
5
5
|
private _output;
|
|
@@ -15,7 +15,7 @@ declare class MesioCommand extends EventEmitter {
|
|
|
15
15
|
kill(signal?: NodeJS.Signals): void;
|
|
16
16
|
}
|
|
17
17
|
export declare const createMesioBuilder: () => MesioCommand;
|
|
18
|
-
export declare class
|
|
18
|
+
export declare class mesioDownloader extends EventEmitter implements IDownloader {
|
|
19
19
|
private onEnd;
|
|
20
20
|
private onUpdateLiveInfo;
|
|
21
21
|
type: "mesio";
|
|
@@ -26,9 +26,8 @@ export declare class MesioRecorder extends EventEmitter implements IRecorder {
|
|
|
26
26
|
startTime: number;
|
|
27
27
|
title?: string;
|
|
28
28
|
}) => string;
|
|
29
|
-
readonly segment:
|
|
29
|
+
readonly segment: Segment;
|
|
30
30
|
readonly inputOptions: string[];
|
|
31
|
-
readonly disableDanma: boolean;
|
|
32
31
|
readonly url: string;
|
|
33
32
|
readonly debugLevel: "none" | "basic" | "verbose";
|
|
34
33
|
readonly headers: {
|
|
@@ -43,5 +42,6 @@ export declare class MesioRecorder extends EventEmitter implements IRecorder {
|
|
|
43
42
|
getArguments(): string[];
|
|
44
43
|
stop(): Promise<void>;
|
|
45
44
|
getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
|
|
45
|
+
get videoFilePath(): string;
|
|
46
46
|
}
|
|
47
47
|
export {};
|
|
@@ -84,7 +84,7 @@ class MesioCommand extends EventEmitter {
|
|
|
84
84
|
export const createMesioBuilder = () => {
|
|
85
85
|
return new MesioCommand();
|
|
86
86
|
};
|
|
87
|
-
export class
|
|
87
|
+
export class mesioDownloader extends EventEmitter {
|
|
88
88
|
onEnd;
|
|
89
89
|
onUpdateLiveInfo;
|
|
90
90
|
type = "mesio";
|
|
@@ -94,7 +94,6 @@ export class MesioRecorder extends EventEmitter {
|
|
|
94
94
|
getSavePath;
|
|
95
95
|
segment;
|
|
96
96
|
inputOptions = [];
|
|
97
|
-
disableDanma = false;
|
|
98
97
|
url;
|
|
99
98
|
debugLevel = "none";
|
|
100
99
|
headers;
|
|
@@ -102,8 +101,9 @@ export class MesioRecorder extends EventEmitter {
|
|
|
102
101
|
super();
|
|
103
102
|
this.onEnd = onEnd;
|
|
104
103
|
this.onUpdateLiveInfo = onUpdateLiveInfo;
|
|
104
|
+
// 存在自动分段,永远为true
|
|
105
105
|
const hasSegment = true;
|
|
106
|
-
this.
|
|
106
|
+
this.hasSegment = hasSegment;
|
|
107
107
|
this.debugLevel = opts.debugLevel ?? "none";
|
|
108
108
|
let videoFormat = "flv";
|
|
109
109
|
if (opts.url.includes(".m3u8")) {
|
|
@@ -118,18 +118,17 @@ export class MesioRecorder extends EventEmitter {
|
|
|
118
118
|
else if (opts.formatName === "flv") {
|
|
119
119
|
videoFormat = "flv";
|
|
120
120
|
}
|
|
121
|
-
this.streamManager = new StreamManager(opts.getSavePath, hasSegment,
|
|
121
|
+
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, "mesio", videoFormat, {
|
|
122
122
|
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
123
123
|
});
|
|
124
|
-
this.hasSegment = hasSegment;
|
|
125
124
|
this.getSavePath = opts.getSavePath;
|
|
126
125
|
this.inputOptions = [];
|
|
127
126
|
this.url = opts.url;
|
|
128
127
|
this.segment = opts.segment;
|
|
129
128
|
this.headers = opts.headers;
|
|
130
129
|
this.command = this.createCommand();
|
|
131
|
-
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
|
|
132
|
-
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
130
|
+
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
|
|
131
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename, title });
|
|
133
132
|
});
|
|
134
133
|
this.streamManager.on("videoFileCompleted", ({ filename }) => {
|
|
135
134
|
this.emit("videoFileCompleted", { filename });
|
|
@@ -144,6 +143,7 @@ export class MesioRecorder extends EventEmitter {
|
|
|
144
143
|
"--fix",
|
|
145
144
|
"-H",
|
|
146
145
|
"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",
|
|
146
|
+
"--no-proxy",
|
|
147
147
|
];
|
|
148
148
|
if (this.debugLevel === "verbose") {
|
|
149
149
|
inputOptions.push("-v");
|
|
@@ -155,8 +155,13 @@ export class MesioRecorder extends EventEmitter {
|
|
|
155
155
|
inputOptions.push("-H", `${key}: ${value}`);
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
|
-
if (this.
|
|
159
|
-
|
|
158
|
+
if (this.segment) {
|
|
159
|
+
if (typeof this.segment === "number") {
|
|
160
|
+
inputOptions.push("-d", `${this.segment * 60}s`);
|
|
161
|
+
}
|
|
162
|
+
else if (typeof this.segment === "string") {
|
|
163
|
+
inputOptions.push("-m", this.segment);
|
|
164
|
+
}
|
|
160
165
|
}
|
|
161
166
|
const command = createMesioBuilder()
|
|
162
167
|
.input(this.url)
|
|
@@ -189,4 +194,7 @@ export class MesioRecorder extends EventEmitter {
|
|
|
189
194
|
getExtraDataController() {
|
|
190
195
|
return this.streamManager?.getExtraDataController();
|
|
191
196
|
}
|
|
197
|
+
get videoFilePath() {
|
|
198
|
+
return this.streamManager.videoFilePath;
|
|
199
|
+
}
|
|
192
200
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import { createRecordExtraDataController } from "../xml_stream_controller.js";
|
|
3
3
|
import type { RecorderCreateOpts } from "../recorder.js";
|
|
4
|
+
import type { TrueVideoFormat } from "../index.js";
|
|
4
5
|
export type GetSavePath = (data: {
|
|
5
6
|
startTime: number;
|
|
6
7
|
title?: string;
|
|
7
8
|
}) => string;
|
|
8
9
|
type RecorderType = Exclude<RecorderCreateOpts["recorderType"], undefined | "auto">;
|
|
9
|
-
type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
|
|
10
10
|
export declare class Segment extends EventEmitter {
|
|
11
11
|
extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
|
|
12
12
|
init: boolean;
|
|
@@ -15,9 +15,8 @@ export declare class Segment extends EventEmitter {
|
|
|
15
15
|
rawRecordingVideoPath: string;
|
|
16
16
|
/** 输出文件名名,不包含拓展名 */
|
|
17
17
|
outputVideoFilePath: string;
|
|
18
|
-
|
|
19
|
-
videoExt:
|
|
20
|
-
constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: VideoFormat);
|
|
18
|
+
videoExt: TrueVideoFormat;
|
|
19
|
+
constructor(getSavePath: GetSavePath, videoExt: TrueVideoFormat);
|
|
21
20
|
handleSegmentEnd(): Promise<void>;
|
|
22
21
|
onSegmentStart(stderrLine: string, callBack?: {
|
|
23
22
|
onUpdateLiveInfo: () => Promise<{
|
|
@@ -36,7 +35,7 @@ export declare class StreamManager extends EventEmitter {
|
|
|
36
35
|
recorderType: RecorderType;
|
|
37
36
|
private videoFormat;
|
|
38
37
|
private callBack?;
|
|
39
|
-
constructor(getSavePath: GetSavePath, hasSegment: boolean,
|
|
38
|
+
constructor(getSavePath: GetSavePath, hasSegment: boolean, recorderType: RecorderType, videoFormat: TrueVideoFormat, callBack?: {
|
|
40
39
|
onUpdateLiveInfo: () => Promise<{
|
|
41
40
|
title?: string;
|
|
42
41
|
cover?: string;
|
|
@@ -45,7 +44,7 @@ export declare class StreamManager extends EventEmitter {
|
|
|
45
44
|
handleVideoStarted(stderrLine: string): Promise<void>;
|
|
46
45
|
handleVideoCompleted(): Promise<void>;
|
|
47
46
|
getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
|
|
48
|
-
get videoExt():
|
|
47
|
+
get videoExt(): TrueVideoFormat;
|
|
49
48
|
get videoFilePath(): string;
|
|
50
49
|
}
|
|
51
50
|
export {};
|
|
@@ -10,12 +10,10 @@ export class Segment extends EventEmitter {
|
|
|
10
10
|
rawRecordingVideoPath;
|
|
11
11
|
/** 输出文件名名,不包含拓展名 */
|
|
12
12
|
outputVideoFilePath;
|
|
13
|
-
disableDanma;
|
|
14
13
|
videoExt;
|
|
15
|
-
constructor(getSavePath,
|
|
14
|
+
constructor(getSavePath, videoExt) {
|
|
16
15
|
super();
|
|
17
16
|
this.getSavePath = getSavePath;
|
|
18
|
-
this.disableDanma = disableDanma;
|
|
19
17
|
this.videoExt = videoExt;
|
|
20
18
|
}
|
|
21
19
|
async handleSegmentEnd() {
|
|
@@ -69,9 +67,7 @@ export class Segment extends EventEmitter {
|
|
|
69
67
|
title: liveInfo?.title,
|
|
70
68
|
});
|
|
71
69
|
ensureFolderExist(this.outputVideoFilePath);
|
|
72
|
-
|
|
73
|
-
this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
|
|
74
|
-
}
|
|
70
|
+
this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
|
|
75
71
|
// 支持两种格式的正则表达式
|
|
76
72
|
// 1. FFmpeg格式: Opening 'filename' for writing
|
|
77
73
|
// 2. Mesio格式: Opening FLV segment path=filename Processing
|
|
@@ -107,19 +103,19 @@ export class StreamManager extends EventEmitter {
|
|
|
107
103
|
recordSavePath;
|
|
108
104
|
recordStartTime;
|
|
109
105
|
hasSegment;
|
|
110
|
-
recorderType
|
|
106
|
+
recorderType;
|
|
111
107
|
videoFormat;
|
|
112
108
|
callBack;
|
|
113
|
-
constructor(getSavePath, hasSegment,
|
|
109
|
+
constructor(getSavePath, hasSegment, recorderType, videoFormat, callBack) {
|
|
114
110
|
super();
|
|
115
111
|
const recordSavePath = getSavePath({ startTime: Date.now() });
|
|
116
112
|
this.recordSavePath = recordSavePath;
|
|
117
|
-
this.videoFormat = videoFormat
|
|
113
|
+
this.videoFormat = videoFormat;
|
|
118
114
|
this.recorderType = recorderType;
|
|
119
115
|
this.hasSegment = hasSegment;
|
|
120
116
|
this.callBack = callBack;
|
|
121
117
|
if (hasSegment) {
|
|
122
|
-
this.segment = new Segment(getSavePath,
|
|
118
|
+
this.segment = new Segment(getSavePath, this.videoExt);
|
|
123
119
|
this.segment.on("DebugLog", (data) => {
|
|
124
120
|
this.emit("DebugLog", data);
|
|
125
121
|
});
|
|
@@ -132,9 +128,7 @@ export class StreamManager extends EventEmitter {
|
|
|
132
128
|
}
|
|
133
129
|
else {
|
|
134
130
|
const extraDataSavePath = replaceExtName(recordSavePath, ".xml");
|
|
135
|
-
|
|
136
|
-
this.extraDataController = createRecordExtraDataController(extraDataSavePath);
|
|
137
|
-
}
|
|
131
|
+
this.extraDataController = createRecordExtraDataController(extraDataSavePath);
|
|
138
132
|
}
|
|
139
133
|
}
|
|
140
134
|
async handleVideoStarted(stderrLine) {
|
|
@@ -196,18 +190,7 @@ export class StreamManager extends EventEmitter {
|
|
|
196
190
|
return this.segment?.extraDataController || this.extraDataController;
|
|
197
191
|
}
|
|
198
192
|
get videoExt() {
|
|
199
|
-
|
|
200
|
-
return this.videoFormat;
|
|
201
|
-
}
|
|
202
|
-
else if (this.recorderType === "mesio") {
|
|
203
|
-
return this.videoFormat;
|
|
204
|
-
}
|
|
205
|
-
else if (this.recorderType === "bililive") {
|
|
206
|
-
return "flv";
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
throw new Error("Unknown recorderType");
|
|
210
|
-
}
|
|
193
|
+
return this.videoFormat;
|
|
211
194
|
}
|
|
212
195
|
get videoFilePath() {
|
|
213
196
|
if (this.recorderType === "ffmpeg") {
|
package/lib/index.d.ts
CHANGED
|
@@ -6,9 +6,11 @@ import utils from "./utils.js";
|
|
|
6
6
|
export * from "./common.js";
|
|
7
7
|
export * from "./recorder.js";
|
|
8
8
|
export * from "./manager.js";
|
|
9
|
+
export * from "./cache.js";
|
|
9
10
|
export * from "./record_extra_data_controller.js";
|
|
10
|
-
export * from "./
|
|
11
|
-
export {
|
|
11
|
+
export * from "./downloader/FFmpegDownloader.js";
|
|
12
|
+
export { createDownloader } from "./downloader/index.js";
|
|
13
|
+
export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
|
|
12
14
|
export { utils };
|
|
13
15
|
/**
|
|
14
16
|
* 提供一些 utils
|
|
@@ -24,3 +26,5 @@ export declare function getMesioPath(): string;
|
|
|
24
26
|
export declare function setBililivePath(newPath: string): void;
|
|
25
27
|
export declare function getBililivePath(): string;
|
|
26
28
|
export declare function getDataFolderPath<E extends AnyObject>(provider: RecorderProvider<E>): string;
|
|
29
|
+
export type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
|
|
30
|
+
export type TrueVideoFormat = Exclude<VideoFormat, "auto">;
|
package/lib/index.js
CHANGED
|
@@ -5,9 +5,11 @@ import utils from "./utils.js";
|
|
|
5
5
|
export * from "./common.js";
|
|
6
6
|
export * from "./recorder.js";
|
|
7
7
|
export * from "./manager.js";
|
|
8
|
+
export * from "./cache.js";
|
|
8
9
|
export * from "./record_extra_data_controller.js";
|
|
9
|
-
export * from "./
|
|
10
|
-
export {
|
|
10
|
+
export * from "./downloader/FFmpegDownloader.js";
|
|
11
|
+
export { createDownloader } from "./downloader/index.js";
|
|
12
|
+
export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
|
|
11
13
|
export { utils };
|
|
12
14
|
/**
|
|
13
15
|
* 提供一些 utils
|
package/lib/manager.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Emitter } from "mitt";
|
|
2
2
|
import { ChannelId, Message } from "./common.js";
|
|
3
|
+
import { RecorderCache } from "./cache.js";
|
|
3
4
|
import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog, Progress } from "./recorder.js";
|
|
4
5
|
import { AnyObject, UnknownObject } from "./utils.js";
|
|
5
|
-
import { StreamManager } from "./
|
|
6
|
-
import { Cache } from "./cache.js";
|
|
6
|
+
import { StreamManager } from "./downloader/streamManager.js";
|
|
7
7
|
export interface RecorderProvider<E extends AnyObject> {
|
|
8
8
|
id: string;
|
|
9
9
|
name: string;
|
|
@@ -75,7 +75,7 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
75
75
|
providers: P[];
|
|
76
76
|
getChannelURLMatchedRecorderProviders: (this: RecorderManager<ME, P, PE, E>, channelURL: string) => P[];
|
|
77
77
|
recorders: Recorder<E>[];
|
|
78
|
-
addRecorder: (this: RecorderManager<ME, P, PE, E>, opts:
|
|
78
|
+
addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: RecorderCreateOpts<E>) => Recorder<E>;
|
|
79
79
|
removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
|
|
80
80
|
startRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
81
81
|
stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
@@ -91,19 +91,23 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
91
91
|
ffmpegOutputArgs: string;
|
|
92
92
|
/** b站使用批量查询接口 */
|
|
93
93
|
biliBatchQuery: boolean;
|
|
94
|
-
/**
|
|
94
|
+
/** 下播延迟检查 */
|
|
95
95
|
recordRetryImmediately: boolean;
|
|
96
|
-
/**
|
|
97
|
-
cache:
|
|
96
|
+
/** 缓存系统 */
|
|
97
|
+
cache: RecorderCache;
|
|
98
98
|
}
|
|
99
99
|
export type RecorderManagerCreateOpts<ME extends AnyObject = UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE> = Partial<Pick<RecorderManager<ME, P, PE, E>, ConfigurableProp>> & {
|
|
100
100
|
providers: P[];
|
|
101
|
+
/** 自定义缓存实现,不提供则使用默认的内存缓存 */
|
|
102
|
+
cache?: RecorderCache;
|
|
101
103
|
};
|
|
102
104
|
export declare function createRecorderManager<ME extends AnyObject = UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE>(opts: RecorderManagerCreateOpts<ME, P, PE, E>): RecorderManager<ME, P, PE, E>;
|
|
103
105
|
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: {
|
|
104
106
|
owner: string;
|
|
105
107
|
title: string;
|
|
106
|
-
startTime
|
|
108
|
+
startTime: number;
|
|
109
|
+
liveStartTime: Date;
|
|
110
|
+
recordStartTime: Date;
|
|
107
111
|
}): string;
|
|
108
112
|
export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
|
|
109
|
-
export { StreamManager
|
|
113
|
+
export { StreamManager };
|
package/lib/manager.js
CHANGED
|
@@ -3,10 +3,10 @@ import mitt from "mitt";
|
|
|
3
3
|
import ejs from "ejs";
|
|
4
4
|
import { omit, range } from "lodash-es";
|
|
5
5
|
import { parseArgsStringToArgv } from "string-argv";
|
|
6
|
+
import { RecorderCacheImpl, MemoryCacheStore } from "./cache.js";
|
|
6
7
|
import { getBiliStatusInfoByRoomIds } from "./api.js";
|
|
7
8
|
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, } from "./utils.js";
|
|
8
|
-
import { StreamManager } from "./
|
|
9
|
-
import { Cache } from "./cache.js";
|
|
9
|
+
import { StreamManager } from "./downloader/streamManager.js";
|
|
10
10
|
const configurableProps = [
|
|
11
11
|
"savePathRule",
|
|
12
12
|
"autoRemoveSystemReservedChars",
|
|
@@ -97,8 +97,6 @@ export function createRecorderManager(opts) {
|
|
|
97
97
|
const liveStartObj = {};
|
|
98
98
|
// 用于记录触发重试直播场次的次数
|
|
99
99
|
const retryCountObj = {};
|
|
100
|
-
// 获取缓存单例
|
|
101
|
-
const cache = Cache.getInstance();
|
|
102
100
|
const manager = {
|
|
103
101
|
// @ts-ignore
|
|
104
102
|
...mitt(),
|
|
@@ -115,8 +113,10 @@ export function createRecorderManager(opts) {
|
|
|
115
113
|
// provider.createRecorder 能返回 Recorder<PE> 才能进一步优化。
|
|
116
114
|
const recorder = provider.createRecorder({
|
|
117
115
|
...omit(opts, ["providerId"]),
|
|
118
|
-
cache,
|
|
116
|
+
// cache,
|
|
119
117
|
});
|
|
118
|
+
// 为录制器注入独立的缓存命名空间
|
|
119
|
+
recorder.cache = this.cache.createNamespace(recorder.id);
|
|
120
120
|
this.recorders.push(recorder);
|
|
121
121
|
recorder.on("RecordStart", (recordHandle) => this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle }));
|
|
122
122
|
recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
|
|
@@ -133,29 +133,32 @@ export function createRecorderManager(opts) {
|
|
|
133
133
|
recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
|
|
134
134
|
recorder.on("RecordStop", ({ recordHandle, reason }) => {
|
|
135
135
|
this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
//
|
|
136
|
+
const maxRetryCount = 10;
|
|
137
|
+
// 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
|
|
138
|
+
// 如果开启了该选项,且录制开始时间与结束时间相差在一分钟以上(某些平台下播会扔会有重复流),那么会立即进行一次检查。
|
|
139
139
|
// 也许之后还能链接复用,但也会引入更多复杂度,需要谨慎考虑
|
|
140
140
|
// 虎牙直播结束后可能额外触发导致错误,忽略虎牙直播间:https://www.huya.com/910323
|
|
141
141
|
if (manager.recordRetryImmediately &&
|
|
142
|
-
recorder
|
|
143
|
-
reason
|
|
144
|
-
reason.includes("invalid stream") &&
|
|
145
|
-
recorder?.liveInfo?.liveId) {
|
|
142
|
+
recorder?.liveInfo?.liveId &&
|
|
143
|
+
reason !== "manual stop") {
|
|
146
144
|
const key = `${recorder.channelId}-${recorder.liveInfo?.liveId}`;
|
|
147
|
-
|
|
145
|
+
const recordStartTime = recorder.liveInfo?.recordStartTime.getTime() ?? 0;
|
|
146
|
+
const recordStopTime = Date.now();
|
|
147
|
+
// 录制时间差在一分钟以上
|
|
148
|
+
if (recordStopTime - recordStartTime < 60 * 1000)
|
|
149
|
+
return;
|
|
150
|
+
if (retryCountObj[key] > maxRetryCount)
|
|
148
151
|
return;
|
|
149
152
|
if (!retryCountObj[key]) {
|
|
150
153
|
retryCountObj[key] = 0;
|
|
151
154
|
}
|
|
152
|
-
if (retryCountObj[key] <
|
|
155
|
+
if (retryCountObj[key] < maxRetryCount) {
|
|
153
156
|
retryCountObj[key]++;
|
|
154
157
|
}
|
|
155
158
|
this.emit("RecorderDebugLog", {
|
|
156
159
|
recorder,
|
|
157
160
|
type: "common",
|
|
158
|
-
text: `录制${recorder
|
|
161
|
+
text: `录制${recorder.channelId}中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
|
|
159
162
|
});
|
|
160
163
|
// 触发一次检查,等待一秒使状态清理完毕
|
|
161
164
|
setTimeout(() => {
|
|
@@ -214,7 +217,7 @@ export function createRecorderManager(opts) {
|
|
|
214
217
|
if (recorder.recordHandle == null)
|
|
215
218
|
return;
|
|
216
219
|
const liveId = recorder.liveInfo?.liveId;
|
|
217
|
-
await recorder.recordHandle.stop("manual stop"
|
|
220
|
+
await recorder.recordHandle.stop("manual stop");
|
|
218
221
|
if (liveId) {
|
|
219
222
|
tempBanObj[recorder.channelId] = liveId;
|
|
220
223
|
recorder.tempStopIntervalCheck = true;
|
|
@@ -269,6 +272,7 @@ export function createRecorderManager(opts) {
|
|
|
269
272
|
autoRemoveSystemReservedChars: opts.autoRemoveSystemReservedChars ?? true,
|
|
270
273
|
biliBatchQuery: opts.biliBatchQuery ?? false,
|
|
271
274
|
recordRetryImmediately: opts.recordRetryImmediately ?? false,
|
|
275
|
+
cache: opts.cache ?? new RecorderCacheImpl(new MemoryCacheStore()),
|
|
272
276
|
ffmpegOutputArgs: opts.ffmpegOutputArgs ??
|
|
273
277
|
"-c copy" +
|
|
274
278
|
/**
|
|
@@ -276,18 +280,7 @@ export function createRecorderManager(opts) {
|
|
|
276
280
|
* 最后一个片段,而 FLV 格式如果录制中 KILL 了需要手动修复下 keyframes。所以默认使用 fmp4 格式。
|
|
277
281
|
*/
|
|
278
282
|
" -movflags faststart+frag_keyframe+empty_moov" +
|
|
279
|
-
/**
|
|
280
|
-
* 浏览器加载 FragmentMP4 会需要先把它所有的 moof boxes 都加载完成后才能播放,
|
|
281
|
-
* 默认的分段时长很小,会产生大量的 moof,导致加载很慢,所以这里设置一个分段的最小时长。
|
|
282
|
-
*
|
|
283
|
-
* TODO: 这个浏览器行为或许是可以优化的,比如试试给 fmp4 在录制完成后设置或者录制过程中实时更新 mvhd.duration。
|
|
284
|
-
* https://stackoverflow.com/questions/55887980/how-to-use-media-source-extension-mse-low-latency-mode
|
|
285
|
-
* https://stackoverflow.com/questions/61803136/ffmpeg-fragmented-mp4-takes-long-time-to-start-playing-on-chrome
|
|
286
|
-
*
|
|
287
|
-
* TODO: 如果浏览器行为无法优化,并且想进一步优化加载速度,可以考虑录制时使用 fmp4,录制完成后再转一次普通 mp4。
|
|
288
|
-
*/
|
|
289
283
|
" -min_frag_duration 10000000",
|
|
290
|
-
cache,
|
|
291
284
|
};
|
|
292
285
|
const setProvidersFFMPEGOutputArgs = (ffmpegOutputArgs) => {
|
|
293
286
|
const args = parseArgsStringToArgv(ffmpegOutputArgs);
|
|
@@ -312,12 +305,12 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
312
305
|
// TODO: 这里随便写的,后面再优化
|
|
313
306
|
const provider = manager.providers.find((p) => p.id === recorder.toJSON().providerId);
|
|
314
307
|
const now = extData?.startTime ? new Date(extData.startTime) : new Date();
|
|
315
|
-
const owner = (extData?.owner ?? "").replaceAll("%", "_");
|
|
316
|
-
const title = (extData?.title ?? "").replaceAll("%", "_");
|
|
308
|
+
const owner = removeSystemReservedChars((extData?.owner ?? "").replaceAll("%", "_"));
|
|
309
|
+
const title = removeSystemReservedChars((extData?.title ?? "").replaceAll("%", "_"));
|
|
310
|
+
const remarks = removeSystemReservedChars((recorder.remarks ?? "").replaceAll("%", "_"));
|
|
311
|
+
const channelId = removeSystemReservedChars(String(recorder.channelId));
|
|
317
312
|
const params = {
|
|
318
313
|
platform: provider?.name ?? "unknown",
|
|
319
|
-
channelId: recorder.channelId,
|
|
320
|
-
remarks: recorder.remarks ?? "",
|
|
321
314
|
year: formatDate(now, "yyyy"),
|
|
322
315
|
month: formatDate(now, "MM"),
|
|
323
316
|
date: formatDate(now, "dd"),
|
|
@@ -325,21 +318,19 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
325
318
|
min: formatDate(now, "mm"),
|
|
326
319
|
sec: formatDate(now, "ss"),
|
|
327
320
|
...extData,
|
|
321
|
+
startTime: now,
|
|
328
322
|
owner: owner,
|
|
329
323
|
title: title,
|
|
324
|
+
remarks: remarks,
|
|
325
|
+
channelId,
|
|
330
326
|
};
|
|
331
|
-
if (manager.autoRemoveSystemReservedChars) {
|
|
332
|
-
for (const key in params) {
|
|
333
|
-
params[key] = removeSystemReservedChars(String(params[key])).trim();
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
327
|
let savePathRule = manager.savePathRule;
|
|
337
328
|
try {
|
|
338
329
|
savePathRule = ejs.render(savePathRule, params);
|
|
339
330
|
}
|
|
340
331
|
catch (error) {
|
|
341
|
-
console.error("模板解析错误", error);
|
|
332
|
+
console.error("模板解析错误", error, savePathRule, params);
|
|
342
333
|
}
|
|
343
334
|
return formatTemplate(savePathRule, params);
|
|
344
335
|
}
|
|
345
|
-
export { StreamManager
|
|
336
|
+
export { StreamManager };
|
package/lib/recorder.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { Emitter } from "mitt";
|
|
|
2
2
|
import { ChannelId, Message, Quality } from "./common.js";
|
|
3
3
|
import { RecorderProvider } from "./manager.js";
|
|
4
4
|
import { AnyObject, PickRequired, UnknownObject } from "./utils.js";
|
|
5
|
-
import {
|
|
5
|
+
import type { NamespacedCache } from "./cache.js";
|
|
6
|
+
import type { DownloaderType } from "./downloader/index.js";
|
|
6
7
|
type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
|
|
7
8
|
type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
|
|
8
9
|
export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
@@ -18,7 +19,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
18
19
|
sourcePriorities: string[];
|
|
19
20
|
formatPriorities?: Array<"flv" | "hls">;
|
|
20
21
|
source?: string;
|
|
21
|
-
segment?:
|
|
22
|
+
segment?: string;
|
|
22
23
|
saveGiftDanma?: boolean;
|
|
23
24
|
saveSCDanma?: boolean;
|
|
24
25
|
/** 保存封面 */
|
|
@@ -44,7 +45,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
44
45
|
/** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制(仅对斗鱼有效),多个关键词用英文逗号分隔 */
|
|
45
46
|
titleKeywords?: string;
|
|
46
47
|
/** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
|
|
47
|
-
videoFormat?: "auto" | "ts" | "mkv";
|
|
48
|
+
videoFormat?: "auto" | "ts" | "mkv" | "flv";
|
|
48
49
|
/** 录制类型 */
|
|
49
50
|
recorderType?: "auto" | "ffmpeg" | "mesio" | "bililive";
|
|
50
51
|
/** 流格式优先级 */
|
|
@@ -58,8 +59,6 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
58
59
|
extra?: Partial<E>;
|
|
59
60
|
/** 调试等级 */
|
|
60
61
|
debugLevel?: "none" | "basic" | "verbose";
|
|
61
|
-
/** 缓存 */
|
|
62
|
-
cache: Cache;
|
|
63
62
|
}
|
|
64
63
|
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords">;
|
|
65
64
|
/** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单 */
|
|
@@ -71,11 +70,12 @@ export interface RecordHandle {
|
|
|
71
70
|
id: string;
|
|
72
71
|
stream: string;
|
|
73
72
|
source: string;
|
|
73
|
+
recorderType?: DownloaderType;
|
|
74
74
|
url: string;
|
|
75
|
-
|
|
75
|
+
downloaderArgs?: string[];
|
|
76
76
|
progress?: Progress;
|
|
77
77
|
savePath: string;
|
|
78
|
-
stop: (this: RecordHandle, reason?: string
|
|
78
|
+
stop: (this: RecordHandle, reason?: string) => Promise<void>;
|
|
79
79
|
cut: (this: RecordHandle) => Promise<void>;
|
|
80
80
|
}
|
|
81
81
|
export interface DebugLog {
|
|
@@ -85,7 +85,9 @@ export interface DebugLog {
|
|
|
85
85
|
export type GetSavePath = (data: {
|
|
86
86
|
owner: string;
|
|
87
87
|
title: string;
|
|
88
|
-
startTime
|
|
88
|
+
startTime: number;
|
|
89
|
+
liveStartTime: Date;
|
|
90
|
+
recordStartTime: Date;
|
|
89
91
|
}) => string;
|
|
90
92
|
export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
91
93
|
RecordStart: RecordHandle;
|
|
@@ -114,21 +116,21 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
114
116
|
usedStream?: string;
|
|
115
117
|
usedSource?: string;
|
|
116
118
|
state: RecorderState;
|
|
117
|
-
qualityMaxRetry: number;
|
|
118
119
|
qualityRetry: number;
|
|
119
120
|
uid?: number | string;
|
|
120
121
|
liveInfo?: {
|
|
121
122
|
living: boolean;
|
|
122
123
|
owner: string;
|
|
123
124
|
title: string;
|
|
124
|
-
|
|
125
|
+
liveStartTime: Date;
|
|
125
126
|
avatar: string;
|
|
126
127
|
cover: string;
|
|
127
128
|
liveId?: string;
|
|
129
|
+
recordStartTime: Date;
|
|
128
130
|
};
|
|
129
131
|
tempStopIntervalCheck?: boolean;
|
|
130
|
-
/**
|
|
131
|
-
cache:
|
|
132
|
+
/** 缓存实例(命名空间) */
|
|
133
|
+
cache: NamespacedCache;
|
|
132
134
|
getChannelURL: (this: Recorder<E>) => string;
|
|
133
135
|
checkLiveStatusAndRecord: (this: Recorder<E>, opts: {
|
|
134
136
|
getSavePath: GetSavePath;
|
|
@@ -144,7 +146,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
144
146
|
cover: string;
|
|
145
147
|
channelId: ChannelId;
|
|
146
148
|
living: boolean;
|
|
147
|
-
|
|
149
|
+
liveStartTime: Date;
|
|
148
150
|
}>;
|
|
149
151
|
getStream: (this: Recorder<E>) => Promise<{
|
|
150
152
|
source: string;
|