@bililive-tools/douyu-recorder 1.0.2 → 1.2.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 +22 -3
- package/lib/dy_client/index.d.ts +15 -1
- package/lib/dy_client/index.js +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +181 -73
- package/lib/stream.d.ts +1 -0
- package/lib/stream.js +1 -0
- package/lib/utils.d.ts +1 -0
- package/lib/utils.js +25 -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,15 +35,18 @@ manager.startCheckLoop();
|
|
|
33
35
|
interface Options {
|
|
34
36
|
channelId: string; // 直播间ID,具体解析见文档,也可自行解析
|
|
35
37
|
quality: number; // 见画质参数
|
|
36
|
-
qualityRetry?: number; //
|
|
38
|
+
qualityRetry?: number; // 画质匹配重试次数, -1为强制匹配画质,0为自动配置,正整数为最大匹配次数
|
|
39
|
+
source?: string; // 指定 cdn,见文档,不传为自动
|
|
37
40
|
streamPriorities: []; // 废弃
|
|
38
41
|
sourcePriorities: []; // 废弃
|
|
39
42
|
disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
|
|
40
|
-
segment?: number; //
|
|
43
|
+
segment?: number; // 分段参数,单位分钟
|
|
44
|
+
titleKeywords?: string; // 禁止录制的标题关键字,英文逗号分开多个
|
|
41
45
|
disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
|
|
42
46
|
saveGiftDanma?: boolean; // 保存礼物弹幕
|
|
43
47
|
saveSCDanma?: boolean; // 保存高能弹幕
|
|
44
48
|
saveCover?: boolean; // 保存封面
|
|
49
|
+
videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
|
|
45
50
|
}
|
|
46
51
|
```
|
|
47
52
|
|
|
@@ -64,10 +69,24 @@ interface Options {
|
|
|
64
69
|
```ts
|
|
65
70
|
import { provider } from "@bililive-tools/douyu-recorder";
|
|
66
71
|
|
|
67
|
-
const url = "https://
|
|
72
|
+
const url = "https://www.douyu.com/2140934";
|
|
68
73
|
const { id } = await provider.resolveChannelInfoFromURL(url);
|
|
69
74
|
```
|
|
70
75
|
|
|
76
|
+
## cdn
|
|
77
|
+
|
|
78
|
+
如果有更多线路或者错误,请发issue
|
|
79
|
+
|
|
80
|
+
| 线路 | 值 |
|
|
81
|
+
| ------ | --------- |
|
|
82
|
+
| 自动 | auto |
|
|
83
|
+
| 线路1 | scdnctshh |
|
|
84
|
+
| 线路4 | tctc-h5 |
|
|
85
|
+
| 线路5 | tct-h5 |
|
|
86
|
+
| 线路6 | ali-h5 |
|
|
87
|
+
| 线路7 | hw-h5 |
|
|
88
|
+
| 线路13 | hs-h5 |
|
|
89
|
+
|
|
71
90
|
# 协议
|
|
72
91
|
|
|
73
92
|
与原项目保存一致为 LGPL
|
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/dy_client/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { BufferCoder } from "./buffer_coder.js";
|
|
|
9
9
|
import { STT } from "./stt.js";
|
|
10
10
|
export function createDYClient(channelId, opts = {}) {
|
|
11
11
|
let ws = null;
|
|
12
|
-
let maxRetry =
|
|
12
|
+
let maxRetry = 10;
|
|
13
13
|
let coder = new BufferCoder();
|
|
14
14
|
let heartbeatTimer = null;
|
|
15
15
|
const send = (message) => ws?.send(coder.encode(STT.serialize(message)));
|
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,52 @@ 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
|
-
const { living, owner, title, cover } = liveInfo;
|
|
71
111
|
if (liveInfo.liveId === banLiveId) {
|
|
72
112
|
this.tempStopIntervalCheck = true;
|
|
73
113
|
}
|
|
@@ -78,17 +118,43 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
78
118
|
return null;
|
|
79
119
|
if (!living)
|
|
80
120
|
return null;
|
|
81
|
-
|
|
121
|
+
// 检查标题是否包含关键词,如果包含则不自动录制
|
|
122
|
+
// 手动开始录制时不检查标题关键词
|
|
123
|
+
if (!isManualStart &&
|
|
124
|
+
this.titleKeywords &&
|
|
125
|
+
typeof this.titleKeywords === "string" &&
|
|
126
|
+
this.titleKeywords.trim()) {
|
|
127
|
+
const keywords = this.titleKeywords
|
|
128
|
+
.split(",")
|
|
129
|
+
.map((k) => k.trim())
|
|
130
|
+
.filter((k) => k);
|
|
131
|
+
const hasTitleKeyword = keywords.some((keyword) => title.toLowerCase().includes(keyword.toLowerCase()));
|
|
132
|
+
if (hasTitleKeyword) {
|
|
133
|
+
this.emit("DebugLog", {
|
|
134
|
+
type: "common",
|
|
135
|
+
text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
|
|
136
|
+
});
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.emit("LiveStart", { liveId });
|
|
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,
|
|
91
156
|
quality: this.quality,
|
|
157
|
+
source: this.source,
|
|
92
158
|
strictQuality,
|
|
93
159
|
});
|
|
94
160
|
}
|
|
@@ -97,14 +163,36 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
97
163
|
this.qualityRetry -= 1;
|
|
98
164
|
throw err;
|
|
99
165
|
}
|
|
166
|
+
this.state = "recording";
|
|
100
167
|
const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
|
|
101
168
|
this.availableStreams = availableStreams.map((s) => s.name);
|
|
102
169
|
this.availableSources = availableSources.map((s) => s.name);
|
|
103
170
|
this.usedStream = stream.name;
|
|
104
171
|
this.usedSource = stream.source;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
172
|
+
const onEnd = (...args) => {
|
|
173
|
+
if (isEnded)
|
|
174
|
+
return;
|
|
175
|
+
isEnded = true;
|
|
176
|
+
this.emit("DebugLog", {
|
|
177
|
+
type: "common",
|
|
178
|
+
text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
|
|
179
|
+
});
|
|
180
|
+
const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
181
|
+
this.recordHandle?.stop(reason);
|
|
182
|
+
};
|
|
183
|
+
let isEnded = false;
|
|
184
|
+
const recorder = new FFMPEGRecorder({
|
|
185
|
+
url: stream.url,
|
|
186
|
+
outputOptions: ffmpegOutputOptions,
|
|
187
|
+
segment: this.segment ?? 0,
|
|
188
|
+
getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
|
|
189
|
+
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
190
|
+
videoFormat: this.videoFormat ?? "auto",
|
|
191
|
+
}, onEnd);
|
|
192
|
+
const savePath = getSavePath({
|
|
193
|
+
owner,
|
|
194
|
+
title,
|
|
195
|
+
});
|
|
108
196
|
try {
|
|
109
197
|
ensureFolderExist(savePath);
|
|
110
198
|
}
|
|
@@ -112,25 +200,36 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
112
200
|
this.state = "idle";
|
|
113
201
|
throw err;
|
|
114
202
|
}
|
|
115
|
-
const streamManager = new StreamManager(this, getSavePath, owner, title, savePath, hasSegment);
|
|
116
203
|
const handleVideoCreated = async ({ filename }) => {
|
|
117
|
-
|
|
204
|
+
this.emit("videoFileCreated", { filename });
|
|
205
|
+
const extraDataController = recorder.getExtraDataController();
|
|
118
206
|
extraDataController?.setMeta({
|
|
119
207
|
room_id: this.channelId,
|
|
120
208
|
platform: provider?.id,
|
|
121
209
|
liveStartTimestamp: liveInfo.startTime?.getTime(),
|
|
210
|
+
recordStopTimestamp: Date.now(),
|
|
211
|
+
title: title,
|
|
212
|
+
user_name: owner,
|
|
122
213
|
});
|
|
123
|
-
if (this.saveCover) {
|
|
124
|
-
const coverPath = utils.replaceExtName(filename, ".jpg");
|
|
125
|
-
utils.downloadImage(cover, coverPath);
|
|
126
|
-
}
|
|
127
214
|
};
|
|
128
|
-
|
|
215
|
+
recorder.on("videoFileCreated", handleVideoCreated);
|
|
216
|
+
recorder.on("videoFileCompleted", ({ filename }) => {
|
|
217
|
+
this.emit("videoFileCompleted", { filename });
|
|
218
|
+
});
|
|
219
|
+
recorder.on("DebugLog", (data) => {
|
|
220
|
+
this.emit("DebugLog", data);
|
|
221
|
+
});
|
|
222
|
+
recorder.on("progress", (progress) => {
|
|
223
|
+
if (this.recordHandle) {
|
|
224
|
+
this.recordHandle.progress = progress;
|
|
225
|
+
}
|
|
226
|
+
this.emit("progress", progress);
|
|
227
|
+
});
|
|
129
228
|
const client = createDYClient(Number(this.channelId), {
|
|
130
229
|
notAutoStart: true,
|
|
131
230
|
});
|
|
132
231
|
client.on("message", (msg) => {
|
|
133
|
-
const extraDataController =
|
|
232
|
+
const extraDataController = recorder.getExtraDataController();
|
|
134
233
|
if (!extraDataController)
|
|
135
234
|
return;
|
|
136
235
|
switch (msg.type) {
|
|
@@ -178,7 +277,60 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
178
277
|
this.emit("Message", gift);
|
|
179
278
|
extraDataController.addMessage(gift);
|
|
180
279
|
break;
|
|
181
|
-
|
|
280
|
+
}
|
|
281
|
+
// 开通钻粉
|
|
282
|
+
case "odfbc": {
|
|
283
|
+
if (this.saveGiftDanma === false)
|
|
284
|
+
return;
|
|
285
|
+
const gift = {
|
|
286
|
+
type: "give_gift",
|
|
287
|
+
timestamp: Date.now(),
|
|
288
|
+
name: "钻粉",
|
|
289
|
+
price: Number(msg.price) / 100,
|
|
290
|
+
count: 1,
|
|
291
|
+
color: "#ffffff",
|
|
292
|
+
sender: {
|
|
293
|
+
uid: msg.uid,
|
|
294
|
+
name: msg.nick,
|
|
295
|
+
// avatar: msg.ic,
|
|
296
|
+
// extra: {
|
|
297
|
+
// level: msg.level,
|
|
298
|
+
// },
|
|
299
|
+
},
|
|
300
|
+
// extra: {
|
|
301
|
+
// hits: Number(msg.hits),
|
|
302
|
+
// },
|
|
303
|
+
};
|
|
304
|
+
this.emit("Message", gift);
|
|
305
|
+
extraDataController.addMessage(gift);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
// 续费钻粉
|
|
309
|
+
case "rndfbc": {
|
|
310
|
+
if (this.saveGiftDanma === false)
|
|
311
|
+
return;
|
|
312
|
+
const gift = {
|
|
313
|
+
type: "give_gift",
|
|
314
|
+
timestamp: Date.now(),
|
|
315
|
+
name: "钻粉",
|
|
316
|
+
price: Number(msg.price) / 100,
|
|
317
|
+
count: 1,
|
|
318
|
+
color: "#ffffff",
|
|
319
|
+
sender: {
|
|
320
|
+
uid: msg.uid,
|
|
321
|
+
name: msg.nick,
|
|
322
|
+
// avatar: msg.ic,
|
|
323
|
+
// extra: {
|
|
324
|
+
// level: msg.level,
|
|
325
|
+
// },
|
|
326
|
+
},
|
|
327
|
+
// extra: {
|
|
328
|
+
// hits: Number(msg.hits),
|
|
329
|
+
// },
|
|
330
|
+
};
|
|
331
|
+
this.emit("Message", gift);
|
|
332
|
+
extraDataController.addMessage(gift);
|
|
333
|
+
break;
|
|
182
334
|
}
|
|
183
335
|
case "comm_chatmsg": {
|
|
184
336
|
if (this.saveSCDanma === false)
|
|
@@ -215,70 +367,26 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
215
367
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
216
368
|
client.start();
|
|
217
369
|
}
|
|
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();
|
|
370
|
+
const ffmpegArgs = recorder.getArguments();
|
|
371
|
+
recorder.run();
|
|
258
372
|
// TODO: 需要一个机制防止空录制,比如检查文件的大小变化、ffmpeg 的输出、直播状态等
|
|
259
373
|
const stop = utils.singleton(async (reason) => {
|
|
260
374
|
if (!this.recordHandle)
|
|
261
375
|
return;
|
|
262
376
|
this.state = "stopping-record";
|
|
263
|
-
|
|
264
|
-
timeoutChecker.stop();
|
|
377
|
+
client.stop();
|
|
265
378
|
try {
|
|
266
|
-
|
|
267
|
-
command.ffmpegProc?.stdin?.write("q");
|
|
268
|
-
// TODO: 这里可能会有内存泄露,因为事件还没清,之后再检查下看看。
|
|
269
|
-
client.stop();
|
|
379
|
+
await recorder.stop();
|
|
270
380
|
}
|
|
271
381
|
catch (err) {
|
|
272
|
-
|
|
273
|
-
|
|
382
|
+
this.emit("DebugLog", {
|
|
383
|
+
type: "common",
|
|
384
|
+
text: `stop ffmpeg error: ${String(err)}`,
|
|
385
|
+
});
|
|
274
386
|
}
|
|
275
387
|
this.usedStream = undefined;
|
|
276
388
|
this.usedSource = undefined;
|
|
277
|
-
// TODO: other codes
|
|
278
|
-
// TODO: emit update event
|
|
279
|
-
await streamManager.handleVideoCompleted();
|
|
280
389
|
this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
|
|
281
|
-
this.off("videoFileCreated", handleVideoCreated);
|
|
282
390
|
this.recordHandle = undefined;
|
|
283
391
|
this.liveInfo = undefined;
|
|
284
392
|
this.state = "idle";
|
package/lib/stream.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare function getInfo(channelId: string): Promise<{
|
|
|
11
11
|
export declare function getStream(opts: Pick<Recorder, "channelId" | "quality"> & {
|
|
12
12
|
rejectCache?: boolean;
|
|
13
13
|
strictQuality?: boolean;
|
|
14
|
+
source?: string;
|
|
14
15
|
}): Promise<{
|
|
15
16
|
living: true;
|
|
16
17
|
sources: import("./dy_api.js").SourceProfile[];
|
package/lib/stream.js
CHANGED
|
@@ -47,6 +47,7 @@ export async function getStream(opts) {
|
|
|
47
47
|
let liveInfo = await getLiveInfo({
|
|
48
48
|
channelId: opts.channelId,
|
|
49
49
|
rate: qn,
|
|
50
|
+
cdn: opts.source === "auto" ? undefined : opts.source,
|
|
50
51
|
});
|
|
51
52
|
if (!liveInfo.living)
|
|
52
53
|
throw new Error("It must be called getStream when living");
|
package/lib/utils.d.ts
CHANGED
|
@@ -18,3 +18,4 @@ export declare function getValuesFromArrayLikeFlexSpaceBetween<T>(array: T[], co
|
|
|
18
18
|
export declare function ensureFolderExist(fileOrFolderPath: string): void;
|
|
19
19
|
export declare function assert(assertion: unknown, msg?: string): asserts assertion;
|
|
20
20
|
export declare const uuid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
21
|
+
export declare function createInvalidStreamChecker(): (ffmpegLogLine: string) => boolean;
|
package/lib/utils.js
CHANGED
|
@@ -54,3 +54,28 @@ export function assert(assertion, msg) {
|
|
|
54
54
|
export const uuid = () => {
|
|
55
55
|
return crypto.randomUUID();
|
|
56
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.2.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.2.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"
|