@bililive-tools/manager 1.12.0 → 1.14.1
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 +17 -7
- package/lib/downloader/BililiveDownloader.d.ts +2 -0
- package/lib/downloader/BililiveDownloader.js +17 -5
- package/lib/downloader/FFmpegDownloader.js +2 -2
- package/lib/manager.d.ts +18 -2
- package/lib/manager.js +83 -31
- package/lib/recorder.d.ts +4 -0
- package/lib/utils.d.ts +2 -2
- package/lib/utils.js +4 -3
- package/lib/xml_stream_controller.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
## 支持的平台
|
|
14
14
|
|
|
15
|
-
| 平台
|
|
16
|
-
|
|
|
17
|
-
| B站
|
|
18
|
-
| 斗鱼
|
|
19
|
-
| 虎牙
|
|
20
|
-
| 抖音
|
|
15
|
+
| 平台 | 包名 |
|
|
16
|
+
| ------ | ----------------------------------- |
|
|
17
|
+
| B站 | `@bililive-tools/bilibili-recorder` |
|
|
18
|
+
| 斗鱼 | `@bililive-tools/douyu-recorder` |
|
|
19
|
+
| 虎牙 | `@bililive-tools/huya-recorder` |
|
|
20
|
+
| 抖音 | `@bililive-tools/douyin-recorder` |
|
|
21
|
+
| 小红书 | `@bililive-tools/xhs-recorder` |
|
|
21
22
|
|
|
22
23
|
# 使用
|
|
23
24
|
|
|
@@ -32,9 +33,17 @@ const manager = createRecorderManager({
|
|
|
32
33
|
savePathRule: "D:\\录制\\{platforme}}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}", // 保存路径,占位符见文档,支持 [ejs](https://ejs.co/) 模板引擎
|
|
33
34
|
autoCheckInterval: 1000 * 60, // 自动检查间隔,单位秒
|
|
34
35
|
maxThreadCount: 3, // 检查并发数
|
|
35
|
-
waitTime: 0, //
|
|
36
|
+
waitTime: 0, // 检查后等待时间,单位毫秒
|
|
36
37
|
autoRemoveSystemReservedChars: true, // 移除系统非法字符串
|
|
37
38
|
biliBatchQuery: false, // B站检查使用批量接口
|
|
39
|
+
providerCheckConfig: {
|
|
40
|
+
// Bilibili 配置:高频检查,更多线程
|
|
41
|
+
Bilibili: {
|
|
42
|
+
autoCheckInterval: 5000, // 5秒检查一次
|
|
43
|
+
maxThreadCount: 5, // 最多5个并发线程
|
|
44
|
+
waitTime: 500, // 每次检查间隔0.5秒
|
|
45
|
+
},
|
|
46
|
+
},
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
// 不同provider支持的参数不尽相同,具体见相关文档
|
|
@@ -107,6 +116,7 @@ setBililivePath("BililiveRecorder.Cli.exe");
|
|
|
107
116
|
| {hour} | 时 |
|
|
108
117
|
| {min} | 分 |
|
|
109
118
|
| {sec} | 秒 |
|
|
119
|
+
| {ms} | 毫秒 |
|
|
110
120
|
| {startTime} | 分段开始时间,Date对象 |
|
|
111
121
|
| {recordStartTime} | 录制开始时间,Date对象 |
|
|
112
122
|
| {liveStartTime} | 直播开始时间,Date对象,抖音同录制开始时间 |
|
|
@@ -12,6 +12,7 @@ declare class BililiveRecorderCommand extends EventEmitter {
|
|
|
12
12
|
inputOptions(...options: string[]): BililiveRecorderCommand;
|
|
13
13
|
_getArguments(): string[];
|
|
14
14
|
run(): void;
|
|
15
|
+
stop(): void;
|
|
15
16
|
kill(): void;
|
|
16
17
|
cut(): void;
|
|
17
18
|
}
|
|
@@ -22,6 +23,7 @@ export declare class BililiveDownloader extends EventEmitter implements IDownloa
|
|
|
22
23
|
type: "bililive";
|
|
23
24
|
private command;
|
|
24
25
|
private streamManager;
|
|
26
|
+
private timeoutChecker;
|
|
25
27
|
readonly hasSegment: boolean;
|
|
26
28
|
readonly getSavePath: (data: {
|
|
27
29
|
startTime: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { DEFAULT_USER_AGENT } from "./index.js";
|
|
4
|
-
import { StreamManager, getBililivePath } from "../index.js";
|
|
4
|
+
import { StreamManager, getBililivePath, utils } from "../index.js";
|
|
5
5
|
import { byte2MB } from "../utils.js";
|
|
6
6
|
// Bililive command builder class similar to ffmpeg
|
|
7
7
|
class BililiveRecorderCommand extends EventEmitter {
|
|
@@ -75,10 +75,14 @@ class BililiveRecorderCommand extends EventEmitter {
|
|
|
75
75
|
}
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
stop() {
|
|
79
79
|
if (this.process) {
|
|
80
80
|
this.process.stdin?.write("q\n");
|
|
81
|
-
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
kill() {
|
|
84
|
+
if (this.process) {
|
|
85
|
+
this.process.kill("SIGINT");
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
cut() {
|
|
@@ -97,6 +101,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
97
101
|
type = "bililive";
|
|
98
102
|
command;
|
|
99
103
|
streamManager;
|
|
104
|
+
timeoutChecker;
|
|
100
105
|
hasSegment;
|
|
101
106
|
getSavePath;
|
|
102
107
|
segment;
|
|
@@ -118,6 +123,11 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
118
123
|
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "bililive", videoFormat, {
|
|
119
124
|
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
120
125
|
});
|
|
126
|
+
this.timeoutChecker = utils.createTimeoutChecker(() => {
|
|
127
|
+
this.emit("DebugLog", { type: "error", text: "bililive timeout, killing process" });
|
|
128
|
+
this.command?.kill();
|
|
129
|
+
this.onEnd("bililive timeout");
|
|
130
|
+
}, 20 * 1000, false);
|
|
121
131
|
this.getSavePath = opts.getSavePath;
|
|
122
132
|
this.inputOptions = [];
|
|
123
133
|
this.url = opts.url;
|
|
@@ -138,6 +148,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
138
148
|
});
|
|
139
149
|
}
|
|
140
150
|
createCommand() {
|
|
151
|
+
this.timeoutChecker?.start();
|
|
141
152
|
const inputOptions = [...this.inputOptions, "--disable-log-file", "true"];
|
|
142
153
|
if (this.debugLevel === "verbose") {
|
|
143
154
|
inputOptions.push("-l", "Debug");
|
|
@@ -164,6 +175,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
164
175
|
.on("error", this.onEnd)
|
|
165
176
|
.on("end", () => this.onEnd("finished"))
|
|
166
177
|
.on("stderr", async (stderrLine) => {
|
|
178
|
+
this.timeoutChecker?.update();
|
|
167
179
|
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
168
180
|
await this.streamManager.handleVideoStarted(stderrLine);
|
|
169
181
|
const info = this.formatLine(stderrLine);
|
|
@@ -193,9 +205,9 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
193
205
|
return this.command._getArguments();
|
|
194
206
|
}
|
|
195
207
|
async stop() {
|
|
208
|
+
this.timeoutChecker?.stop();
|
|
196
209
|
try {
|
|
197
|
-
|
|
198
|
-
this.command.kill();
|
|
210
|
+
this.command.stop();
|
|
199
211
|
await this.streamManager.handleVideoCompleted();
|
|
200
212
|
}
|
|
201
213
|
catch (err) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import { createFFMPEGBuilder, StreamManager, utils } from "../index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createFFmpegInvalidStreamChecker, assert } from "../utils.js";
|
|
4
4
|
import { DEFAULT_USER_AGENT } from "./index.js";
|
|
5
5
|
export class FFmpegDownloader extends EventEmitter {
|
|
6
6
|
onEnd;
|
|
@@ -77,7 +77,7 @@ export class FFmpegDownloader extends EventEmitter {
|
|
|
77
77
|
createCommand() {
|
|
78
78
|
this.timeoutChecker?.start();
|
|
79
79
|
const invalidCount = this.isHls ? 35 : 18;
|
|
80
|
-
const isInvalidStream =
|
|
80
|
+
const isInvalidStream = createFFmpegInvalidStreamChecker(invalidCount);
|
|
81
81
|
const inputOptions = [
|
|
82
82
|
...this.inputOptions,
|
|
83
83
|
"-user_agent",
|
package/lib/manager.d.ts
CHANGED
|
@@ -4,6 +4,14 @@ import { RecorderCache } from "./cache.js";
|
|
|
4
4
|
import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog, Progress } from "./recorder.js";
|
|
5
5
|
import { AnyObject, UnknownObject } from "./utils.js";
|
|
6
6
|
import { StreamManager } from "./downloader/streamManager.js";
|
|
7
|
+
export interface ProviderCheckConfig {
|
|
8
|
+
/** 检查循环间隔(毫秒) */
|
|
9
|
+
autoCheckInterval?: number;
|
|
10
|
+
/** 最大并发检查线程数 */
|
|
11
|
+
maxThreadCount?: number;
|
|
12
|
+
/** 每次检查之间的等待时间(毫秒) */
|
|
13
|
+
waitTime?: number;
|
|
14
|
+
}
|
|
7
15
|
export interface RecorderProvider<E extends AnyObject> {
|
|
8
16
|
id: string;
|
|
9
17
|
name: string;
|
|
@@ -20,7 +28,7 @@ export interface RecorderProvider<E extends AnyObject> {
|
|
|
20
28
|
fromJSON: <T extends SerializedRecorder<E>>(this: RecorderProvider<E>, json: T) => Recorder<E>;
|
|
21
29
|
setFFMPEGOutputArgs: (this: RecorderProvider<E>, args: string[]) => void;
|
|
22
30
|
}
|
|
23
|
-
declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "maxThreadCount", "waitTime", "ffmpegOutputArgs", "biliBatchQuery", "recordRetryImmediately"];
|
|
31
|
+
declare const configurableProps: readonly ["savePathRule", "autoRemoveSystemReservedChars", "autoCheckInterval", "maxThreadCount", "waitTime", "ffmpegOutputArgs", "biliBatchQuery", "recordRetryImmediately", "providerCheckConfig"];
|
|
24
32
|
type ConfigurableProp = (typeof configurableProps)[number];
|
|
25
33
|
export interface RecorderManager<ME extends UnknownObject, P extends RecorderProvider<AnyObject> = RecorderProvider<UnknownObject>, PE extends AnyObject = GetProviderExtra<P>, E extends AnyObject = ME & PE> extends Emitter<{
|
|
26
34
|
error: {
|
|
@@ -78,7 +86,9 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
78
86
|
addRecorder: (this: RecorderManager<ME, P, PE, E>, opts: RecorderCreateOpts<E>) => Recorder<E>;
|
|
79
87
|
removeRecorder: (this: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>) => void;
|
|
80
88
|
getRecorder: (this: RecorderManager<ME, P, PE, E>, id: string) => Recorder<E> | null;
|
|
81
|
-
startRecord: (this: RecorderManager<ME, P, PE, E>, id: string
|
|
89
|
+
startRecord: (this: RecorderManager<ME, P, PE, E>, id: string, opts?: {
|
|
90
|
+
ignoreDataLimit?: boolean;
|
|
91
|
+
}) => Promise<Recorder<E> | undefined>;
|
|
82
92
|
stopRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
83
93
|
cutRecord: (this: RecorderManager<ME, P, PE, E>, id: string) => Promise<Recorder<E> | undefined>;
|
|
84
94
|
autoCheckInterval: number;
|
|
@@ -96,11 +106,17 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
96
106
|
recordRetryImmediately: boolean;
|
|
97
107
|
/** 缓存系统 */
|
|
98
108
|
cache: RecorderCache;
|
|
109
|
+
/** 每个 provider 的检查配置 */
|
|
110
|
+
providerCheckConfig: Record<string, ProviderCheckConfig>;
|
|
111
|
+
/** 获取指定 provider 的检查配置(自动 fallback 到全局配置) */
|
|
112
|
+
getProviderCheckConfig: (this: RecorderManager<ME, P, PE, E>, providerId: string) => Required<ProviderCheckConfig>;
|
|
99
113
|
}
|
|
100
114
|
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>> & {
|
|
101
115
|
providers: P[];
|
|
102
116
|
/** 自定义缓存实现,不提供则使用默认的内存缓存 */
|
|
103
117
|
cache?: RecorderCache;
|
|
118
|
+
/** 每个 provider 的检查配置,key 为 provider.id */
|
|
119
|
+
providerCheckConfig?: Record<string, ProviderCheckConfig>;
|
|
104
120
|
};
|
|
105
121
|
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>;
|
|
106
122
|
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: {
|
package/lib/manager.js
CHANGED
|
@@ -16,14 +16,16 @@ const configurableProps = [
|
|
|
16
16
|
"ffmpegOutputArgs",
|
|
17
17
|
"biliBatchQuery",
|
|
18
18
|
"recordRetryImmediately",
|
|
19
|
+
"providerCheckConfig",
|
|
19
20
|
];
|
|
20
21
|
function isConfigurableProp(prop) {
|
|
21
22
|
return configurableProps.includes(prop);
|
|
22
23
|
}
|
|
23
24
|
export function createRecorderManager(opts) {
|
|
24
25
|
const recorders = [];
|
|
25
|
-
|
|
26
|
-
const
|
|
26
|
+
// 存储每个 provider 的 timer,key 为 providerId
|
|
27
|
+
const checkLoopTimers = new Map();
|
|
28
|
+
const multiThreadCheck = async (manager, providerId) => {
|
|
27
29
|
const handleBatchQuery = async (obj) => {
|
|
28
30
|
for (const recorder of recorders
|
|
29
31
|
.filter((r) => !r.disableAutoCheck)
|
|
@@ -42,15 +44,16 @@ export function createRecorderManager(opts) {
|
|
|
42
44
|
};
|
|
43
45
|
// 这里暂时不打算用 state == recording 来过滤,provider 必须内部自己处理录制过程中的 check,
|
|
44
46
|
// 这样可以防止一些意外调用 checkLiveStatusAndRecord 时出现重复录制。
|
|
45
|
-
|
|
47
|
+
const needCheckRecorders = recorders
|
|
46
48
|
.filter((r) => !r.disableAutoCheck)
|
|
47
|
-
.filter((r) => isBetweenTimeRange(r.handleTime))
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
.filter((r) => isBetweenTimeRange(r.handleTime))
|
|
50
|
+
.filter((r) => r.providerId === providerId);
|
|
51
|
+
const providerConfig = manager.getProviderCheckConfig(providerId);
|
|
52
|
+
const threads = [];
|
|
53
|
+
// Bilibili 批量查询特殊处理
|
|
54
|
+
if (providerId === "Bilibili" && manager.biliBatchQuery) {
|
|
55
|
+
const biliNeedCheckRecorders = needCheckRecorders.filter((r) => r.recordHandle == null);
|
|
56
|
+
// const biliRecordingRecorders = needCheckRecorders.filter((r) => r.recordHandle != null);
|
|
54
57
|
const roomIds = biliNeedCheckRecorders.map((r) => r.channelId).map(Number);
|
|
55
58
|
try {
|
|
56
59
|
if (roomIds.length !== 0) {
|
|
@@ -61,9 +64,13 @@ export function createRecorderManager(opts) {
|
|
|
61
64
|
catch (err) {
|
|
62
65
|
manager.emit("error", { source: "getBiliStatusInfoByRoomIds", err });
|
|
63
66
|
// 如果批量查询失败,则使用单个查询
|
|
64
|
-
needCheckRecorders
|
|
67
|
+
needCheckRecorders.push(...biliNeedCheckRecorders);
|
|
65
68
|
}
|
|
69
|
+
// 正在录制的也需要检查(放回队列)
|
|
70
|
+
// needCheckRecorders.length = 0;
|
|
71
|
+
// needCheckRecorders.push(...biliRecordingRecorders);
|
|
66
72
|
}
|
|
73
|
+
// 为当前 provider 创建线程池
|
|
67
74
|
const checkOnce = async () => {
|
|
68
75
|
const recorder = needCheckRecorders.shift();
|
|
69
76
|
if (recorder == null)
|
|
@@ -76,12 +83,12 @@ export function createRecorderManager(opts) {
|
|
|
76
83
|
banLiveId,
|
|
77
84
|
});
|
|
78
85
|
};
|
|
79
|
-
threads
|
|
86
|
+
threads.push(...range(0, providerConfig.maxThreadCount).map(async () => {
|
|
80
87
|
while (needCheckRecorders.length > 0) {
|
|
81
88
|
try {
|
|
82
89
|
await checkOnce();
|
|
83
|
-
if (
|
|
84
|
-
await sleep(
|
|
90
|
+
if (providerConfig.waitTime > 0) {
|
|
91
|
+
await sleep(providerConfig.waitTime);
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
catch (err) {
|
|
@@ -183,6 +190,7 @@ export function createRecorderManager(opts) {
|
|
|
183
190
|
this.emit("RecoderLiveStart", { recorder: recorder });
|
|
184
191
|
});
|
|
185
192
|
this.emit("RecorderAdded", recorder.toJSON());
|
|
193
|
+
// startCheckLoop 会为所有注册的 provider 启动检查循环,无需在此处额外处理
|
|
186
194
|
return recorder;
|
|
187
195
|
},
|
|
188
196
|
removeRecorder(recorder) {
|
|
@@ -198,12 +206,16 @@ export function createRecorderManager(opts) {
|
|
|
198
206
|
const recorder = this.recorders.find((item) => item.id === id);
|
|
199
207
|
return recorder ?? null;
|
|
200
208
|
},
|
|
201
|
-
async startRecord(id) {
|
|
209
|
+
async startRecord(id, iOpts = {}) {
|
|
210
|
+
const { ignoreDataLimit = true } = iOpts;
|
|
202
211
|
const recorder = this.recorders.find((item) => item.id === id);
|
|
203
212
|
if (recorder == null)
|
|
204
213
|
return;
|
|
205
214
|
if (recorder.recordHandle != null)
|
|
206
215
|
return;
|
|
216
|
+
// 如果手动开启且需要判断限制时间,再时间限制内时,就不启动录制
|
|
217
|
+
if (!ignoreDataLimit && isBetweenTimeRange(recorder.handleTime))
|
|
218
|
+
return;
|
|
207
219
|
await recorder.checkLiveStatusAndRecord({
|
|
208
220
|
getSavePath(data) {
|
|
209
221
|
return genSavePathFromRule(manager, recorder, data);
|
|
@@ -237,7 +249,7 @@ export function createRecorderManager(opts) {
|
|
|
237
249
|
await recorder.recordHandle.cut();
|
|
238
250
|
return recorder;
|
|
239
251
|
},
|
|
240
|
-
autoCheckInterval: opts.autoCheckInterval ??
|
|
252
|
+
autoCheckInterval: opts.autoCheckInterval ?? 60000,
|
|
241
253
|
maxThreadCount: opts.maxThreadCount ?? 3,
|
|
242
254
|
waitTime: opts.waitTime ?? 0,
|
|
243
255
|
isCheckLoopRunning: false,
|
|
@@ -246,30 +258,60 @@ export function createRecorderManager(opts) {
|
|
|
246
258
|
return;
|
|
247
259
|
this.isCheckLoopRunning = true;
|
|
248
260
|
// TODO: emit updated event
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
finally {
|
|
257
|
-
if (!this.isCheckLoopRunning) {
|
|
258
|
-
// do nothing
|
|
261
|
+
// 为每个 provider 创建独立的检查循环
|
|
262
|
+
const startProviderCheckLoop = (providerId) => {
|
|
263
|
+
const providerConfig = this.getProviderCheckConfig(providerId);
|
|
264
|
+
const checkLoop = async () => {
|
|
265
|
+
try {
|
|
266
|
+
// 只检查当前 provider 的 recorders
|
|
267
|
+
await multiThreadCheck(this, providerId);
|
|
259
268
|
}
|
|
260
|
-
|
|
261
|
-
|
|
269
|
+
catch (err) {
|
|
270
|
+
this.emit("error", { source: "multiThreadCheck", err });
|
|
262
271
|
}
|
|
263
|
-
|
|
272
|
+
finally {
|
|
273
|
+
if (!this.isCheckLoopRunning) {
|
|
274
|
+
// 停止了,清理 timer
|
|
275
|
+
const timer = checkLoopTimers.get(providerId);
|
|
276
|
+
if (timer) {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
checkLoopTimers.delete(providerId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// 检查该 provider 是否还有 recorder
|
|
283
|
+
const hasRecorders = this.recorders.some((r) => r.providerId === providerId);
|
|
284
|
+
if (hasRecorders) {
|
|
285
|
+
// 继续循环
|
|
286
|
+
const timer = setTimeout(checkLoop, providerConfig.autoCheckInterval);
|
|
287
|
+
checkLoopTimers.set(providerId, timer);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// 没有 recorder 了,停止该 provider 的检查循环
|
|
291
|
+
// TODO: 也许不需要删除定时器
|
|
292
|
+
checkLoopTimers.delete(providerId);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
void checkLoop();
|
|
264
298
|
};
|
|
265
|
-
|
|
299
|
+
// 直接从注册的 provider 获取所有 provider IDs
|
|
300
|
+
const providerIds = this.providers.map((p) => p.id);
|
|
301
|
+
for (const providerId of providerIds) {
|
|
302
|
+
startProviderCheckLoop(providerId);
|
|
303
|
+
}
|
|
266
304
|
},
|
|
267
305
|
stopCheckLoop() {
|
|
268
306
|
if (!this.isCheckLoopRunning)
|
|
269
307
|
return;
|
|
270
308
|
this.isCheckLoopRunning = false;
|
|
271
309
|
// TODO: emit updated event
|
|
272
|
-
|
|
310
|
+
// 清理所有 provider 的 timer
|
|
311
|
+
for (const timer of checkLoopTimers.values()) {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
}
|
|
314
|
+
checkLoopTimers.clear();
|
|
273
315
|
},
|
|
274
316
|
savePathRule: opts.savePathRule ??
|
|
275
317
|
path.join(process.cwd(), "{platform}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}"),
|
|
@@ -277,6 +319,15 @@ export function createRecorderManager(opts) {
|
|
|
277
319
|
biliBatchQuery: opts.biliBatchQuery ?? false,
|
|
278
320
|
recordRetryImmediately: opts.recordRetryImmediately ?? false,
|
|
279
321
|
cache: opts.cache ?? new RecorderCacheImpl(new MemoryCacheStore()),
|
|
322
|
+
providerCheckConfig: opts.providerCheckConfig ?? {},
|
|
323
|
+
getProviderCheckConfig(providerId) {
|
|
324
|
+
const providerConfig = this.providerCheckConfig[providerId];
|
|
325
|
+
return {
|
|
326
|
+
autoCheckInterval: providerConfig?.autoCheckInterval ?? this.autoCheckInterval,
|
|
327
|
+
maxThreadCount: providerConfig?.maxThreadCount ?? this.maxThreadCount,
|
|
328
|
+
waitTime: providerConfig?.waitTime ?? this.waitTime,
|
|
329
|
+
};
|
|
330
|
+
},
|
|
280
331
|
ffmpegOutputArgs: opts.ffmpegOutputArgs ??
|
|
281
332
|
"-c copy" +
|
|
282
333
|
/**
|
|
@@ -321,6 +372,7 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
321
372
|
hour: formatDate(now, "HH"),
|
|
322
373
|
min: formatDate(now, "mm"),
|
|
323
374
|
sec: formatDate(now, "ss"),
|
|
375
|
+
ms: formatDate(now, "SSS"),
|
|
324
376
|
...extData,
|
|
325
377
|
startTime: now,
|
|
326
378
|
owner: owner,
|
package/lib/recorder.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
36
36
|
useM3U8Proxy?: boolean;
|
|
37
37
|
/**B站m3u8代理url */
|
|
38
38
|
m3u8ProxyUrl?: string;
|
|
39
|
+
/** B站自定义Host,用于替换直播流链接中的host */
|
|
40
|
+
customHost?: string;
|
|
39
41
|
/** 流格式 */
|
|
40
42
|
formatName?: FormatName;
|
|
41
43
|
/** 流编码 */
|
|
@@ -130,6 +132,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
130
132
|
cover: string;
|
|
131
133
|
liveId?: string;
|
|
132
134
|
recordStartTime: Date;
|
|
135
|
+
area?: string;
|
|
133
136
|
};
|
|
134
137
|
tempStopIntervalCheck?: boolean;
|
|
135
138
|
/** 缓存实例(命名空间) */
|
|
@@ -150,6 +153,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
150
153
|
channelId: ChannelId;
|
|
151
154
|
living: boolean;
|
|
152
155
|
liveStartTime: Date;
|
|
156
|
+
area: string;
|
|
153
157
|
}>;
|
|
154
158
|
getStream: (this: Recorder<E>) => Promise<{
|
|
155
159
|
source: string;
|
package/lib/utils.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export declare const formatTemplate: (string: string, ...args: any[]) => string;
|
|
|
50
50
|
* "receive invalid aac stream": ADTS无法被解析的flv流
|
|
51
51
|
* "invalid stream": 一段时间内帧数不变
|
|
52
52
|
*/
|
|
53
|
-
export declare function
|
|
53
|
+
export declare function createFFmpegInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => [boolean, string];
|
|
54
54
|
export declare function createTimeoutChecker(onTimeout: () => void, time: number, autoStart?: boolean): {
|
|
55
55
|
update: () => void;
|
|
56
56
|
stop: () => void;
|
|
@@ -125,7 +125,7 @@ declare const _default: {
|
|
|
125
125
|
assertObjectType: typeof assertObjectType;
|
|
126
126
|
asyncThrottle: typeof asyncThrottle;
|
|
127
127
|
isFfmpegStartSegment: typeof isFfmpegStartSegment;
|
|
128
|
-
|
|
128
|
+
createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
|
|
129
129
|
createTimeoutChecker: typeof createTimeoutChecker;
|
|
130
130
|
downloadImage: typeof downloadImage;
|
|
131
131
|
md5: (str: string) => string;
|
package/lib/utils.js
CHANGED
|
@@ -117,8 +117,9 @@ export function formatDate(date, format) {
|
|
|
117
117
|
HH: date.getHours().toString().padStart(2, "0"),
|
|
118
118
|
mm: date.getMinutes().toString().padStart(2, "0"),
|
|
119
119
|
ss: date.getSeconds().toString().padStart(2, "0"),
|
|
120
|
+
SSS: date.getMilliseconds().toString().padStart(3, "0"),
|
|
120
121
|
};
|
|
121
|
-
return format.replace(/yyyy|MM|dd|HH|mm|ss/g, (matched) => map[matched]);
|
|
122
|
+
return format.replace(/yyyy|MM|dd|HH|mm|ss|SSS/g, (matched) => map[matched]);
|
|
122
123
|
}
|
|
123
124
|
export function removeSystemReservedChars(str) {
|
|
124
125
|
return filenamify(str, { replacement: "_" });
|
|
@@ -173,7 +174,7 @@ export const formatTemplate = function template(string, ...args) {
|
|
|
173
174
|
* "receive invalid aac stream": ADTS无法被解析的flv流
|
|
174
175
|
* "invalid stream": 一段时间内帧数不变
|
|
175
176
|
*/
|
|
176
|
-
export function
|
|
177
|
+
export function createFFmpegInvalidStreamChecker(count = 15) {
|
|
177
178
|
let prevFrame = 0;
|
|
178
179
|
let frameUnchangedCount = 0;
|
|
179
180
|
return (ffmpegLogLine) => {
|
|
@@ -504,7 +505,7 @@ export default {
|
|
|
504
505
|
assertObjectType,
|
|
505
506
|
asyncThrottle,
|
|
506
507
|
isFfmpegStartSegment,
|
|
507
|
-
|
|
508
|
+
createFFmpegInvalidStreamChecker,
|
|
508
509
|
createTimeoutChecker,
|
|
509
510
|
downloadImage,
|
|
510
511
|
md5,
|
|
@@ -23,7 +23,7 @@ export function createRecordExtraDataController(savePath) {
|
|
|
23
23
|
isInitialized = true;
|
|
24
24
|
try {
|
|
25
25
|
// 创建XML文件头,使用占位符预留metadata位置
|
|
26
|
-
const header = `<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet type="text/xsl" href="#s"?>\n<i>\n<!--METADATA_PLACEHOLDER-->\n<
|
|
26
|
+
const header = `<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet type="text/xsl" href="#s"?>\n<i>\n<!--METADATA_PLACEHOLDER-->\n<RecorderXmlStyle><z:stylesheet version="1.0" id="s" xml:id="s" xmlns:z="http://www.w3.org/1999/XSL/Transform"><z:output method="html"/><z:template match="/"><html><meta name="viewport" content="width=device-width"/><title>弹幕文件 <z:value-of select="/i/metadata/user_name/text()"/></title><style>body{margin:0}h1,h2,p,table{margin-left:5px}table{border-spacing:0}td,th{border:1px solid grey;padding:1px 5px}th{position:sticky;top:0;background:#4098de}tr:hover{background:#d9f4ff}div{overflow:auto;max-height:80vh;max-width:100vw;width:fit-content}</style><h1>弹幕XML文件</h1><p>本文件不支持在 IE 浏览器里预览,请使用 Chrome Firefox Edge 等浏览器。</p><p>文件用法参考文档 <a href="https://rec.danmuji.org/user/danmaku/">https://rec.danmuji.org/user/danmaku/</a></p><table><tr><td>房间号</td><td><z:value-of select="/i/metadata/room_id/text()"/></td></tr><tr><td>主播名</td><td><z:value-of select="/i/metadata/user_name/text()"/></td></tr><tr><td><a href="#d">弹幕</a></td><td>共<z:value-of select="count(/i/d)"/>条记录</td></tr><tr><td><a href="#guard">上船</a></td><td>共<z:value-of select="count(/i/guard)"/>条记录</td></tr><tr><td><a href="#sc">SC</a></td><td>共<z:value-of select="count(/i/sc)"/>条记录</td></tr><tr><td><a href="#gift">礼物</a></td><td>共<z:value-of select="count(/i/gift)"/>条记录</td></tr></table><h2 id="d">弹幕</h2><div id="dm"><table><tr><th>用户名</th><th>出现时间</th><th>用户ID</th><th>弹幕</th><th>参数</th></tr><z:for-each select="/i/d"><tr><td><z:value-of select="@user"/></td><td></td><td></td><td><z:value-of select="."/></td><td><z:value-of select="@p"/></td></tr></z:for-each></table></div><script>Array.from(document.querySelectorAll('#dm tr')).slice(1).map(t=>t.querySelectorAll('td')).forEach(t=>{let p=t[4].textContent.split(','),a=p[0];t[1].textContent=\`\u0024{(Math.floor(a/60/60)+'').padStart(2,0)}:\u0024{(Math.floor(a/60%60)+'').padStart(2,0)}:\u0024{(a%60).toFixed(3).padStart(6,0)}\`;t[2].innerHTML=\`<a target=_blank rel="nofollow noreferrer" ">\u0024{p[6]}</a>\`})</script><h2 id="guard">舰长购买</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>舰长等级</th><th>购买数量</th><th>出现时间</th></tr><z:for-each select="/i/guard"><tr><td><z:value-of select="@user"/></td><td><a rel="nofollow noreferrer"><z:attribute name="href"><z:text></z:text><z:value-of select="@uid" /></z:attribute><z:value-of select="@uid"/></a></td><td><z:value-of select="@level"/></td><td><z:value-of select="@count"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div><h2 id="sc">SuperChat 醒目留言</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>内容</th><th>显示时长</th><th>价格</th><th>出现时间</th></tr><z:for-each select="/i/sc"><tr><td><z:value-of select="@user"/></td><td><a rel="nofollow noreferrer"><z:attribute name="href"><z:text></z:text><z:value-of select="@uid" /></z:attribute><z:value-of select="@uid"/></a></td><td><z:value-of select="."/></td><td><z:value-of select="@time"/></td><td><z:value-of select="@price"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div><h2 id="gift">礼物</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>礼物名</th><th>礼物数量</th><th>出现时间</th></tr><z:for-each select="/i/gift"><tr><td><z:value-of select="@user"/></td><td><span rel="nofollow noreferrer"><z:attribute name="href"></z:attribute><z:value-of select="@uid"/></span></td><td><z:value-of select="@giftname"/></td><td><z:value-of select="@giftcount"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div></html></z:template></z:stylesheet></RecorderXmlStyle>`;
|
|
27
27
|
await fs.promises.writeFile(savePath, header);
|
|
28
28
|
}
|
|
29
29
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/manager",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.1",
|
|
4
4
|
"description": "Batch scheduling recorders",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"mitt": "^3.0.1",
|
|
39
39
|
"string-argv": "^0.3.2",
|
|
40
40
|
"lodash-es": "^4.17.21",
|
|
41
|
-
"axios": "^1.
|
|
41
|
+
"axios": "^1.15.0",
|
|
42
42
|
"fs-extra": "^11.2.0",
|
|
43
43
|
"ejs": "^3.1.10"
|
|
44
44
|
},
|