@bililive-tools/manager 1.14.1 → 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 +7 -2
- package/lib/downloader/FFmpegDownloader.js +3 -3
- package/lib/downloader/IDownloader.d.ts +5 -6
- package/lib/downloader/mesioDownloader.js +2 -2
- package/lib/downloader/streamManager.d.ts +7 -0
- package/lib/downloader/streamManager.js +38 -8
- package/lib/index.d.ts +0 -1
- package/lib/index.js +0 -1
- package/lib/manager.d.ts +3 -0
- package/lib/manager.js +7 -13
- package/lib/recorder.d.ts +3 -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);
|
|
@@ -194,6 +194,11 @@ export class BililiveDownloader extends EventEmitter {
|
|
|
194
194
|
if (timeMatch) {
|
|
195
195
|
time = timeMatch[1];
|
|
196
196
|
}
|
|
197
|
+
const spaceMath = line.match(/下载进度:\s*([\d.]+\s*MB)\s*/);
|
|
198
|
+
if (spaceMath) {
|
|
199
|
+
const space = spaceMath[1];
|
|
200
|
+
time = time ? `${time} ${space}` : space;
|
|
201
|
+
}
|
|
197
202
|
return {
|
|
198
203
|
time,
|
|
199
204
|
};
|
|
@@ -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);
|
|
@@ -154,7 +154,7 @@ export class FFmpegDownloader extends EventEmitter {
|
|
|
154
154
|
let time = null;
|
|
155
155
|
const timeMatch = line.match(/time=([0-9:.]+)/);
|
|
156
156
|
if (timeMatch) {
|
|
157
|
-
time = timeMatch[1];
|
|
157
|
+
time = timeMatch[1].split(".")[0];
|
|
158
158
|
}
|
|
159
159
|
return {
|
|
160
160
|
time,
|
|
@@ -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
|
* 录制器构造函数选项的基础接口
|
|
@@ -11,6 +12,7 @@ export interface BaseRecorderOptions {
|
|
|
11
12
|
getSavePath: (data: {
|
|
12
13
|
startTime: number;
|
|
13
14
|
title?: string;
|
|
15
|
+
extraMs?: boolean;
|
|
14
16
|
}) => string;
|
|
15
17
|
segment: Segment;
|
|
16
18
|
inputOptions?: string[];
|
|
@@ -38,6 +40,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
38
40
|
readonly getSavePath: (data: {
|
|
39
41
|
startTime: number;
|
|
40
42
|
title?: string;
|
|
43
|
+
extraMs?: boolean;
|
|
41
44
|
}) => string;
|
|
42
45
|
run(): void;
|
|
43
46
|
stop(): Promise<void>;
|
|
@@ -52,9 +55,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
52
55
|
rawFilename?: string;
|
|
53
56
|
title?: string;
|
|
54
57
|
}) => void): this;
|
|
55
|
-
on(event: "videoFileCompleted", listener: (data:
|
|
56
|
-
filename: string;
|
|
57
|
-
}) => void): this;
|
|
58
|
+
on(event: "videoFileCompleted", listener: (data: VideoFileCompletedPayload) => void): this;
|
|
58
59
|
on(event: "DebugLog", listener: (data: {
|
|
59
60
|
type: string;
|
|
60
61
|
text: string;
|
|
@@ -67,9 +68,7 @@ export interface IDownloader extends EventEmitter {
|
|
|
67
68
|
rawFilename?: string;
|
|
68
69
|
title?: string;
|
|
69
70
|
}): boolean;
|
|
70
|
-
emit(event: "videoFileCompleted", data:
|
|
71
|
-
filename: string;
|
|
72
|
-
}): boolean;
|
|
71
|
+
emit(event: "videoFileCompleted", data: VideoFileCompletedPayload): boolean;
|
|
73
72
|
emit(event: "DebugLog", data: {
|
|
74
73
|
type: string;
|
|
75
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,11 +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;
|
|
9
|
+
extraMs?: boolean;
|
|
8
10
|
}) => string;
|
|
9
11
|
type RecorderType = Exclude<RecorderCreateOpts["recorderType"], undefined | "auto">;
|
|
12
|
+
export interface VideoFileCompletedPayload {
|
|
13
|
+
filename: string;
|
|
14
|
+
stats?: XmlStreamStats;
|
|
15
|
+
}
|
|
10
16
|
export declare class Segment extends EventEmitter {
|
|
11
17
|
extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
|
|
12
18
|
init: boolean;
|
|
@@ -18,6 +24,7 @@ export declare class Segment extends EventEmitter {
|
|
|
18
24
|
disableDanma: boolean;
|
|
19
25
|
videoExt: TrueVideoFormat;
|
|
20
26
|
constructor(getSavePath: GetSavePath, disableDanma: boolean, videoExt: TrueVideoFormat);
|
|
27
|
+
private getVideoFileCompletedPayload;
|
|
21
28
|
handleSegmentEnd(): Promise<void>;
|
|
22
29
|
onSegmentStart(stderrLine: string, callBack?: {
|
|
23
30
|
onUpdateLiveInfo: () => Promise<{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import fs from "fs/promises";
|
|
3
|
+
import fsSync from "fs";
|
|
3
4
|
import { createRecordExtraDataController } from "../xml_stream_controller.js";
|
|
4
5
|
import { ensureFolderExist, isFfmpegStartSegment, isMesioStartSegment, isBililiveStartSegment, isFfmpegStart, retry, cleanTerminalText, } from "../utils.js";
|
|
5
6
|
export class Segment extends EventEmitter {
|
|
@@ -18,6 +19,12 @@ export class Segment extends EventEmitter {
|
|
|
18
19
|
this.disableDanma = disableDanma;
|
|
19
20
|
this.videoExt = videoExt;
|
|
20
21
|
}
|
|
22
|
+
getVideoFileCompletedPayload() {
|
|
23
|
+
return {
|
|
24
|
+
filename: this.outputFilePath,
|
|
25
|
+
stats: this.extraDataController?.getStats(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
21
28
|
async handleSegmentEnd() {
|
|
22
29
|
if (!this.outputVideoFilePath) {
|
|
23
30
|
this.emit("DebugLog", {
|
|
@@ -26,6 +33,7 @@ export class Segment extends EventEmitter {
|
|
|
26
33
|
});
|
|
27
34
|
return;
|
|
28
35
|
}
|
|
36
|
+
const data = this.getVideoFileCompletedPayload();
|
|
29
37
|
try {
|
|
30
38
|
this.emit("DebugLog", {
|
|
31
39
|
type: "info",
|
|
@@ -35,7 +43,7 @@ export class Segment extends EventEmitter {
|
|
|
35
43
|
retry(() => fs.rename(this.rawRecordingVideoPath, this.outputFilePath), 20, 1000),
|
|
36
44
|
this.extraDataController?.flush(),
|
|
37
45
|
]);
|
|
38
|
-
this.emit("videoFileCompleted",
|
|
46
|
+
this.emit("videoFileCompleted", data);
|
|
39
47
|
}
|
|
40
48
|
catch (err) {
|
|
41
49
|
this.emit("DebugLog", {
|
|
@@ -43,7 +51,7 @@ export class Segment extends EventEmitter {
|
|
|
43
51
|
text: "videoFileCompleted error " + String(err),
|
|
44
52
|
});
|
|
45
53
|
// 虽然重命名失败了,但是也当作完成处理,避免卡住录制流程
|
|
46
|
-
this.emit("videoFileCompleted",
|
|
54
|
+
this.emit("videoFileCompleted", data);
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
async onSegmentStart(stderrLine, callBack) {
|
|
@@ -64,10 +72,19 @@ export class Segment extends EventEmitter {
|
|
|
64
72
|
});
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
|
-
|
|
75
|
+
let recordSavePath = this.getSavePath({
|
|
68
76
|
startTime: startTime,
|
|
69
|
-
title: liveInfo?.title,
|
|
77
|
+
title: liveInfo?.title ? liveInfo.title : undefined,
|
|
70
78
|
});
|
|
79
|
+
// 文件重复判断
|
|
80
|
+
if (fsSync.existsSync(recordSavePath + "." + this.videoExt)) {
|
|
81
|
+
recordSavePath = this.getSavePath({
|
|
82
|
+
startTime: startTime,
|
|
83
|
+
title: liveInfo?.title,
|
|
84
|
+
extraMs: true,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
this.outputVideoFilePath = recordSavePath;
|
|
71
88
|
ensureFolderExist(this.outputVideoFilePath);
|
|
72
89
|
if (!this.disableDanma) {
|
|
73
90
|
this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
|
|
@@ -112,12 +129,19 @@ export class StreamManager extends EventEmitter {
|
|
|
112
129
|
callBack;
|
|
113
130
|
constructor(getSavePath, hasSegment, disableDanma, recorderType, videoFormat, callBack) {
|
|
114
131
|
super();
|
|
115
|
-
const
|
|
116
|
-
|
|
132
|
+
const startTime = Date.now();
|
|
133
|
+
let recordSavePath = getSavePath({ startTime });
|
|
117
134
|
this.videoFormat = videoFormat;
|
|
118
135
|
this.recorderType = recorderType;
|
|
119
136
|
this.hasSegment = hasSegment;
|
|
120
137
|
this.callBack = callBack;
|
|
138
|
+
console.log("Initial recordSavePath:", recordSavePath);
|
|
139
|
+
// 文件重复判断
|
|
140
|
+
if (fsSync.existsSync(recordSavePath + "." + videoFormat)) {
|
|
141
|
+
console.log("File already exists, generating new save path with extraMs");
|
|
142
|
+
recordSavePath = getSavePath({ startTime, extraMs: true });
|
|
143
|
+
}
|
|
144
|
+
this.recordSavePath = recordSavePath;
|
|
121
145
|
if (hasSegment) {
|
|
122
146
|
this.segment = new Segment(getSavePath, disableDanma, this.videoExt);
|
|
123
147
|
this.segment.on("DebugLog", (data) => {
|
|
@@ -131,6 +155,7 @@ export class StreamManager extends EventEmitter {
|
|
|
131
155
|
});
|
|
132
156
|
}
|
|
133
157
|
else {
|
|
158
|
+
ensureFolderExist(recordSavePath);
|
|
134
159
|
const extraDataSavePath = `${recordSavePath}.xml`;
|
|
135
160
|
if (!disableDanma) {
|
|
136
161
|
this.extraDataController = createRecordExtraDataController(extraDataSavePath);
|
|
@@ -176,8 +201,13 @@ export class StreamManager extends EventEmitter {
|
|
|
176
201
|
}
|
|
177
202
|
else {
|
|
178
203
|
if (this.recordStartTime) {
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
});
|
|
181
211
|
}
|
|
182
212
|
}
|
|
183
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>;
|
|
@@ -125,6 +127,7 @@ export declare function genSavePathFromRule<ME extends AnyObject, P extends Reco
|
|
|
125
127
|
startTime: number;
|
|
126
128
|
liveStartTime: Date;
|
|
127
129
|
recordStartTime: Date;
|
|
130
|
+
extraMs?: boolean;
|
|
128
131
|
}): string;
|
|
129
132
|
export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
|
|
130
133
|
export { StreamManager };
|
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
|
};
|
|
@@ -381,6 +372,9 @@ export function genSavePathFromRule(manager, recorder, extData) {
|
|
|
381
372
|
channelId,
|
|
382
373
|
};
|
|
383
374
|
let savePathRule = manager.savePathRule;
|
|
375
|
+
if (extData?.extraMs) {
|
|
376
|
+
savePathRule += "_{ms}";
|
|
377
|
+
}
|
|
384
378
|
try {
|
|
385
379
|
savePathRule = ejs.render(savePathRule, params);
|
|
386
380
|
}
|
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> {
|
|
@@ -93,6 +94,7 @@ export type GetSavePath = (data: {
|
|
|
93
94
|
startTime: number;
|
|
94
95
|
liveStartTime: Date;
|
|
95
96
|
recordStartTime: Date;
|
|
97
|
+
extraMs?: boolean;
|
|
96
98
|
}) => string;
|
|
97
99
|
export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
98
100
|
RecordStart: RecordHandle;
|
|
@@ -104,6 +106,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
|
|
|
104
106
|
};
|
|
105
107
|
videoFileCompleted: {
|
|
106
108
|
filename: string;
|
|
109
|
+
stats?: XmlStreamStats;
|
|
107
110
|
};
|
|
108
111
|
progress: Progress;
|
|
109
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
|
-
}
|