@bililive-tools/douyu-recorder 1.9.0 → 1.11.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/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",
@@ -59,34 +59,9 @@ const ffmpegOutputOptions = [];
59
59
  const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
60
60
  // 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
61
61
  if (this.recordHandle != null) {
62
- // 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
63
- if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
64
- const now = Date.now();
65
- // 每5分钟检查一次标题变化
66
- const titleCheckInterval = 5 * 60 * 1000; // 5分钟
67
- // 获取上次检查时间
68
- const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
69
- // 如果距离上次检查时间不足指定间隔,则跳过检查
70
- if (now - lastCheckTime < titleCheckInterval) {
71
- return this.recordHandle;
72
- }
73
- // 更新检查时间
74
- this.extra.lastTitleCheckTime = now;
75
- // 获取直播间信息
76
- const liveInfo = await getInfo(this.channelId);
77
- const { title } = liveInfo;
78
- // 检查标题是否包含关键词
79
- if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
80
- this.state = "title-blocked";
81
- this.emit("DebugLog", {
82
- type: "common",
83
- text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
84
- });
85
- // 停止录制
86
- await this.recordHandle.stop("直播间标题包含关键词");
87
- // 返回 null,停止录制
88
- return null;
89
- }
62
+ const shouldStop = await utils.checkTitleKeywordsWhileRecording(this, isManualStart, getInfo);
63
+ if (shouldStop) {
64
+ return null;
90
65
  }
91
66
  // 已经在录制中,直接返回
92
67
  return this.recordHandle;
@@ -101,7 +76,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
101
76
  this.state = "check-error";
102
77
  throw error;
103
78
  }
104
- const { living, owner, title, startTime } = this.liveInfo;
79
+ const { living, owner, title, liveStartTime, recordStartTime } = this.liveInfo;
105
80
  if (this.liveInfo.liveId === banLiveId) {
106
81
  this.tempStopIntervalCheck = true;
107
82
  }
@@ -112,30 +87,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
112
87
  return null;
113
88
  if (!living)
114
89
  return null;
115
- // 检查标题是否包含关键词,如果包含则不自动录制
116
- // 手动开始录制时不检查标题关键词
117
- if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
118
- if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
119
- this.state = "title-blocked";
120
- this.emit("DebugLog", {
121
- type: "common",
122
- text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
123
- });
124
- return null;
125
- }
126
- }
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);
127
95
  let res;
128
96
  try {
129
- let strictQuality = false;
130
- if (this.qualityRetry > 0) {
131
- strictQuality = true;
132
- }
133
- if (this.qualityMaxRetry < 0) {
134
- strictQuality = true;
135
- }
136
- if (isManualStart) {
137
- strictQuality = false;
138
- }
139
97
  res = await getStream({
140
98
  channelId: this.channelId,
141
99
  quality: this.quality,
@@ -146,8 +104,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
146
104
  });
147
105
  }
148
106
  catch (err) {
149
- if (this.qualityRetry > 0)
150
- this.qualityRetry -= 1;
107
+ if (qualityRetryLeft > 0)
108
+ await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
151
109
  this.state = "check-error";
152
110
  throw err;
153
111
  }
@@ -158,10 +116,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
158
116
  this.usedStream = stream.name;
159
117
  this.usedSource = stream.source;
160
118
  const onEnd = (...args) => {
161
- if (isCutting) {
162
- isCutting = false;
163
- return;
164
- }
165
119
  if (isEnded)
166
120
  return;
167
121
  isEnded = true;
@@ -173,9 +127,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
173
127
  this.recordHandle?.stop(reason);
174
128
  };
175
129
  let isEnded = false;
176
- let isCutting = false;
177
- const recordStartTime = new Date();
178
- const recorder = createBaseRecorder(this.recorderType, {
130
+ const downloader = createDownloader(this.recorderType, {
179
131
  url: stream.url,
180
132
  // @ts-ignore
181
133
  outputOptions: ffmpegOutputOptions,
@@ -184,7 +136,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
184
136
  owner,
185
137
  title: opts.title ?? title,
186
138
  startTime: opts.startTime,
187
- liveStartTime: startTime,
139
+ liveStartTime,
188
140
  recordStartTime,
189
141
  }),
190
142
  disableDanma: this.disableProvideCommentsWhenRecording,
@@ -199,7 +151,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
199
151
  owner,
200
152
  title,
201
153
  startTime: Date.now(),
202
- liveStartTime: startTime,
154
+ liveStartTime,
203
155
  recordStartTime,
204
156
  });
