@bililive-tools/huya-recorder 1.0.1 → 1.1.1

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
@@ -6,6 +6,8 @@
6
6
 
7
7
  # 安装
8
8
 
9
+ **建议所有录制器和manager包都升级到最新版,我不会对兼容性做过多考虑**
10
+
9
11
  `npm i @bililive-tools/huya-recorder @bililive-tools/manager`
10
12
 
11
13
  # 使用
@@ -18,7 +20,7 @@ const manager = createRecorderManager({ providers: [provider] });
18
20
  manager.addRecorder({
19
21
  providerId: provider.id,
20
22
  channelId: "7734200",
21
- quality: "highest",
23
+ quality: 0,
22
24
  streamPriorities: [],
23
25
  sourcePriorities: [],
24
26
  });
@@ -33,28 +35,55 @@ 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
- sourcePriorities: []; // 废弃
40
+ sourcePriorities: []; // 按提供的源优先级去给CDN列表排序,并过滤掉不在优先级配置中的源,在未匹配到的情况下会优先使用TX的CDN,具体参数见 CDN 参数
41
+ formatName?: "auto" | "flv" | "hls"; // 支持 flv,hls参数,默认使用flv,具体见文档
39
42
  disableAutoCheck?: boolean; // 为 true 时 manager 将跳过自动检查
40
- segment?: number; // 分段参数
43
+ segment?: number; // 分段参数,单位分钟
41
44
  disableProvideCommentsWhenRecording?: boolean; // 禁用弹幕录制
42
45
  saveGiftDanma?: boolean; // 保存礼物弹幕
43
46
  saveCover?: boolean; // 保存封面
47
+ api?: "auto" | "mp" | "web"; // 默认为auto,在星秀区使用mp接口,其他使用web接口,你也可以强制指定
44
48
  }
45
49
  ```
46
50
 
47
51
  ### 画质
48
52
 
49
- 遗漏了部分画质,有了解的可以提PR
50
-
51
- | 画质 | |
52
- | ------ | --- |
53
- | 原画 | 0 |
54
- | 蓝光8M | 8 |
55
- | 蓝光4M | 4 |
56
- | 超清 | 3 |
57
- | 高清 | 2 |
53
+ | 画质 | 值 |
54
+ | -------- | ----- |
55
+ | 2K HDR | 14100 |
56
+ | 2K | 14000 |
57
+ | HDR(10M) | 4200 |
58
+ | 原画 | 0 |
59
+ | 蓝光20M | 20000 |
60
+ | 蓝光10M | 10000 |
61
+ | 蓝光8M | 8000 |
62
+ | 蓝光4M | 4000 |
63
+ | 超清 | 2000 |
64
+ | 流畅 | 500 |
65
+
66
+ ### CDN
67
+
68
+ 不同直播间可能支持的cdn并不一致
69
+
70
+ | 画质 | 值 |
71
+ | -------------------------- | ---- |
72
+ | 阿里 | AL |
73
+ | 腾讯 | TX |
74
+ | 华为 | HW |
75
+ | 火山 | HS |
76
+ | 网宿 | WS |
77
+ | 阿里13 | AL13 |
78
+ | 腾讯15 | TX15 |
79
+ | 华为16 | HW16 |
80
+ | 不知道是啥(可能是虎牙自建) | HYZJ |
81
+
82
+ ### 流格式
83
+
84
+ hls 可能并不适合 mp 的 api,也许你能找到可以使用的cdn
85
+
86
+ 支持 hls 和 flv
58
87
 
59
88
  ## 直播间ID解析
60
89
 
@@ -63,7 +92,7 @@ interface Options {
63
92
  ```ts
64
93
  import { provider } from "@bililive-tools/huya-recorder";
65
94
 
66
- const url = "https://live.bilibili.com/5055636";
95
+ const url = "https://www.huya.com/910323";
67
96
  const { id } = await provider.resolveChannelInfoFromURL(url);
