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