@bililive-tools/manager 1.16.0 → 1.16.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/lib/downloader/BililiveDownloader.js +2 -2
- package/lib/downloader/FFmpegDownloader.js +2 -2
- package/lib/downloader/IDownloader.d.ts +3 -6
- package/lib/downloader/mesioDownloader.js +2 -2
- package/lib/downloader/streamManager.d.ts +6 -0
- package/lib/downloader/streamManager.js +16 -4
- package/lib/index.d.ts +0 -1
- package/lib/index.js +0 -1
- package/lib/manager.d.ts +2 -0
- package/lib/manager.js +4 -13
- package/lib/recorder.d.ts +2 -0
- package/lib/xml_stream_controller.d.ts +8 -0
- package/lib/xml_stream_controller.js +92 -116
- package/package.json +1 -1
- package/lib/record_extra_data_controller.d.ts +0 -28
- package/lib/record_extra_data_controller.js +0 -168
|
@@ -140,8 +140,8 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
140
140
|
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
|
|
141
141
|
this.emit("videoFileCreated", { filename, cover, rawFilename, title });
|
|
142
142
|
});
|
|
143
|
-
this.streamManager.on("videoFileCompleted", (
|
|
144
|
-
this.emit("videoFileCompleted",
|
|
143
|
+
this.streamManager.on("videoFileCompleted", (data) => {
|
|
144
|
+
this.emit("videoFileCompleted", data);
|
|
145
145
|
});
|
|
146
146
|
this.streamManager.on("DebugLog", (data) => {
|
|
147
147
|
this.emit("DebugLog", data);
|
|
@@ -67,8 +67,8 @@ export class FFmpegDownloader extends EventEmitter {
|
|
|
67
67
|
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
|
|
68
68
|
this.emit("videoFileCreated", { filename, cover, rawFilename, title });
|
|
69
69
|
});
|
|
70
|
-
this.streamManager.on("videoFileCompleted", (
|
|
71
|
-
this.emit("videoFileCompleted",
|
|
70
|
+
this.streamManager.on("videoFileCompleted", (data) => {
|
|
71
|
+
this.emit("videoFileCompleted", data);
|
|
72
72
|
});
|
|
73
73
|
this.streamManager.on("DebugLog", (data) => {
|
|
74
74
|
this.emit("DebugLog", data);
|
|
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import type { VideoFormat } from "../index.js";
|
|
3
3
|
import type { FormatName } from "./index.js";
|
|
4
4
|
import type { XmlStreamController } from "../xml_stream_controller.js";
|
|
5
|
+
import type { VideoFileCompletedPayload } from "./streamManager.js";
|
|
5
6
|
export type Segment = number | string | undefined;
|
|
6
7
|
/**
|
|
7
8
|
* 录制器构造函数选项的基础接口
|
|
@@ -54,9 +55,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
54
55
|
rawFilename?: string;
|
|
55
56
|
title?: string;
|
|
56
57
|
}) => void): this;
|
|
57
|
-
on(event: "videoFileCompleted", listener: (data:
|
|
58
|
-
filename: string;
|
|
59
|
-
}) => void): this;
|
|
58
|
+
on(event: "videoFileCompleted", listener: (data: VideoFileCompletedPayload) => void): this;
|
|
60
59
|
on(event: "DebugLog", listener: (data: {
|
|
61
60
|
type: string;
|
|
62
61
|
text: string;
|
|
@@ -69,9 +68,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
69
68
|
rawFilename?: string;
|
|
70
69
|
title?: string;
|
|
71
70
|
}): boolean;
|
|
72
|
-
emit(event: "videoFileCompleted", data:
|
|
73
|
-
filename: string;
|
|
74
|
-
}): boolean;
|
|
71
|
+
emit(event: "videoFileCompleted", data: VideoFileCompletedPayload): boolean;
|
|
75
72
|
emit(event: "DebugLog", data: {
|
|
76
73
|
type: string;
|
|
77
74
|
text: string;
|
|
@@ -137,8 +137,8 @@ export class mesioDownloader extends EventEmitter {
|
|
|
137
137
|
this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
|
|
138
138
|
this.emit("videoFileCreated", { filename, cover, rawFilename, title });
|
|
139
139
|
});
|
|
140
|
-
this.streamManager.on("videoFileCompleted", (
|
|
141
|
-
this.emit("videoFileCompleted",
|
|
140
|
+
this.streamManager.on("videoFileCompleted", (data) => {
|
|
141
|
+
this.emit("videoFileCompleted", data);
|
|
142
142
|
});
|
|
143
143
|
this.streamManager.on("DebugLog", (data) => {
|
|
144
144
|
this.emit("DebugLog", data);
|
|
@@ -2,12 +2,17 @@ import EventEmitter from "node:events";
|
|
|
2
2
|
import { createRecordExtraDataController } from "../xml_stream_controller.js";
|
|
3
3
|
import type { RecorderCreateOpts } from "../recorder.js";
|
|
4
4
|
import type { TrueVideoFormat } from "../index.js";
|
|
5
|
+
import type { XmlStreamStats } from "../xml_stream_controller.js";
|
|
5
6
|
export type GetSavePath = (data: {
|
|
6
7
|
startTime: number;
|
|
7
8
|
title?: string;
|
|
8
9
|
extraMs?: boolean;
|
|
9
10
|
}) => string;
|
|
10
11
|
type RecorderType = Exclude<RecorderCreateOpts["recorderType"], undefined | "auto">;
|
|
12
|
+
export interface VideoFileCompletedPayload {
|
|
13
|
+
filename: string;
|
|
14
|
+
stats?: XmlStreamStats;
|
|
15
|
+
}
|
|
11
16
|
export declare class Segment extends EventEmitter {
|
|
12
17
|
extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
|
|
13
18
|
init: boolean;
|
|
@@ -19,6 +24,7 @@ export declare class Segment extends EventEmitter {
|
|
|
19
24
|
disableDanma: boolean;
|
|
20
25
|
videoExt: TrueVideoFormat;
|
|
21
26
|
constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: TrueVideoFormat);
|
|
27
|
+
private getVideoFileCompletedPayload;
|
|
22
28
|
handleSegmentEnd(): Promise<void>;
|
|
23
29
|
onSegmentStart(stderrLine: string, callBack?: {
|
|
24
30
|
onUpdateLiveInfo: () => Promise<{
|
|
@@ -19,6 +19,12 @@ export class Segment extends EventEmitter {
|
|
|
19
19
|
this.disableDanma = disableDanma;
|
|
20
20
|
this.videoExt = videoExt;
|
|
21
21
|
}
|
|
22
|
+
getVideoFileCompletedPayload() {
|
|
23
|
+
return {
|
|
24
|
+
filename: this.outputFilePath,
|
|
25
|
+
stats: this.extraDataController?.getStats(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
22
28
|
async handleSegmentEnd() {
|
|
23
29
|
if (!this.outputVideoFilePath) {
|
|
24
30
|
this.emit("DebugLog", {
|
|
@@ -27,6 +33,7 @@ export class Segment extends EventEmitter {
|
|
|
27
33
|
});
|
|
28
34
|
return;
|
|
29
35
|
}
|
|
36
|
+
const data = this.getVideoFileCompletedPayload();
|
|
30
37
|
try {
|
|
31
38
|
this.emit("DebugLog", {
|
|
32
39
|
type: "info",
|
|
@@ -36,7 +43,7 @@ export class Segment extends EventEmitter {
|
|
|
36
43
|
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath), 20, 1000),
|
|
37
44
|
this.extraDataController?.flush(),
|
|
38
45
|
]);
|
|
39
|
-
this.emit("videoFileCompleted",
|
|
46
|
+
this.emit("videoFileCompleted", data);
|
|
40
47
|
}
|
|
41
48
|
catch (err) {
|
|
42
49
|
this.emit("DebugLog", {
|
|
@@ -44,7 +51,7 @@ export class Segment extends EventEmitter {
|
|
|
44
51
|
text: "videoFileCompleted error " + String(err),
|
|
45
52
|
});
|
|
46
53
|
// 虽然重命名失败了,但是也当作完成处理,避免卡住录制流程
|
|
47
|
-
this.emit("videoFileCompleted",
|
|
54
|
+
this.emit("videoFileCompleted", data);
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
57
|
async onSegmentStart(stderrLine, callBack) {
|
|
@@ -194,8 +201,13 @@ export class StreamManager extends EventEmitter {
|
|
|
194
201
|
}
|
|
195
202
|
else {
|
|
196
203
|
if (this.recordStartTime) {
|
|
197
|
-
|
|
198
|
-
|
|
204
|
+
const stats = this.extraDataController?.getStats();
|
|
205
|
+
const extraDataController = this.getExtraDataController();
|
|
206
|
+
await extraDataController?.flush();
|
|
207
|
+
this.emit("videoFileCompleted", {
|
|
208
|
+
filename: this.videoFilePath,
|
|
209
|
+
stats: stats,
|
|
210
|
+
});
|
|
199
211
|
}
|
|
200
212
|
}
|
|
201
213
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -7,7 +7,6 @@ export * from "./common.js";
|
|
|
7
7
|
export * from "./recorder.js";
|
|
8
8
|
export * from "./manager.js";
|
|
9
9
|
export * from "./cache.js";
|
|
10
|
-
export * from "./record_extra_data_controller.js";
|
|
11
10
|
export * from "./downloader/FFmpegDownloader.js";
|
|
12
11
|
export { createDownloader } from "./downloader/index.js";
|
|
13
12
|
export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
|
package/lib/index.js
CHANGED
|
@@ -6,7 +6,6 @@ export * from "./common.js";
|
|
|
6
6
|
export * from "./recorder.js";
|
|
7
7
|
export * from "./manager.js";
|
|
8
8
|
export * from "./cache.js";
|
|
9
|
-
export * from "./record_extra_data_controller.js";
|
|
10
9
|
export * from "./downloader/FFmpegDownloader.js";
|
|
11
10
|
export { createDownloader } from "./downloader/index.js";
|
|
12
11
|
export { checkTitleKeywordsWhileRecording, checkTitleKeywordsBeforeRecord } from "./utils.js";
|
package/lib/manager.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Emitter } from "mitt";
|
|
|
2
2
|
import { ChannelId, Message } from "./common.js";
|
|
3
3
|
import { RecorderCache } from "./cache.js";
|
|
4
4
|
import { RecorderCreateOpts, Recorder, SerializedRecorder, RecordHandle, DebugLog, Progress } from "./recorder.js";
|
|
5
|
+
import type { XmlStreamStats } from "./xml_stream_controller.js";
|
|
5
6
|
import { AnyObject, UnknownObject } from "./utils.js";
|
|
6
7
|
import { StreamManager } from "./downloader/streamManager.js";
|
|
7
8
|
export interface ProviderCheckConfig {
|
|
@@ -52,6 +53,7 @@ export interface RecorderManager<ME extends UnknownObject, P extends RecorderPro
|
|
|
52
53
|
videoFileCompleted: {
|
|
53
54
|
recorder: SerializedRecorder<E>;
|
|
54
55
|
filename: string;
|
|
56
|
+
stats?: XmlStreamStats;
|
|
55
57
|
};
|
|
56
58
|
RecorderProgress: {
|
|
57
59
|
recorder: SerializedRecorder<E>;
|
package/lib/manager.js
CHANGED
|
@@ -134,7 +134,7 @@ export function createRecorderManager(opts) {
|
|
|
134
134
|
}
|
|
135
135
|
this.emit("videoFileCreated", { recorder: recorder.toJSON(), filename, rawFilename });
|
|
136
136
|
});
|
|
137
|
-
recorder.on("videoFileCompleted", ({ filename }) => this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename }));
|
|
137
|
+
recorder.on("videoFileCompleted", ({ filename, stats }) => this.emit("videoFileCompleted", { recorder: recorder.toJSON(), filename, stats }));
|
|
138
138
|
recorder.on("Message", (message) => this.emit("Message", { recorder: recorder.toJSON(), message }));
|
|
139
139
|
recorder.on("Updated", (keys) => this.emit("RecorderUpdated", { recorder: recorder.toJSON(), keys }));
|
|
140
140
|
recorder.on("DebugLog", (log) => this.emit("RecorderDebugLog", { recorder: recorder, ...log }));
|
|
@@ -279,18 +279,9 @@ export function createRecorderManager(opts) {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
else {
|
|
282
|
-
//
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
// 继续循环
|
|
286
|
-
const timer = setTimeout(checkLoop, providerConfig.autoCheckInterval);
|
|
287
|
-
checkLoopTimers.set(providerId, timer);
|
|
288
|
-
}
|
|
289
|
-
else {
|
|
290
|
-
// 没有 recorder 了,停止该 provider 的检查循环
|
|
291
|
-
// TODO: 也许不需要删除定时器
|
|
292
|
-
checkLoopTimers.delete(providerId);
|
|
293
|
-
}
|
|
282
|
+
// 即使当前 provider 暂时没有 recorder,也保留轮询,避免后续新增 recorder 时漏掉自动检查。
|
|
283
|
+
const timer = setTimeout(checkLoop, providerConfig.autoCheckInterval);
|
|
284
|
+
checkLoopTimers.set(providerId, timer);
|
|
294
285
|
}
|
|
295
286
|
}
|
|
296
287
|
};
|
package/lib/recorder.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { RecorderProvider } from "./manager.js";
|
|
|
4
4
|
import { AnyObject, PickRequired, UnknownObject } from "./utils.js";
|
|
5
5
|
import type { NamespacedCache } from "./cache.js";
|
|
6
6
|
import type { DownloaderType } from "./downloader/index.js";
|
|
7
|
+
import type { XmlStreamStats } from "./xml_stream_controller.js";
|
|
7
8
|
type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
|
|
8
9
|
type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
|
|
9
10
|
export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
|
|
@@ -105,6 +106,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
105
106
|
};
|
|
106
107
|
videoFileCompleted: {
|
|
107
108
|
filename: string;
|
|
109
|
+
stats?: XmlStreamStats;
|
|
108
110
|
};
|
|
109
111
|
progress: Progress;
|
|
110
112
|
RecordStop: {
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Message } from "./common.js";
|
|
2
|
+
export interface XmlStreamStats {
|
|
3
|
+
danmaNum: number;
|
|
4
|
+
uniqMember: number;
|
|
5
|
+
scNum: number;
|
|
6
|
+
guardNum: number;
|
|
7
|
+
}
|
|
2
8
|
export interface XmlStreamData {
|
|
9
|
+
header: string;
|
|
3
10
|
meta: {
|
|
4
11
|
title?: string;
|
|
5
12
|
recordStartTimestamp: number;
|
|
@@ -19,5 +26,6 @@ export interface XmlStreamController {
|
|
|
19
26
|
addMessage: (message: Message) => void;
|
|
20
27
|
setMeta: (meta: Partial<XmlStreamData["meta"]>) => Promise<void>;
|
|
21
28
|
flush: () => Promise<void>;
|
|
29
|
+
getStats: () => XmlStreamStats;
|
|
22
30
|
}
|
|
23
31
|
export declare function createRecordExtraDataController(savePath: string): XmlStreamController;
|
|
@@ -1,74 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* XML流式写入控制器,用于实时写入弹幕、礼物等信息到XML文件
|
|
3
|
-
* 相比原有的json方案,这个实现每隔
|
|
3
|
+
* 相比原有的json方案,这个实现每隔10秒就会写入数据,减少内存占用和数据丢失风险
|
|
4
4
|
*/
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import { XMLBuilder } from "fast-xml-parser";
|
|
7
7
|
import { pick } from "lodash-es";
|
|
8
|
-
|
|
8
|
+
const METADATA_PLACEHOLDER = "<!--METADATA_PLACEHOLDER-->";
|
|
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`;
|
|
9
10
|
export function createRecordExtraDataController(savePath) {
|
|
10
11
|
const data = {
|
|
12
|
+
header: XML_FILE_HEADER,
|
|
11
13
|
meta: {
|
|
12
14
|
recordStartTimestamp: Date.now(),
|
|
13
15
|
},
|
|
14
16
|
pendingMessages: [],
|
|
15
17
|
};
|
|
16
18
|
let hasCompleted = false;
|
|
17
|
-
let
|
|
18
|
-
let
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
await fs.promises.writeFile(savePath, header);
|
|
28
|
-
}
|
|
29
|
-
catch (error) {
|
|
30
|
-
console.error("初始化XML文件失败:", error);
|
|
31
|
-
isInitialized = false;
|
|
32
|
-
throw error;
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
// 每10秒写入一次数据
|
|
36
|
-
const scheduleWrite = asyncThrottle(() => writeToFile(), 10e3, {
|
|
37
|
-
immediateRunWhenEndOfDefer: true,
|
|
19
|
+
let hasPersistedHeader = false;
|
|
20
|
+
let danmaNum = 0;
|
|
21
|
+
let scNum = 0;
|
|
22
|
+
let guardNum = 0;
|
|
23
|
+
const interactedUsers = new Set();
|
|
24
|
+
const getStats = () => ({
|
|
25
|
+
danmaNum,
|
|
26
|
+
uniqMember: interactedUsers.size,
|
|
27
|
+
scNum,
|
|
28
|
+
guardNum,
|
|
38
29
|
});
|
|
39
|
-
const
|
|
40
|
-
|
|
30
|
+
const trackInteractedUser = (message) => {
|
|
31
|
+
const userName = message.sender?.name?.trim();
|
|
32
|
+
if (!userName)
|
|
41
33
|
return;
|
|
34
|
+
interactedUsers.add(userName);
|
|
35
|
+
};
|
|
36
|
+
const initializeFile = async (content) => {
|
|
37
|
+
// 这里有个假设,那就是第一次保存必然存在metatdata信息
|
|
38
|
+
const initialContent = data.header.replace(METADATA_PLACEHOLDER, generateMetadataXml(data.meta));
|
|
39
|
+
await fs.promises.writeFile(savePath, initialContent + content);
|
|
40
|
+
hasPersistedHeader = true;
|
|
41
|
+
};
|
|
42
|
+
const writeToFile = async (force = false) => {
|
|
43
|
+
if (!force && data.pendingMessages.length === 0) {
|
|
44
|
+
return Promise.resolve();
|
|
42
45
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
isWriting = true;
|
|
46
|
+
const messagesToWrite = [...data.pendingMessages];
|
|
47
|
+
data.pendingMessages = [];
|
|
46
48
|
try {
|
|
47
|
-
// 获取待写入的消息
|
|
48
|
-
const messagesToWrite = [...data.pendingMessages];
|
|
49
|
-
data.pendingMessages = [];
|
|
50
|
-
// 生成XML内容
|
|
51
49
|
const xmlContent = generateXmlContent(data.meta, messagesToWrite);
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
if (!hasPersistedHeader) {
|
|
51
|
+
await initializeFile(xmlContent);
|
|
52
|
+
}
|
|
53
|
+
else if (xmlContent) {
|
|
54
|
+
await appendToXmlFile(savePath, xmlContent);
|
|
55
|
+
}
|
|
54
56
|
}
|
|
55
57
|
catch (error) {
|
|
56
58
|
console.error("写入XML文件失败:", error);
|
|
57
|
-
|
|
58
|
-
data.pendingMessages = [...data.pendingMessages];
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
isWriting = false;
|
|
59
|
+
data.pendingMessages = [...messagesToWrite, ...data.pendingMessages];
|
|
62
60
|
}
|
|
63
61
|
};
|
|
62
|
+
// 每10秒写入一次数据
|
|
63
|
+
const writeTimer = setInterval(() => {
|
|
64
|
+
writeToFile();
|
|
65
|
+
}, 10e3);
|
|
64
66
|
const addMessage = (message) => {
|
|
65
67
|
if (hasCompleted)
|
|
66
68
|
return;
|
|
67
|
-
|
|
69
|
+
if (message.type === "comment") {
|
|
70
|
+
danmaNum += 1;
|
|
71
|
+
}
|
|
72
|
+
else if (message.type === "super_chat") {
|
|
73
|
+
scNum += 1;
|
|
74
|
+
}
|
|
75
|
+
else if (message.type === "guard") {
|
|
76
|
+
guardNum += 1;
|
|
77
|
+
}
|
|
78
|
+
trackInteractedUser(message);
|
|
68
79
|
data.pendingMessages.push(message);
|
|
69
|
-
// 确保文件已初始化
|
|
70
|
-
initializeFile().catch(console.error);
|
|
71
|
-
scheduleWrite();
|
|
72
80
|
};
|
|
73
81
|
const setMeta = async (meta) => {
|
|
74
82
|
if (hasCompleted)
|
|
@@ -77,30 +85,31 @@ export function createRecordExtraDataController(savePath) {
|
|
|
77
85
|
...data.meta,
|
|
78
86
|
...meta,
|
|
79
87
|
};
|
|
80
|
-
// 确保文件已初始化,然后立即更新文件中的metadata
|
|
81
|
-
await initializeFile().catch(console.error);
|
|
82
|
-
await updateMetadataInFile(savePath, data.meta).catch(console.error);
|
|
83
88
|
};
|
|
84
89
|
const flush = async () => {
|
|
85
90
|
if (hasCompleted)
|
|
86
91
|
return;
|
|
87
92
|
hasCompleted = true;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
writeTimer && clearInterval(writeTimer);
|
|
94
|
+
try {
|
|
95
|
+
await writeToFile(true);
|
|
96
|
+
await appendToXmlFile(savePath, "</i>");
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
console.error("完成XML文件写入失败:", error);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
// 清理内存
|
|
103
|
+
data.pendingMessages = [];
|
|
104
|
+
interactedUsers.clear();
|
|
93
105
|
}
|
|
94
|
-
// 完成XML文件(添加结束标签等)
|
|
95
|
-
await finalizeXmlFile(savePath);
|
|
96
|
-
// 清理内存
|
|
97
|
-
data.pendingMessages = [];
|
|
98
106
|
};
|
|
99
107
|
return {
|
|
100
108
|
data,
|
|
101
109
|
addMessage,
|
|
102
110
|
setMeta,
|
|
103
111
|
flush,
|
|
112
|
+
getStats,
|
|
104
113
|
};
|
|
105
114
|
}
|
|
106
115
|
/**
|
|
@@ -116,7 +125,7 @@ function generateXmlContent(metadata, messages) {
|
|
|
116
125
|
.filter((item) => item.type === "comment")
|
|
117
126
|
.map((ele) => {
|
|
118
127
|
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
119
|
-
const
|
|
128
|
+
const attrs = {
|
|
120
129
|
"@@p": "",
|
|
121
130
|
"@@progress": progress,
|
|
122
131
|
"@@mode": String(ele.mode ?? 1),
|
|
@@ -131,18 +140,18 @@ function generateXmlContent(metadata, messages) {
|
|
|
131
140
|
"@@uid": String(ele?.sender?.uid),
|
|
132
141
|
"@@timestamp": String(ele.timestamp),
|
|
133
142
|
};
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
attrs["@@p"] = [
|
|
144
|
+
attrs["@@progress"],
|
|
145
|
+
attrs["@@mode"],
|
|
146
|
+
attrs["@@fontsize"],
|
|
147
|
+
attrs["@@color"],
|
|
148
|
+
attrs["@@ctime"],
|
|
149
|
+
attrs["@@pool"],
|
|
150
|
+
attrs["@@midHash"],
|
|
151
|
+
attrs["@@uid"],
|
|
152
|
+
attrs["@@weight"],
|
|
144
153
|
].join(",");
|
|
145
|
-
return pick(
|
|
154
|
+
return pick(attrs, ["@@p", "#text", "@@user", "@@uid", "@@timestamp"]);
|
|
146
155
|
});
|
|
147
156
|
const gifts = messages
|
|
148
157
|
.filter((item) => item.type === "give_gift")
|
|
@@ -208,53 +217,20 @@ async function appendToXmlFile(filePath, content) {
|
|
|
208
217
|
throw error;
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
room_title: metadata.title,
|
|
228
|
-
user_name: metadata.user_name,
|
|
229
|
-
room_id: metadata.room_id,
|
|
230
|
-
},
|
|
231
|
-
});
|
|
232
|
-
// 读取文件内容
|
|
233
|
-
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
234
|
-
// 替换占位符为实际的metadata
|
|
235
|
-
const updatedContent = content.replace("<!--METADATA_PLACEHOLDER-->", metadataXml);
|
|
236
|
-
// 写回文件
|
|
237
|
-
await fs.promises.writeFile(filePath, updatedContent);
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
console.error(`更新XML文件metadata失败: ${filePath}`, error);
|
|
241
|
-
throw error;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* 完成XML文件写入
|
|
246
|
-
*/
|
|
247
|
-
async function finalizeXmlFile(filePath) {
|
|
248
|
-
try {
|
|
249
|
-
// 读取文件内容
|
|
250
|
-
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
251
|
-
// 添加结束标签
|
|
252
|
-
const finalContent = content + "</i>";
|
|
253
|
-
// 写回文件
|
|
254
|
-
await fs.promises.writeFile(filePath, finalContent);
|
|
255
|
-
}
|
|
256
|
-
catch (error) {
|
|
257
|
-
console.error(`完成XML文件写入失败: ${filePath}`, error);
|
|
258
|
-
throw error;
|
|
259
|
-
}
|
|
220
|
+
function generateMetadataXml(metadata) {
|
|
221
|
+
const builder = new XMLBuilder({
|
|
222
|
+
ignoreAttributes: false,
|
|
223
|
+
attributeNamePrefix: "@@",
|
|
224
|
+
format: true,
|
|
225
|
+
});
|
|
226
|
+
return builder.build({
|
|
227
|
+
metadata: {
|
|
228
|
+
platform: metadata.platform,
|
|
229
|
+
video_start_time: metadata.recordStartTimestamp,
|
|
230
|
+
live_start_time: metadata.liveStartTimestamp,
|
|
231
|
+
room_title: metadata.title,
|
|
232
|
+
user_name: metadata.user_name,
|
|
233
|
+
room_id: metadata.room_id,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
260
236
|
}
|
package/package.json
CHANGED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { Message } from "./common.js";
|
|
2
|
-
export interface RecordExtraData {
|
|
3
|
-
meta: {
|
|
4
|
-
title?: string;
|
|
5
|
-
recordStartTimestamp: number;
|
|
6
|
-
recordStopTimestamp?: number;
|
|
7
|
-
liveStartTimestamp?: number;
|
|
8
|
-
downloaderArgs?: string[];
|
|
9
|
-
platform?: string;
|
|
10
|
-
user_name?: string;
|
|
11
|
-
room_id?: string;
|
|
12
|
-
};
|
|
13
|
-
/** 这个数组预期上是一个根据 timestamp 排序的有序数组,方便做一些时间段查询 */
|
|
14
|
-
messages: Message[];
|
|
15
|
-
}
|
|
16
|
-
export interface RecordExtraDataController {
|
|
17
|
-
/** 设计上来说,外部程序不应该能直接修改 data 上的东西 */
|
|
18
|
-
readonly data: RecordExtraData;
|
|
19
|
-
addMessage: (message: Message) => void;
|
|
20
|
-
setMeta: (meta: Partial<RecordExtraData["meta"]>) => void;
|
|
21
|
-
flush: () => Promise<void>;
|
|
22
|
-
}
|
|
23
|
-
export declare function createRecordExtraDataController(savePath: string): RecordExtraDataController;
|
|
24
|
-
/**
|
|
25
|
-
* 转换弹幕为b站格式xml
|
|
26
|
-
* @link: https://socialsisteryi.github.io/bilibili-API-collect/docs/danmaku/danmaku_xml.html#%E5%B1%9E%E6%80%A7-p
|
|
27
|
-
*/
|
|
28
|
-
export declare function convert2Xml(data: RecordExtraData): string;
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 用一个视频之外的独立文件来存储元信息和弹幕、礼物等信息。
|
|
3
|
-
* 暂时使用 json 作为存储方案试试效果,猜测可能会有实时写入差、占用空间大的问题。
|
|
4
|
-
*/
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import fs from "node:fs";
|
|
7
|
-
import { XMLBuilder } from "fast-xml-parser";
|
|
8
|
-
import { pick } from "lodash-es";
|
|
9
|
-
import { asyncThrottle } from "./utils.js";
|
|
10
|
-
export function createRecordExtraDataController(savePath) {
|
|
11
|
-
const data = {
|
|
12
|
-
meta: {
|
|
13
|
-
recordStartTimestamp: Date.now(),
|
|
14
|
-
},
|
|
15
|
-
messages: [],
|
|
16
|
-
};
|
|
17
|
-
let hasCompleted = false;
|
|
18
|
-
const scheduleSave = asyncThrottle(() => save(), 30e3, {
|
|
19
|
-
immediateRunWhenEndOfDefer: true,
|
|
20
|
-
});
|
|
21
|
-
const save = () => {
|
|
22
|
-
return fs.promises.writeFile(savePath, JSON.stringify(data));
|
|
23
|
-
};
|
|
24
|
-
// TODO: 将所有数据存放在内存中可能存在问题
|
|
25
|
-
const addMessage = (comment) => {
|
|
26
|
-
if (hasCompleted)
|
|
27
|
-
return;
|
|
28
|
-
data.messages.push(comment);
|
|
29
|
-
scheduleSave();
|
|
30
|
-
};
|
|
31
|
-
const setMeta = (meta) => {
|
|
32
|
-
if (hasCompleted)
|
|
33
|
-
return;
|
|
34
|
-
data.meta = {
|
|
35
|
-
...data.meta,
|
|
36
|
-
...meta,
|
|
37
|
-
};
|
|
38
|
-
scheduleSave();
|
|
39
|
-
};
|
|
40
|
-
const flush = async () => {
|
|
41
|
-
if (hasCompleted)
|
|
42
|
-
return;
|
|
43
|
-
hasCompleted = true;
|
|
44
|
-
scheduleSave.cancel();
|
|
45
|
-
const xmlContent = convert2Xml(data);
|
|
46
|
-
const parsedPath = path.parse(savePath);
|
|
47
|
-
const xmlPath = path.join(parsedPath.dir, parsedPath.name + ".xml");
|
|
48
|
-
await fs.promises.writeFile(xmlPath, xmlContent);
|
|
49
|
-
await fs.promises.rm(savePath);
|
|
50
|
-
data.messages = [];
|
|
51
|
-
};
|
|
52
|
-
return {
|
|
53
|
-
data,
|
|
54
|
-
addMessage,
|
|
55
|
-
setMeta,
|
|
56
|
-
flush,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* 转换弹幕为b站格式xml
|
|
61
|
-
* @link: https://socialsisteryi.github.io/bilibili-API-collect/docs/danmaku/danmaku_xml.html#%E5%B1%9E%E6%80%A7-p
|
|
62
|
-
*/
|
|
63
|
-
export function convert2Xml(data) {
|
|
64
|
-
const metadata = data.meta;
|
|
65
|
-
const builder = new XMLBuilder({
|
|
66
|
-
ignoreAttributes: false,
|
|
67
|
-
attributeNamePrefix: "@@",
|
|
68
|
-
format: true,
|
|
69
|
-
});
|
|
70
|
-
const comments = data.messages
|
|
71
|
-
.filter((item) => item.type === "comment")
|
|
72
|
-
.map((ele) => {
|
|
73
|
-
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
74
|
-
const data = {
|
|
75
|
-
"@@p": "",
|
|
76
|
-
"@@progress": progress,
|
|
77
|
-
"@@mode": String(ele.mode ?? 1),
|
|
78
|
-
"@@fontsize": String(25),
|
|
79
|
-
"@@color": String(parseInt((ele.color || "#ffffff").replace("#", ""), 16)),
|
|
80
|
-
"@@midHash": String(ele?.sender?.uid),
|
|
81
|
-
"#text": String(ele?.text || ""),
|
|
82
|
-
"@@ctime": String(ele.timestamp),
|
|
83
|
-
"@@pool": String(0),
|
|
84
|
-
"@@weight": String(0),
|
|
85
|
-
"@@user": String(ele.sender?.name),
|
|
86
|
-
"@@uid": String(ele?.sender?.uid),
|
|
87
|
-
"@@timestamp": String(ele.timestamp),
|
|
88
|
-
};
|
|
89
|
-
data["@@p"] = [
|
|
90
|
-
data["@@progress"],
|
|
91
|
-
data["@@mode"],
|
|
92
|
-
data["@@fontsize"],
|
|
93
|
-
data["@@color"],
|
|
94
|
-
data["@@ctime"],
|
|
95
|
-
data["@@pool"],
|
|
96
|
-
data["@@midHash"],
|
|
97
|
-
data["@@uid"],
|
|
98
|
-
data["@@weight"],
|
|
99
|
-
].join(",");
|
|
100
|
-
return pick(data, ["@@p", "#text", "@@user", "@@uid", "@@timestamp"]);
|
|
101
|
-
});
|
|
102
|
-
const gifts = data.messages
|
|
103
|
-
.filter((item) => item.type === "give_gift")
|
|
104
|
-
.map((ele) => {
|
|
105
|
-
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
106
|
-
const data = {
|
|
107
|
-
"@@ts": progress,
|
|
108
|
-
"@@giftname": String(ele.name),
|
|
109
|
-
"@@giftcount": String(ele.count),
|
|
110
|
-
"@@price": String(ele.price * 1000),
|
|
111
|
-
"@@user": String(ele.sender?.name),
|
|
112
|
-
"@@uid": String(ele?.sender?.uid),
|
|
113
|
-
"@@timestamp": String(ele.timestamp),
|
|
114
|
-
// "@@raw": JSON.stringify(ele),
|
|
115
|
-
};
|
|
116
|
-
return data;
|
|
117
|
-
});
|
|
118
|
-
const superChats = data.messages
|
|
119
|
-
.filter((item) => item.type === "super_chat")
|
|
120
|
-
.map((ele) => {
|
|
121
|
-
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
122
|
-
const data = {
|
|
123
|
-
"@@ts": progress,
|
|
124
|
-
"@@price": String(ele.price * 1000),
|
|
125
|
-
"#text": String(ele.text),
|
|
126
|
-
"@@user": String(ele.sender?.name),
|
|
127
|
-
"@@uid": String(ele?.sender?.uid),
|
|
128
|
-
"@@timestamp": String(ele.timestamp),
|
|
129
|
-
// "@@raw": JSON.stringify(ele),
|
|
130
|
-
};
|
|
131
|
-
return data;
|
|
132
|
-
});
|
|
133
|
-
const guardGift = data.messages
|
|
134
|
-
.filter((item) => item.type === "guard")
|
|
135
|
-
.map((ele) => {
|
|
136
|
-
const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
|
|
137
|
-
const data = {
|
|
138
|
-
"@@ts": progress,
|
|
139
|
-
"@@price": String(ele.price * 1000),
|
|
140
|
-
"@@giftname": String(ele.name),
|
|
141
|
-
"@@giftcount": String(ele.count),
|
|
142
|
-
"@@level": String(ele.level),
|
|
143
|
-
"@@user": String(ele.sender?.name),
|
|
144
|
-
"@@uid": String(ele?.sender?.uid),
|
|
145
|
-
"@@timestamp": String(ele.timestamp),
|
|
146
|
-
// "@@raw": JSON.stringify(ele),
|
|
147
|
-
};
|
|
148
|
-
return data;
|
|
149
|
-
});
|
|
150
|
-
const xmlContent = builder.build({
|
|
151
|
-
i: {
|
|
152
|
-
metadata: {
|
|
153
|
-
platform: metadata.platform,
|
|
154
|
-
video_start_time: metadata.recordStartTimestamp,
|
|
155
|
-
live_start_time: metadata.liveStartTimestamp,
|
|
156
|
-
room_title: metadata.title,
|
|
157
|
-
user_name: metadata.user_name,
|
|
158
|
-
room_id: metadata.room_id,
|
|
159
|
-
},
|
|
160
|
-
d: comments,
|
|
161
|
-
gift: gifts,
|
|
162
|
-
sc: superChats,
|
|
163
|
-
guard: guardGift,
|
|
164
|
-
},
|
|
165
|
-
});
|
|
166
|
-
return `<?xml version="1.0" encoding="utf-8"?>
|
|
167
|
-
${xmlContent}`;
|
|
168
|
-
}
|