@bililive-tools/douyu-recorder 1.8.0 → 1.10.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,7 +40,7 @@ interface Options {
40
40
  streamPriorities: []; // 废弃
41
41
  sourcePriorities: []; // 废弃
42
42
  disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
43
- segment?: number; // 分段参数,单位分钟
43
+ segment?: number | string; // 分段参数,单位分钟,如果以"B","KB","MB","GB"结尾,会尝试使用文件大小分段,仅推荐在使用mesio录制引擎时使用
44
44
  titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
45
45
  disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
46
46
  saveGiftDanma?: boolean; // 保存礼物弹幕
package/lib/dy_api.js CHANGED
@@ -46,7 +46,6 @@ export async function getLiveInfo(opts) {
46
46
  delete signCaches[opts.channelId];
47
47
  throw new Error("Unexpected error code, " + json.error);
48
48
  }
49
- console.log(JSON.stringify(json, null, 2));
50
49
  const streamUrl = `${json.data.rtmp_url}/${json.data.rtmp_live}`;
51
50
  let cdn = json.data.rtmp_cdn;
52
51
  let onlyAudio = false;
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import mitt from "mitt";
2
- import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createBaseRecorder, } from "@bililive-tools/manager";
2
+ import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createDownloader, } from "@bililive-tools/manager";
3
+ import { live } from "douyu-api";
3
4
  import { getInfo, getStream } from "./stream.js";
4
5
  import { getRoomInfo } from "./dy_api.js";
5
6
  import { ensureFolderExist } from "./utils.js";
6
7
  import { createDYClient } from "./dy_client/index.js";
7
8
  import { giftMap, colorTab } from "./danma.js";
8
- import { requester } from "./requester.js";
9
9
  function createRecorder(opts) {
10
10
  // 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
11
11
  // 此标志来操作这个对象的地方,不然会跳过 proxy 的拦截。
@@ -15,9 +15,9 @@ function createRecorder(opts) {
15
15
  // @ts-ignore
16
16
  ...mitt(),
17
17
  ...opts,
18
+ cache: null,
18
19
  availableStreams: [],
19
20
  availableSources: [],
20
- qualityMaxRetry: opts.qualityRetry ?? 0,
21
21
  qualityRetry: opts.qualityRetry ?? 0,
22
22
  useServerTimestamp: opts.useServerTimestamp ?? true,
23
23
  state: "idle",
@@ -55,45 +55,13 @@ function createRecorder(opts) {
55
55
  });
56
56
  return recorderWithSupportUpdatedEvent;
57
57
  }
58
- const ffmpegOutputOptions = [
59
- "-c",
60
- "copy",
61
- "-movflags",
62
- "faststart+frag_keyframe+empty_moov",
63
- "-min_frag_duration",
64
- "10000000",
65
- ];
58
+ const ffmpegOutputOptions = [];
66
59
  const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
67
60
  // 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
68
61
  if (this.recordHandle != null) {
69
- // 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
70
- if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
71
- const now = Date.now();
72
- // 每5分钟检查一次标题变化
73
- const titleCheckInterval = 5 * 60 * 1000; // 5分钟
74
- // 获取上次检查时间
75
- const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
76
- // 如果距离上次检查时间不足指定间隔,则跳过检查
77
- if (now - lastCheckTime < titleCheckInterval) {
78
- return this.recordHandle;
79
- }
80
- // 更新检查时间
81
- this.extra.lastTitleCheckTime = now;
82
- // 获取直播间信息
83
- const liveInfo = await getInfo(this.channelId);
84
- const { title } = liveInfo;
85
- // 检查标题是否包含关键词
86
- if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
87
- this.state = "title-blocked";
88
- this.emit("DebugLog", {
89
- type: "common",
90
- text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
91
- });
92
- // 停止录制
93
- await this.recordHandle.stop("直播间标题包含关键词");
94
- // 返回 null,停止录制
95
- return null;
96
- }
62
+ const shouldStop = await utils.checkTitleKeywordsWhileRecording(this, isManualStart, getInfo);
63
+ if (shouldStop) {
64
+ return null;
97
65
  }
