@bililive-tools/manager 1.8.0 → 1.9.0

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/README.md CHANGED
@@ -94,19 +94,22 @@ setBililivePath("BililiveRecorder.Cli.exe");
94
94
 
95
95
  默认值为 `{platform}/{owner}/{year}-{month}-{date} {hour}-{min}-{sec} {title}`
96
96
 
97
- | 值 | 标签 |
98
- | ----------- | ------ |
99
- | {platform} | 平台 |
100
- | {channelId} | 房间号 |
101
- | {remarks} | 备注 |
102
- | {owner} | 主播名 |
103
- | {title} | 标题 |
104
- | {year} | 年 |
105
- | {month} | 月 |
106
- | {date} | 日 |
107
- | {hour} | 时 |
108
- | {min} | 分 |
109
- | {sec} | 秒 |
97
+ | 值 | 标签 |
98
+ | ----------------- | ------------------------------------------ |
99
+ | {platform} | 平台 |
100
+ | {channelId} | 房间号 |
101
+ | {remarks} | 备注 |
102
+ | {owner} | 主播名 |
103
+ | {title} | 标题 |
104
+ | {year} | 年 |
105
+ | {month} | 月 |
106
+ | {date} | 日 |
107
+ | {hour} | 时 |
108
+ | {min} | 分 |
109
+ | {sec} | 秒 |
110
+ | {startTime} | 分段开始时间,Date对象 |
111
+ | {recordStartTime} | 录制开始时间,Date对象 |
112
+ | {liveStartTime} | 直播开始时间,Date对象,抖音同录制开始时间 |
110
113
 
111
114
  ## 事件
112
115
 
package/lib/common.d.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { AnyObject, UnknownObject } from "./utils.js";
2
2
  export type ChannelId = string;
3
3
  export declare const Qualities: readonly ["lowest", "low", "medium", "high", "highest"];
4
- export declare const BiliQualities: readonly [30000, 20000, 10000, 400, 250, 150, 80];
5
4
  export declare const DouyuQualities: readonly [0, 2, 3, 4, 8];
6
5
  export declare const HuYaQualities: readonly [0, 20000, 14100, 14000, 10000, 8000, 4200, 4000, 2000, 500, -1];
7
6
  export declare const DouYinQualities: readonly ["origin", "uhd", "hd", "sd", "ld", "ao", "real_origin"];
