@bililive-tools/manager 1.17.1 → 1.17.3
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/index.js +1 -0
- package/lib/manager.js +71 -1
- package/lib/recorder.d.ts +9 -1
- package/lib/utils.d.ts +6 -4
- package/package.json +1 -1
package/lib/index.js
CHANGED
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
|
@@ -52,6 +52,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
52
52
|
* 2. 正则表达式:'/pattern/flags'(如:'/回放|录播/i')
|
|
53
53
|
*/
|
|
54
54
|
titleKeywords?: string;
|
|
55
|
+
/** B站直播间标题变更时分段 */
|
|
56
|
+
segmentOnTitleChange?: boolean;
|
|
55
57
|
/** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
|
|
56
58
|
videoFormat?: "auto" | "ts" | "mkv" | "flv";
|
|
57
59
|
/** 录制类型 */
|
|
@@ -70,7 +72,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
70
72
|
/** 调试等级 */
|
|
71
73
|
debugLevel?: "none" | "basic" | "verbose";
|
|
72
74
|
}
|
|
73
|
-
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "convert2Mp4" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords">;
|
|
75
|
+
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "convert2Mp4" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords" | "segmentOnTitleChange">;
|
|
74
76
|
/** 录制状态,idle: 空闲中,recording: 录制中,stopping-record: 停止录制中,check-error: 检查错误,title-blocked: 标题黑名单,charge-skipped: 充电直播(DRM加密)已跳过 */
|
|
75
77
|
export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error" | "title-blocked" | "charge-skipped";
|
|
76
78
|
export type Progress = {
|
|
@@ -85,6 +87,10 @@ export interface AppendRecorderTimelineArgs {
|
|
|
85
87
|
startTime?: number;
|
|
86
88
|
text: string;
|
|
87
89
|
}
|
|
90
|
+
export interface StreamRetryHint {
|
|
91
|
+
liveId: string;
|
|
92
|
+
preferAlternativeStream: boolean;
|
|
93
|
+
}
|
|
88
94
|
export interface RecordHandle {
|
|
89
95
|
id: string;
|
|
90
96
|
stream: string;
|
|
@@ -170,6 +176,8 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
170
176
|
getSavePath: GetSavePath;
|
|
171
177
|
banLiveId?: string;
|
|
172
178
|
isManualStart?: boolean;
|
|
179
|
+
/** 上一次使用的流疑似不可用,录制器可在同一场直播中优先尝试备用流 */
|
|
180
|
+
streamRetryHint?: StreamRetryHint;
|
|
173
181
|
}) => Promise<RecordHandle | null>;
|
|
174
182
|
recordHandle?: RecordHandle;
|
|
175
183
|
toJSON: (this: Recorder<E>) => SerializedRecorder<E>;
|
package/lib/utils.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export declare function createTimeoutChecker(onTimeout: () => void, time: number
|
|
|
61
61
|
start: () => void;
|
|
62
62
|
};
|
|
63
63
|
export declare function downloadImage(imageUrl: string, savePath: string): Promise<void>;
|
|
64
|
+
declare const md5: (str: string) => string;
|
|
65
|
+
declare const uuid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
64
66
|
/**
|
|
65
67
|
* 根据指定的顺序对对象数组进行排序
|
|
66
68
|
* @param objects 要排序的对象数组
|
|
@@ -133,15 +135,15 @@ declare const _default: {
|
|
|
133
135
|
createFFmpegInvalidStreamChecker: typeof createFFmpegInvalidStreamChecker;
|
|
134
136
|
createTimeoutChecker: typeof createTimeoutChecker;
|
|
135
137
|
downloadImage: typeof downloadImage;
|
|
136
|
-
md5:
|
|
137
|
-
uuid:
|
|
138
|
+
md5: typeof md5;
|
|
139
|
+
uuid: typeof uuid;
|
|
138
140
|
sortByKeyOrder: typeof sortByKeyOrder;
|
|
139
141
|
retry: typeof retry;
|
|
140
|
-
isBetweenTimeRange:
|
|
142
|
+
isBetweenTimeRange: typeof isBetweenTimeRange;
|
|
141
143
|
hasBlockedTitleKeywords: typeof hasBlockedTitleKeywords;
|
|
142
144
|
shouldCheckTitleKeywords: typeof shouldCheckTitleKeywords;
|
|
143
145
|
shouldUseStrictQuality: typeof shouldUseStrictQuality;
|
|
144
|
-
sleep:
|
|
146
|
+
sleep: typeof sleep;
|
|
145
147
|
checkTitleKeywordsWhileRecording: typeof checkTitleKeywordsWhileRecording;
|
|
146
148
|
checkTitleKeywordsBeforeRecord: typeof checkTitleKeywordsBeforeRecord;
|
|
147
149
|
};
|