98
66
  // 已经在录制中,直接返回
99
67
  return this.recordHandle;
@@ -108,7 +76,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
108
76
  this.state = "check-error";
109
77
  throw error;
110
78
  }
111
- const { living, owner, title } = this.liveInfo;
79
+ const { living, owner, title, liveStartTime, recordStartTime } = this.liveInfo;
112
80
  if (this.liveInfo.liveId === banLiveId) {
113
81
  this.tempStopIntervalCheck = true;
114
82
  }
@@ -119,30 +87,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
119
87
  return null;
120
88
  if (!living)
121
89
  return null;
122
- // 检查标题是否包含关键词,如果包含则不自动录制
123
- // 手动开始录制时不检查标题关键词
124
- if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
125
- if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
126
- this.state = "title-blocked";
127
- this.emit("DebugLog", {
128
- type: "common",
129
- text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
130
- });
131
- return null;
132
- }
133
- }
90
+ // 检查标题是否包含关键词
91
+ if (utils.checkTitleKeywordsBeforeRecord(title, this, isManualStart))
92
+ return null;
93
+ const qualityRetryLeft = (await this.cache.get("qualityRetryLeft")) ?? this.qualityRetry;
94
+ const strictQuality = utils.shouldUseStrictQuality(qualityRetryLeft, this.qualityRetry, isManualStart);
134
95
  let res;
135
96
  try {
136
- let strictQuality = false;
137
- if (this.qualityRetry > 0) {
138
- strictQuality = true;
139
- }
140
- if (this.qualityMaxRetry < 0) {
141
- strictQuality = true;
142
- }
143
- if (isManualStart) {
144
- strictQuality = false;
145
- }
146
97
  res = await getStream({
147
98
  channelId: this.channelId,
148
99
  quality: this.quality,
@@ -153,8 +104,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
153
104
  });
154
105
  }
155
106
  catch (err) {
156
- if (this.qualityRetry > 0)
157
- this.qualityRetry -= 1;
107
+ if (qualityRetryLeft > 0)
108
+ await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
158
109
  this.state = "check-error";
159
110
  throw err;
160
111
  }
@@ -181,13 +132,18 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
181
132
  };
182
133
  let isEnded = false;
183
134
  let isCutting = false;
