@bililive-tools/bilibili-recorder 1.0.1 → 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 +5 -2
- package/lib/bilibili_api.d.ts +2 -1
- package/lib/danma.d.ts +12 -0
- package/lib/danma.js +124 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +96 -211
- package/lib/stream.d.ts +3 -2
- package/lib/stream.js +17 -6
- package/lib/utils.d.ts +1 -1
- package/lib/utils.js +2 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
# 安装
|
|
8
8
|
|
|
9
|
+
**建议所有录制器和manager包都升级到最新版,我不会对兼容性做过多考虑**
|
|
10
|
+
|
|
9
11
|
`npm i @bililive-tools/bilibili-recorder @bililive-tools/manager`
|
|
10
12
|
|
|
11
13
|
# 使用
|
|
@@ -33,11 +35,11 @@ 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; // 分段参数,单位分钟
|
|
41
43
|
disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
|
|
42
44
|
saveGiftDanma?: boolean; // 保存礼物弹幕,包含舰长
|
|
43
45
|
saveSCDanma?: boolean; // 保存SC
|
|
@@ -48,6 +50,7 @@ interface Options {
|
|
|
48
50
|
codecName?: CodecName; // 见 CodecName 参数
|
|
49
51
|
useM3U8Proxy?: boolean; // 是否使用m3u8代理,由于hls及fmp4存在一个小时超时时间,需自行实现代理避免
|
|
50
52
|
m3u8ProxyUrl?: string; // 代理链接,文档待补充
|
|
53
|
+
videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
|
|
51
54
|
}
|
|
52
55
|
```
|
|
53
56
|
|
package/lib/bilibili_api.d.ts
CHANGED
|
@@ -49,6 +49,7 @@ export declare function getRoomBaseInfo<RoomId extends number>(roomId: RoomId):
|
|
|
49
49
|
live_time: string;
|
|
50
50
|
live_status: LiveStatus;
|
|
51
51
|
cover: string;
|
|
52
|
+
is_encrypted: boolean;
|
|
52
53
|
}>>;
|
|
53
54
|
export declare function getPlayURL(roomId: number, opts?: {
|
|
54
55
|
useHLS?: boolean;
|
|
@@ -85,7 +86,7 @@ export declare function getRoomPlayInfo(roomIdOrShortId: number, opts?: {
|
|
|
85
86
|
};
|
|
86
87
|
}>;
|
|
87
88
|
export interface ProtocolInfo {
|
|
88
|
-
protocol_name:
|
|
89
|
+
protocol_name: "http_stream" | "http_hls";
|
|
89
90
|
format: FormatInfo[];
|
|
90
91
|
}
|
|
91
92
|
export interface FormatInfo {
|
package/lib/danma.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
declare class DanmaClient extends EventEmitter {
|
|
3
|
+
private client;
|
|
4
|
+
private roomId;
|
|
5
|
+
private auth;
|
|
6
|
+
private uid;
|
|
7
|
+
private retryCount;
|
|
8
|
+
constructor(roomId: number, auth: string | undefined, uid: number | undefined);
|
|
9
|
+
start(): void;
|
|
10
|
+
stop(): void;
|
|
11
|
+
}
|
|
12
|
+
export default DanmaClient;
|
package/lib/danma.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { startListen } from "./blive-message-listener/index.js";
|
|
3
|
+
class DanmaClient extends EventEmitter {
|
|
4
|
+
client = null;
|
|
5
|
+
roomId;
|
|
6
|
+
auth;
|
|
7
|
+
uid;
|
|
8
|
+
retryCount = 10;
|
|
9
|
+
constructor(roomId, auth, uid) {
|
|
10
|
+
super();
|
|
11
|
+
this.roomId = roomId;
|
|
12
|
+
this.auth = auth;
|
|
13
|
+
this.uid = uid;
|
|
14
|
+
}
|
|
15
|
+
start() {
|
|
16
|
+
const handler = {
|
|
17
|
+
onIncomeDanmu: (msg) => {
|
|
18
|
+
let content = msg.body.content;
|
|
19
|
+
content = content.replace(/(^\s*)|(\s*$)/g, "").replace(/[\r\n]/g, "");
|
|
20
|
+
if (content === "")
|
|
21
|
+
return;
|
|
22
|
+
const comment = {
|
|
23
|
+
type: "comment",
|
|
24
|
+
timestamp: msg.timestamp,
|
|
25
|
+
text: content,
|
|
26
|
+
color: msg.body.content_color,
|
|
27
|
+
mode: msg.body.type,
|
|
28
|
+
sender: {
|
|
29
|
+
uid: String(msg.body.user.uid),
|
|
30
|
+
name: msg.body.user.uname,
|
|
31
|
+
avatar: msg.body.user.face,
|
|
32
|
+
extra: {
|
|
33
|
+
badgeName: msg.body.user.badge?.name,
|
|
34
|
+
badgeLevel: msg.body.user.badge?.level,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
this.emit("Message", comment);
|
|
39
|
+
},
|
|
40
|
+
onIncomeSuperChat: (msg) => {
|
|
41
|
+
const content = msg.body.content.replaceAll(/[\r\n]/g, "");
|
|
42
|
+
const comment = {
|
|
43
|
+
type: "super_chat",
|
|
44
|
+
timestamp: msg.timestamp,
|
|
45
|
+
text: content,
|
|
46
|
+
price: msg.body.price,
|
|
47
|
+
sender: {
|
|
48
|
+
uid: String(msg.body.user.uid),
|
|
49
|
+
name: msg.body.user.uname,
|
|
50
|
+
avatar: msg.body.user.face,
|
|
51
|
+
extra: {
|
|
52
|
+
badgeName: msg.body.user.badge?.name,
|
|
53
|
+
badgeLevel: msg.body.user.badge?.level,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
this.emit("Message", comment);
|
|
58
|
+
},
|
|
59
|
+
onGuardBuy: (msg) => {
|
|
60
|
+
const gift = {
|
|
61
|
+
type: "guard",
|
|
62
|
+
timestamp: msg.timestamp,
|
|
63
|
+
name: msg.body.gift_name,
|
|
64
|
+
price: msg.body.price,
|
|
65
|
+
count: 1,
|
|
66
|
+
level: msg.body.guard_level,
|
|
67
|
+
sender: {
|
|
68
|
+
uid: String(msg.body.user.uid),
|
|
69
|
+
name: msg.body.user.uname,
|
|
70
|
+
avatar: msg.body.user.face,
|
|
71
|
+
extra: {
|
|
72
|
+
badgeName: msg.body.user.badge?.name,
|
|
73
|
+
badgeLevel: msg.body.user.badge?.level,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
this.emit("Message", gift);
|
|
78
|
+
},
|
|
79
|
+
onGift: (msg) => {
|
|
80
|
+
const gift = {
|
|
81
|
+
type: "give_gift",
|
|
82
|
+
timestamp: msg.timestamp,
|
|
83
|
+
name: msg.body.gift_name,
|
|
84
|
+
count: msg.body.amount,
|
|
85
|
+
price: msg.body.coin_type === "silver" ? 0 : msg.body.price / 1000,
|
|
86
|
+
sender: {
|
|
87
|
+
uid: String(msg.body.user.uid),
|
|
88
|
+
name: msg.body.user.uname,
|
|
89
|
+
avatar: msg.body.user.face,
|
|
90
|
+
extra: {
|
|
91
|
+
badgeName: msg.body.user.badge?.name,
|
|
92
|
+
badgeLevel: msg.body.user.badge?.level,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
extra: {
|
|
96
|
+
hits: msg.body.combo?.combo_num,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
this.emit("Message", gift);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
this.client = startListen(this.roomId, handler, {
|
|
103
|
+
ws: {
|
|
104
|
+
headers: {
|
|
105
|
+
Cookie: this.auth ?? "",
|
|
106
|
+
},
|
|
107
|
+
uid: this.uid ?? 0,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
this.client.live.on("error", (err) => {
|
|
111
|
+
this.retryCount -= 1;
|
|
112
|
+
if (this.retryCount > 0) {
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
this.client && this.client.reconnect();
|
|
115
|
+
}, 2000);
|
|
116
|
+
}
|
|
117
|
+
this.emit("error", err);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
stop() {
|
|
121
|
+
this.client?.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export default DanmaClient;
|
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,9 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import mitt from "mitt";
|
|
3
|
-
import {
|
|
3
|
+
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, FFMPEGRecorder, } from "@bililive-tools/manager";
|
|
4
4
|
import { getInfo, getStream, getLiveStatus, getStrictStream } from "./stream.js";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { ensureFolderExist } from "./utils.js";
|
|
6
|
+
import DanmaClient from "./danma.js";
|
|
7
7
|
function createRecorder(opts) {
|
|
8
8
|
// 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
|
|
9
9
|
// 此标志来操作这个对象的地方,不然会跳过 proxy 的拦截。
|
|
@@ -68,26 +68,30 @@ const ffmpegOutputOptions = [
|
|
|
68
68
|
"faststart+frag_keyframe+empty_moov",
|
|
69
69
|
"-min_frag_duration",
|
|
70
70
|
"60000000",
|
|
71
|
+
];
|
|
72
|
+
const ffmpegInputOptions = [
|
|
71
73
|
"-reconnect",
|
|
72
74
|
"1",
|
|
73
75
|
"-reconnect_streamed",
|
|
74
76
|
"1",
|
|
75
77
|
"-reconnect_delay_max",
|
|
76
|
-
"
|
|
78
|
+
"10",
|
|
77
79
|
"-rw_timeout",
|
|
78
|
-
"
|
|
80
|
+
"15000000",
|
|
81
|
+
"-headers",
|
|
82
|
+
"Referer:https://live.bilibili.com/",
|
|
79
83
|
];
|
|
80
|
-
const checkLiveStatusAndRecord = async function ({ getSavePath,
|
|
84
|
+
const checkLiveStatusAndRecord = async function ({ getSavePath, isManualStart, banLiveId, }) {
|
|
81
85
|
if (this.recordHandle != null)
|
|
82
86
|
return this.recordHandle;
|
|
83
|
-
const { living, liveId } = await getLiveStatus(this.channelId);
|
|
87
|
+
const { living, liveId, owner: _owner, title: _title } = await getLiveStatus(this.channelId);
|
|
84
88
|
this.liveInfo = {
|
|
85
89
|
living,
|
|
86
|
-
owner:
|
|
87
|
-
title:
|
|
90
|
+
owner: _owner,
|
|
91
|
+
title: _title,
|
|
88
92
|
avatar: "",
|
|
89
93
|
cover: "",
|
|
90
|
-
liveId:
|
|
94
|
+
liveId: liveId,
|
|
91
95
|
};
|
|
92
96
|
if (liveId === banLiveId) {
|
|
93
97
|
this.tempStopIntervalCheck = true;
|
|
@@ -99,16 +103,22 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
99
103
|
return null;
|
|
100
104
|
if (!living)
|
|
101
105
|
return null;
|
|
106
|
+
this.emit("LiveStart", { liveId });
|
|
102
107
|
const liveInfo = await getInfo(this.channelId);
|
|
103
|
-
const { owner, title, roomId
|
|
108
|
+
const { owner, title, roomId } = liveInfo;
|
|
104
109
|
this.liveInfo = liveInfo;
|
|
105
|
-
this.state = "recording";
|
|
106
110
|
let res;
|
|
107
111
|
// TODO: 先不做什么错误处理,就简单包一下预期上会有错误的地方
|
|
108
112
|
try {
|
|
109
|
-
let strictQuality =
|
|
110
|
-
if (qualityRetry
|
|
111
|
-
strictQuality =
|
|
113
|
+
let strictQuality = false;
|
|
114
|
+
if (this.qualityRetry > 0) {
|
|
115
|
+
strictQuality = true;
|
|
116
|
+
}
|
|
117
|
+
if (this.qualityMaxRetry < 0) {
|
|
118
|
+
strictQuality = true;
|
|
119
|
+
}
|
|
120
|
+
if (isManualStart) {
|
|
121
|
+
strictQuality = false;
|
|
112
122
|
}
|
|
113
123
|
res = await getStream({
|
|
114
124
|
channelId: this.channelId,
|
|
@@ -124,6 +134,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
124
134
|
this.state = "idle";
|
|
125
135
|
throw err;
|
|
126
136
|
}
|
|
137
|
+
this.state = "recording";
|
|
127
138
|
const { streamOptions, currentStream: stream, sources: availableSources, streams: availableStreams, } = res;
|
|
128
139
|
this.availableStreams = availableStreams.map((s) => s.desc);
|
|
129
140
|
this.availableSources = availableSources.map((s) => s.name);
|
|
@@ -145,16 +156,41 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
145
156
|
format_name: streamOptions.format_name,
|
|
146
157
|
codec_name: streamOptions.codec_name,
|
|
147
158
|
});
|
|
148
|
-
|
|
159
|
+
if (this.recordHandle) {
|
|
160
|
+
this.recordHandle.url = url;
|
|
161
|
+
}
|
|
149
162
|
this.emit("DebugLog", {
|
|
150
163
|
type: "common",
|
|
151
164
|
text: `update stream: ${url}`,
|
|
152
165
|
});
|
|
153
166
|
}, 50 * 60 * 1000);
|
|
154
167
|
}
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
168
|
+
let isEnded = false;
|
|
169
|
+
const onEnd = (...args) => {
|
|
170
|
+
if (isEnded)
|
|
171
|
+
return;
|
|
172
|
+
isEnded = true;
|
|
173
|
+
this.emit("DebugLog", {
|
|
174
|
+
type: "common",
|
|
175
|
+
text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
|
|
176
|
+
});
|
|
177
|
+
const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
178
|
+
this.recordHandle?.stop(reason);
|
|
179
|
+
};
|
|
180
|
+
const recorder = new FFMPEGRecorder({
|
|
181
|
+
url: url,
|
|
182
|
+
outputOptions: ffmpegOutputOptions,
|
|
183
|
+
inputOptions: ffmpegInputOptions,
|
|
184
|
+
segment: this.segment ?? 0,
|
|
185
|
+
getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
|
|
186
|
+
isHls: streamOptions.protocol_name === "http_hls",
|
|
187
|
+
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
188
|
+
videoFormat: this.videoFormat,
|
|
189
|
+
}, onEnd);
|
|
190
|
+
const savePath = getSavePath({
|
|
191
|
+
owner,
|
|
192
|
+
title,
|
|
193
|
+
});
|
|
158
194
|
try {
|
|
159
195
|
ensureFolderExist(savePath);
|
|
160
196
|
}
|
|
@@ -162,221 +198,70 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, qualityRetry, ba
|
|
|
162
198
|
this.state = "idle";
|
|
163
199
|
throw err;
|
|
164
200
|
}
|
|
165
|
-
const streamManager = new StreamManager(this, getSavePath, owner, title, savePath, hasSegment);
|
|
166
201
|
const handleVideoCreated = async ({ filename }) => {
|
|
167
|
-
|
|
202
|
+
this.emit("videoFileCreated", { filename });
|
|
203
|
+
const extraDataController = recorder.getExtraDataController();
|
|
168
204
|
extraDataController?.setMeta({
|
|
169
205
|
room_id: String(roomId),
|
|
170
206
|
platform: provider?.id,
|
|
171
207
|
liveStartTimestamp: liveInfo.startTime?.getTime(),
|
|
208
|
+
recordStopTimestamp: Date.now(),
|
|
209
|
+
title: title,
|
|
210
|
+
user_name: owner,
|
|
172
211
|
});
|
|
173
|
-
if (this.saveCover) {
|
|
174
|
-
const coverPath = utils.replaceExtName(filename, ".jpg");
|
|
175
|
-
utils.downloadImage(cover, coverPath);
|
|
176
|
-
}
|
|
177
212
|
};
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
});
|
|
226
|
+
let danmaClient = new DanmaClient(roomId, this.auth, this.uid);
|
|
180
227
|
if (!this.disableProvideCommentsWhenRecording) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const comment = {
|
|
192
|
-
type: "comment",
|
|
193
|
-
timestamp: msg.timestamp,
|
|
194
|
-
text: content,
|
|
195
|
-
color: msg.body.content_color,
|
|
196
|
-
mode: msg.body.type,
|
|
197
|
-
sender: {
|
|
198
|
-
uid: String(msg.body.user.uid),
|
|
199
|
-
name: msg.body.user.uname,
|
|
200
|
-
avatar: msg.body.user.face,
|
|
201
|
-
extra: {
|
|
202
|
-
badgeName: msg.body.user.badge?.name,
|
|
203
|
-
badgeLevel: msg.body.user.badge?.level,
|
|
204
|
-
},
|
|
205
|
-
},
|
|
206
|
-
};
|
|
207
|
-
this.emit("Message", comment);
|
|
208
|
-
extraDataController.addMessage(comment);
|
|
209
|
-
},
|
|
210
|
-
onIncomeSuperChat: (msg) => {
|
|
211
|
-
const extraDataController = streamManager.getExtraDataController();
|
|
212
|
-
if (!extraDataController)
|
|
213
|
-
return;
|
|
214
|
-
if (this.saveSCDanma === false)
|
|
215
|
-
return;
|
|
216
|
-
const content = msg.body.content.replaceAll(/[\r\n]/g, "");
|
|
217
|
-
// console.log(msg.id, msg.body);
|
|
218
|
-
const comment = {
|
|
219
|
-
type: "super_chat",
|
|
220
|
-
timestamp: msg.timestamp,
|
|
221
|
-
text: content,
|
|
222
|
-
price: msg.body.price,
|
|
223
|
-
sender: {
|
|
224
|
-
uid: String(msg.body.user.uid),
|
|
225
|
-
name: msg.body.user.uname,
|
|
226
|
-
avatar: msg.body.user.face,
|
|
227
|
-
extra: {
|
|
228
|
-
badgeName: msg.body.user.badge?.name,
|
|
229
|
-
badgeLevel: msg.body.user.badge?.level,
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
};
|
|
233
|
-
this.emit("Message", comment);
|
|
234
|
-
extraDataController.addMessage(comment);
|
|
235
|
-
},
|
|
236
|
-
onGuardBuy: (msg) => {
|
|
237
|
-
const extraDataController = streamManager.getExtraDataController();
|
|
238
|
-
if (!extraDataController)
|
|
239
|
-
return;
|
|
240
|
-
// console.log("guard", msg);
|
|
241
|
-
if (this.saveGiftDanma === false)
|
|
242
|
-
return;
|
|
243
|
-
const gift = {
|
|
244
|
-
type: "guard",
|
|
245
|
-
timestamp: msg.timestamp,
|
|
246
|
-
name: msg.body.gift_name,
|
|
247
|
-
price: msg.body.price,
|
|
248
|
-
count: 1,
|
|
249
|
-
level: msg.body.guard_level,
|
|
250
|
-
sender: {
|
|
251
|
-
uid: String(msg.body.user.uid),
|
|
252
|
-
name: msg.body.user.uname,
|
|
253
|
-
avatar: msg.body.user.face,
|
|
254
|
-
extra: {
|
|
255
|
-
badgeName: msg.body.user.badge?.name,
|
|
256
|
-
badgeLevel: msg.body.user.badge?.level,
|
|
257
|
-
},
|
|
258
|
-
},
|
|
259
|
-
};
|
|
260
|
-
this.emit("Message", gift);
|
|
261
|
-
extraDataController.addMessage(gift);
|
|
262
|
-
},
|
|
263
|
-
onGift: (msg) => {
|
|
264
|
-
const extraDataController = streamManager.getExtraDataController();
|
|
265
|
-
if (!extraDataController)
|
|
266
|
-
return;
|
|
267
|
-
// console.log("gift", msg);
|
|
268
|
-
if (this.saveGiftDanma === false)
|
|
269
|
-
return;
|
|
270
|
-
const gift = {
|
|
271
|
-
type: "give_gift",
|
|
272
|
-
timestamp: msg.timestamp,
|
|
273
|
-
name: msg.body.gift_name,
|
|
274
|
-
count: msg.body.amount,
|
|
275
|
-
price: msg.body.coin_type === "silver" ? 0 : msg.body.price / 1000,
|
|
276
|
-
sender: {
|
|
277
|
-
uid: String(msg.body.user.uid),
|
|
278
|
-
name: msg.body.user.uname,
|
|
279
|
-
avatar: msg.body.user.face,
|
|
280
|
-
extra: {
|
|
281
|
-
badgeName: msg.body.user.badge?.name,
|
|
282
|
-
badgeLevel: msg.body.user.badge?.level,
|
|
283
|
-
},
|
|
284
|
-
},
|
|
285
|
-
extra: {
|
|
286
|
-
hits: msg.body.combo?.combo_num,
|
|
287
|
-
},
|
|
288
|
-
};
|
|
289
|
-
this.emit("Message", gift);
|
|
290
|
-
extraDataController.addMessage(gift);
|
|
291
|
-
},
|
|
292
|
-
};
|
|
293
|
-
// 弹幕协议不能走短 id,所以不能直接用 channelId。
|
|
294
|
-
client = startListen(roomId, handler, {
|
|
295
|
-
ws: {
|
|
296
|
-
headers: {
|
|
297
|
-
Cookie: this.auth ?? "",
|
|
298
|
-
},
|
|
299
|
-
uid: this.uid ?? 0,
|
|
300
|
-
},
|
|
228
|
+
danmaClient = danmaClient.on("Message", (msg) => {
|
|
229
|
+
const extraDataController = recorder.getExtraDataController();
|
|
230
|
+
if (!extraDataController)
|
|
231
|
+
return;
|
|
232
|
+
if (msg.type === "super_chat" && this.saveSCDanma === false)
|
|
233
|
+
return;
|
|
234
|
+
if ((msg.type === "give_gift" || msg.type === "guard") && this.saveGiftDanma === false)
|
|
235
|
+
return;
|
|
236
|
+
this.emit("Message", msg);
|
|
237
|
+
extraDataController.addMessage(msg);
|
|
301
238
|
});
|
|
239
|
+
danmaClient.start();
|
|
302
240
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (isEnded)
|
|
306
|
-
return;
|
|
307
|
-
isEnded = true;
|
|
308
|
-
this.emit("DebugLog", {
|
|
309
|
-
type: "common",
|
|
310
|
-
text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
|
|
311
|
-
});
|
|
312
|
-
const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
313
|
-
this.recordHandle?.stop(reason);
|
|
314
|
-
};
|
|
315
|
-
const isInvalidStream = createInvalidStreamChecker();
|
|
316
|
-
const timeoutChecker = utils.createTimeoutChecker(() => onEnd("ffmpeg timeout"), 3 * 10e3);
|
|
317
|
-
const command = createFFMPEGBuilder()
|
|
318
|
-
.input(url)
|
|
319
|
-
.addInputOptions("-user_agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:107.0) Gecko/20100101 Firefox/107.0", "-headers", "Referer: https://live.bilibili.com/")
|
|
320
|
-
.outputOptions(ffmpegOutputOptions)
|
|
321
|
-
.output(streamManager.videoFilePath)
|
|
322
|
-
.on("start", async () => {
|
|
323
|
-
try {
|
|
324
|
-
await streamManager.handleVideoStarted();
|
|
325
|
-
}
|
|
326
|
-
catch (err) {
|
|
327
|
-
onEnd("ffmpeg start error");
|
|
328
|
-
this.emit("DebugLog", { type: "common", text: String(err) });
|
|
329
|
-
}
|
|
330
|
-
})
|
|
331
|
-
.on("error", onEnd)
|
|
332
|
-
.on("end", () => onEnd("finished"))
|
|
333
|
-
.on("stderr", async (stderrLine) => {
|
|
334
|
-
assertStringType(stderrLine);
|
|
335
|
-
if (utils.isFfmpegStartSegment(stderrLine)) {
|
|
336
|
-
try {
|
|
337
|
-
await streamManager.handleVideoStarted(stderrLine);
|
|
338
|
-
}
|
|
339
|
-
catch (err) {
|
|
340
|
-
onEnd("ffmpeg start error");
|
|
341
|
-
this.emit("DebugLog", { type: "common", text: String(err) });
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
|
|
345
|
-
if (isInvalidStream(stderrLine)) {
|
|
346
|
-
onEnd("invalid stream");
|
|
347
|
-
}
|
|
348
|
-
})
|
|
349
|
-
.on("stderr", timeoutChecker.update);
|
|
350
|
-
if (hasSegment) {
|
|
351
|
-
command.outputOptions("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
|
|
352
|
-
}
|
|
353
|
-
const ffmpegArgs = command._getArguments();
|
|
354
|
-
command.run();
|
|
241
|
+
const ffmpegArgs = recorder.getArguments();
|
|
242
|
+
recorder.run();
|
|
355
243
|
const stop = utils.singleton(async (reason) => {
|
|
356
244
|
if (!this.recordHandle)
|
|
357
245
|
return;
|
|
358
246
|
this.state = "stopping-record";
|
|
359
|
-
|
|
360
|
-
|
|
247
|
+
intervalId && clearInterval(intervalId);
|
|
248
|
+
danmaClient.stop();
|
|
361
249
|
try {
|
|
362
|
-
|
|
363
|
-
command.ffmpegProc?.stdin?.write("q");
|
|
364
|
-
client?.close();
|
|
365
|
-
this.usedStream = undefined;
|
|
366
|
-
this.usedSource = undefined;
|
|
367
|
-
await streamManager.handleVideoCompleted();
|
|
250
|
+
await recorder.stop();
|
|
368
251
|
}
|
|
369
252
|
catch (err) {
|
|
370
|
-
|
|
371
|
-
|
|
253
|
+
this.emit("DebugLog", {
|
|
254
|
+
type: "common",
|
|
255
|
+
text: `stop ffmpeg error: ${String(err)}`,
|
|
256
|
+
});
|
|
372
257
|
}
|
|
258
|
+
this.usedStream = undefined;
|
|
259
|
+
this.usedSource = undefined;
|
|
373
260
|
this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
|
|
374
|
-
this.off("videoFileCreated", handleVideoCreated);
|
|
375
261
|
this.recordHandle = undefined;
|
|
376
262
|
this.liveInfo = undefined;
|
|
377
263
|
this.state = "idle";
|
|
378
264
|
this.qualityRetry = this.qualityMaxRetry;
|
|
379
|
-
intervalId && clearInterval(intervalId);
|
|
380
265
|
});
|
|
381
266
|
this.recordHandle = {
|
|
382
267
|
id: genRecordUUID(),
|
package/lib/stream.d.ts
CHANGED
|
@@ -11,13 +11,14 @@ export declare function getStrictStream(roomId: number, options: {
|
|
|
11
11
|
export declare function getLiveStatus(channelId: string): Promise<{
|
|
12
12
|
living: boolean;
|
|
13
13
|
liveId: string;
|
|
14
|
+
owner: string;
|
|
15
|
+
title: string;
|
|
14
16
|
}>;
|
|
15
17
|
export declare function getInfo(channelId: string): Promise<{
|
|
16
18
|
living: boolean;
|
|
17
19
|
owner: string;
|
|
18
20
|
title: string;
|
|
19
21
|
roomId: number;
|
|
20
|
-
shortId: number;
|
|
21
22
|
avatar: string;
|
|
22
23
|
cover: string;
|
|
23
24
|
startTime: Date;
|
|
@@ -39,7 +40,7 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality">
|
|
|
39
40
|
sources: SourceProfile[];
|
|
40
41
|
name: string;
|
|
41
42
|
streamOptions: {
|
|
42
|
-
protocol_name:
|
|
43
|
+
protocol_name: "http_stream" | "http_hls";
|
|
43
44
|
format_name: string;
|
|
44
45
|
codec_name: string;
|
|
45
46
|
qn: number;
|
package/lib/stream.js
CHANGED
|
@@ -13,12 +13,25 @@ export async function getStrictStream(roomId, options) {
|
|
|
13
13
|
return url;
|
|
14
14
|
}
|
|
15
15
|
export async function getLiveStatus(channelId) {
|
|
16
|
+
const obj = await getRoomBaseInfo(Number(channelId));
|
|
17
|
+
const data = obj[Number(channelId)];
|
|
18
|
+
if (data) {
|
|
19
|
+
const startTime = new Date(data.live_time);
|
|
20
|
+
return {
|
|
21
|
+
living: data.live_status === 1 && !data.is_encrypted,
|
|
22
|
+
liveId: utils.md5(`${channelId}-${startTime?.getTime()}`),
|
|
23
|
+
owner: data.uname,
|
|
24
|
+
title: data.title,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
16
27
|
const roomInit = await getRoomInit(Number(channelId));
|
|
17
28
|
const startTime = new Date(roomInit.live_time * 1000);
|
|
18
29
|
return {
|
|
19
30
|
living: roomInit.live_status === 1 && !roomInit.encrypted,
|
|
20
31
|
liveId: utils.md5(`${roomInit.room_id}-${startTime?.getTime()}`),
|
|
21
32
|
...roomInit,
|
|
33
|
+
owner: "",
|
|
34
|
+
title: "",
|
|
22
35
|
};
|
|
23
36
|
}
|
|
24
37
|
export async function getInfo(channelId) {
|
|
@@ -38,7 +51,6 @@ export async function getInfo(channelId) {
|
|
|
38
51
|
avatar: "",
|
|
39
52
|
cover: status.cover,
|
|
40
53
|
roomId: roomInit.room_id,
|
|
41
|
-
shortId: roomInit.short_id,
|
|
42
54
|
liveId: utils.md5(`${roomInit.room_id}-${startTime?.getTime()}`),
|
|
43
55
|
};
|
|
44
56
|
}
|
|
@@ -51,7 +63,6 @@ export async function getInfo(channelId) {
|
|
|
51
63
|
avatar: status.face,
|
|
52
64
|
cover: status.cover_from_user,
|
|
53
65
|
roomId: roomInit.room_id,
|
|
54
|
-
shortId: roomInit.short_id,
|
|
55
66
|
startTime: startTime,
|
|
56
67
|
liveId: utils.md5(`${roomInit.room_id}-${startTime.getTime()}`),
|
|
57
68
|
};
|
|
@@ -68,13 +79,13 @@ async function getLiveInfo(roomIdOrShortId, opts) {
|
|
|
68
79
|
},
|
|
69
80
|
{
|
|
70
81
|
protocol_name: "http_hls",
|
|
71
|
-
format_name: "
|
|
82
|
+
format_name: "ts",
|
|
72
83
|
codec_name: "avc",
|
|
73
84
|
sort: 8,
|
|
74
85
|
},
|
|
75
86
|
{
|
|
76
87
|
protocol_name: "http_hls",
|
|
77
|
-
format_name: "
|
|
88
|
+
format_name: "fmp4",
|
|
78
89
|
codec_name: "avc",
|
|
79
90
|
sort: 7,
|
|
80
91
|
},
|
|
@@ -86,13 +97,13 @@ async function getLiveInfo(roomIdOrShortId, opts) {
|
|
|
86
97
|
},
|
|
87
98
|
{
|
|
88
99
|
protocol_name: "http_hls",
|
|
89
|
-
format_name: "
|
|
100
|
+
format_name: "ts",
|
|
90
101
|
codec_name: "hevc",
|
|
91
102
|
sort: 5,
|
|
92
103
|
},
|
|
93
104
|
{
|
|
94
105
|
protocol_name: "http_hls",
|
|
95
|
-
format_name: "
|
|
106
|
+
format_name: "fmp4",
|
|
96
107
|
codec_name: "hevc",
|
|
97
108
|
sort: 4,
|
|
98
109
|
},
|
package/lib/utils.d.ts
CHANGED
|
@@ -20,4 +20,4 @@ export declare function assert(assertion: unknown, msg?: string): asserts assert
|
|
|
20
20
|
export declare function assertStringType(data: unknown, msg?: string): asserts data is string;
|
|
21
21
|
export declare function assertNumberType(data: unknown, msg?: string): asserts data is number;
|
|
22
22
|
export declare function assertObjectType(data: unknown, msg?: string): asserts data is object;
|
|
23
|
-
export declare function createInvalidStreamChecker(): (ffmpegLogLine: string) => boolean;
|
|
23
|
+
export declare function createInvalidStreamChecker(count?: number): (ffmpegLogLine: string) => boolean;
|
package/lib/utils.js
CHANGED
|
@@ -59,7 +59,7 @@ export function assertNumberType(data, msg) {
|
|
|
59
59
|
export function assertObjectType(data, msg) {
|
|
60
60
|
assert(typeof data === "object", msg);
|
|
61
61
|
}
|
|
62
|
-
export function createInvalidStreamChecker() {
|
|
62
|
+
export function createInvalidStreamChecker(count = 10) {
|
|
63
63
|
let prevFrame = 0;
|
|
64
64
|
let frameUnchangedCount = 0;
|
|
65
65
|
return (ffmpegLogLine) => {
|
|
@@ -68,7 +68,7 @@ export function createInvalidStreamChecker() {
|
|
|
68
68
|
const [, frameText] = streamInfo;
|
|
69
69
|
const frame = Number(frameText);
|
|
70
70
|
if (frame === prevFrame) {
|
|
71
|
-
if (++frameUnchangedCount >=
|
|
71
|
+
if (++frameUnchangedCount >= count) {
|
|
72
72
|
return true;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -78,9 +78,6 @@ export function createInvalidStreamChecker() {
|
|
|
78
78
|
}
|
|
79
79
|
return false;
|
|
80
80
|
}
|
|
81
|
-
// if (ffmpegLogLine.includes("HTTP error 404 Not Found")) {
|
|
82
|
-
// return true;
|
|
83
|
-
// }
|
|
84
81
|
return false;
|
|
85
82
|
};
|
|
86
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/bilibili-recorder",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "bililive-tools bilibili recorder implemention",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"tiny-bilibili-ws": "^1.0.1",
|
|
40
40
|
"lodash-es": "^4.17.21",
|
|
41
41
|
"axios": "^1.7.8",
|
|
42
|
-
"@bililive-tools/manager": "1.0
|
|
42
|
+
"@bililive-tools/manager": "^1.2.0"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsc",
|