@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.
@@ -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);
@@ -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);
@@ -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", ({ 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,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", { filename: this.outputFilePath });
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", { filename: this.outputFilePath });
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
- await this.getExtraDataController()?.flush();
198
- 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
+ });
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
- // 检查该 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
  };
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方案,这个实现每隔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.16.0",
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
- }