@bililive-tools/huya-recorder 1.9.0 → 1.11.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 CHANGED
@@ -41,11 +41,11 @@ interface Options {
41
41
  sourcePriorities: []; // 按提供的源优先级去给CDN列表排序,并过滤掉不在优先级配置中的源,在未匹配到的情况下会优先使用TX的CDN,具体参数见 CDN 参数
42
42
  formatPriorities?: string[]; // 支持,`flv`和`hls` 参数,默认为['flv','hls']
43
43
  disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
44
- segment?: number; // 分段参数,单位分钟
44
+ segment?: number | string; // 分段参数,单位分钟,如果以"B","KB","MB","GB"结尾,会尝试使用文件大小分段,仅推荐在使用mesio录制引擎时使用
45
45
  disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
46
46
  saveGiftDanma?: boolean; // 保存礼物弹幕
47
47
  saveCover?: boolean; // 保存封面
48
- api?: "auto" | "mp" | "web"; // 默认为auto,在星秀区使用mp接口,其他使用web接口,你也可以强制指定
48
+ api?: "auto" | "mp" | "web" | "wup"; // 默认为auto,在星秀区使用mp接口,其他使用web接口,你也可以强制指定
49
49
  videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
50
50
  recorderType?: "auto" | "ffmpeg" | "mesio"; // 底层录制器,使用mesio时videoFormat参数无效
51
51
  debugLevel?: `verbose` | "basic"; // verbose参数时,录制器会输出更加详细的log
@@ -100,6 +100,17 @@ const url = "https://www.huya.com/910323";
100
100
  const { id } = await provider.resolveChannelInfoFromURL(url);
101
101
  ```
102
102
 
103
+ ### API 接口选择
104
+
105
+ 虎牙提供多种API接口,可以根据情况选择:
106
+
107
+ | 接口类型 | 说明 |
108
+ | -------- | ---------------------------------------- |
109
+ | auto | 一般使用web接口,星秀区使用wup接口 |
110
+ | web | 你web看到的东西,星秀区会两分钟分段 |
111
+ | wup | 神奇的接口 |
112
+ | mp | 小程序接口,也可以录星秀区,但画质差了点 |
113
+
103
114
  # 协议
104
115
 
105
116
  与原项目保存一致为 LGPL
package/lib/huya_api.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare function getRoomInfo(roomIdOrShortId: string, opts?: {
5
5
  quality?: Recorder["quality"];
6
6
  }): Promise<{
7
7
  living: boolean;
8
+ api: string;
8
9
  id: number;
9
10
  owner: string;
10
11
  title: string;
package/lib/huya_api.js CHANGED
@@ -47,6 +47,11 @@ export async function getRoomInfo(roomIdOrShortId, opts = {}) {
47
47
  sources.flv.push({
48
48
  name: item.sCdnType,
49
49
  url,
50
+ streamName: sStreamName,
51
+ presenterUid: item.lPresenterUid,
52
+ subChannelId: item.lSubChannelId,
53
+ channelId: item.lChannelId,
54
+ suffix: item.sFlvUrlSuffix,
50
55
  });
51
56
  }
52
57
  if (item.sHlsAntiCode && item.sHlsAntiCode.length > 0) {
@@ -64,6 +69,11 @@ export async function getRoomInfo(roomIdOrShortId, opts = {}) {
64
69
  sources.hls.push({
65
70
  name: item.sCdnType,
66
71
  url,
72
+ streamName: sStreamName,
73
+ presenterUid: item.lPresenterUid,
74
+ subChannelId: item.lSubChannelId,
75
+ channelId: item.lChannelId,
76
+ suffix: item.sHlsUrlSuffix,
67
77
  });
68
78
  }
69
79
  }
@@ -71,6 +81,7 @@ export async function getRoomInfo(roomIdOrShortId, opts = {}) {
71
81
  const formatSources = getFormatSources(sources, opts.formatPriorities);
72
82
  return {
73
83
  living: vMultiStreamInfo.length > 0 && data.gameStreamInfoList.length > 0,
84
+ api: "web",
74
85
  id: data.gameLiveInfo.profileRoom,
75
86
  owner: data.gameLiveInfo.nick,
76
87
  title: data.gameLiveInfo.introduction,
@@ -1,6 +1,7 @@
1
1
  import type { StreamProfile } from "./types.js";
2
2
  export declare function getRoomInfo(roomIdOrShortId: string, formatPriorities?: Array<"flv" | "hls">): Promise<{
3
3
  living: boolean;
4
+ api: string;
4
5
  id: number;
5
6
  owner: string;
6
7
  title: string;
@@ -19,6 +19,7 @@ export async function getRoomInfo(roomIdOrShortId, formatPriorities = ["flv", "h
19
19
  flv: [],
20
20
  hls: [],
21
21
  };
22
+ // console.log("profile", JSON.stringify(profile, null, 2));
22
23
  // const uid = await getAnonymousUid();
23
24
  for (const item of profile?.stream?.baseSteamInfoList ?? []) {
24
25
  if (item.sFlvAntiCode && item.sFlvAntiCode.length > 0) {
@@ -32,6 +33,11 @@ export async function getRoomInfo(roomIdOrShortId, formatPriorities = ["flv", "h
32
33
  sources.flv.push({
33
34
  name: item.sCdnType,
34
35
  url,
36
+ streamName: item.sStreamName,
37
+ presenterUid: item.lPresenterUid,
38
+ subChannelId: item.lSubChannelId,
39
+ channelId: item.lChannelId,
40
+ suffix: item.sFlvUrlSuffix,
35
41
  });
36
42
  }
37
43
  if (item.sHlsAntiCode && item.sHlsAntiCode.length > 0) {
@@ -39,6 +45,11 @@ export async function getRoomInfo(roomIdOrShortId, formatPriorities = ["flv", "h
39
45
  sources.hls.push({
40
46
  name: item.sCdnType,
41
47
  url,
48
+ streamName: item.sStreamName,
49
+ presenterUid: item.lPresenterUid,
50
+ subChannelId: item.lSubChannelId,
51
+ channelId: item.lChannelId,
52
+ suffix: item.sHlsUrlSuffix,
42
53
  });
43
54
  }
44
55
  }
@@ -59,6 +70,7 @@ export async function getRoomInfo(roomIdOrShortId, formatPriorities = ["flv", "h
59
70
  }
60
71
  return {
61
72
  living: profile.liveStatus === "ON",
73
+ api: "mp",
62
74
  id: profile.liveData.profileRoom,
63
75
  owner: profile.liveData.nick,
64
76
  title: profile.liveData.introduction,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 虎牙 WUP 协议客户端
3
+ * 参考 https://github.com/hua0512/rust-srec 实现
4
+ */
5
+ declare const WUP_URL = "https://wup.huya.com";
6
+ declare const WUP_UA = "HYSDK(Windows, 30000002)_APP(pc_exe&7030003&official)_SDK(trans&2.29.0.5493)";
7
+ declare const HUYA_ORIGIN = "https://www.huya.com";
8
+ /**
9
+ * 流信息接口
10
+ */
11
+ export interface StreamInfo {
12
+ url: string;
13
+ streamFormat: string;
14
+ extras?: StreamExtras;
15
+ }
16
+ /**
17
+ * 流扩展信息接口
18
+ */
19
+ export interface StreamExtras {
20
+ cdn?: string;
21
+ stream_name: string;
22
+ presenter_uid?: number;
23
+ }
24
+ /**
25
+ * CDN Token 信息接口
26
+ */
27
+ export interface CdnTokenInfo {
28
+ url: string;
29
+ cdnType: string;
30
+ streamName: string;
31
+ presenterUid: number;
32
+ antiCode: string;
33
+ sTime: string;
34
+ flvAntiCode: string;
35
+ hlsAntiCode: string;
36
+ }
37
+ /**
38
+ * GetCdnTokenInfoReq 请求结构体
39
+ * 对应 Rust 代码中的 GetCdnTokenInfoReq
40
+ */
41
+ declare class GetCdnTokenInfoReq {
42
+ url: string;
43
+ cdnType: string;
44
+ streamName: string;
45
+ presenterUid: number;
46
+ constructor(url?: string, streamName?: string, cdnType?: string, presenterUid?: number);
47
+ /**
48
+ * TARS 编码方法 - 将结构体写入输出流
49
+ * @param os - TARS 输出流
50
+ */
51
+ _writeTo(os: any): void;
52
+ /**
53
+ * TARS 解码方法 - 从输入流读取结构体
54
+ * @param is - TARS 输入流
55
+ */
56
+ _readFrom(is: any): void;
57
+ }
58
+ /**
59
+ * HuyaGetTokenResp 响应结构体
60
+ * 对应 Rust 代码中的 HuyaGetTokenResp
61
+ */
62
+ declare class HuyaGetTokenResp {
63
+ url: string;
64
+ cdnType: string;
65
+ streamName: string;
66
+ presenterUid: number;
67
+ antiCode: string;
68
+ sTime: string;
69
+ flvAntiCode: string;
70
+ hlsAntiCode: string;
71
+ constructor();
72
+ /**
73
+ * TARS 编码方法 - 将结构体写入输出流
74
+ * @param os - TARS 输出流
75
+ */
76
+ _writeTo(os: any): void;
77
+ /**
78
+ * TARS 解码方法 - 从输入流读取结构体
79
+ * @param is - TARS 输入流
80
+ */
81
+ _readFrom(is: any): void;
82
+ }
83
+ /**
84
+ * 构建 getCdnTokenInfo 请求
85
+ * 对应 Rust 中的 build_get_cdn_token_info_request 函数
86
+ *
87
+ * @param streamName - 流名称
88
+ * @param cdnType - CDN 类型 (例如: "AL")
89
+ * @param presenterUid - 主播 UID
90
+ * @returns TARS 编码的请求体
91
+ */
92
+ declare function buildGetCdnTokenInfoRequest(streamName: string, cdnType: string, presenterUid: number): Buffer;
93
+ /**
94
+ * 解码 getCdnTokenInfo 响应
95
+ * 对应 Rust 中的 decode_get_cdn_token_info_response 函数
96
+ *
97
+ * @param responseBytes - 响应二进制数据
98
+ * @returns 解码后的响应对象
99
+ */
100
+ declare function decodeGetCdnTokenInfoResponse(responseBytes: Buffer): HuyaGetTokenResp;
101
+ /**
102
+ * 发送 WUP 请求到虎牙服务器
103
+ *
104
+ * @param requestBody - 请求体
105
+ * @returns 响应体
106
+ */
107
+ declare function sendWupRequest(requestBody: Buffer): Promise<Buffer>;
108
+ /**
109
+ * 获取虎牙流的真实 URL(使用 WUP 协议)
110
+ * 对应 Rust 中的 get_stream_url_wup 方法
111
+ *
112
+ * @param streamInfo - 流信息对象
113
+ * @returns 真实流 URL
114
+ */
115
+ export declare function getStreamUrlWup(streamInfo: StreamInfo): Promise<string>;
116
+ /**
117
+ * 简化版本:直接获取防盗链参数
118
+ *
119
+ * @param streamName - 流名称
120
+ * @param cdnType - CDN 类型
121
+ * @param presenterUid - 主播 UID
122
+ * @returns 包含 flvAntiCode 和 hlsAntiCode
123
+ */
124
+ export declare function getCdnTokenInfo(streamName: string, cdnType?: string, presenterUid?: number): Promise<CdnTokenInfo>;
125
+ export { GetCdnTokenInfoReq, HuyaGetTokenResp, buildGetCdnTokenInfoRequest, decodeGetCdnTokenInfoResponse, sendWupRequest, WUP_URL, WUP_UA, HUYA_ORIGIN, };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * 虎牙 WUP 协议客户端
3
+ * 参考 https://github.com/hua0512/rust-srec 实现
4
+ */
5
+ import { requester } from "./requester.js";
6
+ import TarsStream from "@tars/stream";
7
+ const Tup = TarsStream.Tup;
8
+ const WUP_URL = "https://wup.huya.com";
9
+ const WUP_UA = "HYSDK(Windows, 30000002)_APP(pc_exe&7030003&official)_SDK(trans&2.29.0.5493)";
10
+ const HUYA_ORIGIN = "https://www.huya.com";
11
+ // ============================================================================
12
+ // TARS 结构体定义
13
+ // ============================================================================
14
+ /**
15
+ * GetCdnTokenInfoReq 请求结构体
16
+ * 对应 Rust 代码中的 GetCdnTokenInfoReq
17
+ */
18
+ class GetCdnTokenInfoReq {
19
+ url;
20
+ cdnType;
21
+ streamName;
22
+ presenterUid;
23
+ constructor(url = "", streamName = "", cdnType = "", presenterUid = 0) {
24
+ this.url = url; // tag=0, String
25
+ this.cdnType = cdnType; // tag=1, String
26
+ this.streamName = streamName; // tag=2, String
27
+ this.presenterUid = presenterUid; // tag=3, Long/Int64
28
+ }
29
+ /**
30
+ * TARS 编码方法 - 将结构体写入输出流
31
+ * @param os - TARS 输出流
32
+ */
33
+ _writeTo(os) {
34
+ os.writeString(0, this.url);
35
+ os.writeString(1, this.cdnType);
36
+ os.writeString(2, this.streamName);
37
+ os.writeInt64(3, this.presenterUid);
38
+ }
39
+ /**
40
+ * TARS 解码方法 - 从输入流读取结构体
41
+ * @param is - TARS 输入流
42
+ */
43
+ _readFrom(is) {
44
+ this.url = is.readString(0, false, "");
45
+ this.cdnType = is.readString(1, false, "");
46
+ this.streamName = is.readString(2, false, "");
47
+ this.presenterUid = is.readInt64(3, false, 0);
48
+ }
49
+ }
50
+ /**
51
+ * HuyaGetTokenResp 响应结构体
52
+ * 对应 Rust 代码中的 HuyaGetTokenResp
53
+ */
54
+ class HuyaGetTokenResp {
55
+ url;
56
+ cdnType;
57
+ streamName;
58
+ presenterUid;
59
+ antiCode;
60
+ sTime;
61
+ flvAntiCode;
62
+ hlsAntiCode;
63
+ constructor() {
64
+ this.url = ""; // tag=0
65
+ this.cdnType = ""; // tag=1
66
+ this.streamName = ""; // tag=2
67
+ this.presenterUid = 0; // tag=3
68
+ this.antiCode = ""; // tag=4
69
+ this.sTime = ""; // tag=5
70
+ this.flvAntiCode = ""; // tag=6
71
+ this.hlsAntiCode = ""; // tag=7
72
+ }
73
+ /**
74
+ * TARS 编码方法 - 将结构体写入输出流
75
+ * @param os - TARS 输出流
76
+ */
77
+ _writeTo(os) {
78
+ os.writeString(0, this.url);
79
+ os.writeString(1, this.cdnType);
80
+ os.writeString(2, this.streamName);
81
+ os.writeInt64(3, this.presenterUid);
82
+ os.writeString(4, this.antiCode);
83
+ os.writeString(5, this.sTime);
84
+ os.writeString(6, this.flvAntiCode);
85
+ os.writeString(7, this.hlsAntiCode);
86
+ }
87
+ /**
88
+ * TARS 解码方法 - 从输入流读取结构体
89
+ * @param is - TARS 输入流
90
+ */
91
+ _readFrom(is) {
92
+ this.url = is.readString(0, false, "");
93
+ this.cdnType = is.readString(1, false, "");
94
+ this.streamName = is.readString(2, false, "");
95
+ this.presenterUid = is.readInt64(3, false, 0);
96
+ this.antiCode = is.readString(4, false, "");
97
+ this.sTime = is.readString(5, false, "");
98
+ this.flvAntiCode = is.readString(6, false, "");
99
+ this.hlsAntiCode = is.readString(7, false, "");
100
+ }
101
+ }
102
+ // ============================================================================
103
+ // WUP 协议处理
104
+ // ============================================================================
105
+ /**
106
+ * 构建 getCdnTokenInfo 请求
107
+ * 对应 Rust 中的 build_get_cdn_token_info_request 函数
108
+ *
109
+ * @param streamName - 流名称
110
+ * @param cdnType - CDN 类型 (例如: "AL")
111
+ * @param presenterUid - 主播 UID
112
+ * @returns TARS 编码的请求体
113
+ */
114
+ function buildGetCdnTokenInfoRequest(streamName, cdnType, presenterUid) {
115
+ // 1. 创建请求对象
116
+ const req = new GetCdnTokenInfoReq("", streamName, cdnType, presenterUid);
117
+ // 2. 创建 TUP 实例
118
+ const tup = new Tup();
119
+ // 3. 设置请求头信息
120
+ tup.tupVersion = 3; // version: 3
121
+ tup.requestId = 1; // request_id: 1
122
+ tup.servantName = "liveui"; // servant_name: "liveui"
123
+ tup.funcName = "getCdnTokenInfo"; // func_name: "getCdnTokenInfo"
124
+ // 4. 写入请求结构体到 body["tReq"]
125
+ tup.writeStruct("tReq", req);
126
+ // 5. 编码为二进制
127
+ const binBuffer = tup.encode();
128
+ return binBuffer.toNodeBuffer();
129
+ }
130
+ /**
131
+ * 解码 getCdnTokenInfo 响应
132
+ * 对应 Rust 中的 decode_get_cdn_token_info_response 函数
133
+ *
134
+ * @param responseBytes - 响应二进制数据
135
+ * @returns 解码后的响应对象
136
+ */
137
+ function decodeGetCdnTokenInfoResponse(responseBytes) {
138
+ // 1. 将 Node.js Buffer 转换为 BinBuffer
139
+ const binBuffer = new TarsStream.BinBuffer();
140
+ binBuffer.writeNodeBuffer(responseBytes);
141
+ // 2. 创建 TUP 实例并解码
142
+ const tup = new Tup();
143
+ tup.decode(binBuffer);
144
+ // 3. 读取响应结构体 body["tRsp"]
145
+ const resp = new HuyaGetTokenResp();
146
+ tup.readStruct("tRsp", resp);
147
+ // 4. 返回响应对象
148
+ return resp;
149
+ }
150
+ /**
151
+ * 发送 WUP 请求到虎牙服务器
152
+ *
153
+ * @param requestBody - 请求体
154
+ * @returns 响应体
155
+ */
156
+ async function sendWupRequest(requestBody) {
157
+ const response = await requester.post(WUP_URL, requestBody, {
158
+ headers: {
159
+ "User-Agent": WUP_UA,
160
+ Origin: HUYA_ORIGIN,
161
+ Referer: HUYA_ORIGIN,
162
+ "Content-Type": "application/octet-stream",
163
+ },
164
+ responseType: "arraybuffer",
165
+ });
166
+ return Buffer.from(response.data);
167
+ }
168
+ /**
169
+ * 获取虎牙流的真实 URL(使用 WUP 协议)
170
+ * 对应 Rust 中的 get_stream_url_wup 方法
171
+ *
172
+ * @param streamInfo - 流信息对象
173
+ * @returns 真实流 URL
174
+ */
175
+ export async function getStreamUrlWup(streamInfo) {
176
+ // 1. 提取参数
177
+ const { extras } = streamInfo;
178
+ if (!extras) {
179
+ throw new Error("Stream extras not found for WUP request");
180
+ }
181
+ const cdn = extras.cdn || "AL";
182
+ const streamName = extras.stream_name;
183
+ const presenterUid = extras.presenter_uid || 0;
184
+ if (!streamName) {
185
+ throw new Error("Stream name not found in extras");
186
+ }
187
+ // 2. 构建请求
188
+ const requestBody = buildGetCdnTokenInfoRequest(streamName, cdn, presenterUid);
189
+ // 3. 发送请求
190
+ const responseBytes = await sendWupRequest(requestBody);
191
+ // 4. 解码响应
192
+ const tokenInfo = decodeGetCdnTokenInfoResponse(responseBytes);
193
+ // 5. 获取防盗链参数
194
+ const antiCode = streamInfo.streamFormat === "flv" ? tokenInfo.flvAntiCode : tokenInfo.hlsAntiCode;
195
+ // 6. 解析原始 URL
196
+ const url = new URL(streamInfo.url);
197
+ const host = url.host;
198
+ const pathParts = url.pathname.split("/");
199
+ const pathPrefix = pathParts[1] || "";
200
+ const baseUrl = `${url.protocol}//${host}/${pathPrefix}`;
201
+ // 7. 确定文件后缀
202
+ const suffix = streamInfo.streamFormat;
203
+ // 8. 构建新 URL
204
+ const newUrl = `${baseUrl}/${streamName}.${suffix}?${antiCode}`;
205
+ return newUrl;
206
+ }
207
+ /**
208
+ * 简化版本:直接获取防盗链参数
209
+ *
210
+ * @param streamName - 流名称
211
+ * @param cdnType - CDN 类型
212
+ * @param presenterUid - 主播 UID
213
+ * @returns 包含 flvAntiCode 和 hlsAntiCode
214
+ */
215
+ export async function getCdnTokenInfo(streamName, cdnType = "AL", presenterUid = 0) {
216
+ const requestBody = buildGetCdnTokenInfoRequest(streamName, cdnType, presenterUid);
217
+ const responseBytes = await sendWupRequest(requestBody);
218
+ const tokenInfo = decodeGetCdnTokenInfoResponse(responseBytes);
219
+ return tokenInfo;
220
+ }
221
+ export {
222
+ // 类
223
+ GetCdnTokenInfoReq, HuyaGetTokenResp,
224
+ // 核心函数
225
+ buildGetCdnTokenInfoRequest, decodeGetCdnTokenInfoResponse, sendWupRequest,
226
+ // 常量
227
+ WUP_URL, WUP_UA, HUYA_ORIGIN, };
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import mitt from "mitt";
3
- import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createBaseRecorder, } from "@bililive-tools/manager";
3
+ import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, createDownloader, } from "@bililive-tools/manager";
4
4
  import { getInfo, getStream } from "./stream.js";