68
97
  ```
69
98
 
package/lib/anticode.d.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * 该文件是从 vplayer.js 中提取出来的,保留的部分注释掉的代码是为了标明改动前的代码逻辑
3
3
  */
4
4
  export declare function initInfo(info: {
5
- sFlvUrl: string;
6
- sFlvAntiCode: string;
5
+ baseUrl: string;
6
+ antiCode: string;
7
7
  sStreamName: string;
8
8
  _sessionId: number;
9
+ suffix: string;
9
10
  }, t?: unknown): any;
package/lib/anticode.js CHANGED
@@ -12,8 +12,8 @@ const PLATFORM_TYPE = {
12
12
  web: 100,
13
13
  };
14
14
  const PLATFORM_TYPE_NAME = {
15
- web: 'web',
16
- wap: 'wap',
15
+ web: "web",
16
+ wap: "wap",
17
17
  };
18
18
  function pe(e, t) {
19
19
  var i = (65535 & e) + (65535 & t);
@@ -112,7 +112,7 @@ function ge(e, t) {
112
112
  return [o, h, u, l];
113
113
  }
114
114
  function Se(e) {
115
- var t, i = '', s = 32 * e.length;
115
+ var t, i = "", s = 32 * e.length;
116
116
  for (t = 0; t < s; t += 8)
117
117
  i += String.fromCharCode((e[t >> 5] >>> t % 32) & 255);
118
118
  return i;
@@ -127,9 +127,10 @@ function Te(e) {
127
127
  return i;
128
128
  }
129
129
  function Pe(e) {
130
- var t, i, s = '';
130
+ var t, i, s = "";
131
131
  for (i = 0; i < e.length; i += 1)
132
- (t = e.charCodeAt(i)), (s += '0123456789abcdef'.charAt((t >>> 4) & 15) + '0123456789abcdef'.charAt(15 & t));
132
+ (t = e.charCodeAt(i)),
133
+ (s += "0123456789abcdef".charAt((t >>> 4) & 15) + "0123456789abcdef".charAt(15 & t));
133
134
  return s;
134
135
  }
135
136
  function Ee(e) {
@@ -166,74 +167,78 @@ class Anticode {
166
167
  }
167
168
  parseAnticode(e) {
168
169
  var self = this;
169
- this._fm = '';
170
- this['_wsTime'] = '';
171
- this['_ctype'] = '';
172
- this['_params'] = [];
170
+ this._fm = "";
171
+ this["_wsTime"] = "";
172
+ this["_ctype"] = "";
173
+ this["_params"] = [];
173
174
  this._sFlvAnticode = e;
174
- e.split('&').forEach(function (e) {
175
- let [key, val] = e.split('=');
176
- if (key === 'fm') {
175
+ e.split("&").forEach(function (e) {
176
+ let [key, val] = e.split("=");
177
+ if (key === "fm") {
177
178
  val = decodeURI(val);
178
179
  val = unescape(val);
179
180
  val = atob(val);
180
181
  self._fm = val;
181
182
  }
182
- else if (key === 'wsTime') {
183
- self['_wsTime'] = val;
183
+ else if (key === "wsTime") {
184
+ self["_wsTime"] = val;
184
185
  var r = 1e3 * parseInt(val, 16) + 3e5;
185
- self['_invalidTime'] = performance.now() + (r - Date.now());
186
- self['_nextRefreshTime'] = self['_invalidTime'] - 3e4;
186
+ self["_invalidTime"] = performance.now() + (r - Date.now());
187
+ self["_nextRefreshTime"] = self["_invalidTime"] - 3e4;
187
188
  }
188
189
  else
189
- key == 'ctype' ? (self['_ctype'] = val) : key !== 'wsSecret' && self['_params'].push(e);
190
+ key == "ctype" ? (self["_ctype"] = val) : key !== "wsSecret" && self["_params"].push(e);
190
191
  });
191
192
  }
192
193
  hasAnticode() {
193
- return '' !== this._sFlvAnticode;
194
+ return "" !== this._sFlvAnticode;
194
195
  }
195
196
  getAnticode(e) {
196
- if ('' === this._fm)
197
+ if ("" === this._fm)
197
198
  return this._sFlvAnticode;
198
- const platform = 'web';
199
+ const platform = "web";
199
200
  const i = PLATFORM_TYPE[platform] || PLATFORM_TYPE.web;
200
201
  this._seqid = Number(this.uid) + Date.now();
201
- var s = keHash(''.concat(this._seqid, '|').concat(this._ctype, '|').concat(i));
202
+ var s = keHash("".concat(this._seqid, "|").concat(this._ctype, "|").concat(i));
202
203
  var uid = platform === PLATFORM_TYPE_NAME.wap ? this.uid : this.convertUid;
203
- var r = this._fm.replace('$0', uid).replace('$1', this._sStreamName).replace('$2', s).replace('$3', this._wsTime);
204
+ var r = this._fm
205
+ .replace("$0", uid)
206
+ .replace("$1", this._sStreamName)
207
+ .replace("$2", s)
208
+ .replace("$3", this._wsTime);
204
209
  if (e)
205
210
  r += Ye;
206
- var n = ''
207
- .concat('wsSecret')
208
- .concat('=')
211
+ var n = ""
212
+ .concat("wsSecret")
213
+ .concat("=")
209
214
  .concat(keHash(r))
210
- .concat('&')
211
- .concat('wsTime')
212
- .concat('=')
215
+ .concat("&")
216
+ .concat("wsTime")
217
+ .concat("=")
213
218
  .concat(this._wsTime)
214
- .concat('&')
215
- .concat('seqid')
216
- .concat('=')
219
+ .concat("&")
220
+ .concat("seqid")
221
+ .concat("=")
217
222
  .concat(this._seqid)
218
- .concat('&')
219
- .concat('ctype')
220
- .concat('=')
223
+ .concat("&")
224
+ .concat("ctype")
225
+ .concat("=")
221
226
  .concat(this._ctype)
222
- .concat('&')
223
- .concat('ver=1');
227
+ .concat("&")
228
+ .concat("ver=1");
224
229
  if (this._params.length > 0) {
225
- n += '&' + this._params.join('&');
230
+ n += "&" + this._params.join("&");
226
231
  }
227
232
  return n;
228
233
  }
229
234
  }
230
235
  export function initInfo(info, t) {
231
- if (info.sFlvUrl && info.sStreamName) {
232
- info.url = ''.concat(info.sFlvUrl, '/').concat(info.sStreamName, '.flv');
236
+ if (info.baseUrl && info.sStreamName) {
237
+ info.url = "".concat(info.baseUrl, "/").concat(info.sStreamName, `.${info.suffix}`);
233
238
  }
234
239
  var i = [];
235
240
  const anticode = new Anticode();
236
- anticode.init(info.sFlvUrl, info.sStreamName, info.sFlvAntiCode);
241
+ anticode.init(info.baseUrl, info.sStreamName, info.antiCode);
237
242
  if (anticode.hasAnticode()) {
238
243
  i.push(anticode.getAnticode(t));
239
244
  // c.a.log('FlvPlayer.initInfo, anticode='.concat(this.anticode.getAnticode(t)))
@@ -245,7 +250,7 @@ export function initInfo(info, t) {
245
250
  // } else {
246
251
  // localStorage._ratio && i.push('&ratio='.concat(localStorage._ratio))
247
252
  // }
248
- i.push('ratio='.concat(info.curBitrate));
253
+ // i.push('ratio='.concat(info.curBitrate))
249
254
  // if (
250
255
  // !info.httpDomainOnly &&
251
256
  // !(
@@ -257,34 +262,38 @@ export function initInfo(info, t) {
257
262
  // i.push('&https=1')
258
263
  // }
259
264
  if (i.length > 0) {
260
- info.url += (-1 !== info.url.indexOf('?') ? '&' : '?') + ''.concat(i.join('&'));
265
+ info.url += (-1 !== info.url.indexOf("?") ? "&" : "?") + "".concat(i.join("&"));
261
266
  }
262
267
  // if (this.isUseAV1(e)) e.url += '&codec=av1'
263
268
  // else if (this.h265Proxy.isReady || this.h265Proxy.isH265MseCodec) this.h265Proxy.checkUrl(e)
264
- info.url += '&codec=264';
269
+ info.url += "&codec=264";
265
270
  // info.url += '&dMod='.concat(getDMod(info._dMod, info._sMod))
266
271
  // this.url = info.url
267
272
  // this.lineType = info.lineType
268
- info.url = _getUrlParams(info.url, !1, 'firstCdn');
273
+ info.url = _getUrlParams(info.url, !1, "firstCdn");
269
274
  function _getUrlParams(url, t, i) {
270
- var s = '';
275
+ var s = "";
271
276
  // s += t ? "pulltype=pcdn&" : "sdkPcdn=".concat(this._cdnCnt, "_").concat(this._getSdkPcdnReason(i), "&");
272
- s += t ? 'pulltype=pcdn&' : 'sdkPcdn='.concat(1, '_').concat(1, '&');
273
- var platform = 'web';
277
+ s += t ? "pulltype=pcdn&" : "sdkPcdn=".concat(1, "_").concat(1, "&");
278
+ var platform = "web";
274
279
  var r = PLATFORM_TYPE[platform] || PLATFORM_TYPE.web;
275
280
  var n = platform === PLATFORM_TYPE_NAME.wap
276
- ? 'uid='.concat(anticode.uid, '&uuid=').concat(anticode.uuid)
277
- : 'u='.concat(anticode.convertUid);
281
+ ? "uid=".concat(anticode.uid, "&uuid=").concat(anticode.uuid)
282
+ : "u=".concat(anticode.convertUid);
278
283
  // s += ''.concat(n, '&t=').concat(r, '&sv=').concat(2401040319, '&sdk_sid=').concat(this._flvPlayer.info._sessionId)
279
- s += ''.concat(n, '&t=').concat(r, '&sv=').concat(2401040319, '&sdk_sid=').concat(info._sessionId);
280
- -1 === url.indexOf('?') ? (url += '?') : (url += '&');
284
+ s += ""
285
+ .concat(n, "&t=")
286
+ .concat(r, "&sv=")
287
+ .concat(2401040319, "&sdk_sid=")
288
+ .concat(info._sessionId);
289
+ -1 === url.indexOf("?") ? (url += "?") : (url += "&");
281
290
  return url + s;
282
291
  }
283
292
  return info.url;
284
293
  }
285
294
  function getDMod(e, t) {
286
- return ('mseh' !== e && 'wcs' !== e && 'wasm' !== e && 'mses' !== e && (e = 'unknow'),
295
+ return ("mseh" !== e && "wcs" !== e && "wasm" !== e && "mses" !== e && (e = "unknow"),
287
296
  (t = Number(t)),
288
297
  isNaN(t) && (t = 0),
289
- ''.concat(e, '-').concat(t));
298
+ "".concat(e, "-").concat(t));
290
299
  }
package/lib/huya_api.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare function getRoomInfo(roomIdOrShortId: string): Promise<{
1
+ export declare function getRoomInfo(roomIdOrShortId: string, formatName?: "auto" | "flv" | "hls"): Promise<{
2
2
  living: boolean;
3
3
  id: number;
4
4
  owner: string;
@@ -7,9 +7,13 @@ export declare function getRoomInfo(roomIdOrShortId: string): Promise<{
7
7
  avatar: string;
8
8
  cover: string;
9
9
  streams: StreamProfile[];
10
- sources: SourceProfile[];
10
+ sources: {
11
+ name: string;
12
+ url: string;
13
+ }[];
11
14
  startTime: Date;
12
15
  liveId: string;
16
+ gid: number;
13
17
  }>;
14
18
  export interface StreamProfile {
15
19
  desc: string;
package/lib/huya_api.js CHANGED
@@ -5,7 +5,7 @@ import { initInfo } from "./anticode.js";
5
5
  const requester = axios.create({
6
6
  timeout: 10e3,
7
7
  });
8
- export async function getRoomInfo(roomIdOrShortId) {
8
+ export async function getRoomInfo(roomIdOrShortId, formatName = "auto") {
9
9
  const res = await requester.get(`https://www.huya.com/${roomIdOrShortId}`);
