@bililive-tools/manager 1.17.2 → 1.18.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/lib/manager.js CHANGED
@@ -8,6 +8,14 @@ import { getBiliStatusInfoByRoomIds } from "./api.js";
8
8
  import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, sleep, replaceFourByteUnicode, } from "./utils.js";
9
9
  import { StreamManager } from "./downloader/streamManager.js";
10
10
  const RECORDER_TIMELINE_LIMIT = 40;
11
+ const SHORT_RECORDING_THRESHOLD = 12 * 1000;
12
+ const SHORT_RECORDING_RETRY_COUNT = 2;
13
+ const SHORT_RECORDING_REASON_BLACKLIST = new Set([
14
+ "manual stop",
15
+ "remove recorder",
16
+ "直播间标题包含关键词",
17
+ "标题变更分段",
18
+ ]);
11
19
  function normalizeTimelineText(text) {
12
20
  return text.trim();
13
21
  }
@@ -63,6 +71,18 @@ function isConfigurableProp(prop) {
63
71
  }
64
72
  export function createRecorderManager(opts) {
65
73
  const recorders = [];
74
+ // 短录制检测仅用于当前进程,不需要持久化。
75
+ const recordAttempts = new Map();
76
+ const shortRecordingStates = new Map();
77
+ const getStreamRetryHint = (recorder) => {
78
+ const state = shortRecordingStates.get(recorder.id);
79
+ if (!state?.preferAlternativeStream)
80
+ return undefined;
81
+ return {
82
+ liveId: state.liveId,
83
+ preferAlternativeStream: true,
84
+ };
85
+ };
66
86
  // 存储每个 provider 的 timer,key 为 providerId
67
87
  const checkLoopTimers = new Map();
68
88
  const multiThreadCheck = async (manager, providerId, providerConfig) => {
@@ -78,6 +98,7 @@ export function createRecorderManager(opts) {
78
98
  return genSavePathFromRule(manager, recorder, data);
79
99
  },
80
100
  banLiveId: tempBanObj[recorder.channelId],
101
+ streamRetryHint: getStreamRetryHint(recorder),
81
102
  });
82
103
  }
83
104
  }
@@ -120,6 +141,7 @@ export function createRecorderManager(opts) {
120
141
  return genSavePathFromRule(manager, recorder, data);
121
142
  },
122
143
  banLiveId,
144
+ streamRetryHint: getStreamRetryHint(recorder),
123
145
  });
124
146
  };
