@bililive-tools/douyin-recorder 1.6.0 → 1.7.1
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 +16 -2
- package/lib/douyin_api.d.ts +5 -1
- package/lib/douyin_api.js +209 -19
- package/lib/index.js +28 -12
- package/lib/loadBalancer/example.d.ts +2 -0
- package/lib/loadBalancer/example.js +130 -0
- package/lib/loadBalancer/loadBalancer.d.ts +71 -0
- package/lib/loadBalancer/loadBalancer.js +196 -0
- package/lib/loadBalancer/loadBalancerManager.d.ts +75 -0
- package/lib/loadBalancer/loadBalancerManager.js +127 -0
- package/lib/stream.d.ts +9 -2
- package/lib/stream.js +21 -2
- package/lib/types.d.ts +33 -0
- package/lib/types.js +1 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -46,8 +46,9 @@ interface Options {
|
|
|
46
46
|
useServerTimestamp?: boolean; // 控制弹幕是否使用服务端时间戳,默认为true
|
|
47
47
|
doubleScreen?: boolean; // 是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true
|
|
48
48
|
recorderType?: "auto" | "ffmpeg" | "mesio"; // 底层录制器,使用mesio时videoFormat参数无效
|
|
49
|
-
auth?: string; // 传递cookie
|
|
50
|
-
|
|
49
|
+
auth?: string; // 传递cookie
|
|
50
|
+
uid?: string; // 参数为 sec_user_uid 参数
|
|
51
|
+
api?: "web" | "webHTML" | "mobile" | "userHTML" | "balance" | "random"; // 使用不同的接口,默认使用web,具体区别见文档
|
|
51
52
|
}
|
|
52
53
|
```
|
|
53
54
|
|
|
@@ -77,6 +78,19 @@ const url = "https://live.douyin.com/203641303310";
|
|
|
77
78
|
const { id } = await provider.resolveChannelInfoFromURL(url);
|
|
78
79
|
```
|
|
79
80
|
|
|
81
|
+
## 不同请求接口的区别
|
|
82
|
+
|
|
83
|
+
`mobile` 及 `userHTML` 必须传入 `uid` 参数
|
|
84
|
+
|
|
85
|
+
| 描述 | 备注 |
|
|
86
|
+
| ---------------- | ---------------------------------------- |
|
|
87
|
+
| web直播间接口 | 效果不错 |
|
|
88
|
+
| mobile直播间接口 | 不易风控,无验证码,海外IP可能无法使用 |
|
|
89
|
+
| 直播间web解析 | 易风控,有验证码,单个接口1M流量 |
|
|
90
|
+
| 用户web解析 | 不易风控,海外IP无法使用,单个接口1M流量 |
|
|
91
|
+
| 负载均衡 | 使用负载均衡算法来分摊防止风控 |
|
|
92
|
+
| 随机 | 从几个接口里挑一个 |
|
|
93
|
+
|
|
80
94
|
# 协议
|
|
81
95
|
|
|
82
96
|
与原项目保存一致为 LGPL
|
package/lib/douyin_api.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { APIType, RealAPIType } from "./types.js";
|
|
1
2
|
/**
|
|
2
3
|
* 从抖音短链接解析得到直播间ID
|
|
3
4
|
* @param shortURL 短链接,如 https://v.douyin.com/DpfoBLAXoHM/
|
|
@@ -8,7 +9,8 @@ export declare const getCookie: () => Promise<string>;
|
|
|
8
9
|
export declare function getRoomInfo(webRoomId: string, opts?: {
|
|
9
10
|
auth?: string;
|
|
10
11
|
doubleScreen?: boolean;
|
|
11
|
-
api?:
|
|
12
|
+
api?: APIType;
|
|
13
|
+
uid?: string | number;
|
|
12
14
|
}): Promise<{
|
|
13
15
|
living: boolean;
|
|
14
16
|
roomId: string;
|
|
@@ -19,6 +21,8 @@ export declare function getRoomInfo(webRoomId: string, opts?: {
|
|
|
19
21
|
avatar: string;
|
|
20
22
|
cover: string;
|
|
21
23
|
liveId: string;
|
|
24
|
+
uid: string;
|
|
25
|
+
api: RealAPIType;
|
|
22
26
|
}>;
|
|
23
27
|
/**
|
|
24
28
|
* 解析抖音号
|
package/lib/douyin_api.js
CHANGED
|
@@ -104,7 +104,107 @@ function generateNonce() {
|
|
|
104
104
|
return nonce;
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
107
|
+
* 随机选择一个可用的 API 接口
|
|
108
|
+
* @returns 随机选择的 API 类型
|
|
109
|
+
*/
|
|
110
|
+
function selectRandomAPI() {
|
|
111
|
+
const availableAPIs = ["web", "webHTML", "mobile", "userHTML"];
|
|
112
|
+
const randomIndex = Math.floor(Math.random() * availableAPIs.length);
|
|
113
|
+
return availableAPIs[randomIndex];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 通过解析直播html页面来获取房间数据
|
|
117
|
+
* @param secUserId
|
|
118
|
+
* @param opts
|
|
119
|
+
*/
|
|
120
|
+
async function getRoomInfoByUserWeb(secUserId, opts = {}) {
|
|
121
|
+
const url = `https://www.douyin.com/user/${secUserId}`;
|
|
122
|
+
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0";
|
|
123
|
+
let nonce = "068ea1c0100bb2c06590f";
|
|
124
|
+
try {
|
|
125
|
+
nonce = await getNonce(url);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
console.warn("获取nonce失败,使用默认值", error);
|
|
129
|
+
}
|
|
130
|
+
let cookies = undefined;
|
|
131
|
+
if (opts.auth) {
|
|
132
|
+
cookies = opts.auth;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
136
|
+
const signed = get__ac_signature(timestamp, url, nonce, ua);
|
|
137
|
+
cookies = `__ac_nonce=${nonce}; __ac_signature=${signed}; __ac_referer=__ac_blank`;
|
|
138
|
+
}
|
|
139
|
+
const res = await axios.get(url, {
|
|
140
|
+
headers: {
|
|
141
|
+
"User-Agent": ua,
|
|
142
|
+
cookie: cookies,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
if (res.data.includes("验证码")) {
|
|
146
|
+
throw new Error("需要验证码,请在浏览器中打开链接获取" + url);
|
|
147
|
+
}
|
|
148
|
+
if (!res.data.includes("直播中")) {
|
|
149
|
+
return {
|
|
150
|
+
living: false,
|
|
151
|
+
nickname: "",
|
|
152
|
+
sec_uid: "",
|
|
153
|
+
avatar: "",
|
|
154
|
+
api: "webHTML",
|
|
155
|
+
room: null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const userRegex = /(\{\\"user\\":.*?)\]\\n"\]\)/;
|
|
159
|
+
// fs.writeFileSync("douyin.html", res.data);
|
|
160
|
+
const userMatch = res.data.match(userRegex);
|
|
161
|
+
if (!userMatch) {
|
|
162
|
+
throw new Error("No match found in HTML");
|
|
163
|
+
}
|
|
164
|
+
let userJsonStr = userMatch[1];
|
|
165
|
+
userJsonStr = userJsonStr
|
|
166
|
+
.replace(/\\"/g, '"')
|
|
167
|
+
.replace(/\\"/g, '"')
|
|
168
|
+
.replace(/"\$\w+"/g, "null");
|
|
169
|
+
// const roomRegex = /(\{\\"common\\":.*?)"\]\)/;
|
|
170
|
+
// const roomMatch = res.data.match(roomRegex);
|
|
171
|
+
// if (!roomMatch) {
|
|
172
|
+
// throw new Error("No room match found in HTML");
|
|
173
|
+
// }
|
|
174
|
+
// let roomJsonStr = roomMatch[1];
|
|
175
|
+
// roomJsonStr = roomJsonStr
|
|
176
|
+
// .replace(/\\"/g, '"')
|
|
177
|
+
// .replace(/\\"/g, '"')
|
|
178
|
+
// .replace(/"\$\w+"/g, "null");
|
|
179
|
+
try {
|
|
180
|
+
// console.log(userJsonStr);
|
|
181
|
+
const userData = JSON.parse(userJsonStr);
|
|
182
|
+
// console.log(JSON.stringify(userData, null, 2));
|
|
183
|
+
// const roomData = JSON.parse(roomJsonStr);
|
|
184
|
+
// console.log(roomData);
|
|
185
|
+
// const roomInfo = data.state.roomStore.roomInfo;
|
|
186
|
+
// const streamData = data.state.streamStore.streamData;
|
|
187
|
+
return {
|
|
188
|
+
living: userData?.user?.user?.roomData?.status === 2,
|
|
189
|
+
nickname: userData?.user?.user?.nickname ?? "",
|
|
190
|
+
sec_uid: userData?.user?.user?.secUid ?? "",
|
|
191
|
+
avatar: userData?.user?.user?.avatar ?? "",
|
|
192
|
+
api: "webHTML",
|
|
193
|
+
room: {
|
|
194
|
+
title: "",
|
|
195
|
+
cover: "",
|
|
196
|
+
id_str: userData?.user?.user?.roomIdStr,
|
|
197
|
+
stream_url: null,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
console.error("Failed to parse JSON:", e);
|
|
203
|
+
throw e;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 通过解析用户html页面来获取房间数据
|
|
108
208
|
* @param webRoomId
|
|
109
209
|
* @param opts
|
|
110
210
|
*/
|
|
@@ -140,15 +240,17 @@ async function getRoomInfoByHtml(webRoomId, opts = {}) {
|
|
|
140
240
|
const roomInfo = data.state.roomStore.roomInfo;
|
|
141
241
|
const streamData = data.state.streamStore.streamData;
|
|
142
242
|
return {
|
|
143
|
-
living: roomInfo
|
|
144
|
-
nickname: roomInfo
|
|
145
|
-
|
|
243
|
+
living: roomInfo?.room?.status === 2,
|
|
244
|
+
nickname: roomInfo?.anchor?.nickname ?? "",
|
|
245
|
+
sec_uid: roomInfo?.anchor?.sec_uid ?? "",
|
|
246
|
+
avatar: roomInfo?.anchor?.avatar_thumb?.url_list?.[0] ?? "",
|
|
247
|
+
api: "userHTML",
|
|
146
248
|
room: {
|
|
147
|
-
title: roomInfo
|
|
148
|
-
cover: roomInfo
|
|
149
|
-
id_str: roomInfo
|
|
249
|
+
title: roomInfo?.room?.title ?? "",
|
|
250
|
+
cover: roomInfo?.room?.cover?.url_list?.[0] ?? "",
|
|
251
|
+
id_str: roomInfo?.room?.id_str ?? "",
|
|
150
252
|
stream_url: {
|
|
151
|
-
pull_datas: roomInfo
|
|
253
|
+
pull_datas: roomInfo?.room?.stream_url?.pull_datas,
|
|
152
254
|
live_core_sdk_data: {
|
|
153
255
|
pull_data: {
|
|
154
256
|
options: { qualities: streamData.H264_streamData?.options?.qualities ?? [] },
|
|
@@ -201,30 +303,101 @@ async function getRoomInfoByWeb(webRoomId, opts = {}) {
|
|
|
201
303
|
});
|
|
202
304
|
assert(res.data.status_code === 0, `Unexpected resp, code ${res.data.status_code}, msg ${JSON.stringify(res.data.data)}, id ${webRoomId}, cookies: ${cookies}`);
|
|
203
305
|
const data = res.data.data;
|
|
204
|
-
const room = data
|
|
205
|
-
|
|
306
|
+
const room = data?.data?.[0];
|
|
307
|
+
return {
|
|
308
|
+
living: data?.room_status === 0,
|
|
309
|
+
nickname: data?.user?.nickname ?? "",
|
|
310
|
+
avatar: data?.user?.avatar_thumb?.url_list?.[0] ?? "",
|
|
311
|
+
sec_uid: data?.user?.sec_uid ?? "",
|
|
312
|
+
api: "web",
|
|
313
|
+
room: {
|
|
314
|
+
title: room?.title ?? "",
|
|
315
|
+
cover: room?.cover?.url_list?.[0] ?? "",
|
|
316
|
+
id_str: room?.id_str ?? "",
|
|
317
|
+
stream_url: room?.stream_url,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
async function getRoomInfoByMobile(secUserId, opts = {}) {
|
|
322
|
+
if (!secUserId) {
|
|
323
|
+
throw new Error("Mobile API need secUserId, please set uid field");
|
|
324
|
+
}
|
|
325
|
+
if (typeof secUserId === "number") {
|
|
326
|
+
throw new Error("Mobile API need secUserId string, please set uid field");
|
|
327
|
+
}
|
|
328
|
+
const params = {
|
|
329
|
+
app_id: 1128,
|
|
330
|
+
live_id: 1,
|
|
331
|
+
verifyFp: "",
|
|
332
|
+
room_id: 2,
|
|
333
|
+
type_id: 0,
|
|
334
|
+
sec_user_id: secUserId,
|
|
335
|
+
};
|
|
336
|
+
const res = await requester.get(`https://webcast.amemv.com/webcast/room/reflow/info/`, {
|
|
337
|
+
params,
|
|
338
|
+
headers: {
|
|
339
|
+
cookie: opts.auth,
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
// @ts-ignore
|
|
343
|
+
const room = res?.data?.data?.room;
|
|
206
344
|
return {
|
|
207
|
-
living:
|
|
208
|
-
nickname:
|
|
209
|
-
|
|
345
|
+
living: room?.status === 2,
|
|
346
|
+
nickname: room?.owner?.nickname,
|
|
347
|
+
sec_uid: room?.owner?.sec_uid,
|
|
348
|
+
avatar: room?.owner?.avatar_thumb?.url_list?.[0],
|
|
349
|
+
api: "mobile",
|
|
210
350
|
room: {
|
|
211
|
-
title: room
|
|
212
|
-
cover: room
|
|
213
|
-
id_str: room
|
|
214
|
-
stream_url: room
|
|
351
|
+
title: room?.title,
|
|
352
|
+
cover: room?.cover?.url_list?.[0],
|
|
353
|
+
id_str: room?.id_str,
|
|
354
|
+
stream_url: room?.stream_url,
|
|
215
355
|
},
|
|
216
356
|
};
|
|
217
357
|
}
|
|
218
358
|
export async function getRoomInfo(webRoomId, opts = {}) {
|
|
219
359
|
let data;
|
|
220
|
-
|
|
360
|
+
let api = opts.api ?? "web";
|
|
361
|
+
// 如果选择了 random,则随机选择一个可用的接口
|
|
362
|
+
if (api === "random") {
|
|
363
|
+
api = selectRandomAPI();
|
|
364
|
+
}
|
|
365
|
+
if (api === "mobile" || api === "userHTML") {
|
|
366
|
+
// mobile 接口需要 sec_uid 参数,老数据可能没有,实现兼容
|
|
367
|
+
if (!opts.uid || typeof opts.uid !== "string") {
|
|
368
|
+
api = "web";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (api === "webHTML") {
|
|
221
372
|
data = await getRoomInfoByHtml(webRoomId, opts);
|
|
222
373
|
}
|
|
374
|
+
else if (api === "mobile") {
|
|
375
|
+
data = await getRoomInfoByMobile(opts.uid, opts);
|
|
376
|
+
}
|
|
377
|
+
else if (api === "userHTML") {
|
|
378
|
+
data = await getRoomInfoByUserWeb(opts.uid, opts);
|
|
379
|
+
}
|
|
223
380
|
else {
|
|
224
381
|
data = await getRoomInfoByWeb(webRoomId, opts);
|
|
225
382
|
}
|
|
383
|
+
// console.log(JSON.stringify(data, null, 2));
|
|
226
384
|
const room = data.room;
|
|
227
385
|
assert(room, `No room data, id ${webRoomId}`);
|
|
386
|
+
if (api === "userHTML") {
|
|
387
|
+
return {
|
|
388
|
+
living: data.living,
|
|
389
|
+
roomId: webRoomId,
|
|
390
|
+
owner: data.nickname,
|
|
391
|
+
title: room?.title ?? data.nickname,
|
|
392
|
+
streams: [],
|
|
393
|
+
sources: [],
|
|
394
|
+
avatar: data.avatar,
|
|
395
|
+
cover: room.cover,
|
|
396
|
+
liveId: room.id_str,
|
|
397
|
+
uid: data.sec_uid,
|
|
398
|
+
api: data.api,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
228
401
|
if (room?.stream_url == null) {
|
|
229
402
|
return {
|
|
230
403
|
living: false,
|
|
@@ -236,6 +409,8 @@ export async function getRoomInfo(webRoomId, opts = {}) {
|
|
|
236
409
|
avatar: data.avatar,
|
|
237
410
|
cover: room.cover,
|
|
238
411
|
liveId: room.id_str,
|
|
412
|
+
uid: data.sec_uid,
|
|
413
|
+
api: data.api,
|
|
239
414
|
};
|
|
240
415
|
}
|
|
241
416
|
let qualities = [];
|
|
@@ -314,12 +489,20 @@ export async function getRoomInfo(webRoomId, opts = {}) {
|
|
|
314
489
|
avatar: data.avatar,
|
|
315
490
|
cover: room.cover,
|
|
316
491
|
liveId: room.id_str,
|
|
492
|
+
uid: data.sec_uid,
|
|
493
|
+
api: data.api,
|
|
317
494
|
};
|
|
318
495
|
}
|
|
496
|
+
let nonceCache;
|
|
319
497
|
/**
|
|
320
498
|
* 获取nonce
|
|
321
499
|
*/
|
|
322
500
|
async function getNonce(url) {
|
|
501
|
+
const now = new Date().getTime();
|
|
502
|
+
// 缓存6小时
|
|
503
|
+
if (nonceCache?.startTimestamp && now - nonceCache.startTimestamp < 6 * 60 * 60 * 1000) {
|
|
504
|
+
return nonceCache.nonce;
|
|
505
|
+
}
|
|
323
506
|
const res = await requester.get(url);
|
|
324
507
|
if (!res.headers["set-cookie"]) {
|
|
325
508
|
throw new Error("No cookie in response");
|
|
@@ -332,7 +515,14 @@ async function getNonce(url) {
|
|
|
332
515
|
return;
|
|
333
516
|
cookies[keyPart.trim()] = valuePart.trim();
|
|
334
517
|
});
|
|
335
|
-
|
|
518
|
+
const nonce = cookies["__ac_nonce"];
|
|
519
|
+
if (nonce) {
|
|
520
|
+
nonceCache = {
|
|
521
|
+
startTimestamp: now,
|
|
522
|
+
nonce: nonce,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return nonce;
|
|
336
526
|
}
|
|
337
527
|
/**
|
|
338
528
|
* 解析抖音号
|
package/lib/index.js
CHANGED
|
@@ -31,7 +31,7 @@ function createRecorder(opts) {
|
|
|
31
31
|
const channelId = this.channelId;
|
|
32
32
|
const info = await getInfo(channelId, {
|
|
33
33
|
cookie: this.auth,
|
|
34
|
-
|
|
34
|
+
uid: this.uid,
|
|
35
35
|
});
|
|
36
36
|
return {
|
|
37
37
|
channelId,
|
|
@@ -80,13 +80,20 @@ const ffmpegInputOptions = [
|
|
|
80
80
|
const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
|
|
81
81
|
if (this.recordHandle != null)
|
|
82
82
|
return this.recordHandle;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
try {
|
|
84
|
+
const liveInfo = await getInfo(this.channelId, {
|
|
85
|
+
cookie: this.auth,
|
|
86
|
+
api: this.api,
|
|
87
|
+
uid: this.uid,
|
|
88
|
+
});
|
|
89
|
+
this.liveInfo = liveInfo;
|
|
90
|
+
this.state = "idle";
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
this.state = "check-error";
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (this.liveInfo.liveId && this.liveInfo.liveId === banLiveId) {
|
|
90
97
|
this.tempStopIntervalCheck = true;
|
|
91
98
|
}
|
|
92
99
|
else {
|
|
@@ -94,7 +101,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
94
101
|
}
|
|
95
102
|
if (this.tempStopIntervalCheck)
|
|
96
103
|
return null;
|
|
97
|
-
if (!living)
|
|
104
|
+
if (!this.liveInfo.living)
|
|
98
105
|
return null;
|
|
99
106
|
let res;
|
|
100
107
|
try {
|
|
@@ -108,6 +115,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
108
115
|
if (isManualStart) {
|
|
109
116
|
strictQuality = false;
|
|
110
117
|
}
|
|
118
|
+
// TODO: 检查mobile接口处理双屏录播流
|
|
111
119
|
res = await getStream({
|
|
112
120
|
channelId: this.channelId,
|
|
113
121
|
quality: this.quality,
|
|
@@ -118,14 +126,22 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
118
126
|
formatPriorities: this.formatPriorities,
|
|
119
127
|
doubleScreen: this.doubleScreen,
|
|
120
128
|
api: this.api,
|
|
129
|
+
uid: this.uid,
|
|
121
130
|
});
|
|
131
|
+
this.liveInfo.owner = res.owner;
|
|
132
|
+
this.liveInfo.title = res.title;
|
|
133
|
+
this.liveInfo.cover = res.cover;
|
|
134
|
+
this.liveInfo.liveId = res.liveId;
|
|
135
|
+
this.liveInfo.avatar = res.avatar;
|
|
136
|
+
this.liveInfo.startTime = new Date();
|
|
122
137
|
}
|
|
123
138
|
catch (err) {
|
|
124
139
|
if (this.qualityRetry > 0)
|
|
125
140
|
this.qualityRetry -= 1;
|
|
126
|
-
this.state = "
|
|
141
|
+
this.state = "check-error";
|
|
127
142
|
throw err;
|
|
128
143
|
}
|
|
144
|
+
const { owner, title } = this.liveInfo;
|
|
129
145
|
this.state = "recording";
|
|
130
146
|
const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
|
|
131
147
|
this.availableStreams = availableStreams.map((s) => s.desc);
|
|
@@ -150,7 +166,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
150
166
|
this.recordHandle?.stop(reason);
|
|
151
167
|
};
|
|
152
168
|
let recorderType = this.recorderType === "mesio" ? "mesio" : "ffmpeg";
|
|
153
|
-
// TODO:测试只录制音频,hls以及fmp4
|
|
154
169
|
const recorder = createBaseRecorder(recorderType, {
|
|
155
170
|
url: stream.url,
|
|
156
171
|
outputOptions: ffmpegOutputOptions,
|
|
@@ -208,7 +223,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
208
223
|
}
|
|
209
224
|
this.emit("progress", progress);
|
|
210
225
|
});
|
|
211
|
-
const client = new DouYinDanmaClient(liveInfo
|
|
226
|
+
const client = new DouYinDanmaClient(this?.liveInfo?.liveId, {
|
|
212
227
|
cookie: this.auth,
|
|
213
228
|
});
|
|
214
229
|
client.on("chat", (msg) => {
|
|
@@ -389,6 +404,7 @@ export const provider = {
|
|
|
389
404
|
title: info.title,
|
|
390
405
|
owner: info.owner,
|
|
391
406
|
avatar: info.avatar,
|
|
407
|
+
uid: info.uid,
|
|
392
408
|
};
|
|
393
409
|
},
|
|
394
410
|
createRecorder(opts) {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 抖音录播器负载均衡使用示例
|
|
3
|
+
*/
|
|
4
|
+
import { getInfo } from "../stream.js";
|
|
5
|
+
import { loadBalancer } from "./loadBalancerManager.js";
|
|
6
|
+
async function basicExample() {
|
|
7
|
+
console.log("=== 基本使用示例 ===");
|
|
8
|
+
try {
|
|
9
|
+
// 使用负载均衡模式获取房间信息
|
|
10
|
+
const roomInfo = await getInfo("测试房间ID", {
|
|
11
|
+
api: "balance", // 关键:使用balance模式启用负载均衡
|
|
12
|
+
cookie: "your-cookie-here",
|
|
13
|
+
});
|
|
14
|
+
console.log("获取到房间信息:", {
|
|
15
|
+
title: roomInfo.title,
|
|
16
|
+
owner: roomInfo.owner,
|
|
17
|
+
living: roomInfo.living,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
console.error("获取房间信息失败:", error.message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function managementExample() {
|
|
25
|
+
console.log("\n=== 管理功能示例 ===");
|
|
26
|
+
// 1. 查看当前状态
|
|
27
|
+
console.log("1. 当前负载均衡器状态:");
|
|
28
|
+
loadBalancer.printStatus();
|
|
29
|
+
// 2. 检查API健康状态
|
|
30
|
+
console.log("\n2. API健康状态检查:");
|
|
31
|
+
const apis = ["web", "webHTML", "mobile", "userHTML"];
|
|
32
|
+
apis.forEach((api) => {
|
|
33
|
+
const isHealthy = loadBalancer.isAPIHealthy(api);
|
|
34
|
+
console.log(`${api}: ${isHealthy ? "✅ 健康" : "❌ 不健康"}`);
|
|
35
|
+
});
|
|
36
|
+
// 3. 获取推荐API
|
|
37
|
+
const recommendedAPI = loadBalancer.getRecommendedAPI();
|
|
38
|
+
console.log(`\n3. 推荐使用的API: ${recommendedAPI}`);
|
|
39
|
+
// 4. 配置调整示例
|
|
40
|
+
console.log("\n4. 配置调整示例:");
|
|
41
|
+
console.log("原始配置:", loadBalancer.getConfig());
|
|
42
|
+
// 调整配置:更保守的失败策略
|
|
43
|
+
loadBalancer.updateConfig({
|
|
44
|
+
maxFailures: 2, // 减少到2次失败就禁用
|
|
45
|
+
blockDuration: 120000, // 禁用2分钟
|
|
46
|
+
});
|
|
47
|
+
console.log("调整后配置:", loadBalancer.getConfig());
|
|
48
|
+
// 5. API权重调整示例
|
|
49
|
+
console.log("\n5. API权重调整示例:");
|
|
50
|
+
console.log("调整前状态:");
|
|
51
|
+
console.table(loadBalancer.getStatus());
|
|
52
|
+
// 提高mobile接口的优先级和权重
|
|
53
|
+
loadBalancer.updateAPIConfig("mobile", {
|
|
54
|
+
priority: 1, // 最高优先级
|
|
55
|
+
weight: 5, // 最高权重
|
|
56
|
+
});
|
|
57
|
+
console.log("调整mobile接口权重后:");
|
|
58
|
+
console.table(loadBalancer.getStatus());
|
|
59
|
+
}
|
|
60
|
+
async function errorHandlingExample() {
|
|
61
|
+
console.log("\n=== 错误处理示例 ===");
|
|
62
|
+
// 模拟检查被禁用的API
|
|
63
|
+
const blockedAPIs = loadBalancer.getBlockedAPIs();
|
|
64
|
+
if (blockedAPIs.length > 0) {
|
|
65
|
+
console.log(`发现 ${blockedAPIs.length} 个被禁用的API:`, blockedAPIs);
|
|
66
|
+
// 可以选择重置这些API
|
|
67
|
+
console.log("重置被禁用的API...");
|
|
68
|
+
blockedAPIs.forEach((api) => {
|
|
69
|
+
loadBalancer.resetAPI(api);
|
|
70
|
+
});
|
|
71
|
+
console.log("重置完成,当前健康的API:", loadBalancer.getHealthyAPIs());
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.log("所有API都处于健康状态 ✅");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function monitoringExample() {
|
|
78
|
+
console.log("\n=== 监控示例 ===");
|
|
79
|
+
// 模拟监控函数
|
|
80
|
+
function checkSystemHealth() {
|
|
81
|
+
const status = loadBalancer.getStatus();
|
|
82
|
+
const healthyCount = status.filter((s) => !s.isBlocked).length;
|
|
83
|
+
const totalCount = status.length;
|
|
84
|
+
console.log(`系统健康状态: ${healthyCount}/${totalCount} 个API可用`);
|
|
85
|
+
if (healthyCount < totalCount / 2) {
|
|
86
|
+
console.warn("⚠️ 警告:超过一半的API不可用!");
|
|
87
|
+
loadBalancer.printStatus();
|
|
88
|
+
// 可以触发告警或自动恢复逻辑
|
|
89
|
+
console.log("执行自动恢复...");
|
|
90
|
+
loadBalancer.resetAll();
|
|
91
|
+
}
|
|
92
|
+
// 显示详细状态
|
|
93
|
+
status.forEach((s) => {
|
|
94
|
+
if (s.isBlocked) {
|
|
95
|
+
const retryTime = new Date(s.nextRetryTime).toLocaleString();
|
|
96
|
+
console.log(`❌ ${s.api}: 被禁用,下次重试时间: ${retryTime}`);
|
|
97
|
+
}
|
|
98
|
+
else if (s.failureCount > 0) {
|
|
99
|
+
console.log(`⚠️ ${s.api}: 失败 ${s.failureCount} 次,仍可用`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
console.log(`✅ ${s.api}: 健康`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// 执行健康检查
|
|
107
|
+
checkSystemHealth();
|
|
108
|
+
// 在实际应用中,可以设置定时器
|
|
109
|
+
// setInterval(checkSystemHealth, 60000); // 每分钟检查一次
|
|
110
|
+
}
|
|
111
|
+
// 主函数
|
|
112
|
+
async function runExamples() {
|
|
113
|
+
try {
|
|
114
|
+
await basicExample();
|
|
115
|
+
await managementExample();
|
|
116
|
+
await errorHandlingExample();
|
|
117
|
+
await monitoringExample();
|
|
118
|
+
console.log("\n=== 示例执行完成 ===");
|
|
119
|
+
console.log("\n最终状态:");
|
|
120
|
+
loadBalancer.printStatus();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
console.error("示例执行失败:", error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 如果直接运行此文件
|
|
127
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
128
|
+
runExamples();
|
|
129
|
+
}
|
|
130
|
+
export { runExamples };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { APIType, APIEndpoint, APIEndpointStatus, LoadBalancerConfig } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* API 负载均衡器类
|
|
4
|
+
* 实现多个 API 接口的负载均衡调用,具备失败重试和禁用机制
|
|
5
|
+
*/
|
|
6
|
+
export declare class APILoadBalancer {
|
|
7
|
+
private endpoints;
|
|
8
|
+
private config;
|
|
9
|
+
constructor(config?: Partial<LoadBalancerConfig>);
|
|
10
|
+
/**
|
|
11
|
+
* 初始化 API 端点配置
|
|
12
|
+
*/
|
|
13
|
+
private initializeEndpoints;
|
|
14
|
+
/**
|
|
15
|
+
* 获取下一个可用的 API 端点
|
|
16
|
+
* 使用加权轮询算法,优先选择权重高且未被禁用的端点
|
|
17
|
+
*/
|
|
18
|
+
private getNextEndpoint;
|
|
19
|
+
/**
|
|
20
|
+
* 记录 API 调用失败
|
|
21
|
+
*/
|
|
22
|
+
private recordFailure;
|
|
23
|
+
/**
|
|
24
|
+
* 记录 API 调用成功
|
|
25
|
+
*/
|
|
26
|
+
private recordSuccess;
|
|
27
|
+
/**
|
|
28
|
+
* 使用负载均衡策略调用 getRoomInfo
|
|
29
|
+
*/
|
|
30
|
+
callWithLoadBalance(webRoomId: string, opts?: {
|
|
31
|
+
auth?: string;
|
|
32
|
+
doubleScreen?: boolean;
|
|
33
|
+
uid?: string | number;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
living: boolean;
|
|
36
|
+
roomId: string;
|
|
37
|
+
owner: string;
|
|
38
|
+
title: string;
|
|
39
|
+
streams: any[];
|
|
40
|
+
sources: any[];
|
|
41
|
+
avatar: string;
|
|
42
|
+
cover: string;
|
|
43
|
+
liveId: string;
|
|
44
|
+
uid: string;
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* 获取当前端点状态(用于调试和监控)
|
|
48
|
+
*/
|
|
49
|
+
getEndpointStatus(): APIEndpointStatus[];
|
|
50
|
+
/**
|
|
51
|
+
* 手动重置某个端点的状态
|
|
52
|
+
*/
|
|
53
|
+
resetEndpoint(apiType: APIType): void;
|
|
54
|
+
/**
|
|
55
|
+
* 重置所有端点状态
|
|
56
|
+
*/
|
|
57
|
+
resetAllEndpoints(): void;
|
|
58
|
+
/**
|
|
59
|
+
* 更新端点配置
|
|
60
|
+
*/
|
|
61
|
+
updateEndpointConfig(apiType: APIType, updates: Partial<APIEndpoint>): void;
|
|
62
|
+
/**
|
|
63
|
+
* 获取负载均衡器配置
|
|
64
|
+
*/
|
|
65
|
+
getConfig(): LoadBalancerConfig;
|
|
66
|
+
/**
|
|
67
|
+
* 更新负载均衡器配置
|
|
68
|
+
*/
|
|
69
|
+
updateConfig(updates: Partial<LoadBalancerConfig>): void;
|
|
70
|
+
}
|
|
71
|
+
export declare const globalLoadBalancer: APILoadBalancer;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { getRoomInfo } from "../douyin_api.js";
|
|
2
|
+
/**
|
|
3
|
+
* API 负载均衡器类
|
|
4
|
+
* 实现多个 API 接口的负载均衡调用,具备失败重试和禁用机制
|
|
5
|
+
*/
|
|
6
|
+
export class APILoadBalancer {
|
|
7
|
+
endpoints = [];
|
|
8
|
+
config;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = {
|
|
11
|
+
maxFailures: 3, // 连续失败3次后禁用
|
|
12
|
+
blockDuration: 3 * 60 * 1000, // 禁用3分钟
|
|
13
|
+
retryMultiplier: 1.5, // 重试时间倍增
|
|
14
|
+
healthCheckInterval: 30 * 1000, // 30秒健康检查
|
|
15
|
+
...config,
|
|
16
|
+
};
|
|
17
|
+
// 初始化可用的 API 端点
|
|
18
|
+
this.initializeEndpoints();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 初始化 API 端点配置
|
|
22
|
+
*/
|
|
23
|
+
initializeEndpoints() {
|
|
24
|
+
const defaultEndpoints = [
|
|
25
|
+
{ name: "web", priority: 2, weight: 1 },
|
|
26
|
+
{ name: "webHTML", priority: 1, weight: 1 },
|
|
27
|
+
{ name: "mobile", priority: 6, weight: 1 },
|
|
28
|
+
{ name: "userHTML", priority: 4, weight: 1 },
|
|
29
|
+
];
|
|
30
|
+
this.endpoints = defaultEndpoints.map((endpoint) => ({
|
|
31
|
+
endpoint,
|
|
32
|
+
failureCount: 0,
|
|
33
|
+
lastFailureTime: 0,
|
|
34
|
+
isBlocked: false,
|
|
35
|
+
nextRetryTime: 0,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 获取下一个可用的 API 端点
|
|
40
|
+
* 使用加权轮询算法,优先选择权重高且未被禁用的端点
|
|
41
|
+
*/
|
|
42
|
+
getNextEndpoint() {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
// 清理过期的禁用状态
|
|
45
|
+
this.endpoints.forEach((status) => {
|
|
46
|
+
if (status.isBlocked && now >= status.nextRetryTime) {
|
|
47
|
+
status.isBlocked = false;
|
|
48
|
+
status.failureCount = Math.max(0, status.failureCount - 1); // 部分恢复
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// 获取可用的端点
|
|
52
|
+
const availableEndpoints = this.endpoints.filter((status) => !status.isBlocked);
|
|
53
|
+
if (availableEndpoints.length === 0) {
|
|
54
|
+
return null; // 所有端点都被禁用
|
|
55
|
+
}
|
|
56
|
+
// 按优先级和权重排序
|
|
57
|
+
availableEndpoints.sort((a, b) => {
|
|
58
|
+
if (a.endpoint.priority !== b.endpoint.priority) {
|
|
59
|
+
return a.endpoint.priority - b.endpoint.priority; // 优先级越小越好
|
|
60
|
+
}
|
|
61
|
+
return b.endpoint.weight - a.endpoint.weight; // 权重越大越好
|
|
62
|
+
});
|
|
63
|
+
// 使用加权随机选择
|
|
64
|
+
const totalWeight = availableEndpoints.reduce((sum, status) => sum + status.endpoint.weight, 0);
|
|
65
|
+
const random = Math.random() * totalWeight;
|
|
66
|
+
let currentWeight = 0;
|
|
67
|
+
for (const status of availableEndpoints) {
|
|
68
|
+
currentWeight += status.endpoint.weight;
|
|
69
|
+
if (random <= currentWeight) {
|
|
70
|
+
return status;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 如果加权选择失败,返回第一个可用的
|
|
74
|
+
return availableEndpoints[0];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 记录 API 调用失败
|
|
78
|
+
*/
|
|
79
|
+
recordFailure(apiType, error) {
|
|
80
|
+
const status = this.endpoints.find((s) => s.endpoint.name === apiType);
|
|
81
|
+
if (!status)
|
|
82
|
+
return;
|
|
83
|
+
status.failureCount++;
|
|
84
|
+
status.lastFailureTime = Date.now();
|
|
85
|
+
// 如果失败次数超过阈值,禁用该端点
|
|
86
|
+
if (status.failureCount >= this.config.maxFailures) {
|
|
87
|
+
status.isBlocked = true;
|
|
88
|
+
const blockDuration = this.config.blockDuration *
|
|
89
|
+
Math.pow(this.config.retryMultiplier, status.failureCount - this.config.maxFailures);
|
|
90
|
+
status.nextRetryTime = Date.now() + blockDuration;
|
|
91
|
+
console.warn(`API ${apiType} has been blocked due to ${status.failureCount} failures. Next retry at: ${(new Date(status.nextRetryTime).toISOString(), error.message)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 记录 API 调用成功
|
|
96
|
+
*/
|
|
97
|
+
recordSuccess(apiType) {
|
|
98
|
+
const status = this.endpoints.find((s) => s.endpoint.name === apiType);
|
|
99
|
+
if (!status)
|
|
100
|
+
return;
|
|
101
|
+
// 成功调用后,减少失败计数
|
|
102
|
+
if (status.failureCount > 0) {
|
|
103
|
+
status.failureCount = Math.max(0, status.failureCount - 1);
|
|
104
|
+
}
|
|
105
|
+
// 如果之前被禁用,现在可以恢复
|
|
106
|
+
if (status.isBlocked && status.failureCount === 0) {
|
|
107
|
+
status.isBlocked = false;
|
|
108
|
+
status.nextRetryTime = 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 使用负载均衡策略调用 getRoomInfo
|
|
113
|
+
*/
|
|
114
|
+
async callWithLoadBalance(webRoomId, opts = {}) {
|
|
115
|
+
const maxAttempts = this.endpoints.length;
|
|
116
|
+
let lastError = null;
|
|
117
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
118
|
+
const endpointStatus = this.getNextEndpoint();
|
|
119
|
+
if (!endpointStatus) {
|
|
120
|
+
throw new Error("所有 API 端点都不可用,请稍后重试");
|
|
121
|
+
}
|
|
122
|
+
const apiType = endpointStatus.endpoint.name;
|
|
123
|
+
try {
|
|
124
|
+
const result = await getRoomInfo(webRoomId, {
|
|
125
|
+
...opts,
|
|
126
|
+
api: apiType,
|
|
127
|
+
});
|
|
128
|
+
// 调用成功,记录成功状态
|
|
129
|
+
this.recordSuccess(apiType);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
lastError = error;
|
|
134
|
+
this.recordFailure(apiType, lastError);
|
|
135
|
+
console.warn(`API ${apiType} failed (attempt ${attempt + 1}/${maxAttempts}):`, lastError.message);
|
|
136
|
+
// 如果这是最后一次尝试,或者没有更多可用端点,则抛出错误
|
|
137
|
+
if (attempt === maxAttempts - 1) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`所有 API 调用都失败了。最后一个错误: ${lastError?.message || "未知错误"}`);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 获取当前端点状态(用于调试和监控)
|
|
146
|
+
*/
|
|
147
|
+
getEndpointStatus() {
|
|
148
|
+
return this.endpoints.map((status) => ({ ...status }));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 手动重置某个端点的状态
|
|
152
|
+
*/
|
|
153
|
+
resetEndpoint(apiType) {
|
|
154
|
+
const status = this.endpoints.find((s) => s.endpoint.name === apiType);
|
|
155
|
+
if (status) {
|
|
156
|
+
status.failureCount = 0;
|
|
157
|
+
status.lastFailureTime = 0;
|
|
158
|
+
status.isBlocked = false;
|
|
159
|
+
status.nextRetryTime = 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 重置所有端点状态
|
|
164
|
+
*/
|
|
165
|
+
resetAllEndpoints() {
|
|
166
|
+
this.endpoints.forEach((status) => {
|
|
167
|
+
status.failureCount = 0;
|
|
168
|
+
status.lastFailureTime = 0;
|
|
169
|
+
status.isBlocked = false;
|
|
170
|
+
status.nextRetryTime = 0;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 更新端点配置
|
|
175
|
+
*/
|
|
176
|
+
updateEndpointConfig(apiType, updates) {
|
|
177
|
+
const status = this.endpoints.find((s) => s.endpoint.name === apiType);
|
|
178
|
+
if (status) {
|
|
179
|
+
Object.assign(status.endpoint, updates);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 获取负载均衡器配置
|
|
184
|
+
*/
|
|
185
|
+
getConfig() {
|
|
186
|
+
return { ...this.config };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 更新负载均衡器配置
|
|
190
|
+
*/
|
|
191
|
+
updateConfig(updates) {
|
|
192
|
+
Object.assign(this.config, updates);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 创建全局单例实例
|
|
196
|
+
export const globalLoadBalancer = new APILoadBalancer();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { APIType, LoadBalancerConfig } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 负载均衡器管理工具类
|
|
4
|
+
* 提供简化的接口来管理和配置负载均衡器
|
|
5
|
+
*/
|
|
6
|
+
export declare class LoadBalancerManager {
|
|
7
|
+
/**
|
|
8
|
+
* 获取所有端点的当前状态
|
|
9
|
+
*/
|
|
10
|
+
static getStatus(): {
|
|
11
|
+
api: APIType;
|
|
12
|
+
priority: number;
|
|
13
|
+
weight: number;
|
|
14
|
+
failureCount: number;
|
|
15
|
+
isBlocked: boolean;
|
|
16
|
+
lastFailureTime: string;
|
|
17
|
+
nextRetryTime: string;
|
|
18
|
+
}[];
|
|
19
|
+
/**
|
|
20
|
+
* 重置指定 API 的状态
|
|
21
|
+
*/
|
|
22
|
+
static resetAPI(apiType: APIType): void;
|
|
23
|
+
/**
|
|
24
|
+
* 重置所有 API 的状态
|
|
25
|
+
*/
|
|
26
|
+
static resetAll(): void;
|
|
27
|
+
/**
|
|
28
|
+
* 更新 API 端点的配置
|
|
29
|
+
*/
|
|
30
|
+
static updateAPIConfig(apiType: APIType, config: {
|
|
31
|
+
priority?: number;
|
|
32
|
+
weight?: number;
|
|
33
|
+
}): void;
|
|
34
|
+
/**
|
|
35
|
+
* 获取负载均衡器配置
|
|
36
|
+
*/
|
|
37
|
+
static getConfig(): LoadBalancerConfig;
|
|
38
|
+
/**
|
|
39
|
+
* 更新负载均衡器配置
|
|
40
|
+
*/
|
|
41
|
+
static updateConfig(config: Partial<LoadBalancerConfig>): void;
|
|
42
|
+
/**
|
|
43
|
+
* 获取健康的(未被禁用的)API 列表
|
|
44
|
+
*/
|
|
45
|
+
static getHealthyAPIs(): APIType[];
|
|
46
|
+
/**
|
|
47
|
+
* 获取被禁用的 API 列表
|
|
48
|
+
*/
|
|
49
|
+
static getBlockedAPIs(): APIType[];
|
|
50
|
+
/**
|
|
51
|
+
* 检查特定 API 是否可用
|
|
52
|
+
*/
|
|
53
|
+
static isAPIHealthy(apiType: APIType): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* 获取推荐使用的 API(基于当前状态和权重)
|
|
56
|
+
*/
|
|
57
|
+
static getRecommendedAPI(): APIType | null;
|
|
58
|
+
/**
|
|
59
|
+
* 打印当前负载均衡器状态(用于调试)
|
|
60
|
+
*/
|
|
61
|
+
static printStatus(): void;
|
|
62
|
+
}
|
|
63
|
+
export declare const loadBalancer: {
|
|
64
|
+
getStatus: typeof LoadBalancerManager.getStatus;
|
|
65
|
+
resetAPI: typeof LoadBalancerManager.resetAPI;
|
|
66
|
+
resetAll: typeof LoadBalancerManager.resetAll;
|
|
67
|
+
updateAPIConfig: typeof LoadBalancerManager.updateAPIConfig;
|
|
68
|
+
getConfig: typeof LoadBalancerManager.getConfig;
|
|
69
|
+
updateConfig: typeof LoadBalancerManager.updateConfig;
|
|
70
|
+
getHealthyAPIs: typeof LoadBalancerManager.getHealthyAPIs;
|
|
71
|
+
getBlockedAPIs: typeof LoadBalancerManager.getBlockedAPIs;
|
|
72
|
+
isAPIHealthy: typeof LoadBalancerManager.isAPIHealthy;
|
|
73
|
+
getRecommendedAPI: typeof LoadBalancerManager.getRecommendedAPI;
|
|
74
|
+
printStatus: typeof LoadBalancerManager.printStatus;
|
|
75
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { globalLoadBalancer } from "./loadBalancer.js";
|
|
2
|
+
/**
|
|
3
|
+
* 负载均衡器管理工具类
|
|
4
|
+
* 提供简化的接口来管理和配置负载均衡器
|
|
5
|
+
*/
|
|
6
|
+
export class LoadBalancerManager {
|
|
7
|
+
/**
|
|
8
|
+
* 获取所有端点的当前状态
|
|
9
|
+
*/
|
|
10
|
+
static getStatus() {
|
|
11
|
+
return globalLoadBalancer.getEndpointStatus().map((status) => ({
|
|
12
|
+
api: status.endpoint.name,
|
|
13
|
+
priority: status.endpoint.priority,
|
|
14
|
+
weight: status.endpoint.weight,
|
|
15
|
+
failureCount: status.failureCount,
|
|
16
|
+
isBlocked: status.isBlocked,
|
|
17
|
+
lastFailureTime: status.lastFailureTime
|
|
18
|
+
? new Date(status.lastFailureTime).toISOString()
|
|
19
|
+
: null,
|
|
20
|
+
nextRetryTime: status.nextRetryTime ? new Date(status.nextRetryTime).toISOString() : null,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 重置指定 API 的状态
|
|
25
|
+
*/
|
|
26
|
+
static resetAPI(apiType) {
|
|
27
|
+
if (apiType === "balance") {
|
|
28
|
+
throw new Error("Cannot reset 'balance' type. Use resetAll() instead.");
|
|
29
|
+
}
|
|
30
|
+
globalLoadBalancer.resetEndpoint(apiType);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 重置所有 API 的状态
|
|
34
|
+
*/
|
|
35
|
+
static resetAll() {
|
|
36
|
+
globalLoadBalancer.resetAllEndpoints();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 更新 API 端点的配置
|
|
40
|
+
*/
|
|
41
|
+
static updateAPIConfig(apiType, config) {
|
|
42
|
+
if (apiType === "balance") {
|
|
43
|
+
throw new Error("Cannot update 'balance' type configuration.");
|
|
44
|
+
}
|
|
45
|
+
globalLoadBalancer.updateEndpointConfig(apiType, config);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 获取负载均衡器配置
|
|
49
|
+
*/
|
|
50
|
+
static getConfig() {
|
|
51
|
+
return globalLoadBalancer.getConfig();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 更新负载均衡器配置
|
|
55
|
+
*/
|
|
56
|
+
static updateConfig(config) {
|
|
57
|
+
globalLoadBalancer.updateConfig(config);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 获取健康的(未被禁用的)API 列表
|
|
61
|
+
*/
|
|
62
|
+
static getHealthyAPIs() {
|
|
63
|
+
return globalLoadBalancer
|
|
64
|
+
.getEndpointStatus()
|
|
65
|
+
.filter((status) => !status.isBlocked)
|
|
66
|
+
.map((status) => status.endpoint.name);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 获取被禁用的 API 列表
|
|
70
|
+
*/
|
|
71
|
+
static getBlockedAPIs() {
|
|
72
|
+
return globalLoadBalancer
|
|
73
|
+
.getEndpointStatus()
|
|
74
|
+
.filter((status) => status.isBlocked)
|
|
75
|
+
.map((status) => status.endpoint.name);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 检查特定 API 是否可用
|
|
79
|
+
*/
|
|
80
|
+
static isAPIHealthy(apiType) {
|
|
81
|
+
if (apiType === "balance")
|
|
82
|
+
return true; // balance 类型总是可用的
|
|
83
|
+
const status = globalLoadBalancer.getEndpointStatus().find((s) => s.endpoint.name === apiType);
|
|
84
|
+
return status ? !status.isBlocked : false;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 获取推荐使用的 API(基于当前状态和权重)
|
|
88
|
+
*/
|
|
89
|
+
static getRecommendedAPI() {
|
|
90
|
+
const healthyEndpoints = globalLoadBalancer
|
|
91
|
+
.getEndpointStatus()
|
|
92
|
+
.filter((status) => !status.isBlocked)
|
|
93
|
+
.sort((a, b) => {
|
|
94
|
+
if (a.endpoint.priority !== b.endpoint.priority) {
|
|
95
|
+
return a.endpoint.priority - b.endpoint.priority;
|
|
96
|
+
}
|
|
97
|
+
return b.endpoint.weight - a.endpoint.weight;
|
|
98
|
+
});
|
|
99
|
+
return healthyEndpoints.length > 0 ? healthyEndpoints[0].endpoint.name : null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 打印当前负载均衡器状态(用于调试)
|
|
103
|
+
*/
|
|
104
|
+
static printStatus() {
|
|
105
|
+
console.log("=== 负载均衡器状态 ===");
|
|
106
|
+
console.log("配置:", LoadBalancerManager.getConfig());
|
|
107
|
+
console.log("端点状态:");
|
|
108
|
+
console.table(LoadBalancerManager.getStatus());
|
|
109
|
+
console.log("健康的 APIs:", LoadBalancerManager.getHealthyAPIs());
|
|
110
|
+
console.log("被禁用的 APIs:", LoadBalancerManager.getBlockedAPIs());
|
|
111
|
+
console.log("推荐 API:", LoadBalancerManager.getRecommendedAPI());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// 暴露简化的管理函数
|
|
115
|
+
export const loadBalancer = {
|
|
116
|
+
getStatus: LoadBalancerManager.getStatus,
|
|
117
|
+
resetAPI: LoadBalancerManager.resetAPI,
|
|
118
|
+
resetAll: LoadBalancerManager.resetAll,
|
|
119
|
+
updateAPIConfig: LoadBalancerManager.updateAPIConfig,
|
|
120
|
+
getConfig: LoadBalancerManager.getConfig,
|
|
121
|
+
updateConfig: LoadBalancerManager.updateConfig,
|
|
122
|
+
getHealthyAPIs: LoadBalancerManager.getHealthyAPIs,
|
|
123
|
+
getBlockedAPIs: LoadBalancerManager.getBlockedAPIs,
|
|
124
|
+
isAPIHealthy: LoadBalancerManager.isAPIHealthy,
|
|
125
|
+
getRecommendedAPI: LoadBalancerManager.getRecommendedAPI,
|
|
126
|
+
printStatus: LoadBalancerManager.printStatus,
|
|
127
|
+
};
|
package/lib/stream.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Recorder } from "@bililive-tools/manager";
|
|
2
|
+
import type { APIType, RealAPIType } from "./types.js";
|
|
2
3
|
export declare function getInfo(channelId: string, opts?: {
|
|
3
4
|
cookie?: string;
|
|
4
|
-
api?:
|
|
5
|
+
api?: APIType;
|
|
6
|
+
uid?: string | number;
|
|
5
7
|
}): Promise<{
|
|
6
8
|
living: boolean;
|
|
7
9
|
owner: string;
|
|
@@ -11,6 +13,8 @@ export declare function getInfo(channelId: string, opts?: {
|
|
|
11
13
|
cover: string;
|
|
12
14
|
startTime: Date;
|
|
13
15
|
liveId: string;
|
|
16
|
+
uid: string;
|
|
17
|
+
api: RealAPIType;
|
|
14
18
|
}>;
|
|
15
19
|
export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" | "streamPriorities" | "sourcePriorities"> & {
|
|
16
20
|
rejectCache?: boolean;
|
|
@@ -18,7 +22,8 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" |
|
|
|
18
22
|
cookie?: string;
|
|
19
23
|
formatPriorities?: Array<"flv" | "hls">;
|
|
20
24
|
doubleScreen?: boolean;
|
|
21
|
-
api?:
|
|
25
|
+
api?: APIType;
|
|
26
|
+
uid?: string | number;
|
|
22
27
|
}): Promise<{
|
|
23
28
|
currentStream: {
|
|
24
29
|
name: string;
|
|
@@ -34,4 +39,6 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" |
|
|
|
34
39
|
avatar: string;
|
|
35
40
|
cover: string;
|
|
36
41
|
liveId: string;
|
|
42
|
+
uid: string;
|
|
43
|
+
api: RealAPIType;
|
|
37
44
|
}>;
|
package/lib/stream.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { getRoomInfo } from "./douyin_api.js";
|
|
2
|
+
import { globalLoadBalancer } from "./loadBalancer/loadBalancer.js";
|
|
2
3
|
export async function getInfo(channelId, opts) {
|
|
3
|
-
|
|
4
|
+
let info;
|
|
5
|
+
// 如果使用 balance 模式,使用负载均衡器
|
|
6
|
+
if (opts?.api === "balance") {
|
|
7
|
+
info = await globalLoadBalancer.callWithLoadBalance(channelId, {
|
|
8
|
+
auth: opts.cookie,
|
|
9
|
+
uid: opts.uid,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
info = await getRoomInfo(channelId, opts ?? {});
|
|
14
|
+
}
|
|
4
15
|
return {
|
|
5
16
|
living: info.living,
|
|
6
17
|
owner: info.owner,
|
|
@@ -10,13 +21,21 @@ export async function getInfo(channelId, opts) {
|
|
|
10
21
|
cover: info.cover,
|
|
11
22
|
startTime: new Date(),
|
|
12
23
|
liveId: info.liveId,
|
|
24
|
+
uid: info.uid,
|
|
25
|
+
api: info.api,
|
|
13
26
|
};
|
|
14
27
|
}
|
|
15
28
|
export async function getStream(opts) {
|
|
29
|
+
let api = opts.api ?? "web";
|
|
30
|
+
if (api === "userHTML") {
|
|
31
|
+
// userHTML 接口只能用于状态检测
|
|
32
|
+
api = "web";
|
|
33
|
+
}
|
|
16
34
|
const info = await getRoomInfo(opts.channelId, {
|
|
17
35
|
doubleScreen: opts.doubleScreen ?? true,
|
|
18
36
|
auth: opts.cookie,
|
|
19
|
-
api:
|
|
37
|
+
api: api,
|
|
38
|
+
uid: opts.uid,
|
|
20
39
|
});
|
|
21
40
|
if (!info.living) {
|
|
22
41
|
throw new Error("It must be called getStream when living");
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type APIType = "web" | "webHTML" | "mobile" | "userHTML" | "balance" | "random";
|
|
2
|
+
export type RealAPIType = Exclude<APIType, "balance" | "random">;
|
|
3
|
+
export interface APIEndpoint {
|
|
4
|
+
name: APIType;
|
|
5
|
+
priority: number;
|
|
6
|
+
weight: number;
|
|
7
|
+
}
|
|
8
|
+
export interface APIEndpointStatus {
|
|
9
|
+
endpoint: APIEndpoint;
|
|
10
|
+
failureCount: number;
|
|
11
|
+
lastFailureTime: number;
|
|
12
|
+
isBlocked: boolean;
|
|
13
|
+
nextRetryTime: number;
|
|
14
|
+
}
|
|
15
|
+
export interface LoadBalancerConfig {
|
|
16
|
+
maxFailures: number;
|
|
17
|
+
blockDuration: number;
|
|
18
|
+
retryMultiplier: number;
|
|
19
|
+
healthCheckInterval: number;
|
|
20
|
+
}
|
|
21
|
+
export interface RoomInfo {
|
|
22
|
+
living: boolean;
|
|
23
|
+
nickname: string;
|
|
24
|
+
sec_uid: string;
|
|
25
|
+
avatar: string;
|
|
26
|
+
api: RealAPIType;
|
|
27
|
+
room: {
|
|
28
|
+
title: string;
|
|
29
|
+
cover: string;
|
|
30
|
+
id_str: string;
|
|
31
|
+
stream_url: any | null;
|
|
32
|
+
} | null;
|
|
33
|
+
}
|
package/lib/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/douyin-recorder",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
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
|
+
"@bililive-tools/manager": "^1.6.1",
|
|
42
|
+
"douyin-danma-listener": "0.2.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "*"
|