@bililive-tools/bilibili-recorder 1.17.0 → 1.18.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/lib/bilibili_api.d.ts +2 -0
- package/lib/danma.d.ts +4 -0
- package/lib/danma.js +53 -17
- package/lib/index.js +33 -7
- package/lib/stream.d.ts +15 -1
- package/lib/stream.js +92 -29
- package/package.json +1 -1
package/lib/bilibili_api.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare function getRoomInit(roomIdOrShortId: number): Promise<{
|
|
|
7
7
|
live_time: number;
|
|
8
8
|
encrypted: boolean;
|
|
9
9
|
is_sp: 0 | 1;
|
|
10
|
+
special_type: number;
|
|
10
11
|
}>;
|
|
11
12
|
export declare function getRoomInfo(roomIdOrShortId: number): Promise<{
|
|
12
13
|
room_id: number;
|
|
@@ -79,6 +80,7 @@ export declare function getRoomPlayInfo(roomIdOrShortId: number, opts?: {
|
|
|
79
80
|
short_id: number;
|
|
80
81
|
live_status: LiveStatus;
|
|
81
82
|
live_time: number;
|
|
83
|
+
all_special_types?: number[];
|
|
82
84
|
playurl_info: {
|
|
83
85
|
conf_json: string;
|
|
84
86
|
playurl: {
|
package/lib/danma.d.ts
CHANGED
|
@@ -4,7 +4,11 @@ declare class DanmaClient extends EventEmitter {
|
|
|
4
4
|
private roomId;
|
|
5
5
|
private auth;
|
|
6
6
|
private uid;
|
|
7
|
+
private readonly maxRetryCount;
|
|
7
8
|
private retryCount;
|
|
9
|
+
private reconnectTimer;
|
|
10
|
+
private connectionGeneration;
|
|
11
|
+
private stopped;
|
|
8
12
|
private useServerTimestamp;
|
|
9
13
|
constructor(roomId: number, { auth, uid, useServerTimestamp, }: {
|
|
10
14
|
auth: string | undefined;
|
package/lib/danma.js
CHANGED
|
@@ -46,7 +46,11 @@ class DanmaClient extends EventEmitter {
|
|
|
46
46
|
roomId;
|
|
47
47
|
auth;
|
|
48
48
|
uid;
|
|
49
|
-
|
|
49
|
+
maxRetryCount = 10;
|
|
50
|
+
retryCount = this.maxRetryCount;
|
|
51
|
+
reconnectTimer = null;
|
|
52
|
+
connectionGeneration = 0;
|
|
53
|
+
stopped = true;
|
|
50
54
|
useServerTimestamp;
|
|
51
55
|
constructor(roomId, { auth, uid, useServerTimestamp, }) {
|
|
52
56
|
super();
|
|
@@ -56,7 +60,12 @@ class DanmaClient extends EventEmitter {
|
|
|
56
60
|
this.useServerTimestamp = useServerTimestamp ?? true;
|
|
57
61
|
}
|
|
58
62
|
async start() {
|
|
63
|
+
const connectionGeneration = ++this.connectionGeneration;
|
|
64
|
+
this.stopped = false;
|
|
65
|
+
this.retryCount = this.maxRetryCount;
|
|
59
66
|
const info = await getCachedBuvidConf();
|
|
67
|
+
if (this.stopped || connectionGeneration !== this.connectionGeneration)
|
|
68
|
+
return;
|
|
60
69
|
const buvid3 = info.data.b_3;
|
|
61
70
|
const handler = {
|
|
62
71
|
onOpen: () => {
|
|
@@ -165,27 +174,54 @@ class DanmaClient extends EventEmitter {
|
|
|
165
174
|
lastAuth = `buvid3=${buvid3}`;
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
const connect = () => {
|
|
178
|
+
if (this.stopped || connectionGeneration !== this.connectionGeneration)
|
|
179
|
+
return;
|
|
180
|
+
const client = startListen(this.roomId, handler, {
|
|
181
|
+
ws: {
|
|
182
|
+
headers: {
|
|
183
|
+
Cookie: lastAuth,
|
|
184
|
+
},
|
|
185
|
+
uid: this.uid ?? 0,
|
|
186
|
+
// 在本层控制重连次数,避免底层无限重连。
|
|
187
|
+
keepalive: false,
|
|
172
188
|
},
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
});
|
|
190
|
+
this.client = client;
|
|
191
|
+
client.live.on("close", () => {
|
|
192
|
+
if (this.stopped ||
|
|
193
|
+
connectionGeneration !== this.connectionGeneration ||
|
|
194
|
+
this.client !== client ||
|
|
195
|
+
this.reconnectTimer) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (this.retryCount <= 0) {
|
|
199
|
+
console.error(`DanmaClient reconnect failed after ${this.maxRetryCount} attempts`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const attempt = this.maxRetryCount - this.retryCount + 1;
|
|
203
|
+
this.retryCount -= 1;
|
|
204
|
+
this.emit("reconnect", { retryCount: attempt, maxRetry: this.maxRetryCount });
|
|
205
|
+
this.reconnectTimer = setTimeout(() => {
|
|
206
|
+
this.reconnectTimer = null;
|
|
207
|
+
connect();
|
|
182
208
|
}, 2000);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
});
|
|
210
|
+
client.live.on("error", (err) => {
|
|
211
|
+
console.error("DanmaClient error", err);
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
connect();
|
|
186
215
|
}
|
|
187
216
|
stop() {
|
|
217
|
+
this.stopped = true;
|
|
218
|
+
this.connectionGeneration += 1;
|
|
219
|
+
if (this.reconnectTimer) {
|
|
220
|
+
clearTimeout(this.reconnectTimer);
|
|
221
|
+
this.reconnectTimer = null;
|
|
222
|
+
}
|
|
188
223
|
this.client?.close();
|
|
224
|
+
this.client = null;
|
|
189
225
|
}
|
|
190
226
|
}
|
|
191
227
|
export default DanmaClient;
|
package/lib/index.js
CHANGED
|
@@ -110,9 +110,29 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
110
110
|
// 检查标题是否包含关键词,如果包含则不自动录制
|
|
111
111
|
if (utils.checkTitleKeywordsBeforeRecord(this.liveInfo.title, this, isManualStart))
|
|
112
112
|
return null;
|
|
113
|
-
const liveInfo = await getInfo(this.channelId);
|
|
113
|
+
const liveInfo = await getInfo(this.channelId, { cookie: this.auth });
|
|
114
114
|
const { owner, title, roomId, liveStartTime, recordStartTime } = liveInfo;
|
|
115
115
|
this.liveInfo = liveInfo;
|
|
116
|
+
// 特殊直播间无法录制(付费/DRM 加密、大航海权限专属、密码房等):识别后跳过并告知用户。
|
|
117
|
+
// - 付费直播流为 DRM(SAMPLE-AES + Widevine/FairPlay/com.bilidrm)加密,ffmpeg 无法解密;
|
|
118
|
+
// - 大航海/权限专属、密码房无观看权限时拿不到可用流。
|
|
119
|
+
// 如需强行尝试(例如后续接入其它录制方式),可设置 recorder.recordCharge = true。
|
|
120
|
+
if (!liveInfo.canRecord && this.recordCharge !== true) {
|
|
121
|
+
this.emit("DebugLog", {
|
|
122
|
+
type: "error",
|
|
123
|
+
text: `检测到${liveInfo.liveTypeDesc}(room ${roomId}),无法录制,已跳过:${title}`,
|
|
124
|
+
});
|
|
125
|
+
// 通知上层:检测到无法录制的特殊直播(供推送通知使用,由 manager 按场次去重)
|
|
126
|
+
this.emit("ChargeLive", { channelId: this.channelId });
|
|
127
|
+
// 持续状态标识:特殊直播已跳过。仅在状态变化时 emit,避免每个检查周期重复写时间线
|
|
128
|
+
if (this.state !== "charge-skipped") {
|
|
129
|
+
this.emit("stateChange", {
|
|
130
|
+
state: "charge-skipped",
|
|
131
|
+
msg: `${liveInfo.liveTypeDesc},已跳过录制`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
116
136
|
const qualityRetryLeft = (await this.cache.get("qualityRetryLeft")) ?? this.qualityRetry;
|
|
117
137
|
const strictQuality = utils.shouldUseStrictQuality(qualityRetryLeft, this.qualityRetry, isManualStart);
|
|
118
138
|
let res;
|
|
@@ -205,7 +225,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
205
225
|
const info = await getInfo(this.channelId);
|
|
206
226
|
return info;
|
|
207
227
|
});
|
|
208
|
-
const handleVideoCreated = async ({ filename, title, cover, rawFilename }) => {
|
|
228
|
+
const handleVideoCreated = async ({ filename, title, cover, rawFilename, }) => {
|
|
209
229
|
this.emit("videoFileCreated", { filename, cover, rawFilename });
|
|
210
230
|
if (title && this?.liveInfo) {
|
|
211
231
|
this.liveInfo.title = title;
|
|
@@ -285,16 +305,22 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
285
305
|
text: "弹幕连接已关闭",
|
|
286
306
|
});
|
|
287
307
|
});
|
|
308
|
+
danmaClient.on("reconnect", ({ retryCount, maxRetry }) => {
|
|
309
|
+
this.appendTimeline({
|
|
310
|
+
text: `弹幕连接已断开,正在尝试重连... (重试次数: ${retryCount}/${maxRetry})`,
|
|
311
|
+
});
|
|
312
|
+
this.emit("DebugLog", {
|
|
313
|
+
type: "common",
|
|
314
|
+
text: `弹幕连接已断开,正在尝试重连... (重试次数: ${retryCount}/${maxRetry})`,
|
|
315
|
+
});
|
|
316
|
+
});
|
|
288
317
|
if (enableDanmaListen) {
|
|
289
|
-
|
|
290
|
-
danmaClient.start();
|
|
291
|
-
}
|
|
292
|
-
catch (err) {
|
|
318
|
+
void danmaClient.start().catch((err) => {
|
|
293
319
|
this.emit("DebugLog", {
|
|
294
320
|
type: "error",
|
|
295
321
|
text: `弹幕连接失败,错误信息: ${String(err)}`,
|
|
296
322
|
});
|
|
297
|
-
}
|
|
323
|
+
});
|
|
298
324
|
}
|
|
299
325
|
const downloaderArgs = downloader.getArguments();
|
|
300
326
|
downloader.run();
|
package/lib/stream.d.ts
CHANGED
|
@@ -14,7 +14,17 @@ export declare function getLiveStatus(channelId: string): Promise<{
|
|
|
14
14
|
owner: string;
|
|
15
15
|
title: string;
|
|
16
16
|
}>;
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 直播类型(B站专属):
|
|
19
|
+
* - normal: 普通直播,可正常录制
|
|
20
|
+
* - paid: 付费/充电直播,流为 DRM(SAMPLE-AES + Widevine/FairPlay/com.bilidrm)加密,无法录制
|
|
21
|
+
* - guard: 大航海/权限专属直播(舰长/提督/总督等),无观看权限时拿不到流
|
|
22
|
+
* - password: 密码加密直播间,未验证密码拿不到流
|
|
23
|
+
*/
|
|
24
|
+
export type BiliLiveType = "normal" | "paid" | "guard" | "password";
|
|
25
|
+
export declare function getInfo(channelId: string, opts?: {
|
|
26
|
+
cookie?: string;
|
|
27
|
+
}): Promise<{
|
|
18
28
|
living: boolean;
|
|
19
29
|
owner: string;
|
|
20
30
|
title: string;
|
|
@@ -26,6 +36,10 @@ export declare function getInfo(channelId: string): Promise<{
|
|
|
26
36
|
liveStartTime: Date;
|
|
27
37
|
recordStartTime: Date;
|
|
28
38
|
area: string;
|
|
39
|
+
liveType: BiliLiveType;
|
|
40
|
+
liveTypeDesc: string;
|
|
41
|
+
canRecord: boolean;
|
|
42
|
+
isCharge: boolean;
|
|
29
43
|
}>;
|
|
30
44
|
export declare function getStream(opts: Pick<Recorder, "channelId" | "quality"> & {
|
|
31
45
|
cookie?: string;
|
package/lib/stream.js
CHANGED
|
@@ -19,7 +19,9 @@ export async function getLiveStatus(channelId) {
|
|
|
19
19
|
if (data) {
|
|
20
20
|
const startTime = new Date(data.live_time);
|
|
21
21
|
return {
|
|
22
|
-
living
|
|
22
|
+
// living 仅反映主播是否在播(live_status===1);加密/付费/权限等"房间特殊状态"
|
|
23
|
+
// 不并入 living,由 getInfo 的 liveType/canRecord 单独判定。
|
|
24
|
+
living: data.live_status === 1,
|
|
23
25
|
liveId: utils.md5(`${channelId}-${startTime?.getTime()}`),
|
|
24
26
|
owner: data.uname,
|
|
25
27
|
title: data.title,
|
|
@@ -28,49 +30,110 @@ export async function getLiveStatus(channelId) {
|
|
|
28
30
|
const roomInit = await getRoomInit(Number(channelId));
|
|
29
31
|
const startTime = new Date(roomInit.live_time * 1000);
|
|
30
32
|
return {
|
|
31
|
-
living: roomInit.live_status === 1
|
|
33
|
+
living: roomInit.live_status === 1,
|
|
32
34
|
liveId: utils.md5(`${roomInit.room_id}-${startTime?.getTime()}`),
|
|
33
35
|
...roomInit,
|
|
34
36
|
owner: "",
|
|
35
37
|
title: "",
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
const LIVE_TYPE_DESC = {
|
|
41
|
+
normal: "普通直播",
|
|
42
|
+
paid: "付费直播(DRM 加密)",
|
|
43
|
+
guard: "大航海/权限专属直播",
|
|
44
|
+
password: "密码加密直播",
|
|
45
|
+
};
|
|
46
|
+
export async function getInfo(channelId, opts = {}) {
|
|
39
47
|
const roomInit = await getRoomInit(Number(channelId));
|
|
40
|
-
|
|
48
|
+
// 判断 1:是否在直播。仅看 live_status,不混入加密/付费等房间特殊状态。
|
|
49
|
+
const living = roomInit.live_status === 1;
|
|
41
50
|
const recordStartTime = new Date();
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
// 基础信息(主播名/标题/封面等)始终获取,供频道预览(离线也要展示)。
|
|
52
|
+
let owner = "";
|
|
53
|
+
let title = "";
|
|
54
|
+
let avatar = "";
|
|
55
|
+
let cover = "";
|
|
56
|
+
let area = "";
|
|
57
|
+
let liveStartTime = new Date(0);
|
|
58
|
+
const { [roomInit.uid]: status } = await getStatusInfoByUIDs([roomInit.uid]);
|
|
59
|
+
if (status) {
|
|
60
|
+
owner = status.uname;
|
|
61
|
+
title = status.title;
|
|
62
|
+
avatar = status.face;
|
|
63
|
+
cover = status.cover_from_user;
|
|
64
|
+
area = status.area_v2_parent_name;
|
|
65
|
+
liveStartTime = new Date(status.live_time * 1000);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// 未获取到直播间信息(可能是加密房等),换一个接口取基础信息
|
|
44
69
|
const data = await getRoomBaseInfo(Number(channelId));
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
const base = data[Number(channelId)];
|
|
71
|
+
if (base) {
|
|
72
|
+
owner = base.uname;
|
|
73
|
+
title = base.title;
|
|
74
|
+
cover = base.cover;
|
|
75
|
+
liveStartTime = new Date(base.live_time);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 判断 2 + 3:直播类型与能否录制。仅在播时才探测流(不在播无需多余请求)。
|
|
79
|
+
// 判定优先级(自上而下,命中即止):
|
|
80
|
+
// 1. 密码房 → 未验证密码拿不到流,优先判定,不再请求取流接口
|
|
81
|
+
// 2. 付费标记 → room_init 的 is_sp/special_type 在付费直播进行时翻 1,无需取流即可确认
|
|
82
|
+
// 3. 取流探测 → 上面都未命中时才请求 getRoomPlayInfo,区分 DRM付费 / 权限受限 / 普通可录
|
|
83
|
+
let liveType = "normal";
|
|
84
|
+
let canRecord = false;
|
|
85
|
+
if (living) {
|
|
86
|
+
const paidByRoomInit = roomInit.is_sp === 1 || roomInit.special_type === 1;
|
|
87
|
+
if (roomInit.encrypted) {
|
|
88
|
+
liveType = "password";
|
|
89
|
+
}
|
|
90
|
+
else if (paidByRoomInit) {
|
|
91
|
+
liveType = "paid";
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
try {
|
|
95
|
+
const playInfo = await getRoomPlayInfo(Number(channelId), { cookie: opts.cookie });
|
|
96
|
+
// all_special_types 含 203 = B站播放器自身判定的 DRM 加密直播
|
|
97
|
+
const allSpecialTypes = playInfo.all_special_types ?? [];
|
|
98
|
+
const isDRM = Array.isArray(allSpecialTypes) && allSpecialTypes.includes(203);
|
|
99
|
+
const hasStream = (playInfo.playurl_info?.playurl?.stream?.length ?? 0) > 0;
|
|
100
|
+
if (isDRM) {
|
|
101
|
+
liveType = "paid";
|
|
102
|
+
}
|
|
103
|
+
else if (!hasStream) {
|
|
104
|
+
// 取流接口正常返回、却拿不到任何流 → 权限受限(舰长/大航海等专属直播)
|
|
105
|
+
liveType = "guard";
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
liveType = "normal";
|
|
109
|
+
canRecord = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// 取流接口异常(网络/瞬时错误):不臆断为权限受限,按普通直播放行,
|
|
114
|
+
// 交由后续 getStream 重试并产生 check-error,避免误跳过导致漏录。
|
|
115
|
+
liveType = "normal";
|
|
116
|
+
canRecord = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
60
119
|
}
|
|
61
|
-
const
|
|
120
|
+
const liveTypeDesc = LIVE_TYPE_DESC[liveType];
|
|
62
121
|
return {
|
|
63
122
|
uid: roomInit.uid,
|
|
64
|
-
living
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
123
|
+
living,
|
|
124
|
+
liveType,
|
|
125
|
+
liveTypeDesc,
|
|
126
|
+
canRecord,
|
|
127
|
+
isCharge: liveType === "paid",
|
|
128
|
+
owner,
|
|
129
|
+
title,
|
|
130
|
+
avatar,
|
|
131
|
+
cover,
|
|
69
132
|
roomId: roomInit.room_id,
|
|
70
|
-
liveStartTime
|
|
133
|
+
liveStartTime,
|
|
71
134
|
liveId: utils.md5(`${roomInit.room_id}-${liveStartTime.getTime()}`),
|
|
72
135
|
recordStartTime,
|
|
73
|
-
area
|
|
136
|
+
area,
|
|
74
137
|
};
|
|
75
138
|
}
|
|
76
139
|
async function getLiveInfo(roomIdOrShortId, opts) {
|