8
- export type Quality = (typeof Qualities)[number] | (typeof BiliQualities)[number] | (typeof DouyuQualities)[number] | (typeof HuYaQualities)[number] | (typeof DouYinQualities)[number];
7
+ export type Quality = string | number;
9
8
  export interface MessageSender<E extends AnyObject = UnknownObject> {
10
9
  uid?: string;
11
10
  name: string;
package/lib/common.js CHANGED
@@ -1,5 +1,4 @@
1
1
  export const Qualities = ["lowest", "low", "medium", "high", "highest"];
2
- export const BiliQualities = [30000, 20000, 10000, 400, 250, 150, 80];
3
2
  export const DouyuQualities = [0, 2, 3, 4, 8];
4
3
  // 14100: 2K HDR;14000:2K;4200:HDR(10M);0:原画;8000:蓝光8M;4000:蓝光4M;2000:超清;500:流畅
5
4
  export const HuYaQualities = [
package/lib/index.d.ts CHANGED
@@ -24,3 +24,4 @@ export declare function getMesioPath(): string;
24
24
  export declare function setBililivePath(newPath: string): void;
25
25
  export declare function getBililivePath(): string;
26
26
  export declare function getDataFolderPath<E extends AnyObject>(provider: RecorderProvider<E>): string;
27
+ export type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
package/lib/manager.d.ts CHANGED
@@ -103,7 +103,9 @@ export declare function createRecorderManager<ME extends AnyObject = UnknownObje
103
103
  export declare function genSavePathFromRule<ME extends AnyObject, P extends RecorderProvider<AnyObject>, PE extends AnyObject, E extends AnyObject>(manager: RecorderManager<ME, P, PE, E>, recorder: Recorder<E>, extData: {
104
104
  owner: string;
105
105
  title: string;
106
- startTime?: number;
106
+ startTime: number;
107
+ liveStartTime: Date;
108
+ recordStartTime: Date;
107
109
  }): string;
108
110
  export type GetProviderExtra<P> = P extends RecorderProvider<infer E> ? E : never;
109
111
  export { StreamManager, Cache };
package/lib/manager.js CHANGED
@@ -312,12 +312,12 @@ export function genSavePathFromRule(manager, recorder, extData) {
312
312
  // TODO: 这里随便写的,后面再优化
313
313
  const provider = manager.providers.find((p) => p.id === recorder.toJSON().providerId);
314
314
  const now = extData?.startTime ? new Date(extData.startTime) : new Date();
315
- const owner = (extData?.owner ?? "").replaceAll("%", "_");
316
- const title = (extData?.title ?? "").replaceAll("%", "_");
315
+ const owner = removeSystemReservedChars((extData?.owner ?? "").replaceAll("%", "_"));
316
+ const title = removeSystemReservedChars((extData?.title ?? "").replaceAll("%", "_"));
317
+ const remarks = removeSystemReservedChars((recorder.remarks ?? "").replaceAll("%", "_"));
318
+ const channelId = removeSystemReservedChars(String(recorder.channelId));
317
319
  const params = {
318
320
  platform: provider?.name ?? "unknown",
319
- channelId: recorder.channelId,
320
- remarks: recorder.remarks ?? "",
321
321
  year: formatDate(now, "yyyy"),
322
322
  month: formatDate(now, "MM"),
323
323
  date: formatDate(now, "dd"),
@@ -325,20 +325,19 @@ export function genSavePathFromRule(manager, recorder, extData) {
325
325
  min: formatDate(now, "mm"),
326
326
  sec: formatDate(now, "ss"),
327
327
  ...extData,
328
+ startTime: now,
328
329
  owner: owner,
329
330
  title: title,
331
+ remarks: remarks,
332
+ channelId,
330
333
  };
331
- if (manager.autoRemoveSystemReservedChars) {
332
- for (const key in params) {
333
- params[key] = removeSystemReservedChars(String(params[key])).trim();
334
- }
335
- }
336
334
  let savePathRule = manager.savePathRule;
337
335
  try {
338
336
  savePathRule = ejs.render(savePathRule, params);
337
+ console.log("解析后保存路径模板:", savePathRule, params);
339
338
  }
340
339
  catch (error) {
341
- console.error("模板解析错误", error);
340
+ console.error("模板解析错误", error, savePathRule, params);
342
341
  }
343
342
  return formatTemplate(savePathRule, params);
344
343
  }
@@ -43,7 +43,6 @@ class BililiveRecorderCommand extends EventEmitter {
43
43
  run() {
44
44
  const args = this._getArguments();
45
45
  const bililiveExecutable = getBililivePath();
46
- console.log("Starting BililiveRecorder with args:", bililiveExecutable, args);
47
46
  this.process = spawn(bililiveExecutable, args, {
48
47
  stdio: ["pipe", "pipe", "pipe"],
49
48
  });
@@ -116,8 +115,8 @@ export class BililiveRecorder extends EventEmitter {
116
115
  this.segment = opts.segment;
117
116
  this.headers = opts.headers;
118
117
  this.command = this.createCommand();
119
- this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
120
- this.emit("videoFileCreated", { filename, cover, rawFilename });
118
+ this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
119
+ this.emit("videoFileCreated", { filename, cover, rawFilename, title });
121
120
  });
122
121
  this.streamManager.on("videoFileCompleted", ({ filename }) => {
123
122
  this.emit("videoFileCompleted", { filename });
@@ -1,6 +1,7 @@
1
1
  import EventEmitter from "node:events";
2
2
  import { IRecorder, FFMPEGRecorderOptions } from "./IRecorder.js";
3
- import type { FormatName } from "./index.js";
3
+ import { FormatName } from "./index.js";
4
+ import type { VideoFormat } from "../index.js";
4
5
  export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
5
6
  private onEnd;
6
7
  private onUpdateLiveInfo;
@@ -20,7 +21,7 @@ export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
20
21
  readonly disableDanma: boolean;
21
22
  readonly url: string;
22
23
  formatName: FormatName;
23
- videoFormat: "ts" | "mkv" | "mp4";
24
+ videoFormat: VideoFormat;
24
25
  readonly debugLevel: "none" | "basic" | "verbose";
25
26
  readonly headers: {
26
27
  [key: string]: string | undefined;
@@ -30,6 +31,7 @@ export declare class FFMPEGRecorder extends EventEmitter implements IRecorder {
30
31
  cover?: string;
31
32
  }>);
32
33
  createCommand(): import("@renmu/fluent-ffmpeg").FfmpegCommand;
34
+ buildOutputOptions(): string[];
33
35
  formatLine(line: string): {
34
36
  time: string | null;
35
37
  } | null;
@@ -37,7 +37,7 @@ export class FFMPEGRecorder extends EventEmitter {
37
37
  let videoFormat = opts.videoFormat ?? "auto";
38
38
  if (videoFormat === "auto") {
39
39
  if (!this.hasSegment) {
40
- videoFormat = "mp4";
40
+ videoFormat = "m4s";
41
41
  if (this.formatName === "ts") {
42
42
  videoFormat = "ts";
43
43
  }
@@ -59,8 +59,8 @@ export class FFMPEGRecorder extends EventEmitter {
59
59
  this.segment = opts.segment;
60
60
  this.headers = opts.headers;
61
61
  this.command = this.createCommand();
62
- this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
63
- this.emit("videoFileCreated", { filename, cover, rawFilename });
62
+ this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
63
+ this.emit("videoFileCreated", { filename, cover, rawFilename, title });
64
64
  });
65
65
  this.streamManager.on("videoFileCompleted", ({ filename }) => {
66
66
  this.emit("videoFileCompleted", { filename });
@@ -95,10 +95,11 @@ export class FFMPEGRecorder extends EventEmitter {
95
95
  inputOptions.push("-headers", headers.join("\\r\\n"));
96
96
  }
97
97
  }
98
+ const outputOptions = this.buildOutputOptions();
98
99
  const command = createFFMPEGBuilder()
99
100
  .input(this.url)
100
101
  .inputOptions(inputOptions)
101
- .outputOptions(this.ffmpegOutputOptions)
102
+ .outputOptions(outputOptions)
102
103
  .output(this.streamManager.videoFilePath)
103
104
  .on("error", this.onEnd)
104
105
  .on("end", () => this.onEnd("finished"))
@@ -116,10 +117,24 @@ export class FFMPEGRecorder extends EventEmitter {
116
117
  }
117
118
  })
118
119
  .on("stderr", this.timeoutChecker?.update);
120
+ return command;
121
+ }
122
+ buildOutputOptions() {
123
+ const options = [];
124
+ options.push(...this.ffmpegOutputOptions);
125
+ options.push("-c", "copy", "-movflags", "+frag_keyframe+empty_moov+separate_moof", "-fflags", "+genpts+igndts", "-min_frag_duration", "10000000");
119
126
  if (this.hasSegment) {
120
- command.outputOptions("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
127
+ options.push("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
128
+ if (this.videoFormat === "m4s") {
129
+ options.push("-segment_format", "mp4");
130
+ }
121
131
  }
122
- return command;
132
+ else {
133
+ if (this.videoFormat === "m4s") {
134
+ options.push("-f", "mp4");
135
+ }
136
+ }
137
+ return options;
123
138
  }
124
139
  formatLine(line) {
125
140
  if (!line.includes("time=")) {
@@ -1,5 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import type { VideoFormat } from "../index.js";
2
3
  import type { FormatName } from "./index.js";
4
+ import type { XmlStreamController } from "../xml_stream_controller.js";
3
5
  /**
4
6
  * 录制器构造函数选项的基础接口
5
7
  */
@@ -17,7 +19,7 @@ export interface BaseRecorderOptions {
17
19
  headers?: {
18
20
  [key: string]: string | undefined;
19
21
  };
20
- videoFormat?: "auto" | "ts" | "mkv" | "mp4";
22
+ videoFormat?: VideoFormat;
21
23
  }
22
24
  /**
23
25
  * 录制器接口定义
@@ -39,12 +41,13 @@ export interface IRecorder extends EventEmitter {
39
41
  run(): void;
40
42
  stop(): Promise<void>;
41
43
  getArguments(): string[];
42
- getExtraDataController(): any;
44
+ getExtraDataController(): XmlStreamController | null;
43
45
  createCommand(): any;
44
46
  on(event: "videoFileCreated", listener: (data: {
45
47
  filename: string;
46
48
  cover?: string;
47
49
  rawFilename?: string;
50
+ title?: string;
48
51
  }) => void): this;
49
52
  on(event: "videoFileCompleted", listener: (data: {
50
53
  filename: string;
@@ -59,6 +62,7 @@ export interface IRecorder extends EventEmitter {
59
62
  filename: string;
60
63
  cover?: string;
61
64
  rawFilename?: string;
65
+ title?: string;
62
66
  }): boolean;
63
67
  emit(event: "videoFileCompleted", data: {
64
68
  filename: string;
@@ -128,8 +128,8 @@ export class MesioRecorder extends EventEmitter {
128
128
  this.segment = opts.segment;
129
129
  this.headers = opts.headers;
130
130
  this.command = this.createCommand();
131
- this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename }) => {
132
- this.emit("videoFileCreated", { filename, cover, rawFilename });
131
+ this.streamManager.on("videoFileCreated", ({ filename, cover, rawFilename, title }) => {
132
+ this.emit("videoFileCreated", { filename, cover, rawFilename, title });
133
133
  });
134
134
  this.streamManager.on("videoFileCompleted", ({ filename }) => {
135
135
  this.emit("videoFileCompleted", { filename });
@@ -144,6 +144,7 @@ export class MesioRecorder extends EventEmitter {
144
144
  "--fix",
145
145
  "-H",
146
146
  "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36",
147
+ "--no-proxy",
147
148
  ];
148
149
  if (this.debugLevel === "verbose") {
149
150
  inputOptions.push("-v");
@@ -1,12 +1,12 @@
1
1
  import EventEmitter from "node:events";
2
2
  import { createRecordExtraDataController } from "../xml_stream_controller.js";
3
3
  import type { RecorderCreateOpts } from "../recorder.js";
4
+ import type { VideoFormat } from "../index.js";
4
5
  export type GetSavePath = (data: {
5
6
  startTime: number;
6
7
  title?: string;
7
8
  }) => string;
8
9
  type RecorderType = Exclude<RecorderCreateOpts["recorderType"], undefined | "auto">;
9
- type VideoFormat = "auto" | "ts" | "mkv" | "flv" | "mp4" | "m4s";
10
10
  export declare class Segment extends EventEmitter {
11
11
  extraDataController: ReturnType<typeof createRecordExtraDataController> | null;
12
12
  init: boolean;
package/lib/recorder.d.ts CHANGED
@@ -3,6 +3,7 @@ import { ChannelId, Message, Quality } from "./common.js";
3
3
  import { RecorderProvider } from "./manager.js";
4
4
  import { AnyObject, PickRequired, UnknownObject } from "./utils.js";
5
5
  import { Cache } from "./cache.js";
6
+ import type { RecorderType } from "./recorder/index.js";
6
7
  type FormatName = "auto" | "flv" | "hls" | "fmp4" | "flv_only" | "hls_only" | "fmp4_only";
7
8
  type CodecName = "auto" | "avc" | "hevc" | "avc_only" | "hevc_only";
8
9
  export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
@@ -44,7 +45,7 @@ export interface RecorderCreateOpts<E extends AnyObject = UnknownObject> {
44
45
  /** 标题关键词,如果直播间标题包含这些关键词,则不会自动录制(仅对斗鱼有效),多个关键词用英文逗号分隔 */
45
46
  titleKeywords?: string;
46
47
  /** 用于指定录制文件格式,auto时,分段使用ts,不分段使用mp4 */
47
- videoFormat?: "auto" | "ts" | "mkv";
48
+ videoFormat?: "auto" | "ts" | "mkv" | "flv";
48
49
  /** 录制类型 */
49
50
  recorderType?: "auto" | "ffmpeg" | "mesio" | "bililive";
50
51
  /** 流格式优先级 */
@@ -71,6 +72,7 @@ export interface RecordHandle {
71
72
  id: string;
72
73
  stream: string;
73
74
  source: string;
75
+ recorderType?: RecorderType;
74
76
  url: string;
75
77
  ffmpegArgs?: string[];
76
78
  progress?: Progress;
@@ -85,7 +87,9 @@ export interface DebugLog {
85
87
  export type GetSavePath = (data: {
86
88
  owner: string;
87
89
  title: string;
88
- startTime?: number;
90
+ startTime: number;
91
+ liveStartTime: Date;
92
+ recordStartTime: Date;
89
93
  }) => string;
90
94
  export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
91
95
  RecordStart: RecordHandle;
@@ -121,7 +125,7 @@ export interface Recorder<E extends AnyObject = UnknownObject> extends Emitter<{
121
125
  living: boolean;
122
126
  owner: string;
123
127
  title: string;
124
- startTime?: Date;
128
+ startTime: Date;
125
129
  avatar: string;
126
130
  cover: string;
127
131
  liveId?: string;
package/lib/utils.d.ts CHANGED
@@ -34,7 +34,7 @@ export declare function assertStringType(data: unknown, msg?: string): asserts d
34
34
  export declare function assertNumberType(data: unknown, msg?: string): asserts data is number;
35
35
  export declare function assertObjectType(data: unknown, msg?: string): asserts data is object;
36
36
  export declare function formatDate(date: Date, format: string): string;
37
- export declare function removeSystemReservedChars(filename: string): string;
37
+ export declare function removeSystemReservedChars(str: string): string;
38
38
  export declare function isFfmpegStartSegment(line: string): boolean;
39
39
  export declare function isMesioStartSegment(line: string): boolean;
40
40
  export declare function isBililiveStartSegment(line: string): boolean;
package/lib/utils.js CHANGED
@@ -120,8 +120,8 @@ export function formatDate(date, format) {
120
120
  };
121
121
  return format.replace(/yyyy|MM|dd|HH|mm|ss/g, (matched) => map[matched]);
122
122
  }
123
- export function removeSystemReservedChars(filename) {
124
- return filenamify(filename, { replacement: "_" });
123
+ export function removeSystemReservedChars(str) {
124
+ return filenamify(str, { replacement: "_" });
125
125
  }
126
126
  export function isFfmpegStartSegment(line) {
127
127
  return line.includes("Opening ") && line.includes("for writing");
@@ -17,7 +17,7 @@ export interface XmlStreamController {
17
17
  /** 设计上来说,外部程序不应该能直接修改 data 上的东西 */
18
18
  readonly data: XmlStreamData;
19
19
  addMessage: (message: Message) => void;
20
- setMeta: (meta: Partial<XmlStreamData["meta"]>) => void;
20
+ setMeta: (meta: Partial<XmlStreamData["meta"]>) => Promise<void>;
21
21
  flush: () => Promise<void>;
22
22
  }
23
23
  export declare function createRecordExtraDataController(savePath: string): XmlStreamController;
@@ -70,13 +70,16 @@ export function createRecordExtraDataController(savePath) {
70
70
  initializeFile().catch(console.error);
71
71
  scheduleWrite();
72
72
  };
73
- const setMeta = (meta) => {
73
+ const setMeta = async (meta) => {
74
74
  if (hasCompleted)
75
75
  return;
76
76
  data.meta = {
77
77
  ...data.meta,
78
78
  ...meta,
79
79
  };
80
+ // 确保文件已初始化,然后立即更新文件中的metadata
81
+ await initializeFile().catch(console.error);
82
+ await updateMetadataInFile(savePath, data.meta).catch(console.error);
80
83
  };
81
84
  const flush = async () => {
82
85
  if (hasCompleted)
@@ -89,7 +92,7 @@ export function createRecordExtraDataController(savePath) {
89
92
  await writeToFile();
90
93
  }
91
94
  // 完成XML文件(添加结束标签等)
92
- await finalizeXmlFile(savePath, data.meta);
95
+ await finalizeXmlFile(savePath);
93
96
  // 清理内存
94
97
  data.pendingMessages = [];
95
98
  };
@@ -206,9 +209,9 @@ async function appendToXmlFile(filePath, content) {
206
209
  }
207
210
  }
208
211
  /**
209
- * 完成XML文件写入
212
+ * 更新XML文件中的metadata
210
213
  */
211
- async function finalizeXmlFile(filePath, metadata) {
214
+ async function updateMetadataInFile(filePath, metadata) {
212
215
  try {
213
216
  const builder = new XMLBuilder({
214
217
  ignoreAttributes: false,
@@ -228,8 +231,25 @@ async function finalizeXmlFile(filePath, metadata) {
228
231
  });
229
232
  // 读取文件内容
230
233
  const content = await fs.promises.readFile(filePath, "utf-8");
231
- // 替换占位符为实际的metadata,并添加结束标签
232
- const finalContent = content.replace("<!--METADATA_PLACEHOLDER-->", metadataXml) + "</i>";
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>";
233
253
  // 写回文件
234
254
  await fs.promises.writeFile(filePath, finalContent);
235
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/manager",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Batch scheduling recorders",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",