205
157
  try {
@@ -217,24 +169,24 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
217
169
  if (cover && this?.liveInfo) {
218
170
  this.liveInfo.cover = cover;
219
171
  }
220
- const extraDataController = recorder.getExtraDataController();
172
+ const extraDataController = downloader.getExtraDataController();
221
173
  extraDataController?.setMeta({
222
174
  room_id: this.channelId,
223
175
  platform: provider?.id,
224
- liveStartTimestamp: this?.liveInfo?.startTime?.getTime(),
176
+ liveStartTimestamp: this?.liveInfo?.liveStartTime?.getTime(),
225
177
  // recordStopTimestamp: Date.now(),
226
178
  title: title,
227
179
  user_name: owner,
228
180
  });
229
181
  };
230
- recorder.on("videoFileCreated", handleVideoCreated);
231
- recorder.on("videoFileCompleted", ({ filename }) => {
182
+ downloader.on("videoFileCreated", handleVideoCreated);
183
+ downloader.on("videoFileCompleted", ({ filename }) => {
232
184
  this.emit("videoFileCompleted", { filename });
233
185
  });
234
- recorder.on("DebugLog", (data) => {
186
+ downloader.on("DebugLog", (data) => {
235
187
  this.emit("DebugLog", data);
236
188
  });
237
- recorder.on("progress", (progress) => {
189
+ downloader.on("progress", (progress) => {
238
190
  if (this.recordHandle) {
239
191
  this.recordHandle.progress = progress;
240
192
  }
@@ -244,7 +196,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
244
196
  notAutoStart: true,
245
197
  });
246
198
  client.on("message", (msg) => {
247
- const extraDataController = recorder.getExtraDataController();
199
+ const extraDataController = downloader.getExtraDataController();
248
200
  if (!extraDataController)
249
201
  return;
250
202
  switch (msg.type) {
@@ -383,18 +335,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
383
335
  if (!this.disableProvideCommentsWhenRecording) {
384
336
  client.start();
385
337
  }
386
- const ffmpegArgs = recorder.getArguments();
387
- recorder.run();
338
+ const downloaderArgs = downloader.getArguments();
339
+ downloader.run();
388
340
  // TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
389
341
  const cut = utils.singleton(async () => {
390
342
  if (!this.recordHandle)
391
343
  return;
392
- if (isCutting)
393
- return;
394
- isCutting = true;
395
- await recorder.stop();
396
- recorder.createCommand();
397
- recorder.run();
344
+ downloader.cut();
398
345
  });
399
346
  const stop = utils.singleton(async (reason) => {
400
347
  if (!this.recordHandle)
@@ -402,12 +349,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
402
349
  this.state = "stopping-record";
403
350
  try {
404
351
  client.stop();
405
- await recorder.stop();
352
+ await downloader.stop();
406
353
  }
407
354
  catch (err) {
408
355
  this.emit("DebugLog", {
409
356
  type: "common",
410
- text: `stop ffmpeg error: ${String(err)}`,
357
+ text: `stop record error: ${String(err)}`,
411
358
  });
412
359
  }
413
360
  this.usedStream = undefined;
@@ -416,15 +363,15 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
416
363
  this.recordHandle = undefined;
417
364
  this.liveInfo = undefined;
418
365
  this.state = "idle";
419
- this.qualityRetry = this.qualityMaxRetry;
366
+ this.cache.set("qualityRetryLeft", this.qualityRetry);
420
367
  });
421
368
  this.recordHandle = {
422
369
  id: genRecordUUID(),
423
370
  stream: stream.name,
424
371
  source: stream.source,
425
- recorderType: recorder.type,
372
+ recorderType: downloader.type,
426
373
  url: stream.url,
427
- ffmpegArgs,
374
+ downloaderArgs,
428
375
  savePath: savePath,
429
376
  stop,
430
377
  cut,
@@ -442,29 +389,7 @@ export const provider = {
442
389
  async resolveChannelInfoFromURL(channelURL) {
443
390
  if (!this.matchURL(channelURL))
444
391
  return null;
445
- channelURL = channelURL.trim();
446
- const res = await requester.get(channelURL);
447
- const html = res.data;
448
- const matched = html.match(/\$ROOM\.room_id.?=(.*?);/);
449
- let roomId = undefined;
450
- if (matched) {
451
- roomId = matched[1].trim();
452
- }
453
- else {
454
- // 解析出query中的rid参数
455
- const rid = new URL(channelURL).searchParams.get("rid");
456
- if (rid) {
457
- roomId = rid;
458
- }
459
- else {
460
- // 解析<link rel="canonical" href="xxxxxxx"/>中的href
461
- const canonicalLink = html.match(/<link rel="canonical" href="(.*?)"/);
462
- if (canonicalLink) {
463
- const url = canonicalLink[1];
464
- roomId = url.split("/").pop();
465
- }
466
- }
467
- }
392
+ const roomId = await live.parseRoomId(channelURL);
468
393
  if (!roomId)
469
394
  return null;
470
395
  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.9.0",
3
+ "version": "1.11.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.9.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"