@bililive-tools/bilibili-recorder 1.1.0 → 1.3.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 +1 -0
- package/lib/bilibili_api.d.ts +1 -2
- package/lib/danma.js +8 -5
- package/lib/index.js +65 -8
- package/lib/stream.js +14 -3
- package/lib/utils.d.ts +1 -0
- package/lib/utils.js +8 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ interface Options {
|
|
|
50
50
|
codecName?: CodecName; // 见 CodecName 参数
|
|
51
51
|
useM3U8Proxy?: boolean; // 是否使用m3u8代理,由于hls及fmp4存在一个小时超时时间,需自行实现代理避免
|
|
52
52
|
m3u8ProxyUrl?: string; // 代理链接,文档待补充
|
|
53
|
+
videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
|
|
53
54
|
}
|
|
54
55
|
```
|
|
55
56
|
|
package/lib/bilibili_api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type LiveStatus = 0 | 1 | 2;
|
|
1
|
+
export type LiveStatus = 0 | 1 | 2;
|
|
2
2
|
export declare function getRoomInit(roomIdOrShortId: number): Promise<{
|
|
3
3
|
room_id: number;
|
|
4
4
|
short_id: number;
|
|
@@ -110,4 +110,3 @@ export interface SourceProfile {
|
|
|
110
110
|
extra: string;
|
|
111
111
|
stream_ttl: number;
|
|
112
112
|
}
|
|
113
|
-
export {};
|
package/lib/danma.js
CHANGED
|
@@ -5,7 +5,7 @@ class DanmaClient extends EventEmitter {
|
|
|
5
5
|
roomId;
|
|
6
6
|
auth;
|
|
7
7
|
uid;
|
|
8
|
-
retryCount =
|
|
8
|
+
retryCount = 10;
|
|
9
9
|
constructor(roomId, auth, uid) {
|
|
10
10
|
super();
|
|
11
11
|
this.roomId = roomId;
|
|
@@ -21,7 +21,7 @@ class DanmaClient extends EventEmitter {
|
|
|
21
21
|
return;
|
|
22
22
|
const comment = {
|
|
23
23
|
type: "comment",
|
|
24
|
-
timestamp: msg.timestamp,
|
|
24
|
+
timestamp: msg.body.timestamp,
|
|
25
25
|
text: content,
|
|
26
26
|
color: msg.body.content_color,
|
|
27
27
|
mode: msg.body.type,
|
|
@@ -41,7 +41,7 @@ class DanmaClient extends EventEmitter {
|
|
|
41
41
|
const content = msg.body.content.replaceAll(/[\r\n]/g, "");
|
|
42
42
|
const comment = {
|
|
43
43
|
type: "super_chat",
|
|
44
|
-
timestamp: msg.
|
|
44
|
+
timestamp: msg.raw.send_time,
|
|
45
45
|
text: content,
|
|
46
46
|
price: msg.body.price,
|
|
47
47
|
sender: {
|
|
@@ -79,7 +79,7 @@ class DanmaClient extends EventEmitter {
|
|
|
79
79
|
onGift: (msg) => {
|
|
80
80
|
const gift = {
|
|
81
81
|
type: "give_gift",
|
|
82
|
-
timestamp: msg.
|
|
82
|
+
timestamp: msg.raw.send_time,
|
|
83
83
|
name: msg.body.gift_name,
|
|
84
84
|
count: msg.body.amount,
|
|
85
85
|
price: msg.body.coin_type === "silver" ? 0 : msg.body.price / 1000,
|
|
@@ -98,6 +98,9 @@ class DanmaClient extends EventEmitter {
|
|
|
98
98
|
};
|
|
99
99
|
this.emit("Message", gift);
|
|
100
100
|
},
|
|
101
|
+
onRoomInfoChange: (msg) => {
|
|
102
|
+
this.emit("RoomInfoChange", msg);
|
|
103
|
+
},
|
|
101
104
|
};
|
|
102
105
|
this.client = startListen(this.roomId, handler, {
|
|
103
106
|
ws: {
|
|
@@ -112,7 +115,7 @@ class DanmaClient extends EventEmitter {
|
|
|
112
115
|
if (this.retryCount > 0) {
|
|
113
116
|
setTimeout(() => {
|
|
114
117
|
this.client && this.client.reconnect();
|
|
115
|
-
}, 2000
|
|
118
|
+
}, 2000);
|
|
116
119
|
}
|
|
117
120
|
this.emit("error", err);
|
|
118
121
|
});
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import mitt from "mitt";
|
|
3
3
|
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, FFMPEGRecorder, } from "@bililive-tools/manager";
|
|
4
4
|
import { getInfo, getStream, getLiveStatus, getStrictStream } from "./stream.js";
|
|
5
|
-
import { ensureFolderExist } from "./utils.js";
|
|
5
|
+
import { ensureFolderExist, hasKeyword } from "./utils.js";
|
|
6
6
|
import DanmaClient from "./danma.js";
|
|
7
7
|
function createRecorder(opts) {
|
|
8
8
|
// 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
|
|
@@ -67,7 +67,7 @@ const ffmpegOutputOptions = [
|
|
|
67
67
|
"-movflags",
|
|
68
68
|
"faststart+frag_keyframe+empty_moov",
|
|
69
69
|
"-min_frag_duration",
|
|
70
|
-
"
|
|
70
|
+
"10000000",
|
|
71
71
|
];
|
|
72
72
|
const ffmpegInputOptions = [
|
|
73
73
|
"-reconnect",
|
|
@@ -93,7 +93,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
93
93
|
cover: "",
|
|
94
94
|
liveId: liveId,
|
|
95
95
|
};
|
|
96
|
-
this.emit("LiveStart", { liveId });
|
|
97
96
|
if (liveId === banLiveId) {
|
|
98
97
|
this.tempStopIntervalCheck = true;
|
|
99
98
|
}
|
|
@@ -104,6 +103,21 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
104
103
|
return null;
|
|
105
104
|
if (!living)
|
|
106
105
|
return null;
|
|
106
|
+
// 检查标题是否包含关键词,如果包含则不自动录制
|
|
107
|
+
// 手动开始录制时不检查标题关键词
|
|
108
|
+
if (!isManualStart &&
|
|
109
|
+
this.titleKeywords &&
|
|
110
|
+
typeof this.titleKeywords === "string" &&
|
|
111
|
+
this.titleKeywords.trim()) {
|
|
112
|
+
const hasTitleKeyword = hasKeyword(_title, this.titleKeywords);
|
|
113
|
+
if (hasTitleKeyword) {
|
|
114
|
+
this.emit("DebugLog", {
|
|
115
|
+
type: "common",
|
|
116
|
+
text: `跳过录制:直播间标题 "${_title}" 包含关键词 "${this.titleKeywords}"`,
|
|
117
|
+
});
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
107
121
|
const liveInfo = await getInfo(this.channelId);
|
|
108
122
|
const { owner, title, roomId } = liveInfo;
|
|
109
123
|
this.liveInfo = liveInfo;
|
|
@@ -165,8 +179,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
165
179
|
});
|
|
166
180
|
}, 50 * 60 * 1000);
|
|
167
181
|
}
|
|
182
|
+
let isCutting = false;
|
|
168
183
|
let isEnded = false;
|
|
169
184
|
const onEnd = (...args) => {
|
|
185
|
+
if (isCutting) {
|
|
186
|
+
isCutting = false;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
170
189
|
if (isEnded)
|
|
171
190
|
return;
|
|
172
191
|
isEnded = true;
|
|
@@ -182,10 +201,14 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
182
201
|
outputOptions: ffmpegOutputOptions,
|
|
183
202
|
inputOptions: ffmpegInputOptions,
|
|
184
203
|
segment: this.segment ?? 0,
|
|
185
|
-
getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
|
|
204
|
+
getSavePath: (opts) => getSavePath({ owner, title: opts.title ?? title, startTime: opts.startTime }),
|
|
186
205
|
isHls: streamOptions.protocol_name === "http_hls",
|
|
187
206
|
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
188
|
-
|
|
207
|
+
videoFormat: this.videoFormat,
|
|
208
|
+
}, onEnd, async () => {
|
|
209
|
+
const info = await getInfo(this.channelId);
|
|
210
|
+
return info;
|
|
211
|
+
});
|
|
189
212
|
const savePath = getSavePath({
|
|
190
213
|
owner,
|
|
191
214
|
title,
|
|
@@ -197,8 +220,14 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
197
220
|
this.state = "idle";
|
|
198
221
|
throw err;
|
|
199
222
|
}
|
|
200
|
-
const handleVideoCreated = async ({ filename }) => {
|
|
201
|
-
this.emit("videoFileCreated", { filename });
|
|
223
|
+
const handleVideoCreated = async ({ filename, title, cover }) => {
|
|
224
|
+
this.emit("videoFileCreated", { filename, cover });
|
|
225
|
+
if (title && this?.liveInfo) {
|
|
226
|
+
this.liveInfo.title = title;
|
|
227
|
+
}
|
|
228
|
+
if (cover && this?.liveInfo) {
|
|
229
|
+
this.liveInfo.cover = cover;
|
|
230
|
+
}
|
|
202
231
|
const extraDataController = recorder.getExtraDataController();
|
|
203
232
|
extraDataController?.setMeta({
|
|
204
233
|
room_id: String(roomId),
|
|
@@ -224,7 +253,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
224
253
|
});
|
|
225
254
|
let danmaClient = new DanmaClient(roomId, this.auth, this.uid);
|
|
226
255
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
227
|
-
danmaClient
|
|
256
|
+
danmaClient.on("Message", (msg) => {
|
|
228
257
|
const extraDataController = recorder.getExtraDataController();
|
|
229
258
|
if (!extraDataController)
|
|
230
259
|
return;
|
|
@@ -235,10 +264,37 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
235
264
|
this.emit("Message", msg);
|
|
236
265
|
extraDataController.addMessage(msg);
|
|
237
266
|
});
|
|
267
|
+
danmaClient.on("onRoomInfoChange", (msg) => {
|
|
268
|
+
if (!isManualStart &&
|
|
269
|
+
this.titleKeywords &&
|
|
270
|
+
typeof this.titleKeywords === "string" &&
|
|
271
|
+
this.titleKeywords.trim()) {
|
|
272
|
+
const title = msg?.body?.title ?? "";
|
|
273
|
+
const hasTitleKeyword = hasKeyword(title, this.titleKeywords);
|
|
274
|
+
if (hasTitleKeyword) {
|
|
275
|
+
this.emit("DebugLog", {
|
|
276
|
+
type: "common",
|
|
277
|
+
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
278
|
+
});
|
|
279
|
+
// 停止录制
|
|
280
|
+
this.recordHandle && this.recordHandle.stop("直播间标题包含关键词");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
238
284
|
danmaClient.start();
|
|
239
285
|
}
|
|
240
286
|
const ffmpegArgs = recorder.getArguments();
|
|
241
287
|
recorder.run();
|
|
288
|
+
const cut = utils.singleton(async () => {
|
|
289
|
+
if (!this.recordHandle)
|
|
290
|
+
return;
|
|
291
|
+
if (isCutting)
|
|
292
|
+
return;
|
|
293
|
+
isCutting = true;
|
|
294
|
+
await recorder.stop();
|
|
295
|
+
recorder.createCommand();
|
|
296
|
+
recorder.run();
|
|
297
|
+
});
|
|
242
298
|
const stop = utils.singleton(async (reason) => {
|
|
243
299
|
if (!this.recordHandle)
|
|
244
300
|
return;
|
|
@@ -270,6 +326,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
|
|
|
270
326
|
ffmpegArgs,
|
|
271
327
|
savePath: savePath,
|
|
272
328
|
stop,
|
|
329
|
+
cut,
|
|
273
330
|
};
|
|
274
331
|
this.emit("RecordStart", this.recordHandle);
|
|
275
332
|
return this.recordHandle;
|
package/lib/stream.js
CHANGED
|
@@ -155,10 +155,17 @@ async function getLiveInfo(roomIdOrShortId, opts) {
|
|
|
155
155
|
let streamInfo;
|
|
156
156
|
let streamOptions;
|
|
157
157
|
for (const condition of conditons) {
|
|
158
|
-
|
|
158
|
+
const streamList = res.playurl_info.playurl.stream
|
|
159
159
|
.find(({ protocol_name }) => protocol_name === condition.protocol_name)
|
|
160
160
|
?.format.find(({ format_name }) => format_name === condition.format_name)
|
|
161
|
-
?.codec.
|
|
161
|
+
?.codec.filter(({ codec_name }) => codec_name === condition.codec_name);
|
|
162
|
+
if (streamList && streamList.length > 1) {
|
|
163
|
+
// 由于录播姬直推hevc时,指定qn,服务端仍会返回其他画质的流,这里需要指定找一下流
|
|
164
|
+
streamInfo = streamList.find((item) => item.current_qn === opts.qn);
|
|
165
|
+
}
|
|
166
|
+
if (!streamInfo) {
|
|
167
|
+
streamInfo = streamList?.[0];
|
|
168
|
+
}
|
|
162
169
|
if (streamInfo) {
|
|
163
170
|
streamOptions = {
|
|
164
171
|
...condition,
|
|
@@ -167,7 +174,11 @@ async function getLiveInfo(roomIdOrShortId, opts) {
|
|
|
167
174
|
break;
|
|
168
175
|
}
|
|
169
176
|
}
|
|
170
|
-
console.log(
|
|
177
|
+
// console.log(
|
|
178
|
+
// "streamOptions",
|
|
179
|
+
// streamOptions,
|
|
180
|
+
// JSON.stringify(res.playurl_info.playurl.stream, null, 2),
|
|
181
|
+
// );
|
|
171
182
|
assert(streamInfo, "没有找到支持的流");
|
|
172
183
|
const streams = streamInfo.accept_qn.map((qn) => {
|
|
173
184
|
const qnDesc = res.playurl_info.playurl.g_qn_desc.find((item) => item.qn === qn);
|
package/lib/utils.d.ts
CHANGED
|
@@ -21,3 +21,4 @@ export declare function assertStringType(data: unknown, msg?: string): asserts d
|
|
|
21
21
|
export declare function assertNumberType(data: unknown, msg?: string): asserts data is number;
|
|
22
22
|
export declare function assertObjectType(data: unknown, msg?: string): asserts data is object;
|
|
23
23
|
export declare function createInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => boolean;
|
|
24
|
+
export declare function hasKeyword(title: string, titleKeywords: string): boolean;
|
package/lib/utils.js
CHANGED
|
@@ -81,3 +81,11 @@ export function createInvalidStreamChecker(count = 10) {
|
|
|
81
81
|
return false;
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
+
export function hasKeyword(title, titleKeywords) {
|
|
85
|
+
const keywords = titleKeywords
|
|
86
|
+
.split(",")
|
|
87
|
+
.map((k) => k.trim())
|
|
88
|
+
.filter((k) => k);
|
|
89
|
+
const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
90
|
+
return hasTitleKeyword;
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/bilibili-recorder",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "bililive-tools bilibili recorder implemention",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"blive-message-listener": "^0.5.0",
|
|
38
38
|
"mitt": "^3.0.1",
|
|
39
|
-
"tiny-bilibili-ws": "^1.0.
|
|
39
|
+
"tiny-bilibili-ws": "^1.0.2",
|
|
40
40
|
"lodash-es": "^4.17.21",
|
|
41
41
|
"axios": "^1.7.8",
|
|
42
|
-
"@bililive-tools/manager": "^1.
|
|
42
|
+
"@bililive-tools/manager": "^1.3.0"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsc",
|