@bililive-tools/douyu-recorder 1.16.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 +42 -16
- 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,8 +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
5
|
import { createDYClient } from "./dy_client/index.js";
|
|
7
6
|
import { giftMap, colorTab } from "./danma.js";
|
|
8
7
|
function createRecorder(opts) {
|
|
@@ -15,11 +14,13 @@ function createRecorder(opts) {
|
|
|
15
14
|
...mitt(),
|
|
16
15
|
...opts,
|
|
17
16
|
cache: null,
|
|
17
|
+
appendTimeline: null,
|
|
18
18
|
availableStreams: [],
|
|
19
19
|
availableSources: [],
|
|
20
20
|
qualityRetry: opts.qualityRetry ?? 0,
|
|
21
21
|
useServerTimestamp: opts.useServerTimestamp ?? true,
|
|
22
22
|
state: "idle",
|
|
23
|
+
codecName: opts.codecName ?? "auto",
|
|
23
24
|
getChannelURL() {
|
|
24
25
|
return `https://www.douyu.com/${this.channelId}`;
|
|
25
26
|
},
|
|
@@ -39,6 +40,7 @@ function createRecorder(opts) {
|
|
|
39
40
|
const res = await getStream({
|
|
40
41
|
channelId: this.channelId,
|
|
41
42
|
quality: this.quality,
|
|
43
|
+
codecName: this.codecName,
|
|
42
44
|
});
|
|
43
45
|
return res.currentStream;
|
|
44
46
|
},
|
|
@@ -69,10 +71,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
69
71
|
try {
|
|
70
72
|
const liveInfo = await getInfo(this.channelId);
|
|
71
73
|
this.liveInfo = liveInfo;
|
|
72
|
-
this.state
|
|
74
|
+
this.emit("stateChange", { state: "idle" });
|
|
73
75
|
}
|
|
74
76
|
catch (error) {
|
|
75
|
-
this.
|
|
77
|
+
this.emit("stateChange", {
|
|
78
|
+
state: "check-error",
|
|
79
|
+
msg: `检查失败,` + (error instanceof Error ? error.message : String(error)),
|
|
80
|
+
});
|
|
76
81
|
throw error;
|
|
77
82
|
}
|
|
78
83
|
const { living, owner, title, liveStartTime, recordStartTime } = this.liveInfo;
|
|
@@ -87,6 +92,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
87
92
|
if (!living)
|
|
88
93
|
return null;
|
|
89
94
|
// 检查标题是否包含关键词
|
|
95
|
+
console.log("检查标题关键词", { title, channelId: this.channelId, isManualStart });
|
|
90
96
|
if (utils.checkTitleKeywordsBeforeRecord(title, this, isManualStart))
|
|
91
97
|
return null;
|
|
92
98
|
const qualityRetryLeft = (await this.cache.get("qualityRetryLeft")) ?? this.qualityRetry;
|
|
@@ -100,15 +106,20 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
100
106
|
strictQuality,
|
|
101
107
|
onlyAudio: this.onlyAudio,
|
|
102
108
|
avoidEdgeCDN: true,
|
|
109
|
+
codecName: this.codecName,
|
|
110
|
+
api: this.api,
|
|
103
111
|
});
|
|
104
112
|
}
|
|
105
113
|
catch (err) {
|
|
106
114
|
if (qualityRetryLeft > 0)
|
|
107
115
|
await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
|
|
108
|
-
this.
|
|
116
|
+
this.emit("stateChange", {
|
|
117
|
+
state: "check-error",
|
|
118
|
+
msg: `检查失败,` + (err instanceof Error ? err.message : String(err)),
|
|
119
|
+
});
|
|
109
120
|
throw err;
|
|
110
121
|
}
|
|
111
|
-
this.state
|
|
122
|
+
this.emit("stateChange", { state: "recording" });
|
|
112
123
|
const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
|
|
113
124
|
this.availableStreams = availableStreams.map((s) => s.name);
|
|
114
125
|
this.availableSources = availableSources.map((s) => s.name);
|
|
@@ -143,6 +154,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
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;
|
|
@@ -166,8 +178,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
166
178
|
});
|
|
167
179
|
};
|
|
168
180
|
downloader.on("videoFileCreated", handleVideoCreated);
|
|
169
|
-
downloader.on("videoFileCompleted", (
|
|
170
|
-
this.emit("videoFileCompleted",
|
|
181
|
+
downloader.on("videoFileCompleted", (data) => {
|
|
182
|
+
this.emit("videoFileCompleted", data);
|
|
171
183
|
});
|
|
172
184
|
downloader.on("DebugLog", (data) => {
|
|
173
185
|
this.emit("DebugLog", data);
|
|
@@ -316,14 +328,27 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
316
328
|
}
|
|
317
329
|
});
|
|
318
330
|
client.on("error", (err) => {
|
|
331
|
+
this.appendTimeline({ text: `弹幕连接发生错误: ${String(err)}` });
|
|
319
332
|
this.emit("DebugLog", { type: "common", text: String(err) });
|
|
320
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
|
+
});
|
|
321
347
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
322
348
|
client.start();
|
|
323
349
|
}
|
|
324
350
|
const downloaderArgs = downloader.getArguments();
|
|
325
351
|
downloader.run();
|
|
326
|
-
// TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
|
|
327
352
|
const cut = utils.singleton(async () => {
|
|
328
353
|
if (!this.recordHandle)
|
|
329
354
|
return;
|
|
@@ -332,7 +357,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
332
357
|
const stop = utils.singleton(async (reason) => {
|
|
333
358
|
if (!this.recordHandle)
|
|
334
359
|
return;
|
|
335
|
-
this.state
|
|
360
|
+
this.emit("stateChange", { state: "stopping-record" });
|
|
336
361
|
try {
|
|
337
362
|
client.stop();
|
|
338
363
|
await downloader.stop();
|
|
@@ -348,7 +373,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
348
373
|
this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
|
|
349
374
|
this.recordHandle = undefined;
|
|
350
375
|
this.liveInfo = undefined;
|
|
351
|
-
this.state
|
|
376
|
+
this.emit("stateChange", { state: "idle" });
|
|
352
377
|
this.cache.set("qualityRetryLeft", this.qualityRetry);
|
|
353
378
|
});
|
|
354
379
|
this.recordHandle = {
|
|
@@ -375,15 +400,16 @@ export const provider = {
|
|
|
375
400
|
async resolveChannelInfoFromURL(channelURL) {
|
|
376
401
|
if (!this.matchURL(channelURL))
|
|
377
402
|
return null;
|
|
378
|
-
const
|
|
403
|
+
const parser = new DouyuParser();
|
|
404
|
+
const roomId = await parser.extractRoomId(channelURL);
|
|
379
405
|
if (!roomId)
|
|
380
406
|
return null;
|
|
381
|
-
const roomInfo = await getRoomInfo(
|
|
407
|
+
const roomInfo = await parser.getRoomInfo(roomId);
|
|
382
408
|
return {
|
|
383
409
|
id: roomId,
|
|
384
|
-
title: roomInfo.
|
|
385
|
-
owner: roomInfo.
|
|
386
|
-
avatar: roomInfo.
|
|
410
|
+
title: roomInfo.title,
|
|
411
|
+
owner: roomInfo.owner,
|
|
412
|
+
avatar: roomInfo.avatar,
|
|
387
413
|
};
|
|
388
414
|
},
|
|
389
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.16.0"
|
|
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