@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 +71 -1
- package/lib/recorder.d.ts +8 -0
- package/lib/xml_stream_controller.d.ts +7 -0
- package/lib/xml_stream_controller.js +31 -0
- package/package.json +6 -2
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:
|
|
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=\`<a target=_blank rel="nofollow noreferrer" ">\u0024{p[6]}</a>\`})</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.
|
|
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":
|
|
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": {
|