10
10
  const html = res.data;
11
11
  const match = html.match(/var hyPlayerConfig = ({[^]+?};)/);
@@ -20,15 +20,47 @@ export async function getRoomInfo(roomIdOrShortId) {
20
20
  }));
21
21
  const data = hyPlayerConfig.stream.data[0];
22
22
  assert(data, `Unexpected resp, data is null`);
23
- const sources = data.gameStreamInfoList.map((info) => ({
24
- name: `直播线路 ${info.iLineIndex}`,
25
- url: initInfo({
26
- sFlvUrl: info.sFlvUrl,
27
- sStreamName: info.sStreamName,
28
- sFlvAntiCode: info.sFlvAntiCode,
29
- _sessionId: Date.now(),
30
- }),
31
- }));
23
+ const sources = {
24
+ flv: [],
25
+ hls: [],
26
+ };
27
+ // const sources: SourceProfile[] = data.gameStreamInfoList.map((info) => ({
28
+ // name: info.sCdnType,
29
+ // url: initInfo({
30
+ // sFlvUrl: info.sFlvUrl,
31
+ // sStreamName: info.sStreamName,
32
+ // sFlvAntiCode: info.sFlvAntiCode,
33
+ // _sessionId: Date.now(),
34
+ // }),
35
+ // }));
36
+ for (const item of data?.gameStreamInfoList ?? []) {
37
+ if (item.sFlvAntiCode && item.sFlvAntiCode.length > 0) {
38
+ const url = initInfo({
39
+ baseUrl: item.sFlvUrl,
40
+ sStreamName: item.sStreamName,
41
+ antiCode: item.sFlvAntiCode,
42
+ suffix: item.sFlvUrlSuffix,
43
+ _sessionId: Date.now(),
44
+ });
45
+ sources.flv.push({
46
+ name: item.sCdnType,
47
+ url,
48
+ });
49
+ }
50
+ if (item.sHlsAntiCode && item.sHlsAntiCode.length > 0) {
51
+ const url = initInfo({
52
+ baseUrl: item.sHlsUrl,
53
+ sStreamName: item.sStreamName,
54
+ antiCode: item.sHlsAntiCode,
55
+ suffix: item.sHlsUrlSuffix,
56
+ _sessionId: Date.now(),
57
+ });
58
+ sources.hls.push({
59
+ name: item.sCdnType,
60
+ url,
61
+ });
62
+ }
63
+ }
32
64
  const startTime = new Date(data.gameLiveInfo?.startTime * 1000);
