@bililive-tools/douyin-recorder 1.0.0 → 1.3.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 +13 -8
- package/lib/douyin_api.d.ts +14 -1
- package/lib/douyin_api.js +96 -9
- package/lib/index.js +79 -17
- package/lib/stream.d.ts +2 -0
- package/lib/stream.js +22 -34
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -36,11 +36,14 @@ interface Options {
|
|
|
36
36
|
qualityRetry?: number; // 画质匹配重试次数, -1为强制匹配画质,0为自动配置,正整数为最大匹配次数
|
|
37
37
|
streamPriorities: []; // 废弃
|
|
38
38
|
sourcePriorities: []; // 废弃
|
|
39
|
+
formatPriorities?: string[]; // 支持,`flv`和`hls` 参数,默认为['flv','hls']
|
|
39
40
|
disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
|
|
40
41
|
segment?: number; // 分段参数,单位分钟
|
|
41
42
|
disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
|
|
42
43
|
saveGiftDanma?: boolean; // 保存礼物弹幕
|
|
43
44
|
saveCover?: boolean; // 保存封面
|
|
45
|
+
videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
|
|
46
|
+
auth?: string; // 传递cookie,用于录制会员视频
|
|
44
47
|
}
|
|
45
48
|
```
|
|
46
49
|
|
|
@@ -48,14 +51,15 @@ interface Options {
|
|
|
48
51
|
|
|
49
52
|
遗漏了部分画质,有了解的可以提PR
|
|
50
53
|
|
|
51
|
-
| 画质
|
|
52
|
-
|
|
|
53
|
-
| 原画
|
|
54
|
-
| 蓝光
|
|
55
|
-
| 超清
|
|
56
|
-
| 高清
|
|
57
|
-
| 标清
|
|
58
|
-
| 音频流
|
|
54
|
+
| 画质 | 值 |
|
|
55
|
+
| ---------------------- | ----------- |
|
|
56
|
+
| 原画 | origin |
|
|
57
|
+
| 蓝光 | uhd |
|
|
58
|
+
| 超清 | hd |
|
|
59
|
+
| 高清 | sd |
|
|
60
|
+
| 标清 | ld |
|
|
61
|
+
| 音频流 | ao |
|
|
62
|
+
| 真原画(音频流中获取的) | real_origin |
|
|
59
63
|
|
|
60
64
|
## 直播间ID解析
|
|
61
65
|
|
|
@@ -65,6 +69,7 @@ interface Options {
|
|
|
65
69
|
import { provider } from "@bililive-tools/douyin-recorder";
|
|
66
70
|
|
|
67
71
|
const url = "https://live.douyin.com/203641303310";
|
|
72
|
+
// 同样支持解析 https://v.douyin.com/DpfoBLAXoHM/
|
|
68
73
|
const { id } = await provider.resolveChannelInfoFromURL(url);
|
|
69
74
|
```
|
|
70
75
|
|
package/lib/douyin_api.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从抖音短链接解析得到直播间ID
|
|
3
|
+
* @param shortURL 短链接,如 https://v.douyin.com/DpfoBLAXoHM/
|
|
4
|
+
* @returns webRoomId 直播间ID
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveShortURL(shortURL: string): Promise<string>;
|
|
1
7
|
export declare const getCookie: () => Promise<string>;
|
|
2
|
-
export declare function getRoomInfo(webRoomId: string, retryOnSpecialCode?: boolean): Promise<{
|
|
8
|
+
export declare function getRoomInfo(webRoomId: string, retryOnSpecialCode?: boolean, auth?: string): Promise<{
|
|
3
9
|
living: boolean;
|
|
4
10
|
roomId: string;
|
|
5
11
|
owner: string;
|
|
@@ -15,9 +21,16 @@ export interface StreamProfile {
|
|
|
15
21
|
key: string;
|
|
16
22
|
bitRate: number;
|
|
17
23
|
}
|
|
24
|
+
export interface StreamInfo {
|
|
25
|
+
quality: string;
|
|
26
|
+
name: string;
|
|
27
|
+
flv?: string;
|
|
28
|
+
hls?: string;
|
|
29
|
+
}
|
|
18
30
|
export interface SourceProfile {
|
|
19
31
|
name: string;
|
|
20
32
|
streamMap: StreamData["data"];
|
|
33
|
+
streams: StreamInfo[];
|
|
21
34
|
}
|
|
22
35
|
interface StreamData {
|
|
23
36
|
common: unknown;
|
package/lib/douyin_api.js
CHANGED
|
@@ -5,12 +5,60 @@ const requester = axios.create({
|
|
|
5
5
|
// axios 会自动读取环境变量中的 http_proxy 和 https_proxy 并应用,这会让请求发往代理的 host。
|
|
6
6
|
// 所以这里需要主动禁用代理功能。
|
|
7
7
|
proxy: false,
|
|
8
|
+
headers: {
|
|
9
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
|
10
|
+
},
|
|
8
11
|
});
|
|
12
|
+
/**
|
|
13
|
+
* 从抖音短链接解析得到直播间ID
|
|
14
|
+
* @param shortURL 短链接,如 https://v.douyin.com/DpfoBLAXoHM/
|
|
15
|
+
* @returns webRoomId 直播间ID
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveShortURL(shortURL) {
|
|
18
|
+
// 获取跳转后的页面内容
|
|
19
|
+
const response = await requester.get(shortURL);
|
|
20
|
+
// 尝试从页面内容中提取webRid
|
|
21
|
+
const webRidMatch = response.data.match(/"webRid\\":\\"(\d+)\\"/);
|
|
22
|
+
if (webRidMatch) {
|
|
23
|
+
return webRidMatch[1];
|
|
24
|
+
}
|
|
25
|
+
throw new Error("无法从短链接解析出直播间ID");
|
|
26
|
+
}
|
|
27
|
+
const qualityList = [
|
|
28
|
+
{
|
|
29
|
+
key: "origin",
|
|
30
|
+
desc: "原画",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: "uhd",
|
|
34
|
+
desc: "蓝光",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
key: "hd",
|
|
38
|
+
desc: "超清",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "sd",
|
|
42
|
+
desc: "高清",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "ld",
|
|
46
|
+
desc: "标清",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "ao",
|
|
50
|
+
desc: "音频流",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: "real_origin",
|
|
54
|
+
desc: "真原画",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
9
57
|
let cookieCache;
|
|
10
58
|
export const getCookie = async () => {
|
|
11
59
|
const now = new Date().getTime();
|
|
12
|
-
// 缓存
|
|
13
|
-
if (cookieCache?.startTimestamp && now - cookieCache.startTimestamp <
|
|
60
|
+
// 缓存6小时
|
|
61
|
+
if (cookieCache?.startTimestamp && now - cookieCache.startTimestamp < 6 * 60 * 60 * 1000) {
|
|
14
62
|
return cookieCache.cookies;
|
|
15
63
|
}
|
|
16
64
|
const res = await requester.get("https://live.douyin.com/");
|
|
@@ -28,10 +76,16 @@ export const getCookie = async () => {
|
|
|
28
76
|
};
|
|
29
77
|
return cookies;
|
|
30
78
|
};
|
|
31
|
-
export async function getRoomInfo(webRoomId, retryOnSpecialCode = true) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
79
|
+
export async function getRoomInfo(webRoomId, retryOnSpecialCode = true, auth) {
|
|
80
|
+
let cookies = undefined;
|
|
81
|
+
if (auth) {
|
|
82
|
+
cookies = auth;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// 抖音的 'webcast/room/web/enter' api 会需要 ttwid 的 cookie,这个 cookie 是由这个请求的响应头设置的,
|
|
86
|
+
// 所以在这里请求一次自动设置。
|
|
87
|
+
cookies = await getCookie();
|
|
88
|
+
}
|
|
35
89
|
const res = await requester.get("https://live.douyin.com/webcast/room/web/enter/", {
|
|
36
90
|
params: {
|
|
37
91
|
aid: 6383,
|
|
@@ -55,7 +109,6 @@ export async function getRoomInfo(webRoomId, retryOnSpecialCode = true) {
|
|
|
55
109
|
cookie: cookies,
|
|
56
110
|
},
|
|
57
111
|
});
|
|
58
|
-
// console.log(JSON.stringify(res.data, null, 2));
|
|
59
112
|
// 无 cookie 时 code 为 10037
|
|
60
113
|
if (res.data.status_code === 10037 && retryOnSpecialCode) {
|
|
61
114
|
// resp 自动设置 cookie
|
|
@@ -68,7 +121,7 @@ export async function getRoomInfo(webRoomId, retryOnSpecialCode = true) {
|
|
|
68
121
|
// console.log("cookies", cookies);
|
|
69
122
|
return getRoomInfo(webRoomId, false);
|
|
70
123
|
}
|
|
71
|
-
assert(res.data.status_code === 0, `Unexpected resp, code ${res.data.status_code}, msg ${res.data.data}, id ${webRoomId}`);
|
|
124
|
+
assert(res.data.status_code === 0, `Unexpected resp, code ${res.data.status_code}, msg ${JSON.stringify(res.data.data)}, id ${webRoomId}`);
|
|
72
125
|
const data = res.data.data;
|
|
73
126
|
const room = data.data[0];
|
|
74
127
|
assert(room, `No room data, id ${webRoomId}`);
|
|
@@ -92,16 +145,50 @@ export async function getRoomInfo(webRoomId, retryOnSpecialCode = true) {
|
|
|
92
145
|
key: info.sdk_key,
|
|
93
146
|
bitRate: info.v_bit_rate,
|
|
94
147
|
}));
|
|
148
|
+
// 转换流数据结构
|
|
149
|
+
const streamList = Object.entries(streamData)
|
|
150
|
+
.map(([quality, info]) => {
|
|
151
|
+
const stream = info?.main;
|
|
152
|
+
const name = qualityList.find((item) => item.key === quality)?.desc;
|
|
153
|
+
return {
|
|
154
|
+
quality: quality,
|
|
155
|
+
name: name ?? "未知",
|
|
156
|
+
flv: stream?.flv,
|
|
157
|
+
hls: stream?.hls,
|
|
158
|
+
};
|
|
159
|
+
})
|
|
160
|
+
.filter((stream) => stream.flv || stream.hls);
|
|
161
|
+
const aoStream = streamList.find((stream) => stream.quality === "ao");
|
|
162
|
+
if (!!aoStream) {
|
|
163
|
+
// 真原画流是在ao流中拿到的
|
|
164
|
+
streamList.push({
|
|
165
|
+
quality: "real_origin",
|
|
166
|
+
name: "真原画",
|
|
167
|
+
flv: (aoStream?.flv ?? "").replace("&only_audio=1", ""),
|
|
168
|
+
hls: (aoStream?.hls ?? "").replace("&only_audio=1", ""),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
streamList.sort((a, b) => {
|
|
172
|
+
const aIndex = qualityList.findIndex((item) => item.key === a.quality);
|
|
173
|
+
const bIndex = qualityList.findIndex((item) => item.key === b.quality);
|
|
174
|
+
// 如果找不到对应的质量等级,将其排在最后
|
|
175
|
+
if (aIndex === -1)
|
|
176
|
+
return 1;
|
|
177
|
+
if (bIndex === -1)
|
|
178
|
+
return -1;
|
|
179
|
+
return aIndex - bIndex;
|
|
180
|
+
});
|
|
95
181
|
// 看起来抖音是自动切换 cdn 的,所以这里固定返回一个默认的 source。
|
|
96
182
|
const sources = [
|
|
97
183
|
{
|
|
98
184
|
name: "自动",
|
|
99
185
|
streamMap: streamData,
|
|
186
|
+
streams: streamList,
|
|
100
187
|
},
|
|
101
188
|
];
|
|
189
|
+
// console.log(JSON.stringify(sources, null, 2), qualities);
|
|
102
190
|
return {
|
|
103
191
|
living: data.room_status === 0,
|
|
104
|
-
// 接口里不会再返回 web room id,只能直接用入参原路返回了。
|
|
105
192
|
roomId: webRoomId,
|
|
106
193
|
owner: data.user.nickname,
|
|
107
194
|
title: room.title,
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import mitt from "mitt";
|
|
|
3
3
|
import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, FFMPEGRecorder, } from "@bililive-tools/manager";
|
|
4
4
|
import { getInfo, getStream } from "./stream.js";
|
|
5
5
|
import { ensureFolderExist, singleton } from "./utils.js";
|
|
6
|
+
import { resolveShortURL } from "./douyin_api.js";
|
|
6
7
|
import DouYinDanmaClient from "douyin-danma-listener";
|
|
7
8
|
function createRecorder(opts) {
|
|
8
9
|
// 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
|
|
@@ -60,15 +61,24 @@ const ffmpegOutputOptions = [
|
|
|
60
61
|
"-movflags",
|
|
61
62
|
"faststart+frag_keyframe+empty_moov",
|
|
62
63
|
"-min_frag_duration",
|
|
63
|
-
"
|
|
64
|
+
"10000000",
|
|
65
|
+
];
|
|
66
|
+
const ffmpegInputOptions = [
|
|
67
|
+
"-reconnect",
|
|
68
|
+
"1",
|
|
69
|
+
"-reconnect_streamed",
|
|
70
|
+
"1",
|
|
71
|
+
"-reconnect_delay_max",
|
|
72
|
+
"10",
|
|
73
|
+
"-rw_timeout",
|
|
74
|
+
"15000000",
|
|
64
75
|
];
|
|
65
76
|
const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
|
|
66
77
|
if (this.recordHandle != null)
|
|
67
78
|
return this.recordHandle;
|
|
68
79
|
const liveInfo = await getInfo(this.channelId);
|
|
69
|
-
const { living, owner, title
|
|
80
|
+
const { living, owner, title } = liveInfo;
|
|
70
81
|
this.liveInfo = liveInfo;
|
|
71
|
-
this.emit("LiveStart", { liveId });
|
|
72
82
|
if (liveInfo.liveId === banLiveId) {
|
|
73
83
|
this.tempStopIntervalCheck = true;
|
|
74
84
|
}
|
|
@@ -97,6 +107,8 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
97
107
|
streamPriorities: this.streamPriorities,
|
|
98
108
|
sourcePriorities: this.sourcePriorities,
|
|
99
109
|
strictQuality: strictQuality,
|
|
110
|
+
cookie: this.auth,
|
|
111
|
+
formatPriorities: this.formatPriorities,
|
|
100
112
|
});
|
|
101
113
|
}
|
|
102
114
|
catch (err) {
|
|
@@ -111,7 +123,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
111
123
|
this.usedSource = stream.source;
|
|
112
124
|
// TODO: emit update event
|
|
113
125
|
let isEnded = false;
|
|
126
|
+
let isCutting = false;
|
|
114
127
|
const onEnd = (...args) => {
|
|
128
|
+
if (isCutting) {
|
|
129
|
+
isCutting = false;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
115
132
|
if (isEnded)
|
|
116
133
|
return;
|
|
117
134
|
isEnded = true;
|
|
@@ -125,10 +142,18 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
125
142
|
const recorder = new FFMPEGRecorder({
|
|
126
143
|
url: stream.url,
|
|
127
144
|
outputOptions: ffmpegOutputOptions,
|
|
145
|
+
inputOptions: ffmpegInputOptions,
|
|
128
146
|
segment: this.segment ?? 0,
|
|
129
|
-
getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
|
|
147
|
+
getSavePath: (opts) => getSavePath({ owner, title: opts.title ?? title, startTime: opts.startTime }),
|
|
130
148
|
disableDanma: this.disableProvideCommentsWhenRecording,
|
|
131
|
-
|
|
149
|
+
videoFormat: this.videoFormat ?? "auto",
|
|
150
|
+
headers: {
|
|
151
|
+
Cookie: this.auth,
|
|
152
|
+
},
|
|
153
|
+
}, onEnd, async () => {
|
|
154
|
+
const info = await getInfo(this.channelId);
|
|
155
|
+
return info;
|
|
156
|
+
});
|
|
132
157
|
const savePath = getSavePath({
|
|
133
158
|
owner,
|
|
134
159
|
title,
|
|
@@ -140,8 +165,14 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
140
165
|
this.state = "idle";
|
|
141
166
|
throw err;
|
|
142
167
|
}
|
|
143
|
-
const handleVideoCreated = async ({ filename }) => {
|
|
144
|
-
this.emit("videoFileCreated", { filename });
|
|
168
|
+
const handleVideoCreated = async ({ filename, title, cover }) => {
|
|
169
|
+
this.emit("videoFileCreated", { filename, cover });
|
|
170
|
+
if (title && this?.liveInfo) {
|
|
171
|
+
this.liveInfo.title = title;
|
|
172
|
+
}
|
|
173
|
+
if (cover && this?.liveInfo) {
|
|
174
|
+
this.liveInfo.cover = cover;
|
|
175
|
+
}
|
|
145
176
|
const extraDataController = recorder.getExtraDataController();
|
|
146
177
|
extraDataController?.setMeta({
|
|
147
178
|
room_id: this.channelId,
|
|
@@ -165,14 +196,16 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
165
196
|
}
|
|
166
197
|
this.emit("progress", progress);
|
|
167
198
|
});
|
|
168
|
-
const client = new DouYinDanmaClient(liveInfo.liveId
|
|
199
|
+
const client = new DouYinDanmaClient(liveInfo.liveId, {
|
|
200
|
+
cookie: this.auth,
|
|
201
|
+
});
|
|
169
202
|
client.on("chat", (msg) => {
|
|
170
203
|
const extraDataController = recorder.getExtraDataController();
|
|
171
204
|
if (!extraDataController)
|
|
172
205
|
return;
|
|
173
206
|
const comment = {
|
|
174
207
|
type: "comment",
|
|
175
|
-
timestamp:
|
|
208
|
+
timestamp: Number(msg.eventTime) * 1000,
|
|
176
209
|
text: msg.content,
|
|
177
210
|
color: "#ffffff",
|
|
178
211
|
sender: {
|
|
@@ -184,7 +217,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
184
217
|
// },
|
|
185
218
|
},
|
|
186
219
|
};
|
|
187
|
-
// console.log("comment", comment);
|
|
188
220
|
this.emit("Message", comment);
|
|
189
221
|
extraDataController.addMessage(comment);
|
|
190
222
|
});
|
|
@@ -194,13 +226,14 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
194
226
|
return;
|
|
195
227
|
if (this.saveGiftDanma === false)
|
|
196
228
|
return;
|
|
197
|
-
// console.log("gift", msg);
|
|
198
229
|
const gift = {
|
|
199
230
|
type: "give_gift",
|
|
200
|
-
timestamp:
|
|
231
|
+
timestamp: Number(msg.common.createTime) > 9999999999
|
|
232
|
+
? Number(msg.common.createTime)
|
|
233
|
+
: Number(msg.common.createTime) * 1000,
|
|
201
234
|
name: msg.gift.name,
|
|
202
235
|
price: 1,
|
|
203
|
-
count: Number(msg.totalCount),
|
|
236
|
+
count: Number(msg.totalCount ?? 1),
|
|
204
237
|
color: "#ffffff",
|
|
205
238
|
sender: {
|
|
206
239
|
uid: msg.user.id,
|
|
@@ -211,10 +244,15 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
211
244
|
// },
|
|
212
245
|
},
|
|
213
246
|
};
|
|
214
|
-
// console.log("gift", gift);
|
|
215
247
|
this.emit("Message", gift);
|
|
216
248
|
extraDataController.addMessage(gift);
|
|
217
249
|
});
|
|
250
|
+
client.on("reconnect", (attempts) => {
|
|
251
|
+
this.emit("DebugLog", {
|
|
252
|
+
type: "common",
|
|
253
|
+
text: `danma has reconnect ${attempts}`,
|
|
254
|
+
});
|
|
255
|
+
});
|
|
218
256
|
// client.on("open", () => {
|
|
219
257
|
// console.log("open");
|
|
220
258
|
// });
|
|
@@ -232,6 +270,16 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
232
270
|
}
|
|
233
271
|
const ffmpegArgs = recorder.getArguments();
|
|
234
272
|
recorder.run();
|
|
273
|
+
const cut = singleton(async () => {
|
|
274
|
+
if (!this.recordHandle)
|
|
275
|
+
return;
|
|
276
|
+
if (isCutting)
|
|
277
|
+
return;
|
|
278
|
+
isCutting = true;
|
|
279
|
+
await recorder.stop();
|
|
280
|
+
recorder.createCommand();
|
|
281
|
+
recorder.run();
|
|
282
|
+
});
|
|
235
283
|
const stop = singleton(async (reason) => {
|
|
236
284
|
if (!this.recordHandle)
|
|
237
285
|
return;
|
|
@@ -261,6 +309,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
|
|
|
261
309
|
ffmpegArgs,
|
|
262
310
|
savePath: savePath,
|
|
263
311
|
stop,
|
|
312
|
+
cut,
|
|
264
313
|
};
|
|
265
314
|
this.emit("RecordStart", this.recordHandle);
|
|
266
315
|
return this.recordHandle;
|
|
@@ -270,13 +319,26 @@ export const provider = {
|
|
|
270
319
|
name: "抖音",
|
|
271
320
|
siteURL: "https://live.douyin.com/",
|
|
272
321
|
matchURL(channelURL) {
|
|
273
|
-
//
|
|
274
|
-
return /https?:\/\/live\.douyin\.com\//.test(channelURL);
|
|
322
|
+
// 支持 v.douyin.com 和 live.douyin.com
|
|
323
|
+
return /https?:\/\/(live|v)\.douyin\.com\//.test(channelURL);
|
|
275
324
|
},
|
|
276
325
|
async resolveChannelInfoFromURL(channelURL) {
|
|
277
326
|
if (!this.matchURL(channelURL))
|
|
278
327
|
return null;
|
|
279
|
-
|
|
328
|
+
let id;
|
|
329
|
+
if (channelURL.includes("v.douyin.com")) {
|
|
330
|
+
// 处理短链接
|
|
331
|
+
try {
|
|
332
|
+
id = await resolveShortURL(channelURL);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
throw new Error(`解析抖音短链接失败: ${err?.message}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
// 处理常规直播链接
|
|
340
|
+
id = path.basename(new URL(channelURL).pathname);
|
|
341
|
+
}
|
|
280
342
|
const info = await getInfo(id);
|
|
281
343
|
return {
|
|
282
344
|
id: info.roomId,
|
package/lib/stream.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare function getInfo(channelId: string): Promise<{
|
|
|
12
12
|
export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" | "streamPriorities" | "sourcePriorities"> & {
|
|
13
13
|
rejectCache?: boolean;
|
|
14
14
|
strictQuality?: boolean;
|
|
15
|
+
cookie?: string;
|
|
16
|
+
formatPriorities?: Array<"flv" | "hls">;
|
|
15
17
|
}): Promise<{
|
|
16
18
|
currentStream: {
|
|
17
19
|
name: string;
|
package/lib/stream.js
CHANGED
|
@@ -13,48 +13,36 @@ export async function getInfo(channelId) {
|
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
export async function getStream(opts) {
|
|
16
|
-
const info = await getRoomInfo(opts.channelId);
|
|
16
|
+
const info = await getRoomInfo(opts.channelId, true, opts.cookie);
|
|
17
17
|
if (!info.living) {
|
|
18
18
|
throw new Error("It must be called getStream when living");
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
{
|
|
22
|
-
key: "origin",
|
|
23
|
-
desc: "原画",
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
key: "uhd",
|
|
27
|
-
desc: "蓝光",
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
key: "hd",
|
|
31
|
-
desc: "超清",
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
key: "sd",
|
|
35
|
-
desc: "高清",
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
key: "标清",
|
|
39
|
-
desc: "ld",
|
|
40
|
-
},
|
|
41
|
-
];
|
|
20
|
+
// 抖音为自动cdn,所以指定选择第一个
|
|
42
21
|
const sources = info.sources[0];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
22
|
+
const formatPriorities = opts.formatPriorities || ["flv", "hls"];
|
|
23
|
+
// 查找指定质量的流
|
|
24
|
+
let targetStream = sources.streams.find((s) => s.quality === opts.quality);
|
|
25
|
+
let qualityName = targetStream?.name ?? "未知";
|
|
26
|
+
if (!targetStream && opts.strictQuality) {
|
|
46
27
|
throw new Error("Can not get expect quality because of strictQuality");
|
|
47
28
|
}
|
|
48
|
-
//
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
qualityName = quality.desc;
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
29
|
+
// 如果找不到指定质量的流,按照流顺序选择第一个可用的流
|
|
30
|
+
if (!targetStream) {
|
|
31
|
+
targetStream = sources.streams.find((stream) => stream.flv || stream.hls);
|
|
32
|
+
if (targetStream) {
|
|
33
|
+
qualityName = targetStream.name;
|
|
56
34
|
}
|
|
57
35
|
}
|
|
36
|
+
if (!targetStream) {
|
|
37
|
+
throw new Error("未找到对应的流");
|
|
38
|
+
}
|
|
39
|
+
// 根据格式优先级选择 URL
|
|
40
|
+
let url;
|
|
41
|
+
for (const format of formatPriorities) {
|
|
42
|
+
url = targetStream[format];
|
|
43
|
+
if (url)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
58
46
|
if (!url) {
|
|
59
47
|
throw new Error("未找到对应的流");
|
|
60
48
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bililive-tools/douyin-recorder",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "@bililive-tools douyin recorder implemention",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"axios": "^1.7.8",
|
|
38
38
|
"lodash-es": "^4.17.21",
|
|
39
39
|
"mitt": "^3.0.1",
|
|
40
|
-
"@bililive-tools/manager": "^1.
|
|
41
|
-
"douyin-danma-listener": "0.
|
|
40
|
+
"@bililive-tools/manager": "^1.3.0",
|
|
41
|
+
"douyin-danma-listener": "0.2.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "*"
|