5
5
  import { ensureFolderExist } from "./utils.js";
6
6
  import HuYaDanMu from "huya-danma-listener";
@@ -13,9 +13,9 @@ function createRecorder(opts) {
13
13
  // @ts-ignore
14
14
  ...mitt(),
15
15
  ...opts,
16
+ cache: null,
16
17
  availableStreams: [],
17
18
  availableSources: [],
18
- qualityMaxRetry: opts.qualityRetry ?? 0,
19
19
  qualityRetry: opts.qualityRetry ?? 0,
20
20
  state: "idle",
21
21
  api: opts.api ?? "auto",
@@ -61,34 +61,9 @@ const ffmpegInputOptions = ["-rw_timeout", "10000000", "-timeout", "10000000"];
61
61
  const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
62
62
  // 如果已经在录制中,只在需要检查标题关键词时才获取最新信息
63
63
  if (this.recordHandle != null) {
64
- // 只有当设置了标题关键词时,并且不是手动启动的录制,才获取最新的直播间信息
65
- if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
66
- const now = Date.now();
67
- // 每5分钟检查一次标题变化
68
- const titleCheckInterval = 5 * 60 * 1000; // 5分钟
69
- // 获取上次检查时间
70
- const lastCheckTime = typeof this.extra.lastTitleCheckTime === "number" ? this.extra.lastTitleCheckTime : 0;
71
- // 如果距离上次检查时间不足指定间隔,则跳过检查
72
- if (now - lastCheckTime < titleCheckInterval) {
73
- return this.recordHandle;
74
- }
75
- // 更新检查时间
76
- this.extra.lastTitleCheckTime = now;
77
- // 获取直播间信息
78
- const liveInfo = await getInfo(this.channelId);
79
- const { title } = liveInfo;
80
- // 检查标题是否包含关键词
81
- if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
82
- this.state = "title-blocked";
83
- this.emit("DebugLog", {
84
- type: "common",
85
- text: `检测到标题包含关键词,停止录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
86
- });
87
- // 停止录制
88
- await this.recordHandle.stop("直播间标题包含关键词");
89
- // 返回 null,停止录制
90
- return null;
91
- }
64
+ const shouldStop = await utils.checkTitleKeywordsWhileRecording(this, isManualStart, getInfo);
65
+ if (shouldStop) {
66
+ return null;
92
67
  }
93
68
  // 已经在录制中,直接返回
94
69
  return this.recordHandle;
@@ -103,7 +78,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
103
78
  this.state = "check-error";
104
79
  throw error;
105
80
  }
106
- const { living, owner, title, startTime } = this.liveInfo;
81
+ const { living, owner, title, liveStartTime, recordStartTime } = this.liveInfo;
107
82
  if (this.liveInfo.liveId === banLiveId) {
108
83
  this.tempStopIntervalCheck = true;
109
84
  }
@@ -114,44 +89,26 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
114
89
  return null;
115
90
  if (!living)
116
91
  return null;
117
- // 检查标题是否包含关键词,如果包含则不自动录制
118
- // 手动开始录制时不检查标题关键词
119
- if (utils.shouldCheckTitleKeywords(isManualStart, this.titleKeywords)) {
120
- if (utils.hasBlockedTitleKeywords(title, this.titleKeywords)) {
121
- this.state = "title-blocked";
122
- this.emit("DebugLog", {
123
- type: "common",
124
- text: `跳过录制:直播间标题 "${title}" 包含关键词 "${this.titleKeywords}"`,
125
- });
126
- return null;
127
- }
128
- }
92
+ // 检查标题是否包含关键词
93
+ if (utils.checkTitleKeywordsBeforeRecord(title, this, isManualStart))
94
+ return null;
95
+ const qualityRetryLeft = (await this.cache.get("qualityRetryLeft")) ?? this.qualityRetry;
96
+ const strictQuality = utils.shouldUseStrictQuality(qualityRetryLeft, this.qualityRetry, isManualStart);
129
97
  let res;
130
- // TODO: 先不做什么错误处理,就简单包一下预期上会有错误的地方
131
98
  try {
132
- let strictQuality = false;
133
- if (this.qualityRetry > 0) {
134
- strictQuality = true;
135
- }
136
- if (this.qualityMaxRetry < 0) {
137
- strictQuality = true;
138
- }
139
- if (isManualStart) {
140
- strictQuality = false;
141
- }
142
99
  res = await getStream({
143
100
  channelId: this.channelId,
144
101
  quality: this.quality,
145
102
  streamPriorities: this.streamPriorities,
146
103
  sourcePriorities: this.sourcePriorities,
147
- api: this.api,
104
+ api: this.api, //"wup"
148
105
  strictQuality,
149
106
  formatPriorities: this.formatPriorities,
150
107
  });
151
108
  }
152
109
  catch (err) {
153
- if (this.qualityRetry > 0)
154
- this.qualityRetry -= 1;
110
+ if (qualityRetryLeft > 0)
111
+ await this.cache.set("qualityRetryLeft", qualityRetryLeft - 1);
155
112
  this.state = "check-error";
156
113
  throw err;
157
114
  }
@@ -162,12 +119,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
162
119
  this.usedStream = stream.name;
163
120
  this.usedSource = stream.source;
164
121
  let isEnded = false;
165
- let isCutting = false;
166
122
  const onEnd = (...args) => {
167
- if (isCutting) {
168
- isCutting = false;
169
- return;
170
- }
171
123
  if (isEnded)
172
124
  return;
173
125
  isEnded = true;
@@ -178,8 +130,11 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
178
130
  const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
179
131
  this.recordHandle?.stop(reason);
180
132
  };
181
- const recordStartTime = new Date();
182
- const recorder = createBaseRecorder(this.recorderType, {
133
+ let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36";
134
+ if (res.api === "wup") {
135
+ ua = "HYSDK(Windows,30000002)_APP(pc_exe&7030003&official)_SDK(trans&2.29.0.5493)";
136
+ }
137
+ const downloader = createDownloader(this.recorderType, {
183
138
  url: stream.url,
184
139
  outputOptions: ffmpegOutputOptions,
185
140
  inputOptions: ffmpegInputOptions,
@@ -188,12 +143,15 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
188
143
  owner,
189
144
  title: opts.title ?? title,
190
145
  startTime: opts.startTime,
191
- liveStartTime: startTime,
146
+ liveStartTime,
192
147
  recordStartTime,
193
148
  }),
194
149
  disableDanma: this.disableProvideCommentsWhenRecording,
195
150
  videoFormat: this.videoFormat ?? "auto",
196
151
  debugLevel: this.debugLevel ?? "none",
152
+ headers: {
153
+ "User-Agent": ua,
154
+ },
197
155
  }, onEnd, async () => {
198
156
  const info = await getInfo(this.channelId);
199
157
  return info;
@@ -202,7 +160,7 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
202
160
  owner,
203
161
  title,
204
162
  startTime: Date.now(),
205
- liveStartTime: startTime,
163
+ liveStartTime,
206
164
  recordStartTime,
207
165
  });
208
166
  try {
@@ -220,24 +178,24 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
220
178
  if (cover && this?.liveInfo) {
221
179
  this.liveInfo.cover = cover;
222
180
  }
223
- const extraDataController = recorder.getExtraDataController();
181
+ const extraDataController = downloader.getExtraDataController();
224
182
  extraDataController?.setMeta({
225
183
  room_id: this.channelId,
226
184
  platform: provider?.id,
227
- liveStartTimestamp: this?.liveInfo?.startTime?.getTime(),
185
+ liveStartTimestamp: this?.liveInfo?.liveStartTime?.getTime(),
228
186
  // recordStopTimestamp: Date.now(),
229
187
  title: title,
230
188
  user_name: owner,
231
189
  });
232
190
  };
233
- recorder.on("videoFileCreated", handleVideoCreated);
234
- recorder.on("videoFileCompleted", ({ filename }) => {
191
+ downloader.on("videoFileCreated", handleVideoCreated);
192
+ downloader.on("videoFileCompleted", ({ filename }) => {
235
193
  this.emit("videoFileCompleted", { filename });
236
194
  });
237
- recorder.on("DebugLog", (data) => {
195
+ downloader.on("DebugLog", (data) => {
238
196
  this.emit("DebugLog", data);
239
197
  });
240
- recorder.on("progress", (progress) => {
198
+ downloader.on("progress", (progress) => {
241
199
  if (this.recordHandle) {
242
200
  this.recordHandle.progress = progress;
243
201
  }
@@ -245,9 +203,14 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
245
203
  });
246
204
  let client = null;
247
205
  if (!this.disableProvideCommentsWhenRecording) {
248
- client = new HuYaDanMu(this.channelId);
206
+ client = new HuYaDanMu({
207
+ roomid: this.channelId,
208
+ uid: res.currentStream.uid,
209
+ subChannelId: res.currentStream.subChannelId,
210
+ channelId: res.currentStream.channelId,
211
+ });
249
212
  client.on("message", (msg) => {
250
- const extraDataController = recorder.getExtraDataController();
213
+ const extraDataController = downloader.getExtraDataController();
251
214
  if (!extraDataController)
252
215
  return;
253
216
  switch (msg.type) {
@@ -299,17 +262,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
299
262
  });
300
263
  client.start();
301
264
  }
302
- const ffmpegArgs = recorder.getArguments();
303
- recorder.run();
265
+ const downloaderArgs = downloader.getArguments();
266
+ downloader.run();
304
267
  const cut = utils.singleton(async () => {
305
268
  if (!this.recordHandle)
306
269
  return;
307
- if (isCutting)
308
- return;
309
- isCutting = true;
310
- await recorder.stop();
311
- recorder.createCommand();
312
- recorder.run();
270
+ downloader.cut();
313
271
  });
314
272
  const stop = utils.singleton(async (reason) => {
315
273
  if (!this.recordHandle)
@@ -317,12 +275,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
317
275
  this.state = "stopping-record";
318
276
  try {
319
277
  client?.stop();
320
- await recorder.stop();
278
+ await downloader.stop();
321
279
  }
322
280
  catch (err) {
323
281
  this.emit("DebugLog", {
324
282
  type: "error",
325
- text: `stop ffmpeg error: ${String(err)}`,
283
+ text: `stop record error: ${String(err)}`,
326
284
  });
327
285
  }
328
286
  this.usedStream = undefined;
@@ -331,14 +289,15 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
331
289
  this.recordHandle = undefined;
332
290
  this.liveInfo = undefined;
333
291
  this.state = "idle";
292
+ this.cache.set("qualityRetryLeft", this.qualityRetry);
334
293
  });
335
294
  this.recordHandle = {
336
295
  id: genRecordUUID(),
337
296
  stream: stream.name,
338
297
  source: stream.source,
339
- recorderType: recorder.type,
298
+ recorderType: downloader.type,
340
299
  url: stream.url,
341
- ffmpegArgs,
300
+ downloaderArgs,
342
301
  savePath: savePath,
343
302
  stop,
344
303
  cut,
package/lib/stream.d.ts CHANGED
@@ -7,19 +7,24 @@ export declare function getInfo(channelId: string): Promise<{
7
7
  roomId: number;
8
8
  avatar: string;
9
9
  cover: string;
10
- startTime: Date;
10
+ liveStartTime: Date;
11
11
  liveId: string;
12
+ recordStartTime: Date;
12
13
  }>;
13
14
  export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" | "streamPriorities" | "sourcePriorities" | "api" | "formatPriorities"> & {
14
15
  strictQuality?: boolean;
15
- api?: "web" | "mp" | "auto";
16
+ api?: "web" | "mp" | "auto" | "wup";
16
17
  }): Promise<{
17
18
  currentStream: {
18
19
  name: string;
19
20
  source: string;
21
+ uid: number;
22
+ subChannelId: number;
23
+ channelId: number;
20
24
  url: string;
21
25
  };
22
26
  living: boolean;
27
+ api: string;
23
28
  id: number;
24
29
  owner: string;
25
30
  title: string;
package/lib/stream.js CHANGED
@@ -2,9 +2,11 @@ import { sortBy } from "lodash-es";
2
2
  import { HuYaQualities } from "@bililive-tools/manager";
3
3
  import { getRoomInfo as getRoomInfoByWeb } from "./huya_api.js";
4
4
  import { getRoomInfo as getRoomInfoByMobile } from "./huya_mobile_api.js";
5
+ import { getStreamUrlWup } from "./huya_wup_api.js";
5
6
  import { assert } from "./utils.js";
6
7
  export async function getInfo(channelId) {
7
8
  const info = await getRoomInfoByWeb(channelId);
9
+ const recordStartTime = new Date();
8
10
  return {
9
11
  living: info.living,
10
12
  owner: info.owner,
@@ -12,8 +14,9 @@ export async function getInfo(channelId) {
12
14
  avatar: info.avatar,
13
15
  cover: info.cover,
14
16
  roomId: info.roomId,
15
- startTime: info.startTime,
17
+ liveStartTime: info.startTime,
16
18
  liveId: info.liveId,
19
+ recordStartTime: recordStartTime,
17
20
  };
18
21
  }
19
22
  async function getRoomInfo(channelId, options) {
@@ -23,7 +26,11 @@ async function getRoomInfo(channelId, options) {
23
26
  quality: options.quality,
24
27
  });
25
28
  if (info.gid == 1663) {
26
- return getRoomInfoByMobile(channelId, options.formatPriorities);
29
+ return getRoomInfo(channelId, {
30
+ api: "wup",
31
+ formatPriorities: options.formatPriorities,
32
+ quality: options.quality,
33
+ });
27
34
  }
28
35
  return info;
29
36
  }
@@ -36,6 +43,14 @@ async function getRoomInfo(channelId, options) {
36
43
  quality: options.quality,
37
44
  });
38
45
  }
46
+ else if (options.api == "wup") {
47
+ // 参数与web一致,之后anticode有额外处理
48
+ const info = await getRoomInfoByWeb(channelId, {
49
+ formatPriorities: options.formatPriorities,
50
+ quality: options.quality,
51
+ });
52
+ return { ...info, api: "wup" };
53
+ }
39
54
  assert(false, "Invalid api");
40
55
  }
41
56
  export async function getStream(opts) {
@@ -78,6 +93,26 @@ export async function getStream(opts) {
78
93
  }
79
94
  }
80
95
  let url = expectSource.url;
96
+ if (info.api === "wup") {
97
+ try {
98
+ let newUrl = await getStreamUrlWup({
99
+ url,
100
+ streamFormat: expectSource.suffix,
101
+ extras: {
102
+ stream_name: expectSource.streamName,
103
+ cdn: expectSource.name,
104
+ presenter_uid: expectSource.presenterUid,
105
+ },
106
+ });
107
+ if (!newUrl.includes("codec=")) {
108
+ newUrl += "&codec=264";
109
+ }
110
+ url = newUrl;
111
+ }
112
+ catch (e) {
113
+ console.warn("Get stream url by WUP failed, fallback to original url", e);
114
+ }
115
+ }
81
116
  // MP协议下原画不需要添加ratio参数
82
117
  if (expectStream.bitRate && expectStream.bitRate !== -1 && !url.includes("ratio=")) {
83
118
  url = url + "&ratio=" + expectStream.bitRate;
@@ -87,6 +122,9 @@ export async function getStream(opts) {
87
122
  currentStream: {
88
123
  name: expectStream.desc,
89
124
  source: expectSource.name,
125
+ uid: expectSource.presenterUid,
126
+ subChannelId: expectSource.subChannelId,
127
+ channelId: expectSource.channelId,
90
128
  url,
91
129
  },
92
130
  };
package/lib/types.d.ts CHANGED
@@ -1,12 +1,6 @@
1
1
  export interface StreamResult {
2
- flv: {
3
- name: string;
4
- url: string;
5
- }[];
6
- hls: {
7
- name: string;
8
- url: string;
9
- }[];
2
+ flv: SourceProfile[];
3
+ hls: SourceProfile[];
10
4
  }
11
5
  export interface StreamProfile {
12
6
  desc: string;
@@ -15,4 +9,9 @@ export interface StreamProfile {
15
9
  export interface SourceProfile {
16
10
  name: string;
17
11
  url: string;
12
+ streamName: string;
13
+ presenterUid: number;
14
+ subChannelId: number;
15
+ channelId: number;
16
+ suffix: string;
18
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/huya-recorder",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "bililive-tools huya recorder implemention",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -34,13 +34,13 @@
34
34
  "author": "renmu123",
35
35
  "license": "LGPL",
36
36
  "dependencies": {
37
- "mitt": "^3.0.1",
38
- "lodash-es": "^4.17.21",
37
+ "@tars/stream": "^2.0.3",
39
38
  "axios": "^1.7.8",
40
- "huya-danma-listener": "0.1.3",
41
- "@bililive-tools/manager": "^1.9.0"
39
+ "lodash-es": "^4.17.21",
40
+ "mitt": "^3.0.1",
41
+ "@bililive-tools/manager": "^1.10.0",
42
+ "huya-danma-listener": "0.1.4"
42
43
  },
43
- "devDependencies": {},
44
44
  "scripts": {
45
45
  "build": "tsc",
46
46
  "watch": "tsc -w"