@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 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
@@ -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;
@@ -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 = 10;
14
+ let maxRetry = MAX_RETRY;
13
15
  let coder = new BufferCoder();
14
16
  let heartbeatTimer = null;
15
- const send = (message) => {
16
- if (ws && ws.readyState === WebSocket.OPEN) {
17
- ws?.send(coder.encode(STT.serialize(message)));
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 = () => send({ type: "logout" });
29
- const onOpen = () => {
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 onError = (err) => {
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
- stop();
46
- setTimeout(() => {
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
- else {
51
- client.emit("error", err);
52
- client.emit("error", new Error("重连次数过多,停止重连"));
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
- onClose();
97
- ws = null;
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 { live } from "douyu-api";
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 = "idle";
74
+ this.emit("stateChange", { state: "idle" });
73
75
  }
74
76
  catch (error) {
75
- this.state = "check-error";
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.state = "check-error";
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 = "recording";
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", ({ filename }) => {
170
- this.emit("videoFileCompleted", { filename });
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 = "stopping-record";
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 = "idle";
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 roomId = await live.parseRoomId(channelURL);
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(Number(roomId));
407
+ const roomInfo = await parser.getRoomInfo(roomId);
382
408
  return {
383
409
  id: roomId,
384
- title: roomInfo.room.room_name,
385
- owner: roomInfo.room.nickname,
386
- avatar: roomInfo.room.avatar?.big,
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("./dy_api.js").SourceProfile[];
22
- streams: import("./dy_api.js").StreamProfile[];
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 { getLiveInfo } from "./dy_api.js";
4
- import { requester } from "./requester.js";
2
+ import { DouyuParser } from "@bililive-tools/stream-get";
5
3
  export async function getInfo(channelId) {
6
- const res = await requester.get(`http://open.douyucdn.cn/api/RoomApi/room/${channelId}`);
7
- if (res.status !== 200) {
8
- if (res.status === 404 && res.data === "Not Found") {
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.room.nickname,
33
- title: data.room.room_name,
34
- avatar: data.room.avatar.big,
35
- cover: data.room.room_pic,
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.room.second_lvl_name,
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
- let liveInfo = await getLiveInfo({
55
- channelId: opts.channelId,
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.16.0",
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
- "axios": "^1.15.0",
43
- "douyu-api": "^0.2.1",
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
- }
@@ -1 +0,0 @@
1
- export declare const requester: import("axios").AxiosInstance;
package/lib/requester.js DELETED
@@ -1,7 +0,0 @@
1
- import axios from "axios";
2
- export const requester = axios.create({
3
- timeout: 10e3,
4
- // axios 会自动读取环境变量中的 http_proxy 和 https_proxy 并应用,
5
- // 但会导致请求报错 "Client network socket disconnected before secure TLS connection was established"。
6
- proxy: false,
7
- });