@bililive-tools/douyu-recorder 1.15.0 → 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/README.md +12 -0
- package/lib/dy_client/index.d.ts +5 -0
- package/lib/dy_client/index.js +80 -24
- package/lib/index.js +44 -32
- package/lib/stream.d.ts +3 -3
- package/lib/stream.js +20 -44
- package/package.json +3 -6
- package/lib/dy_api.d.ts +0 -102
- package/lib/dy_api.js +0 -117
- package/lib/requester.d.ts +0 -1
- package/lib/requester.js +0 -7
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ interface Options {
|
|
|
40
40
|
streamPriorities: []; // 废弃
|
|
41
41
|
sourcePriorities: []; // 废弃
|
|
42
42
|
disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
|
|
43
|
+
api: "auto" | "newAPI" | "oldAPI"; // 仅有newAPI支持hevc
|
|
44
|
+
codecName?: CodecName; // 见 CodecName 参数
|
|
43
45
|
segment?: number | string; // 分段参数,单位分钟,如果以"B","KB","MB","GB"结尾,会尝试使用文件大小分段,仅推荐在使用mesio录制引擎时使用
|
|
44
46
|
titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
|
|
45
47
|
disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
|
|
@@ -93,6 +95,16 @@ const { id } = await provider.resolveChannelInfoFromURL(url);
|
|
|
93
95
|
| 线路7 | hw-h5 |
|
|
94
96
|
| 线路13 | hs-h5 |
|
|
95
97
|
|
|
98
|
+
### CodecName
|
|
99
|
+
|
|
100
|
+
用于控制使用avc还是hevc
|
|
101
|
+
|
|
102
|
+
| 解释 | 值 |
|
|
103
|
+
| ------------ | ---- |
|
|
104
|
+
| 等于avc | auto |
|
|
105
|
+
| 优先使用avc | avc |
|
|
106
|
+
| 优先使用hevc | hevc |
|
|
107
|
+
|
|
96
108
|
# 协议
|
|
97
109
|
|
|
98
110
|
与原项目保存一致为 LGPL
|
package/lib/dy_client/index.d.ts
CHANGED
|
@@ -115,6 +115,11 @@ type Message$CommChat = Message$CommChatPandora | Message$CommChatVoiceDanmu;
|
|
|
115
115
|
export type Message = Message$Chat | Message$Gift | Message$CommChat | Message$ODFBC | Message$RNDFBC;
|
|
116
116
|
export interface DYClient extends Emitter<{
|
|
117
117
|
message: Message;
|
|
118
|
+
start: undefined;
|
|
119
|
+
reconnect: {
|
|
120
|
+
retryCount: number;
|
|
121
|
+
maxRetry: number;
|
|
122
|
+
};
|
|
118
123
|
error: unknown;
|
|
119
124
|
}> {
|
|
120
125
|
start: () => void;
|
package/lib/dy_client/index.js
CHANGED
|
@@ -7,16 +7,22 @@ import WebSocket from "ws";
|
|
|
7
7
|
import mitt from "mitt";
|
|
8
8
|
import { BufferCoder } from "./buffer_coder.js";
|
|
9
9
|
import { STT } from "./stt.js";
|
|
10
|
+
// 最大重试次数,超过这个次数后将不再尝试重连
|
|
11
|
+
const MAX_RETRY = 10;
|
|
10
12
|
export function createDYClient(channelId, opts = {}) {
|
|
11
13
|
let ws = null;
|
|
12
|
-
let maxRetry =
|
|
14
|
+
let maxRetry = MAX_RETRY;
|
|
13
15
|
let coder = new BufferCoder();
|
|
14
16
|
let heartbeatTimer = null;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
let reconnectTimer = null;
|
|
18
|
+
const sendWithSocket = (socket, message) => {
|
|
19
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
20
|
+
socket.send(coder.encode(STT.serialize(message)));
|
|
18
21
|
}
|
|
19
22
|
};
|
|
23
|
+
const send = (message) => {
|
|
24
|
+
sendWithSocket(ws, message);
|
|
25
|
+
};
|
|
20
26
|
const sendLogin = () => send({ type: "loginreq", roomid: channelId });
|
|
21
27
|
const sendJoinGroup = () => send({ type: "joingroup", rid: channelId, gid: -9999 });
|
|
22
28
|
const sendHeartbeat = () => {
|
|
@@ -25,41 +31,87 @@ export function createDYClient(channelId, opts = {}) {
|
|
|
25
31
|
}
|
|
26
32
|
send({ type: "mrkl" });
|
|
27
33
|
};
|
|
28
|
-
const sendLogout = () =>
|
|
29
|
-
const
|
|
30
|
-
sendLogin();
|
|
31
|
-
sendJoinGroup();
|
|
32
|
-
heartbeatTimer = setInterval(sendHeartbeat, 45e3);
|
|
33
|
-
};
|
|
34
|
-
const onClose = () => {
|
|
35
|
-
sendLogout();
|
|
34
|
+
const sendLogout = (socket = ws) => sendWithSocket(socket, { type: "logout" });
|
|
35
|
+
const clearHeartbeat = () => {
|
|
36
36
|
if (heartbeatTimer) {
|
|
37
|
-
// @ts-ignore
|
|
38
37
|
clearInterval(heartbeatTimer);
|
|
39
38
|
heartbeatTimer = null;
|
|
40
39
|
}
|
|
41
40
|
};
|
|
42
|
-
const
|
|
41
|
+
const clearReconnect = () => {
|
|
42
|
+
if (reconnectTimer) {
|
|
43
|
+
clearTimeout(reconnectTimer);
|
|
44
|
+
reconnectTimer = null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const scheduleReconnect = (_err) => {
|
|
48
|
+
if (reconnectTimer) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
43
51
|
if (maxRetry > 0) {
|
|
44
52
|
maxRetry -= 1;
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
reconnectTimer = setTimeout(() => {
|
|
54
|
+
reconnectTimer = null;
|
|
47
55
|
start();
|
|
56
|
+
client.emit("reconnect", { retryCount: MAX_RETRY - maxRetry, maxRetry: MAX_RETRY });
|
|
48
57
|
}, 3e3);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
client.emit("error", new Error("重连次数过多,停止重连"));
|
|
61
|
+
};
|
|
62
|
+
const cleanupSocket = (socket = ws) => {
|
|
63
|
+
clearHeartbeat();
|
|
64
|
+
if (socket === ws) {
|
|
65
|
+
ws = null;
|
|
49
66
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
};
|
|
68
|
+
const disconnect = (err, opts) => {
|
|
69
|
+
const currentWs = ws;
|
|
70
|
+
if (!currentWs) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
cleanupSocket(currentWs);
|
|
74
|
+
currentWs.off("open", onOpen);
|
|
75
|
+
currentWs.off("error", onError);
|
|
76
|
+
currentWs.off("close", onClose);
|
|
77
|
+
if (opts.shouldSendLogout) {
|
|
78
|
+
sendLogout(currentWs);
|
|
79
|
+
}
|
|
80
|
+
if (opts.shouldCloseSocket &&
|
|
81
|
+
(currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) {
|
|
82
|
+
currentWs.close();
|
|
83
|
+
}
|
|
84
|
+
if (opts.shouldReconnect) {
|
|
85
|
+
scheduleReconnect(err);
|
|
53
86
|
}
|
|
54
87
|
};
|
|
88
|
+
const onOpen = () => {
|
|
89
|
+
sendLogin();
|
|
90
|
+
sendJoinGroup();
|
|
91
|
+
clearReconnect();
|
|
92
|
+
heartbeatTimer = setInterval(sendHeartbeat, 45e3);
|
|
93
|
+
client.emit("start");
|
|
94
|
+
};
|
|
95
|
+
const onClose = () => {
|
|
96
|
+
disconnect(new Error("斗鱼弹幕连接已关闭"), {
|
|
97
|
+
shouldReconnect: true,
|
|
98
|
+
shouldCloseSocket: false,
|
|
99
|
+
shouldSendLogout: false,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const onError = (err) => {
|
|
103
|
+
disconnect(err, {
|
|
104
|
+
shouldReconnect: true,
|
|
105
|
+
shouldCloseSocket: true,
|
|
106
|
+
shouldSendLogout: false,
|
|
107
|
+
});
|
|
108
|
+
};
|
|
55
109
|
const onMessage = (message) => {
|
|
56
110
|
if (typeof message != "object" || message == null || !("type" in message)) {
|
|
57
111
|
console.warn("Unexpected message format", { message });
|
|
58
112
|
return;
|
|
59
113
|
}
|
|
60
|
-
client.emit("message",
|
|
61
|
-
// TODO: 不太好验证 schema,先强制转了
|
|
62
|
-
message);
|
|
114
|
+
client.emit("message", message);
|
|
63
115
|
};
|
|
64
116
|
const start = () => {
|
|
65
117
|
if (ws != null)
|
|
@@ -91,10 +143,14 @@ export function createDYClient(channelId, opts = {}) {
|
|
|
91
143
|
});
|
|
92
144
|
};
|
|
93
145
|
const stop = () => {
|
|
146
|
+
clearReconnect();
|
|
94
147
|
if (ws == null)
|
|
95
148
|
return;
|
|
96
|
-
|
|
97
|
-
|
|
149
|
+
disconnect(new Error("手动停止斗鱼弹幕连接"), {
|
|
150
|
+
shouldReconnect: false,
|
|
151
|
+
shouldCloseSocket: true,
|
|
152
|
+
shouldSendLogout: true,
|
|
153
|
+
});
|
|
98
154
|
};
|
|
99
155
|
if (!opts.notAutoStart) {
|
|
100
156
|
start();
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import mitt from "mitt";
|
|
2
2
|
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createDownloader, } from "@bililive-tools/manager";
|
|
3
|
-
import {
|
|
3
|
+
import { DouyuParser } from "@bililive-tools/stream-get";
|
|
4
4
|
import { getInfo, getStream } from "./stream.js";
|
|
5
|
-
import { getRoomInfo } from "./dy_api.js";
|
|
6
|
-
import { ensureFolderExist } from "./utils.js";
|
|
7
5
|
import { createDYClient } from "./dy_client/index.js";
|
|
8
6
|
import { giftMap, colorTab } from "./danma.js";
|
|
9
7
|
function createRecorder(opts) {
|
|
@@ -16,11 +14,13 @@ function createRecorder(opts) {
|
|
|
16
14
|
...mitt(),
|
|
17
15
|
...opts,
|
|
18
16
|
cache: null,
|
|
17
|
+
appendTimeline: null,
|
|
19
18
|
availableStreams: [],
|
|
20
19
|
availableSources: [],
|
|
21
20
|
qualityRetry: opts.qualityRetry ?? 0,
|
|
22
21
|
useServerTimestamp: opts.useServerTimestamp ?? true,
|
|
23
22
|
state: "idle",
|
|
23
|
+
codecName: opts.codecName ?? "auto",
|
|
24
24
|
getChannelURL() {
|
|
25
25
|
return `https://www.douyu.com/${this.channelId}`;
|
|
26
26
|
},
|
|
@@ -40,6 +40,7 @@ function createRecorder(opts) {
|
|
|
40
40
|
const res = await getStream({
|
|
41
41
|
channelId: this.channelId,
|
|
42
42
|
quality: this.quality,
|
|
43
|
+
codecName: this.codecName,
|
|
43
44
|
});
|
|
44
45
|
return res.currentStream;
|
|
45
46
|
},
|
|
@@ -70,10 +71,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
70
71
|
try {
|
|
71
72
|
const liveInfo = await getInfo(this.channelId);
|
|
72
73
|
this.liveInfo = liveInfo;
|
|
73
|
-
this.state
|
|
74
|
+
this.emit("stateChange", { state: "idle" });
|
|
74
75
|
}
|
|
75
76
|
catch (error) {
|
|
76
|
-
this.
|
|
77
|
+
this.emit("stateChange", {
|
|
78
|
+
state: "check-error",
|
|
79
|
+
msg: `检查失败,` + (error instanceof Error ? error.message : String(error)),
|
|
80
|
+
});
|
|
77
81
|
throw error;
|
|
78
82
|
}
|
|
79
83
|
const { living, owner, title, liveStartTime, recordStartTime } = this.liveInfo;
|
|
@@ -88,6 +92,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
88
92
|
if (!living)
|
|
89
93
|
return null;
|
|
90
94
|
// 检查标题是否包含关键词
|
|
95
|
+
console.log("检查标题关键词", { title, channelId: this.channelId, isManualStart });
|
|
91
96
|
if (utils.checkTitleKeywordsBeforeRecord(title, this, isManualStart))
|
|
92
97
|
return null;
|
|
93
98
|
const qualityRetryLeft = (await this.cache.get("qualityRetryLeft")) ?? this.qualityRetry;
|
|
@@ -101,15 +106,20 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
101
106
|
strictQuality,
|
|
102
107
|
onlyAudio: this.onlyAudio,
|
|
103
108
|
avoidEdgeCDN: true,
|
|
109
|
+
codecName: this.codecName,
|
|
110
|
+
api: this.api,
|
|
104
111
|
});
|
|
105
112
|
}
|
|
106
113
|
catch (err) {
|
|
107
114
|
if (qualityRetryLeft > 0)
|
|
108
115
|
await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
|
|
109
|
-
this.
|
|
116
|
+
this.emit("stateChange", {
|
|
117
|
+
state: "check-error",
|
|
118
|
+
msg: `检查失败,` + (err instanceof Error ? err.message : String(err)),
|
|
119
|
+
});
|
|
110
120
|
throw err;
|
|
111
121
|
}
|
|
112
|
-
this.state
|
|
122
|
+
this.emit("stateChange", { state: "recording" });
|
|
113
123
|
const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
|
|
114
124
|
this.availableStreams = availableStreams.map((s) => s.name);
|
|
115
125
|
this.availableSources = availableSources.map((s) => s.name);
|
|
@@ -138,29 +148,17 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
138
148
|
startTime: opts.startTime,
|
|
139
149
|
liveStartTime,
|
|
140
150
|
recordStartTime,
|
|
151
|
+
extraMs: opts.extraMs,
|
|
141
152
|
}),
|
|
142
153
|
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
143
154
|
videoFormat: this.videoFormat ?? "auto",
|
|
144
155
|
debugLevel: this.debugLevel ?? "none",
|
|
145
156
|
onlyAudio: stream.onlyAudio,
|
|
157
|
+
proxy: this.proxy,
|
|
146
158
|
}, onEnd, async () => {
|
|
147
159
|
const info = await getInfo(this.channelId);
|
|
148
160
|
return info;
|
|
149
161
|
});
|
|
150
|
-
const savePath = getSavePath({
|
|
151
|
-
owner,
|
|
152
|
-
title,
|
|
153
|
-
startTime: Date.now(),
|
|
154
|
-
liveStartTime,
|
|
155
|
-
recordStartTime,
|
|
156
|
-
});
|
|
157
|
-
try {
|
|
158
|
-
ensureFolderExist(savePath);
|
|
159
|
-
}
|
|
160
|
-
catch (err) {
|
|
161
|
-
this.state = "idle";
|
|
162
|
-
throw err;
|
|
163
|
-
}
|
|
164
162
|
const handleVideoCreated = async ({ filename, title, cover, rawFilename }) => {
|
|
165
163
|
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
166
164
|
if (title && this?.liveInfo) {
|
|
@@ -180,8 +178,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
180
178
|
});
|
|
181
179
|
};
|
|
182
180
|
downloader.on("videoFileCreated", handleVideoCreated);
|
|
183
|
-
downloader.on("videoFileCompleted", (
|
|
184
|
-
this.emit("videoFileCompleted",
|
|
181
|
+
downloader.on("videoFileCompleted", (data) => {
|
|
182
|
+
this.emit("videoFileCompleted", data);
|
|
185
183
|
});
|
|
186
184
|
downloader.on("DebugLog", (data) => {
|
|
187
185
|
this.emit("DebugLog", data);
|
|
@@ -330,14 +328,27 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
330
328
|
}
|
|
331
329
|
});
|
|
332
330
|
client.on("error", (err) => {
|
|
331
|
+
this.appendTimeline({ text: `弹幕连接发生错误: ${String(err)}` });
|
|
333
332
|
this.emit("DebugLog", { type: "common", text: String(err) });
|
|
334
333
|
});
|
|
334
|
+
client.on("start", () => {
|
|
335
|
+
this.appendTimeline({ text: "弹幕连接已建立" });
|
|
336
|
+
this.emit("DebugLog", { type: "common", text: "弹幕连接已建立" });
|
|
337
|
+
});
|
|
338
|
+
client.on("reconnect", ({ retryCount, maxRetry }) => {
|
|
339
|
+
this.appendTimeline({
|
|
340
|
+
text: `弹幕连接已断开,正在尝试重连... (重试次数: ${retryCount}/${maxRetry})`,
|
|
341
|
+
});
|
|
342
|
+
this.emit("DebugLog", {
|
|
343
|
+
type: "common",
|
|
344
|
+
text: `弹幕连接已断开,正在尝试重连... (重试次数: ${retryCount}/${maxRetry})`,
|
|
345
|
+
});
|
|
346
|
+
});
|
|
335
347
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
336
348
|
client.start();
|
|
337
349
|
}
|
|
338
350
|
const downloaderArgs = downloader.getArguments();
|
|
339
351
|
downloader.run();
|
|
340
|
-
// TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
|
|
341
352
|
const cut = utils.singleton(async () => {
|
|
342
353
|
if (!this.recordHandle)
|
|
343
354
|
return;
|
|
@@ -346,7 +357,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
346
357
|
const stop = utils.singleton(async (reason) => {
|
|
347
358
|
if (!this.recordHandle)
|
|
348
359
|
return;
|
|
349
|
-
this.state
|
|
360
|
+
this.emit("stateChange", { state: "stopping-record" });
|
|
350
361
|
try {
|
|
351
362
|
client.stop();
|
|
352
363
|
await downloader.stop();
|
|
@@ -362,7 +373,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
362
373
|
this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
|
|
363
374
|
this.recordHandle = undefined;
|
|
364
375
|
this.liveInfo = undefined;
|
|
365
|
-
this.state
|
|
376
|
+
this.emit("stateChange", { state: "idle" });
|
|
366
377
|
this.cache.set("qualityRetryLeft", this.qualityRetry);
|
|
367
378
|
});
|
|
368
379
|
this.recordHandle = {
|
|
@@ -372,7 +383,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
372
383
|
recorderType: downloader.type,
|
|
373
384
|
url: stream.url,
|
|
374
385
|
downloaderArgs,
|
|
375
|
-
savePath:
|
|
386
|
+
savePath: downloader.videoFilePath,
|
|
376
387
|
stop,
|
|
377
388
|
cut,
|
|
378
389
|
};
|
|
@@ -389,15 +400,16 @@ export const provider = {
|
|
|
389
400
|
async resolveChannelInfoFromURL(channelURL) {
|
|
390
401
|
if (!this.matchURL(channelURL))
|
|
391
402
|
return null;
|
|
392
|
-
const
|
|
403
|
+
const parser = new DouyuParser();
|
|
404
|
+
const roomId = await parser.extractRoomId(channelURL);
|
|
393
405
|
if (!roomId)
|
|
394
406
|
return null;
|
|
395
|
-
const roomInfo = await getRoomInfo(
|
|
407
|
+
const roomInfo = await parser.getRoomInfo(roomId);
|
|
396
408
|
return {
|
|
397
409
|
id: roomId,
|
|
398
|
-
title: roomInfo.
|
|
399
|
-
owner: roomInfo.
|
|
400
|
-
avatar: roomInfo.
|
|
410
|
+
title: roomInfo.title,
|
|
411
|
+
owner: roomInfo.owner,
|
|
412
|
+
avatar: roomInfo.avatar,
|
|
401
413
|
};
|
|
402
414
|
},
|
|
403
415
|
createRecorder(opts) {
|
package/lib/stream.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare function getInfo(channelId: string): Promise<{
|
|
|
10
10
|
recordStartTime: Date;
|
|
11
11
|
area: string;
|
|
12
12
|
}>;
|
|
13
|
-
export declare function getStream(opts: Pick<Recorder, "channelId" | "quality"> & {
|
|
13
|
+
export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" | "api" | "codecName"> & {
|
|
14
14
|
rejectCache?: boolean;
|
|
15
15
|
strictQuality?: boolean;
|
|
16
16
|
source?: string;
|
|
@@ -18,8 +18,8 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality">
|
|
|
18
18
|
avoidEdgeCDN?: boolean;
|
|
19
19
|
}): Promise<{
|
|
20
20
|
living: true;
|
|
21
|
-
sources: import("
|
|
22
|
-
streams: import("
|
|
21
|
+
sources: import("@bililive-tools/stream-get/douyu/types.js").SourceProfile[];
|
|
22
|
+
streams: import("@bililive-tools/stream-get/douyu/types.js").StreamProfile[];
|
|
23
23
|
isSupportRateSwitch: boolean;
|
|
24
24
|
isOriginalStream: boolean;
|
|
25
25
|
currentStream: {
|
package/lib/stream.js
CHANGED
|
@@ -1,48 +1,20 @@
|
|
|
1
|
-
import { live } from "douyu-api";
|
|
2
1
|
import { DouyuQualities, utils } from "@bililive-tools/manager";
|
|
3
|
-
import {
|
|
4
|
-
import { requester } from "./requester.js";
|
|
2
|
+
import { DouyuParser } from "@bililive-tools/stream-get";
|
|
5
3
|
export async function getInfo(channelId) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
throw new Error("错误的地址 " + channelId);
|
|
10
|
-
}
|
|
11
|
-
throw new Error(`Unexpected status code, ${res.status}, ${res.data}`);
|
|
12
|
-
}
|
|
13
|
-
if (typeof res.data !== "object")
|
|
14
|
-
throw new Error(`Unexpected response, ${res.status}, ${res.data}`);
|
|
15
|
-
const json = res.data;
|
|
16
|
-
if (json.error === 101)
|
|
17
|
-
throw new Error("错误的地址 " + channelId);
|
|
18
|
-
if (json.error !== 0)
|
|
19
|
-
throw new Error("Unexpected error code, " + json.error);
|
|
20
|
-
let living = json.data.room_status === "1";
|
|
21
|
-
const data = await live.getRoomInfo(Number(channelId));
|
|
22
|
-
if (living) {
|
|
23
|
-
const isVideoLoop = data.room.videoLoop === 1;
|
|
24
|
-
if (isVideoLoop) {
|
|
25
|
-
living = false;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
const startTime = new Date(data.room.show_time * 1000);
|
|
4
|
+
const parser = new DouyuParser();
|
|
5
|
+
const data = await parser.getRoomInfo(channelId);
|
|
6
|
+
const startTime = data.liveStartTime || new Date();
|
|
29
7
|
const recordStartTime = new Date();
|
|
30
8
|
return {
|
|
31
|
-
living,
|
|
32
|
-
owner: data.
|
|
33
|
-
title: data.
|
|
34
|
-
avatar: data.
|
|
35
|
-
cover: data.
|
|
9
|
+
living: data.living,
|
|
10
|
+
owner: data.owner,
|
|
11
|
+
title: data.title,
|
|
12
|
+
avatar: data.avatar,
|
|
13
|
+
cover: data.cover,
|
|
36
14
|
liveStartTime: startTime,
|
|
37
15
|
liveId: utils.md5(`${channelId}-${startTime?.getTime() ?? Date.now()}`),
|
|
38
16
|
recordStartTime: recordStartTime,
|
|
39
|
-
area: data.
|
|
40
|
-
// gifts: data.gift.map((g) => ({
|
|
41
|
-
// id: g.id,
|
|
42
|
-
// name: g.name,
|
|
43
|
-
// img: g.himg,
|
|
44
|
-
// cost: g.pc,
|
|
45
|
-
// })),
|
|
17
|
+
area: data.area || "",
|
|
46
18
|
};
|
|
47
19
|
}
|
|
48
20
|
export async function getStream(opts) {
|
|
@@ -51,11 +23,15 @@ export async function getStream(opts) {
|
|
|
51
23
|
if (opts.source === "auto" && opts.avoidEdgeCDN) {
|
|
52
24
|
cdn = "hw-h5";
|
|
53
25
|
}
|
|
54
|
-
|
|
55
|
-
|
|
26
|
+
const parser = new DouyuParser();
|
|
27
|
+
const shouldHevc = opts.codecName === "hevc";
|
|
28
|
+
const isOldApi = opts.api === "old";
|
|
29
|
+
let liveInfo = await parser.getLiveInfo(opts.channelId, {
|
|
56
30
|
rate: qn,
|
|
57
31
|
cdn,
|
|
58
32
|
onlyAudio: opts.onlyAudio,
|
|
33
|
+
hevc: shouldHevc,
|
|
34
|
+
oldApi: isOldApi,
|
|
59
35
|
});
|
|
60
36
|
if (!liveInfo.living)
|
|
61
37
|
throw new Error("It must be called getStream when living");
|
|
@@ -63,11 +39,11 @@ export async function getStream(opts) {
|
|
|
63
39
|
if (liveInfo.currentStream.source === "scdn") {
|
|
64
40
|
const nonScdnSource = liveInfo.sources.find((source) => source.cdn !== "scdnctshh");
|
|
65
41
|
if (nonScdnSource) {
|
|
66
|
-
liveInfo = await getLiveInfo({
|
|
67
|
-
channelId: opts.channelId,
|
|
42
|
+
liveInfo = await parser.getLiveInfo(opts.channelId, {
|
|
68
43
|
rate: qn,
|
|
69
44
|
cdn: nonScdnSource?.cdn,
|
|
70
45
|
onlyAudio: opts.onlyAudio,
|
|
46
|
+
hevc: shouldHevc,
|
|
71
47
|
});
|
|
72
48
|
}
|
|
73
49
|
}
|
|
@@ -86,10 +62,10 @@ export async function getStream(opts) {
|
|
|
86
62
|
throw new Error("Can not get expect quality because of no available stream");
|
|
87
63
|
}
|
|
88
64
|
else {
|
|
89
|
-
liveInfo = await getLiveInfo({
|
|
90
|
-
channelId: opts.channelId,
|
|
65
|
+
liveInfo = await parser.getLiveInfo(opts.channelId, {
|
|
91
66
|
rate: liveInfo.streams[0].rate,
|
|
92
67
|
onlyAudio: opts.onlyAudio,
|
|
68
|
+
hevc: shouldHevc,
|
|
93
69
|
});
|
|
94
70
|
if (!liveInfo.living)
|
|
95
71
|
throw new Error("It must be called getStream when living");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/douyu-recorder",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "bililive-tools douyu recorder implemention",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -35,13 +35,10 @@
|
|
|
35
35
|
"license": "LGPL",
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"mitt": "^3.0.1",
|
|
38
|
-
"query-string": "^9.1.1",
|
|
39
|
-
"safe-eval": "^0.4.1",
|
|
40
38
|
"ws": "^8.18.0",
|
|
41
39
|
"lodash-es": "^4.17.21",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"@bililive-tools/manager": "^1.14.1"
|
|
40
|
+
"@bililive-tools/manager": "^1.17.0",
|
|
41
|
+
"@bililive-tools/stream-get": "0.2.0"
|
|
45
42
|
},
|
|
46
43
|
"devDependencies": {
|
|
47
44
|
"@types/ws": "^8.5.13"
|
package/lib/dy_api.d.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 对斗鱼 getH5Play 接口的封装
|
|
3
|
-
*/
|
|
4
|
-
export declare function getLiveInfo(opts: {
|
|
5
|
-
channelId: string;
|
|
6
|
-
cdn?: string;
|
|
7
|
-
rate?: number;
|
|
8
|
-
rejectSignFnCache?: boolean;
|
|
9
|
-
onlyAudio?: boolean;
|
|
10
|
-
}): Promise<{
|
|
11
|
-
living: false;
|
|
12
|
-
} | {
|
|
13
|
-
living: true;
|
|
14
|
-
sources: GetH5PlaySuccessData["cdnsWithName"];
|
|
15
|
-
streams: GetH5PlaySuccessData["multirates"];
|
|
16
|
-
isSupportRateSwitch: boolean;
|
|
17
|
-
isOriginalStream: boolean;
|
|
18
|
-
currentStream: {
|
|
19
|
-
onlyAudio: boolean;
|
|
20
|
-
source: string;
|
|
21
|
-
name: string;
|
|
22
|
-
rate: number;
|
|
23
|
-
url: string;
|
|
24
|
-
};
|
|
25
|
-
}>;
|
|
26
|
-
/**
|
|
27
|
-
* 获取直播间相关信息
|
|
28
|
-
*/
|
|
29
|
-
export declare function getRoomInfo(roomId: number): Promise<{
|
|
30
|
-
room: {
|
|
31
|
-
/** 主播id */
|
|
32
|
-
up_id: string;
|
|
33
|
-
/** 主播昵称 */
|
|
34
|
-
nickname: string;
|
|
35
|
-
/** 主播头像 */
|
|
36
|
-
avatar: {
|
|
37
|
-
big: string;
|
|
38
|
-
middle: string;
|
|
39
|
-
small: string;
|
|
40
|
-
};
|
|
41
|
-
/** 直播间标题 */
|
|
42
|
-
room_name: string;
|
|
43
|
-
/** 直播间封面 */
|
|
44
|
-
room_pic: string;
|
|
45
|
-
/** 直播间号 */
|
|
46
|
-
room_id: number;
|
|
47
|
-
/** 直播状态,1是正在直播 */
|
|
48
|
-
status: "1" | string;
|
|
49
|
-
/** 轮播:1是正在轮播 */
|
|
50
|
-
videoLoop: 1 | number;
|
|
51
|
-
/** 开播时间,秒时间戳 */
|
|
52
|
-
show_time: number;
|
|
53
|
-
[key: string]: any;
|
|
54
|
-
};
|
|
55
|
-
[key: string]: any;
|
|
56
|
-
}>;
|
|
57
|
-
export interface SourceProfile {
|
|
58
|
-
name: string;
|
|
59
|
-
cdn: string;
|
|
60
|
-
isH265: true;
|
|
61
|
-
}
|
|
62
|
-
export interface StreamProfile {
|
|
63
|
-
name: string;
|
|
64
|
-
rate: number;
|
|
65
|
-
highBit: number;
|
|
66
|
-
bit: number;
|
|
67
|
-
diamondFan: number;
|
|
68
|
-
}
|
|
69
|
-
interface GetH5PlaySuccessData {
|
|
70
|
-
room_id: number;
|
|
71
|
-
is_mixed: false;
|
|
72
|
-
mixed_live: string;
|
|
73
|
-
mixed_url: string;
|
|
74
|
-
rtmp_cdn: string;
|
|
75
|
-
rtmp_url: string;
|
|
76
|
-
rtmp_live: string;
|
|
77
|
-
client_ip: string;
|
|
78
|
-
inNA: number;
|
|
79
|
-
rateSwitch: number;
|
|
80
|
-
rate: number;
|
|
81
|
-
cdnsWithName: SourceProfile[];
|
|
82
|
-
multirates: StreamProfile[];
|
|
83
|
-
isPassPlayer: number;
|
|
84
|
-
eticket: null;
|
|
85
|
-
online: number;
|
|
86
|
-
mixedCDN: string;
|
|
87
|
-
p2p: number;
|
|
88
|
-
streamStatus: number;
|
|
89
|
-
smt: number;
|
|
90
|
-
p2pMeta: unknown;
|
|
91
|
-
p2pCid: number;
|
|
92
|
-
p2pCids: string;
|
|
93
|
-
player_1: string;
|
|
94
|
-
h265_p2p: number;
|
|
95
|
-
h265_p2p_cid: number;
|
|
96
|
-
h265_p2p_cids: string;
|
|
97
|
-
acdn: string;
|
|
98
|
-
av1_url: string;
|
|
99
|
-
rtc_stream_url: string;
|
|
100
|
-
rtc_stream_config: string;
|
|
101
|
-
}
|
|
102
|
-
export {};
|
package/lib/dy_api.js
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import safeEval from "safe-eval";
|
|
3
|
-
import { uuid } from "./utils.js";
|
|
4
|
-
import queryString from "query-string";
|
|
5
|
-
import { requester } from "./requester.js";
|
|
6
|
-
/**
|
|
7
|
-
* 对斗鱼 getH5Play 接口的封装
|
|
8
|
-
*/
|
|
9
|
-
export async function getLiveInfo(opts) {
|
|
10
|
-
const sign = await getSignFn(opts.channelId, opts.rejectSignFnCache);
|
|
11
|
-
const did = uuid().replace(/-/g, "");
|
|
12
|
-
const time = Math.ceil(Date.now() / 1000);
|
|
13
|
-
const signedStr = String(sign(opts.channelId, did, time));
|
|
14
|
-
// TODO: 这里类型处理的有点问题,先用 as 顶着
|
|
15
|
-
// @ts-ignore
|
|
16
|
-
const signed = queryString.parse(signedStr);
|
|
17
|
-
// TODO: 以后可以试试换成 https://open.douyu.com/source/api/9 里提供的公开接口,
|
|
18
|
-
// 不过公开接口可能会存在最高码率的限制。
|
|
19
|
-
const res = await requester.post(`https://www.douyu.com/lapi/live/getH5Play/${opts.channelId}`, new URLSearchParams({
|
|
20
|
-
...signed,
|
|
21
|
-
cdn: opts.cdn ?? "",
|
|
22
|
-
// 相当于清晰度类型的 id,给 -1 会由后端决定,0为原画
|
|
23
|
-
rate: String(opts.rate ?? 0),
|
|
24
|
-
// 是否只录制音频
|
|
25
|
-
fa: opts.onlyAudio ? "1" : "0",
|
|
26
|
-
}));
|
|
27
|
-
if (res.status !== 200) {
|
|
28
|
-
if (res.status === 403 && res.data === "鉴权失败" && !opts.rejectSignFnCache) {
|
|
29
|
-
// 使用非缓存的sign函数再次签名
|
|
30
|
-
return getLiveInfo({ ...opts, rejectSignFnCache: true });
|
|
31
|
-
}
|
|
32
|
-
throw new Error(`Unexpected status code, ${res.status}, ${res.data}`);
|
|
33
|
-
}
|
|
34
|
-
// TODO: assert data not string
|
|
35
|
-
if (typeof res.data === "string")
|
|
36
|
-
throw new Error();
|
|
37
|
-
const json = res.data;
|
|
38
|
-
// 不存在的房间、已被封禁、未开播
|
|
39
|
-
if ([-3, -4, -5].includes(json.error))
|
|
40
|
-
return { living: false };
|
|
41
|
-
// 其他
|
|
42
|
-
if (json.error !== 0) {
|
|
43
|
-
// 时间戳错误,目前不确定原因,但重新获取几次 sign 函数可解决。
|
|
44
|
-
// TODO: 这里与 getSignFn 隐式的耦合了
|
|
45
|
-
if (json.error === -9)
|
|
46
|
-
delete signCaches[opts.channelId];
|
|
47
|
-
throw new Error("Unexpected error code, " + json.error);
|
|
48
|
-
}
|
|
49
|
-
const streamUrl = `${json.data.rtmp_url}/${json.data.rtmp_live}`;
|
|
50
|
-
let cdn = json.data.rtmp_cdn;
|
|
51
|
-
let onlyAudio = false;
|
|
52
|
-
try {
|
|
53
|
-
const url = new URL(streamUrl);
|
|
54
|
-
cdn = url.searchParams.get("fcdn") ?? "";
|
|
55
|
-
if (url.searchParams.get("only-audio") == "1") {
|
|
56
|
-
onlyAudio = true;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
console.warn("解析 rtmp_url 失败", error);
|
|
61
|
-
}
|
|
62
|
-
return {
|
|
63
|
-
living: true,
|
|
64
|
-
sources: json.data.cdnsWithName,
|
|
65
|
-
streams: json.data.multirates,
|
|
66
|
-
isSupportRateSwitch: json.data.rateSwitch === 1,
|
|
67
|
-
isOriginalStream: json.data.rateSwitch !== 1,
|
|
68
|
-
currentStream: {
|
|
69
|
-
onlyAudio,
|
|
70
|
-
source: cdn,
|
|
71
|
-
name: json.data.rateSwitch !== 1
|
|
72
|
-
? "原画"
|
|
73
|
-
: (json.data.multirates.find(({ rate }) => rate === json.data.rate)?.name ?? "未知"),
|
|
74
|
-
rate: json.data.rate,
|
|
75
|
-
url: streamUrl,
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
// 斗鱼为了判断是否是浏览器环境,会在 sign 过程中去验证一些 window / document 上的函数
|
|
80
|
-
// 是否是 native 的,这里利用 proxy 来模拟。
|
|
81
|
-
const disguisedNativeMethods = new Proxy({}, {
|
|
82
|
-
get: function () {
|
|
83
|
-
return "function () { [native code] }";
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
const signCaches = {};
|
|
87
|
-
async function getSignFn(address, rejectCache) {
|
|
88
|
-
if (!rejectCache && Object.hasOwn(signCaches, address)) {
|
|
89
|
-
// 有缓存, 直接使用
|
|
90
|
-
return signCaches[address];
|
|
91
|
-
}
|
|
92
|
-
const res = await requester.get("https://www.douyu.com/swf_api/homeH5Enc?rids=" + address);
|
|
93
|
-
const json = res.data;
|
|
94
|
-
if (json.error !== 0)
|
|
95
|
-
throw new Error("Unexpected error code, " + json.error);
|
|
96
|
-
const code = json.data && json.data["room" + address];
|
|
97
|
-
if (!code)
|
|
98
|
-
throw new Error("Unexpected result with homeH5Enc, " + JSON.stringify(json));
|
|
99
|
-
const sign = safeEval(`(function func(a,b,c){${code};return ub98484234(a,b,c)})`, {
|
|
100
|
-
CryptoJS: {
|
|
101
|
-
MD5: (str) => {
|
|
102
|
-
return crypto.createHash("md5").update(str).digest("hex");
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
window: disguisedNativeMethods,
|
|
106
|
-
document: disguisedNativeMethods,
|
|
107
|
-
});
|
|
108
|
-
signCaches[address] = sign;
|
|
109
|
-
return sign;
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* 获取直播间相关信息
|
|
113
|
-
*/
|
|
114
|
-
export async function getRoomInfo(roomId) {
|
|
115
|
-
const response = await requester.get(`https://www.douyu.com/betard/${roomId}`);
|
|
116
|
-
return response.data;
|
|
117
|
-
}
|
package/lib/requester.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const requester: import("axios").AxiosInstance;
|
package/lib/requester.js
DELETED