33
65
  return {
34
66
  living: vMultiStreamInfo.length > 0 && data.gameStreamInfoList.length > 0,
@@ -39,8 +71,9 @@ export async function getRoomInfo(roomIdOrShortId) {
39
71
  avatar: data.gameLiveInfo.avatar180,
40
72
  cover: data.gameLiveInfo.screenshot,
41
73
  streams,
42
- sources,
74
+ sources: formatName === "hls" ? sources.hls : sources.flv,
43
75
  startTime,
44
76
  liveId: utils.md5(`${roomIdOrShortId}-${startTime?.getTime()}`),
77
+ gid: data.gameLiveInfo.gid,
45
78
  };
46
79
  }
@@ -0,0 +1,24 @@
1
+ export declare function getRoomInfo(roomIdOrShortId: string, formatName?: "auto" | "flv" | "hls"): Promise<{
2
+ living: boolean;
3
+ id: number;
4
+ owner: string;
5
+ title: string;
6
+ roomId: number;
7
+ avatar: string;
8
+ cover: string;
9
+ streams: StreamProfile[];
10
+ sources: {
11
+ name: string;
12
+ url: string;
13
+ }[];
14
+ startTime: Date;
15
+ liveId: string;
16
+ }>;
17
+ export interface StreamProfile {
18
+ desc: string;
19
+ bitRate: number;
20
+ }
21
+ export interface SourceProfile {
22
+ name: string;
23
+ url: string;
24
+ }
@@ -0,0 +1,83 @@
1
+ // import { createHash, randomInt } from "node:crypto";
2
+ // import { URLSearchParams } from "node:url";
3
+ import axios from "axios";
4
+ import { utils } from "@bililive-tools/manager";
5
+ import { assert } from "./utils.js";
6
+ const requester = axios.create({
7
+ timeout: 10e3,
8
+ headers: {
9
+ "User-Agent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko)",
10
+ },
11
+ });
12
+ export async function getRoomInfo(roomIdOrShortId, formatName = "auto") {
13
+ const res = await requester.get(`https://mp.huya.com/cache.php?m=Live&do=profileRoom&roomid=${roomIdOrShortId}`);
14
+ const html = res.data;
15
+ assert(html, `Unexpected resp, hyPlayerConfig is null`);
16
+ if (res.status !== 200) {
17
+ throw new Error(`Unexpected resp, status is ${res.status}`);
18
+ }
19
+ const profile = html.data;
20
+ const sources = {
21
+ flv: [],
22
+ hls: [],
23
+ };
24
+ // const uid = await getAnonymousUid();
25
+ for (const item of profile?.stream?.baseSteamInfoList ?? []) {
26
+ if (item.sFlvAntiCode && item.sFlvAntiCode.length > 0) {
27
+ // const { uid, urlQuery, seq_id, ws_time, ws_secret } = generateStreamParams({
28
+ // sFlvAntiCode: item.sFlvAntiCode,
29
+ // sStreamName: item.sStreamName,
30
+ // });
31
+ // console.log("uid", uid, urlQuery);
32
+ // const url = `${item.sFlvUrl}/${item.sStreamName}.${item.sFlvUrlSuffix}?wsSecret=${ws_secret}&wsTime=${ws_time}&seqid=${seq_id}&ctype=${urlQuery.get("ctype")}&ver=1&fs=${urlQuery.get("fs")}&t=${urlQuery.get("t")}&uid=${uid}`;
33
+ const url = `${item.sFlvUrl}/${item.sStreamName}.${item.sFlvUrlSuffix}?${item.sFlvAntiCode}`;
34
+ sources.flv.push({
35
+ name: item.sCdnType,
36
+ url,
37
+ });
38
+ }
39
+ if (item.sHlsAntiCode && item.sHlsAntiCode.length > 0) {
40
+ const url = `${item.sHlsUrl}/${item.sStreamName}.${item.sHlsUrlSuffix}?${item.sHlsAntiCode}`;
41
+ sources.hls.push({
42
+ name: item.sCdnType,
43
+ url,
44
+ });
45
+ }
46
+ }
47
+ const streams = {
48
+ hls: profile.stream.hls.rateArray.map((info) => ({
49
+ desc: info.sDisplayName,
50
+ bitRate: info.iBitRate,
51
+ })),
52
+ flv: profile.stream.flv.rateArray.map((info) => ({
53
+ desc: info.sDisplayName,
54
+ bitRate: info.iBitRate,
55
+ })),
56
+ };
57
+ const startTime = new Date(profile.liveData?.startTime * 1000);
58
+ return {
59
+ living: profile.liveStatus === "ON",
60
+ id: profile.liveData.profileRoom,
61
+ owner: profile.liveData.nick,
62
+ title: profile.liveData.introduction,
63
+ roomId: profile.liveData.profileRoom,
64
+ avatar: profile.liveData.avatar180,
65
+ cover: profile.liveData.screenshot,
66
+ streams: formatName === "hls" ? streams.hls : streams.flv,
67
+ sources: formatName === "hls" ? sources.hls : sources.flv,
68
+ startTime,
69
+ liveId: utils.md5(`${roomIdOrShortId}-${startTime?.getTime()}`),
70
+ };
71
+ }
72
+ // type CacheProfileData = CacheProfileOffData | CacheProfileReplayData | CacheProfileOnData;
73
+ const cdn = {
74
+ AL: "阿里",
75
+ AL13: "阿里13",
76
+ TX15: "腾讯15",
77
+ HW16: "华为16",
78
+ HYZJ: "不知道是啥",
79
+ TX: "腾讯",
80
+ HW: "华为",
81
+ HS: "火山",
82
+ WS: "网宿",
83
+ };
package/lib/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { RecorderProvider } from "@bililive-tools/manager";
1
+ import type { RecorderProvider } from "@bililive-tools/manager";
2
2
  export declare const provider: RecorderProvider<Record<string, unknown>>;
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import path from "path";
1
+ import path from "node:path";
2
2
  import mitt from "mitt";
