@bililive-tools/douyu-recorder 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/dy_api.js +0 -1
- package/lib/index.js +46 -107
- 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/dy_api.js
CHANGED
|
@@ -46,7 +46,6 @@ export async function getLiveInfo(opts) {
|
|
|
46
46
|
delete signCaches[opts.channelId];
|
|
47
47
|
throw new Error("Unexpected error code, " + json.error);
|
|
48
48
|
}
|
|
49
|
-
console.log(JSON.stringify(json, null, 2));
|
|
50
49
|
const streamUrl = `${json.data.rtmp_url}/${json.data.rtmp_live}`;
|
|
51
50
|
let cdn = json.data.rtmp_cdn;
|
|
52
51
|
let onlyAudio = false;
|
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",
|
|
@@ -55,45 +55,13 @@ function createRecorder(opts) {
|
|
|
55
55
|
});
|
|
56
56
|
return recorderWithSupportUpdatedEvent;
|
|
57
57
|
}
|
|
58
|
-
const ffmpegOutputOptions = [
|
|
59
|
-
"-c",
|
|
60
|
-
"copy",
|
|
61
|
-
"-movflags",
|
|
62
|
-
"faststart+frag_keyframe+empty_moov",
|
|
63
|
-
"-min_frag_duration",
|
|
64
|
-
"10000000",
|
|
65
|
-
];
|
|
58
|
+
const ffmpegOutputOptions = [];
|
|
66
59
|
const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
|
|
67
60
|
// 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
|
|
68
61
|
if (this.recordHandle != null) {
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
// 每5分钟检查一次标题变化
|
|
73
|
-
const titleCheckInterval = 5 * 60 * 1000; // 5分钟
|
|
74
|
-
// 获取上次检查时间
|
|
75
|
-
const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
|
|
76
|
-
// 如果距离上次检查时间不足指定间隔,则跳过检查
|
|
77
|
-
if (now - lastCheckTime < titleCheckInterval) {
|
|
78
|
-
return this.recordHandle;
|
|
79
|
-
}
|
|
80
|
-
// 更新检查时间
|
|
81
|
-
this.extra.lastTitleCheckTime = now;
|
|
82
|
-
// 获取直播间信息
|
|
83
|
-
const liveInfo = await getInfo(this.channelId);
|
|
84
|
-
const { title } = liveInfo;
|
|
85
|
-
// 检查标题是否包含关键词
|
|
86
|
-
if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
|
|
87
|
-
this.state = "title-blocked";
|
|
88
|
-
this.emit("DebugLog", {
|
|
89
|
-
type: "common",
|
|
90
|
-
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
91
|
-
});
|
|
92
|
-
// 停止录制
|
|
93
|
-
await this.recordHandle.stop("直播间标题包含关键词");
|
|
94
|
-
// 返回 null,停止录制
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
62
|
+
const shouldStop = await utils.checkTitleKeywordsWhileRecording(this, isManualStart, getInfo);
|
|
63
|
+
if (shouldStop) {
|
|
64
|
+
return null;
|
|
97
65
|
}
|
|
98
66
|
// 已经在录制中,直接返回
|
|
99
67
|
return this.recordHandle;
|
|
@@ -108,7 +76,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
108
76
|
this.state = "check-error";
|
|
109
77
|
throw error;
|
|
110
78
|
}
|
|
111
|
-
const { living, owner, title } = this.liveInfo;
|
|
79
|
+
const { living, owner, title, liveStartTime, recordStartTime } = this.liveInfo;
|
|
112
80
|
if (this.liveInfo.liveId === banLiveId) {
|
|
113
81
|
this.tempStopIntervalCheck = true;
|
|
114
82
|
}
|
|
@@ -119,30 +87,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
119
87
|
return null;
|
|
120
88
|
if (!living)
|
|
121
89
|
return null;
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.emit("DebugLog", {
|
|
128
|
-
type: "common",
|
|
129
|
-
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
130
|
-
});
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
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);
|
|
134
95
|
let res;
|
|
135
96
|
try {
|
|
136
|
-
let strictQuality = false;
|
|
137
|
-
if (this.qualityRetry > 0) {
|
|
138
|
-
strictQuality = true;
|
|
139
|
-
}
|
|
140
|
-
if (this.qualityMaxRetry < 0) {
|
|
141
|
-
strictQuality = true;
|
|
142
|
-
}
|
|
143
|
-
if (isManualStart) {
|
|
144
|
-
strictQuality = false;
|
|
145
|
-
}
|
|
146
97
|
res = await getStream({
|
|
147
98
|
channelId: this.channelId,
|
|
148
99
|
quality: this.quality,
|
|
@@ -153,8 +104,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
153
104
|
});
|
|
154
105
|
}
|
|
155
106
|
catch (err) {
|
|
156
|
-
if (
|
|
157
|
-
this.
|
|
107
|
+
if (qualityRetryLeft > 0)
|
|
108
|
+
await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
|
|
158
109
|
this.state = "check-error";
|
|
159
110
|
throw err;
|
|
160
111
|
}
|
|
@@ -181,13 +132,18 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
181
132
|
};
|
|
182
133
|
let isEnded = false;
|
|
183
134
|
let isCutting = false;
|
|
184
|
-
const
|
|
135
|
+
const downloader = createDownloader(this.recorderType, {
|
|
185
136
|
url: stream.url,
|
|
186
137
|
// @ts-ignore
|
|
187
138
|
outputOptions: ffmpegOutputOptions,
|
|
188
139
|
segment: this.segment ?? 0,
|
|
189
|
-
getSavePath: (opts) => getSavePath({
|
|
190
|
-
|
|
140
|
+
getSavePath: (opts) => getSavePath({
|
|
141
|
+
owner,
|
|
142
|
+
title: opts.title ?? title,
|
|
143
|
+
startTime: opts.startTime,
|
|
144
|
+
liveStartTime,
|
|
145
|
+
recordStartTime,
|
|
146
|
+
}),
|
|
191
147
|
videoFormat: this.videoFormat ?? "auto",
|
|
192
148
|
debugLevel: this.debugLevel ?? "none",
|
|
193
149
|
onlyAudio: stream.onlyAudio,
|
|
@@ -198,6 +154,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
198
154
|
const savePath = getSavePath({
|
|
199
155
|
owner,
|
|
200
156
|
title,
|
|
157
|
+
startTime: Date.now(),
|
|
158
|
+
liveStartTime,
|
|
159
|
+
recordStartTime,
|
|
201
160
|
});
|
|
202
161
|
try {
|
|
203
162
|
ensureFolderExist(savePath);
|
|
@@ -214,24 +173,24 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
214
173
|
if (cover && this?.liveInfo) {
|
|
215
174
|
this.liveInfo.cover = cover;
|
|
216
175
|
}
|
|
217
|
-
const extraDataController =
|
|
176
|
+
const extraDataController = downloader.getExtraDataController();
|
|
218
177
|
extraDataController?.setMeta({
|
|
219
178
|
room_id: this.channelId,
|
|
220
179
|
platform: provider?.id,
|
|
221
|
-
liveStartTimestamp: this?.liveInfo?.
|
|
180
|
+
liveStartTimestamp: this?.liveInfo?.liveStartTime?.getTime(),
|
|
222
181
|
// recordStopTimestamp: Date.now(),
|
|
223
182
|
title: title,
|
|
224
183
|
user_name: owner,
|
|
225
184
|
});
|
|
226
185
|
};
|
|
227
|
-
|
|
228
|
-
|
|
186
|
+
downloader.on("videoFileCreated", handleVideoCreated);
|
|
187
|
+
downloader.on("videoFileCompleted", ({ filename }) => {
|
|
229
188
|
this.emit("videoFileCompleted", { filename });
|
|
230
189
|
});
|
|
231
|
-
|
|
190
|
+
downloader.on("DebugLog", (data) => {
|
|
232
191
|
this.emit("DebugLog", data);
|
|
233
192
|
});
|
|
234
|
-
|
|
193
|
+
downloader.on("progress", (progress) => {
|
|
235
194
|
if (this.recordHandle) {
|
|
236
195
|
this.recordHandle.progress = progress;
|
|
237
196
|
}
|
|
@@ -241,12 +200,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
241
200
|
notAutoStart: true,
|
|
242
201
|
});
|
|
243
202
|
client.on("message", (msg) => {
|
|
244
|
-
const extraDataController =
|
|
203
|
+
const extraDataController = downloader.getExtraDataController();
|
|
245
204
|
if (!extraDataController)
|
|
246
205
|
return;
|
|
247
206
|
switch (msg.type) {
|
|
248
207
|
case "chatmsg": {
|
|
249
|
-
|
|
208
|
+
// 某些情况下cst不存在,可能是其他平台发送的弹幕?
|
|
209
|
+
const timestamp = this.useServerTimestamp && msg.cst ? Number(msg.cst) : Date.now();
|
|
250
210
|
const comment = {
|
|
251
211
|
type: "comment",
|
|
252
212
|
timestamp: timestamp,
|
|
@@ -379,8 +339,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
379
339
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
380
340
|
client.start();
|
|
381
341
|
}
|
|
382
|
-
const
|
|
383
|
-
|
|
342
|
+
const downloaderArgs = downloader.getArguments();
|
|
343
|
+
downloader.run();
|
|
384
344
|
// TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
|
|
385
345
|
const cut = utils.singleton(async () => {
|
|
386
346
|
if (!this.recordHandle)
|
|
@@ -388,9 +348,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
388
348
|
if (isCutting)
|
|
389
349
|
return;
|
|
390
350
|
isCutting = true;
|
|
391
|
-
await
|
|
392
|
-
|
|
393
|
-
|
|
351
|
+
await downloader.stop();
|
|
352
|
+
downloader.createCommand();
|
|
353
|
+
downloader.run();
|
|
394
354
|
});
|
|
395
355
|
const stop = utils.singleton(async (reason) => {
|
|
396
356
|
if (!this.recordHandle)
|
|
@@ -398,12 +358,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
398
358
|
this.state = "stopping-record";
|
|
399
359
|
try {
|
|
400
360
|
client.stop();
|
|
401
|
-
await
|
|
361
|
+
await downloader.stop();
|
|
402
362
|
}
|
|
403
363
|
catch (err) {
|
|
404
364
|
this.emit("DebugLog", {
|
|
405
365
|
type: "common",
|
|
406
|
-
text: `stop
|
|
366
|
+
text: `stop record error: ${String(err)}`,
|
|
407
367
|
});
|
|
408
368
|
}
|
|
409
369
|
this.usedStream = undefined;
|
|
@@ -412,14 +372,15 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
412
372
|
this.recordHandle = undefined;
|
|
413
373
|
this.liveInfo = undefined;
|
|
414
374
|
this.state = "idle";
|
|
415
|
-
this.
|
|
375
|
+
this.cache.set("qualityRetryLeft", this.qualityRetry);
|
|
416
376
|
});
|
|
417
377
|
this.recordHandle = {
|
|
418
378
|
id: genRecordUUID(),
|
|
419
379
|
stream: stream.name,
|
|
420
380
|
source: stream.source,
|
|
381
|
+
recorderType: downloader.type,
|
|
421
382
|
url: stream.url,
|
|
422
|
-
|
|
383
|
+
downloaderArgs,
|
|
423
384
|
savePath: savePath,
|
|
424
385
|
stop,
|
|
425
386
|
cut,
|
|
@@ -437,29 +398,7 @@ export const provider = {
|
|
|
437
398
|
async resolveChannelInfoFromURL(channelURL) {
|
|
438
399
|
if (!this.matchURL(channelURL))
|
|
439
400
|
return null;
|
|
440
|
-
|
|
441
|
-
const res = await requester.get(channelURL);
|
|
442
|
-
const html = res.data;
|
|
443
|
-
const matched = html.match(/\$ROOM\.room_id.?=(.*?);/);
|
|
444
|
-
let roomId = undefined;
|
|
445
|
-
if (matched) {
|
|
446
|
-
roomId = matched[1].trim();
|
|
447
|
-
}
|
|
448
|
-
else {
|
|
449
|
-
// 解析出query中的rid参数
|
|
450
|
-
const rid = new URL(channelURL).searchParams.get("rid");
|
|
451
|
-
if (rid) {
|
|
452
|
-
roomId = rid;
|
|
453
|
-
}
|
|
454
|
-
else {
|
|
455
|
-
// 解析<link rel="canonical" href="xxxxxxx"/>中的href
|
|
456
|
-
const canonicalLink = html.match(/<link rel="canonical" href="(.*?)"/);
|
|
457
|
-
if (canonicalLink) {
|
|
458
|
-
const url = canonicalLink[1];
|
|
459
|
-
roomId = url.split("/").pop();
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
401
|
+
const roomId = await live.parseRoomId(channelURL);
|
|
463
402
|
if (!roomId)
|
|
464
403
|
return null;
|
|
465
404
|
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.10.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"
|