@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 +3 -1
- package/lib/douyin_api.d.ts +5 -0
- package/lib/douyin_api.js +11 -3
- package/lib/index.js +105 -28
- package/lib/stream.d.ts +1 -0
- package/lib/stream.js +15 -1
- package/package.json +3 -3
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
|
|
package/lib/douyin_api.d.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
276
|
-
|
|
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
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.
|
|
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
|
-
"
|
|
42
|
-
"
|
|
41
|
+
"douyin-danma-listener": "0.2.1",
|
|
42
|
+
"@bililive-tools/manager": "^1.9.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "*"
|