@bililive-tools/douyin-recorder 1.7.1 → 1.9.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
@@ -45,10 +45,12 @@ interface Options {
45
45
  videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
46
46
  useServerTimestamp?: boolean; // 控制弹幕是否使用服务端时间戳,默认为true
47
47
  doubleScreen?: boolean; // 是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true
48
- recorderType?: "auto" | "ffmpeg" | "mesio"; // 底层录制器,使用mesio时videoFormat参数无效
48
+ recorderType?: "auto" | "ffmpeg" | "mesio" | "bililive"; // 底层录制器,使用mesio和bililive时videoFormat参数无效
49
49
  auth?: string; // 传递cookie
50
50
  uid?: string; // 参数为 sec_user_uid 参数
51
51
  api?: "web" | "webHTML" | "mobile" | "userHTML" | "balance" | "random"; // 使用不同的接口,默认使用web,具体区别见文档
52
+ titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
53
+ debugLevel?: `verbose` | "basic"; // verbose参数时,录制器会输出更加详细的log
52
54
  }
53
55
  ```
54
56
 
@@ -6,6 +6,11 @@ import type { APIType, RealAPIType } from "./types.js";
6
6
  */
7
7
  export declare function resolveShortURL(shortURL: string): Promise<string>;
8
8
  export declare const getCookie: () => Promise<string>;
9
+ /**
10
+ * 随机选择一个可用的 API 接口
11
+ * @returns 随机选择的 API 类型
12
+ */
13
+ export declare function selectRandomAPI(exclude?: RealAPIType[]): RealAPIType;
9
14
  export declare function getRoomInfo(webRoomId: string, opts?: {
10
15
  auth?: string;
11
16
  doubleScreen?: boolean;
package/lib/douyin_api.js CHANGED
@@ -107,8 +107,16 @@ function generateNonce() {
107
107
  * 随机选择一个可用的 API 接口
108
108
  * @returns 随机选择的 API 类型
109
109
  */
110
- function selectRandomAPI() {
110
+ export function selectRandomAPI(exclude) {
111
111
  const availableAPIs = ["web", "webHTML", "mobile", "userHTML"];
112
+ if (exclude && exclude.length > 0) {
113
+ for (const api of exclude) {
114
+ const index = availableAPIs.indexOf(api);
115
+ if (index !== -1) {
116
+ availableAPIs.splice(index, 1);
117
+ }
118
+ }
119
+ }
112
120
  const randomIndex = Math.floor(Math.random() * availableAPIs.length);
113
121
  return availableAPIs[randomIndex];
114
122
  }
@@ -151,7 +159,7 @@ async function getRoomInfoByUserWeb(secUserId, opts = {}) {
151
159
  nickname: "",
152
160
  sec_uid: "",
153
161
  avatar: "",
154
- api: "webHTML",
162
+ api: "userHTML",
155
163
  room: null,
156
164
  };
157
165
  }
@@ -244,7 +252,7 @@ async function getRoomInfoByHtml(webRoomId, opts = {}) {
244
252
  nickname: roomInfo?.anchor?.nickname ?? "",
245
253
  sec_uid: roomInfo?.anchor?.sec_uid ?? "",
246
254
  avatar: roomInfo?.anchor?.avatar_thumb?.url_list?.[0] ?? "",
247
- api: "userHTML",
255
+ api: "webHTML",
248
256
  room: {
249
257
  title: roomInfo?.room?.title ?? "",
250
258
  cover: roomInfo?.room?.cover?.url_list?.[0] ?? "",
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import mitt from "mitt";
3
- import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, createBaseRecorder, } from "@bililive-tools/manager";
3
+ import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createBaseRecorder, } from "@bililive-tools/manager";
4
4
  import { getInfo, getStream } from "./stream.js";
5
5
  import { ensureFolderExist, singleton } from "./utils.js";
6
6
  import { resolveShortURL, parseUser } from "./douyin_api.js";
@@ -59,27 +59,48 @@ function createRecorder(opts) {
59
59
  });
60
60
  return recorderWithSupportUpdatedEvent;
61
61
  }
62
- const ffmpegOutputOptions = [
63
- "-c",
64
- "copy",
65
- "-movflags",
66
- "faststart+frag_keyframe+empty_moov",
67
- "-min_frag_duration",
68
- "10000000",
69
- ];
70
- const ffmpegInputOptions = [
71
- "-reconnect",
72
- "1",
73
- "-reconnect_streamed",
74
- "1",
75
- "-reconnect_delay_max",
76
- "10",
77
- "-rw_timeout",
78
- "15000000",
79
- ];
62
+ const ffmpegOutputOptions = [];
63
+ const ffmpegInputOptions = ["-rw_timeout", "10000000", "-timeout", "10000000"];
80
64
  const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
81
- if (this.recordHandle != null)
65
+ // 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
66
+ if (this.recordHandle != null) {
67
+ // 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
68
+ if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
69
+ const now = Date.now();
70
+ // 每5分钟检查一次标题变化
71
+ const titleCheckInterval = 5 * 60 * 1000; // 5分钟
72
+ // 获取上次检查时间
73
+ const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
74
+ // 如果距离上次检查时间不足指定间隔,则跳过检查
75
+ if (now - lastCheckTime < titleCheckInterval) {
76
+ return this.recordHandle;
77
+ }
78
+ // 更新检查时间
79
+ this.extra.lastTitleCheckTime = now;
80
+ // 获取直播间信息
81
+ const liveInfo = await getInfo(this.channelId, {
82
+ cookie: this.auth,
83
+ api: this.api,
84
+ uid: this.uid,
85
+ });
86
+ const { title } = liveInfo;
87
+ // 检查标题是否包含关键词
88
+ if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
89
+ this.state = "title-blocked";
90
+ this.emit("DebugLog", {
91
+ type: "common",
92
+ text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
93
+ });
94
+ // 停止录制
95
+ await this.recordHandle.stop("直播间标题包含关键词");
96
+ // 返回 null,停止录制
97
+ return null;
98
+ }
99
+ }
100
+ // 已经在录制中,直接返回
82
101
  return this.recordHandle;
102
+ }
103
+ // 获取直播间信息
83
104
  try {
84
105
  const liveInfo = await getInfo(this.channelId, {
85
106
  cookie: this.auth,
@@ -103,6 +124,18 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
103
124
  return null;
104
125
  if (!this.liveInfo.living)
105
126
  return null;
127
+ // 检查标题是否包含关键词,如果包含则不自动录制
128
+ // 手动开始录制时不检查标题关键词
129
+ if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
130
+ if (utils.hasBlockedTitleKeywords(this.liveInfo.title, this.titleKeywords)) {
131
+ this.state = "title-blocked";
132
+ this.emit("DebugLog", {
133
+ type: "common",
134
+ text: `跳过录制:直播间标题 "${this.liveInfo.title}" 包含关键词 "${this.titleKeywords}"`,
135
+ });
136
+ return null;
137
+ }
138
+ }
106
139
  let res;
107
140
  try {
108
141
  let strictQuality = false;
@@ -141,7 +174,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
141
174
  this.state = "check-error";
142
175
  throw err;
143
176
  }
144
- const { owner, title } = this.liveInfo;
177
+ const { owner, title, startTime } = this.liveInfo;
145
178
  this.state = "recording";
146
179
  const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
147
180
  this.availableStreams = availableStreams.map((s) => s.desc);
@@ -165,15 +198,23 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
165
198
  const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
166
199
  this.recordHandle?.stop(reason);
167
200
  };
168
- let recorderType = this.recorderType === "mesio" ? "mesio" : "ffmpeg";
169
- const recorder = createBaseRecorder(recorderType, {
201
+ const recordStartTime = new Date();
202
+ const recorder = createBaseRecorder(this.recorderType, {
170
203
  url: stream.url,
171
204
  outputOptions: ffmpegOutputOptions,
172
205
  inputOptions: ffmpegInputOptions,
173
206
  segment: this.segment ?? 0,
174
- getSavePath: (opts) => getSavePath({ owner, title: opts.title ?? title, startTime: opts.startTime }),
207
+ getSavePath: (opts) => getSavePath({
208
+ owner,
209
+ title: opts.title ?? title,
210
+ startTime: opts.startTime,
211
+ liveStartTime: startTime,
212
+ recordStartTime,
213
+ }),
175
214
  disableDanma: this.disableProvideCommentsWhenRecording,
176
215
  videoFormat: this.videoFormat ?? "auto",
216
+ debugLevel: this.debugLevel ?? "none",
217
+ onlyAudio: stream.onlyAudio,
177
218
  headers: {
178
219
  Cookie: this.auth,
179
220
  },
@@ -184,6 +225,9 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
184
225
  const savePath = getSavePath({
185
226
  owner,
186
227
  title,
228
+ startTime: Date.now(),
229
+ liveStartTime: startTime,
230
+ recordStartTime,
187
231
  });
188
232
  try {
189
233
  ensureFolderExist(savePath);
@@ -192,8 +236,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
192
236
  this.state = "idle";
193
237
  throw err;
194
238
  }
195
- const handleVideoCreated = async ({ filename, title, cover }) => {
196
- this.emit("videoFileCreated", { filename, cover });
239
+ const handleVideoCreated = async ({ filename, title, cover, rawFilename }) => {
240
+ this.emit("videoFileCreated", { filename, cover, rawFilename });
197
241
  if (title && this?.liveInfo) {
198
242
  this.liveInfo.title = title;
199
243
  }
@@ -223,6 +267,10 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
223
267
  }
224
268
  this.emit("progress", progress);
225
269
  });
270
+ // 礼物消息缓存管理
271
+ const giftMessageCache = new Map();
272
+ // 礼物延迟处理时间(毫秒),可根据实际情况调整
273
+ const GIFT_DELAY = 5000;
226
274
  const client = new DouYinDanmaClient(this?.liveInfo?.liveId, {
227
275
  cookie: this.auth,
228
276
  });
@@ -272,8 +320,25 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
272
320
  // },
273
321
  },
274
322
  };
275
- this.emit("Message", gift);
276
- extraDataController.addMessage(gift);
323
+ // 单独使用groupId并不可靠
324
+ const groupId = `${msg.groupId}_${msg.user.id}_${msg.giftId}`;
325
+ // 如果已存在相同 groupId 的礼物,清除旧的定时器
326
+ const existing = giftMessageCache.get(groupId);
327
+ if (existing) {
328
+ clearTimeout(existing.timer);
329
+ }
330
+ // 创建新的定时器
331
+ const timer = setTimeout(() => {
332
+ const cachedGift = giftMessageCache.get(groupId);
333
+ if (cachedGift) {
334
+ // 延迟时间到,添加最终的礼物消息
335
+ this.emit("Message", cachedGift.gift);
336
+ extraDataController.addMessage(cachedGift.gift);
337
+ giftMessageCache.delete(groupId);
338
+ }
339
+ }, GIFT_DELAY);
340
+ // 更新缓存
341
+ giftMessageCache.set(groupId, { gift, timer });
277
342
  });
278
343
  client.on("reconnect", (attempts) => {
279
344
  this.emit("DebugLog", {
@@ -337,6 +402,17 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
337
402
  return;
338
403
  this.state = "stopping-record";
339
404
  try {
405
+ // 清理所有礼物缓存定时器
406
+ for (const [_groupId, cached] of giftMessageCache.entries()) {
407
+ clearTimeout(cached.timer);
408
+ // 立即添加剩余的礼物消息
409
+ const extraDataController = recorder.getExtraDataController();
410
+ if (extraDataController) {
411
+ this.emit("Message", cached.gift);
412
+ extraDataController.addMessage(cached.gift);
413
+ }
414
+ }
415
+ giftMessageCache.clear();
340
416
  client.close();
341
417
  await recorder.stop();
342
418
  }
@@ -357,6 +433,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
357
433
  id: genRecordUUID(),
358
434
  stream: stream.name,
359
435
  source: stream.source,
436
+ recorderType: recorder.type,
360
437
  url: stream.url,
361
438
  ffmpegArgs,
362
439
  savePath: savePath,
package/lib/stream.d.ts CHANGED
@@ -29,6 +29,7 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" |
29
29
  name: string;
30
30
  source: string;
31
31
  url: string;
32
+ onlyAudio: boolean;
32
33
  };
33
34
  living: boolean;
34
35
  roomId: string;
package/lib/stream.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getRoomInfo } from "./douyin_api.js";
1
+ import { getRoomInfo, selectRandomAPI } from "./douyin_api.js";
2
2
  import { globalLoadBalancer } from "./loadBalancer/loadBalancer.js";
3
3
  export async function getInfo(channelId, opts) {
4
4
  let info;
@@ -31,6 +31,9 @@ export async function getStream(opts) {
31
31
  // userHTML 接口只能用于状态检测
32
32
  api = "web";
33
33
  }
34
+ else if (api === "random") {
35
+ api = selectRandomAPI(["userHTML"]);
36
+ }
34
37
  const info = await getRoomInfo(opts.channelId, {
35
38
  doubleScreen: opts.doubleScreen ?? true,
36
39
  auth: opts.cookie,
@@ -69,12 +72,23 @@ export async function getStream(opts) {
69
72
  if (!url) {
70
73
  throw new Error("未找到对应的流");
71
74
  }
75
+ let onlyAudio = false;
76
+ try {
77
+ const urlObj = new URL(url);
78
+ if (urlObj.searchParams.get("only_audio") == "1") {
79
+ onlyAudio = true;
80
+ }
81
+ }
82
+ catch (error) {
83
+ console.warn("解析流 URL 失败", error);
84
+ }
72
85
  return {
73
86
  ...info,
74
87
  currentStream: {
75
88
  name: qualityName,
76
89
  source: "自动",
77
90
  url: url,
91
+ onlyAudio,
78
92
  },
79
93
  };
80
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/douyin-recorder",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "@bililive-tools douyin recorder implemention",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -38,8 +38,8 @@
38
38
  "lodash-es": "^4.17.21",
39
39
  "mitt": "^3.0.1",
40
40
  "sm-crypto": "^0.3.13",
41
- "@bililive-tools/manager": "^1.6.1",
42
- "douyin-danma-listener": "0.2.0"
41
+ "douyin-danma-listener": "0.2.1",
42
+ "@bililive-tools/manager": "^1.9.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "*"