@bililive-tools/douyu-recorder 1.0.1 → 1.1.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
@@ -6,6 +6,8 @@
6
6
 
7
7
  # 安装
8
8
 
9
+ **建议所有录制器和manager包都升级到最新版,我不会对兼容性做过多考虑**
10
+
9
11
  `npm i @bililive-tools/douyu-recorder @bililive-tools/manager`
10
12
 
11
13
  # 使用
@@ -33,11 +35,12 @@ manager.startCheckLoop();
33
35
  interface Options {
34
36
  channelId: string; // 直播间ID,具体解析见文档,也可自行解析
35
37
  quality: number; // 见画质参数
36
- qualityRetry?: number; // 画质匹配重试次数
38
+ qualityRetry?: number; // 画质匹配重试次数, -1为强制匹配画质,0为自动配置,正整数为最大匹配次数
37
39
  streamPriorities: []; // 废弃
38
40
  sourcePriorities: []; // 废弃
39
41
  disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
40
- segment?: number; // 分段参数
42
+ segment?: number; // 分段参数,单位分钟
43
+ titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
41
44
  disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
42
45
  saveGiftDanma?: boolean; // 保存礼物弹幕
43
46
  saveSCDanma?: boolean; // 保存高能弹幕
@@ -64,7 +67,7 @@ interface Options {
64
67
  ```ts
65
68
  import { provider } from "@bililive-tools/douyu-recorder";
66
69
 
67
- const url = "https://live.bilibili.com/5055636";
70
+ const url = "https://www.douyu.com/2140934";
68
71
  const { id } = await provider.resolveChannelInfoFromURL(url);
69
72
  ```
70
73
 
package/lib/dy_api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
- import { v4 as uuid4 } from "uuid";
3
2
  import safeEval from "safe-eval";
3
+ import { uuid } from "./utils.js";
4
4
  import queryString from "query-string";
5
5
  import { requester } from "./requester.js";
6
6
  /**
@@ -8,7 +8,7 @@ import { requester } from "./requester.js";
8
8
  */
9
9
  export async function getLiveInfo(opts) {
10
10
  const sign = await getSignFn(opts.channelId, opts.rejectSignFnCache);
11
- const did = uuid4().replace(/-/g, "");
11
+ const did = uuid().replace(/-/g, "");
12
12
  const time = Math.ceil(Date.now() / 1000);
13
13
  const signedStr = String(sign(opts.channelId, did, time));
14
14
  // TODO: 这里类型处理的有点问题,先用 as 顶着
@@ -43,6 +43,20 @@ interface Message$Gift {
43
43
  bnn: string;
44
44
  gfn: string;
45
45
  }
46
+ interface Message$ODFBC {
47
+ type: "odfbc";
48
+ uid: string;
49
+ rid: string;
50
+ nick: string;
51
+ price: string;
52
+ }
53
+ interface Message$RNDFBC {
54
+ type: "rndfbc";
55
+ uid: string;
56
+ rid: string;
57
+ nick: string;
58
+ price: string;
59
+ }
46
60
  interface Message$CommChatPandora {
47
61
  type: "comm_chatmsg";
48
62
  rid: string;
@@ -97,7 +111,7 @@ interface Message$CommChatVoiceDanmu {
97
111
  };
98
112
  }
99
113
  type Message$CommChat = Message$CommChatPandora | Message$CommChatVoiceDanmu;
100
- export type Message = Message$Chat | Message$Gift | Message$CommChat;
114
+ export type Message = Message$Chat | Message$Gift | Message$CommChat | Message$ODFBC | Message$RNDFBC;
101
115
  export interface DYClient extends Emitter<{
102
116
  message: Message;
103
117
  error: unknown;
package/lib/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { RecorderProvider } from "@bililive-tools/manager";
1
+ import type { RecorderProvider } from "@bililive-tools/manager";
2
2
  export declare const provider: RecorderProvider<Record<string, unknown>>;
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import mitt from "mitt";
2
- import { createFFMPEGBuilder, defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, StreamManager, utils, } from "@bililive-tools/manager";
2
+ import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, FFMPEGRecorder, } from "@bililive-tools/manager";
3
3
  import { getInfo, getStream } from "./stream.js";
4
4
  import { getRoomInfo } from "./dy_api.js";
5
- import { assert, ensureFolderExist } from "./utils.js";
5
+ import { ensureFolderExist } from "./utils.js";
6
6
  import { createDYClient } from "./dy_client/index.js";
7
7
  import { giftMap, colorTab } from "./danma.js";
8
8
  import { requester } from "./requester.js";
@@ -62,12 +62,53 @@ const ffmpegOutputOptions = [
62
62
  "-min_frag_duration",
63
63
  "60000000",
64
64
  ];
65
- const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, banLiveId, }) {
66
- if (this.recordHandle != null)
65
+ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
66
+ // 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
67
+ if (this.recordHandle != null) {
68
+ // 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
69
+ if (!isManualStart &&
70
+ this.titleKeywords &&
71
+ typeof this.titleKeywords === "string" &&
72
+ this.titleKeywords.trim()) {
73
+ const now = Date.now();
74
+ // 每5分钟检查一次标题变化
75
+ const titleCheckInterval = 5 * 60 * 1000; // 5分钟
76
+ // 获取上次检查时间
77
+ const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
78
+ // 如果距离上次检查时间不足指定间隔,则跳过检查
79
+ if (now - lastCheckTime < titleCheckInterval) {
80
+ return this.recordHandle;
81
+ }
82
+ // 更新检查时间
83
+ this.extra.lastTitleCheckTime = now;
84
+ // 获取直播间信息
85
+ const liveInfo = await getInfo(this.channelId);
86
+ const { title } = liveInfo;
87
+ // 检查标题是否包含关键词
88
+ const keywords = this.titleKeywords
89
+ .split(",")
90
+ .map((k) => k.trim())
91
+ .filter((k) => k);
92
+ const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
93
+ if (hasTitleKeyword) {
94
+ this.emit("DebugLog", {
95
+ type: "common",
96
+ text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
97
+ });
98
+ // 停止录制
99
+ await this.recordHandle.stop("直播间标题包含关键词");
100
+ // 返回 null,停止录制
101
+ return null;
102
+ }
103
+ }
104
+ // 已经在录制中,直接返回
67
105
  return this.recordHandle;
106
+ }
107
+ // 获取直播间信息
68
108
  const liveInfo = await getInfo(this.channelId);
109
+ const { living, owner, title, liveId } = liveInfo;
69
110
  this.liveInfo = liveInfo;
70
- const { living, owner, title, cover } = liveInfo;
111
+ this.emit("LiveStart", { liveId });
71
112
  if (liveInfo.liveId === banLiveId) {
72
113
  this.tempStopIntervalCheck = true;
73
114
  }
@@ -78,13 +119,37 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
78
119
  return null;
79
120
  if (!living)
80
121
  return null;
81
- this.state = "recording";
122
+ // 检查标题是否包含关键词,如果包含则不自动录制
123
+ // 手动开始录制时不检查标题关键词
124
+ if (!isManualStart &&
125
+ this.titleKeywords &&
126
+ typeof this.titleKeywords === "string" &&
127
+ this.titleKeywords.trim()) {
128
+ const keywords = this.titleKeywords
129
+ .split(",")
130
+ .map((k) => k.trim())
131
+ .filter((k) => k);
132
+ const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
133
+ if (hasTitleKeyword) {
134
+ this.emit("DebugLog", {
135
+ type: "common",
136
+ text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
137
+ });
138
+ return null;
139
+ }
140
+ }
82
141
  let res;
83
142
  // TODO: 先不做什么错误处理,就简单包一下预期上会有错误的地方
84
143
  try {
85
- let strictQuality = !!this.qualityRetry;
86
- if (qualityRetry !== undefined) {
87
- strictQuality = !!qualityRetry;
144
+ let strictQuality = false;
145
+ if (this.qualityRetry > 0) {
146
+ strictQuality = true;
147
+ }
148
+ if (this.qualityMaxRetry < 0) {
149
+ strictQuality = true;
150
+ }
151
+ if (isManualStart) {
152
+ strictQuality = false;
88
153
  }
89
154
  res = await getStream({
90
155
  channelId: this.channelId,
@@ -97,14 +162,35 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
97
162
  this.qualityRetry -= 1;
98
163
  throw err;
99
164
  }
165
+ this.state = "recording";
100
166
  const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
101
167
  this.availableStreams = availableStreams.map((s) => s.name);
102
168
  this.availableSources = availableSources.map((s) => s.name);
103
169
  this.usedStream = stream.name;
104
170
  this.usedSource = stream.source;
105
- // TODO: emit update event
106
- const savePath = getSavePath({ owner, title });
107
- const hasSegment = !!this.segment;
171
+ const onEnd = (...args) => {
172
+ if (isEnded)
173
+ return;
174
+ isEnded = true;
175
+ this.emit("DebugLog", {
176
+ type: "common",
177
+ text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
178
+ });
179
+ const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
180
+ this.recordHandle?.stop(reason);
181
+ };
182
+ let isEnded = false;
183
+ const recorder = new FFMPEGRecorder({
184
+ url: stream.url,
185
+ outputOptions: ffmpegOutputOptions,
186
+ segment: this.segment ?? 0,
187
+ getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
188
+ disableDanma: this.disableProvideCommentsWhenRecording,
189
+ }, onEnd);
190
+ const savePath = getSavePath({
191
+ owner,
192
+ title,
193
+ });
108
194
  try {
109
195
  ensureFolderExist(savePath);
110
196
  }
@@ -112,25 +198,36 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
112
198
  this.state = "idle";
113
199
  throw err;
114
200
  }
115
- const streamManager = new StreamManager(this, getSavePath, owner, title, savePath, hasSegment);
116
201
  const handleVideoCreated = async ({ filename }) => {
117
- const extraDataController = streamManager?.getExtraDataController();
202
+ this.emit("videoFileCreated", { filename });
203
+ const extraDataController = recorder.getExtraDataController();
118
204
  extraDataController?.setMeta({
119
205
  room_id: this.channelId,
120
206
  platform: provider?.id,
121
207
  liveStartTimestamp: liveInfo.startTime?.getTime(),
208
+ recordStopTimestamp: Date.now(),
209
+ title: title,
210
+ user_name: owner,
122
211
  });
123
- if (this.saveCover) {
124
- const coverPath = utils.replaceExtName(filename, ".jpg");
125
- utils.downloadImage(cover, coverPath);
126
- }
127
212
  };
128
- this.on("videoFileCreated", handleVideoCreated);
213
+ recorder.on("videoFileCreated", handleVideoCreated);
214
+ recorder.on("videoFileCompleted", ({ filename }) => {
215
+ this.emit("videoFileCompleted", { filename });
216
+ });
217
+ recorder.on("DebugLog", (data) => {
218
+ this.emit("DebugLog", data);
219
+ });
220
+ recorder.on("progress", (progress) => {
221
+ if (this.recordHandle) {
222
+ this.recordHandle.progress = progress;
223
+ }
224
+ this.emit("progress", progress);
225
+ });
129
226
  const client = createDYClient(Number(this.channelId), {
130
227
  notAutoStart: true,
131
228
  });
132
229
  client.on("message", (msg) => {
133
- const extraDataController = streamManager.getExtraDataController();
230
+ const extraDataController = recorder.getExtraDataController();
134
231
  if (!extraDataController)
135
232
  return;
136
233
  switch (msg.type) {
@@ -178,7 +275,60 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
178
275
  this.emit("Message", gift);
179
276
  extraDataController.addMessage(gift);
180
277
  break;
181
- // TODO: 还有一些其他礼物相关的 msg 要处理,目前先简单点只处理 dgb
278
+ }
279
+ // 开通钻粉
280
+ case "odfbc": {
281
+ if (this.saveGiftDanma === false)
282
+ return;
283
+ const gift = {
284
+ type: "give_gift",
285
+ timestamp: Date.now(),
286
+ name: "钻粉",
287
+ price: Number(msg.price) / 100,
288
+ count: 1,
289
+ color: "#ffffff",
290
+ sender: {
291
+ uid: msg.uid,
292
+ name: msg.nick,
293
+ // avatar: msg.ic,
294
+ // extra: {
295
+ // level: msg.level,
296
+ // },
297
+ },
298
+ // extra: {
299
+ // hits: Number(msg.hits),
300
+ // },
301
+ };
302
+ this.emit("Message", gift);
303
+ extraDataController.addMessage(gift);
304
+ break;
305
+ }
306
+ // 续费钻粉
307
+ case "rndfbc": {
308
+ if (this.saveGiftDanma === false)
309
+ return;
310
+ const gift = {
311
+ type: "give_gift",
312
+ timestamp: Date.now(),
313
+ name: "钻粉",
314
+ price: Number(msg.price) / 100,
315
+ count: 1,
316
+ color: "#ffffff",
317
+ sender: {
318
+ uid: msg.uid,
319
+ name: msg.nick,
320
+ // avatar: msg.ic,
321
+ // extra: {
322
+ // level: msg.level,
323
+ // },
324
+ },
325
+ // extra: {
326
+ // hits: Number(msg.hits),
327
+ // },
328
+ };
329
+ this.emit("Message", gift);
330
+ extraDataController.addMessage(gift);
331
+ break;
182
332
  }
183
333
  case "comm_chatmsg": {
184
334
  if (this.saveSCDanma === false)
@@ -215,70 +365,26 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
215
365
  if (!this.disableProvideCommentsWhenRecording) {
216
366
  client.start();
217
367
  }
218
- let isEnded = false;
219
- const onEnd = async (...args) => {
220
- if (isEnded)
221
- return;
222
- isEnded = true;
223
- this.emit("DebugLog", {
224
- type: "common",
225
- text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
226
- });
227
- const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
228
- this.recordHandle?.stop(reason);
229
- };
230
- const isInvalidStream = utils.createInvalidStreamChecker();
231
- const timeoutChecker = utils.createTimeoutChecker(() => onEnd("ffmpeg timeout"), 10e3);
232
- const command = createFFMPEGBuilder(stream.url)
233
- .inputOptions("-user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36")
234
- .outputOptions(ffmpegOutputOptions)
235
- .output(streamManager.videoFilePath)
236
- .on("start", () => {
237
- streamManager.handleVideoStarted();
238
- })
239
- .on("error", onEnd)
240
- .on("end", () => onEnd("finished"))
241
- .on("stderr", async (stderrLine) => {
242
- assert(typeof stderrLine === "string");
243
- if (utils.isFfmpegStartSegment(stderrLine)) {
244
- await streamManager.handleVideoStarted(stderrLine);
245
- }
246
- // TODO:解析时间
247
- this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
248
- if (isInvalidStream(stderrLine)) {
249
- onEnd("invalid stream");
250
- }
251
- })
252
- .on("stderr", timeoutChecker.update);
253
- if (hasSegment) {
254
- command.outputOptions("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
255
- }
256
- const ffmpegArgs = command._getArguments();
257
- command.run();
368
+ const ffmpegArgs = recorder.getArguments();
369
+ recorder.run();
258
370
  // TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
259
371
  const stop = utils.singleton(async (reason) => {
260
372
  if (!this.recordHandle)
261
373
  return;
262
374
  this.state = "stopping-record";
263
- // TODO: emit update event
264
- timeoutChecker.stop();
375
+ client.stop();
265
376
  try {
266
- // @ts-ignore
267
- command.ffmpegProc?.stdin?.write("q");
268
- // TODO: 这里可能会有内存泄露,因为事件还没清,之后再检查下看看。
269
- client.stop();
377
+ await recorder.stop();
270
378
  }
271
379
  catch (err) {
272
- // TODO: 这个 stop 经常报错,这里先把错误吞掉,以后再处理。
273
- this.emit("DebugLog", { type: "common", text: String(err) });
380
+ this.emit("DebugLog", {
381
+ type: "common",
382
+ text: `stop ffmpeg error: ${String(err)}`,
383
+ });
274
384
  }
275
385
  this.usedStream = undefined;
276
386
  this.usedSource = undefined;
277
- // TODO: other codes
278
- // TODO: emit update event
279
- await streamManager.handleVideoCompleted();
280
387
  this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
281
- this.off("videoFileCreated", handleVideoCreated);
282
388
  this.recordHandle = undefined;
283
389
  this.liveInfo = undefined;
284
390
  this.state = "idle";
package/lib/utils.d.ts CHANGED
@@ -17,3 +17,5 @@
17
17
  export declare function getValuesFromArrayLikeFlexSpaceBetween<T>(array: T[], columnCount: number): T[];
18
18
  export declare function ensureFolderExist(fileOrFolderPath: string): void;
19
19
  export declare function assert(assertion: unknown, msg?: string): asserts assertion;
20
+ export declare const uuid: () => `${string}-${string}-${string}-${string}-${string}`;
21
+ export declare function createInvalidStreamChecker(): (ffmpegLogLine: string) => boolean;
package/lib/utils.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
3
4
  import { range } from "lodash-es";
4
5
  /**
5
6
  * 从数组中按照特定算法提取一些值(允许同个索引重复提取)。
@@ -50,3 +51,31 @@ export function assert(assertion, msg) {
50
51
  throw new Error(msg);
51
52
  }
52
53
  }
54
+ export const uuid = () => {
55
+ return crypto.randomUUID();
56
+ };
57
+ export function createInvalidStreamChecker() {
58
+ let prevFrame = 0;
59
+ let frameUnchangedCount = 0;
60
+ return (ffmpegLogLine) => {
61
+ const streamInfo = ffmpegLogLine.match(/frame=\s*(\d+) fps=.*? q=.*? size=.*? time=.*? bitrate=.*? speed=.*?/);
62
+ if (streamInfo != null) {
63
+ const [, frameText] = streamInfo;
64
+ const frame = Number(frameText);
65
+ if (frame === prevFrame) {
66
+ if (++frameUnchangedCount >= 15) {
67
+ return true;
68
+ }
69
+ }
70
+ else {
71
+ prevFrame = frame;
72
+ frameUnchangedCount = 0;
73
+ }
74
+ return false;
75
+ }
76
+ if (ffmpegLogLine.includes("HTTP error 404 Not Found")) {
77
+ return true;
78
+ }
79
+ return false;
80
+ };
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/douyu-recorder",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "bililive-tools douyu recorder implemention",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -41,14 +41,11 @@
41
41
  "lodash-es": "^4.17.21",
42
42
  "axios": "^1.7.8",
43
43
  "douyu-api": "^0.1.0",
44
- "@bililive-tools/manager": "1.0.1"
44
+ "@bililive-tools/manager": "^1.1.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/ws": "^8.5.13"
48
48
  },
49
- "peerDependencies": {
50
- "@bililive-tools/manager": "*"
51
- },
52
49
  "optionalDependencies": {
53
50
  "bufferutil": "^4.0.8",
54
51
  "utf-8-validate": "^6.0.5"