3
- import { createFFMPEGBuilder, defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, StreamManager, utils, } from "@bililive-tools/manager";
3
+ import { defaultFromJSON, defaultToJSON, genRecorderUUID, genRecordUUID, utils, FFMPEGRecorder, } from "@bililive-tools/manager";
4
4
  import { getInfo, getStream } from "./stream.js";
5
- import { assertStringType, ensureFolderExist } from "./utils.js";
5
+ import { ensureFolderExist } from "./utils.js";
6
6
  import HuYaDanMu from "huya-danma-listener";
7
7
  function createRecorder(opts) {
8
8
  // 内部实现时,应该只有 proxy 包裹的那一层会使用这个 recorder 标识符,不应该有直接通过
@@ -18,6 +18,8 @@ function createRecorder(opts) {
18
18
  qualityMaxRetry: opts.qualityRetry ?? 0,
19
19
  qualityRetry: opts.qualityRetry ?? 0,
20
20
  state: "idle",
21
+ api: opts.api ?? "auto",
22
+ formatName: opts.formatName ?? "auto",
21
23
  getChannelURL() {
22
24
  return `https://www.huya.com/${this.channelId}`;
23
25
  },
@@ -62,12 +64,25 @@ const ffmpegOutputOptions = [
62
64
  "-min_frag_duration",
63
65
  "60000000",
64
66
  ];
65
- const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, }) {
67
+ const ffmpegInputOptions = [
68
+ "-reconnect",
69
+ "1",
70
+ "-reconnect_streamed",
71
+ "1",
72
+ "-reconnect_delay_max",
73
+ "10",
74
+ "-rw_timeout",
75
+ "15000000",
76
+ "-user_agent",
77
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:107.0) Gecko/20100101 Firefox/107.0",
78
+ ];
79
+ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isManualStart, }) {
66
80
  if (this.recordHandle != null)
67
81
  return this.recordHandle;
68
82
  const liveInfo = await getInfo(this.channelId);
69
- const { living, owner, title, cover } = liveInfo;
83
+ const { living, owner, title, liveId } = liveInfo;
70
84
  this.liveInfo = liveInfo;
85
+ this.emit("LiveStart", { liveId });
71
86
  if (liveInfo.liveId === banLiveId) {
72
87
  this.tempStopIntervalCheck = true;
73
88
  }
@@ -78,29 +93,63 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, }) {
78
93
  return null;
79
94
  if (!living)
80
95
  return null;
81
- this.state = "recording";
82
96
  let res;
83
97
  // TODO: 先不做什么错误处理,就简单包一下预期上会有错误的地方
84
98
  try {
99
+ let strictQuality = false;
100
+ if (this.qualityRetry > 0) {
101
+ strictQuality = true;
102
+ }
103
+ if (this.qualityMaxRetry < 0) {
104
+ strictQuality = true;
105
+ }
106
+ if (isManualStart) {
107
+ strictQuality = false;
108
+ }
85
109
  res = await getStream({
86
110
  channelId: this.channelId,
87
111
  quality: this.quality,
88
112
  streamPriorities: this.streamPriorities,
89
113
  sourcePriorities: this.sourcePriorities,
114
+ api: this.api,
115
+ strictQuality,
116
+ formatName: this.formatName,
90
117
  });
91
- // console.log('live info', res)
92
118
  }
93
119
  catch (err) {
94
120
  this.state = "idle";
95
121
  throw err;
96
122
  }
123
+ this.state = "recording";
97
124
  const { currentStream: stream, sources: availableSources, streams: availableStreams } = res;
98
125
  this.availableStreams = availableStreams.map((s) => s.desc);
99
126
  this.availableSources = availableSources.map((s) => s.name);
100
127
  this.usedStream = stream.name;
101
128
  this.usedSource = stream.source;
102
- const savePath = getSavePath({ owner, title });
103
- const hasSegment = !!this.segment;
129
+ let isEnded = false;
130
+ const onEnd = (...args) => {
131
+ if (isEnded)
132
+ return;
133
+ isEnded = true;
134
+ this.emit("DebugLog", {
135
+ type: "common",
136
+ text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
137
+ });
138
+ const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
139
+ this.recordHandle?.stop(reason);
140
+ };
141
+ const recorder = new FFMPEGRecorder({
142
+ url: stream.url,
143
+ outputOptions: ffmpegOutputOptions,
144
+ inputOptions: ffmpegInputOptions,
145
+ segment: this.segment ?? 0,
146
+ getSavePath: (opts) => getSavePath({ owner, title, startTime: opts.startTime }),
147
+ disableDanma: this.disableProvideCommentsWhenRecording,
148
+ }, onEnd);
149
+ const savePath = getSavePath({
150
+ owner,
151
+ title,
152
+ });
104
153
  try {
105
154
  ensureFolderExist(savePath);
106
155
  }
@@ -108,25 +157,36 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, }) {
108
157
  this.state = "idle";
109
158
  throw err;
110
159
  }
