@bililive-tools/douyu-recorder 1.0.2 → 1.2.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,15 +35,18 @@ manager.startCheckLoop();
33
35
  interface Options {
34
36
  channelId: string; // 直播间ID,具体解析见文档,也可自行解析
35
37
  quality: number; // 见画质参数
36
- qualityRetry?: number; // 画质匹配重试次数
38
+ qualityRetry?: number; // 画质匹配重试次数, -1为强制匹配画质,0为自动配置,正整数为最大匹配次数
39
+ source?: string; // 指定 cdn,见文档,不传为自动
37
40
  streamPriorities: []; // 废弃
38
41
  sourcePriorities: []; // 废弃
39
42
  disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
40
- segment?: number; // 分段参数
43
+ segment?: number; // 分段参数,单位分钟
44
+ titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
41
45
  disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
42
46
  saveGiftDanma?: boolean; // 保存礼物弹幕
43
47
  saveSCDanma?: boolean; // 保存高能弹幕
44
48
  saveCover?: boolean; // 保存封面
49
+ videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
45
50
  }
46
51
  ```
47
52
 
@@ -64,10 +69,24 @@ interface Options {
64
69
  ```ts
65
70
  import { provider } from "@bililive-tools/douyu-recorder";
66
71
 
67
- const url = "https://live.bilibili.com/5055636";
72
+ const url = "https://www.douyu.com/2140934";
68
73
  const { id } = await provider.resolveChannelInfoFromURL(url);
