@bililive-tools/douyu-recorder 1.7.1 → 1.9.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 +5 -0
- package/lib/dy_client/index.js +5 -1
- package/lib/index.js +28 -36
- 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
|
@@ -48,9 +48,13 @@ export async function getLiveInfo(opts) {
|
|
|
48
48
|
}
|
|
49
49
|
const streamUrl = `${json.data.rtmp_url}/${json.data.rtmp_live}`;
|
|
50
50
|
let cdn = json.data.rtmp_cdn;
|
|
51
|
+
let onlyAudio = false;
|
|
51
52
|
try {
|
|
52
53
|
const url = new URL(streamUrl);
|
|
53
54
|
cdn = url.searchParams.get("fcdn") ?? "";
|
|
55
|
+
if (url.searchParams.get("only-audio") == "1") {
|
|
56
|
+
onlyAudio = true;
|
|
57
|
+
}
|
|
54
58
|
}
|
|
55
59
|
catch (error) {
|
|
56
60
|
console.warn("解析 rtmp_url 失败", error);
|
|
@@ -62,6 +66,7 @@ export async function getLiveInfo(opts) {
|
|
|
62
66
|
isSupportRateSwitch: json.data.rateSwitch === 1,
|
|
63
67
|
isOriginalStream: json.data.rateSwitch !== 1,
|
|
64
68
|
currentStream: {
|
|
69
|
+
onlyAudio,
|
|
65
70
|
source: cdn,
|
|
66
71
|
name: json.data.rateSwitch !== 1
|
|
67
72
|
? "原画"
|
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
|
@@ -55,22 +55,12 @@ 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
62
|
// 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
|
|
70
|
-
if (
|
|
71
|
-
this.titleKeywords &&
|
|
72
|
-
typeof this.titleKeywords === "string" &&
|
|
73
|
-
this.titleKeywords.trim()) {
|
|
63
|
+
if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
|
|
74
64
|
const now = Date.now();
|
|
75
65
|
// 每5分钟检查一次标题变化
|
|
76
66
|
const titleCheckInterval = 5 * 60 * 1000; // 5分钟
|
|
@@ -86,12 +76,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
86
76
|
const liveInfo = await getInfo(this.channelId);
|
|
87
77
|
const { title } = liveInfo;
|
|
88
78
|
// 检查标题是否包含关键词
|
|
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) {
|
|
79
|
+
if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
|
|
80
|
+
this.state = "title-blocked";
|
|
95
81
|
this.emit("DebugLog", {
|
|
96
82
|
type: "common",
|
|
97
83
|
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
@@ -115,7 +101,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
115
101
|
this.state = "check-error";
|
|
116
102
|
throw error;
|
|
117
103
|
}
|
|
118
|
-
const { living, owner, title } = this.liveInfo;
|
|
104
|
+
const { living, owner, title, startTime } = this.liveInfo;
|
|
119
105
|
if (this.liveInfo.liveId === banLiveId) {
|
|
120
106
|
this.tempStopIntervalCheck = true;
|
|
121
107
|
}
|
|
@@ -128,16 +114,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
128
114
|
return null;
|
|
129
115
|
// 检查标题是否包含关键词,如果包含则不自动录制
|
|
130
116
|
// 手动开始录制时不检查标题关键词
|
|
131
|
-
if (
|
|
132
|
-
this.titleKeywords
|
|
133
|
-
|
|
134
|
-
this.titleKeywords.trim()) {
|
|
135
|
-
const keywords = this.titleKeywords
|
|
136
|
-
.split(",")
|
|
137
|
-
.map((k) => k.trim())
|
|
138
|
-
.filter((k) => k);
|
|
139
|
-
const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
140
|
-
if (hasTitleKeyword) {
|
|
117
|
+
if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
|
|
118
|
+
if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
|
|
119
|
+
this.state = "title-blocked";
|
|
141
120
|
this.emit("DebugLog", {
|
|
142
121
|
type: "common",
|
|
143
122
|
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
@@ -145,7 +124,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
145
124
|
return null;
|
|
146
125
|
}
|
|
147
126
|
}
|
|
148
|
-
let recorderType = this.recorderType === "mesio" ? "mesio" : "ffmpeg";
|
|
149
127
|
let res;
|
|
150
128
|
try {
|
|
151
129
|
let strictQuality = false;
|
|
@@ -164,7 +142,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
164
142
|
source: this.source,
|
|
165
143
|
strictQuality,
|
|
166
144
|
onlyAudio: this.onlyAudio,
|
|
167
|
-
avoidEdgeCDN:
|
|
145
|
+
avoidEdgeCDN: true,
|
|
168
146
|
});
|
|
169
147
|
}
|
|
170
148
|
catch (err) {
|
|
@@ -196,14 +174,23 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
196
174
|
};
|
|
197
175
|
let isEnded = false;
|
|
198
176
|
let isCutting = false;
|
|
199
|
-
const
|
|
177
|
+
const recordStartTime = new Date();
|
|
178
|
+
const recorder = createBaseRecorder(this.recorderType, {
|
|
200
179
|
url: stream.url,
|
|
201
180
|
// @ts-ignore
|
|
202
181
|
outputOptions: ffmpegOutputOptions,
|
|
203
182
|
segment: this.segment ?? 0,
|
|
204
|
-
getSavePath: (opts) => getSavePath({
|
|
183
|
+
getSavePath: (opts) => getSavePath({
|
|
184
|
+
owner,
|
|
185
|
+
title: opts.title ?? title,
|
|
186
|
+
startTime: opts.startTime,
|
|
187
|
+
liveStartTime: startTime,
|
|
188
|
+
recordStartTime,
|
|
189
|
+
}),
|
|
205
190
|
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
206
191
|
videoFormat: this.videoFormat ?? "auto",
|
|
192
|
+
debugLevel: this.debugLevel ?? "none",
|
|
193
|
+
onlyAudio: stream.onlyAudio,
|
|
207
194
|
}, onEnd, async () => {
|
|
208
195
|
const info = await getInfo(this.channelId);
|
|
209
196
|
return info;
|
|
@@ -211,6 +198,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
211
198
|
const savePath = getSavePath({
|
|
212
199
|
owner,
|
|
213
200
|
title,
|
|
201
|
+
startTime: Date.now(),
|
|
202
|
+
liveStartTime: startTime,
|
|
203
|
+
recordStartTime,
|
|
214
204
|
});
|
|
215
205
|
try {
|
|
216
206
|
ensureFolderExist(savePath);
|
|
@@ -219,8 +209,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
219
209
|
this.state = "idle";
|
|
220
210
|
throw err;
|
|
221
211
|
}
|
|
222
|
-
const handleVideoCreated = async ({ filename, title, cover }) => {
|
|
223
|
-
this.emit("videoFileCreated", { filename, cover });
|
|
212
|
+
const handleVideoCreated = async ({ filename, title, cover, rawFilename }) => {
|
|
213
|
+
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
224
214
|
if (title && this?.liveInfo) {
|
|
225
215
|
this.liveInfo.title = title;
|
|
226
216
|
}
|
|
@@ -259,7 +249,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
259
249
|
return;
|
|
260
250
|
switch (msg.type) {
|
|
261
251
|
case "chatmsg": {
|
|
262
|
-
|
|
252
|
+
// 某些情况下cst不存在,可能是其他平台发送的弹幕?
|
|
253
|
+
const timestamp = this.useServerTimestamp && msg.cst ? Number(msg.cst) : Date.now();
|
|
263
254
|
const comment = {
|
|
264
255
|
type: "comment",
|
|
265
256
|
timestamp: timestamp,
|
|
@@ -431,6 +422,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
431
422
|
id: genRecordUUID(),
|
|
432
423
|
stream: stream.name,
|
|
433
424
|
source: stream.source,
|
|
425
|
+
recorderType: recorder.type,
|
|
434
426
|
url: stream.url,
|
|
435
427
|
ffmpegArgs,
|
|
436
428
|
savePath: savePath,
|
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.9.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.9.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/ws": "^8.5.13"
|