@bililive-tools/manager 1.9.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/lib/cache.d.ts +55 -16
- package/lib/cache.js +61 -33
- package/lib/{recorder/BililiveRecorder.d.ts → downloader/BililiveDownloader.d.ts} +4 -4
- package/lib/{recorder/BililiveRecorder.js → downloader/BililiveDownloader.js} +15 -7
- package/lib/{recorder/FFMPEGRecorder.d.ts → downloader/FFmpegDownloader.d.ts} +4 -4
- package/lib/{recorder/FFMPEGRecorder.js → downloader/FFmpegDownloader.js} +18 -7
- package/lib/{recorder/IRecorder.d.ts → downloader/IDownloader.d.ts} +5 -5
- 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} +14 -7
- package/lib/{recorder → downloader}/streamManager.d.ts +5 -6
- package/lib/{recorder → downloader}/streamManager.js +8 -25
- package/lib/index.d.ts +5 -2
- package/lib/index.js +4 -2
- package/lib/manager.d.ts +9 -7
- package/lib/manager.js +21 -29
- package/lib/record_extra_data_controller.d.ts +1 -1
- package/lib/recorder.d.ts +11 -13
- package/lib/utils.d.ts +30 -0
- package/lib/utils.js +129 -0
- package/lib/xml_stream_controller.d.ts +1 -1
- package/package.json +1 -1
- package/lib/recorder/index.d.ts +0 -48
- /package/lib/{recorder/IRecorder.js → downloader/IDownloader.js} +0 -0
|
@@ -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
|
|
@@ -25,3 +27,4 @@ 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;
|
|
27
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,13 +91,15 @@ 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: {
|
|
@@ -108,4 +110,4 @@ export declare function genSavePathFromRule<ME extends AnyObject, P extends Reco
|
|
|
108
110
|
recordStartTime: Date;
|
|
109
111
|
}): string;
|
|
110
112
|
export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
|
|
111
|
-
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);
|
|
@@ -334,11 +327,10 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
334
327
|
let savePathRule = manager.savePathRule;
|
|
335
328
|
try {
|
|
336
329
|
savePathRule = ejs.render(savePathRule, params);
|
|
337
|
-
console.log("解析后保存路径模板:", savePathRule, params);
|
|
338
330
|
}
|
|
339
331
|
catch (error) {
|
|
340
332
|
console.error("模板解析错误", error, savePathRule, params);
|
|
341
333
|
}
|
|
342
334
|
return formatTemplate(savePathRule, params);
|
|
343
335
|
}
|
|
344
|
-
export { StreamManager
|
|
336
|
+
export { StreamManager };
|
package/lib/recorder.d.ts
CHANGED
|
@@ -2,8 +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 {
|
|
6
|
-
import type {
|
|
5
|
+
import type { NamespacedCache } from "./cache.js";
|
|
6
|
+
import type { DownloaderType } from "./downloader/index.js";
|
|
7
7
|
type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
|
|
8
8
|
type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
|
|
9
9
|
export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
@@ -19,7 +19,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
19
19
|
sourcePriorities: string[];
|
|
20
20
|
formatPriorities?: Array<"flv" | "hls">;
|
|
21
21
|
source?: string;
|
|
22
|
-
segment?:
|
|
22
|
+
segment?: string;
|
|
23
23
|
saveGiftDanma?: boolean;
|
|
24
24
|
saveSCDanma?: boolean;
|
|
25
25
|
/** 保存封面 */
|
|
@@ -59,8 +59,6 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
59
59
|
extra?: Partial<E>;
|
|
60
60
|
/** 调试等级 */
|
|
61
61
|
debugLevel?: "none" | "basic" | "verbose";
|
|
62
|
-
/** 缓存 */
|
|
63
|
-
cache: Cache;
|
|
64
62
|
}
|
|
65
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">;
|
|
66
64
|
/** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单 */
|
|
@@ -72,12 +70,12 @@ export interface RecordHandle {
|
|
|
72
70
|
id: string;
|
|
73
71
|
stream: string;
|
|
74
72
|
source: string;
|
|
75
|
-
recorderType?:
|
|
73
|
+
recorderType?: DownloaderType;
|
|
76
74
|
url: string;
|
|
77
|
-
|
|
75
|
+
downloaderArgs?: string[];
|
|
78
76
|
progress?: Progress;
|
|
79
77
|
savePath: string;
|
|
80
|
-
stop: (this: RecordHandle, reason?: string
|
|
78
|
+
stop: (this: RecordHandle, reason?: string) => Promise<void>;
|
|
81
79
|
cut: (this: RecordHandle) => Promise<void>;
|
|
82
80
|
}
|
|
83
81
|
export interface DebugLog {
|
|
@@ -118,21 +116,21 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
118
116
|
usedStream?: string;
|
|
119
117
|
usedSource?: string;
|
|
120
118
|
state: RecorderState;
|
|
121
|
-
qualityMaxRetry: number;
|
|
122
119
|
qualityRetry: number;
|
|
123
120
|
uid?: number | string;
|
|
124
121
|
liveInfo?: {
|
|
125
122
|
living: boolean;
|
|
126
123
|
owner: string;
|
|
127
124
|
title: string;
|
|
128
|
-
|
|
125
|
+
liveStartTime: Date;
|
|
129
126
|
avatar: string;
|
|
130
127
|
cover: string;
|
|
131
128
|
liveId?: string;
|
|
129
|
+
recordStartTime: Date;
|
|
132
130
|
};
|
|
133
131
|
tempStopIntervalCheck?: boolean;
|
|
134
|
-
/**
|
|
135
|
-
cache:
|
|
132
|
+
/** 缓存实例(命名空间) */
|
|
133
|
+
cache: NamespacedCache;
|
|
136
134
|
getChannelURL: (this: Recorder<E>) => string;
|
|
137
135
|
checkLiveStatusAndRecord: (this: Recorder<E>, opts: {
|
|
138
136
|
getSavePath: GetSavePath;
|
|
@@ -148,7 +146,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
148
146
|
cover: string;
|
|
149
147
|
channelId: ChannelId;
|
|
150
148
|
living: boolean;
|
|
151
|
-
|
|
149
|
+
liveStartTime: Date;
|
|
152
150
|
}>;
|
|
153
151
|
getStream: (this: Recorder<E>) => Promise<{
|
|
154
152
|
source: string;
|
package/lib/utils.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DebouncedFunc } from "lodash-es";
|
|
2
|
+
import type { Recorder } from "./recorder.js";
|
|
2
3
|
export type AnyObject = Record<string, any>;
|
|
3
4
|
export type UnknownObject = Record<string, unknown>;
|
|
4
5
|
export type PickRequired<T, K extends keyof T> = T & Pick<Required<T>, K>;
|
|
@@ -74,6 +75,14 @@ export declare function sortByKeyOrder<T, K extends keyof T>(objects: T[], order
|
|
|
74
75
|
export declare function retry<T>(fn: () => Promise<T>, retries?: number, delay?: number): Promise<T>;
|
|
75
76
|
export declare const isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
|
|
76
77
|
export declare const sleep: (ms: number) => Promise<unknown>;
|
|
78
|
+
/**
|
|
79
|
+
* 判断是否应该使用严格画质模式
|
|
80
|
+
* @param qualityRetryLeft 剩余的画质重试次数
|
|
81
|
+
* @param qualityRetry 初始画质重试次数配置
|
|
82
|
+
* @param isManualStart 是否手动启动
|
|
83
|
+
* @returns 是否使用严格画质模式
|
|
84
|
+
*/
|
|
85
|
+
export declare function shouldUseStrictQuality(qualityRetryLeft: number, qualityRetry: number, isManualStart?: boolean): boolean;
|
|
77
86
|
/**
|
|
78
87
|
* 检查标题是否包含黑名单关键词
|
|
79
88
|
*/
|
|
@@ -82,6 +91,24 @@ declare function hasBlockedTitleKeywords(title: string, titleKeywords: string |
|
|
|
82
91
|
* 检查是否需要进行标题关键词检查
|
|
83
92
|
*/
|
|
84
93
|
declare function shouldCheckTitleKeywords(isManualStart: boolean | undefined, titleKeywords: string | undefined): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* 逆向格式化"xxxB", "xxxKB", "xxxMB", "xxxGB"为字节数,如果值为空返回0,如果为数字则直接返回数字,如果带单位则转换为字节数
|
|
96
|
+
* @param sizeStr 大小字符串
|
|
97
|
+
* @returns 字节数
|
|
98
|
+
*/
|
|
99
|
+
export declare function parseSizeToBytes(sizeStr: string): number | string;
|
|
100
|
+
export declare const byte2MB: (bytes: number) => number;
|
|
101
|
+
export declare function checkTitleKeywordsWhileRecording(recorder: Recorder, isManualStart: boolean | undefined, getInfo: (channelId: string) => Promise<{
|
|
102
|
+
title: string;
|
|
103
|
+
}>): Promise<boolean>;
|
|
104
|
+
/**
|
|
105
|
+
* 检查开始录制前的标题关键词
|
|
106
|
+
* @param title 直播间标题
|
|
107
|
+
* @param recorder 录制器实例
|
|
108
|
+
* @param isManualStart 是否手动启动
|
|
109
|
+
* @returns 如果标题包含关键词返回 true(不应录制),否则返回 false
|
|
110
|
+
*/
|
|
111
|
+
export declare function checkTitleKeywordsBeforeRecord(title: string, recorder: Recorder, isManualStart: boolean | undefined): boolean;
|
|
85
112
|
declare const _default: {
|
|
86
113
|
replaceExtName: typeof replaceExtName;
|
|
87
114
|
singleton: typeof singleton;
|
|
@@ -103,6 +130,9 @@ declare const _default: {
|
|
|
103
130
|
isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
|
|
104
131
|
hasBlockedTitleKeywords: typeof hasBlockedTitleKeywords;
|
|
105
132
|
shouldCheckTitleKeywords: typeof shouldCheckTitleKeywords;
|
|
133
|
+
shouldUseStrictQuality: typeof shouldUseStrictQuality;
|
|
106
134
|
sleep: (ms: number) => Promise<unknown>;
|
|
135
|
+
checkTitleKeywordsWhileRecording: typeof checkTitleKeywordsWhileRecording;
|
|
136
|
+
checkTitleKeywordsBeforeRecord: typeof checkTitleKeywordsBeforeRecord;
|
|
107
137
|
};
|
|
108
138
|
export default _default;
|
package/lib/utils.js
CHANGED
|
@@ -327,6 +327,32 @@ function isBetweenTime(currentTime, timeRange) {
|
|
|
327
327
|
return start <= current && current <= end;
|
|
328
328
|
}
|
|
329
329
|
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
330
|
+
/**
|
|
331
|
+
* 判断是否应该使用严格画质模式
|
|
332
|
+
* @param qualityRetryLeft 剩余的画质重试次数
|
|
333
|
+
* @param qualityRetry 初始画质重试次数配置
|
|
334
|
+
* @param isManualStart 是否手动启动
|
|
335
|
+
* @returns 是否使用严格画质模式
|
|
336
|
+
*/
|
|
337
|
+
export function shouldUseStrictQuality(qualityRetryLeft, qualityRetry, isManualStart) {
|
|
338
|
+
// 手动启动时不使用严格模式
|
|
339
|
+
if (isManualStart) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
// 如果配置为0,不使用严格模式
|
|
343
|
+
if (qualityRetry === 0) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
// 如果还有重试次数,使用严格模式
|
|
347
|
+
if (qualityRetryLeft > 0) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
// 如果配置为负数(无限重试),使用严格模式
|
|
351
|
+
if (qualityRetry < 0) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
330
356
|
/**
|
|
331
357
|
* 检查标题是否包含黑名单关键词
|
|
332
358
|
*/
|
|
@@ -343,6 +369,106 @@ function hasBlockedTitleKeywords(title, titleKeywords) {
|
|
|
343
369
|
function shouldCheckTitleKeywords(isManualStart, titleKeywords) {
|
|
344
370
|
return (!isManualStart && !!titleKeywords && typeof titleKeywords === "string" && !!titleKeywords.trim());
|
|
345
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* 逆向格式化"xxxB", "xxxKB", "xxxMB", "xxxGB"为字节数,如果值为空返回0,如果为数字则直接返回数字,如果带单位则转换为字节数
|
|
374
|
+
* @param sizeStr 大小字符串
|
|
375
|
+
* @returns 字节数
|
|
376
|
+
*/
|
|
377
|
+
export function parseSizeToBytes(sizeStr) {
|
|
378
|
+
if (!sizeStr) {
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
// 字符类型的数字
|
|
382
|
+
if (!isNaN(Number(sizeStr))) {
|
|
383
|
+
return Number(sizeStr);
|
|
384
|
+
}
|
|
385
|
+
const sizePattern = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/i;
|
|
386
|
+
const match = sizeStr.toUpperCase().trim().match(sizePattern);
|
|
387
|
+
if (match) {
|
|
388
|
+
const size = parseFloat(match[1]);
|
|
389
|
+
const unit = match[2];
|
|
390
|
+
switch (unit) {
|
|
391
|
+
case "B":
|
|
392
|
+
return String(size);
|
|
393
|
+
case "KB":
|
|
394
|
+
return String(size * 1024);
|
|
395
|
+
case "MB":
|
|
396
|
+
return String(size * 1024 * 1024);
|
|
397
|
+
case "GB":
|
|
398
|
+
return String(size * 1024 * 1024 * 1024);
|
|
399
|
+
default:
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
export const byte2MB = (bytes) => {
|
|
408
|
+
return bytes / (1024 * 1024);
|
|
409
|
+
};
|
|
410
|
+
/*
|
|
411
|
+
* 检查录制中的标题关键词
|
|
412
|
+
* @param recorder 录制器实例
|
|
413
|
+
* @param isManualStart 是否手动启动
|
|
414
|
+
* @param getInfo 获取直播间信息的函数
|
|
415
|
+
* @returns 如果标题包含关键词返回 true(需要停止),否则返回 false
|
|
416
|
+
*/
|
|
417
|
+
export async function checkTitleKeywordsWhileRecording(recorder, isManualStart, getInfo) {
|
|
418
|
+
// 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
|
|
419
|
+
if (!shouldCheckTitleKeywords(isManualStart, recorder.titleKeywords)) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
// 每5分钟检查一次标题变化
|
|
424
|
+
const titleCheckInterval = 5 * 60 * 1000; // 5分钟
|
|
425
|
+
// 获取上次检查时间
|
|
426
|
+
const lastCheckTime = await recorder.cache.get("lastTitleCheckTime");
|
|
427
|
+
// 如果距离上次检查时间不足指定间隔,则跳过检查
|
|
428
|
+
if (lastCheckTime && now - lastCheckTime < titleCheckInterval) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
// 更新检查时间
|
|
432
|
+
await recorder.cache.set("lastTitleCheckTime", now);
|
|
433
|
+
// 获取直播间信息
|
|
434
|
+
const liveInfo = await getInfo(recorder.channelId);
|
|
435
|
+
const { title } = liveInfo;
|
|
436
|
+
// 检查标题是否包含关键词
|
|
437
|
+
if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
|
|
438
|
+
recorder.state = "title-blocked";
|
|
439
|
+
recorder.emit("DebugLog", {
|
|
440
|
+
type: "common",
|
|
441
|
+
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
442
|
+
});
|
|
443
|
+
// 停止录制
|
|
444
|
+
await recorder?.recordHandle?.stop("直播间标题包含关键词");
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* 检查开始录制前的标题关键词
|
|
451
|
+
* @param title 直播间标题
|
|
452
|
+
* @param recorder 录制器实例
|
|
453
|
+
* @param isManualStart 是否手动启动
|
|
454
|
+
* @returns 如果标题包含关键词返回 true(不应录制),否则返回 false
|
|
455
|
+
*/
|
|
456
|
+
export function checkTitleKeywordsBeforeRecord(title, recorder, isManualStart) {
|
|
457
|
+
// 检查标题是否包含关键词,如果包含则不自动录制
|
|
458
|
+
// 手动开始录制时不检查标题关键词
|
|
459
|
+
if (!shouldCheckTitleKeywords(isManualStart, recorder.titleKeywords)) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
|
|
463
|
+
recorder.state = "title-blocked";
|
|
464
|
+
recorder.emit("DebugLog", {
|
|
465
|
+
type: "common",
|
|
466
|
+
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
467
|
+
});
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
346
472
|
export default {
|
|
347
473
|
replaceExtName,
|
|
348
474
|
singleton,
|
|
@@ -364,5 +490,8 @@ export default {
|
|
|
364
490
|
isBetweenTimeRange,
|
|
365
491
|
hasBlockedTitleKeywords,
|
|
366
492
|
shouldCheckTitleKeywords,
|
|
493
|
+
shouldUseStrictQuality,
|
|
367
494
|
sleep,
|
|
495
|
+
checkTitleKeywordsWhileRecording,
|
|
496
|
+
checkTitleKeywordsBeforeRecord,
|
|
368
497
|
};
|
package/package.json
CHANGED
package/lib/recorder/index.d.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { FFMPEGRecorder } from "./FFMPEGRecorder.js";
|
|
2
|
-
import { MesioRecorder } from "./mesioRecorder.js";
|
|
3
|
-
import { BililiveRecorder } from "./BililiveRecorder.js";
|
|
4
|
-
export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
|
|
5
|
-
export { MesioRecorder } from "./mesioRecorder.js";
|
|
6
|
-
export { BililiveRecorder } from "./BililiveRecorder.js";
|
|
7
|
-
import type { IRecorder, FFMPEGRecorderOptions, MesioRecorderOptions, BililiveRecorderOptions } from "./IRecorder.js";
|
|
8
|
-
/**
|
|
9
|
-
* 录制器类型
|
|
10
|
-
*/
|
|
11
|
-
export type RecorderType = "ffmpeg" | "mesio" | "bililive";
|
|
12
|
-
export type FormatName = "flv" | "ts" | "fmp4";
|
|
13
|
-
/**
|
|
14
|
-
* 根据录制器类型获取对应的配置选项类型
|
|
15
|
-
*/
|
|
16
|
-
export type RecorderOptions<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorderOptions : T extends "mesio" ? MesioRecorderOptions : BililiveRecorderOptions;
|
|
17
|
-
/**
|
|
18
|
-
* 根据录制器类型获取对应的录制器实例类型
|
|
19
|
-
*/
|
|
20
|
-
export type RecorderInstance<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorder : T extends "mesio" ? MesioRecorder : BililiveRecorder;
|
|
21
|
-
type RecorderOpts = FFMPEGRecorderOptions | MesioRecorderOptions | BililiveRecorderOptions;
|
|
22
|
-
/**
|
|
23
|
-
* 创建录制器的工厂函数
|
|
24
|
-
*/
|
|
25
|
-
export declare function createRecorder<T extends RecorderType>(type: T, opts: RecorderOptions<T> & {
|
|
26
|
-
onlyAudio?: boolean;
|
|
27
|
-
}, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
28
|
-
title?: string;
|
|
29
|
-
cover?: string;
|
|
30
|
-
}>): IRecorder;
|
|
31
|
-
/**
|
|
32
|
-
* 选择录制器
|
|
33
|
-
*/
|
|
34
|
-
export declare function selectRecorder(preferredRecorder: "auto" | RecorderType | undefined): RecorderType;
|
|
35
|
-
/**
|
|
36
|
-
* 判断原始录制流格式,flv, ts, m4s
|
|
37
|
-
*/
|
|
38
|
-
export declare function getSourceFormatName(streamUrl: string, formatName: FormatName | undefined): FormatName;
|
|
39
|
-
type PickPartial<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & Partial<Pick<T, K>>;
|
|
40
|
-
/**
|
|
41
|
-
* 创建录制器的工厂函数
|
|
42
|
-
*/
|
|
43
|
-
export declare function createBaseRecorder(type: "auto" | RecorderType | undefined, opts: PickPartial<RecorderOpts, "formatName"> & {
|
|
44
|
-
onlyAudio?: boolean;
|
|
45
|
-
}, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
|
|
46
|
-
title?: string;
|
|
47
|
-
cover?: string;
|
|
48
|
-
}>): IRecorder;
|
|
File without changes
|