@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.
@@ -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", ({ filename }) => {
144
- this.emit("videoFileCompleted", { filename });
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", ({ filename }) => {
71
- this.emit("videoFileCompleted", { filename });
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", ({ filename }) => {
141
- this.emit("videoFileCompleted", { filename });
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", { filename: this.outputFilePath });
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", { filename: this.outputFilePath });
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
- this.outputVideoFilePath = this.getSavePath({
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 recordSavePath = getSavePath({ startTime: Date.now() });
116
- this.recordSavePath = recordSavePath;
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
- await this.getExtraDataController()?.flush();
180
- this.emit("videoFileCompleted", { filename: this.videoFilePath });
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
- // 检查该 provider 是否还有 recorder
283
- const hasRecorders = this.recorders.some((r) => r.providerId === providerId);
284
- if (hasRecorders) {
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方案,这个实现每隔5秒就会写入数据,减少内存占用和数据丢失风险
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
- import { asyncThrottle } from "./utils.js";
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=\`&lt;a target=_blank rel="nofollow noreferrer" "&gt;\u0024{p[6]}&lt;/a&gt;\`})</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 isWriting = false;
18
- let isInitialized = false;
19
- // 初始化文件
20
- const initializeFile = async () => {
21
- if (isInitialized)
22
- return;
23
- isInitialized = true;
24
- try {
25
- // 创建XML文件头,使用占位符预留metadata位置
26
- const 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=\`&lt;a target=_blank rel="nofollow noreferrer" "&gt;\u0024{p[6]}&lt;/a&gt;\`})</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>`;
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 writeToFile = async () => {
40
- if (isWriting || hasCompleted || data.pendingMessages.length === 0) {
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
- await initializeFile();
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
- await appendToXmlFile(savePath, xmlContent);
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
- // if (!isInitialized) return;
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
- scheduleWrite.cancel();
89
- await initializeFile().catch(console.error);
90
- // 写入剩余的数据
91
- if (data.pendingMessages.length > 0) {
92
- await writeToFile();
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 data = {
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
- data["@@p"] = [
135
- data["@@progress"],
136
- data["@@mode"],
137
- data["@@fontsize"],
138
- data["@@color"],
139
- data["@@ctime"],
140
- data["@@pool"],
141
- data["@@midHash"],
142
- data["@@uid"],
143
- data["@@weight"],
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(data, ["@@p", "#text", "@@user", "@@uid", "@@timestamp"]);
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
- * 更新XML文件中的metadata
213
- */
214
- async function updateMetadataInFile(filePath, metadata) {
215
- try {
216
- const builder = new XMLBuilder({
217
- ignoreAttributes: false,
218
- attributeNamePrefix: "@@",
219
- format: true,
220
- });
221
- // 生成metadata XML
222
- const metadataXml = builder.build({
223
- metadata: {
224
- platform: metadata.platform,
225
- video_start_time: metadata.recordStartTimestamp,
226
- live_start_time: metadata.liveStartTimestamp,
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.14.1",
3
+ "version": "1.16.1",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -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
- }