111
- const streamManager = new StreamManager(this, getSavePath, owner, title, savePath, hasSegment);
112
160
  const handleVideoCreated = async ({ filename }) => {
113
- const extraDataController = streamManager?.getExtraDataController();
161
+ this.emit("videoFileCreated", { filename });
162
+ const extraDataController = recorder.getExtraDataController();
114
163
  extraDataController?.setMeta({
115
164
  room_id: this.channelId,
116
165
  platform: provider?.id,
117
166
  liveStartTimestamp: liveInfo.startTime?.getTime(),
167
+ recordStopTimestamp: Date.now(),
168
+ title: title,
169
+ user_name: owner,
118
170
  });
119
- if (this.saveCover) {
120
- const coverPath = utils.replaceExtName(filename, ".jpg");
121
- utils.downloadImage(cover, coverPath);
122
- }
123
171
  };
124
- this.on("videoFileCreated", handleVideoCreated);
172
+ recorder.on("videoFileCreated", handleVideoCreated);
173
+ recorder.on("videoFileCompleted", ({ filename }) => {
174
+ this.emit("videoFileCompleted", { filename });
175
+ });
176
+ recorder.on("DebugLog", (data) => {
177
+ this.emit("DebugLog", data);
178
+ });
179
+ recorder.on("progress", (progress) => {
180
+ if (this.recordHandle) {
181
+ this.recordHandle.progress = progress;
182
+ }
183
+ this.emit("progress", progress);
184
+ });
125
185
  let client = null;
