@bililive-tools/douyin-recorder 1.2.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 CHANGED
@@ -36,12 +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; // 保存封面
44
45
  videoFormat?: "auto"; // 视频格式: "auto", "ts", "mkv" ,auto模式下, 分段使用 "ts",不分段使用 "mp4"
46
+ auth?: string; // 传递cookie,用于录制会员视频
45
47
  }
46
48
  ```
47
49
 
@@ -49,14 +51,15 @@ interface Options {
49
51
 
50
52
  遗漏了部分画质,有了解的可以提PR
51
53
 
52
- | 画质 | 值 |
53
- | ------ | ------ |
54
- | 原画 | origin |
55
- | 蓝光 | uhd |
56
- | 超清 | hd |
57
- | 高清 | sd |
58
- | 标清 | ld |
59
- | 音频流 | ao |
54
+ | 画质 | 值 |
55
+ | ---------------------- | ----------- |
56
+ | 原画 | origin |
57
+ | 蓝光 | uhd |
58
+ | 超清 | hd |
59
+ | 高清 | sd |
60
+ | 标清 | ld |
61
+ | 音频流 | ao |
62
+ | 真原画(音频流中获取的) | real_origin |
60
63
 
61
64
  ## 直播间ID解析
62
65
 
@@ -66,6 +69,7 @@ interface Options {
66
69
  import { provider } from "@bililive-tools/douyin-recorder";
67
70
 
68
71
  const url = "https://live.douyin.com/203641303310";
72
+ // 同样支持解析 https://v.douyin.com/DpfoBLAXoHM/
69
73
  const { id } = await provider.resolveChannelInfoFromURL(url);
70
74
  ```
71
75
 
@@ -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
- // 缓存24小时
13
- if (cookieCache?.startTimestamp && now - cookieCache.startTimestamp < 24 * 60 * 60 * 1000) {
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
- // 抖音的 'webcast/room/web/enter' api 会需要 ttwid 的 cookie,这个 cookie 是由这个请求的响应头设置的,
33
- // 所以在这里请求一次自动设置。
34
- const cookies = await getCookie();
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,13 +61,23 @@ const ffmpegOutputOptions = [
60
61
  "-movflags",
61
62
  "faststart+frag_keyframe+empty_moov",
62
63
  "-min_frag_duration",
63
- "60000000",
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, liveId } = liveInfo;
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,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,11 +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",
132
- }, onEnd);
150
+ headers: {
151
+ Cookie: this.auth,
152
+ },
153
+ }, onEnd, async () => {
154
+ const info = await getInfo(this.channelId);
155
+ return info;
156
+ });
133
157
  const savePath = getSavePath({
134
158
  owner,
135
159
  title,
@@ -141,8 +165,14 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
141
165
  this.state = "idle";
142
166
  throw err;
143
167
  }
144
- const handleVideoCreated = async ({ filename }) => {
145
- 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
+ }
146
176
  const extraDataController = recorder.getExtraDataController();
147
177
  extraDataController?.setMeta({
148
178
  room_id: this.channelId,
@@ -166,14 +196,16 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
166
196
  }
167
197
  this.emit("progress", progress);
168
198
  });
169
- const client = new DouYinDanmaClient(liveInfo.liveId);
199
+ const client = new DouYinDanmaClient(liveInfo.liveId, {
200
+ cookie: this.auth,
201
+ });
170
202
  client.on("chat", (msg) => {
171
203
  const extraDataController = recorder.getExtraDataController();
172
204
  if (!extraDataController)
173
205
  return;
174
206
  const comment = {
175
207
  type: "comment",
176
- timestamp: Date.now(),
208
+ timestamp: Number(msg.eventTime) * 1000,
177
209
  text: msg.content,
178
210
  color: "#ffffff",
179
211
  sender: {
@@ -185,7 +217,6 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
185
217
  // },
186
218
  },
187
219
  };
188
- // console.log("comment", comment);
189
220
  this.emit("Message", comment);
190
221
  extraDataController.addMessage(comment);
191
222
  });
@@ -197,10 +228,12 @@ const checkLiveStatusAndRecord = async function ({ getSavePath, banLiveId, isMan
197
228
  return;
198
229
  const gift = {
199
230
  type: "give_gift",
200
- timestamp: Number(msg.sendTime),
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
- // TODO: 暂时不支持 v.douyin.com
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
- const id = path.basename(new URL(channelURL).pathname);
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
- const qualityMap = [
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
- let url = sources.streamMap[opts.quality]?.main?.flv;
44
- let qualityName = qualityMap.find((q) => q.key === opts.quality)?.desc ?? "未知";
45
- if (!url && opts.strictQuality) {
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
- // 如果url不存在,那么按照优先级选择
49
- if (!url) {
50
- for (const quality of qualityMap) {
51
- url = sources.streamMap[quality.key]?.main?.flv;
52
- if (url) {
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.2.0",
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.2.0",
41
- "douyin-danma-listener": "0.1.1"
40
+ "@bililive-tools/manager": "^1.3.0",
41
+ "douyin-danma-listener": "0.2.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "*"