@bililive-tools/manager 1.6.0 → 1.6.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/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export declare function defaultToJSON<E extends AnyObject>(provider: RecorderPro
18
18
  export declare function genRecorderUUID(): Recorder["id"];
19
19
  export declare function genRecordUUID(): RecordHandle["id"];
20
20
  export declare function setFFMPEGPath(newPath: string): void;
21
- export declare const createFFMPEGBuilder: (input?: string | import("stream").Readable | undefined, options?: ffmpeg.FfmpegCommandOptions | undefined) => ffmpeg.FfmpegCommand;
21
+ export declare const createFFMPEGBuilder: (...args: Parameters<typeof ffmpeg>) => ffmpeg.FfmpegCommand;
22
22
  export declare function setMesioPath(newPath: string): void;
23
23
  export declare function getMesioPath(): string;
24
24
  export declare function getDataFolderPath<E extends AnyObject>(provider: RecorderProvider<E>): string;
package/lib/manager.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface RecorderProvider<E extends AnyObject> {
13
13
  id: ChannelId;
14
14
  title: string;
15
15
  owner: string;
16
- uid?: number;
16
+ uid?: number | string;
17
17
  avatar?: string;
18
18
  } | null>;
19
19
  createRecorder: (this: RecorderProvider<E>, opts: Omit<RecorderCreateOpts<E>, "providerId">) => Recorder<E>;
@@ -1,42 +1,28 @@
1
1
  import EventEmitter from "node:events";
2
- export declare class FFMPEGRecorder extends EventEmitter {
2
+ import { IRecorder, FFMPEGRecorderOptions } from "./IRecorder.js";
3
+ export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
3
4
  private onEnd;
4
5
  private onUpdateLiveInfo;
5
6
  private command;
6
7
  private streamManager;
7
8
  private timeoutChecker;
8
- hasSegment: boolean;
9
- getSavePath: (data: {
9
+ readonly hasSegment: boolean;
10
+ readonly getSavePath: (data: {
10
11
  startTime: number;
11
12
  title?: string;
12
13
  }) => string;
13
- segment: number;
14
+ readonly segment: number;
14
15
  ffmpegOutputOptions: string[];
15
- inputOptions: string[];
16
- isHls: boolean;
17
- disableDanma: boolean;
18
- url: string;
16
+ readonly inputOptions: string[];
17
+ readonly isHls: boolean;
18
+ readonly disableDanma: boolean;
19
+ readonly url: string;
19
20
  formatName: "flv" | "ts" | "fmp4";
20
21
  videoFormat: "ts" | "mkv" | "mp4";
21
- headers: {
22
+ readonly headers: {
22
23
  [key: string]: string | undefined;
23
24
  } | undefined;
24
- constructor(opts: {
25
- url: string;
26
- getSavePath: (data: {
27
- startTime: number;
28
- title?: string;
29
- }) => string;
30
- segment: number;
31
- outputOptions: string[];
32
- inputOptions?: string[];
33
- disableDanma?: boolean;
34
- videoFormat?: "auto" | "ts" | "mkv" | "mp4";
35
- formatName?: "flv" | "ts" | "fmp4";
36
- headers?: {
37
- [key: string]: string | undefined;
38
- };
39
- }, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
25
+ constructor(opts: FFMPEGRecorderOptions, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
40
26
  title?: string;
41
27
  cover?: string;
42
28
  }>);
@@ -47,5 +33,5 @@ export declare class FFMPEGRecorder extends EventEmitter {
47
33
  run(): void;
48
34
  getArguments(): string[];
49
35
  stop(): Promise<void>;
50
- getExtraDataController(): import("../record_extra_data_controller.js").RecordExtraDataController | null;
36
+ getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
51
37
  }
@@ -0,0 +1,81 @@
1
+ import { EventEmitter } from "node:events";
2
+ /**
3
+ * 录制器构造函数选项的基础接口
4
+ */
5
+ export interface BaseRecorderOptions {
6
+ url: string;
7
+ getSavePath: (data: {
8
+ startTime: number;
9
+ title?: string;
10
+ }) => string;
11
+ segment: number;
12
+ inputOptions?: string[];
13
+ disableDanma?: boolean;
14
+ formatName?: "flv" | "ts" | "fmp4";
15
+ headers?: {
16
+ [key: string]: string | undefined;
17
+ };
18
+ }
19
+ /**
20
+ * 录制器接口定义
21
+ */
22
+ export interface IRecorder extends EventEmitter {
23
+ readonly hasSegment: boolean;
24
+ readonly segment: number;
25
+ readonly inputOptions: string[];
26
+ readonly isHls: boolean;
27
+ readonly disableDanma: boolean;
28
+ readonly url: string;
29
+ readonly headers: {
30
+ [key: string]: string | undefined;
31
+ } | undefined;
32
+ readonly getSavePath: (data: {
33
+ startTime: number;
34
+ title?: string;
35
+ }) => string;
36
+ run(): void;
37
+ stop(): Promise<void>;
38
+ getArguments(): string[];
39
+ getExtraDataController(): any;
40
+ createCommand(): any;
41
+ on(event: "videoFileCreated", listener: (data: {
42
+ filename: string;
43
+ cover?: string;
44
+ }) => void): this;
45
+ on(event: "videoFileCompleted", listener: (data: {
46
+ filename: string;
47
+ }) => void): this;
48
+ on(event: "DebugLog", listener: (data: {
49
+ type: string;
50
+ text: string;
51
+ }) => void): this;
52
+ on(event: "progress", listener: (info: any) => void): this;
53
+ on(event: string, listener: (...args: any[]) => void): this;
54
+ emit(event: "videoFileCreated", data: {
55
+ filename: string;
56
+ cover?: string;
57
+ }): boolean;
58
+ emit(event: "videoFileCompleted", data: {
59
+ filename: string;
60
+ }): boolean;
61
+ emit(event: "DebugLog", data: {
62
+ type: string;
63
+ text: string;
64
+ }): boolean;
65
+ emit(event: "progress", info: any): boolean;
66
+ emit(event: string, ...args: any[]): boolean;
67
+ }
68
+ /**
69
+ * FFMPEG录制器特定选项
70
+ */
71
+ export interface FFMPEGRecorderOptions extends BaseRecorderOptions {
72
+ outputOptions: string[];
73
+ videoFormat?: "auto" | "ts" | "mkv" | "mp4";
74
+ }
75
+ /**
76
+ * Mesio录制器特定选项
77
+ */
78
+ export interface MesioRecorderOptions extends BaseRecorderOptions {
79
+ outputOptions?: string[];
80
+ isHls?: boolean;
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,39 +2,11 @@ import { FFMPEGRecorder } from "./FFMPEGRecorder.js";
2
2
  import { mesioRecorder } from "./mesioRecorder.js";
3
3
  export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
4
4
  export { mesioRecorder } from "./mesioRecorder.js";
5
+ import type { IRecorder, FFMPEGRecorderOptions, MesioRecorderOptions } from "./IRecorder.js";
5
6
  /**
6
7
  * 录制器类型
7
8
  */
8
9
  export type RecorderType = "ffmpeg" | "mesio";
9
- /**
10
- * 录制器基础配置选项
11
- */
12
- export interface BaseRecorderOptions {
13
- url: string;
14
- getSavePath: (data: {
15
- startTime: number;
16
- title?: string;
17
- }) => string;
18
- segment: number;
19
- inputOptions?: string[];
20
- disableDanma?: boolean;
21
- videoFormat?: "auto" | "ts" | "mkv";
22
- formatName?: "flv" | "ts" | "fmp4";
23
- headers?: {
24
- [key: string]: string | undefined;
25
- };
26
- }
27
- /**
28
- * FFMPEG录制器配置选项
29
- */
30
- export interface FFMPEGRecorderOptions extends BaseRecorderOptions {
31
- outputOptions: string[];
32
- }
33
- /**
34
- * Mesio录制器配置选项
35
- */
36
- export interface MesioRecorderOptions extends BaseRecorderOptions {
37
- }
38
10
  /**
39
11
  * 根据录制器类型获取对应的配置选项类型
40
12
  */
@@ -45,36 +17,10 @@ export type RecorderOptions<T extends RecorderType> = T extends "ffmpeg" ? FFMPE
45
17
  export type RecorderInstance<T extends RecorderType> = T extends "ffmpeg" ? FFMPEGRecorder : mesioRecorder;
46
18
  /**
47
19
  * 创建录制器的工厂函数
48
- *
49
- * @example
50
- * ```typescript
51
- * // 创建 FFMPEG 录制器
52
- * const ffmpegRecorder = createRecorder("ffmpeg", {
53
- * url: "https://example.com/stream.m3u8",
54
- * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
55
- * segment: 30,
56
- * outputOptions: ["-c", "copy"],
57
- * inputOptions: ["-user_agent", "Custom-Agent"]
58
- * }, onEnd, onUpdateLiveInfo);
59
- *
60
- * // 创建 Mesio 录制器
61
- * const mesioRecorder = createRecorder("mesio", {
62
- * url: "https://example.com/stream.m3u8",
63
- * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
64
- * segment: 30,
65
- * inputOptions: ["--fix"]
66
- * }, onEnd, onUpdateLiveInfo);
67
- * ```
68
- *
69
- * @param type 录制器类型
70
- * @param opts 录制器配置选项
71
- * @param onEnd 录制结束回调
72
- * @param onUpdateLiveInfo 更新直播信息回调
73
- * @returns 对应类型的录制器实例
74
20
  */
75
21
  export declare function createBaseRecorder<T extends RecorderType>(type: T, opts: RecorderOptions<T> & {
76
22
  mesioOptions?: string[];
77
23
  }, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
78
24
  title?: string;
79
25
  cover?: string;
80
- }>): RecorderInstance<T>;
26
+ }>): IRecorder;
@@ -4,32 +4,6 @@ export { FFMPEGRecorder } from "./FFMPEGRecorder.js";
4
4
  export { mesioRecorder } from "./mesioRecorder.js";
5
5
  /**
6
6
  * 创建录制器的工厂函数
7
- *
8
- * @example
9
- * ```typescript
10
- * // 创建 FFMPEG 录制器
11
- * const ffmpegRecorder = createRecorder("ffmpeg", {
12
- * url: "https://example.com/stream.m3u8",
13
- * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
14
- * segment: 30,
15
- * outputOptions: ["-c", "copy"],
16
- * inputOptions: ["-user_agent", "Custom-Agent"]
17
- * }, onEnd, onUpdateLiveInfo);
18
- *
19
- * // 创建 Mesio 录制器
20
- * const mesioRecorder = createRecorder("mesio", {
21
- * url: "https://example.com/stream.m3u8",
22
- * getSavePath: ({ startTime, title }) => `/recordings/${title}_${startTime}.ts`,
23
- * segment: 30,
24
- * inputOptions: ["--fix"]
25
- * }, onEnd, onUpdateLiveInfo);
26
- * ```
27
- *
28
- * @param type 录制器类型
29
- * @param opts 录制器配置选项
30
- * @param onEnd 录制结束回调
31
- * @param onUpdateLiveInfo 更新直播信息回调
32
- * @returns 对应类型的录制器实例
33
7
  */
34
8
  export function createBaseRecorder(type, opts, onEnd, onUpdateLiveInfo) {
35
9
  if (type === "ffmpeg") {
@@ -1,4 +1,5 @@
1
1
  import EventEmitter from "node:events";
2
+ import { IRecorder, MesioRecorderOptions } from "./IRecorder.js";
2
3
  declare class MesioCommand extends EventEmitter {
3
4
  private _input;
4
5
  private _output;
@@ -14,40 +15,25 @@ declare class MesioCommand extends EventEmitter {
14
15
  kill(signal?: NodeJS.Signals): void;
15
16
  }
16
17
  export declare const createMesioBuilder: () => MesioCommand;
17
- export declare class mesioRecorder extends EventEmitter {
18
+ export declare class mesioRecorder extends EventEmitter implements IRecorder {
18
19
  private onEnd;
19
20
  private onUpdateLiveInfo;
20
21
  private command;
21
22
  private streamManager;
22
- hasSegment: boolean;
23
- getSavePath: (data: {
23
+ readonly hasSegment: boolean;
24
+ readonly getSavePath: (data: {
24
25
  startTime: number;
25
26
  title?: string;
26
27
  }) => string;
27
- segment: number;
28
- inputOptions: string[];
29
- isHls: boolean;
30
- disableDanma: boolean;
31
- url: string;
32
- headers: {
28
+ readonly segment: number;
29
+ readonly inputOptions: string[];
30
+ readonly isHls: boolean;
31
+ readonly disableDanma: boolean;
32
+ readonly url: string;
33
+ readonly headers: {
33
34
  [key: string]: string | undefined;
34
35
  } | undefined;
35
- constructor(opts: {
36
- url: string;
37
- getSavePath: (data: {
38
- startTime: number;
39
- title?: string;
40
- }) => string;
41
- segment: number;
42
- outputOptions?: string[];
43
- inputOptions?: string[];
44
- isHls?: boolean;
45
- disableDanma?: boolean;
46
- formatName?: "flv" | "ts" | "fmp4";
47
- headers?: {
48
- [key: string]: string | undefined;
49
- };
50
- }, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
36
+ constructor(opts: MesioRecorderOptions, onEnd: (...args: unknown[]) => void, onUpdateLiveInfo: () => Promise<{
51
37
  title?: string;
52
38
  cover?: string;
53
39
  }>);
@@ -55,6 +41,6 @@ export declare class mesioRecorder extends EventEmitter {
55
41
  run(): void;
56
42
  getArguments(): string[];
57
43
  stop(): Promise<void>;
58
- getExtraDataController(): import("../record_extra_data_controller.js").RecordExtraDataController | null;
44
+ getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
59
45
  }
60
46
  export {};
@@ -64,6 +64,7 @@ class MesioCommand extends EventEmitter {
64
64
  this.process.on("error", (error) => {
65
65
  this.emit("error", error);
66
66
  });
67
+ [];
67
68
  this.process.on("close", (code) => {
68
69
  if (code === 0) {
69
70
  this.emit("end");
@@ -1,5 +1,5 @@
1
1
  import EventEmitter from "node:events";
2
- import { createRecordExtraDataController } from "../record_extra_data_controller.js";
2
+ import { createRecordExtraDataController } from "../xml_stream_controller.js";
3
3
  import type { RecorderCreateOpts } from "../recorder.js";
4
4
  export type GetSavePath = (data: {
5
5
  startTime: number;
@@ -44,7 +44,7 @@ export declare class StreamManager extends EventEmitter {
44
44
  });
45
45
  handleVideoStarted(stderrLine: string): Promise<void>;
46
46
  handleVideoCompleted(): Promise<void>;
47
- getExtraDataController(): import("../record_extra_data_controller.js").RecordExtraDataController | null;
47
+ getExtraDataController(): import("../xml_stream_controller.js").XmlStreamController | null;
48
48
  get videoExt(): VideoFormat;
49
49
  get videoFilePath(): string;
50
50
  }
@@ -1,6 +1,6 @@
1
1
  import EventEmitter from "node:events";
2
2
  import fs from "fs/promises";
3
- import { createRecordExtraDataController } from "../record_extra_data_controller.js";
3
+ import { createRecordExtraDataController } from "../xml_stream_controller.js";
4
4
  import { replaceExtName, ensureFolderExist, isFfmpegStartSegment, isMesioStartSegment, isFfmpegStart, retry, cleanTerminalText, } from "../utils.js";
5
5
  export class Segment extends EventEmitter {
6
6
  extraDataController = null;
@@ -64,13 +64,13 @@ export class Segment extends EventEmitter {
64
64
  });
65
65
  ensureFolderExist(this.outputVideoFilePath);
66
66
  if (!this.disableDanma) {
67
- this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.json`);
67
+ this.extraDataController = createRecordExtraDataController(`${this.outputVideoFilePath}.xml`);
68
68
  }
69
69
  // 支持两种格式的正则表达式
70
70
  // 1. FFmpeg格式: Opening 'filename' for writing
71
71
  // 2. Mesio格式: Opening FLV segment path=filename Processing
72
72
  const ffmpegRegex = /'([^']+)'/;
73
- const mesioRegex = /segment path=([^\n]*)/i;
73
+ const mesioRegex = /segment path=(.+?\.(?:flv|ts|m4s))/is;
74
74
  let match = stderrLine.match(ffmpegRegex);
75
75
  if (!match) {
76
76
  match = cleanTerminalText(stderrLine).match(mesioRegex);
@@ -122,7 +122,7 @@ export class StreamManager extends EventEmitter {
122
122
  });
123
123
  }
124
124
  else {
125
- const extraDataSavePath = replaceExtName(recordSavePath, ".json");
125
+ const extraDataSavePath = replaceExtName(recordSavePath, ".xml");
126
126
  if (!disableDanma) {
127
127
  this.extraDataController = createRecordExtraDataController(extraDataSavePath);
128
128
  }
package/lib/recorder.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
10
10
  channelId: ChannelId;
11
11
  id?: string;
12
12
  remarks?: string;
13
+ weight?: number;
13
14
  disableAutoCheck?: boolean;
14
15
  disableProvideCommentsWhenRecording?: boolean;
15
16
  quality: Quality;
@@ -25,7 +26,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
25
26
  /** 身份验证 */
26
27
  auth?: string;
27
28
  /** cookie所有者uid,B站弹幕录制 */
28
- uid?: number;
29
+ uid?: number | string;
29
30
  /** 画质匹配重试次数 */
30
31
  qualityRetry?: number;
31
32
  /** 抖音是否使用双屏直播流,开启后如果是双屏直播,那么就使用拼接的流,默认为true */
@@ -38,8 +39,8 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
38
39
  formatName?: FormatName;
39
40
  /** 流编码 */
40
41
  codecName?: CodecName;
41
- /** 选择使用的api,虎牙支持: auto,web,mp,抖音支持:web,webHTML */
42
- api?: "auto" | "web" | "mp" | "webHTML";
42
+ /** 选择使用的api,虎牙支持: auto,web,mp,抖音支持:web,webHTML,mobile,userHTML */
43
+ api?: "auto" | "web" | "mp" | "webHTML" | "mobile" | "userHTML";
43
44
  /** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制(仅对斗鱼有效),多个关键词用英文逗号分隔 */
44
45
  titleKeywords?: string;
45
46
  /** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
@@ -58,7 +59,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
58
59
  cache: Cache;
59
60
  }
60
61
  export type SerializedRecorder<E extends AnyObject> = PickRequired<RecorderCreateOpts<E>, "id"> & Pick<Recorder<E>, "id" | "channelId" | "remarks" | "disableAutoCheck" | "quality" | "streamPriorities" | "sourcePriorities" | "extra" | "segment" | "saveSCDanma" | "saveCover" | "saveGiftDanma" | "disableProvideCommentsWhenRecording" | "liveInfo" | "uid" | "titleKeywords">;
61
- export type RecorderState = "idle" | "recording" | "stopping-record";
62
+ export type RecorderState = "idle" | "recording" | "stopping-record" | "check-error";
62
63
  export type Progress = {
63
64
  time: string | null;
64
65
  };
@@ -110,7 +111,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
110
111
  state: RecorderState;
111
112
  qualityMaxRetry: number;
112
113
  qualityRetry: number;
113
- uid?: number;
114
+ uid?: number | string;
114
115
  liveInfo?: {
115
116
  living: boolean;
116
117
  owner: string;
@@ -0,0 +1,23 @@
1
+ import { Message } from "./common.js";
2
+ export interface XmlStreamData {
3
+ meta: {
4
+ title?: string;
5
+ recordStartTimestamp: number;
6
+ recordStopTimestamp?: number;
7
+ liveStartTimestamp?: number;
8
+ ffmpegArgs?: string[];
9
+ platform?: string;
10
+ user_name?: string;
11
+ room_id?: string;
12
+ };
13
+ /** 缓存的消息,待写入到文件 */
14
+ pendingMessages: Message[];
15
+ }
16
+ export interface XmlStreamController {
17
+ /** 设计上来说,外部程序不应该能直接修改 data 上的东西 */
18
+ readonly data: XmlStreamData;
19
+ addMessage: (message: Message) => void;
20
+ setMeta: (meta: Partial<XmlStreamData["meta"]>) => void;
21
+ flush: () => Promise<void>;
22
+ }
23
+ export declare function createRecordExtraDataController(savePath: string): XmlStreamController;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * XML流式写入控制器,用于实时写入弹幕、礼物等信息到XML文件
3
+ * 相比原有的json方案,这个实现每隔5秒就会写入数据,减少内存占用和数据丢失风险
4
+ */
5
+ import fs from "node:fs";
6
+ import { XMLBuilder } from "fast-xml-parser";
7
+ import { pick } from "lodash-es";
8
+ import { asyncThrottle } from "./utils.js";
9
+ export function createRecordExtraDataController(savePath) {
10
+ const data = {
11
+ meta: {
12
+ recordStartTimestamp: Date.now(),
13
+ },
14
+ pendingMessages: [],
15
+ };
16
+ 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<i>\n<!--METADATA_PLACEHOLDER-->\n`;
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,
38
+ });
39
+ const writeToFile = async () => {
40
+ if (isWriting || hasCompleted || data.pendingMessages.length === 0) {
41
+ return;
42
+ }
43
+ // 确保文件已初始化
44
+ await initializeFile();
45
+ isWriting = true;
46
+ try {
47
+ // 获取待写入的消息
48
+ const messagesToWrite = [...data.pendingMessages];
49
+ data.pendingMessages = [];
50
+ // 生成XML内容
51
+ const xmlContent = generateXmlContent(data.meta, messagesToWrite);
52
+ // 追加写入文件
53
+ await appendToXmlFile(savePath, xmlContent);
54
+ }
55
+ catch (error) {
56
+ console.error("写入XML文件失败:", error);
57
+ // 如果写入失败,将消息重新加入队列
58
+ data.pendingMessages = [...data.pendingMessages];
59
+ }
60
+ finally {
61
+ isWriting = false;
62
+ }
63
+ };
64
+ const addMessage = (message) => {
65
+ if (hasCompleted)
66
+ return;
67
+ // if (!isInitialized) return;
68
+ data.pendingMessages.push(message);
69
+ // 确保文件已初始化
70
+ initializeFile().catch(console.error);
71
+ scheduleWrite();
72
+ };
73
+ const setMeta = (meta) => {
74
+ if (hasCompleted)
75
+ return;
76
+ data.meta = {
77
+ ...data.meta,
78
+ ...meta,
79
+ };
80
+ };
81
+ const flush = async () => {
82
+ if (hasCompleted)
83
+ return;
84
+ hasCompleted = true;
85
+ scheduleWrite.cancel();
86
+ await initializeFile().catch(console.error);
87
+ // 写入剩余的数据
88
+ if (data.pendingMessages.length > 0) {
89
+ await writeToFile();
90
+ }
91
+ // 完成XML文件(添加结束标签等)
92
+ await finalizeXmlFile(savePath, data.meta);
93
+ // 清理内存
94
+ data.pendingMessages = [];
95
+ };
96
+ return {
97
+ data,
98
+ addMessage,
99
+ setMeta,
100
+ flush,
101
+ };
102
+ }
103
+ /**
104
+ * 生成XML内容片段
105
+ */
106
+ function generateXmlContent(metadata, messages) {
107
+ const builder = new XMLBuilder({
108
+ ignoreAttributes: false,
109
+ attributeNamePrefix: "@@",
110
+ format: true,
111
+ });
112
+ const comments = messages
113
+ .filter((item) => item.type === "comment")
114
+ .map((ele) => {
115
+ const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
116
+ const data = {
117
+ "@@p": "",
118
+ "@@progress": progress,
119
+ "@@mode": String(ele.mode ?? 1),
120
+ "@@fontsize": String(25),
121
+ "@@color": String(parseInt((ele.color || "#ffffff").replace("#", ""), 16)),
122
+ "@@midHash": String(ele?.sender?.uid),
123
+ "#text": String(ele?.text || ""),
124
+ "@@ctime": String(ele.timestamp),
125
+ "@@pool": String(0),
126
+ "@@weight": String(0),
127
+ "@@user": String(ele.sender?.name),
128
+ "@@uid": String(ele?.sender?.uid),
129
+ "@@timestamp": String(ele.timestamp),
130
+ };
131
+ data["@@p"] = [
132
+ data["@@progress"],
133
+ data["@@mode"],
134
+ data["@@fontsize"],
135
+ data["@@color"],
136
+ data["@@ctime"],
137
+ data["@@pool"],
138
+ data["@@midHash"],
139
+ data["@@uid"],
140
+ data["@@weight"],
141
+ ].join(",");
142
+ return pick(data, ["@@p", "#text", "@@user", "@@uid", "@@timestamp"]);
143
+ });
144
+ const gifts = messages
145
+ .filter((item) => item.type === "give_gift")
146
+ .map((ele) => {
147
+ const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
148
+ return {
149
+ "@@ts": progress,
150
+ "@@giftname": String(ele.name),
151
+ "@@giftcount": String(ele.count),
152
+ "@@price": String(ele.price * 1000),
153
+ "@@user": String(ele.sender?.name),
154
+ "@@uid": String(ele?.sender?.uid),
155
+ "@@timestamp": String(ele.timestamp),
156
+ };
157
+ });
158
+ const superChats = messages
159
+ .filter((item) => item.type === "super_chat")
160
+ .map((ele) => {
161
+ const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
162
+ return {
163
+ "@@ts": progress,
164
+ "@@price": String(ele.price * 1000),
165
+ "#text": String(ele.text),
166
+ "@@user": String(ele.sender?.name),
167
+ "@@uid": String(ele?.sender?.uid),
168
+ "@@timestamp": String(ele.timestamp),
169
+ };
170
+ });
171
+ const guardGift = messages
172
+ .filter((item) => item.type === "guard")
173
+ .map((ele) => {
174
+ const progress = Math.max((ele.timestamp - metadata.recordStartTimestamp) / 1000, 0);
175
+ return {
176
+ "@@ts": progress,
177
+ "@@price": String(ele.price * 1000),
178
+ "@@giftname": String(ele.name),
179
+ "@@giftcount": String(ele.count),
180
+ "@@level": String(ele.level),
181
+ "@@user": String(ele.sender?.name),
182
+ "@@uid": String(ele?.sender?.uid),
183
+ "@@timestamp": String(ele.timestamp),
184
+ };
185
+ });
186
+ // 构建这一批消息的XML片段
187
+ const fragment = {
188
+ d: comments,
189
+ gift: gifts,
190
+ sc: superChats,
191
+ guard: guardGift,
192
+ };
193
+ return builder.build(fragment);
194
+ }
195
+ /**
196
+ * 追加内容到XML文件
197
+ */
198
+ async function appendToXmlFile(filePath, content) {
199
+ try {
200
+ // 直接追加内容
201
+ await fs.promises.appendFile(filePath, content);
202
+ }
203
+ catch (error) {
204
+ console.error(`写入XML文件失败: ${filePath}`, error);
205
+ throw error;
206
+ }
207
+ }
208
+ /**
209
+ * 完成XML文件写入
210
+ */
211
+ async function finalizeXmlFile(filePath, metadata) {
212
+ try {
213
+ const builder = new XMLBuilder({
214
+ ignoreAttributes: false,
215
+ attributeNamePrefix: "@@",
216
+ format: true,
217
+ });
218
+ // 生成metadata XML
219
+ const metadataXml = builder.build({
220
+ metadata: {
221
+ platform: metadata.platform,
222
+ video_start_time: metadata.recordStartTimestamp,
223
+ live_start_time: metadata.liveStartTimestamp,
224
+ room_title: metadata.title,
225
+ user_name: metadata.user_name,
226
+ room_id: metadata.room_id,
227
+ },
228
+ });
229
+ // 读取文件内容
230
+ const content = await fs.promises.readFile(filePath, "utf-8");
231
+ // 替换占位符为实际的metadata,并添加结束标签
232
+ const finalContent = content.replace("<!--METADATA_PLACEHOLDER-->", metadataXml) + "</i>";
233
+ // 写回文件
234
+ await fs.promises.writeFile(filePath, finalContent);
235
+ }
236
+ catch (error) {
237
+ console.error(`完成XML文件写入失败: ${filePath}`, error);
238
+ throw error;
239
+ }
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "dependencies": {
35
35
  "@renmu/fluent-ffmpeg": "2.3.3",
36
36
  "fast-xml-parser": "^4.5.0",
37
- "filenamify": "^6.0.0",
37
+ "filenamify": "^7.0.0",
38
38
  "mitt": "^3.0.1",
39
39
  "string-argv": "^0.3.2",
40
40
  "lodash-es": "^4.17.21",