@bililive-tools/manager 1.16.2 → 1.17.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/lib/downloader/BililiveDownloader.d.ts +1 -0
- package/lib/downloader/BililiveDownloader.js +5 -0
- package/lib/downloader/FFmpegDownloader.d.ts +1 -0
- package/lib/downloader/FFmpegDownloader.js +5 -0
- package/lib/downloader/IDownloader.d.ts +10 -12
- package/lib/downloader/mesioDownloader.d.ts +1 -0
- package/lib/downloader/mesioDownloader.js +25 -1
- package/lib/downloader/streamManager.js +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/manager.d.ts +8 -1
- package/lib/manager.js +107 -10
- package/lib/recorder.d.ts +32 -7
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +15 -6
- package/package.json +10 -7
|
@@ -34,6 +34,7 @@ export declare class BililiveDownloader extends EventEmitter implements IDownloa
|
|
|
34
34
|
readonly disableDanma: boolean;
|
|
35
35
|
readonly url: string;
|
|
36
36
|
readonly debugLevel: "none" | "basic" | "verbose";
|
|
37
|
+
readonly proxy?: string;
|
|
37
38
|
readonly headers: {
|
|
38
39
|
[key: string]: string | undefined;
|
|
39
40
|
} | undefined;
|
|
@@ -109,6 +109,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
109
109
|
disableDanma = false;
|
|
110
110
|
url;
|
|
111
111
|
debugLevel = "none";
|
|
112
|
+
proxy;
|
|
112
113
|
headers;
|
|
113
114
|
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
114
115
|
super();
|
|
@@ -119,6 +120,7 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
119
120
|
this.hasSegment = hasSegment;
|
|
120
121
|
this.disableDanma = opts.disableDanma ?? false;
|
|
121
122
|
this.debugLevel = opts.debugLevel ?? "none";
|
|
123
|
+
this.proxy = opts.proxy;
|
|
122
124
|
let videoFormat = "flv";
|
|
123
125
|
this.streamManager = new StreamManager(opts.getSavePath, hasSegment, this.disableDanma, "bililive", videoFormat, {
|
|
124
126
|
onUpdateLiveInfo: this.onUpdateLiveInfo,
|
|
@@ -153,6 +155,9 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
153
155
|
if (this.debugLevel === "verbose") {
|
|
154
156
|
inputOptions.push("-l", "Debug");
|
|
155
157
|
}
|
|
158
|
+
if (this.proxy) {
|
|
159
|
+
inputOptions.push(...["--proxy", this.proxy]);
|
|
160
|
+
}
|
|
156
161
|
if (this.headers) {
|
|
157
162
|
Object.entries(this.headers).forEach(([key, value]) => {
|
|
158
163
|
if (!value)
|
|
@@ -23,6 +23,7 @@ export declare class FFmpegDownloader extends EventEmitter implements IDownloade
|
|
|
23
23
|
formatName: FormatName;
|
|
24
24
|
videoFormat: VideoFormat;
|
|
25
25
|
readonly debugLevel: "none" | "basic" | "verbose";
|
|
26
|
+
readonly proxy?: string;
|
|
26
27
|
readonly headers: {
|
|
27
28
|
[key: string]: string | undefined;
|
|
28
29
|
} | undefined;
|
|
@@ -20,6 +20,7 @@ export class FFmpegDownloader extends EventEmitter {
|
|
|
20
20
|
formatName;
|
|
21
21
|
videoFormat;
|
|
22
22
|
debugLevel = "none";
|
|
23
|
+
proxy;
|
|
23
24
|
headers;
|
|
24
25
|
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
25
26
|
super();
|
|
@@ -63,6 +64,7 @@ export class FFmpegDownloader extends EventEmitter {
|
|
|
63
64
|
this.url = opts.url;
|
|
64
65
|
this.segment = opts.segment;
|
|
65
66
|
this.headers = opts.headers;
|
|
67
|
+
this.proxy = opts.proxy;
|
|
66
68
|
this.command = this.createCommand();
|
|
67
69
|
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
|
|
68
70
|
this.emit("videoFileCreated", { filename, cover, rawFilename, title });
|
|
@@ -86,6 +88,9 @@ export class FFmpegDownloader extends EventEmitter {
|
|
|
86
88
|
if (this.isHls) {
|
|
87
89
|
inputOptions.push(...["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "3"]);
|
|
88
90
|
}
|
|
91
|
+
if (this.proxy) {
|
|
92
|
+
inputOptions.push(...["-http_proxy", this.proxy]);
|
|
93
|
+
}
|
|
89
94
|
if (this.debugLevel === "verbose") {
|
|
90
95
|
inputOptions.push("-loglevel", "debug");
|
|
91
96
|
}
|
|
@@ -4,6 +4,12 @@ import type { FormatName } from "./index.js";
|
|
|
4
4
|
import type { XmlStreamController } from "../xml_stream_controller.js";
|
|
5
5
|
import type { VideoFileCompletedPayload } from "./streamManager.js";
|
|
6
6
|
export type Segment = number | string | undefined;
|
|
7
|
+
export interface VideoFileCreatedPayload {
|
|
8
|
+
filename: string;
|
|
9
|
+
cover?: string;
|
|
10
|
+
rawFilename?: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
7
13
|
/**
|
|
8
14
|
* 录制器构造函数选项的基础接口
|
|
9
15
|
*/
|
|
@@ -23,6 +29,7 @@ export interface BaseRecorderOptions {
|
|
|
23
29
|
[key: string]: string | undefined;
|
|
24
30
|
};
|
|
25
31
|
videoFormat?: VideoFormat;
|
|
32
|
+
proxy?: string;
|
|
26
33
|
}
|
|
27
34
|
/**
|
|
28
35
|
* 录制器接口定义
|
|
@@ -37,6 +44,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
37
44
|
readonly headers: {
|
|
38
45
|
[key: string]: string | undefined;
|
|
39
46
|
} | undefined;
|
|
47
|
+
readonly proxy?: string;
|
|
40
48
|
readonly getSavePath: (data: {
|
|
41
49
|
startTime: number;
|
|
42
50
|
title?: string;
|
|
@@ -49,12 +57,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
49
57
|
getExtraDataController(): XmlStreamController | null;
|
|
50
58
|
createCommand(): any;
|
|
51
59
|
get videoFilePath(): string;
|
|
52
|
-
on(event: "videoFileCreated", listener: (data:
|
|
53
|
-
filename: string;
|
|
54
|
-
cover?: string;
|
|
55
|
-
rawFilename?: string;
|
|
56
|
-
title?: string;
|
|
57
|
-
}) => void): this;
|
|
60
|
+
on(event: "videoFileCreated", listener: (data: VideoFileCreatedPayload) => void): this;
|
|
58
61
|
on(event: "videoFileCompleted", listener: (data: VideoFileCompletedPayload) => void): this;
|
|
59
62
|
on(event: "DebugLog", listener: (data: {
|
|
60
63
|
type: string;
|
|
@@ -62,12 +65,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
62
65
|
}) => void): this;
|
|
63
66
|
on(event: "progress", listener: (info: any) => void): this;
|
|
64
67
|
on(event: string, listener: (...args: any[]) => void): this;
|
|
65
|
-
emit(event: "videoFileCreated", data:
|
|
66
|
-
filename: string;
|
|
67
|
-
cover?: string;
|
|
68
|
-
rawFilename?: string;
|
|
69
|
-
title?: string;
|
|
70
|
-
}): boolean;
|
|
68
|
+
emit(event: "videoFileCreated", data: VideoFileCreatedPayload): boolean;
|
|
71
69
|
emit(event: "videoFileCompleted", data: VideoFileCompletedPayload): boolean;
|
|
72
70
|
emit(event: "DebugLog", data: {
|
|
73
71
|
type: string;
|
|
@@ -31,6 +31,7 @@ export declare class mesioDownloader extends EventEmitter implements IDownloader
|
|
|
31
31
|
readonly disableDanma: boolean;
|
|
32
32
|
readonly url: string;
|
|
33
33
|
readonly debugLevel: "none" | "basic" | "verbose";
|
|
34
|
+
readonly proxy?: string;
|
|
34
35
|
readonly headers: {
|
|
35
36
|
[key: string]: string | undefined;
|
|
36
37
|
} | undefined;
|
|
@@ -99,6 +99,7 @@ export class mesioDownloader extends EventEmitter {
|
|
|
99
99
|
disableDanma = false;
|
|
100
100
|
url;
|
|
101
101
|
debugLevel = "none";
|
|
102
|
+
proxy;
|
|
102
103
|
headers;
|
|
103
104
|
constructor(opts, onEnd, onUpdateLiveInfo) {
|
|
104
105
|
super();
|
|
@@ -109,6 +110,7 @@ export class mesioDownloader extends EventEmitter {
|
|
|
109
110
|
this.hasSegment = hasSegment;
|
|
110
111
|
this.disableDanma = opts.disableDanma ?? false;
|
|
111
112
|
this.debugLevel = opts.debugLevel ?? "none";
|
|
113
|
+
this.proxy = opts.proxy;
|
|
112
114
|
let videoFormat = "flv";
|
|
113
115
|
if (opts.url.includes(".m3u8")) {
|
|
114
116
|
videoFormat = "ts";
|
|
@@ -145,7 +147,29 @@ export class mesioDownloader extends EventEmitter {
|
|
|
145
147
|
});
|
|
146
148
|
}
|
|
147
149
|
createCommand() {
|
|
148
|
-
const inputOptions = [...this.inputOptions, "--fix", "--
|
|
150
|
+
const inputOptions = [...this.inputOptions, "--fix", "--disable-log-file"];
|
|
151
|
+
if (this.proxy) {
|
|
152
|
+
try {
|
|
153
|
+
const { protocol, username, password, origin, search, pathname } = new URL(this.proxy);
|
|
154
|
+
if (protocol.startsWith("https")) {
|
|
155
|
+
inputOptions.push("--proxy-type", "https");
|
|
156
|
+
}
|
|
157
|
+
else if (protocol.startsWith("socks5")) {
|
|
158
|
+
inputOptions.push("--proxy-type", "socks5");
|
|
159
|
+
}
|
|
160
|
+
if (username && password) {
|
|
161
|
+
inputOptions.push("--proxy-user", `${username}`);
|
|
162
|
+
inputOptions.push("--proxy-pass", `${password}`);
|
|
163
|
+
}
|
|
164
|
+
inputOptions.push("--proxy", origin + pathname + search);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
this.emit("DebugLog", { type: "error", text: `Invalid proxy URL: ${this.proxy}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
inputOptions.push("--no-proxy");
|
|
172
|
+
}
|
|
149
173
|
if (this.debugLevel === "verbose") {
|
|
150
174
|
inputOptions.push("-v");
|
|
151
175
|
}
|
|
@@ -146,6 +146,7 @@ export class StreamManager extends EventEmitter {
|
|
|
146
146
|
recordSavePath = getSavePath({ startTime, extraMs: true });
|
|
147
147
|
}
|
|
148
148
|
this.recordSavePath = recordSavePath;
|
|
149
|
+
ensureFolderExist(recordSavePath);
|
|
149
150
|
if (hasSegment) {
|
|
150
151
|
this.segment = new Segment(getSavePath, disableDanma, this.videoExt, {
|
|
151
152
|
firstStartTime: startTime,
|
|
@@ -161,7 +162,6 @@ export class StreamManager extends EventEmitter {
|
|
|
161
162
|
});
|
|
162
163
|
}
|
|
163
164
|
else {
|
|
164
|
-
ensureFolderExist(recordSavePath);
|
|
165
165
|
const extraDataSavePath = `${recordSavePath}.xml`;
|
|
166
166
|
if (!disableDanma) {
|
|
167
167
|
this.extraDataController = createRecordExtraDataController(extraDataSavePath);
|
package/lib/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./manager.js";
|
|
|
9
9
|
export * from "./cache.js";
|
|
10
10
|
export * from "./downloader/FFmpegDownloader.js";
|
|
11
11
|
export { createDownloader } from "./downloader/index.js";
|
|
12
|
+
export type { VideoFileCreatedPayload } from "./downloader/IDownloader.js";
|
|
12
13
|
export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
|
|
13
14
|
export { utils };
|
|
14
15
|
/**
|
package/lib/manager.d.ts
CHANGED
|
@@ -13,12 +13,16 @@ export interface ProviderCheckConfig {
|
|
|
13
13
|
/** 每次检查之间的等待时间(毫秒) */
|
|
14
14
|
waitTime?: number;
|
|
15
15
|
}
|
|
16
|
+
export interface ResolveChannelOptions {
|
|
17
|
+
/** 解析频道信息时使用的网络代理 */
|
|
18
|
+
proxy?: string;
|
|
19
|
+
}
|
|
16
20
|
export interface RecorderProvider<E extends AnyObject> {
|
|
17
21
|
id: string;
|
|
18
22
|
name: string;
|
|
19
23
|
siteURL: string;
|
|
20
24
|
matchURL: (this: RecorderProvider<E>, channelURL: string) => boolean;
|
|
21
|
-
resolveChannelInfoFromURL: (this: RecorderProvider<E>, channelURL: string) => Promise<{
|
|
25
|
+
resolveChannelInfoFromURL: (this: RecorderProvider<E>, channelURL: string, options?: ResolveChannelOptions) => Promise<{
|
|
22
26
|
id: ChannelId;
|
|
23
27
|
title: string;
|
|
24
28
|
owner: string;
|
|
@@ -62,6 +66,9 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
62
66
|
RecoderLiveStart: {
|
|
63
67
|
recorder: Recorder<E>;
|
|
64
68
|
};
|
|
69
|
+
RecoderChargeLive: {
|
|
70
|
+
recorder: Recorder<E>;
|
|
71
|
+
};
|
|
65
72
|
RecordStop: {
|
|
66
73
|
recorder: SerializedRecorder<E>;
|
|
67
74
|
recordHandle: RecordHandle;
|
package/lib/manager.js
CHANGED
|
@@ -5,8 +5,48 @@ import { omit, range } from "lodash-es";
|
|
|
5
5
|
import { parseArgsStringToArgv } from "string-argv";
|
|
6
6
|
import { RecorderCacheImpl, MemoryCacheStore } from "./cache.js";
|
|
7
7
|
import { getBiliStatusInfoByRoomIds } from "./api.js";
|
|
8
|
-
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, } from "./utils.js";
|
|
8
|
+
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, replaceFourByteUnicode, } from "./utils.js";
|
|
9
9
|
import { StreamManager } from "./downloader/streamManager.js";
|
|
10
|
+
const RECORDER_TIMELINE_LIMIT = 40;
|
|
11
|
+
function normalizeTimelineText(text) {
|
|
12
|
+
return text.trim();
|
|
13
|
+
}
|
|
14
|
+
function appendRecorderTimeline(recorder, args) {
|
|
15
|
+
const text = normalizeTimelineText(args.text);
|
|
16
|
+
if (!text) {
|
|
17
|
+
return recorder.timeline ?? [];
|
|
18
|
+
}
|
|
19
|
+
const startTime = args.startTime ?? Date.now();
|
|
20
|
+
const timeline = recorder.timeline ?? [];
|
|
21
|
+
const lastItem = timeline[timeline.length - 1];
|
|
22
|
+
if (lastItem && lastItem.text === text) {
|
|
23
|
+
lastItem.endTime = Math.max(lastItem.endTime ?? lastItem.startTime, startTime);
|
|
24
|
+
recorder.timeline = [...timeline];
|
|
25
|
+
return recorder.timeline;
|
|
26
|
+
}
|
|
27
|
+
const nextTimeline = [...timeline, { startTime, text }].slice(-RECORDER_TIMELINE_LIMIT);
|
|
28
|
+
recorder.timeline = nextTimeline;
|
|
29
|
+
return nextTimeline;
|
|
30
|
+
}
|
|
31
|
+
function ensureRecorderTimeline(recorder) {
|
|
32
|
+
recorder.timeline = Array.isArray(recorder.timeline) ? recorder.timeline : [];
|
|
33
|
+
recorder.appendTimeline = (args) => appendRecorderTimeline(recorder, args);
|
|
34
|
+
}
|
|
35
|
+
function getStateTimelineText(state, msg) {
|
|
36
|
+
const text = normalizeTimelineText(msg ?? "");
|
|
37
|
+
if (text) {
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
const stateTextMap = {
|
|
41
|
+
idle: "空闲中",
|
|
42
|
+
recording: "录制中",
|
|
43
|
+
"stopping-record": "停止录制中",
|
|
44
|
+
"check-error": "检查失败",
|
|
45
|
+
"title-blocked": "标题命中过滤规则",
|
|
46
|
+
"charge-skipped": "特殊直播(付费/权限/加密),无法录制,已跳过",
|
|
47
|
+
};
|
|
48
|
+
return stateTextMap[state];
|
|
49
|
+
}
|
|
10
50
|
const configurableProps = [
|
|
11
51
|
"savePathRule",
|
|
12
52
|
"autoRemoveSystemReservedChars",
|
|
@@ -25,7 +65,7 @@ export function createRecorderManager(opts) {
|
|
|
25
65
|
const recorders = [];
|
|
26
66
|
// 存储每个 provider 的 timer,key 为 providerId
|
|
27
67
|
const checkLoopTimers = new Map();
|
|
28
|
-
const multiThreadCheck = async (manager, providerId) => {
|
|
68
|
+
const multiThreadCheck = async (manager, providerId, providerConfig) => {
|
|
29
69
|
const handleBatchQuery = async (obj) => {
|
|
30
70
|
for (const recorder of recorders
|
|
31
71
|
.filter((r) => !r.disableAutoCheck)
|
|
@@ -48,7 +88,6 @@ export function createRecorderManager(opts) {
|
|
|
48
88
|
.filter((r) => !r.disableAutoCheck)
|
|
49
89
|
.filter((r) => isBetweenTimeRange(r.handleTime))
|
|
50
90
|
.filter((r) => r.providerId === providerId);
|
|
51
|
-
const providerConfig = manager.getProviderCheckConfig(providerId);
|
|
52
91
|
const threads = [];
|
|
53
92
|
// Bilibili 批量查询特殊处理
|
|
54
93
|
if (providerId === "Bilibili" && manager.biliBatchQuery) {
|
|
@@ -87,13 +126,15 @@ export function createRecorderManager(opts) {
|
|
|
87
126
|
while (needCheckRecorders.length > 0) {
|
|
88
127
|
try {
|
|
89
128
|
await checkOnce();
|
|
90
|
-
if (providerConfig.waitTime > 0) {
|
|
91
|
-
await sleep(providerConfig.waitTime);
|
|
92
|
-
}
|
|
93
129
|
}
|
|
94
130
|
catch (err) {
|
|
95
131
|
manager.emit("error", { source: "checkOnceInThread", err });
|
|
96
132
|
}
|
|
133
|
+
finally {
|
|
134
|
+
if (providerConfig.waitTime > 0) {
|
|
135
|
+
await sleep(providerConfig.waitTime);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
97
138
|
}
|
|
98
139
|
}));
|
|
99
140
|
await Promise.all(threads);
|
|
@@ -102,6 +143,8 @@ export function createRecorderManager(opts) {
|
|
|
102
143
|
const tempBanObj = {};
|
|
103
144
|
// 用于是否触发LiveStart事件,不要重复触发
|
|
104
145
|
const liveStartObj = {};
|
|
146
|
+
// 用于是否触发ChargeLive(充电直播)事件,按场次去重,不要每个检查周期重复触发
|
|
147
|
+
const chargeLiveObj = {};
|
|
105
148
|
// 用于记录触发重试直播场次的次数
|
|
106
149
|
const retryCountObj = {};
|
|
107
150
|
const manager = {
|
|
@@ -124,21 +167,42 @@ export function createRecorderManager(opts) {
|
|
|
124
167
|
});
|
|
125
168
|
// 为录制器注入独立的缓存命名空间
|
|
126
169
|
recorder.cache = this.cache.createNamespace(recorder.id);
|
|
170
|
+
ensureRecorderTimeline(recorder);
|
|
127
171
|
this.recorders.push(recorder);
|
|
128
|
-
recorder.on("RecordStart", (recordHandle) =>
|
|
172
|
+
recorder.on("RecordStart", (recordHandle) => {
|
|
173
|
+
recorder.appendTimeline?.({
|
|
174
|
+
startTime: recorder.liveInfo?.recordStartTime?.getTime() ?? Date.now(),
|
|
175
|
+
text: `开始录制:${recordHandle.stream}/${recordHandle.source}/${recordHandle.recorderType}/${recordHandle.url}`,
|
|
176
|
+
});
|
|
177
|
+
this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle });
|
|
178
|
+
});
|
|
129
179
|
recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
|
|
130
180
|
recorder.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
|
|
131
181
|
if (recorder.saveCover && recorder?.liveInfo?.cover) {
|
|
132
182
|
const coverPath = replaceExtName(filename, ".jpg");
|
|
133
183
|
downloadImage(cover ?? recorder?.liveInfo?.cover, coverPath);
|
|
134
184
|
}
|
|
185
|
+
recorder.appendTimeline?.({
|
|
186
|
+
startTime: Date.now(),
|
|
187
|
+
text: `视频文件创建:${filename}`,
|
|
188
|
+
});
|
|
135
189
|
this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename, rawFilename });
|
|
136
190
|
});
|
|
137
|
-
recorder.on("videoFileCompleted", ({ filename, stats }) =>
|
|
191
|
+
recorder.on("videoFileCompleted", ({ filename, stats }) => {
|
|
192
|
+
recorder.appendTimeline?.({
|
|
193
|
+
startTime: Date.now(),
|
|
194
|
+
text: `视频文件创建完成:${filename}`,
|
|
195
|
+
});
|
|
196
|
+
this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename, stats });
|
|
197
|
+
});
|
|
138
198
|
recorder.on("Message", (message) => this.emit("Message", { recorder: recorder.toJSON(), message }));
|
|
139
199
|
recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
|
|
140
200
|
recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
|
|
141
201
|
recorder.on("RecordStop", ({ recordHandle, reason }) => {
|
|
202
|
+
recorder.appendTimeline?.({
|
|
203
|
+
startTime: Date.now(),
|
|
204
|
+
text: reason ? `停止录制:${reason}` : "停止录制",
|
|
205
|
+
});
|
|
142
206
|
this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
|
|
143
207
|
const maxRetryCount = 10;
|
|
144
208
|
// 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
|
|
@@ -167,6 +231,10 @@ export function createRecorderManager(opts) {
|
|
|
167
231
|
type: "common",
|
|
168
232
|
text: `录制${recorder.channelId}中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
|
|
169
233
|
});
|
|
234
|
+
recorder.appendTimeline?.({
|
|
235
|
+
startTime: Date.now(),
|
|
236
|
+
text: `录制中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
|
|
237
|
+
});
|
|
170
238
|
// 触发一次检查,等待一秒使状态清理完毕
|
|
171
239
|
setTimeout(() => {
|
|
172
240
|
recorder.checkLiveStatusAndRecord({
|
|
@@ -189,6 +257,24 @@ export function createRecorderManager(opts) {
|
|
|
189
257
|
liveStartObj[key] = true;
|
|
190
258
|
this.emit("RecoderLiveStart", { recorder: recorder });
|
|
191
259
|
});
|
|
260
|
+
recorder.on("ChargeLive", () => {
|
|
261
|
+
// 充电直播按场次去重,避免每个检查周期重复推送
|
|
262
|
+
const liveId = recorder.liveInfo?.liveId;
|
|
263
|
+
const key = `${recorder.channelId}-${liveId ?? ""}`;
|
|
264
|
+
if (chargeLiveObj[key])
|
|
265
|
+
return;
|
|
266
|
+
chargeLiveObj[key] = true;
|
|
267
|
+
this.emit("RecoderChargeLive", { recorder: recorder });
|
|
268
|
+
});
|
|
269
|
+
recorder.on("stateChange", ({ state, msg }) => {
|
|
270
|
+
recorder.state = state;
|
|
271
|
+
if (state !== "idle") {
|
|
272
|
+
recorder.appendTimeline?.({
|
|
273
|
+
startTime: Date.now(),
|
|
274
|
+
text: `状态变更:${getStateTimelineText(state, msg)}`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
});
|
|
192
278
|
this.emit("RecorderAdded", recorder.toJSON());
|
|
193
279
|
// startCheckLoop 会为所有注册的 provider 启动检查循环,无需在此处额外处理
|
|
194
280
|
return recorder;
|
|
@@ -200,6 +286,16 @@ export function createRecorderManager(opts) {
|
|
|
200
286
|
recorder.recordHandle?.stop("remove recorder");
|
|
201
287
|
this.recorders.splice(idx, 1);
|
|
202
288
|
delete tempBanObj[recorder.channelId];
|
|
289
|
+
// 清理该房间的 liveStart/chargeLive 去重标记,否则删除后重新添加同一场直播不会再次推送通知
|
|
290
|
+
const keyPrefix = `${recorder.channelId}-`;
|
|
291
|
+
Object.keys(liveStartObj).forEach((k) => {
|
|
292
|
+
if (k.startsWith(keyPrefix))
|
|
293
|
+
delete liveStartObj[k];
|
|
294
|
+
});
|
|
295
|
+
Object.keys(chargeLiveObj).forEach((k) => {
|
|
296
|
+
if (k.startsWith(keyPrefix))
|
|
297
|
+
delete chargeLiveObj[k];
|
|
298
|
+
});
|
|
203
299
|
this.emit("RecorderRemoved", recorder.toJSON());
|
|
204
300
|
},
|
|
205
301
|
getRecorder(id) {
|
|
@@ -260,11 +356,11 @@ export function createRecorderManager(opts) {
|
|
|
260
356
|
// TODO: emit updated event
|
|
261
357
|
// 为每个 provider 创建独立的检查循环
|
|
262
358
|
const startProviderCheckLoop = (providerId) => {
|
|
263
|
-
const providerConfig = this.getProviderCheckConfig(providerId);
|
|
264
359
|
const checkLoop = async () => {
|
|
360
|
+
const providerConfig = this.getProviderCheckConfig(providerId);
|
|
265
361
|
try {
|
|
266
362
|
// 只检查当前 provider 的 recorders
|
|
267
|
-
await multiThreadCheck(this, providerId);
|
|
363
|
+
await multiThreadCheck(this, providerId, providerConfig);
|
|
268
364
|
}
|
|
269
365
|
catch (err) {
|
|
270
366
|
this.emit("error", { source: "multiThreadCheck", err });
|
|
@@ -370,6 +466,7 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
370
466
|
title: title,
|
|
371
467
|
remarks: remarks,
|
|
372
468
|
channelId,
|
|
469
|
+
replaceFourByteUnicode,
|
|
373
470
|
};
|
|
374
471
|
let savePathRule = manager.savePathRule;
|
|
375
472
|
if (extData?.extraMs) {
|
package/lib/recorder.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { AnyObject, PickRequired, UnknownObject } from "./utils.js";
|
|
|
5
5
|
import type { NamespacedCache } from "./cache.js";
|
|
6
6
|
import type { DownloaderType } from "./downloader/index.js";
|
|
7
7
|
import type { XmlStreamStats } from "./xml_stream_controller.js";
|
|
8
|
-
type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
|
|
9
|
-
type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
|
|
8
|
+
export type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
|
|
9
|
+
export type CodecName = "auto" | "avc" | "hevc" | "av1" | "avc_only" | "hevc_only" | "av1_only";
|
|
10
10
|
export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
11
11
|
providerId: RecorderProvider<E>["id"];
|
|
12
12
|
channelId: ChannelId;
|
|
@@ -46,7 +46,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
46
46
|
/** 流编码 */
|
|
47
47
|
codecName?: CodecName;
|
|
48
48
|
/** 选择使用的api,虎牙支持: auto,web,mp,wup,抖音支持:web,webHTML,mobile,userHTML */
|
|
49
|
-
api?: "auto" | "web" | "mp" | "wup" | "webHTML" | "mobile" | "userHTML" | "balance" | "random" | string;
|
|
49
|
+
api?: "auto" | "web" | "mp" | "wup" | "webHTML" | "mobile" | "userHTML" | "balance" | "random" | "newAPI" | "oldAPI" | string;
|
|
50
50
|
/** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制,支持两种格式:
|
|
51
51
|
* 1. 逗号分隔的关键词:'回放,录播,重播'
|
|
52
52
|
* 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
|
|
@@ -65,15 +65,26 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
65
65
|
/** 控制弹幕是否使用服务端时间戳 */
|
|
66
66
|
useServerTimestamp?: boolean;
|
|
67
67
|
extra?: Partial<E>;
|
|
68
|
+
/** 代理 如http://127.0.0.1:7890 */
|
|
69
|
+
proxy?: string;
|
|
68
70
|
/** 调试等级 */
|
|
69
71
|
debugLevel?: "none" | "basic" | "verbose";
|
|
70
72
|
}
|
|
71
73
|
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "convert2Mp4" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords">;
|
|
72
|
-
/** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked:
|
|
73
|
-
export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked";
|
|
74
|
+
/** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单,charge-skipped: 充电直播(DRM加密)已跳过 */
|
|
75
|
+
export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked" | "charge-skipped";
|
|
74
76
|
export type Progress = {
|
|
75
77
|
time: string | null;
|
|
76
78
|
};
|
|
79
|
+
export interface RecorderTimelineItem {
|
|
80
|
+
startTime: number;
|
|
81
|
+
text: string;
|
|
82
|
+
endTime?: number;
|
|
83
|
+
}
|
|
84
|
+
export interface AppendRecorderTimelineArgs {
|
|
85
|
+
startTime?: number;
|
|
86
|
+
text: string;
|
|
87
|
+
}
|
|
77
88
|
export interface RecordHandle {
|
|
78
89
|
id: string;
|
|
79
90
|
stream: string;
|
|
@@ -106,6 +117,10 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
106
117
|
cover?: string;
|
|
107
118
|
rawFilename?: string;
|
|
108
119
|
};
|
|
120
|
+
stateChange: {
|
|
121
|
+
state: RecorderState;
|
|
122
|
+
msg?: string;
|
|
123
|
+
};
|
|
109
124
|
videoFileCompleted: {
|
|
110
125
|
filename: string;
|
|
111
126
|
stats?: XmlStreamStats;
|
|
@@ -118,6 +133,9 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
118
133
|
Updated: (string | keyof Recorder)[];
|
|
119
134
|
Message: Message;
|
|
120
135
|
DebugLog: DebugLog;
|
|
136
|
+
ChargeLive: {
|
|
137
|
+
channelId: string;
|
|
138
|
+
};
|
|
121
139
|
}>, RecorderCreateOpts<E> {
|
|
122
140
|
id: string;
|
|
123
141
|
extra: Partial<E>;
|
|
@@ -138,8 +156,13 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
138
156
|
liveId?: string;
|
|
139
157
|
recordStartTime: Date;
|
|
140
158
|
area?: string;
|
|
159
|
+
liveType?: string;
|
|
160
|
+
liveTypeDesc?: string;
|
|
161
|
+
canRecord?: boolean;
|
|
141
162
|
};
|
|
142
163
|
tempStopIntervalCheck?: boolean;
|
|
164
|
+
timeline?: RecorderTimelineItem[];
|
|
165
|
+
appendTimeline: (args: AppendRecorderTimelineArgs) => RecorderTimelineItem[];
|
|
143
166
|
/** 缓存实例(命名空间) */
|
|
144
167
|
cache: NamespacedCache;
|
|
145
168
|
getChannelURL: (this: Recorder<E>) => string;
|
|
@@ -160,10 +183,12 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
160
183
|
liveStartTime: Date;
|
|
161
184
|
area: string;
|
|
162
185
|
}>;
|
|
163
|
-
getStream: (this: Recorder<E
|
|
186
|
+
getStream: (this: Recorder<E>, opts?: {
|
|
187
|
+
formatName?: FormatName;
|
|
188
|
+
formatPriorities?: Array<"flv" | "hls">;
|
|
189
|
+
}) => Promise<{
|
|
164
190
|
source: string;
|
|
165
191
|
name: string;
|
|
166
192
|
url: string;
|
|
167
193
|
}>;
|
|
168
194
|
}
|
|
169
|
-
export {};
|
package/lib/utils.d.ts
CHANGED
|
@@ -36,6 +36,10 @@ export declare function assertNumberType(data: unknown, msg?: string): asserts d
|
|
|
36
36
|
export declare function assertObjectType(data: unknown, msg?: string): asserts data is object;
|
|
37
37
|
export declare function formatDate(date: Date, format: string): string;
|
|
38
38
|
export declare function removeSystemReservedChars(str: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* 替换四字节 Unicode 字符(如部分 emoji 表情)为指定字符。
|
|
41
|
+
*/
|
|
42
|
+
export declare function replaceFourByteUnicode(value: unknown, replacement?: string): string;
|
|
39
43
|
export declare function isFfmpegStartSegment(line: string): boolean;
|
|
40
44
|
export declare function isMesioStartSegment(line: string): boolean;
|
|
41
45
|
export declare function isBililiveStartSegment(line: string): boolean;
|
|
@@ -125,6 +129,7 @@ declare const _default: {
|
|
|
125
129
|
assertObjectType: typeof assertObjectType;
|
|
126
130
|
asyncThrottle: typeof asyncThrottle;
|
|
127
131
|
isFfmpegStartSegment: typeof isFfmpegStartSegment;
|
|
132
|
+
replaceFourByteUnicode: typeof replaceFourByteUnicode;
|
|
128
133
|
createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
|
|
129
134
|
createTimeoutChecker: typeof createTimeoutChecker;
|
|
130
135
|
downloadImage: typeof downloadImage;
|
package/lib/utils.js
CHANGED
|
@@ -124,6 +124,12 @@ export function formatDate(date, format) {
|
|
|
124
124
|
export function removeSystemReservedChars(str) {
|
|
125
125
|
return filenamify(str, { replacement: "_" });
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* 替换四字节 Unicode 字符(如部分 emoji 表情)为指定字符。
|
|
129
|
+
*/
|
|
130
|
+
export function replaceFourByteUnicode(value, replacement = "_") {
|
|
131
|
+
return String(value ?? "").replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, replacement);
|
|
132
|
+
}
|
|
127
133
|
export function isFfmpegStartSegment(line) {
|
|
128
134
|
return line.includes("Opening ") && line.includes("for writing");
|
|
129
135
|
}
|
|
@@ -460,10 +466,13 @@ export async function checkTitleKeywordsWhileRecording(recorder, isManualStart,
|
|
|
460
466
|
const { title } = liveInfo;
|
|
461
467
|
// 检查标题是否包含关键词
|
|
462
468
|
if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
|
|
463
|
-
recorder.
|
|
469
|
+
recorder.emit("stateChange", {
|
|
470
|
+
state: "title-blocked",
|
|
471
|
+
msg: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
472
|
+
});
|
|
464
473
|
recorder.emit("DebugLog", {
|
|
465
474
|
type: "common",
|
|
466
|
-
text:
|
|
475
|
+
text: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
467
476
|
});
|
|
468
477
|
// 停止录制
|
|
469
478
|
await recorder?.recordHandle?.stop("直播间标题包含关键词");
|
|
@@ -485,10 +494,9 @@ export function checkTitleKeywordsBeforeRecord(title, recorder, isManualStart) {
|
|
|
485
494
|
return false;
|
|
486
495
|
}
|
|
487
496
|
if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
|
|
488
|
-
recorder.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
497
|
+
recorder.emit("stateChange", {
|
|
498
|
+
state: "title-blocked",
|
|
499
|
+
msg: `跳过录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
492
500
|
});
|
|
493
501
|
return true;
|
|
494
502
|
}
|
|
@@ -505,6 +513,7 @@ export default {
|
|
|
505
513
|
assertObjectType,
|
|
506
514
|
asyncThrottle,
|
|
507
515
|
isFfmpegStartSegment,
|
|
516
|
+
replaceFourByteUnicode,
|
|
508
517
|
createFFmpegInvalidStreamChecker,
|
|
509
518
|
createTimeoutChecker,
|
|
510
519
|
downloadImage,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/manager",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.1",
|
|
4
4
|
"description": "Batch scheduling recorders",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -33,14 +33,17 @@
|
|
|
33
33
|
"license": "LGPL",
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@renmu/fluent-ffmpeg": "2.3.3",
|
|
36
|
-
"fast-xml-parser": "^4.5.0",
|
|
37
|
-
"filenamify": "^7.0.0",
|
|
38
|
-
"mitt": "^3.0.1",
|
|
39
|
-
"string-argv": "^0.3.2",
|
|
40
|
-
"lodash-es": "^4.17.21",
|
|
41
36
|
"axios": "^1.15.0",
|
|
37
|
+
"ejs": "^3.1.10",
|
|
38
|
+
"fast-xml-parser": "^5.9.3",
|
|
39
|
+
"filenamify": "^7.0.0",
|
|
42
40
|
"fs-extra": "^11.2.0",
|
|
43
|
-
"
|
|
41
|
+
"lodash-es": "^4.17.21",
|
|
42
|
+
"mitt": "^3.0.1",
|
|
43
|
+
"string-argv": "^0.3.2"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/ejs": "^3.1.5"
|
|
44
47
|
},
|
|
45
48
|
"scripts": {
|
|
46
49
|
"build": "pnpm run test && tsc",
|