184
- const recorder = createBaseRecorder(this.recorderType, {
135
+ const downloader = createDownloader(this.recorderType, {
185
136
  url: stream.url,
186
137
  // @ts-ignore
187
138
  outputOptions: ffmpegOutputOptions,
188
139
  segment: this.segment ?? 0,
189
- getSavePath: (opts) => getSavePath({ owner, title: opts.title ?? title, startTime: opts.startTime }),
190
- disableDanma: this.disableProvideCommentsWhenRecording,
140
+ getSavePath: (opts) => getSavePath({
141
+ owner,
142
+ title: opts.title ?? title,
143
+ startTime: opts.startTime,
144
+ liveStartTime,
145
+ recordStartTime,
146
+ }),
191
147
  videoFormat: this.videoFormat ?? "auto",
192
148
  debugLevel: this.debugLevel ?? "none",
193
149
  onlyAudio: stream.onlyAudio,
@@ -198,6 +154,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
198
154
  const savePath = getSavePath({
199
155
  owner,
200
156
  title,
157
+ startTime: Date.now(),
158
+ liveStartTime,
159
+ recordStartTime,
201
160
  });
202
161
  try {
203
162
  ensureFolderExist(savePath);
@@ -214,24 +173,24 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
214
173
  if (cover && this?.liveInfo) {
215
174
  this.liveInfo.cover = cover;
216
175
  }
217
- const extraDataController = recorder.getExtraDataController();
176
+ const extraDataController = downloader.getExtraDataController();
218
177
  extraDataController?.setMeta({
219
178
  room_id: this.channelId,
220
179
  platform: provider?.id,
221
- liveStartTimestamp: this?.liveInfo?.startTime?.getTime(),
180
+ liveStartTimestamp: this?.liveInfo?.liveStartTime?.getTime(),
222
181
  // recordStopTimestamp: Date.now(),
223
182
  title: title,
224
183
  user_name: owner,
225
184
  });
226
185
  };
227
- recorder.on("videoFileCreated", handleVideoCreated);
228
- recorder.on("videoFileCompleted", ({ filename }) => {
186
+ downloader.on("videoFileCreated", handleVideoCreated);
187
+ downloader.on("videoFileCompleted", ({ filename }) => {
229
188
  this.emit("videoFileCompleted", { filename });
230
189
  });
231
- recorder.on("DebugLog", (data) => {
190
+ downloader.on("DebugLog", (data) => {
232
191
  this.emit("DebugLog", data);
233
192
  });
234
- recorder.on("progress", (progress) => {
193
+ downloader.on("progress", (progress) => {
235
194
  if (this.recordHandle) {
236
195
  this.recordHandle.progress = progress;
237
196
  }
@@ -241,12 +200,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
241
200
  notAutoStart: true,
242
201
  });
243
202
  client.on("message", (msg) => {
244
- const extraDataController = recorder.getExtraDataController();
203
+ const extraDataController = downloader.getExtraDataController();
245
204
  if (!extraDataController)
246
205
  return;
247
206
  switch (msg.type) {
248
207
  case "chatmsg": {
249
- const timestamp = this.useServerTimestamp ? Number(msg.cst) : Date.now();
208
+ // 某些情况下cst不存在,可能是其他平台发送的弹幕?
209
+ const timestamp = this.useServerTimestamp && msg.cst ? Number(msg.cst) : Date.now();
250
210
  const comment = {
251
211
  type: "comment",
252
212
  timestamp: timestamp,
@@ -379,8 +339,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
379
339
  if (!this.disableProvideCommentsWhenRecording) {
380
340
  client.start();
381
341
  }
382
- const ffmpegArgs = recorder.getArguments();
383
- recorder.run();
342
+ const downloaderArgs = downloader.getArguments();
343
+ downloader.run();
384
344
  // TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
385
345
  const cut = utils.singleton(async () => {
386
346
  if (!this.recordHandle)
@@ -388,9 +348,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
388
348
  if (isCutting)
389
349
  return;
390
350
  isCutting = true;
391
- await recorder.stop();
392
- recorder.createCommand();
393
- recorder.run();
351
+ await downloader.stop();
352
+ downloader.createCommand();
353
+ downloader.run();
394
354
  });
395
355
  const stop = utils.singleton(async (reason) => {
396
356
  if (!this.recordHandle)
@@ -398,12 +358,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
398
358
  this.state = "stopping-record";
399
359
  try {
400
360
  client.stop();
401
- await recorder.stop();
361
+ await downloader.stop();
402
362
  }
403
363
  catch (err) {
404
364
  this.emit("DebugLog", {
405
365
  type: "common",
406
- text: `stop ffmpeg error: ${String(err)}`,
366
+ text: `stop record error: ${String(err)}`,
407
367
  });
408
368
  }
409
369
  this.usedStream = undefined;
@@ -412,14 +372,15 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
412
372
  this.recordHandle = undefined;
413
373
  this.liveInfo = undefined;
414
374
  this.state = "idle";
415
- this.qualityRetry = this.qualityMaxRetry;
375
+ this.cache.set("qualityRetryLeft", this.qualityRetry);
416
376
  });
417
377
  this.recordHandle = {
418
378
  id: genRecordUUID(),
419
379
  stream: stream.name,
420
380
  source: stream.source,
381
+ recorderType: downloader.type,
421
382
  url: stream.url,
422
- ffmpegArgs,
383
+ downloaderArgs,
423
384
  savePath: savePath,
424
385
  stop,
425
386
  cut,
@@ -437,29 +398,7 @@ export const provider = {
437
398
  async resolveChannelInfoFromURL(channelURL) {
438
399
  if (!this.matchURL(channelURL))
439
400
  return null;
440
- channelURL = channelURL.trim();
441
- const res = await requester.get(channelURL);
442
- const html = res.data;
443
- const matched = html.match(/\$ROOM\.room_id.?=(.*?);/);
444
- let roomId = undefined;
445
- if (matched) {
446
- roomId = matched[1].trim();
447
- }
448
- else {
449
- // 解析出query中的rid参数
450
- const rid = new URL(channelURL).searchParams.get("rid");
451
- if (rid) {
452
- roomId = rid;
453
- }
454
- else {
455
- // 解析<link rel="canonical" href="xxxxxxx"/>中的href
456
- const canonicalLink = html.match(/<link rel="canonical" href="(.*?)"/);
457
- if (canonicalLink) {
458
- const url = canonicalLink[1];
459
- roomId = url.split("/").pop();
460
- }
461
- }
462
- }
401
+ const roomId = await live.parseRoomId(channelURL);
463
402
  if (!roomId)
464
403
  return null;
465
404
  const roomInfo = await getRoomInfo(Number(roomId));
package/lib/stream.d.ts CHANGED
@@ -3,10 +3,11 @@ export declare function getInfo(channelId: string): Promise<{
3
3
  living: boolean;
4
4
  owner: string;
5
5
  title: string;
6
- startTime: Date;
6
+ liveStartTime: Date;
7
7
  avatar: string;
8
8
  cover: string;
9
9
  liveId: string;
10
+ recordStartTime: Date;
10
11
  }>;
11
12
  export declare function getStream(opts: Pick<Recorder, "channelId" | "quality"> & {
12
13
  rejectCache?: boolean;
package/lib/stream.js CHANGED
@@ -26,14 +26,16 @@ export async function getInfo(channelId) {
26
26
  }
27
27
  }
28
28
  const startTime = new Date(data.room.show_time * 1000);
29
+ const recordStartTime = new Date();
29
30
  return {
30
31
  living,
31
32
  owner: data.room.nickname,
32
33
  title: data.room.room_name,
33
34
  avatar: data.room.avatar.big,
34
35
  cover: data.room.room_pic,
35
- startTime: startTime,
36
+ liveStartTime: startTime,
36
37
  liveId: utils.md5(`${channelId}-${startTime?.getTime() ?? Date.now()}`),
38
+ recordStartTime: recordStartTime,
37
39
  // gifts: data.gift.map((g) => ({
38
40
  // id: g.id,
39
41
  // name: g.name,
@@ -46,7 +48,6 @@ export async function getStream(opts) {
46
48
  const qn = (DouyuQualities.includes(opts.quality) ? opts.quality : 0);
47
49
  let cdn = opts.source === "auto" ? undefined : opts.source;
48
50
  if (opts.source === "auto" && opts.avoidEdgeCDN) {
49
- // TODO: 如果不存在 cdn=hw-h5 的源,那么还是可能默认到边缘节点,就先这样吧
50
51
  cdn = "hw-h5";
51
52
  }
52
53
  let liveInfo = await getLiveInfo({
@@ -93,9 +94,5 @@ export async function getStream(opts) {
93
94
  throw new Error("It must be called getStream when living");
94
95
  }
95
96
  }
96
- // 流未准备好,防止刚开播时的无效录制。
97
- // 该判断可能导致开播前 30 秒左右无法录制到,因为 streamStatus 在后端似乎有缓存,所以暂时不使用。
98
- // TODO: 需要在 ffmpeg 那里加处理,防止无效录制
99
- // if (!json.data.streamStatus) return
100
97
  return liveInfo;
101
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/douyu-recorder",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "bililive-tools douyu recorder implemention",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -40,8 +40,8 @@
40
40
  "ws": "^8.18.0",
41
41
  "lodash-es": "^4.17.21",
42
42
  "axios": "^1.7.8",
43
- "douyu-api": "^0.1.0",
44
- "@bililive-tools/manager": "^1.8.0"
43
+ "douyu-api": "^0.2.0",
44
+ "@bililive-tools/manager": "^1.10.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/ws": "^8.5.13"