@bililive-tools/manager 1.16.1 → 1.17.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/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 +2 -0
- package/lib/downloader/mesioDownloader.d.ts +1 -0
- package/lib/downloader/mesioDownloader.js +25 -1
- package/lib/downloader/streamManager.d.ts +6 -1
- package/lib/downloader/streamManager.js +9 -3
- package/lib/index.js +1 -0
- package/lib/manager.js +83 -9
- package/lib/recorder.d.ts +21 -2
- package/lib/utils.js +8 -6
- package/lib/xml_stream_controller.js +3 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -23,6 +23,7 @@ export interface BaseRecorderOptions {
|
|
|
23
23
|
[key: string]: string | undefined;
|
|
24
24
|
};
|
|
25
25
|
videoFormat?: VideoFormat;
|
|
26
|
+
proxy?: string;
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
29
|
* 录制器接口定义
|
|
@@ -37,6 +38,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
37
38
|
readonly headers: {
|
|
38
39
|
[key: string]: string | undefined;
|
|
39
40
|
} | undefined;
|
|
41
|
+
readonly proxy?: string;
|
|
40
42
|
readonly getSavePath: (data: {
|
|
41
43
|
startTime: number;
|
|
42
44
|
title?: 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
|
}
|
|
@@ -23,7 +23,12 @@ export declare class Segment extends EventEmitter {
|
|
|
23
23
|
outputVideoFilePath: string;
|
|
24
24
|
disableDanma: boolean;
|
|
25
25
|
videoExt: TrueVideoFormat;
|
|
26
|
-
|
|
26
|
+
options?: {
|
|
27
|
+
firstStartTime: number;
|
|
28
|
+
};
|
|
29
|
+
constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: TrueVideoFormat, options?: {
|
|
30
|
+
firstStartTime: number;
|
|
31
|
+
});
|
|
27
32
|
private getVideoFileCompletedPayload;
|
|
28
33
|
handleSegmentEnd(): Promise<void>;
|
|
29
34
|
onSegmentStart(stderrLine: string, callBack?: {
|
|
@@ -13,11 +13,13 @@ export class Segment extends EventEmitter {
|
|
|
13
13
|
outputVideoFilePath;
|
|
14
14
|
disableDanma;
|
|
15
15
|
videoExt;
|
|
16
|
-
|
|
16
|
+
options;
|
|
17
|
+
constructor(getSavePath, disableDanma, videoExt, options) {
|
|
17
18
|
super();
|
|
18
19
|
this.getSavePath = getSavePath;
|
|
19
20
|
this.disableDanma = disableDanma;
|
|
20
21
|
this.videoExt = videoExt;
|
|
22
|
+
this.options = options;
|
|
21
23
|
}
|
|
22
24
|
getVideoFileCompletedPayload() {
|
|
23
25
|
return {
|
|
@@ -58,11 +60,13 @@ export class Segment extends EventEmitter {
|
|
|
58
60
|
if (!this.init) {
|
|
59
61
|
await this.handleSegmentEnd();
|
|
60
62
|
}
|
|
63
|
+
// 首次创建使用上次的时间戳,后续创建使用当前时间戳
|
|
64
|
+
const startTime = this.init ? (this.options?.firstStartTime ?? Date.now()) : Date.now();
|
|
61
65
|
this.init = false;
|
|
62
|
-
const startTime = Date.now();
|
|
63
66
|
let liveInfo = { title: "", cover: "" };
|
|
64
67
|
if (callBack?.onUpdateLiveInfo) {
|
|
65
68
|
try {
|
|
69
|
+
// TODO:这里存在bug,当调用onUpdateLiveInfo并在等待时,handleSegmentEnd被调用,那么会造成竞态导致数据错误,后续需要优化,需要保存segment状态
|
|
66
70
|
liveInfo = await callBack.onUpdateLiveInfo();
|
|
67
71
|
}
|
|
68
72
|
catch (err) {
|
|
@@ -143,7 +147,9 @@ export class StreamManager extends EventEmitter {
|
|
|
143
147
|
}
|
|
144
148
|
this.recordSavePath = recordSavePath;
|
|
145
149
|
if (hasSegment) {
|
|
146
|
-
this.segment = new Segment(getSavePath, disableDanma, this.videoExt
|
|
150
|
+
this.segment = new Segment(getSavePath, disableDanma, this.videoExt, {
|
|
151
|
+
firstStartTime: startTime,
|
|
152
|
+
});
|
|
147
153
|
this.segment.on("DebugLog", (data) => {
|
|
148
154
|
this.emit("DebugLog", data);
|
|
149
155
|
});
|
package/lib/index.js
CHANGED
package/lib/manager.js
CHANGED
|
@@ -7,6 +7,45 @@ import { RecorderCacheImpl, MemoryCacheStore } from "./cache.js";
|
|
|
7
7
|
import { getBiliStatusInfoByRoomIds } from "./api.js";
|
|
8
8
|
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, } 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
|
+
};
|
|
47
|
+
return stateTextMap[state];
|
|
48
|
+
}
|
|
10
49
|
const configurableProps = [
|
|
11
50
|
"savePathRule",
|
|
12
51
|
"autoRemoveSystemReservedChars",
|
|
@@ -25,7 +64,7 @@ export function createRecorderManager(opts) {
|
|
|
25
64
|
const recorders = [];
|
|
26
65
|
// 存储每个 provider 的 timer,key 为 providerId
|
|
27
66
|
const checkLoopTimers = new Map();
|
|
28
|
-
const multiThreadCheck = async (manager, providerId) => {
|
|
67
|
+
const multiThreadCheck = async (manager, providerId, providerConfig) => {
|
|
29
68
|
const handleBatchQuery = async (obj) => {
|
|
30
69
|
for (const recorder of recorders
|
|
31
70
|
.filter((r) => !r.disableAutoCheck)
|
|
@@ -48,7 +87,6 @@ export function createRecorderManager(opts) {
|
|
|
48
87
|
.filter((r) => !r.disableAutoCheck)
|
|
49
88
|
.filter((r) => isBetweenTimeRange(r.handleTime))
|
|
50
89
|
.filter((r) => r.providerId === providerId);
|
|
51
|
-
const providerConfig = manager.getProviderCheckConfig(providerId);
|
|
52
90
|
const threads = [];
|
|
53
91
|
// Bilibili 批量查询特殊处理
|
|
54
92
|
if (providerId === "Bilibili" && manager.biliBatchQuery) {
|
|
@@ -87,13 +125,15 @@ export function createRecorderManager(opts) {
|
|
|
87
125
|
while (needCheckRecorders.length > 0) {
|
|
88
126
|
try {
|
|
89
127
|
await checkOnce();
|
|
90
|
-
if (providerConfig.waitTime > 0) {
|
|
91
|
-
await sleep(providerConfig.waitTime);
|
|
92
|
-
}
|
|
93
128
|
}
|
|
94
129
|
catch (err) {
|
|
95
130
|
manager.emit("error", { source: "checkOnceInThread", err });
|
|
96
131
|
}
|
|
132
|
+
finally {
|
|
133
|
+
if (providerConfig.waitTime > 0) {
|
|
134
|
+
await sleep(providerConfig.waitTime);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
97
137
|
}
|
|
98
138
|
}));
|
|
99
139
|
await Promise.all(threads);
|
|
@@ -124,21 +164,42 @@ export function createRecorderManager(opts) {
|
|
|
124
164
|
});
|
|
125
165
|
// 为录制器注入独立的缓存命名空间
|
|
126
166
|
recorder.cache = this.cache.createNamespace(recorder.id);
|
|
167
|
+
ensureRecorderTimeline(recorder);
|
|
127
168
|
this.recorders.push(recorder);
|
|
128
|
-
recorder.on("RecordStart", (recordHandle) =>
|
|
169
|
+
recorder.on("RecordStart", (recordHandle) => {
|
|
170
|
+
recorder.appendTimeline?.({
|
|
171
|
+
startTime: recorder.liveInfo?.recordStartTime?.getTime() ?? Date.now(),
|
|
172
|
+
text: `开始录制:${recordHandle.stream}/${recordHandle.source}/${recordHandle.recorderType}/${recordHandle.url}`,
|
|
173
|
+
});
|
|
174
|
+
this.emit("RecordStart", { recorder: recorder.toJSON(), recordHandle });
|
|
175
|
+
});
|
|
129
176
|
recorder.on("RecordSegment", (recordHandle) => this.emit("RecordSegment", { recorder: recorder.toJSON(), recordHandle }));
|
|
130
177
|
recorder.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
|
|
131
178
|
if (recorder.saveCover && recorder?.liveInfo?.cover) {
|
|
132
179
|
const coverPath = replaceExtName(filename, ".jpg");
|
|
133
180
|
downloadImage(cover ?? recorder?.liveInfo?.cover, coverPath);
|
|
134
181
|
}
|
|
182
|
+
recorder.appendTimeline?.({
|
|
183
|
+
startTime: Date.now(),
|
|
184
|
+
text: `视频文件创建:${filename}`,
|
|
185
|
+
});
|
|
135
186
|
this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename, rawFilename });
|
|
136
187
|
});
|
|
137
|
-
recorder.on("videoFileCompleted", ({ filename, stats }) =>
|
|
188
|
+
recorder.on("videoFileCompleted", ({ filename, stats }) => {
|
|
189
|
+
recorder.appendTimeline?.({
|
|
190
|
+
startTime: Date.now(),
|
|
191
|
+
text: `视频文件创建完成:${filename}`,
|
|
192
|
+
});
|
|
193
|
+
this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename, stats });
|
|
194
|
+
});
|
|
138
195
|
recorder.on("Message", (message) => this.emit("Message", { recorder: recorder.toJSON(), message }));
|
|
139
196
|
recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
|
|
140
197
|
recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
|
|
141
198
|
recorder.on("RecordStop", ({ recordHandle, reason }) => {
|
|
199
|
+
recorder.appendTimeline?.({
|
|
200
|
+
startTime: Date.now(),
|
|
201
|
+
text: reason ? `停止录制:${reason}` : "停止录制",
|
|
202
|
+
});
|
|
142
203
|
this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
|
|
143
204
|
const maxRetryCount = 10;
|
|
144
205
|
// 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
|
|
@@ -167,6 +228,10 @@ export function createRecorderManager(opts) {
|
|
|
167
228
|
type: "common",
|
|
168
229
|
text: `录制${recorder.channelId}中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
|
|
169
230
|
});
|
|
231
|
+
recorder.appendTimeline?.({
|
|
232
|
+
startTime: Date.now(),
|
|
233
|
+
text: `录制中断,立即触发重试(${retryCountObj[key]}/${maxRetryCount})`,
|
|
234
|
+
});
|
|
170
235
|
// 触发一次检查,等待一秒使状态清理完毕
|
|
171
236
|
setTimeout(() => {
|
|
172
237
|
recorder.checkLiveStatusAndRecord({
|
|
@@ -189,6 +254,15 @@ export function createRecorderManager(opts) {
|
|
|
189
254
|
liveStartObj[key] = true;
|
|
190
255
|
this.emit("RecoderLiveStart", { recorder: recorder });
|
|
191
256
|
});
|
|
257
|
+
recorder.on("stateChange", ({ state, msg }) => {
|
|
258
|
+
recorder.state = state;
|
|
259
|
+
if (state !== "idle") {
|
|
260
|
+
recorder.appendTimeline?.({
|
|
261
|
+
startTime: Date.now(),
|
|
262
|
+
text: `状态变更:${getStateTimelineText(state, msg)}`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
192
266
|
this.emit("RecorderAdded", recorder.toJSON());
|
|
193
267
|
// startCheckLoop 会为所有注册的 provider 启动检查循环,无需在此处额外处理
|
|
194
268
|
return recorder;
|
|
@@ -260,11 +334,11 @@ export function createRecorderManager(opts) {
|
|
|
260
334
|
// TODO: emit updated event
|
|
261
335
|
// 为每个 provider 创建独立的检查循环
|
|
262
336
|
const startProviderCheckLoop = (providerId) => {
|
|
263
|
-
const providerConfig = this.getProviderCheckConfig(providerId);
|
|
264
337
|
const checkLoop = async () => {
|
|
338
|
+
const providerConfig = this.getProviderCheckConfig(providerId);
|
|
265
339
|
try {
|
|
266
340
|
// 只检查当前 provider 的 recorders
|
|
267
|
-
await multiThreadCheck(this, providerId);
|
|
341
|
+
await multiThreadCheck(this, providerId, providerConfig);
|
|
268
342
|
}
|
|
269
343
|
catch (err) {
|
|
270
344
|
this.emit("error", { source: "multiThreadCheck", err });
|
package/lib/recorder.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
25
25
|
saveSCDanma?: boolean;
|
|
26
26
|
/** 保存封面 */
|
|
27
27
|
saveCover?: boolean;
|
|
28
|
+
/** 转封装为 mp4 */
|
|
29
|
+
convert2Mp4?: boolean;
|
|
28
30
|
/** 身份验证 */
|
|
29
31
|
auth?: string;
|
|
30
32
|
/** cookie所有者uid,B站弹幕录制 */
|
|
@@ -44,7 +46,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
44
46
|
/** 流编码 */
|
|
45
47
|
codecName?: CodecName;
|
|
46
48
|
/** 选择使用的api,虎牙支持: auto,web,mp,wup,抖音支持:web,webHTML,mobile,userHTML */
|
|
47
|
-
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;
|
|
48
50
|
/** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制,支持两种格式:
|
|
49
51
|
* 1. 逗号分隔的关键词:'回放,录播,重播'
|
|
50
52
|
* 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
|
|
@@ -63,15 +65,26 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
63
65
|
/** 控制弹幕是否使用服务端时间戳 */
|
|
64
66
|
useServerTimestamp?: boolean;
|
|
65
67
|
extra?: Partial<E>;
|
|
68
|
+
/** 代理 如http://127.0.0.1:7890 */
|
|
69
|
+
proxy?: string;
|
|
66
70
|
/** 调试等级 */
|
|
67
71
|
debugLevel?: "none" | "basic" | "verbose";
|
|
68
72
|
}
|
|
69
|
-
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">;
|
|
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">;
|
|
70
74
|
/** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单 */
|
|
71
75
|
export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked";
|
|
72
76
|
export type Progress = {
|
|
73
77
|
time: string | null;
|
|
74
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
|
+
}
|
|
75
88
|
export interface RecordHandle {
|
|
76
89
|
id: string;
|
|
77
90
|
stream: string;
|
|
@@ -104,6 +117,10 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
104
117
|
cover?: string;
|
|
105
118
|
rawFilename?: string;
|
|
106
119
|
};
|
|
120
|
+
stateChange: {
|
|
121
|
+
state: RecorderState;
|
|
122
|
+
msg?: string;
|
|
123
|
+
};
|
|
107
124
|
videoFileCompleted: {
|
|
108
125
|
filename: string;
|
|
109
126
|
stats?: XmlStreamStats;
|
|
@@ -138,6 +155,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
138
155
|
area?: string;
|
|
139
156
|
};
|
|
140
157
|
tempStopIntervalCheck?: boolean;
|
|
158
|
+
timeline?: RecorderTimelineItem[];
|
|
159
|
+
appendTimeline: (args: AppendRecorderTimelineArgs) => RecorderTimelineItem[];
|
|
141
160
|
/** 缓存实例(命名空间) */
|
|
142
161
|
cache: NamespacedCache;
|
|
143
162
|
getChannelURL: (this: Recorder<E>) => string;
|
package/lib/utils.js
CHANGED
|
@@ -460,10 +460,13 @@ export async function checkTitleKeywordsWhileRecording(recorder, isManualStart,
|
|
|
460
460
|
const { title } = liveInfo;
|
|
461
461
|
// 检查标题是否包含关键词
|
|
462
462
|
if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
|
|
463
|
-
recorder.
|
|
463
|
+
recorder.emit("stateChange", {
|
|
464
|
+
state: "title-blocked",
|
|
465
|
+
msg: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
466
|
+
});
|
|
464
467
|
recorder.emit("DebugLog", {
|
|
465
468
|
type: "common",
|
|
466
|
-
text:
|
|
469
|
+
text: `停止录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
467
470
|
});
|
|
468
471
|
// 停止录制
|
|
469
472
|
await recorder?.recordHandle?.stop("直播间标题包含关键词");
|
|
@@ -485,10 +488,9 @@ export function checkTitleKeywordsBeforeRecord(title, recorder, isManualStart) {
|
|
|
485
488
|
return false;
|
|
486
489
|
}
|
|
487
490
|
if (hasBlockedTitleKeywords(title, recorder.titleKeywords)) {
|
|
488
|
-
recorder.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
491
|
+
recorder.emit("stateChange", {
|
|
492
|
+
state: "title-blocked",
|
|
493
|
+
msg: `跳过录制,直播间标题 "${title}" 包含关键词 "${recorder.titleKeywords}"`,
|
|
492
494
|
});
|
|
493
495
|
return true;
|
|
494
496
|
}
|