@bililive-tools/douyu-recorder 1.0.1 → 1.1.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 +6 -3
- package/lib/dy_api.js +2 -2
- package/lib/dy_client/index.d.ts +15 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +179 -73
- package/lib/utils.d.ts +2 -0
- package/lib/utils.js +29 -0
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
# 安装
|
|
8
8
|
|
|
9
|
+
**建议所有录制器和manager包都升级到最新版,我不会对兼容性做过多考虑**
|
|
10
|
+
|
|
9
11
|
`npm i @bililive-tools/douyu-recorder @bililive-tools/manager`
|
|
10
12
|
|
|
11
13
|
# 使用
|
|
@@ -33,11 +35,12 @@ manager.startCheckLoop();
|
|
|
33
35
|
interface Options {
|
|
34
36
|
channelId: string; // 直播间ID,具体解析见文档,也可自行解析
|
|
35
37
|
quality: number; // 见画质参数
|
|
36
|
-
qualityRetry?: number; //
|
|
38
|
+
qualityRetry?: number; // 画质匹配重试次数, -1为强制匹配画质,0为自动配置,正整数为最大匹配次数
|
|
37
39
|
streamPriorities: []; // 废弃
|
|
38
40
|
sourcePriorities: []; // 废弃
|
|
39
41
|
disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
|
|
40
|
-
segment?: number; //
|
|
42
|
+
segment?: number; // 分段参数,单位分钟
|
|
43
|
+
titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
|
|
41
44
|
disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
|
|
42
45
|
saveGiftDanma?: boolean; // 保存礼物弹幕
|
|
43
46
|
saveSCDanma?: boolean; // 保存高能弹幕
|
|
@@ -64,7 +67,7 @@ interface Options {
|
|
|
64
67
|
```ts
|
|
65
68
|
import { provider } from "@bililive-tools/douyu-recorder";
|
|
66
69
|
|
|
67
|
-
const url = "https://
|
|
70
|
+
const url = "https://www.douyu.com/2140934";
|
|
68
71
|
const { id } = await provider.resolveChannelInfoFromURL(url);
|
|
69
72
|
```
|
|
70
73
|
|
package/lib/dy_api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import { v4 as uuid4 } from "uuid";
|
|
3
2
|
import safeEval from "safe-eval";
|
|
3
|
+
import { uuid } from "./utils.js";
|
|
4
4
|
import queryString from "query-string";
|
|
5
5
|
import { requester } from "./requester.js";
|
|
6
6
|
/**
|
|
@@ -8,7 +8,7 @@ import { requester } from "./requester.js";
|
|
|
8
8
|
*/
|
|
9
9
|
export async function getLiveInfo(opts) {
|
|
10
10
|
const sign = await getSignFn(opts.channelId, opts.rejectSignFnCache);
|
|
11
|
-
const did =
|
|
11
|
+
const did = uuid().replace(/-/g, "");
|
|
12
12
|
const time = Math.ceil(Date.now() / 1000);
|
|
13
13
|
const signedStr = String(sign(opts.channelId, did, time));
|
|
14
14
|
// TODO: 这里类型处理的有点问题,先用 as 顶着
|
package/lib/dy_client/index.d.ts
CHANGED
|
@@ -43,6 +43,20 @@ interface Message$Gift {
|
|
|
43
43
|
bnn: string;
|
|
44
44
|
gfn: string;
|
|
45
45
|
}
|
|
46
|
+
interface Message$ODFBC {
|
|
47
|
+
type: "odfbc";
|
|
48
|
+
uid: string;
|
|
49
|
+
rid: string;
|
|
50
|
+
nick: string;
|
|
51
|
+
price: string;
|
|
52
|
+
}
|
|
53
|
+
interface Message$RNDFBC {
|
|
54
|
+
type: "rndfbc";
|
|
55
|
+
uid: string;
|
|
56
|
+
rid: string;
|
|
57
|
+
nick: string;
|
|
58
|
+
price: string;
|
|
59
|
+
}
|
|
46
60
|
interface Message$CommChatPandora {
|
|
47
61
|
type: "comm_chatmsg";
|
|
48
62
|
rid: string;
|
|
@@ -97,7 +111,7 @@ interface Message$CommChatVoiceDanmu {
|
|
|
97
111
|
};
|
|
98
112
|
}
|
|
99
113
|
type Message$CommChat = Message$CommChatPandora | Message$CommChatVoiceDanmu;
|
|
100
|
-
export type Message = Message$Chat | Message$Gift | Message$CommChat;
|
|
114
|
+
export type Message = Message$Chat | Message$Gift | Message$CommChat | Message$ODFBC | Message$RNDFBC;
|
|
101
115
|
export interface DYClient extends Emitter<{
|
|
102
116
|
message: Message;
|
|
103
117
|
error: unknown;
|
package/lib/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { RecorderProvider } from "@bililive-tools/manager";
|
|
1
|
+
import type { RecorderProvider } from "@bililive-tools/manager";
|
|
2
2
|
export declare const provider: RecorderProvider<Record<string, unknown>>;
|
package/lib/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import mitt from "mitt";
|
|
2
|
-
import {
|
|
2
|
+
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, FFMPEGRecorder, } from "@bililive-tools/manager";
|
|
3
3
|
import { getInfo, getStream } from "./stream.js";
|
|
4
4
|
import { getRoomInfo } from "./dy_api.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ensureFolderExist } from "./utils.js";
|
|
6
6
|
import { createDYClient } from "./dy_client/index.js";
|
|
7
7
|
import { giftMap, colorTab } from "./danma.js";
|
|
8
8
|
import { requester } from "./requester.js";
|
|
@@ -62,12 +62,53 @@ const ffmpegOutputOptions = [
|
|
|
62
62
|
"-min_frag_duration",
|
|
63
63
|
"60000000",
|
|
64
64
|
];
|
|
65
|
-
const checkLiveStatusAndRecord = async function ({ getSavePath,
|
|
66
|
-
|
|
65
|
+
const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
|
|
66
|
+
// 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
|
|
67
|
+
if (this.recordHandle != null) {
|
|
68
|
+
// 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
|
|
69
|
+
if (!isManualStart &&
|
|
70
|
+
this.titleKeywords &&
|
|
71
|
+
typeof this.titleKeywords === "string" &&
|
|
72
|
+
this.titleKeywords.trim()) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
// 每5分钟检查一次标题变化
|
|
75
|
+
const titleCheckInterval = 5 * 60 * 1000; // 5分钟
|
|
76
|
+
// 获取上次检查时间
|
|
77
|
+
const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
|
|
78
|
+
// 如果距离上次检查时间不足指定间隔,则跳过检查
|
|
79
|
+
if (now - lastCheckTime < titleCheckInterval) {
|
|
80
|
+
return this.recordHandle;
|
|
81
|
+
}
|
|
82
|
+
// 更新检查时间
|
|
83
|
+
this.extra.lastTitleCheckTime = now;
|
|
84
|
+
// 获取直播间信息
|
|
85
|
+
const liveInfo = await getInfo(this.channelId);
|
|
86
|
+
const { title } = liveInfo;
|
|
87
|
+
// 检查标题是否包含关键词
|
|
88
|
+
const keywords = this.titleKeywords
|
|
89
|
+
.split(",")
|
|
90
|
+
.map((k) => k.trim())
|
|
91
|
+
.filter((k) => k);
|
|
92
|
+
const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
93
|
+
if (hasTitleKeyword) {
|
|
94
|
+
this.emit("DebugLog", {
|
|
95
|
+
type: "common",
|
|
96
|
+
text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
97
|
+
});
|
|
98
|
+
// 停止录制
|
|
99
|
+
await this.recordHandle.stop("直播间标题包含关键词");
|
|
100
|
+
// 返回 null,停止录制
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 已经在录制中,直接返回
|
|
67
105
|
return this.recordHandle;
|
|
106
|
+
}
|
|
107
|
+
// 获取直播间信息
|
|
68
108
|
const liveInfo = await getInfo(this.channelId);
|
|
109
|
+
const { living, owner, title, liveId } = liveInfo;
|
|
69
110
|
this.liveInfo = liveInfo;
|
|
70
|
-
|
|
111
|
+
this.emit("LiveStart", { liveId });
|
|
71
112
|
if (liveInfo.liveId === banLiveId) {
|
|
72
113
|
this.tempStopIntervalCheck = true;
|
|
73
114
|
}
|
|
@@ -78,13 +119,37 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
78
119
|
return null;
|
|
79
120
|
if (!living)
|
|
80
121
|
return null;
|
|
81
|
-
|
|
122
|
+
// 检查标题是否包含关键词,如果包含则不自动录制
|
|
123
|
+
// 手动开始录制时不检查标题关键词
|
|
124
|
+
if (!isManualStart &&
|
|
125
|
+
this.titleKeywords &&
|
|
126
|
+
typeof this.titleKeywords === "string" &&
|
|
127
|
+
this.titleKeywords.trim()) {
|
|
128
|
+
const keywords = this.titleKeywords
|
|
129
|
+
.split(",")
|
|
130
|
+
.map((k) => k.trim())
|
|
131
|
+
.filter((k) => k);
|
|
132
|
+
const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
133
|
+
if (hasTitleKeyword) {
|
|
134
|
+
this.emit("DebugLog", {
|
|
135
|
+
type: "common",
|
|
136
|
+
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
137
|
+
});
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
82
141
|
let res;
|
|
83
142
|
// TODO: 先不做什么错误处理,就简单包一下预期上会有错误的地方
|
|
84
143
|
try {
|
|
85
|
-
let strictQuality =
|
|
86
|
-
if (qualityRetry
|
|
87
|
-
strictQuality =
|
|
144
|
+
let strictQuality = false;
|
|
145
|
+
if (this.qualityRetry > 0) {
|
|
146
|
+
strictQuality = true;
|
|
147
|
+
}
|
|
148
|
+
if (this.qualityMaxRetry < 0) {
|
|
149
|
+
strictQuality = true;
|
|
150
|
+
}
|
|
151
|
+
if (isManualStart) {
|
|
152
|
+
strictQuality = false;
|
|
88
153
|
}
|
|
89
154
|
res = await getStream({
|
|
90
155
|
channelId: this.channelId,
|
|
@@ -97,14 +162,35 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
97
162
|
this.qualityRetry -= 1;
|
|
98
163
|
throw err;
|
|
99
164
|
}
|
|
165
|
+
this.state = "recording";
|
|
100
166
|
const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
|
|
101
167
|
this.availableStreams = availableStreams.map((s) => s.name);
|
|
102
168
|
this.availableSources = availableSources.map((s) => s.name);
|
|
103
169
|
this.usedStream = stream.name;
|
|
104
170
|
this.usedSource = stream.source;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
171
|
+
const onEnd = (...args) => {
|
|
172
|
+
if (isEnded)
|
|
173
|
+
return;
|
|
174
|
+
isEnded = true;
|
|
175
|
+
this.emit("DebugLog", {
|
|
176
|
+
type: "common",
|
|
177
|
+
text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
|
|
178
|
+
});
|
|
179
|
+
const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
180
|
+
this.recordHandle?.stop(reason);
|
|
181
|
+
};
|
|
182
|
+
let isEnded = false;
|
|
183
|
+
const recorder = new FFMPEGRecorder({
|
|
184
|
+
url: stream.url,
|
|
185
|
+
outputOptions: ffmpegOutputOptions,
|
|
186
|
+
segment: this.segment ?? 0,
|
|
187
|
+
getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
|
|
188
|
+
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
189
|
+
}, onEnd);
|
|
190
|
+
const savePath = getSavePath({
|
|
191
|
+
owner,
|
|
192
|
+
title,
|
|
193
|
+
});
|
|
108
194
|
try {
|
|
109
195
|
ensureFolderExist(savePath);
|
|
110
196
|
}
|
|
@@ -112,25 +198,36 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
112
198
|
this.state = "idle";
|
|
113
199
|
throw err;
|
|
114
200
|
}
|
|
115
|
-
const streamManager = new StreamManager(this, getSavePath, owner, title, savePath, hasSegment);
|
|
116
201
|
const handleVideoCreated = async ({ filename }) => {
|
|
117
|
-
|
|
202
|
+
this.emit("videoFileCreated", { filename });
|
|
203
|
+
const extraDataController = recorder.getExtraDataController();
|
|
118
204
|
extraDataController?.setMeta({
|
|
119
205
|
room_id: this.channelId,
|
|
120
206
|
platform: provider?.id,
|
|
121
207
|
liveStartTimestamp: liveInfo.startTime?.getTime(),
|
|
208
|
+
recordStopTimestamp: Date.now(),
|
|
209
|
+
title: title,
|
|
210
|
+
user_name: owner,
|
|
122
211
|
});
|
|
123
|
-
if (this.saveCover) {
|
|
124
|
-
const coverPath = utils.replaceExtName(filename, ".jpg");
|
|
125
|
-
utils.downloadImage(cover, coverPath);
|
|
126
|
-
}
|
|
127
212
|
};
|
|
128
|
-
|
|
213
|
+
recorder.on("videoFileCreated", handleVideoCreated);
|
|
214
|
+
recorder.on("videoFileCompleted", ({ filename }) => {
|
|
215
|
+
this.emit("videoFileCompleted", { filename });
|
|
216
|
+
});
|
|
217
|
+
recorder.on("DebugLog", (data) => {
|
|
218
|
+
this.emit("DebugLog", data);
|
|
219
|
+
});
|
|
220
|
+
recorder.on("progress", (progress) => {
|
|
221
|
+
if (this.recordHandle) {
|
|
222
|
+
this.recordHandle.progress = progress;
|
|
223
|
+
}
|
|
224
|
+
this.emit("progress", progress);
|
|
225
|
+
});
|
|
129
226
|
const client = createDYClient(Number(this.channelId), {
|
|
130
227
|
notAutoStart: true,
|
|
131
228
|
});
|
|
132
229
|
client.on("message", (msg) => {
|
|
133
|
-
const extraDataController =
|
|
230
|
+
const extraDataController = recorder.getExtraDataController();
|
|
134
231
|
if (!extraDataController)
|
|
135
232
|
return;
|
|
136
233
|
switch (msg.type) {
|
|
@@ -178,7 +275,60 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
178
275
|
this.emit("Message", gift);
|
|
179
276
|
extraDataController.addMessage(gift);
|
|
180
277
|
break;
|
|
181
|
-
|
|
278
|
+
}
|
|
279
|
+
// 开通钻粉
|
|
280
|
+
case "odfbc": {
|
|
281
|
+
if (this.saveGiftDanma === false)
|
|
282
|
+
return;
|
|
283
|
+
const gift = {
|
|
284
|
+
type: "give_gift",
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
name: "钻粉",
|
|
287
|
+
price: Number(msg.price) / 100,
|
|
288
|
+
count: 1,
|
|
289
|
+
color: "#ffffff",
|
|
290
|
+
sender: {
|
|
291
|
+
uid: msg.uid,
|
|
292
|
+
name: msg.nick,
|
|
293
|
+
// avatar: msg.ic,
|
|
294
|
+
// extra: {
|
|
295
|
+
// level: msg.level,
|
|
296
|
+
// },
|
|
297
|
+
},
|
|
298
|
+
// extra: {
|
|
299
|
+
// hits: Number(msg.hits),
|
|
300
|
+
// },
|
|
301
|
+
};
|
|
302
|
+
this.emit("Message", gift);
|
|
303
|
+
extraDataController.addMessage(gift);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
// 续费钻粉
|
|
307
|
+
case "rndfbc": {
|
|
308
|
+
if (this.saveGiftDanma === false)
|
|
309
|
+
return;
|
|
310
|
+
const gift = {
|
|
311
|
+
type: "give_gift",
|
|
312
|
+
timestamp: Date.now(),
|
|
313
|
+
name: "钻粉",
|
|
314
|
+
price: Number(msg.price) / 100,
|
|
315
|
+
count: 1,
|
|
316
|
+
color: "#ffffff",
|
|
317
|
+
sender: {
|
|
318
|
+
uid: msg.uid,
|
|
319
|
+
name: msg.nick,
|
|
320
|
+
// avatar: msg.ic,
|
|
321
|
+
// extra: {
|
|
322
|
+
// level: msg.level,
|
|
323
|
+
// },
|
|
324
|
+
},
|
|
325
|
+
// extra: {
|
|
326
|
+
// hits: Number(msg.hits),
|
|
327
|
+
// },
|
|
328
|
+
};
|
|
329
|
+
this.emit("Message", gift);
|
|
330
|
+
extraDataController.addMessage(gift);
|
|
331
|
+
break;
|
|
182
332
|
}
|
|
183
333
|
case "comm_chatmsg": {
|
|
184
334
|
if (this.saveSCDanma === false)
|
|
@@ -215,70 +365,26 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
215
365
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
216
366
|
client.start();
|
|
217
367
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (isEnded)
|
|
221
|
-
return;
|
|
222
|
-
isEnded = true;
|
|
223
|
-
this.emit("DebugLog", {
|
|
224
|
-
type: "common",
|
|
225
|
-
text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
|
|
226
|
-
});
|
|
227
|
-
const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
228
|
-
this.recordHandle?.stop(reason);
|
|
229
|
-
};
|
|
230
|
-
const isInvalidStream = utils.createInvalidStreamChecker();
|
|
231
|
-
const timeoutChecker = utils.createTimeoutChecker(() => onEnd("ffmpeg timeout"), 10e3);
|
|
232
|
-
const command = createFFMPEGBuilder(stream.url)
|
|
233
|
-
.inputOptions("-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")
|
|
234
|
-
.outputOptions(ffmpegOutputOptions)
|
|
235
|
-
.output(streamManager.videoFilePath)
|
|
236
|
-
.on("start", () => {
|
|
237
|
-
streamManager.handleVideoStarted();
|
|
238
|
-
})
|
|
239
|
-
.on("error", onEnd)
|
|
240
|
-
.on("end", () => onEnd("finished"))
|
|
241
|
-
.on("stderr", async (stderrLine) => {
|
|
242
|
-
assert(typeof stderrLine === "string");
|
|
243
|
-
if (utils.isFfmpegStartSegment(stderrLine)) {
|
|
244
|
-
await streamManager.handleVideoStarted(stderrLine);
|
|
245
|
-
}
|
|
246
|
-
// TODO:解析时间
|
|
247
|
-
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
248
|
-
if (isInvalidStream(stderrLine)) {
|
|
249
|
-
onEnd("invalid stream");
|
|
250
|
-
}
|
|
251
|
-
})
|
|
252
|
-
.on("stderr", timeoutChecker.update);
|
|
253
|
-
if (hasSegment) {
|
|
254
|
-
command.outputOptions("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
|
|
255
|
-
}
|
|
256
|
-
const ffmpegArgs = command._getArguments();
|
|
257
|
-
command.run();
|
|
368
|
+
const ffmpegArgs = recorder.getArguments();
|
|
369
|
+
recorder.run();
|
|
258
370
|
// TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
|
|
259
371
|
const stop = utils.singleton(async (reason) => {
|
|
260
372
|
if (!this.recordHandle)
|
|
261
373
|
return;
|
|
262
374
|
this.state = "stopping-record";
|
|
263
|
-
|
|
264
|
-
timeoutChecker.stop();
|
|
375
|
+
client.stop();
|
|
265
376
|
try {
|
|
266
|
-
|
|
267
|
-
command.ffmpegProc?.stdin?.write("q");
|
|
268
|
-
// TODO: 这里可能会有内存泄露,因为事件还没清,之后再检查下看看。
|
|
269
|
-
client.stop();
|
|
377
|
+
await recorder.stop();
|
|
270
378
|
}
|
|
271
379
|
catch (err) {
|
|
272
|
-
|
|
273
|
-
|
|
380
|
+
this.emit("DebugLog", {
|
|
381
|
+
type: "common",
|
|
382
|
+
text: `stop ffmpeg error: ${String(err)}`,
|
|
383
|
+
});
|
|
274
384
|
}
|
|
275
385
|
this.usedStream = undefined;
|
|
276
386
|
this.usedSource = undefined;
|
|
277
|
-
// TODO: other codes
|
|
278
|
-
// TODO: emit update event
|
|
279
|
-
await streamManager.handleVideoCompleted();
|
|
280
387
|
this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
|
|
281
|
-
this.off("videoFileCreated", handleVideoCreated);
|
|
282
388
|
this.recordHandle = undefined;
|
|
283
389
|
this.liveInfo = undefined;
|
|
284
390
|
this.state = "idle";
|
package/lib/utils.d.ts
CHANGED
|
@@ -17,3 +17,5 @@
|
|
|
17
17
|
export declare function getValuesFromArrayLikeFlexSpaceBetween<T>(array: T[], columnCount: number): T[];
|
|
18
18
|
export declare function ensureFolderExist(fileOrFolderPath: string): void;
|
|
19
19
|
export declare function assert(assertion: unknown, msg?: string): asserts assertion;
|
|
20
|
+
export declare const uuid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
21
|
+
export declare function createInvalidStreamChecker(): (ffmpegLogLine: string) => boolean;
|
package/lib/utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import { range } from "lodash-es";
|
|
4
5
|
/**
|
|
5
6
|
* 从数组中按照特定算法提取一些值(允许同个索引重复提取)。
|
|
@@ -50,3 +51,31 @@ export function assert(assertion, msg) {
|
|
|
50
51
|
throw new Error(msg);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
export const uuid = () => {
|
|
55
|
+
return crypto.randomUUID();
|
|
56
|
+
};
|
|
57
|
+
export function createInvalidStreamChecker() {
|
|
58
|
+
let prevFrame = 0;
|
|
59
|
+
let frameUnchangedCount = 0;
|
|
60
|
+
return (ffmpegLogLine) => {
|
|
61
|
+
const streamInfo = ffmpegLogLine.match(/frame=\s*(\d+) fps=.*? q=.*? size=.*? time=.*? bitrate=.*? speed=.*?/);
|
|
62
|
+
if (streamInfo != null) {
|
|
63
|
+
const [, frameText] = streamInfo;
|
|
64
|
+
const frame = Number(frameText);
|
|
65
|
+
if (frame === prevFrame) {
|
|
66
|
+
if (++frameUnchangedCount >= 15) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
prevFrame = frame;
|
|
72
|
+
frameUnchangedCount = 0;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (ffmpegLogLine.includes("HTTP error 404 Not Found")) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/douyu-recorder",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "bililive-tools douyu recorder implemention",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -41,14 +41,11 @@
|
|
|
41
41
|
"lodash-es": "^4.17.21",
|
|
42
42
|
"axios": "^1.7.8",
|
|
43
43
|
"douyu-api": "^0.1.0",
|
|
44
|
-
"@bililive-tools/manager": "1.0
|
|
44
|
+
"@bililive-tools/manager": "^1.1.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/ws": "^8.5.13"
|
|
48
48
|
},
|
|
49
|
-
"peerDependencies": {
|
|
50
|
-
"@bililive-tools/manager": "*"
|
|
51
|
-
},
|
|
52
49
|
"optionalDependencies": {
|
|
53
50
|
"bufferutil": "^4.0.8",
|
|
54
51
|
"utf-8-validate": "^6.0.5"
|