@bililive-tools/bilibili-recorder 1.2.0 → 1.4.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 CHANGED
@@ -51,6 +51,7 @@ interface Options {
51
51
  useM3U8Proxy?: boolean; // 是否使用m3u8代理,由于hls及fmp4存在一个小时超时时间,需自行实现代理避免
52
52
  m3u8ProxyUrl?: string; // 代理链接,文档待补充
53
53
  videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
54
+ onlyAudio?: boolean; // 只录制音频,默认为否
54
55
  }
55
56
  ```
56
57
 
@@ -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;
@@ -71,6 +71,7 @@ export declare function getPlayURL(roomId: number, opts?: {
71
71
  export declare function getRoomPlayInfo(roomIdOrShortId: number, opts?: {
72
72
  qn?: number;
73
73
  cookie?: string;
74
+ onlyAudio?: boolean;
74
75
  }): Promise<{
75
76
  uid: number;
76
77
  room_id: number;
@@ -85,6 +86,7 @@ export declare function getRoomPlayInfo(roomIdOrShortId: number, opts?: {
85
86
  };
86
87
  };
87
88
  }>;
89
+ export declare function getBuvidConf(): Promise<any>;
88
90
  export interface ProtocolInfo {
89
91
  protocol_name: "http_stream" | "http_hls";
90
92
  format: FormatInfo[];
@@ -110,4 +112,3 @@ export interface SourceProfile {
110
112
  extra: string;
111
113
  stream_ttl: number;
112
114
  }
113
- export {};
@@ -61,6 +61,7 @@ export async function getRoomPlayInfo(roomIdOrShortId, opts = {}) {
61
61
  codec: "0,1",
62
62
  // 0 flv, 1 ts, 2 fmp4
63
63
  format: "0,1,2",
64
+ only_audio: opts.onlyAudio ? "1" : "0",
64
65
  },
65
66
  headers: {
66
67
  Cookie: opts.cookie,
@@ -69,3 +70,14 @@ export async function getRoomPlayInfo(roomIdOrShortId, opts = {}) {
69
70
  assert(res.data.code === 0, `Unexpected resp, code ${res.data.code}, msg ${res.data.message}`);
70
71
  return res.data.data;
71
72
  }
73
+ export async function getBuvidConf() {
74
+ const res = await fetch("https://api.bilibili.com/x/frontend/finger/spi", {
75
+ headers: {
76
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
77
+ },
78
+ });
79
+ if (!res.ok)
80
+ throw new Error(`Failed to get buvid conf: ${res.statusText}`);
81
+ const data = await res.json();
82
+ return data;
83
+ }
package/lib/danma.d.ts CHANGED
@@ -6,7 +6,7 @@ declare class DanmaClient extends EventEmitter {
6
6
  private uid;
7
7
  private retryCount;
8
8
  constructor(roomId: number, auth: string | undefined, uid: number | undefined);
9
- start(): void;
9
+ start(): Promise<void>;
10
10
  stop(): void;
11
11
  }
12
12
  export default DanmaClient;
package/lib/danma.js CHANGED
@@ -1,5 +1,46 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { startListen } from "./blive-message-listener/index.js";
3
+ import { getBuvidConf } from "./bilibili_api.js";
4
+ // 全局缓存,一天过期时间 (24 * 60 * 60 * 1000 ms)
5
+ const CACHE_DURATION = 24 * 60 * 60 * 1000;
6
+ let buvidCache = null;
7
+ // 获取带缓存的 buvid 配置
8
+ async function getCachedBuvidConf() {
9
+ const now = Date.now();
10
+ // 检查缓存是否有效
11
+ if (buvidCache && now - buvidCache.timestamp < CACHE_DURATION) {
12
+ return buvidCache.data;
13
+ }
14
+ // 缓存失效或不存在,重新获取(带重试)
15
+ const info = await getBuvidConfWithRetry();
16
+ buvidCache = {
17
+ data: info,
18
+ timestamp: now,
19
+ };
20
+ return info;
21
+ }
22
+ // 带重试功能的 getBuvidConf
23
+ async function getBuvidConfWithRetry(maxRetries = 3, retryDelay = 1000) {
24
+ let lastError;
25
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
26
+ try {
27
+ const result = await getBuvidConf();
28
+ return result;
29
+ }
30
+ catch (error) {
31
+ lastError = error;
32
+ // 如果是最后一次尝试,直接抛出错误
33
+ if (attempt === maxRetries) {
34
+ throw error;
35
+ }
36
+ // 等待指定时间后重试,使用指数退避策略
37
+ const delay = retryDelay * Math.pow(2, attempt - 1);
38
+ await new Promise((resolve) => setTimeout(resolve, delay));
39
+ }
40
+ }
41
+ // 这里不应该到达,但为了类型安全
42
+ throw lastError;
43
+ }
3
44
  class DanmaClient extends EventEmitter {
4
45
  client = null;
5
46
  roomId;
@@ -12,7 +53,9 @@ class DanmaClient extends EventEmitter {
12
53
  this.auth = auth;
13
54
  this.uid = uid;
14
55
  }
15
- start() {
56
+ async start() {
57
+ const info = await getCachedBuvidConf();
58
+ const buvid3 = info.data.b_3;
16
59
  const handler = {
17
60
  onIncomeDanmu: (msg) => {
18
61
  let content = msg.body.content;
@@ -21,7 +64,7 @@ class DanmaClient extends EventEmitter {
21
64
  return;
22
65
  const comment = {
23
66
  type: "comment",
24
- timestamp: msg.timestamp,
67
+ timestamp: msg.body.timestamp,
25
68
  text: content,
26
69
  color: msg.body.content_color,
27
70
  mode: msg.body.type,
@@ -41,7 +84,7 @@ class DanmaClient extends EventEmitter {
41
84
  const content = msg.body.content.replaceAll(/[\r\n]/g, "");
42
85
  const comment = {
43
86
  type: "super_chat",
44
- timestamp: msg.timestamp,
87
+ timestamp: msg.raw.send_time,
45
88
  text: content,
46
89
  price: msg.body.price,
47
90
  sender: {
@@ -79,7 +122,7 @@ class DanmaClient extends EventEmitter {
79
122
  onGift: (msg) => {
80
123
  const gift = {
81
124
  type: "give_gift",
82
- timestamp: msg.timestamp,
125
+ timestamp: msg.raw.send_time,
83
126
  name: msg.body.gift_name,
84
127
  count: msg.body.amount,
85
128
  price: msg.body.coin_type === "silver" ? 0 : msg.body.price / 1000,
@@ -98,11 +141,26 @@ class DanmaClient extends EventEmitter {
98
141
  };
99
142
  this.emit("Message", gift);
100
143
  },
144
+ onRoomInfoChange: (msg) => {
145
+ this.emit("RoomInfoChange", msg);
146
+ },
101
147
  };
148
+ let lastAuth = "";
149
+ if (this.auth?.includes("buvid3")) {
150
+ lastAuth = this.auth;
151
+ }
152
+ else {
153
+ if (this.auth) {
154
+ lastAuth = `${this.auth}; buvid3=${buvid3}`;
155
+ }
156
+ else {
157
+ lastAuth = `buvid3=${buvid3}`;
158
+ }
159
+ }
102
160
  this.client = startListen(this.roomId, handler, {
103
161
  ws: {
104
162
  headers: {
105
- Cookie: this.auth ?? "",
163
+ Cookie: lastAuth,
106
164
  },
107
165
  uid: this.uid ?? 0,
108
166
  },
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
- "60000000",
70
+ "10000000",
71
71
  ];
72
72
  const ffmpegInputOptions = [
73
73
  "-reconnect",
@@ -103,7 +103,21 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
103
103
  return null;
104
104
  if (!living)
105
105
  return null;
106
- this.emit("LiveStart", { liveId });
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;
@@ -127,6 +141,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
127
141
  strictQuality: strictQuality,
128
142
  formatName: this.formatName,
129
143
  codecName: this.codecName,
144
+ onlyAudio: this.onlyAudio,
130
145
  });
131
146
  }
132
147
  catch (err) {
@@ -165,8 +180,13 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
165
180
  });
166
181
  }, 50 * 60 * 1000);
167
182
  }
183
+ let isCutting = false;
168
184
  let isEnded = false;
169
185
  const onEnd = (...args) => {
186
+ if (isCutting) {
187
+ isCutting = false;
188
+ return;
189
+ }
170
190
  if (isEnded)
171
191
  return;
172
192
  isEnded = true;
@@ -182,11 +202,14 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
182
202
  outputOptions: ffmpegOutputOptions,
183
203
  inputOptions: ffmpegInputOptions,
184
204
  segment: this.segment ?? 0,
185
- getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
205
+ getSavePath: (opts) => getSavePath({ owner, title: opts.title ?? title, startTime: opts.startTime }),
186
206
  isHls: streamOptions.protocol_name === "http_hls",
187
207
  disableDanma: this.disableProvideCommentsWhenRecording,
188
208
  videoFormat: this.videoFormat,
189
- }, onEnd);
209
+ }, onEnd, async () => {
210
+ const info = await getInfo(this.channelId);
211
+ return info;
212
+ });
190
213
  const savePath = getSavePath({
191
214
  owner,
192
215
  title,
@@ -198,14 +221,20 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
198
221
  this.state = "idle";
199
222
  throw err;
200
223
  }
201
- const handleVideoCreated = async ({ filename }) => {
202
- this.emit("videoFileCreated", { filename });
224
+ const handleVideoCreated = async ({ filename, title, cover }) => {
225
+ this.emit("videoFileCreated", { filename, cover });
226
+ if (title && this?.liveInfo) {
227
+ this.liveInfo.title = title;
228
+ }
229
+ if (cover && this?.liveInfo) {
230
+ this.liveInfo.cover = cover;
231
+ }
203
232
  const extraDataController = recorder.getExtraDataController();
204
233
  extraDataController?.setMeta({
205
234
  room_id: String(roomId),
206
235
  platform: provider?.id,
207
236
  liveStartTimestamp: liveInfo.startTime?.getTime(),
208
- recordStopTimestamp: Date.now(),
237
+ // recordStopTimestamp: Date.now(),
209
238
  title: title,
210
239
  user_name: owner,
211
240
  });
@@ -225,7 +254,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
225
254
  });
226
255
  let danmaClient = new DanmaClient(roomId, this.auth, this.uid);
227
256
  if (!this.disableProvideCommentsWhenRecording) {
228
- danmaClient = danmaClient.on("Message", (msg) => {
257
+ danmaClient.on("Message", (msg) => {
229
258
  const extraDataController = recorder.getExtraDataController();
230
259
  if (!extraDataController)
231
260
  return;
@@ -236,10 +265,37 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
236
265
  this.emit("Message", msg);
237
266
  extraDataController.addMessage(msg);
238
267
  });
268
+ danmaClient.on("onRoomInfoChange", (msg) => {
269
+ if (!isManualStart &&
270
+ this.titleKeywords &&
271
+ typeof this.titleKeywords === "string" &&
272
+ this.titleKeywords.trim()) {
273
+ const title = msg?.body?.title ?? "";
274
+ const hasTitleKeyword = hasKeyword(title, this.titleKeywords);
275
+ if (hasTitleKeyword) {
276
+ this.emit("DebugLog", {
277
+ type: "common",
278
+ text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
279
+ });
280
+ // 停止录制
281
+ this.recordHandle && this.recordHandle.stop("直播间标题包含关键词");
282
+ }
283
+ }
284
+ });
239
285
  danmaClient.start();
240
286
  }
241
287
  const ffmpegArgs = recorder.getArguments();
242
288
  recorder.run();
289
+ const cut = utils.singleton(async () => {
290
+ if (!this.recordHandle)
291
+ return;
292
+ if (isCutting)
293
+ return;
294
+ isCutting = true;
295
+ await recorder.stop();
296
+ recorder.createCommand();
297
+ recorder.run();
298
+ });
243
299
  const stop = utils.singleton(async (reason) => {
244
300
  if (!this.recordHandle)
245
301
  return;
@@ -271,6 +327,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, b
271
327
  ffmpegArgs,
272
328
  savePath: savePath,
273
329
  stop,
330
+ cut,
274
331
  };
275
332
  this.emit("RecordStart", this.recordHandle);
276
333
  return this.recordHandle;
package/lib/stream.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality">
30
30
  strictQuality?: boolean;
31
31
  formatName: RecorderCreateOpts["formatName"];
32
32
  codecName: RecorderCreateOpts["codecName"];
33
+ onlyAudio?: boolean;
33
34
  }): Promise<{
34
35
  currentStream: {
35
36
  name: string;
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
- streamInfo = res.playurl_info.playurl.stream
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.find(({ codec_name }) => codec_name === condition.codec_name);
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("streamOptions", streamOptions, res.playurl_info.playurl.stream);
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);
@@ -200,6 +211,7 @@ export async function getStream(opts) {
200
211
  cookie: opts.cookie,
201
212
  formatName: opts.formatName,
202
213
  codecName: opts.codecName,
214
+ onlyAudio: opts.onlyAudio,
203
215
  });
204
216
  // console.log(JSON.stringify(liveInfo, null, 2));
205
217
  if (liveInfo.current_qn !== qn && opts.strictQuality) {
@@ -213,6 +225,7 @@ export async function getStream(opts) {
213
225
  cookie: opts.cookie,
214
226
  formatName: opts.formatName,
215
227
  codecName: opts.codecName,
228
+ onlyAudio: opts.onlyAudio,
216
229
  });
217
230
  }
218
231
  let expectSource = liveInfo.sources[0];
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.2.0",
3
+ "version": "1.4.1",
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.1",
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.2.0"
42
+ "@bililive-tools/manager": "^1.3.0"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsc",