@bililive-tools/douyu-recorder 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/index.js +33 -108
- package/lib/stream.d.ts +2 -1
- package/lib/stream.js +3 -6
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ interface Options {
|
|
|
40
40
|
streamPriorities: []; // 废弃
|
|
41
41
|
sourcePriorities: []; // 废弃
|
|
42
42
|
disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
|
|
43
|
-
segment?: number; //
|
|
43
|
+
segment?: number | string; // 分段参数,单位分钟,如果以"B","KB","MB","GB"结尾,会尝试使用文件大小分段,仅推荐在使用mesio录制引擎时使用
|
|
44
44
|
titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
|
|
45
45
|
disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
|
|
46
46
|
saveGiftDanma?: boolean; // 保存礼物弹幕
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import mitt from "mitt";
|
|
2
|
-
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils,
|
|
2
|
+
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createDownloader, } from "@bililive-tools/manager";
|
|
3
|
+
import { live } from "douyu-api";
|
|
3
4
|
import { getInfo, getStream } from "./stream.js";
|
|
4
5
|
import { getRoomInfo } from "./dy_api.js";
|
|
5
6
|
import { ensureFolderExist } from "./utils.js";
|
|
6
7
|
import { createDYClient } from "./dy_client/index.js";
|
|
7
8
|
import { giftMap, colorTab } from "./danma.js";
|
|
8
|
-
import { requester } from "./requester.js";
|
|
9
9
|
function createRecorder(opts) {
|
|
10
10
|
// 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
|
|
11
11
|
// 此标志来操作这个对象的地方,不然会跳过 proxy 的拦截。
|
|
@@ -15,9 +15,9 @@ function createRecorder(opts) {
|
|
|
15
15
|
// @ts-ignore
|
|
16
16
|
...mitt(),
|
|
17
17
|
...opts,
|
|
18
|
+
cache: null,
|
|
18
19
|
availableStreams: [],
|
|
19
20
|
availableSources: [],
|
|
20
|
-
qualityMaxRetry: opts.qualityRetry ?? 0,
|
|
21
21
|
qualityRetry: opts.qualityRetry ?? 0,
|
|
22
22
|
useServerTimestamp: opts.useServerTimestamp ?? true,
|
|
23
23
|
state: "idle",
|
|
@@ -59,34 +59,9 @@ const ffmpegOutputOptions = [];
|
|
|
59
59
|
const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
|
|
60
60
|
// 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
|
|
61
61
|
if (this.recordHandle != null) {
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
// 每5分钟检查一次标题变化
|
|
66
|
-
const titleCheckInterval = 5 * 60 * 1000; // 5分钟
|
|
67
|
-
// 获取上次检查时间
|
|
68
|
-
const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
|
|
69
|
-
// 如果距离上次检查时间不足指定间隔,则跳过检查
|
|
70
|
-
if (now - lastCheckTime < titleCheckInterval) {
|
|
71
|
-
return this.recordHandle;
|
|
72
|
-
}
|
|
73
|
-
// 更新检查时间
|
|
74
|
-
this.extra.lastTitleCheckTime = now;
|
|
75
|
-
// 获取直播间信息
|
|
76
|
-
const liveInfo = await getInfo(this.channelId);
|
|
77
|
-
const { title } = liveInfo;
|
|
78
|
-
// 检查标题是否包含关键词
|
|
79
|
-
if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
|
|
80
|
-
this.state = "title-blocked";
|
|
81
|
-
this.emit("DebugLog", {
|
|
82
|
-
type: "common",
|
|
83
|
-
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
84
|
-
});
|
|
85
|
-
// 停止录制
|
|
86
|
-
await this.recordHandle.stop("直播间标题包含关键词");
|
|
87
|
-
// 返回 null,停止录制
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
62
|
+
const shouldStop = await utils.checkTitleKeywordsWhileRecording(this, isManualStart, getInfo);
|
|
63
|
+
if (shouldStop) {
|
|
64
|
+
return null;
|
|
90
65
|
}
|
|
91
66
|
// 已经在录制中,直接返回
|
|
92
67
|
return this.recordHandle;
|
|
@@ -101,7 +76,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
101
76
|
this.state = "check-error";
|
|
102
77
|
throw error;
|
|
103
78
|
}
|
|
104
|
-
const { living, owner, title,
|
|
79
|
+
const { living, owner, title, liveStartTime, recordStartTime } = this.liveInfo;
|
|
105
80
|
if (this.liveInfo.liveId === banLiveId) {
|
|
106
81
|
this.tempStopIntervalCheck = true;
|
|
107
82
|
}
|
|
@@ -112,30 +87,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
112
87
|
return null;
|
|
113
88
|
if (!living)
|
|
114
89
|
return null;
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.emit("DebugLog", {
|
|
121
|
-
type: "common",
|
|
122
|
-
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
123
|
-
});
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
90
|
+
// 检查标题是否包含关键词
|
|
91
|
+
if (utils.checkTitleKeywordsBeforeRecord(title, this, isManualStart))
|
|
92
|
+
return null;
|
|
93
|
+
const qualityRetryLeft = (await this.cache.get("qualityRetryLeft")) ?? this.qualityRetry;
|
|
94
|
+
const strictQuality = utils.shouldUseStrictQuality(qualityRetryLeft, this.qualityRetry, isManualStart);
|
|
127
95
|
let res;
|
|
128
96
|
try {
|
|
129
|
-
let strictQuality = false;
|
|
130
|
-
if (this.qualityRetry > 0) {
|
|
131
|
-
strictQuality = true;
|
|
132
|
-
}
|
|
133
|
-
if (this.qualityMaxRetry < 0) {
|
|
134
|
-
strictQuality = true;
|
|
135
|
-
}
|
|
136
|
-
if (isManualStart) {
|
|
137
|
-
strictQuality = false;
|
|
138
|
-
}
|
|
139
97
|
res = await getStream({
|
|
140
98
|
channelId: this.channelId,
|
|
141
99
|
quality: this.quality,
|
|
@@ -146,8 +104,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
146
104
|
});
|
|
147
105
|
}
|
|
148
106
|
catch (err) {
|
|
149
|
-
if (
|
|
150
|
-
this.
|
|
107
|
+
if (qualityRetryLeft > 0)
|
|
108
|
+
await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
|
|
151
109
|
this.state = "check-error";
|
|
152
110
|
throw err;
|
|
153
111
|
}
|
|
@@ -158,10 +116,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
158
116
|
this.usedStream = stream.name;
|
|
159
117
|
this.usedSource = stream.source;
|
|
160
118
|
const onEnd = (...args) => {
|
|
161
|
-
if (isCutting) {
|
|
162
|
-
isCutting = false;
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
119
|
if (isEnded)
|
|
166
120
|
return;
|
|
167
121
|
isEnded = true;
|
|
@@ -173,9 +127,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
173
127
|
this.recordHandle?.stop(reason);
|
|
174
128
|
};
|
|
175
129
|
let isEnded = false;
|
|
176
|
-
|
|
177
|
-
const recordStartTime = new Date();
|
|
178
|
-
const recorder = createBaseRecorder(this.recorderType, {
|
|
130
|
+
const downloader = createDownloader(this.recorderType, {
|
|
179
131
|
url: stream.url,
|
|
180
132
|
// @ts-ignore
|
|
181
133
|
outputOptions: ffmpegOutputOptions,
|
|
@@ -184,7 +136,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
184
136
|
owner,
|
|
185
137
|
title: opts.title ?? title,
|
|
186
138
|
startTime: opts.startTime,
|
|
187
|
-
liveStartTime
|
|
139
|
+
liveStartTime,
|
|
188
140
|
recordStartTime,
|
|
189
141
|
}),
|
|
190
142
|
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
@@ -199,7 +151,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
199
151
|
owner,
|
|
200
152
|
title,
|
|
201
153
|
startTime: Date.now(),
|
|
202
|
-
liveStartTime
|
|
154
|
+
liveStartTime,
|
|
203
155
|
recordStartTime,
|
|
204
156
|
});
|
|
205
157
|
try {
|
|
@@ -217,24 +169,24 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
217
169
|
if (cover && this?.liveInfo) {
|
|
218
170
|
this.liveInfo.cover = cover;
|
|
219
171
|
}
|
|
220
|
-
const extraDataController =
|
|
172
|
+
const extraDataController = downloader.getExtraDataController();
|
|
221
173
|
extraDataController?.setMeta({
|
|
222
174
|
room_id: this.channelId,
|
|
223
175
|
platform: provider?.id,
|
|
224
|
-
liveStartTimestamp: this?.liveInfo?.
|
|
176
|
+
liveStartTimestamp: this?.liveInfo?.liveStartTime?.getTime(),
|
|
225
177
|
// recordStopTimestamp: Date.now(),
|
|
226
178
|
title: title,
|
|
227
179
|
user_name: owner,
|
|
228
180
|
});
|
|
229
181
|
};
|
|
230
|
-
|
|
231
|
-
|
|
182
|
+
downloader.on("videoFileCreated", handleVideoCreated);
|
|
183
|
+
downloader.on("videoFileCompleted", ({ filename }) => {
|
|
232
184
|
this.emit("videoFileCompleted", { filename });
|
|
233
185
|
});
|
|
234
|
-
|
|
186
|
+
downloader.on("DebugLog", (data) => {
|
|
235
187
|
this.emit("DebugLog", data);
|
|
236
188
|
});
|
|
237
|
-
|
|
189
|
+
downloader.on("progress", (progress) => {
|
|
238
190
|
if (this.recordHandle) {
|
|
239
191
|
this.recordHandle.progress = progress;
|
|
240
192
|
}
|
|
@@ -244,7 +196,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
244
196
|
notAutoStart: true,
|
|
245
197
|
});
|
|
246
198
|
client.on("message", (msg) => {
|
|
247
|
-
const extraDataController =
|
|
199
|
+
const extraDataController = downloader.getExtraDataController();
|
|
248
200
|
if (!extraDataController)
|
|
249
201
|
return;
|
|
250
202
|
switch (msg.type) {
|
|
@@ -383,18 +335,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
383
335
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
384
336
|
client.start();
|
|
385
337
|
}
|
|
386
|
-
const
|
|
387
|
-
|
|
338
|
+
const downloaderArgs = downloader.getArguments();
|
|
339
|
+
downloader.run();
|
|
388
340
|
// TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
|
|
389
341
|
const cut = utils.singleton(async () => {
|
|
390
342
|
if (!this.recordHandle)
|
|
391
343
|
return;
|
|
392
|
-
|
|
393
|
-
return;
|
|
394
|
-
isCutting = true;
|
|
395
|
-
await recorder.stop();
|
|
396
|
-
recorder.createCommand();
|
|
397
|
-
recorder.run();
|
|
344
|
+
downloader.cut();
|
|
398
345
|
});
|
|
399
346
|
const stop = utils.singleton(async (reason) => {
|
|
400
347
|
if (!this.recordHandle)
|
|
@@ -402,12 +349,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
402
349
|
this.state = "stopping-record";
|
|
403
350
|
try {
|
|
404
351
|
client.stop();
|
|
405
|
-
await
|
|
352
|
+
await downloader.stop();
|
|
406
353
|
}
|
|
407
354
|
catch (err) {
|
|
408
355
|
this.emit("DebugLog", {
|
|
409
356
|
type: "common",
|
|
410
|
-
text: `stop
|
|
357
|
+
text: `stop record error: ${String(err)}`,
|
|
411
358
|
});
|
|
412
359
|
}
|
|
413
360
|
this.usedStream = undefined;
|
|
@@ -416,15 +363,15 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
416
363
|
this.recordHandle = undefined;
|
|
417
364
|
this.liveInfo = undefined;
|
|
418
365
|
this.state = "idle";
|
|
419
|
-
this.
|
|
366
|
+
this.cache.set("qualityRetryLeft", this.qualityRetry);
|
|
420
367
|
});
|
|
421
368
|
this.recordHandle = {
|
|
422
369
|
id: genRecordUUID(),
|
|
423
370
|
stream: stream.name,
|
|
424
371
|
source: stream.source,
|
|
425
|
-
recorderType:
|
|
372
|
+
recorderType: downloader.type,
|
|
426
373
|
url: stream.url,
|
|
427
|
-
|
|
374
|
+
downloaderArgs,
|
|
428
375
|
savePath: savePath,
|
|
429
376
|
stop,
|
|
430
377
|
cut,
|
|
@@ -442,29 +389,7 @@ export const provider = {
|
|
|
442
389
|
async resolveChannelInfoFromURL(channelURL) {
|
|
443
390
|
if (!this.matchURL(channelURL))
|
|
444
391
|
return null;
|
|
445
|
-
|
|
446
|
-
const res = await requester.get(channelURL);
|
|
447
|
-
const html = res.data;
|
|
448
|
-
const matched = html.match(/\$ROOM\.room_id.?=(.*?);/);
|
|
449
|
-
let roomId = undefined;
|
|
450
|
-
if (matched) {
|
|
451
|
-
roomId = matched[1].trim();
|
|
452
|
-
}
|
|
453
|
-
else {
|
|
454
|
-
// 解析出query中的rid参数
|
|
455
|
-
const rid = new URL(channelURL).searchParams.get("rid");
|
|
456
|
-
if (rid) {
|
|
457
|
-
roomId = rid;
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
// 解析<link rel="canonical" href="xxxxxxx"/>中的href
|
|
461
|
-
const canonicalLink = html.match(/<link rel="canonical" href="(.*?)"/);
|
|
462
|
-
if (canonicalLink) {
|
|
463
|
-
const url = canonicalLink[1];
|
|
464
|
-
roomId = url.split("/").pop();
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
392
|
+
const roomId = await live.parseRoomId(channelURL);
|
|
468
393
|
if (!roomId)
|
|
469
394
|
return null;
|
|
470
395
|
const roomInfo = await getRoomInfo(Number(roomId));
|
package/lib/stream.d.ts
CHANGED
|
@@ -3,10 +3,11 @@ export declare function getInfo(channelId: string): Promise<{
|
|
|
3
3
|
living: boolean;
|
|
4
4
|
owner: string;
|
|
5
5
|
title: string;
|
|
6
|
-
|
|
6
|
+
liveStartTime: Date;
|
|
7
7
|
avatar: string;
|
|
8
8
|
cover: string;
|
|
9
9
|
liveId: string;
|
|
10
|
+
recordStartTime: Date;
|
|
10
11
|
}>;
|
|
11
12
|
export declare function getStream(opts: Pick<Recorder, "channelId" | "quality"> & {
|
|
12
13
|
rejectCache?: boolean;
|
package/lib/stream.js
CHANGED
|
@@ -26,14 +26,16 @@ export async function getInfo(channelId) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
const startTime = new Date(data.room.show_time * 1000);
|
|
29
|
+
const recordStartTime = new Date();
|
|
29
30
|
return {
|
|
30
31
|
living,
|
|
31
32
|
owner: data.room.nickname,
|
|
32
33
|
title: data.room.room_name,
|
|
33
34
|
avatar: data.room.avatar.big,
|
|
34
35
|
cover: data.room.room_pic,
|
|
35
|
-
|
|
36
|
+
liveStartTime: startTime,
|
|
36
37
|
liveId: utils.md5(`${channelId}-${startTime?.getTime() ?? Date.now()}`),
|
|
38
|
+
recordStartTime: recordStartTime,
|
|
37
39
|
// gifts: data.gift.map((g) => ({
|
|
38
40
|
// id: g.id,
|
|
39
41
|
// name: g.name,
|
|
@@ -46,7 +48,6 @@ export async function getStream(opts) {
|
|
|
46
48
|
const qn = (DouyuQualities.includes(opts.quality) ? opts.quality : 0);
|
|
47
49
|
let cdn = opts.source === "auto" ? undefined : opts.source;
|
|
48
50
|
if (opts.source === "auto" && opts.avoidEdgeCDN) {
|
|
49
|
-
// TODO: 如果不存在 cdn=hw-h5 的源,那么还是可能默认到边缘节点,就先这样吧
|
|
50
51
|
cdn = "hw-h5";
|
|
51
52
|
}
|
|
52
53
|
let liveInfo = await getLiveInfo({
|
|
@@ -93,9 +94,5 @@ export async function getStream(opts) {
|
|
|
93
94
|
throw new Error("It must be called getStream when living");
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
|
-
// 流未准备好,防止刚开播时的无效录制。
|
|
97
|
-
// 该判断可能导致开播前 30 秒左右无法录制到,因为 streamStatus 在后端似乎有缓存,所以暂时不使用。
|
|
98
|
-
// TODO: 需要在 ffmpeg 那里加处理,防止无效录制
|
|
99
|
-
// if (!json.data.streamStatus) return
|
|
100
97
|
return liveInfo;
|
|
101
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/douyu-recorder",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "bililive-tools douyu recorder implemention",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"ws": "^8.18.0",
|
|
41
41
|
"lodash-es": "^4.17.21",
|
|
42
42
|
"axios": "^1.7.8",
|
|
43
|
-
"douyu-api": "^0.
|
|
44
|
-
"@bililive-tools/manager": "^1.
|
|
43
|
+
"douyu-api": "^0.2.0",
|
|
44
|
+
"@bililive-tools/manager": "^1.10.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/ws": "^8.5.13"
|