126
186
  if (!this.disableProvideCommentsWhenRecording) {
127
187
  client = new HuYaDanMu(this.channelId);
128
188
  client.on("message", (msg) => {
129
- const extraDataController = streamManager.getExtraDataController();
189
+ const extraDataController = recorder.getExtraDataController();
130
190
  if (!extraDataController)
131
191
  return;
132
192
  switch (msg.type) {
@@ -170,71 +230,27 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, }) {
170
230
  client.on("error", (e) => {
171
231
  this.emit("DebugLog", { type: "common", text: String(e) });
172
232
  });
173
- try {
174
- await client.start();
175
- }
176
- catch (err) {
177
- this.state = "idle";
178
- throw err;
179
- }
233
+ client.start();
180
234
  }
181
- let isEnded = false;
182
- const onEnd = (...args) => {
183
- if (isEnded)
184
- return;
185
- isEnded = true;
186
- this.emit("DebugLog", {
187
- type: "common",
188
- text: `ffmpeg end, reason: ${JSON.stringify(args, (_, v) => (v instanceof Error ? v.stack : v))}`,
189
- });
190
- const reason = args[0] instanceof Error ? args[0].message : String(args[0]);
191
- this.recordHandle?.stop(reason);
192
- };
193
- const isInvalidStream = utils.createInvalidStreamChecker();
194
- const timeoutChecker = utils.createTimeoutChecker(() => onEnd("ffmpeg timeout"), 10e3);
195
- const command = createFFMPEGBuilder()
196
- .input(stream.url)
197
- .addInputOptions("-user_agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:107.0) Gecko/20100101 Firefox/107.0")
198
- .outputOptions(ffmpegOutputOptions)
199
- .output(streamManager.videoFilePath)
200
- .on("start", () => {
201
- streamManager.handleVideoStarted();
202
- })
203
- .on("error", onEnd)
204
- .on("end", () => onEnd("finished"))
205
- .on("stderr", async (stderrLine) => {
206
- if (utils.isFfmpegStartSegment(stderrLine)) {
207
- await streamManager.handleVideoStarted(stderrLine);
208
- }
209
- assertStringType(stderrLine);
210
- this.emit("DebugLog", { type: "ffmpeg", text: stderrLine });
211
- if (isInvalidStream(stderrLine)) {
212
- onEnd("invalid stream");
213
- }
214
- })
215
- .on("stderr", timeoutChecker.update);
216
- if (hasSegment) {
217
- command.outputOptions("-f", "segment", "-segment_time", String(this.segment * 60), "-reset_timestamps", "1");
218
- }
219
- const ffmpegArgs = command._getArguments();
220
- command.run();
235
+ const ffmpegArgs = recorder.getArguments();
236
+ recorder.run();
221
237
  const stop = utils.singleton(async (reason) => {
222
238
  if (!this.recordHandle)
223
239
  return;
224
240
  this.state = "stopping-record";
225
- // TODO: emit update event
226
- timeoutChecker.stop();
227
- // @ts-ignore
228
- command.ffmpegProc?.stdin?.write("q");
229
- // TODO: 这里可能会有内存泄露,因为事件还没清,之后再检查下看看。
230
241
  client?.stop();
242
+ try {
243
+ await recorder.stop();
244
+ }
245
+ catch (err) {
246
+ this.emit("DebugLog", {
247
+ type: "common",
248
+ text: `stop ffmpeg error: ${String(err)}`,
249
+ });
250
+ }
231
251
  this.usedStream = undefined;
232
252
  this.usedSource = undefined;
233
- // TODO: other codes
234
- // TODO: emit update event
235
- await streamManager.handleVideoCompleted();
236
253
  this.emit("RecordStop", { recordHandle: this.recordHandle, reason });
237
- this.off("videoFileCreated", handleVideoCreated);
238
254
  this.recordHandle = undefined;
239
255
  this.liveInfo = undefined;
240
256
  this.state = "idle";
package/lib/stream.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Recorder } from "@bililive-tools/manager";
2
- import { SourceProfile, StreamProfile } from "./huya_api.js";
3
2
  export declare function getInfo(channelId: string): Promise<{
4
3
  living: boolean;
5
4
  owner: string;
@@ -8,10 +7,10 @@ export declare function getInfo(channelId: string): Promise<{
8
7
  avatar: string;
9
8
  cover: string;
10
9
  startTime: Date;
11
- liveId?: string;
10
+ liveId: string;
12
11
  }>;
13
- export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" | "streamPriorities" | "sourcePriorities"> & {
14
- rejectCache?: boolean;
12
+ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" | "streamPriorities" | "sourcePriorities" | "api" | "formatName"> & {
13
+ strictQuality?: boolean;
15
14
  }): Promise<{
16
15
  currentStream: {
17
16
  name: string;
@@ -25,8 +24,11 @@ export declare function getStream(opts: Pick<Recorder, "channelId" | "quality" |
25
24
  roomId: number;
26
25
  avatar: string;
27
26
  cover: string;
28
- streams: StreamProfile[];
29
- sources: SourceProfile[];
27
+ streams: import("./huya_mobile_api.js").StreamProfile[];
28
+ sources: {
29
+ name: string;
30
+ url: string;
31
+ }[];
30
32
  startTime: Date;
31
33
  liveId: string;
32
34
  }>;
package/lib/stream.js CHANGED
@@ -1,9 +1,10 @@
1
- import { Qualities } from "@bililive-tools/manager";
2
- import { getRoomInfo } from "./huya_api.js";
3
- import { getValuesFromArrayLikeFlexSpaceBetween } from "./utils.js";
4
1
  import { sortBy } from "lodash-es";
2
+ import { HuYaQualities } from "@bililive-tools/manager";
3
+ import { getRoomInfo as getRoomInfoByWeb } from "./huya_api.js";
4
+ import { getRoomInfo as getRoomInfoByMobile } from "./huya_mobile_api.js";
5
+ import { assert } from "./utils.js";
5
6
  export async function getInfo(channelId) {
6
- const info = await getRoomInfo(channelId);
7
+ const info = await getRoomInfoByWeb(channelId);
7
8
  return {
8
9
  living: info.living,
9
10
  owner: info.owner,
@@ -15,24 +16,43 @@ export async function getInfo(channelId) {
15
16
  liveId: info.liveId,
16
17
  };
17
18
  }
19
+ async function getRoomInfo(channelId, options) {
20
+ if (options.api == "auto") {
21
+ const info = await getRoomInfoByWeb(channelId, options.formatName);
22
+ if (info.gid == 1663) {
23
+ return getRoomInfoByMobile(channelId, options.formatName);
24
+ }
25
+ return info;
26
+ }
27
+ else if (options.api == "mp") {
28
+ return getRoomInfoByMobile(channelId, options.formatName);
29
+ }
30
+ else if (options.api == "web") {
31
+ return getRoomInfoByWeb(channelId, options.formatName);
32
+ }
33
+ assert(false, "Invalid api");
34
+ }
18
35
  export async function getStream(opts) {
19
- const info = await getRoomInfo(opts.channelId);
36
+ const info = await getRoomInfo(opts.channelId, {
37
+ api: opts.api ?? "auto",
38
+ formatName: opts.formatName ?? "auto",
39
+ });
20
40
  if (!info.living) {
21
41
  throw new Error("It must be called getStream when living");
22
42
  }
23
- let expectStream;
24
- const streamsWithPriority = sortAndFilterStreamsByPriority(info.streams, opts.streamPriorities);
25
- if (streamsWithPriority.length > 0) {
26
- // 通过优先级来选择对应流
27
- expectStream = streamsWithPriority[0];
43
+ if (info.streams.length === 0) {
44
+ throw new Error(`No stream found in huya ${opts.channelId} room`);
28
45
  }
29
- else {
30
- // 通过设置的画质选项来选择对应流
31
- const flexedStreams = getValuesFromArrayLikeFlexSpaceBetween(
32
- // 接口给的画质列表是按照清晰到模糊的顺序的,这里翻转下
33
- info.streams.toReversed(), Qualities.length);
34
- const qn = (Qualities.includes(opts.quality) ? opts.quality : "highest");
35
- expectStream = flexedStreams[Qualities.indexOf(qn)];
46
+ const qn = (HuYaQualities.includes(opts.quality) ? opts.quality : 0);
47
+ let expectStream = info.streams.find((stream) => stream.bitRate === qn);
48
+ if (!expectStream && opts.strictQuality) {
49
+ throw new Error("Can not get expect quality because of strictQuality");
50
+ }
51
+ if (!expectStream) {
52
+ expectStream = info.streams[0];
53
+ }
54
+ if (!expectStream) {
55
+ throw new Error("未找到对应的流");
36
56
  }
37
57
  let expectSource = null;
38
58
  const sourcesWithPriority = sortAndFilterSourcesByPriority(info.sources, opts.sourcePriorities);
@@ -40,7 +60,15 @@ export async function getStream(opts) {
40
60
  expectSource = sourcesWithPriority[0];
41
61
  }
42
62
  else {
43
- expectSource = info.sources[0];
63
+ expectSource = info.sources.find((source) => source.name === "TX") ?? null;
64
+ if (!expectSource) {
65
+ expectSource = info.sources[0];
66
+ }
67
+ if (expectSource.name === "TX") {
68
+ expectSource.url = expectSource.url
69
+ .replace("&ctype=tars_mp", "&ctype=huya_webh5")
70
+ .replace("&fs=bhct", "&fs=bgct");
71
+ }
44
72
  }
45
73
  return {
46
74
  ...info,
@@ -51,21 +79,27 @@ export async function getStream(opts) {
51
79
  },
52
80
  };
53
81
  }
54
- /**
55
- * 按提供的流优先级去给流列表排序,并过滤掉不在优先级配置中的流
56
- */
57
- function sortAndFilterStreamsByPriority(streams, streamPriorities) {
58
- if (streamPriorities.length === 0)
59
- return [];
60
- return sortBy(
61
- // 分配优先级属性,数字越大优先级越高
62
- streams
63
- .map((stream) => ({
64
- ...stream,
65
- priority: streamPriorities.toReversed().indexOf(stream.desc),
66
- }))
67
- .filter(({ priority }) => priority !== -1), "priority");
68
- }
82
+ // /**
83
+ // * 按提供的流优先级去给流列表排序,并过滤掉不在优先级配置中的流
84
+ // */
85
+ // function sortAndFilterStreamsByPriority(
86
+ // streams: StreamProfile[],
87
+ // streamPriorities: Recorder["streamPriorities"],
88
+ // ): (StreamProfile & {
89
+ // priority: number;
90
+ // })[] {
91
+ // if (streamPriorities.length === 0) return [];
92
+ // return sortBy(
93
+ // // 分配优先级属性,数字越大优先级越高
94
+ // streams
95
+ // .map((stream) => ({
96
+ // ...stream,
97
+ // priority: streamPriorities.toReversed().indexOf(stream.desc),
98
+ // }))
99
+ // .filter(({ priority }) => priority !== -1),
100
+ // "priority",
101
+ // );
102
+ // }
69
103
  /**
70
104
  * 按提供的源优先级去给源列表排序,并过滤掉不在优先级配置中的源
71
105
  */
package/lib/utils.d.ts CHANGED
@@ -20,3 +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;
package/lib/utils.js CHANGED
@@ -59,3 +59,28 @@ 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() {
63
+ let prevFrame = 0;
64
+ let frameUnchangedCount = 0;
65
+ return (ffmpegLogLine) => {
66
+ const streamInfo = ffmpegLogLine.match(/frame=\s*(\d+) fps=.*? q=.*? size=.*? time=.*? bitrate=.*? speed=.*?/);
67
+ if (streamInfo != null) {
68
+ const [, frameText] = streamInfo;
69
+ const frame = Number(frameText);
70
+ if (frame === prevFrame) {
71
+ if (++frameUnchangedCount >= 15) {
72
+ return true;
73
+ }
74
+ }
75
+ else {
76
+ prevFrame = frame;
77
+ frameUnchangedCount = 0;
78
+ }
79
+ return false;
80
+ }
81
+ if (ffmpegLogLine.includes("HTTP error 404 Not Found")) {
82
+ return true;
83
+ }
84
+ return false;
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bililive-tools/huya-recorder",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "bililive-tools huya recorder implemention",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -37,13 +37,10 @@
37
37
  "mitt": "^3.0.1",
38
38
  "lodash-es": "^4.17.21",
39
39
  "axios": "^1.7.8",
40
- "huya-danma-listener": "0.1.0",
41
- "@bililive-tools/manager": "1.0.1"
40
+ "@bililive-tools/manager": "^1.1.0",
41
+ "huya-danma-listener": "0.1.0"
42
42
  },
43
43
  "devDependencies": {},
44
- "peerDependencies": {
45
- "@bililive-tools/manager": "*"
46
- },
47
44
  "scripts": {
48
45
  "build": "tsc",
49
46
  "watch": "tsc -w"