125
147
  threads.push(...range(0, providerConfig.maxThreadCount).map(async () => {
@@ -170,6 +192,21 @@ export function createRecorderManager(opts) {
170
192
  ensureRecorderTimeline(recorder);
171
193
  this.recorders.push(recorder);
172
194
  recorder.on("RecordStart", (recordHandle) => {
195
+ const liveId = recorder.liveInfo?.liveId;
196
+ recordAttempts.set(recordHandle.id, {
197
+ recorderId: recorder.id,
198
+ liveId,
199
+ startedAt: Date.now(),
200
+ });
201
+ const shortRecordingState = shortRecordingStates.get(recorder.id);
202
+ if (shortRecordingState) {
203
+ if (shortRecordingState.liveId === liveId) {
204
+ shortRecordingState.preferAlternativeStream = false;
205
+ }
206
+ else {
207
+ shortRecordingStates.delete(recorder.id);
208
+ }
209
+ }
173
210
  recorder.appendTimeline?.({
174
211
  startTime: recorder.liveInfo?.recordStartTime?.getTime() ?? Date.now(),
175
212
  text: `开始录制:${recordHandle.stream}/${recordHandle.source}/${recordHandle.recorderType}/${recordHandle.url}`,
@@ -199,10 +236,36 @@ export function createRecorderManager(opts) {
199
236
  recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
200
237
  recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
201
238
  recorder.on("RecordStop", ({ recordHandle, reason }) => {
239
+ const stoppedAt = Date.now();
202
240
  recorder.appendTimeline?.({
203
- startTime: Date.now(),
241
+ startTime: stoppedAt,
204
242
  text: reason ? `停止录制:${reason}` : "停止录制",
205
243
  });
244
+ const attempt = recordAttempts.get(recordHandle.id);
245
+ recordAttempts.delete(recordHandle.id);
246
+ if (attempt?.recorderId === recorder.id && attempt.liveId) {
247
+ const isReasonBlacklisted = reason != null && SHORT_RECORDING_REASON_BLACKLIST.has(reason);
248
+ const duration = stoppedAt - attempt.startedAt;
249
+ const previousState = shortRecordingStates.get(recorder.id);
250
+ if (isReasonBlacklisted || duration >= SHORT_RECORDING_THRESHOLD) {
251
+ shortRecordingStates.delete(recorder.id);
252
+ }
253
+ else {
254
+ const count = previousState?.liveId === attempt.liveId ? previousState.count + 1 : 1;
255
+ const preferAlternativeStream = count >= SHORT_RECORDING_RETRY_COUNT;
256
+ shortRecordingStates.set(recorder.id, {
257
+ liveId: attempt.liveId,
258
+ count,
259
+ preferAlternativeStream,
260
+ });
261
+ if (preferAlternativeStream && !previousState?.preferAlternativeStream) {
262
+ recorder.appendTimeline?.({
263
+ startTime: stoppedAt,
264
+ text: `连续 ${SHORT_RECORDING_RETRY_COUNT} 次录制在 ${SHORT_RECORDING_THRESHOLD / 1000} 秒内结束,下次可能尝试切换流格式(仅抖音)`,
265
+ });
266
+ }
267
+ }
268
+ }
206
269
  this.emit("RecordStop", { recorder: recorder.toJSON(), recordHandle, reason });
207
270
  const maxRetryCount = 10;
208
271
  // 默认策略下,如果录制被中断,那么会在下一个检查周期时重新检查直播状态并重新开始录制,这种策略的问题就是一部分时间会被漏掉。
@@ -241,6 +304,7 @@ export function createRecorderManager(opts) {
241
304
  getSavePath(data) {
242
305
  return genSavePathFromRule(manager, recorder, data);
243
306
  },
307
+ streamRetryHint: getStreamRetryHint(recorder),
244
308
  });
245
309
  }, 1000);
246
310
  }
@@ -296,6 +360,11 @@ export function createRecorderManager(opts) {
296
360
  if (k.startsWith(keyPrefix))
297
361
  delete chargeLiveObj[k];
298
362
  });
363
+ shortRecordingStates.delete(recorder.id);
364
+ for (const [recordHandleId, attempt] of recordAttempts) {
365
+ if (attempt.recorderId === recorder.id)
366
+ recordAttempts.delete(recordHandleId);
367
+ }
299
368
  this.emit("RecorderRemoved", recorder.toJSON());
300
369
  },
301
370
  getRecorder(id) {
@@ -317,6 +386,7 @@ export function createRecorderManager(opts) {
317
386
  return genSavePathFromRule(manager, recorder, data);
318
387
  },
319
388
  isManualStart: true,
389
+ streamRetryHint: getStreamRetryHint(recorder),
320
390
  });
321
391
  delete tempBanObj[recorder.channelId];
322
392
  recorder.tempStopIntervalCheck = false;
package/lib/recorder.d.ts CHANGED
@@ -29,6 +29,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
29
29
  convert2Mp4?: boolean;
30
30
  /** 身份验证 */
31
31
  auth?: string;
32
+ /** 抖音 Cookie 使用范围,默认为全部 */
33
+ cookieMode?: "all" | "stream" | "danma";
32
34
  /** cookie所有者uid,B站弹幕录制 */
33
35
  uid?: number | string;
34
36
  /** 画质匹配重试次数 */
@@ -87,6 +89,10 @@ export interface AppendRecorderTimelineArgs {
87
89
  startTime?: number;
88
90
  text: string;
89
91
  }
92
+ export interface StreamRetryHint {
93
+ liveId: string;
94
+ preferAlternativeStream: boolean;
95
+ }
90
96
  export interface RecordHandle {
91
97
  id: string;
92
98
  stream: string;
@@ -172,6 +178,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
172
178
  getSavePath: GetSavePath;
173
179
  banLiveId?: string;
174
180
  isManualStart?: boolean;
181
+ /** 上一次使用的流疑似不可用,录制器可在同一场直播中优先尝试备用流 */
182
+ streamRetryHint?: StreamRetryHint;
175
183
  }) => Promise<RecordHandle | null>;
176
184
  recordHandle?: RecordHandle;
177
185
  toJSON: (this: Recorder<E>) => SerializedRecorder<E>;
@@ -1,9 +1,16 @@
1
1
  import { Message } from "./common.js";
2
+ export interface DanmaStatsPayload {
3
+ danmaTimeline: {
4
+ interval: number;
5
+ data: number[];
6
+ };
7
+ }
2
8
  export interface XmlStreamStats {
3
9
  danmaNum: number;
4
10
  uniqMember: number;
5
11
  scNum: number;
6
12
  guardNum: number;
13
+ danmaStats: DanmaStatsPayload;
7
14
  }
8
15
  export interface XmlStreamData {
9
16
  header: string;
@@ -7,6 +7,7 @@ import { XMLBuilder } from "fast-xml-parser";
7
7
  import { pick } from "lodash-es";
8
8
  const METADATA_PLACEHOLDER = "<!--METADATA_PLACEHOLDER-->";
9
9
  const XML_FILE_HEADER = `<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet type="text/xsl" href="#s"?>\n<i>\n${METADATA_PLACEHOLDER}\n<RecorderXmlStyle><z:stylesheet version="1.0" id="s" xml:id="s" xmlns:z="http://www.w3.org/1999/XSL/Transform"><z:output method="html"/><z:template match="/"><html><meta name="viewport" content="width=device-width"/><title>弹幕文件 <z:value-of select="/i/metadata/user_name/text()"/></title><style>body{margin:0}h1,h2,p,table{margin-left:5px}table{border-spacing:0}td,th{border:1px solid grey;padding:1px 5px}th{position:sticky;top:0;background:#4098de}tr:hover{background:#d9f4ff}div{overflow:auto;max-height:80vh;max-width:100vw;width:fit-content}</style><h1>弹幕XML文件</h1><p>本文件不支持在 IE 浏览器里预览,请使用 Chrome Firefox Edge 等浏览器。</p><p>文件用法参考文档 <a href="https://rec.danmuji.org/user/danmaku/">https://rec.danmuji.org/user/danmaku/</a></p><table><tr><td>房间号</td><td><z:value-of select="/i/metadata/room_id/text()"/></td></tr><tr><td>主播名</td><td><z:value-of select="/i/metadata/user_name/text()"/></td></tr><tr><td><a href="#d">弹幕</a></td><td>共<z:value-of select="count(/i/d)"/>条记录</td></tr><tr><td><a href="#guard">上船</a></td><td>共<z:value-of select="count(/i/guard)"/>条记录</td></tr><tr><td><a href="#sc">SC</a></td><td>共<z:value-of select="count(/i/sc)"/>条记录</td></tr><tr><td><a href="#gift">礼物</a></td><td>共<z:value-of select="count(/i/gift)"/>条记录</td></tr></table><h2 id="d">弹幕</h2><div id="dm"><table><tr><th>用户名</th><th>出现时间</th><th>用户ID</th><th>弹幕</th><th>参数</th></tr><z:for-each select="/i/d"><tr><td><z:value-of select="@user"/></td><td></td><td></td><td><z:value-of select="."/></td><td><z:value-of select="@p"/></td></tr></z:for-each></table></div><script>Array.from(document.querySelectorAll('#dm tr')).slice(1).map(t=>t.querySelectorAll('td')).forEach(t=>{let p=t[4].textContent.split(','),a=p[0];t[1].textContent=\`\u0024{(Math.floor(a/60/60)+'').padStart(2,0)}:\u0024{(Math.floor(a/60%60)+'').padStart(2,0)}:\u0024{(a%60).toFixed(3).padStart(6,0)}\`;t[2].innerHTML=\`&lt;a target=_blank rel="nofollow noreferrer" "&gt;\u0024{p[6]}&lt;/a&gt;\`})</script><h2 id="guard">舰长购买</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>舰长等级</th><th>购买数量</th><th>出现时间</th></tr><z:for-each select="/i/guard"><tr><td><z:value-of select="@user"/></td><td><a rel="nofollow noreferrer"><z:attribute name="href"><z:text></z:text><z:value-of select="@uid" /></z:attribute><z:value-of select="@uid"/></a></td><td><z:value-of select="@level"/></td><td><z:value-of select="@count"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div><h2 id="sc">SuperChat 醒目留言</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>内容</th><th>显示时长</th><th>价格</th><th>出现时间</th></tr><z:for-each select="/i/sc"><tr><td><z:value-of select="@user"/></td><td><a rel="nofollow noreferrer"><z:attribute name="href"><z:text></z:text><z:value-of select="@uid" /></z:attribute><z:value-of select="@uid"/></a></td><td><z:value-of select="."/></td><td><z:value-of select="@time"/></td><td><z:value-of select="@price"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div><h2 id="gift">礼物</h2><div><table><tr><th>用户名</th><th>用户ID</th><th>礼物名</th><th>礼物数量</th><th>出现时间</th></tr><z:for-each select="/i/gift"><tr><td><z:value-of select="@user"/></td><td><span rel="nofollow noreferrer"><z:attribute name="href"></z:attribute><z:value-of select="@uid"/></span></td><td><z:value-of select="@giftname"/></td><td><z:value-of select="@giftcount"/></td><td><z:value-of select="@ts"/></td></tr></z:for-each></table></div></html></z:template></z:stylesheet></RecorderXmlStyle>\n`;
10
+ const DANMA_STATS_INTERVAL = 10;
10
11
  export function createRecordExtraDataController(savePath) {
11
12
  const data = {
12
13
  header: XML_FILE_HEADER,
@@ -20,13 +21,42 @@ export function createRecordExtraDataController(savePath) {
20
21
  let danmaNum = 0;
21
22
  let scNum = 0;
22
23
  let guardNum = 0;
24
+ let maxDanmaBucketStart = -1;
23
25
  const interactedUsers = new Set();
26
+ const danmaBucketCountMap = new Map();
27
+ const getDanmaStats = () => {
28
+ if (maxDanmaBucketStart < 0) {
29
+ return {
30
+ danmaTimeline: {
31
+ interval: DANMA_STATS_INTERVAL,
32
+ data: [],
33
+ },
34
+ };
35
+ }
36
+ const stats = [];
37
+ for (let start = 0; start <= maxDanmaBucketStart; start += DANMA_STATS_INTERVAL) {
38
+ stats.push(danmaBucketCountMap.get(start) ?? 0);
39
+ }
40
+ return {
41
+ danmaTimeline: {
42
+ interval: DANMA_STATS_INTERVAL,
43
+ data: stats,
44
+ },
45
+ };
46
+ };
24
47
  const getStats = () => ({
25
48
  danmaNum,
26
49
  uniqMember: interactedUsers.size,
27
50
  scNum,
28
51
  guardNum,
52
+ danmaStats: getDanmaStats(),
29
53
  });
54
+ const trackDanmaBucket = (message) => {
55
+ const progressInSeconds = Math.max((message.timestamp - data.meta.recordStartTimestamp) / 1000, 0);
56
+ const bucketStart = Math.floor(progressInSeconds / DANMA_STATS_INTERVAL) * DANMA_STATS_INTERVAL;
57
+ danmaBucketCountMap.set(bucketStart, (danmaBucketCountMap.get(bucketStart) ?? 0) + 1);
58
+ maxDanmaBucketStart = Math.max(maxDanmaBucketStart, bucketStart);
59
+ };
30
60
  const trackInteractedUser = (message) => {
31
61
  const userName = message.sender?.name?.trim();
32
62
  if (!userName)
@@ -68,6 +98,7 @@ export function createRecordExtraDataController(savePath) {
68
98
  return;
69
99
  if (message.type === "comment") {
70
100
  danmaNum += 1;
101
+ trackDanmaBucket(message);
71
102
  }
72
103
  else if (message.type === "super_chat") {
73
104
  scNum += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.17.2",
3
+ "version": "1.18.0",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -28,7 +28,11 @@
28
28
  "scheduling",
29
29
  "recorders"
30
30
  ],
31
- "repository": "https://github.com/renmu123/biliLive-tools",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/renmu123/biliLive-tools",
34
+ "directory": "packages/liveManager"
35
+ },
32
36
  "author": "renmu123",
33
37
  "license": "LGPL",
34
38
  "dependencies": {