@bililive-tools/manager 1.3.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 +1 -1
- package/lib/FFMPEGRecorder.js +10 -5
- package/lib/manager.d.ts +1 -0
- package/lib/manager.js +14 -4
- package/lib/record_extra_data_controller.js +14 -2
- package/lib/recorder.d.ts +9 -1
- package/lib/streamManager.js +4 -4
- package/lib/utils.d.ts +2 -0
- package/lib/utils.js +48 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ import { provider } from "@bililive-tools/bilibili-recorder";
|
|
|
28
28
|
|
|
29
29
|
const manager = createRecorderManager({
|
|
30
30
|
providers: [provider],
|
|
31
|
-
savePathRule: "D:\\录制\\{platforme}}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}", //
|
|
31
|
+
savePathRule: "D:\\录制\\{platforme}}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}", // 保存路径,占位符见文档,支持 [ejs](https://ejs.co/) 模板引擎
|
|
32
32
|
autoCheckInterval: 1000 * 60, // 自动检查间隔,单位秒
|
|
33
33
|
autoRemoveSystemReservedChars: true, // 移除系统非法字符串
|
|
34
34
|
biliBatchQuery: false, // B站检查使用批量接口
|
package/lib/FFMPEGRecorder.js
CHANGED
|
@@ -52,7 +52,7 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
52
52
|
}
|
|
53
53
|
createCommand() {
|
|
54
54
|
this.timeoutChecker?.start();
|
|
55
|
-
const invalidCount = this.isHls ? 35 :
|
|
55
|
+
const invalidCount = this.isHls ? 35 : 18;
|
|
56
56
|
const isInvalidStream = createInvalidStreamChecker(invalidCount);
|
|
57
57
|
const inputOptions = [
|
|
58
58
|
...this.inputOptions,
|
|
@@ -117,13 +117,18 @@ export class FFMPEGRecorder extends EventEmitter {
|
|
|
117
117
|
async stop() {
|
|
118
118
|
this.timeoutChecker.stop();
|
|
119
119
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
// ts文件使用write("q")需要十来秒进行处理,直接中断,其他格式使用sigint会导致缺少数据
|
|
121
|
+
if (this.streamManager.videoFilePath.endsWith(".ts")) {
|
|
122
|
+
this.command.kill("SIGINT");
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// @ts-ignore
|
|
126
|
+
this.command.ffmpegProc?.stdin?.write("q");
|
|
127
|
+
}
|
|
123
128
|
await this.streamManager.handleVideoCompleted();
|
|
124
129
|
}
|
|
125
130
|
catch (err) {
|
|
126
|
-
this.emit("DebugLog", { type: "
|
|
131
|
+
this.emit("DebugLog", { type: "error", text: String(err) });
|
|
127
132
|
}
|
|
128
133
|
}
|
|
129
134
|
getExtraDataController() {
|
package/lib/manager.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface RecorderProvider<E extends AnyObject> {
|
|
|
13
13
|
title: string;
|
|
14
14
|
owner: string;
|
|
15
15
|
uid?: number;
|
|
16
|
+
avatar?: string;
|
|
16
17
|
} | null>;
|
|
17
18
|
createRecorder: (this: RecorderProvider<E>, opts: Omit<RecorderCreateOpts<E>, "providerId">) => Recorder<E>;
|
|
18
19
|
fromJSON: <T extends SerializedRecorder<E>>(this: RecorderProvider<E>, json: T) => Recorder<E>;
|
package/lib/manager.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import mitt from "mitt";
|
|
3
|
+
import ejs from "ejs";
|
|
3
4
|
import { omit, range } from "lodash-es";
|
|
4
5
|
import { parseArgsStringToArgv } from "string-argv";
|
|
5
6
|
import { getBiliStatusInfoByRoomIds } from "./api.js";
|
|
6
|
-
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, } from "./utils.js";
|
|
7
|
+
import { formatDate, removeSystemReservedChars, formatTemplate, replaceExtName, downloadImage, isBetweenTimeRange, } from "./utils.js";
|
|
7
8
|
import { StreamManager } from "./streamManager.js";
|
|
8
9
|
const configurableProps = [
|
|
9
10
|
"savePathRule",
|
|
@@ -38,7 +39,9 @@ export function createRecorderManager(opts) {
|
|
|
38
39
|
const maxThreadCount = 3;
|
|
39
40
|
// 这里暂时不打算用 state == recording 来过滤,provider 必须内部自己处理录制过程中的 check,
|
|
40
41
|
// 这样可以防止一些意外调用 checkLiveStatusAndRecord 时出现重复录制。
|
|
41
|
-
let needCheckRecorders = recorders
|
|
42
|
+
let needCheckRecorders = recorders
|
|
43
|
+
.filter((r) => !r.disableAutoCheck)
|
|
44
|
+
.filter((r) => isBetweenTimeRange(r.handleTime));
|
|
42
45
|
let threads = [];
|
|
43
46
|
if (manager.biliBatchQuery) {
|
|
44
47
|
const biliNeedCheckRecorders = needCheckRecorders
|
|
@@ -271,9 +274,16 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
271
274
|
};
|
|
272
275
|
if (manager.autoRemoveSystemReservedChars) {
|
|
273
276
|
for (const key in params) {
|
|
274
|
-
params[key] = removeSystemReservedChars(String(params[key]));
|
|
277
|
+
params[key] = removeSystemReservedChars(String(params[key])).trim();
|
|
275
278
|
}
|
|
276
279
|
}
|
|
277
|
-
|
|
280
|
+
let savePathRule = manager.savePathRule;
|
|
281
|
+
try {
|
|
282
|
+
savePathRule = ejs.render(savePathRule, params);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
console.error("模板解析错误", error);
|
|
286
|
+
}
|
|
287
|
+
return formatTemplate(savePathRule, params);
|
|
278
288
|
}
|
|
279
289
|
export { StreamManager };
|
|
@@ -14,6 +14,7 @@ export function createRecordExtraDataController(savePath) {
|
|
|
14
14
|
},
|
|
15
15
|
messages: [],
|
|
16
16
|
};
|
|
17
|
+
let hasCompleted = false;
|
|
17
18
|
const scheduleSave = asyncThrottle(() => save(), 30e3, {
|
|
18
19
|
immediateRunWhenEndOfDefer: true,
|
|
19
20
|
});
|
|
@@ -22,10 +23,14 @@ export function createRecordExtraDataController(savePath) {
|
|
|
22
23
|
};
|
|
23
24
|
// TODO: 将所有数据存放在内存中可能存在问题
|
|
24
25
|
const addMessage = (comment) => {
|
|
26
|
+
if (hasCompleted)
|
|
27
|
+
return;
|
|
25
28
|
data.messages.push(comment);
|
|
26
29
|
scheduleSave();
|
|
27
30
|
};
|
|
28
31
|
const setMeta = (meta) => {
|
|
32
|
+
if (hasCompleted)
|
|
33
|
+
return;
|
|
29
34
|
data.meta = {
|
|
30
35
|
...data.meta,
|
|
31
36
|
...meta,
|
|
@@ -33,13 +38,16 @@ export function createRecordExtraDataController(savePath) {
|
|
|
33
38
|
scheduleSave();
|
|
34
39
|
};
|
|
35
40
|
const flush = async () => {
|
|
36
|
-
|
|
41
|
+
if (hasCompleted)
|
|
42
|
+
return;
|
|
43
|
+
hasCompleted = true;
|
|
37
44
|
scheduleSave.cancel();
|
|
38
45
|
const xmlContent = convert2Xml(data);
|
|
39
46
|
const parsedPath = path.parse(savePath);
|
|
40
47
|
const xmlPath = path.join(parsedPath.dir, parsedPath.name + ".xml");
|
|
41
48
|
await fs.promises.writeFile(xmlPath, xmlContent);
|
|
42
49
|
await fs.promises.rm(savePath);
|
|
50
|
+
data.messages = [];
|
|
43
51
|
};
|
|
44
52
|
return {
|
|
45
53
|
data,
|
|
@@ -76,6 +84,7 @@ export function convert2Xml(data) {
|
|
|
76
84
|
"@@weight": String(0),
|
|
77
85
|
"@@user": String(ele.sender?.name),
|
|
78
86
|
"@@uid": String(ele?.sender?.uid),
|
|
87
|
+
"@@timestamp": String(ele.timestamp),
|
|
79
88
|
};
|
|
80
89
|
data["@@p"] = [
|
|
81
90
|
data["@@progress"],
|
|
@@ -88,7 +97,7 @@ export function convert2Xml(data) {
|
|
|
88
97
|
data["@@uid"],
|
|
89
98
|
data["@@weight"],
|
|
90
99
|
].join(",");
|
|
91
|
-
return pick(data, ["@@p", "#text", "@@user", "@@uid"]);
|
|
100
|
+
return pick(data, ["@@p", "#text", "@@user", "@@uid", "@@timestamp"]);
|
|
92
101
|
});
|
|
93
102
|
const gifts = data.messages
|
|
94
103
|
.filter((item) => item.type === "give_gift")
|
|
@@ -101,6 +110,7 @@ export function convert2Xml(data) {
|
|
|
101
110
|
"@@price": String(ele.price * 1000),
|
|
102
111
|
"@@user": String(ele.sender?.name),
|
|
103
112
|
"@@uid": String(ele?.sender?.uid),
|
|
113
|
+
"@@timestamp": String(ele.timestamp),
|
|
104
114
|
// "@@raw": JSON.stringify(ele),
|
|
105
115
|
};
|
|
106
116
|
return data;
|
|
@@ -115,6 +125,7 @@ export function convert2Xml(data) {
|
|
|
115
125
|
"#text": String(ele.text),
|
|
116
126
|
"@@user": String(ele.sender?.name),
|
|
117
127
|
"@@uid": String(ele?.sender?.uid),
|
|
128
|
+
"@@timestamp": String(ele.timestamp),
|
|
118
129
|
// "@@raw": JSON.stringify(ele),
|
|
119
130
|
};
|
|
120
131
|
return data;
|
|
@@ -131,6 +142,7 @@ export function convert2Xml(data) {
|
|
|
131
142
|
"@@level": String(ele.level),
|
|
132
143
|
"@@user": String(ele.sender?.name),
|
|
133
144
|
"@@uid": String(ele?.sender?.uid),
|
|
145
|
+
"@@timestamp": String(ele.timestamp),
|
|
134
146
|
// "@@raw": JSON.stringify(ele),
|
|
135
147
|
};
|
|
136
148
|
return data;
|
package/lib/recorder.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
27
27
|
uid?: number;
|
|
28
28
|
/** 画质匹配重试次数 */
|
|
29
29
|
qualityRetry?: number;
|
|
30
|
+
/** 抖音是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true */
|
|
31
|
+
doubleScreen?: boolean;
|
|
30
32
|
/** B站是否使用m3u8代理 */
|
|
31
33
|
useM3U8Proxy?: boolean;
|
|
32
34
|
/**B站m3u8代理url */
|
|
@@ -43,6 +45,12 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
|
43
45
|
videoFormat?: "auto" | "ts" | "mkv";
|
|
44
46
|
/** 流格式优先级 */
|
|
45
47
|
formatriorities?: Array<"flv" | "hls">;
|
|
48
|
+
/** 只录制音频 */
|
|
49
|
+
onlyAudio?: boolean;
|
|
50
|
+
/** 监控时间段 */
|
|
51
|
+
handleTime?: [string | null, string | null];
|
|
52
|
+
/** 控制弹幕是否使用服务端时间戳 */
|
|
53
|
+
useServerTimestamp?: boolean;
|
|
46
54
|
extra?: Partial<E>;
|
|
47
55
|
}
|
|
48
56
|
export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id">;
|
|
@@ -62,7 +70,7 @@ export interface RecordHandle {
|
|
|
62
70
|
cut: (this: RecordHandle) => Promise<void>;
|
|
63
71
|
}
|
|
64
72
|
export interface DebugLog {
|
|
65
|
-
type: string | "common" | "ffmpeg";
|
|
73
|
+
type: string | "common" | "ffmpeg" | "error";
|
|
66
74
|
text: string;
|
|
67
75
|
}
|
|
68
76
|
export type GetSavePath = (data: {
|
package/lib/streamManager.js
CHANGED
|
@@ -21,21 +21,21 @@ export class Segment extends EventEmitter {
|
|
|
21
21
|
async handleSegmentEnd() {
|
|
22
22
|
if (!this.outputVideoFilePath) {
|
|
23
23
|
this.emit("DebugLog", {
|
|
24
|
-
type: "
|
|
24
|
+
type: "error",
|
|
25
25
|
text: "Should call onSegmentStart first",
|
|
26
26
|
});
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
try {
|
|
30
30
|
await Promise.all([
|
|
31
|
-
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath)),
|
|
31
|
+
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath), 10, 2000),
|
|
32
32
|
this.extraDataController?.flush(),
|
|
33
33
|
]);
|
|
34
34
|
this.emit("videoFileCompleted", { filename: this.outputFilePath });
|
|
35
35
|
}
|
|
36
36
|
catch (err) {
|
|
37
37
|
this.emit("DebugLog", {
|
|
38
|
-
type: "
|
|
38
|
+
type: "error",
|
|
39
39
|
text: "videoFileCompleted error " + String(err),
|
|
40
40
|
});
|
|
41
41
|
}
|
|
@@ -53,7 +53,7 @@ export class Segment extends EventEmitter {
|
|
|
53
53
|
}
|
|
54
54
|
catch (err) {
|
|
55
55
|
this.emit("DebugLog", {
|
|
56
|
-
type: "
|
|
56
|
+
type: "error",
|
|
57
57
|
text: "onUpdateLiveInfo error " + String(err),
|
|
58
58
|
});
|
|
59
59
|
}
|
package/lib/utils.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export declare function sortByKeyOrder<T, K extends keyof T>(objects: T[], order
|
|
|
61
61
|
* @returns Promise
|
|
62
62
|
*/
|
|
63
63
|
export declare function retry<T>(fn: () => Promise<T>, retries?: number, delay?: number): Promise<T>;
|
|
64
|
+
export declare const isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
|
|
64
65
|
declare const _default: {
|
|
65
66
|
replaceExtName: typeof replaceExtName;
|
|
66
67
|
singleton: typeof singleton;
|
|
@@ -79,5 +80,6 @@ declare const _default: {
|
|
|
79
80
|
uuid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
80
81
|
sortByKeyOrder: typeof sortByKeyOrder;
|
|
81
82
|
retry: typeof retry;
|
|
83
|
+
isBetweenTimeRange: (range: undefined | [] | [string | null, string | null]) => boolean;
|
|
82
84
|
};
|
|
83
85
|
export default _default;
|
package/lib/utils.js
CHANGED
|
@@ -159,6 +159,10 @@ export function createInvalidStreamChecker(count = 15) {
|
|
|
159
159
|
let prevFrame = 0;
|
|
160
160
|
let frameUnchangedCount = 0;
|
|
161
161
|
return (ffmpegLogLine) => {
|
|
162
|
+
// B站某些cdn在直播结束后仍会返回一些数据 https://github.com/renmu123/biliLive-tools/issues/123
|
|
163
|
+
if (ffmpegLogLine.includes("New subtitle stream with index")) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
162
166
|
const streamInfo = ffmpegLogLine.match(/frame=\s*(\d+) fps=.*? q=.*? size=.*? time=.*? bitrate=.*? speed=.*?/);
|
|
163
167
|
if (streamInfo != null) {
|
|
164
168
|
const [, frameText] = streamInfo;
|
|
@@ -257,6 +261,49 @@ export async function retry(fn, retries = 3, delay = 1000) {
|
|
|
257
261
|
return retry(fn, retries - 1, delay);
|
|
258
262
|
}
|
|
259
263
|
}
|
|
264
|
+
export const isBetweenTimeRange = (range) => {
|
|
265
|
+
if (!range)
|
|
266
|
+
return true;
|
|
267
|
+
if (range.length !== 2)
|
|
268
|
+
return true;
|
|
269
|
+
if (range[0] === null || range[1] === null)
|
|
270
|
+
return true;
|
|
271
|
+
try {
|
|
272
|
+
const status = isBetweenTime(new Date(), range);
|
|
273
|
+
return status;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* 当前时间是否在两个时间'HH:mm:ss'之间,如果是["22:00:00","05:00:00"],当前时间是凌晨3点,返回true
|
|
281
|
+
* @param {string} currentTime 当前时间
|
|
282
|
+
* @param {string[]} timeRange 时间范围
|
|
283
|
+
*/
|
|
284
|
+
function isBetweenTime(currentTime, timeRange) {
|
|
285
|
+
const [startTime, endTime] = timeRange;
|
|
286
|
+
if (!startTime || !endTime)
|
|
287
|
+
return true;
|
|
288
|
+
const [startHour, startMinute, startSecond] = startTime.split(":").map(Number);
|
|
289
|
+
const [endHour, endMinute, endSecond] = endTime.split(":").map(Number);
|
|
290
|
+
const [currentHour, currentMinute, currentSecond] = [
|
|
291
|
+
currentTime.getHours(),
|
|
292
|
+
currentTime.getMinutes(),
|
|
293
|
+
currentTime.getSeconds(),
|
|
294
|
+
];
|
|
295
|
+
const start = startHour * 3600 + startMinute * 60 + startSecond;
|
|
296
|
+
let end = endHour * 3600 + endMinute * 60 + endSecond;
|
|
297
|
+
let current = currentHour * 3600 + currentMinute * 60 + currentSecond;
|
|
298
|
+
// 如果结束时间小于开始时间,说明跨越了午夜
|
|
299
|
+
if (end < start) {
|
|
300
|
+
end += 24 * 3600; // 将结束时间加上24小时
|
|
301
|
+
if (current < start) {
|
|
302
|
+
current += 24 * 3600; // 如果当前时间小于开始时间,也加上24小时
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return start <= current && current <= end;
|
|
306
|
+
}
|
|
260
307
|
export default {
|
|
261
308
|
replaceExtName,
|
|
262
309
|
singleton,
|
|
@@ -275,4 +322,5 @@ export default {
|
|
|
275
322
|
uuid,
|
|
276
323
|
sortByKeyOrder,
|
|
277
324
|
retry,
|
|
325
|
+
isBetweenTimeRange,
|
|
278
326
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/manager",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Batch scheduling recorders",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"string-argv": "^0.3.2",
|
|
40
40
|
"lodash-es": "^4.17.21",
|
|
41
41
|
"axios": "^1.7.8",
|
|
42
|
-
"fs-extra": "^11.2.0"
|
|
42
|
+
"fs-extra": "^11.2.0",
|
|
43
|
+
"ejs": "^3.1.10"
|
|
43
44
|
},
|
|
44
45
|
"scripts": {
|
|
45
46
|
"build": "pnpm run test && tsc",
|