69
74
  ```
70
75
 
76
+ ## cdn
77
+
78
+ 如果有更多线路或者错误,请发issue
79
+
80
+ | 线路 | 值 |
81
+ | ------ | --------- |
82
+ | 自动 | auto |
83
+ | 线路1 | scdnctshh |
84
+ | 线路4 | tctc-h5 |
85
+ | 线路5 | tct-h5 |
86
+ | 线路6 | ali-h5 |
87
+ | 线路7 | hw-h5 |
88
+ | 线路13 | hs-h5 |
89
+
71
90
  # 协议
72
91
 
73
92
  与原项目保存一致为 LGPL
@@ -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;
@@ -9,7 +9,7 @@ import { BufferCoder } from "./buffer_coder.js";
9
9
  import { STT } from "./stt.js";
10
10
  export function createDYClient(channelId, opts = {}) {
11
11
  let ws = null;
12
- let maxRetry = 5;
12
+ let maxRetry = 10;
13
13
  let coder = new BufferCoder();
14
14
  let heartbeatTimer = null;
15
15
  const send = (message) => ws?.send(coder.encode(STT.serialize(message)));
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,52 @@ 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;
71
111
  if (liveInfo.liveId === banLiveId) {
72
112
  this.tempStopIntervalCheck = true;
73
113
  }
@@ -78,17 +118,43 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
78
118
  return null;
79
119
  if (!living)
80
120
  return null;
81
- this.state = "recording";
121
+ // 检查标题是否包含关键词,如果包含则不自动录制
122
+ // 手动开始录制时不检查标题关键词
123
+ if (!isManualStart &&
124
+ this.titleKeywords &&
125
+ typeof this.titleKeywords === "string" &&
126
+ this.titleKeywords.trim()) {
127
+ const keywords = this.titleKeywords
128
+ .split(",")
129
+ .map((k) => k.trim())
130
+ .filter((k) => k);
131
+ const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
132
+ if (hasTitleKeyword) {
133
+ this.emit("DebugLog", {
134
+ type: "common",
135
+ text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
136
+ });
137
+ return null;
138
+ }
139
+ }
140
+ this.emit("LiveStart", { liveId });
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,
91
156
  quality: this.quality,
157
+ source: this.source,
92
158
  strictQuality,
93
159
  });
94
160
  }
@@ -97,14 +163,36 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
97
163
  this.qualityRetry -= 1;
98
164
  throw err;
99
165
  }
166
+ this.state = "recording";
100
167
  const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
101
168
  this.availableStreams = availableStreams.map((s) => s.name);
102
169
  this.availableSources = availableSources.map((s) => s.name);
103
170
  this.usedStream = stream.name;
104
171
  this.usedSource = stream.source;
105
- // TODO: emit update event
106
- const savePath = getSavePath({ owner, title });
107
- const hasSegment = !!this.segment;
172
+ const onEnd = (...args) => {
173
+ if (isEnded)
174
+ return;
175
+ isEnded = true;
176
+ this.emit("DebugLog", {
177
+ type: "common",
178
+ text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
179
+ });
180
+ const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
181
+ this.recordHandle?.stop(reason);
182
+ };
183
+ let isEnded = false;
184
+ const recorder = new FFMPEGRecorder({
185
+ url: stream.url,
186
+ outputOptions: ffmpegOutputOptions,
187
+ segment: this.segment ?? 0,
188
+ getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
189
+ disableDanma: this.disableProvideCommentsWhenRecording,
190
+ videoFormat: this.videoFormat ?? "auto",
191
+ }, onEnd);
192
+ const savePath = getSavePath({
193
+ owner,
194
+ title,
195
+ });
108
196
  try {
109
197
  ensureFolderExist(savePath);
110
198
  }
@@ -112,25 +200,36 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
112
200
  this.state = "idle";
113
201
  throw err;
114
202
  }
115
- const streamManager = new StreamManager(this, getSavePath, owner, title, savePath, hasSegment);
116
203
  const handleVideoCreated = async ({ filename }) => {
117
- const extraDataController = streamManager?.getExtraDataController();
204
+ this.emit("videoFileCreated", { filename });
205
+ const extraDataController = recorder.getExtraDataController();
118
206
  extraDataController?.setMeta({
119
207
  room_id: this.channelId,
120
208
  platform: provider?.id,
121
209
  liveStartTimestamp: liveInfo.startTime?.getTime(),
210
+ recordStopTimestamp: Date.now(),
211
+ title: title,
212
+ user_name: owner,
122
213
  });
123
- if (this.saveCover) {
124
- const coverPath = utils.replaceExtName(filename, ".jpg");
125
- utils.downloadImage(cover, coverPath);
126
- }
127
214
  };
128
- this.on("videoFileCreated", handleVideoCreated);
215
+ recorder.on("videoFileCreated", handleVideoCreated);
216
+ recorder.on("videoFileCompleted", ({ filename }) => {
217
+ this.emit("videoFileCompleted", { filename });
218
+ });
219
+ recorder.on("DebugLog", (data) => {
220
+ this.emit("DebugLog", data);
221
+ });
222
+ recorder.on("progress", (progress) => {
223
+ if (this.recordHandle) {
224
+ this.recordHandle.progress = progress;
225
+ }
226
+ this.emit("progress", progress);
227
+ });
129
228
  const client = createDYClient(Number(this.channelId), {
130
229
  notAutoStart: true,
131
230
  });
132
231
  client.on("message", (msg) => {
133
- const extraDataController = streamManager.getExtraDataController();
232
+ const extraDataController = recorder.getExtraDataController();
134
233
  if (!extraDataController)
135
234
  return;
136
235
  switch (msg.type) {
@@ -178,7 +277,60 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
178
277
  this.emit("Message", gift);
179
278
  extraDataController.addMessage(gift);
180
279
  break;
181
- // TODO: 还有一些其他礼物相关的 msg 要处理,目前先简单点只处理 dgb
280
+ }
281
+ // 开通钻粉
282
+ case "odfbc": {
283
+ if (this.saveGiftDanma === false)
284
+ return;
285
+ const gift = {
286
+ type: "give_gift",
287
+ timestamp: Date.now(),
288
+ name: "钻粉",
289
+ price: Number(msg.price) / 100,
290
+ count: 1,
291
+ color: "#ffffff",
292
+ sender: {
293
+ uid: msg.uid,
294
+ name: msg.nick,
295
+ // avatar: msg.ic,
296
+ // extra: {
297
+ // level: msg.level,
298
+ // },
299
+ },
300
+ // extra: {
301
+ // hits: Number(msg.hits),
302
+ // },
303
+ };
304
+ this.emit("Message", gift);
305
+ extraDataController.addMessage(gift);
306
+ break;
307
+ }
308
+ // 续费钻粉
309
+ case "rndfbc": {
310
+ if (this.saveGiftDanma === false)
311
+ return;
312
+ const gift = {
313
+ type: "give_gift",
314
+ timestamp: Date.now(),
315
+ name: "钻粉",
316
+ price: Number(msg.price) / 100,
317
+ count: 1,
318
+ color: "#ffffff",
319
+ sender: {
320
+ uid: msg.uid,
321
+ name: msg.nick,
322
+ // avatar: msg.ic,
323
+ // extra: {
324
+ // level: msg.level,
325
+ // },
326
+ },
327
+ // extra: {
328
+ // hits: Number(msg.hits),
329
+ // },
330
+ };
331
+ this.emit("Message", gift);
332
+ extraDataController.addMessage(gift);
333
+ break;
182
334
  }
183
335
  case "comm_chatmsg": {
184
336
  if (this.saveSCDanma === false)
@@ -215,70 +367,26 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
215
367
  if (!this.disableProvideCommentsWhenRecording) {
216
368
  client.start();
217
369
  }
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();
370
+ const ffmpegArgs = recorder.getArguments();
371
+ recorder.run();
258
372
  // TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
259
373
  const stop = utils.singleton(async (reason) => {
260
374
  if (!this.recordHandle)
261
375
  return;
262
376
  this.state = "stopping-record";
263
- // TODO: emit update event
264
- timeoutChecker.stop();
377
+ client.stop();
265
378
  try {
266
- // @ts-ignore
267
- command.ffmpegProc?.stdin?.write("q");
268
- // TODO: 这里可能会有内存泄露,因为事件还没清,之后再检查下看看。
269
- client.stop();
379
+ await recorder.stop();
270
380
  }
271
381
  catch (err) {
272
- // TODO: 这个 stop 经常报错,这里先把错误吞掉,以后再处理。
273
- this.emit("DebugLog", { type: "common", text: String(err) });
382
+ this.emit("DebugLog", {
383
+ type: "common",
384
+ text: `stop ffmpeg error: ${String(err)}`,
385
+ });
274
386
  }
275
387
  this.usedStream = undefined;
276
388
  this.usedSource = undefined;
277
- // TODO: other codes
278
- // TODO: emit update event
279
- await streamManager.handleVideoCompleted();
280
389
  this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
281
- this.off("videoFileCreated", handleVideoCreated);
282
390
  this.recordHandle = undefined;
283
391
  this.liveInfo = undefined;
284
392
  this.state = "idle";
package/lib/stream.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare function getInfo(channelId: string): Promise<{
11
11
  export declare function getStream(opts: Pick<Recorder, "channelId" | "quality"> & {
12
12
  rejectCache?: boolean;
13
13
  strictQuality?: boolean;
14
+ source?: string;
14
15
  }): Promise<{
15
16
  living: true;
16
17
  sources: import("./dy_api.js").SourceProfile[];
package/lib/stream.js CHANGED
@@ -47,6 +47,7 @@ export async function getStream(opts) {
47
47
  let liveInfo = await getLiveInfo({
48
48
  channelId: opts.channelId,
49
49
  rate: qn,
50
+ cdn: opts.source === "auto" ? undefined : opts.source,
50
51
  });
51
52
  if (!liveInfo.living)
52
53
  throw new Error("It must be called getStream when living");
package/lib/utils.d.ts CHANGED
@@ -18,3 +18,4 @@ export declare function getValuesFromArrayLikeFlexSpaceBetween<T>(array: T[], co
18
18
  export declare function ensureFolderExist(fileOrFolderPath: string): void;
19
19
  export declare function assert(assertion: unknown, msg?: string): asserts assertion;
20
20
  export declare const uuid: () => `${string}-${string}-${string}-${string}-${string}`;
21
+ export declare function createInvalidStreamChecker(): (ffmpegLogLine: string) => boolean;
package/lib/utils.js CHANGED
@@ -54,3 +54,28 @@ export function assert(assertion, msg) {
54
54
  export const uuid = () => {
55
55
  return crypto.randomUUID();
56
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.2",
3
+ "version": "1.2.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.2.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"