@bililive-tools/douyu-recorder 1.7.0 → 1.8.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 +2 -1
- package/lib/dy_api.d.ts +1 -0
- package/lib/dy_api.js +6 -0
- package/lib/dy_client/index.js +5 -1
- package/lib/index.js +25 -30
- package/lib/stream.d.ts +1 -0
- package/lib/stream.js +14 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,7 +49,8 @@ interface Options {
|
|
|
49
49
|
saveCover?: boolean; // 保存封面
|
|
50
50
|
videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
|
|
51
51
|
onlyAudio?: boolean; // 只录制音频,默认为否
|
|
52
|
-
recorderType?: "auto" | "ffmpeg" | "mesio"; // 底层录制器,使用mesio时videoFormat参数无效
|
|
52
|
+
recorderType?: "auto" | "ffmpeg" | "mesio" | "bililive"; // 底层录制器,使用mesio和bililive时videoFormat参数无效
|
|
53
|
+
debugLevel?: `verbose` | "basic"; // verbose参数时,录制器会输出更加详细的log
|
|
53
54
|
}
|
|
54
55
|
```
|
|
55
56
|
|
package/lib/dy_api.d.ts
CHANGED
package/lib/dy_api.js
CHANGED
|
@@ -46,11 +46,16 @@ 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));
|
|
49
50
|
const streamUrl = `${json.data.rtmp_url}/${json.data.rtmp_live}`;
|
|
50
51
|
let cdn = json.data.rtmp_cdn;
|
|
52
|
+
let onlyAudio = false;
|
|
51
53
|
try {
|
|
52
54
|
const url = new URL(streamUrl);
|
|
53
55
|
cdn = url.searchParams.get("fcdn") ?? "";
|
|
56
|
+
if (url.searchParams.get("only-audio") == "1") {
|
|
57
|
+
onlyAudio = true;
|
|
58
|
+
}
|
|
54
59
|
}
|
|
55
60
|
catch (error) {
|
|
56
61
|
console.warn("解析 rtmp_url 失败", error);
|
|
@@ -62,6 +67,7 @@ export async function getLiveInfo(opts) {
|
|
|
62
67
|
isSupportRateSwitch: json.data.rateSwitch === 1,
|
|
63
68
|
isOriginalStream: json.data.rateSwitch !== 1,
|
|
64
69
|
currentStream: {
|
|
70
|
+
onlyAudio,
|
|
65
71
|
source: cdn,
|
|
66
72
|
name: json.data.rateSwitch !== 1
|
|
67
73
|
? "原画"
|
package/lib/dy_client/index.js
CHANGED
|
@@ -12,7 +12,11 @@ export function createDYClient(channelId, opts = {}) {
|
|
|
12
12
|
let maxRetry = 10;
|
|
13
13
|
let coder = new BufferCoder();
|
|
14
14
|
let heartbeatTimer = null;
|
|
15
|
-
const send = (message) =>
|
|
15
|
+
const send = (message) => {
|
|
16
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
17
|
+
ws?.send(coder.encode(STT.serialize(message)));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
16
20
|
const sendLogin = () => send({ type: "loginreq", roomid: channelId });
|
|
17
21
|
const sendJoinGroup = () => send({ type: "joingroup", rid: channelId, gid: -9999 });
|
|
18
22
|
const sendHeartbeat = () => {
|
package/lib/index.js
CHANGED
|
@@ -67,10 +67,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
67
67
|
// 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
|
|
68
68
|
if (this.recordHandle != null) {
|
|
69
69
|
// 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
|
|
70
|
-
if (
|
|
71
|
-
this.titleKeywords &&
|
|
72
|
-
typeof this.titleKeywords === "string" &&
|
|
73
|
-
this.titleKeywords.trim()) {
|
|
70
|
+
if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
|
|
74
71
|
const now = Date.now();
|
|
75
72
|
// 每5分钟检查一次标题变化
|
|
76
73
|
const titleCheckInterval = 5 * 60 * 1000; // 5分钟
|
|
@@ -86,12 +83,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
86
83
|
const liveInfo = await getInfo(this.channelId);
|
|
87
84
|
const { title } = liveInfo;
|
|
88
85
|
// 检查标题是否包含关键词
|
|
89
|
-
|
|
90
|
-
.
|
|
91
|
-
.map((k) => k.trim())
|
|
92
|
-
.filter((k) => k);
|
|
93
|
-
const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
94
|
-
if (hasTitleKeyword) {
|
|
86
|
+
if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
|
|
87
|
+
this.state = "title-blocked";
|
|
95
88
|
this.emit("DebugLog", {
|
|
96
89
|
type: "common",
|
|
97
90
|
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
@@ -109,6 +102,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
109
102
|
try {
|
|
110
103
|
const liveInfo = await getInfo(this.channelId);
|
|
111
104
|
this.liveInfo = liveInfo;
|
|
105
|
+
this.state = "idle";
|
|
112
106
|
}
|
|
113
107
|
catch (error) {
|
|
114
108
|
this.state = "check-error";
|
|
@@ -127,16 +121,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
127
121
|
return null;
|
|
128
122
|
// 检查标题是否包含关键词,如果包含则不自动录制
|
|
129
123
|
// 手动开始录制时不检查标题关键词
|
|
130
|
-
if (
|
|
131
|
-
this.titleKeywords
|
|
132
|
-
|
|
133
|
-
this.titleKeywords.trim()) {
|
|
134
|
-
const keywords = this.titleKeywords
|
|
135
|
-
.split(",")
|
|
136
|
-
.map((k) => k.trim())
|
|
137
|
-
.filter((k) => k);
|
|
138
|
-
const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
139
|
-
if (hasTitleKeyword) {
|
|
124
|
+
if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
|
|
125
|
+
if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
|
|
126
|
+
this.state = "title-blocked";
|
|
140
127
|
this.emit("DebugLog", {
|
|
141
128
|
type: "common",
|
|
142
129
|
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
@@ -144,7 +131,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
144
131
|
return null;
|
|
145
132
|
}
|
|
146
133
|
}
|
|
147
|
-
let recorderType = this.recorderType === "mesio" ? "mesio" : "ffmpeg";
|
|
148
134
|
let res;
|
|
149
135
|
try {
|
|
150
136
|
let strictQuality = false;
|
|
@@ -163,7 +149,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
163
149
|
source: this.source,
|
|
164
150
|
strictQuality,
|
|
165
151
|
onlyAudio: this.onlyAudio,
|
|
166
|
-
avoidEdgeCDN:
|
|
152
|
+
avoidEdgeCDN: true,
|
|
167
153
|
});
|
|
168
154
|
}
|
|
169
155
|
catch (err) {
|
|
@@ -195,7 +181,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
195
181
|
};
|
|
196
182
|
let isEnded = false;
|
|
197
183
|
let isCutting = false;
|
|
198
|
-
const recorder = createBaseRecorder(recorderType, {
|
|
184
|
+
const recorder = createBaseRecorder(this.recorderType, {
|
|
199
185
|
url: stream.url,
|
|
200
186
|
// @ts-ignore
|
|
201
187
|
outputOptions: ffmpegOutputOptions,
|
|
@@ -203,6 +189,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
203
189
|
getSavePath: (opts) => getSavePath({ owner, title: opts.title ?? title, startTime: opts.startTime }),
|
|
204
190
|
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
205
191
|
videoFormat: this.videoFormat ?? "auto",
|
|
192
|
+
debugLevel: this.debugLevel ?? "none",
|
|
193
|
+
onlyAudio: stream.onlyAudio,
|
|
206
194
|
}, onEnd, async () => {
|
|
207
195
|
const info = await getInfo(this.channelId);
|
|
208
196
|
return info;
|
|
@@ -218,8 +206,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
218
206
|
this.state = "idle";
|
|
219
207
|
throw err;
|
|
220
208
|
}
|
|
221
|
-
const handleVideoCreated = async ({ filename, title, cover }) => {
|
|
222
|
-
this.emit("videoFileCreated", { filename, cover });
|
|
209
|
+
const handleVideoCreated = async ({ filename, title, cover, rawFilename }) => {
|
|
210
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
223
211
|
if (title && this?.liveInfo) {
|
|
224
212
|
this.liveInfo.title = title;
|
|
225
213
|
}
|
|
@@ -458,11 +446,18 @@ export const provider = {
|
|
|
458
446
|
roomId = matched[1].trim();
|
|
459
447
|
}
|
|
460
448
|
else {
|
|
461
|
-
//
|
|
462
|
-
const
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
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
|
+
}
|
|
466
461
|
}
|
|
467
462
|
}
|
|
468
463
|
if (!roomId)
|
package/lib/stream.d.ts
CHANGED
package/lib/stream.js
CHANGED
|
@@ -57,7 +57,20 @@ export async function getStream(opts) {
|
|
|
57
57
|
});
|
|
58
58
|
if (!liveInfo.living)
|
|
59
59
|
throw new Error("It must be called getStream when living");
|
|
60
|
-
|
|
60
|
+
//如果是scdn,那么找到第一个非scdn的源,重新请求一次
|
|
61
|
+
if (liveInfo.currentStream.source === "scdn") {
|
|
62
|
+
const nonScdnSource = liveInfo.sources.find((source) => source.cdn !== "scdnctshh");
|
|
63
|
+
if (nonScdnSource) {
|
|
64
|
+
liveInfo = await getLiveInfo({
|
|
65
|
+
channelId: opts.channelId,
|
|
66
|
+
rate: qn,
|
|
67
|
+
cdn: nonScdnSource?.cdn,
|
|
68
|
+
onlyAudio: opts.onlyAudio,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!liveInfo.living)
|
|
73
|
+
throw new Error("It must be called getStream when living");
|
|
61
74
|
if (liveInfo.currentStream.rate !== qn && opts.strictQuality) {
|
|
62
75
|
throw new Error("Can not get expect quality because of strictQuality");
|
|
63
76
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/douyu-recorder",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "bililive-tools douyu recorder implemention",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"lodash-es": "^4.17.21",
|
|
42
42
|
"axios": "^1.7.8",
|
|
43
43
|
"douyu-api": "^0.1.0",
|
|
44
|
-
"@bililive-tools/manager": "^1.
|
|
44
|
+
"@bililive-tools/manager": "^1.8.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/ws": "^8.5.13"
|