@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 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,9 +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
- 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 = "idle";
74
+ this.emit("stateChange", { state: "idle" });
74
75
  }
75
76
  catch (error) {
76
- this.state = "check-error";
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.state = "check-error";
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 = "recording";
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", ({ filename }) => {
184
- this.emit("videoFileCompleted", { filename });
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 = "stopping-record";
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 = "idle";
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: 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 roomId = await live.parseRoomId(channelURL);
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(Number(roomId));
407
+ const roomInfo = await parser.getRoomInfo(roomId);
396
408
  return {
397
409
  id: roomId,
398
- title: roomInfo.room.room_name,
399
- owner: roomInfo.room.nickname,
400
- avatar: roomInfo.room.avatar?.big,
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("./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.15.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.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
- }
@@ -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
- });