@tiktool/live 2.6.7 → 2.7.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 +25 -323
- package/dist/index.d.mts +81 -707
- package/dist/index.d.ts +81 -707
- package/dist/index.js +335 -978
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +334 -972
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -17
package/dist/index.mjs
CHANGED
|
@@ -3,94 +3,9 @@ import { EventEmitter } from "events";
|
|
|
3
3
|
import * as http from "http";
|
|
4
4
|
import * as https from "https";
|
|
5
5
|
import * as zlib from "zlib";
|
|
6
|
-
import { URL } from "url";
|
|
7
6
|
import WebSocket from "ws";
|
|
8
7
|
|
|
9
8
|
// src/proto.ts
|
|
10
|
-
var encoder = new TextEncoder();
|
|
11
|
-
var decoder = new TextDecoder();
|
|
12
|
-
var TIKTOK_EMOTE_MAP = {
|
|
13
|
-
// Standard emojis
|
|
14
|
-
"happy": "\u{1F60A}",
|
|
15
|
-
"angry": "\u{1F621}",
|
|
16
|
-
"cry": "\u{1F622}",
|
|
17
|
-
"embarrassed": "\u{1F633}",
|
|
18
|
-
"surprised": "\u{1F62E}",
|
|
19
|
-
"wronged": "\u{1F61E}",
|
|
20
|
-
"shout": "\u{1F624}",
|
|
21
|
-
"flushed": "\u{1F633}",
|
|
22
|
-
"yummy": "\u{1F60B}",
|
|
23
|
-
"complacent": "\u{1F60C}",
|
|
24
|
-
"drool": "\u{1F924}",
|
|
25
|
-
"scream": "\u{1F631}",
|
|
26
|
-
"weep": "\u{1F62D}",
|
|
27
|
-
"speechless": "\u{1F636}",
|
|
28
|
-
"funnyface": "\u{1F92A}",
|
|
29
|
-
"laughwithtears": "\u{1F602}",
|
|
30
|
-
"wicked": "\u{1F608}",
|
|
31
|
-
"facewithrollingeyes": "\u{1F644}",
|
|
32
|
-
"sulk": "\u{1F612}",
|
|
33
|
-
"thinking": "\u{1F914}",
|
|
34
|
-
"lovely": "\u{1F970}",
|
|
35
|
-
"greedy": "\u{1F911}",
|
|
36
|
-
"wow": "\u{1F62F}",
|
|
37
|
-
"joyful": "\u{1F603}",
|
|
38
|
-
"hehe": "\u{1F601}",
|
|
39
|
-
"slap": "\u{1F44B}",
|
|
40
|
-
"tears": "\u{1F63F}",
|
|
41
|
-
"stun": "\u{1F635}",
|
|
42
|
-
"cute": "\u{1F97A}",
|
|
43
|
-
"blink": "\u{1F609}",
|
|
44
|
-
"disdain": "\u{1F60F}",
|
|
45
|
-
"astonish": "\u{1F632}",
|
|
46
|
-
"cool": "\u{1F60E}",
|
|
47
|
-
"excited": "\u{1F929}",
|
|
48
|
-
"proud": "\u{1F624}",
|
|
49
|
-
"smileface": "\u{1F60A}",
|
|
50
|
-
"evil": "\u{1F47F}",
|
|
51
|
-
"angel": "\u{1F607}",
|
|
52
|
-
"laugh": "\u{1F606}",
|
|
53
|
-
"pride": "\u{1F981}",
|
|
54
|
-
"nap": "\u{1F634}",
|
|
55
|
-
"loveface": "\u{1F60D}",
|
|
56
|
-
"awkward": "\u{1F62C}",
|
|
57
|
-
"shock": "\u{1F628}",
|
|
58
|
-
"funny": "\u{1F604}",
|
|
59
|
-
"rage": "\u{1F92C}",
|
|
60
|
-
// Common aliases used in TikTok LIVE
|
|
61
|
-
"laughcry": "\u{1F602}",
|
|
62
|
-
"heart": "\u2764\uFE0F",
|
|
63
|
-
"like": "\u{1F44D}",
|
|
64
|
-
"love": "\u{1F495}",
|
|
65
|
-
"shy": "\u{1F648}",
|
|
66
|
-
"smile": "\u{1F60A}"
|
|
67
|
-
};
|
|
68
|
-
function replaceEmotes(text, imageMap) {
|
|
69
|
-
return text.replace(/\[([a-zA-Z_]+)\]/g, (match, name) => {
|
|
70
|
-
const lower = name.toLowerCase();
|
|
71
|
-
const emoji = TIKTOK_EMOTE_MAP[lower];
|
|
72
|
-
return emoji || match;
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
function concatBytes(...arrays) {
|
|
76
|
-
let totalLength = 0;
|
|
77
|
-
for (const arr of arrays) totalLength += arr.length;
|
|
78
|
-
const result = new Uint8Array(totalLength);
|
|
79
|
-
let offset = 0;
|
|
80
|
-
for (const arr of arrays) {
|
|
81
|
-
result.set(arr, offset);
|
|
82
|
-
offset += arr.length;
|
|
83
|
-
}
|
|
84
|
-
return result;
|
|
85
|
-
}
|
|
86
|
-
function readInt32LE(buf, offset) {
|
|
87
|
-
return buf[offset] | buf[offset + 1] << 8 | buf[offset + 2] << 16 | buf[offset + 3] << 24;
|
|
88
|
-
}
|
|
89
|
-
function readBigInt64LE(buf, offset) {
|
|
90
|
-
const lo = BigInt(buf[offset] | buf[offset + 1] << 8 | buf[offset + 2] << 16 | buf[offset + 3] << 24 >>> 0);
|
|
91
|
-
const hi = BigInt(buf[offset + 4] | buf[offset + 5] << 8 | buf[offset + 6] << 16 | buf[offset + 7] << 24 >>> 0);
|
|
92
|
-
return hi << 32n | lo & 0xFFFFFFFFn;
|
|
93
|
-
}
|
|
94
9
|
function decodeVarint(buf, offset) {
|
|
95
10
|
let result = 0, shift = 0;
|
|
96
11
|
while (offset < buf.length) {
|
|
@@ -120,15 +35,15 @@ function encodeVarint(v) {
|
|
|
120
35
|
if (n > 0n) b |= 128;
|
|
121
36
|
bytes.push(b);
|
|
122
37
|
} while (n > 0n);
|
|
123
|
-
return
|
|
38
|
+
return Buffer.from(bytes);
|
|
124
39
|
}
|
|
125
40
|
function encodeField(fn, wt, value) {
|
|
126
41
|
const tag = encodeVarint(fn << 3 | wt);
|
|
127
42
|
if (wt === 0) {
|
|
128
|
-
return
|
|
43
|
+
return Buffer.concat([tag, encodeVarint(typeof value === "number" ? BigInt(value) : value)]);
|
|
129
44
|
}
|
|
130
|
-
const data = typeof value === "string" ?
|
|
131
|
-
return
|
|
45
|
+
const data = typeof value === "string" ? Buffer.from(value) : value;
|
|
46
|
+
return Buffer.concat([tag, encodeVarint(data.length), data]);
|
|
132
47
|
}
|
|
133
48
|
function decodeProto(buf) {
|
|
134
49
|
const fields = [];
|
|
@@ -149,10 +64,10 @@ function decodeProto(buf) {
|
|
|
149
64
|
offset += lenR.value;
|
|
150
65
|
fields.push({ fn, wt, value: data });
|
|
151
66
|
} else if (wt === 1) {
|
|
152
|
-
fields.push({ fn, wt, value: readBigInt64LE(
|
|
67
|
+
fields.push({ fn, wt, value: buf.readBigInt64LE(offset) });
|
|
153
68
|
offset += 8;
|
|
154
69
|
} else if (wt === 5) {
|
|
155
|
-
fields.push({ fn, wt, value: BigInt(readInt32LE(
|
|
70
|
+
fields.push({ fn, wt, value: BigInt(buf.readInt32LE(offset)) });
|
|
156
71
|
offset += 4;
|
|
157
72
|
} else {
|
|
158
73
|
break;
|
|
@@ -162,7 +77,7 @@ function decodeProto(buf) {
|
|
|
162
77
|
}
|
|
163
78
|
function getStr(fields, fn) {
|
|
164
79
|
const f = fields.find((x) => x.fn === fn && x.wt === 2);
|
|
165
|
-
return f ?
|
|
80
|
+
return f ? f.value.toString("utf-8") : "";
|
|
166
81
|
}
|
|
167
82
|
function getBytes(fields, fn) {
|
|
168
83
|
const f = fields.find((x) => x.fn === fn && x.wt === 2);
|
|
@@ -174,47 +89,47 @@ function getInt(fields, fn) {
|
|
|
174
89
|
}
|
|
175
90
|
function getIntStr(fields, fn) {
|
|
176
91
|
const f = fields.find((x) => x.fn === fn && x.wt === 0);
|
|
177
|
-
return f ? String(f.value) : "";
|
|
92
|
+
return f ? String(f.value) : "0";
|
|
178
93
|
}
|
|
179
94
|
function getAllBytes(fields, fn) {
|
|
180
95
|
return fields.filter((x) => x.fn === fn && x.wt === 2).map((x) => x.value);
|
|
181
96
|
}
|
|
182
97
|
function buildHeartbeat(roomId) {
|
|
183
98
|
const payload = encodeField(1, 0, BigInt(roomId));
|
|
184
|
-
return
|
|
99
|
+
return Buffer.concat([
|
|
185
100
|
encodeField(6, 2, "pb"),
|
|
186
101
|
encodeField(7, 2, "hb"),
|
|
187
102
|
encodeField(8, 2, payload)
|
|
188
|
-
);
|
|
103
|
+
]);
|
|
189
104
|
}
|
|
190
105
|
function buildImEnterRoom(roomId) {
|
|
191
|
-
const inner =
|
|
106
|
+
const inner = Buffer.concat([
|
|
192
107
|
encodeField(1, 0, BigInt(roomId)),
|
|
193
108
|
encodeField(4, 0, 12n),
|
|
194
109
|
encodeField(5, 2, "audience"),
|
|
195
110
|
encodeField(6, 2, ""),
|
|
196
111
|
encodeField(9, 2, ""),
|
|
197
112
|
encodeField(10, 2, "")
|
|
198
|
-
);
|
|
199
|
-
return
|
|
113
|
+
]);
|
|
114
|
+
return Buffer.concat([
|
|
200
115
|
encodeField(6, 2, "pb"),
|
|
201
116
|
encodeField(7, 2, "im_enter_room"),
|
|
202
117
|
encodeField(8, 2, inner)
|
|
203
|
-
);
|
|
118
|
+
]);
|
|
204
119
|
}
|
|
205
120
|
function buildAck(id) {
|
|
206
|
-
return
|
|
121
|
+
return Buffer.concat([
|
|
207
122
|
encodeField(2, 0, id),
|
|
208
123
|
encodeField(6, 2, "pb"),
|
|
209
124
|
encodeField(7, 2, "ack")
|
|
210
|
-
);
|
|
125
|
+
]);
|
|
211
126
|
}
|
|
212
127
|
function looksLikeUsername(s) {
|
|
213
128
|
return s.length > 0 && s.length <= 50 && !s.includes("://") && !s.includes("\0");
|
|
214
129
|
}
|
|
215
130
|
function parseUser(data) {
|
|
216
131
|
const f = decodeProto(data);
|
|
217
|
-
const id = getIntStr(f, 1)
|
|
132
|
+
const id = getIntStr(f, 1) !== "0" ? getIntStr(f, 1) : getStr(f, 1);
|
|
218
133
|
const nickname = getStr(f, 3) || getStr(f, 2);
|
|
219
134
|
let uniqueId = "";
|
|
220
135
|
const uid4 = getStr(f, 4);
|
|
@@ -236,7 +151,7 @@ function parseUser(data) {
|
|
|
236
151
|
const avatarFields = decodeProto(avatarBuf);
|
|
237
152
|
const urlBufs = getAllBytes(avatarFields, 1);
|
|
238
153
|
for (const urlBuf of urlBufs) {
|
|
239
|
-
const url =
|
|
154
|
+
const url = urlBuf.toString("utf-8");
|
|
240
155
|
if (url.includes("://")) {
|
|
241
156
|
profilePicture = url;
|
|
242
157
|
break;
|
|
@@ -245,25 +160,44 @@ function parseUser(data) {
|
|
|
245
160
|
} catch {
|
|
246
161
|
}
|
|
247
162
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
for (const badgeBuf of badgeBufs) {
|
|
163
|
+
let avatarLargeUrl;
|
|
164
|
+
const largeBuf = getBytes(f, 11);
|
|
165
|
+
if (largeBuf) {
|
|
252
166
|
try {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
167
|
+
const largeFields = decodeProto(largeBuf);
|
|
168
|
+
const urlBufs = getAllBytes(largeFields, 1);
|
|
169
|
+
for (const urlBuf of urlBufs) {
|
|
170
|
+
const url = urlBuf.toString("utf-8");
|
|
171
|
+
if (url.includes("://")) {
|
|
172
|
+
avatarLargeUrl = url;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!avatarLargeUrl) {
|
|
180
|
+
const medBuf = getBytes(f, 10);
|
|
181
|
+
if (medBuf) {
|
|
182
|
+
try {
|
|
183
|
+
const medFields = decodeProto(medBuf);
|
|
184
|
+
const urlBufs = getAllBytes(medFields, 1);
|
|
185
|
+
for (const urlBuf of urlBufs) {
|
|
186
|
+
const url = urlBuf.toString("utf-8");
|
|
187
|
+
if (url.includes("://")) {
|
|
188
|
+
avatarLargeUrl = url;
|
|
189
|
+
break;
|
|
264
190
|
}
|
|
265
191
|
}
|
|
192
|
+
} catch {
|
|
266
193
|
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const badges = [];
|
|
197
|
+
const badgeBuf = getBytes(f, 64);
|
|
198
|
+
if (badgeBuf) {
|
|
199
|
+
try {
|
|
200
|
+
const badgeFields = decodeProto(badgeBuf);
|
|
267
201
|
const badgeItems = getAllBytes(badgeFields, 21);
|
|
268
202
|
for (const bi of badgeItems) {
|
|
269
203
|
const bf = decodeProto(bi);
|
|
@@ -277,17 +211,16 @@ function parseUser(data) {
|
|
|
277
211
|
id,
|
|
278
212
|
nickname,
|
|
279
213
|
uniqueId: uniqueId || nickname || id,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
214
|
+
profilePicture,
|
|
215
|
+
avatarLargeUrl,
|
|
216
|
+
badges: badges.length > 0 ? badges : void 0
|
|
283
217
|
};
|
|
284
218
|
}
|
|
285
219
|
function parseBattleTeamFromArmies(itemBuf) {
|
|
286
220
|
const f = decodeProto(itemBuf);
|
|
287
|
-
const hostUserId = getIntStr(f, 1)
|
|
221
|
+
const hostUserId = getIntStr(f, 1);
|
|
288
222
|
let teamScore = 0;
|
|
289
223
|
const users = [];
|
|
290
|
-
let hostUser;
|
|
291
224
|
const groups = getAllBytes(f, 2);
|
|
292
225
|
for (const gb of groups) {
|
|
293
226
|
try {
|
|
@@ -297,29 +230,15 @@ function parseBattleTeamFromArmies(itemBuf) {
|
|
|
297
230
|
const userBufs = getAllBytes(gf, 1);
|
|
298
231
|
for (const ub of userBufs) {
|
|
299
232
|
try {
|
|
300
|
-
const userFields = decodeProto(ub);
|
|
301
|
-
const individualScore = getInt(userFields, 2);
|
|
302
233
|
const user = parseUser(ub);
|
|
303
|
-
users.push({ user, score:
|
|
234
|
+
users.push({ user, score: points });
|
|
304
235
|
} catch {
|
|
305
236
|
}
|
|
306
237
|
}
|
|
307
238
|
} catch {
|
|
308
239
|
}
|
|
309
240
|
}
|
|
310
|
-
|
|
311
|
-
if (hostUser) break;
|
|
312
|
-
const buf = getBytes(f, fieldNum);
|
|
313
|
-
if (!buf) continue;
|
|
314
|
-
try {
|
|
315
|
-
const parsed = parseUser(buf);
|
|
316
|
-
if (parsed && (parsed.nickname || parsed.uniqueId) && parsed.uniqueId !== parsed.id && looksLikeUsername(parsed.uniqueId || "")) {
|
|
317
|
-
hostUser = parsed;
|
|
318
|
-
}
|
|
319
|
-
} catch {
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return { hostUserId, score: teamScore, users, hostUser };
|
|
241
|
+
return { hostUserId, score: teamScore, users };
|
|
323
242
|
}
|
|
324
243
|
function parseWebcastMessage(method, payload) {
|
|
325
244
|
const f = decodeProto(payload);
|
|
@@ -334,29 +253,42 @@ function parseWebcastMessage(method, payload) {
|
|
|
334
253
|
}
|
|
335
254
|
}
|
|
336
255
|
switch (method) {
|
|
256
|
+
// Proto: WebcastChatMessage { MessageType type=1, User user=2, string comment=3, ..., repeated WebcastSubEmote emotes=12 }
|
|
257
|
+
// WebcastSubEmote { int32 placeInComment=1, EmoteDetails emote=2 }
|
|
258
|
+
// EmoteDetails { string emoteId=1, EmoteImage image=2 }
|
|
259
|
+
// EmoteImage { string imageUrl=1 }
|
|
337
260
|
case "WebcastChatMessage": {
|
|
338
261
|
const userBuf = getBytes(f, 2);
|
|
339
262
|
const user = userBuf ? parseUser(userBuf) : { id: "0", nickname: "", uniqueId: "" };
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
263
|
+
const comment = getStr(f, 3);
|
|
264
|
+
const emotes = [];
|
|
265
|
+
const emoteBufs12 = getAllBytes(f, 12);
|
|
266
|
+
const emoteBufs13 = getAllBytes(f, 13);
|
|
267
|
+
const emoteBufs = emoteBufs13.length > 0 ? emoteBufs13 : emoteBufs12;
|
|
268
|
+
for (const eb of emoteBufs) {
|
|
269
|
+
try {
|
|
270
|
+
const ef = decodeProto(eb);
|
|
271
|
+
const placeInComment = getInt(ef, 1);
|
|
272
|
+
const detailBuf = getBytes(ef, 2);
|
|
273
|
+
if (detailBuf) {
|
|
274
|
+
const df = decodeProto(detailBuf);
|
|
275
|
+
const emoteId = getStr(df, 1);
|
|
276
|
+
let imageUrl = "";
|
|
277
|
+
const imgBuf = getBytes(df, 2);
|
|
278
|
+
if (imgBuf) {
|
|
279
|
+
const imgf = decodeProto(imgBuf);
|
|
280
|
+
imageUrl = getStr(imgf, 1);
|
|
281
|
+
}
|
|
282
|
+
if (emoteId) {
|
|
283
|
+
emotes.push({ emoteId, imageUrl, placeInComment });
|
|
352
284
|
}
|
|
353
|
-
} catch {
|
|
354
285
|
}
|
|
286
|
+
} catch {
|
|
355
287
|
}
|
|
356
288
|
}
|
|
357
|
-
|
|
358
|
-
return { ...base, type: "chat", user, comment };
|
|
289
|
+
return { ...base, type: "chat", user, comment, emotes };
|
|
359
290
|
}
|
|
291
|
+
// Proto: WebcastMemberMessage { User user=2, WebcastMessageEvent event=1 }
|
|
360
292
|
case "WebcastMemberMessage": {
|
|
361
293
|
const userBuf = getBytes(f, 2);
|
|
362
294
|
const user = userBuf ? parseUser(userBuf) : { id: "0", nickname: "", uniqueId: "" };
|
|
@@ -374,6 +306,7 @@ function parseWebcastMessage(method, payload) {
|
|
|
374
306
|
}
|
|
375
307
|
return { ...base, type: "member", user, action };
|
|
376
308
|
}
|
|
309
|
+
// Proto: WebcastLikeMessage { User user=5, WebcastMessageEvent event=1, int32 likeCount=2, int32 totalLikeCount=3 }
|
|
377
310
|
case "WebcastLikeMessage": {
|
|
378
311
|
const userBuf = getBytes(f, 5);
|
|
379
312
|
const user = userBuf ? parseUser(userBuf) : { id: "0", nickname: "", uniqueId: "" };
|
|
@@ -385,6 +318,13 @@ function parseWebcastMessage(method, payload) {
|
|
|
385
318
|
totalLikes: getInt(f, 3)
|
|
386
319
|
};
|
|
387
320
|
}
|
|
321
|
+
// Proto: WebcastGiftMessage {
|
|
322
|
+
// User user=7, int32 giftId=2, int32 repeatCount=5, int32 repeatEnd=9,
|
|
323
|
+
// GiftDetails giftDetails=15, GiftExtra giftExtra=23
|
|
324
|
+
// }
|
|
325
|
+
// GiftDetails { GiftImage giftImage=1, string giftName=16, string describe=2,
|
|
326
|
+
// int32 giftType=11, int32 diamondCount=12 }
|
|
327
|
+
// GiftExtra { uint64 timestamp=6, uint64 toUserId=8 }
|
|
388
328
|
case "WebcastGiftMessage": {
|
|
389
329
|
const userBuf = getBytes(f, 7);
|
|
390
330
|
const user = userBuf ? parseUser(userBuf) : { id: "0", nickname: "", uniqueId: "" };
|
|
@@ -409,7 +349,7 @@ function parseWebcastMessage(method, payload) {
|
|
|
409
349
|
const extraBuf = getBytes(f, 23);
|
|
410
350
|
if (extraBuf) {
|
|
411
351
|
const ef = decodeProto(extraBuf);
|
|
412
|
-
toUserId =
|
|
352
|
+
toUserId = String(getInt(ef, 8));
|
|
413
353
|
}
|
|
414
354
|
const groupId = toUserId || getStr(f, 11);
|
|
415
355
|
return {
|
|
@@ -423,10 +363,10 @@ function parseWebcastMessage(method, payload) {
|
|
|
423
363
|
repeatEnd,
|
|
424
364
|
combo: repeatCount > 1 && !repeatEnd,
|
|
425
365
|
giftType,
|
|
426
|
-
groupId
|
|
427
|
-
giftPictureUrl: giftImageUrl
|
|
366
|
+
groupId
|
|
428
367
|
};
|
|
429
368
|
}
|
|
369
|
+
// Proto: WebcastSocialMessage { User user=2, WebcastMessageEvent event=1 }
|
|
430
370
|
case "WebcastSocialMessage": {
|
|
431
371
|
const userBuf = getBytes(f, 2);
|
|
432
372
|
const user = userBuf ? parseUser(userBuf) : { id: "0", nickname: "", uniqueId: "" };
|
|
@@ -446,42 +386,19 @@ function parseWebcastMessage(method, payload) {
|
|
|
446
386
|
}
|
|
447
387
|
return { ...base, type: "social", user, action };
|
|
448
388
|
}
|
|
389
|
+
// Proto: WebcastRoomUserSeqMessage { int32 viewerCount=3 }
|
|
449
390
|
case "WebcastRoomUserSeqMessage": {
|
|
450
391
|
const viewerCount = getInt(f, 3) || getInt(f, 2);
|
|
451
392
|
const totalViewers = getInt(f, 1) || viewerCount;
|
|
452
393
|
return { ...base, type: "roomUserSeq", totalViewers, viewerCount };
|
|
453
394
|
}
|
|
395
|
+
// Proto: WebcastLinkMicBattle { repeated WebcastLinkMicBattleItems battleUsers=10 }
|
|
396
|
+
// battleUsers → { battleGroup=2 → { LinkUser user=1 } }
|
|
454
397
|
case "WebcastLinkMicBattle": {
|
|
455
|
-
const battleId =
|
|
456
|
-
const
|
|
398
|
+
const battleId = String(getInt(f, 1) || "");
|
|
399
|
+
const status = getInt(f, 2) || 1;
|
|
400
|
+
const battleDuration = getInt(f, 3);
|
|
457
401
|
const teams = [];
|
|
458
|
-
let battleDuration = 0;
|
|
459
|
-
let battleSettings;
|
|
460
|
-
const settingsBuf = getBytes(f, 3);
|
|
461
|
-
if (settingsBuf) {
|
|
462
|
-
try {
|
|
463
|
-
const sf = decodeProto(settingsBuf);
|
|
464
|
-
const startTimeMs = getInt(sf, 2);
|
|
465
|
-
const duration = getInt(sf, 3);
|
|
466
|
-
const phase = getInt(sf, 5);
|
|
467
|
-
const endTimeMs = getInt(sf, 10);
|
|
468
|
-
battleDuration = duration;
|
|
469
|
-
battleSettings = {
|
|
470
|
-
startTimeMs: startTimeMs || void 0,
|
|
471
|
-
duration: duration || void 0,
|
|
472
|
-
endTimeMs: endTimeMs || void 0
|
|
473
|
-
};
|
|
474
|
-
} catch {
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
const settingsPhase = settingsBuf ? (() => {
|
|
478
|
-
try {
|
|
479
|
-
return getInt(decodeProto(settingsBuf), 5);
|
|
480
|
-
} catch {
|
|
481
|
-
return 0;
|
|
482
|
-
}
|
|
483
|
-
})() : 0;
|
|
484
|
-
const status = settingsPhase || (overallStatus > 0 && overallStatus <= 10 ? overallStatus : 0) || 1;
|
|
485
402
|
const battleUserBufs = getAllBytes(f, 10);
|
|
486
403
|
for (const bub of battleUserBufs) {
|
|
487
404
|
try {
|
|
@@ -502,23 +419,6 @@ function parseWebcastMessage(method, payload) {
|
|
|
502
419
|
} catch {
|
|
503
420
|
}
|
|
504
421
|
}
|
|
505
|
-
if (teams.length === 0) {
|
|
506
|
-
const teamBufs5 = getAllBytes(f, 5);
|
|
507
|
-
for (const tb of teamBufs5) {
|
|
508
|
-
try {
|
|
509
|
-
const tf = decodeProto(tb);
|
|
510
|
-
const userId = getIntStr(tf, 1);
|
|
511
|
-
if (userId && userId !== "0") {
|
|
512
|
-
teams.push({
|
|
513
|
-
hostUserId: userId,
|
|
514
|
-
score: 0,
|
|
515
|
-
users: []
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
} catch {
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
422
|
if (teams.length === 0) {
|
|
523
423
|
const teamBufs7 = getAllBytes(f, 7);
|
|
524
424
|
for (const tb of teamBufs7) {
|
|
@@ -528,48 +428,51 @@ function parseWebcastMessage(method, payload) {
|
|
|
528
428
|
}
|
|
529
429
|
}
|
|
530
430
|
}
|
|
531
|
-
return { ...base, type: "battle", battleId, status, battleDuration, teams
|
|
431
|
+
return { ...base, type: "battle", battleId, status, battleDuration, teams };
|
|
532
432
|
}
|
|
433
|
+
// Proto: WebcastLinkMicArmies { msg_header=1, session_id=2, repeated battleItems=3, status=7,
|
|
434
|
+
// battle_config=18 { session_id=1, start_time_ms=2, duration_s=3 }, battle_id=17 }
|
|
435
|
+
// battleItems → { hostUserId=1, repeated battleGroups=2 }
|
|
436
|
+
// battleGroups → { repeated users=1, int32 points=2 }
|
|
533
437
|
case "WebcastLinkMicArmies": {
|
|
534
|
-
const battleId =
|
|
438
|
+
const battleId = getStr(f, 17) || String(getInt(f, 2) || "");
|
|
535
439
|
const battleStatus = getInt(f, 7);
|
|
536
440
|
const teams = [];
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
if (settingsBuf18) {
|
|
441
|
+
const itemBufs = getAllBytes(f, 3);
|
|
442
|
+
for (const ib of itemBufs) {
|
|
540
443
|
try {
|
|
541
|
-
|
|
542
|
-
const startTimeMs = getInt(sf, 2);
|
|
543
|
-
const duration = getInt(sf, 3);
|
|
544
|
-
const endTimeMs = getInt(sf, 10);
|
|
545
|
-
battleSettings = {
|
|
546
|
-
startTimeMs: startTimeMs || void 0,
|
|
547
|
-
duration: duration || void 0,
|
|
548
|
-
endTimeMs: endTimeMs || void 0
|
|
549
|
-
};
|
|
444
|
+
teams.push(parseBattleTeamFromArmies(ib));
|
|
550
445
|
} catch {
|
|
551
446
|
}
|
|
552
447
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
448
|
+
let battleStartMs = 0;
|
|
449
|
+
let battleDurationSeconds = 0;
|
|
450
|
+
const configBuf = getBytes(f, 18);
|
|
451
|
+
if (configBuf) {
|
|
557
452
|
try {
|
|
558
|
-
|
|
453
|
+
const cf = decodeProto(configBuf);
|
|
454
|
+
battleStartMs = getInt(cf, 2);
|
|
455
|
+
battleDurationSeconds = getInt(cf, 3);
|
|
559
456
|
} catch {
|
|
560
457
|
}
|
|
561
458
|
}
|
|
459
|
+
let timeLeftSeconds = 0;
|
|
460
|
+
if (battleStartMs > 0 && battleDurationSeconds > 0) {
|
|
461
|
+
const elapsedMs = Date.now() - battleStartMs;
|
|
462
|
+
timeLeftSeconds = Math.max(0, battleDurationSeconds - Math.floor(elapsedMs / 1e3));
|
|
463
|
+
}
|
|
562
464
|
return {
|
|
563
465
|
...base,
|
|
564
466
|
type: "battleArmies",
|
|
565
467
|
battleId,
|
|
566
468
|
teams,
|
|
567
469
|
status: battleStatus,
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
470
|
+
battleStartMs,
|
|
471
|
+
battleDurationSeconds,
|
|
472
|
+
timeLeftSeconds
|
|
571
473
|
};
|
|
572
474
|
}
|
|
475
|
+
// Proto: WebcastSubNotifyMessage { User user=2, ... subMonth=3 }
|
|
573
476
|
case "WebcastSubNotifyMessage": {
|
|
574
477
|
const userBuf = getBytes(f, 2);
|
|
575
478
|
const user = userBuf ? parseUser(userBuf) : { id: "0", nickname: "", uniqueId: "" };
|
|
@@ -595,6 +498,8 @@ function parseWebcastMessage(method, payload) {
|
|
|
595
498
|
const envelopeId = String(getInt(f, 1) || getStr(f, 1));
|
|
596
499
|
return { ...base, type: "envelope", envelopeId, diamondCount: getInt(f, 3) };
|
|
597
500
|
}
|
|
501
|
+
// Proto: WebcastQuestionNewMessage { MessageType type=1, QuestionDetails questionDetails=2 }
|
|
502
|
+
// QuestionDetails { string questionText=2, User user=5 }
|
|
598
503
|
case "WebcastQuestionNewMessage": {
|
|
599
504
|
let questionText = "", user = { id: "0", nickname: "", uniqueId: "" };
|
|
600
505
|
const detailBuf = getBytes(f, 2);
|
|
@@ -624,17 +529,20 @@ function parseWebcastMessage(method, payload) {
|
|
|
624
529
|
}
|
|
625
530
|
return { ...base, type: "rankUpdate", rankType, rankList };
|
|
626
531
|
}
|
|
532
|
+
// Proto: WebcastControlMessage { int32 action=2 }
|
|
627
533
|
case "WebcastControlMessage":
|
|
628
534
|
return { ...base, type: "control", action: getInt(f, 2) || getInt(f, 1) };
|
|
629
535
|
case "WebcastRoomMessage":
|
|
630
536
|
case "RoomMessage":
|
|
631
537
|
return { ...base, type: "room", status: getInt(f, 2) };
|
|
538
|
+
// Proto: WebcastLiveIntroMessage { uint64 id=2, string description=4, User user=5 }
|
|
632
539
|
case "WebcastLiveIntroMessage": {
|
|
633
540
|
const roomId = String(getInt(f, 2));
|
|
634
541
|
const title = getStr(f, 4) || getStr(f, 2);
|
|
635
542
|
return { ...base, type: "liveIntro", roomId, title };
|
|
636
543
|
}
|
|
637
|
-
case "WebcastLinkMicMethod":
|
|
544
|
+
case "WebcastLinkMicMethod":
|
|
545
|
+
case "WebcastLinkmicBattleTaskMessage": {
|
|
638
546
|
const action = getStr(f, 1) || `action_${getInt(f, 1)}`;
|
|
639
547
|
const users = [];
|
|
640
548
|
const userBufs = getAllBytes(f, 2);
|
|
@@ -646,152 +554,6 @@ function parseWebcastMessage(method, payload) {
|
|
|
646
554
|
}
|
|
647
555
|
return { ...base, type: "linkMic", action, users };
|
|
648
556
|
}
|
|
649
|
-
case "WebcastLinkmicBattleTaskMessage": {
|
|
650
|
-
const taskAction = getInt(f, 2);
|
|
651
|
-
const battleRefId = getIntStr(f, 20) || "";
|
|
652
|
-
let timerType = 0;
|
|
653
|
-
let remainingSeconds = 0;
|
|
654
|
-
let endTimestampS = 0;
|
|
655
|
-
const timerBuf = getBytes(f, 5);
|
|
656
|
-
if (timerBuf) {
|
|
657
|
-
try {
|
|
658
|
-
const tf = decodeProto(timerBuf);
|
|
659
|
-
timerType = getInt(tf, 1);
|
|
660
|
-
remainingSeconds = getInt(tf, 2);
|
|
661
|
-
endTimestampS = getInt(tf, 3);
|
|
662
|
-
} catch {
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
let multiplier = 0;
|
|
666
|
-
let missionDuration = 0;
|
|
667
|
-
let missionTarget = 0;
|
|
668
|
-
let missionType = "";
|
|
669
|
-
const bonusBuf = getBytes(f, 3);
|
|
670
|
-
if (bonusBuf) {
|
|
671
|
-
try {
|
|
672
|
-
const bf = decodeProto(bonusBuf);
|
|
673
|
-
const missionBuf = getBytes(bf, 1);
|
|
674
|
-
if (missionBuf) {
|
|
675
|
-
const mf = decodeProto(missionBuf);
|
|
676
|
-
missionTarget = getInt(mf, 1);
|
|
677
|
-
const items = getAllBytes(mf, 2);
|
|
678
|
-
for (const item of items) {
|
|
679
|
-
try {
|
|
680
|
-
const itemFields = decodeProto(item);
|
|
681
|
-
const descBuf = getBytes(itemFields, 2);
|
|
682
|
-
if (descBuf) {
|
|
683
|
-
const descFields = decodeProto(descBuf);
|
|
684
|
-
const paramBufs = getAllBytes(descFields, 2);
|
|
685
|
-
for (const pb of paramBufs) {
|
|
686
|
-
try {
|
|
687
|
-
const pf = decodeProto(pb);
|
|
688
|
-
const key = getStr(pf, 1);
|
|
689
|
-
const val = getStr(pf, 2);
|
|
690
|
-
if (key === "multi" && val) multiplier = parseInt(val) || 0;
|
|
691
|
-
if (key === "dur" && val) missionDuration = parseInt(val) || 0;
|
|
692
|
-
if (key === "sum" && val) missionTarget = parseInt(val) || missionTarget;
|
|
693
|
-
} catch {
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
const typeStr = getStr(descFields, 1);
|
|
697
|
-
if (typeStr) missionType = typeStr;
|
|
698
|
-
}
|
|
699
|
-
} catch {
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
const countdownBuf = getBytes(mf, 3);
|
|
703
|
-
if (countdownBuf) {
|
|
704
|
-
try {
|
|
705
|
-
const cf = decodeProto(countdownBuf);
|
|
706
|
-
if (!missionDuration) missionDuration = getInt(cf, 2);
|
|
707
|
-
} catch {
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
} catch {
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
if (!missionType) {
|
|
715
|
-
const descBuf6 = getBytes(f, 6);
|
|
716
|
-
if (descBuf6) {
|
|
717
|
-
try {
|
|
718
|
-
const d6 = decodeProto(descBuf6);
|
|
719
|
-
const innerBuf = getBytes(d6, 1);
|
|
720
|
-
if (innerBuf) {
|
|
721
|
-
const innerF = decodeProto(innerBuf);
|
|
722
|
-
const typeStr = getStr(innerF, 1);
|
|
723
|
-
if (typeStr) missionType = typeStr;
|
|
724
|
-
const paramBufs = getAllBytes(innerF, 2);
|
|
725
|
-
for (const pb of paramBufs) {
|
|
726
|
-
try {
|
|
727
|
-
const pf = decodeProto(pb);
|
|
728
|
-
const key = getStr(pf, 1);
|
|
729
|
-
const val = getStr(pf, 2);
|
|
730
|
-
if (key === "multi" && val) multiplier = parseInt(val) || 0;
|
|
731
|
-
if (key === "sum" && val) missionTarget = parseInt(val) || missionTarget;
|
|
732
|
-
} catch {
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
} catch {
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
return {
|
|
741
|
-
...base,
|
|
742
|
-
type: "battleTask",
|
|
743
|
-
taskAction,
|
|
744
|
-
battleRefId,
|
|
745
|
-
missionType,
|
|
746
|
-
multiplier,
|
|
747
|
-
missionDuration,
|
|
748
|
-
missionTarget,
|
|
749
|
-
remainingSeconds,
|
|
750
|
-
endTimestampS,
|
|
751
|
-
timerType
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
case "WebcastBarrageMessage": {
|
|
755
|
-
const msgType = getInt(f, 3);
|
|
756
|
-
const duration = getInt(f, 4);
|
|
757
|
-
const displayType = getInt(f, 5);
|
|
758
|
-
const subType = getInt(f, 6);
|
|
759
|
-
let defaultPattern = "";
|
|
760
|
-
let content = "";
|
|
761
|
-
const contentBuf = getBytes(f, 2);
|
|
762
|
-
if (contentBuf) {
|
|
763
|
-
try {
|
|
764
|
-
const cf = decodeProto(contentBuf);
|
|
765
|
-
defaultPattern = getStr(cf, 1) || "";
|
|
766
|
-
const paramBufs = getAllBytes(cf, 2);
|
|
767
|
-
const params = [];
|
|
768
|
-
for (const pb of paramBufs) {
|
|
769
|
-
try {
|
|
770
|
-
const pf = decodeProto(pb);
|
|
771
|
-
const key = getStr(pf, 1);
|
|
772
|
-
const val = getStr(pf, 2);
|
|
773
|
-
if (key && val) params.push(`${key}=${val}`);
|
|
774
|
-
} catch {
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
if (params.length > 0) content = params.join(", ");
|
|
778
|
-
} catch {
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
if (!defaultPattern) {
|
|
782
|
-
defaultPattern = getStr(f, 1) || "";
|
|
783
|
-
}
|
|
784
|
-
return {
|
|
785
|
-
...base,
|
|
786
|
-
type: "barrage",
|
|
787
|
-
msgType,
|
|
788
|
-
subType,
|
|
789
|
-
displayType,
|
|
790
|
-
duration,
|
|
791
|
-
defaultPattern,
|
|
792
|
-
content
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
557
|
default:
|
|
796
558
|
return { ...base, type: "unknown", method };
|
|
797
559
|
}
|
|
@@ -816,12 +578,10 @@ function parseWebcastResponse(payload) {
|
|
|
816
578
|
// src/client.ts
|
|
817
579
|
var DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
818
580
|
var DEFAULT_SIGN_SERVER = "https://api.tik.tools";
|
|
819
|
-
function httpGet(url, headers
|
|
581
|
+
function httpGet(url, headers) {
|
|
820
582
|
return new Promise((resolve, reject) => {
|
|
821
583
|
const mod = url.startsWith("https") ? https : http;
|
|
822
|
-
const
|
|
823
|
-
const parsed = new URL(url);
|
|
824
|
-
const req = mod.get(parsed, opts, (res) => {
|
|
584
|
+
const req = mod.get(url, { headers }, (res) => {
|
|
825
585
|
const chunks = [];
|
|
826
586
|
const enc = res.headers["content-encoding"];
|
|
827
587
|
const stream = enc === "gzip" || enc === "br" ? res.pipe(enc === "br" ? zlib.createBrotliDecompress() : zlib.createGunzip()) : res;
|
|
@@ -847,15 +607,21 @@ function getWsHost(clusterRegion) {
|
|
|
847
607
|
if (r.startsWith("us") || r.includes("us")) return "webcast-ws.us.tiktok.com";
|
|
848
608
|
return "webcast-ws.tiktok.com";
|
|
849
609
|
}
|
|
850
|
-
var TikTokLive = class extends EventEmitter {
|
|
610
|
+
var TikTokLive = class _TikTokLive extends EventEmitter {
|
|
851
611
|
ws = null;
|
|
852
612
|
heartbeatTimer = null;
|
|
853
613
|
reconnectAttempts = 0;
|
|
854
614
|
intentionalClose = false;
|
|
855
615
|
_connected = false;
|
|
616
|
+
_destroyed = false;
|
|
856
617
|
_eventCount = 0;
|
|
857
618
|
_roomId = "";
|
|
858
|
-
|
|
619
|
+
// Cache host identities from battle events for enriching battleArmies
|
|
620
|
+
// Bounded to MAX_BATTLE_HOSTS entries to prevent unbounded memory growth
|
|
621
|
+
static MAX_BATTLE_HOSTS = 50;
|
|
622
|
+
_battleHosts = /* @__PURE__ */ new Map();
|
|
623
|
+
// Track in-flight host resolution requests to avoid duplicate API calls
|
|
624
|
+
_pendingHostResolves = /* @__PURE__ */ new Set();
|
|
859
625
|
uniqueId;
|
|
860
626
|
signServerUrl;
|
|
861
627
|
apiKey;
|
|
@@ -863,13 +629,11 @@ var TikTokLive = class extends EventEmitter {
|
|
|
863
629
|
maxReconnectAttempts;
|
|
864
630
|
heartbeatInterval;
|
|
865
631
|
debug;
|
|
866
|
-
_sessionId;
|
|
867
|
-
_ttTargetIdc;
|
|
868
|
-
proxyAgent;
|
|
869
632
|
_presetRoomId;
|
|
870
|
-
|
|
633
|
+
_presetSessionId;
|
|
871
634
|
constructor(options) {
|
|
872
635
|
super();
|
|
636
|
+
this.setMaxListeners(20);
|
|
873
637
|
this.uniqueId = options.uniqueId.replace(/^@/, "");
|
|
874
638
|
this.signServerUrl = (options.signServerUrl || DEFAULT_SIGN_SERVER).replace(/\/$/, "");
|
|
875
639
|
if (!options.apiKey) throw new Error("apiKey is required. Get a free key at https://tik.tools");
|
|
@@ -878,35 +642,60 @@ var TikTokLive = class extends EventEmitter {
|
|
|
878
642
|
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
879
643
|
this.heartbeatInterval = options.heartbeatInterval ?? 1e4;
|
|
880
644
|
this.debug = options.debug ?? false;
|
|
881
|
-
this.
|
|
882
|
-
this.
|
|
883
|
-
if (options.agent) {
|
|
884
|
-
this.proxyAgent = options.agent;
|
|
885
|
-
}
|
|
886
|
-
this._presetRoomId = options.roomId;
|
|
887
|
-
this._presetTtwid = options.ttwid;
|
|
645
|
+
this._presetRoomId = options.roomId || "";
|
|
646
|
+
this._presetSessionId = options.sessionId || "";
|
|
888
647
|
}
|
|
889
648
|
async connect() {
|
|
649
|
+
if (this._destroyed) throw new Error("Client has been destroyed. Create a new instance.");
|
|
890
650
|
this.intentionalClose = false;
|
|
891
|
-
let ttwid = this.
|
|
892
|
-
let roomId = this._presetRoomId
|
|
893
|
-
let ownerUserId = "";
|
|
651
|
+
let ttwid = this._presetSessionId;
|
|
652
|
+
let roomId = this._presetRoomId;
|
|
894
653
|
let clusterRegion = "";
|
|
895
|
-
if (
|
|
896
|
-
|
|
897
|
-
|
|
654
|
+
if (roomId && ttwid) {
|
|
655
|
+
} else if (roomId && !ttwid) {
|
|
656
|
+
try {
|
|
657
|
+
const credResp = await fetch(`${this.signServerUrl}/webcast/ws_credentials`, {
|
|
658
|
+
method: "POST",
|
|
659
|
+
headers: { "Content-Type": "application/json", "x-api-key": this.apiKey },
|
|
660
|
+
body: JSON.stringify({ unique_id: this.uniqueId, room_id: roomId })
|
|
661
|
+
});
|
|
662
|
+
if (credResp.ok) {
|
|
663
|
+
const credData = await credResp.json();
|
|
664
|
+
if (credData.data?.cookies) {
|
|
665
|
+
const m = credData.data.cookies.match(/ttwid=([^;]+)/);
|
|
666
|
+
if (m) ttwid = m[1];
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
} catch {
|
|
670
|
+
}
|
|
671
|
+
if (!ttwid) {
|
|
672
|
+
try {
|
|
673
|
+
const resp = await httpGet(`https://www.tiktok.com/@${this.uniqueId}/live`, {
|
|
674
|
+
"User-Agent": DEFAULT_UA,
|
|
675
|
+
"Accept": "text/html"
|
|
676
|
+
});
|
|
677
|
+
for (const sc of [resp.headers["set-cookie"] || []].flat()) {
|
|
678
|
+
if (typeof sc === "string" && sc.startsWith("ttwid=")) {
|
|
679
|
+
ttwid = sc.split(";")[0].split("=").slice(1).join("=");
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
const resp = await httpGet(`https://www.tiktok.com/@${this.uniqueId}/live`, {
|
|
898
688
|
"User-Agent": DEFAULT_UA,
|
|
899
689
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
900
690
|
"Accept-Encoding": "gzip, deflate, br",
|
|
901
691
|
"Accept-Language": "en-US,en;q=0.9"
|
|
902
|
-
}
|
|
692
|
+
});
|
|
903
693
|
for (const sc of [resp.headers["set-cookie"] || []].flat()) {
|
|
904
694
|
if (typeof sc === "string" && sc.startsWith("ttwid=")) {
|
|
905
695
|
ttwid = sc.split(";")[0].split("=").slice(1).join("=");
|
|
906
696
|
break;
|
|
907
697
|
}
|
|
908
698
|
}
|
|
909
|
-
if (!ttwid) throw new Error("Failed to obtain session cookie");
|
|
910
699
|
const html = resp.body.toString();
|
|
911
700
|
const sigiMatch = html.match(/id="SIGI_STATE"[^>]*>([^<]+)/);
|
|
912
701
|
if (sigiMatch) {
|
|
@@ -915,19 +704,15 @@ var TikTokLive = class extends EventEmitter {
|
|
|
915
704
|
const jsonStr = JSON.stringify(json);
|
|
916
705
|
const m = jsonStr.match(/"roomId"\s*:\s*"(\d+)"/);
|
|
917
706
|
if (m) roomId = m[1];
|
|
918
|
-
const ownerUser = json?.LiveRoom?.liveRoomUserInfo?.user;
|
|
919
|
-
if (ownerUser?.id) {
|
|
920
|
-
ownerUserId = String(ownerUser.id);
|
|
921
|
-
}
|
|
922
707
|
} catch {
|
|
923
708
|
}
|
|
924
709
|
}
|
|
925
|
-
if (!roomId) throw new Error(`User @${this.uniqueId} is not currently live`);
|
|
926
710
|
const crMatch = html.match(/"clusterRegion"\s*:\s*"([^"]+)"/);
|
|
927
711
|
clusterRegion = crMatch ? crMatch[1] : "";
|
|
928
712
|
}
|
|
713
|
+
if (!ttwid) throw new Error("Failed to obtain session cookie");
|
|
714
|
+
if (!roomId) throw new Error(`User @${this.uniqueId} is not currently live`);
|
|
929
715
|
this._roomId = roomId;
|
|
930
|
-
this._ownerUserId = ownerUserId;
|
|
931
716
|
const wsHost = getWsHost(clusterRegion);
|
|
932
717
|
const wsParams = new URLSearchParams({
|
|
933
718
|
version_code: "270000",
|
|
@@ -981,21 +766,12 @@ var TikTokLive = class extends EventEmitter {
|
|
|
981
766
|
wsUrl = rawWsUrl.replace(/^https:\/\//, "wss://");
|
|
982
767
|
}
|
|
983
768
|
return new Promise((resolve, reject) => {
|
|
984
|
-
let cookieHeader = ttwid ? `ttwid=${ttwid}` : "";
|
|
985
|
-
if (this._sessionId) {
|
|
986
|
-
const sessionCookies = `sessionid=${this._sessionId}; sessionid_ss=${this._sessionId}; sid_tt=${this._sessionId}`;
|
|
987
|
-
cookieHeader = cookieHeader ? `${cookieHeader}; ${sessionCookies}` : sessionCookies;
|
|
988
|
-
if (this._ttTargetIdc) {
|
|
989
|
-
cookieHeader += `; tt-target-idc=${this._ttTargetIdc}`;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
769
|
this.ws = new WebSocket(wsUrl, {
|
|
993
770
|
headers: {
|
|
994
771
|
"User-Agent": DEFAULT_UA,
|
|
995
|
-
"Cookie":
|
|
772
|
+
"Cookie": `ttwid=${ttwid}`,
|
|
996
773
|
"Origin": "https://www.tiktok.com"
|
|
997
|
-
}
|
|
998
|
-
agent: this.proxyAgent
|
|
774
|
+
}
|
|
999
775
|
});
|
|
1000
776
|
this.ws.on("open", () => {
|
|
1001
777
|
this._connected = true;
|
|
@@ -1007,8 +783,7 @@ var TikTokLive = class extends EventEmitter {
|
|
|
1007
783
|
roomId,
|
|
1008
784
|
wsHost,
|
|
1009
785
|
clusterRegion,
|
|
1010
|
-
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1011
|
-
ownerUserId: ownerUserId || void 0
|
|
786
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1012
787
|
};
|
|
1013
788
|
this.emit("connected");
|
|
1014
789
|
this.emit("roomInfo", roomInfo);
|
|
@@ -1038,42 +813,76 @@ var TikTokLive = class extends EventEmitter {
|
|
|
1038
813
|
this.intentionalClose = true;
|
|
1039
814
|
this.stopHeartbeat();
|
|
1040
815
|
if (this.ws) {
|
|
816
|
+
this.ws.removeAllListeners();
|
|
1041
817
|
this.ws.close(1e3);
|
|
1042
818
|
this.ws = null;
|
|
1043
819
|
}
|
|
1044
820
|
this._connected = false;
|
|
1045
821
|
}
|
|
822
|
+
/**
|
|
823
|
+
* Fully destroy the client, releasing all resources and listeners.
|
|
824
|
+
* After calling destroy(), the instance cannot be reused — create a new one.
|
|
825
|
+
*/
|
|
826
|
+
destroy() {
|
|
827
|
+
this.disconnect();
|
|
828
|
+
this._destroyed = true;
|
|
829
|
+
this._battleHosts.clear();
|
|
830
|
+
this._eventCount = 0;
|
|
831
|
+
this._roomId = "";
|
|
832
|
+
this.removeAllListeners();
|
|
833
|
+
}
|
|
1046
834
|
get connected() {
|
|
1047
835
|
return this._connected;
|
|
1048
836
|
}
|
|
837
|
+
get destroyed() {
|
|
838
|
+
return this._destroyed;
|
|
839
|
+
}
|
|
1049
840
|
get eventCount() {
|
|
1050
841
|
return this._eventCount;
|
|
1051
842
|
}
|
|
1052
843
|
get roomId() {
|
|
1053
844
|
return this._roomId;
|
|
1054
845
|
}
|
|
1055
|
-
/** Get the stored session ID (if any) */
|
|
1056
|
-
get sessionId() {
|
|
1057
|
-
return this._sessionId;
|
|
1058
|
-
}
|
|
1059
|
-
/** Update the session ID at runtime (e.g. after TikTok login) */
|
|
1060
|
-
setSession(sessionId, ttTargetIdc) {
|
|
1061
|
-
this._sessionId = sessionId;
|
|
1062
|
-
if (ttTargetIdc) this._ttTargetIdc = ttTargetIdc;
|
|
1063
|
-
}
|
|
1064
846
|
/**
|
|
1065
|
-
*
|
|
1066
|
-
* Returns
|
|
847
|
+
* Get live stream video URLs for a TikTok user.
|
|
848
|
+
* Returns FLV & HLS URLs segmented by quality (FULL_HD1, HD1, SD1, SD2).
|
|
849
|
+
* This is a standalone method — no WebSocket connection required.
|
|
850
|
+
*
|
|
851
|
+
* @example
|
|
852
|
+
* ```ts
|
|
853
|
+
* const stream = await TikTokLive.getStreamUrl({
|
|
854
|
+
* uniqueId: 'username',
|
|
855
|
+
* apiKey: 'your-api-key',
|
|
856
|
+
* });
|
|
857
|
+
* if (stream.alive) {
|
|
858
|
+
* console.log(stream.streamUrls.HD1?.hls); // HLS URL for HD quality
|
|
859
|
+
* console.log(stream.flvPullUrl); // Best FLV URL
|
|
860
|
+
* }
|
|
861
|
+
* ```
|
|
1067
862
|
*/
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
863
|
+
static async getStreamUrl(options) {
|
|
864
|
+
const serverUrl = (options.signServerUrl || DEFAULT_SIGN_SERVER).replace(/\/$/, "");
|
|
865
|
+
const resp = await fetch(`${serverUrl}/webcast/room_video`, {
|
|
866
|
+
method: "POST",
|
|
867
|
+
headers: {
|
|
868
|
+
"Content-Type": "application/json",
|
|
869
|
+
"x-api-key": options.apiKey
|
|
870
|
+
},
|
|
871
|
+
body: JSON.stringify({ unique_id: options.uniqueId.replace(/^@/, "") })
|
|
872
|
+
});
|
|
873
|
+
const data = await resp.json();
|
|
874
|
+
if (data.status_code !== 0) {
|
|
875
|
+
throw new Error(data.error || "Failed to get stream URL");
|
|
876
|
+
}
|
|
877
|
+
const d = data.data;
|
|
878
|
+
return {
|
|
879
|
+
roomId: d.room_id,
|
|
880
|
+
alive: d.alive ?? false,
|
|
881
|
+
streamUrls: d.stream_urls || {},
|
|
882
|
+
defaultQuality: d.default_quality || "",
|
|
883
|
+
flvPullUrl: d.flv_pull_url || void 0,
|
|
884
|
+
hlsPullUrl: d.hls_pull_url || void 0
|
|
885
|
+
};
|
|
1077
886
|
}
|
|
1078
887
|
on(event, listener) {
|
|
1079
888
|
return super.on(event, listener);
|
|
@@ -1108,566 +917,119 @@ var TikTokLive = class extends EventEmitter {
|
|
|
1108
917
|
const events = parseWebcastResponse(inner);
|
|
1109
918
|
for (const evt of events) {
|
|
1110
919
|
this._eventCount++;
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}
|
|
1124
|
-
}, this.heartbeatInterval);
|
|
1125
|
-
}
|
|
1126
|
-
stopHeartbeat() {
|
|
1127
|
-
if (this.heartbeatTimer) {
|
|
1128
|
-
clearInterval(this.heartbeatTimer);
|
|
1129
|
-
this.heartbeatTimer = null;
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
};
|
|
1133
|
-
|
|
1134
|
-
// src/captions.ts
|
|
1135
|
-
import { EventEmitter as EventEmitter2 } from "events";
|
|
1136
|
-
import WebSocket2 from "ws";
|
|
1137
|
-
var FLV_TAG_AUDIO = 8;
|
|
1138
|
-
var FLV_HEADER_SIZE = 9;
|
|
1139
|
-
var FLV_PREV_TAG_SIZE = 4;
|
|
1140
|
-
var FlvAudioExtractor = class {
|
|
1141
|
-
buffer = new Uint8Array(0);
|
|
1142
|
-
headerParsed = false;
|
|
1143
|
-
onAudio;
|
|
1144
|
-
aacProfile = 2;
|
|
1145
|
-
sampleRateIndex = 4;
|
|
1146
|
-
channelConfig = 2;
|
|
1147
|
-
ascParsed = false;
|
|
1148
|
-
constructor(onAudio) {
|
|
1149
|
-
this.onAudio = onAudio;
|
|
1150
|
-
}
|
|
1151
|
-
parseASC(asc) {
|
|
1152
|
-
if (asc.length < 2) return;
|
|
1153
|
-
this.aacProfile = asc[0] >> 3 & 31;
|
|
1154
|
-
this.sampleRateIndex = (asc[0] & 7) << 1 | asc[1] >> 7 & 1;
|
|
1155
|
-
this.channelConfig = asc[1] >> 3 & 15;
|
|
1156
|
-
this.ascParsed = true;
|
|
1157
|
-
}
|
|
1158
|
-
buildAdtsHeader(frameLength) {
|
|
1159
|
-
const adts = new Uint8Array(7);
|
|
1160
|
-
const fullLength = frameLength + 7;
|
|
1161
|
-
const profile = this.aacProfile - 1;
|
|
1162
|
-
adts[0] = 255;
|
|
1163
|
-
adts[1] = 241;
|
|
1164
|
-
adts[2] = (profile & 3) << 6 | (this.sampleRateIndex & 15) << 2 | this.channelConfig >> 2 & 1;
|
|
1165
|
-
adts[3] = (this.channelConfig & 3) << 6 | fullLength >> 11 & 3;
|
|
1166
|
-
adts[4] = fullLength >> 3 & 255;
|
|
1167
|
-
adts[5] = (fullLength & 7) << 5 | 31;
|
|
1168
|
-
adts[6] = 252;
|
|
1169
|
-
return adts;
|
|
1170
|
-
}
|
|
1171
|
-
push(chunk) {
|
|
1172
|
-
const newBuf = new Uint8Array(this.buffer.length + chunk.length);
|
|
1173
|
-
newBuf.set(this.buffer, 0);
|
|
1174
|
-
newBuf.set(chunk, this.buffer.length);
|
|
1175
|
-
this.buffer = newBuf;
|
|
1176
|
-
if (!this.headerParsed) {
|
|
1177
|
-
if (this.buffer.length < FLV_HEADER_SIZE + FLV_PREV_TAG_SIZE) return;
|
|
1178
|
-
if (this.buffer[0] !== 70 || this.buffer[1] !== 76 || this.buffer[2] !== 86) return;
|
|
1179
|
-
const dv = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
1180
|
-
const dataOffset = dv.getUint32(5);
|
|
1181
|
-
this.buffer = this.buffer.subarray(dataOffset + FLV_PREV_TAG_SIZE);
|
|
1182
|
-
this.headerParsed = true;
|
|
1183
|
-
}
|
|
1184
|
-
while (this.buffer.length >= 11) {
|
|
1185
|
-
const tagType = this.buffer[0] & 31;
|
|
1186
|
-
const dataSize = this.buffer[1] << 16 | this.buffer[2] << 8 | this.buffer[3];
|
|
1187
|
-
const totalTagSize = 11 + dataSize + FLV_PREV_TAG_SIZE;
|
|
1188
|
-
if (this.buffer.length < totalTagSize) break;
|
|
1189
|
-
if (tagType === FLV_TAG_AUDIO) {
|
|
1190
|
-
const audioData = this.buffer.subarray(11, 11 + dataSize);
|
|
1191
|
-
if (audioData.length > 0) {
|
|
1192
|
-
const soundFormat = audioData[0] >> 4 & 15;
|
|
1193
|
-
if (soundFormat === 10 && audioData.length > 2) {
|
|
1194
|
-
const aacPacketType = audioData[1];
|
|
1195
|
-
if (aacPacketType === 0) {
|
|
1196
|
-
this.parseASC(audioData.subarray(2));
|
|
1197
|
-
} else if (aacPacketType === 1 && this.ascParsed) {
|
|
1198
|
-
const rawFrame = audioData.subarray(2);
|
|
1199
|
-
const adtsHeader = this.buildAdtsHeader(rawFrame.length);
|
|
1200
|
-
const adtsFrame = new Uint8Array(adtsHeader.length + rawFrame.length);
|
|
1201
|
-
adtsFrame.set(adtsHeader, 0);
|
|
1202
|
-
adtsFrame.set(rawFrame, adtsHeader.length);
|
|
1203
|
-
this.onAudio(adtsFrame);
|
|
920
|
+
if (evt.type === "battle") {
|
|
921
|
+
for (const team of evt.teams) {
|
|
922
|
+
const host = team.users.find((u) => u.user.id === team.hostUserId);
|
|
923
|
+
if (host) {
|
|
924
|
+
this._battleHosts.set(team.hostUserId, host.user);
|
|
925
|
+
team.hostUser = host.user;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (this._battleHosts.size > _TikTokLive.MAX_BATTLE_HOSTS) {
|
|
929
|
+
const excess = this._battleHosts.size - _TikTokLive.MAX_BATTLE_HOSTS;
|
|
930
|
+
const keys = [...this._battleHosts.keys()].slice(0, excess);
|
|
931
|
+
for (const key of keys) this._battleHosts.delete(key);
|
|
1204
932
|
}
|
|
1205
933
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
maxReconnectAttempts;
|
|
1228
|
-
debug;
|
|
1229
|
-
_translate;
|
|
1230
|
-
_diarization;
|
|
1231
|
-
_maxDurationMinutes;
|
|
1232
|
-
_language;
|
|
1233
|
-
streamAbortController = null;
|
|
1234
|
-
flvExtractor = null;
|
|
1235
|
-
streamUrl = null;
|
|
1236
|
-
constructor(options) {
|
|
1237
|
-
super();
|
|
1238
|
-
this.uniqueId = options.uniqueId.replace(/^@/, "");
|
|
1239
|
-
if (!options.apiKey) throw new Error("apiKey is required. Get a free key at https://tik.tools");
|
|
1240
|
-
this.apiKey = options.apiKey;
|
|
1241
|
-
this._language = options.language || "";
|
|
1242
|
-
this._translate = options.translate || "";
|
|
1243
|
-
this._diarization = options.diarization ?? true;
|
|
1244
|
-
this._maxDurationMinutes = options.maxDurationMinutes ?? 60;
|
|
1245
|
-
this.serverUrl = (options.signServerUrl || DEFAULT_CAPTIONS_SERVER).replace(/\/$/, "");
|
|
1246
|
-
this.autoReconnect = options.autoReconnect ?? true;
|
|
1247
|
-
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
1248
|
-
this.debug = options.debug ?? false;
|
|
1249
|
-
}
|
|
1250
|
-
/**
|
|
1251
|
-
* Start real-time captions for the configured TikTok user.
|
|
1252
|
-
* Connects to the captions WebSocket relay and begins transcription
|
|
1253
|
-
* once the user goes live (or immediately if already live).
|
|
1254
|
-
*/
|
|
1255
|
-
async start() {
|
|
1256
|
-
this.intentionalClose = false;
|
|
1257
|
-
const wsUrl = this.buildWsUrl();
|
|
1258
|
-
if (this.debug) console.log(`[Captions] Connecting to ${wsUrl}`);
|
|
1259
|
-
return new Promise((resolve, reject) => {
|
|
1260
|
-
this.ws = new WebSocket2(wsUrl);
|
|
1261
|
-
this.ws.on("open", () => {
|
|
1262
|
-
this._connected = true;
|
|
1263
|
-
this.reconnectAttempts = 0;
|
|
1264
|
-
if (this.debug) console.log("[Captions] Connected");
|
|
1265
|
-
this.emit("connected");
|
|
1266
|
-
resolve();
|
|
1267
|
-
});
|
|
1268
|
-
this.ws.on("message", (data) => {
|
|
1269
|
-
this.handleMessage(typeof data === "string" ? data : data.toString());
|
|
1270
|
-
});
|
|
1271
|
-
this.ws.on("close", (code, reason) => {
|
|
1272
|
-
this._connected = false;
|
|
1273
|
-
const reasonStr = reason?.toString() || "";
|
|
1274
|
-
if (this.debug) console.log(`[Captions] Disconnected: ${code} ${reasonStr}`);
|
|
1275
|
-
this.emit("disconnected", code, reasonStr);
|
|
1276
|
-
if (!this.intentionalClose && this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
1277
|
-
this.reconnectAttempts++;
|
|
1278
|
-
const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
|
|
1279
|
-
if (this.debug) console.log(`[Captions] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
1280
|
-
setTimeout(() => this.start().catch((e) => this.emit("error", { code: "RECONNECT_FAILED", message: e.message })), delay);
|
|
1281
|
-
}
|
|
1282
|
-
});
|
|
1283
|
-
this.ws.on("error", (err) => {
|
|
1284
|
-
this.emit("error", { code: "WS_ERROR", message: err.message });
|
|
1285
|
-
if (!this._connected) reject(err);
|
|
1286
|
-
});
|
|
1287
|
-
});
|
|
1288
|
-
}
|
|
1289
|
-
/**
|
|
1290
|
-
* Stop captions and disconnect.
|
|
1291
|
-
*/
|
|
1292
|
-
stop() {
|
|
1293
|
-
this.intentionalClose = true;
|
|
1294
|
-
if (this.streamAbortController) {
|
|
1295
|
-
this.streamAbortController.abort();
|
|
1296
|
-
this.streamAbortController = null;
|
|
1297
|
-
}
|
|
1298
|
-
if (this.flvExtractor) {
|
|
1299
|
-
this.flvExtractor.reset();
|
|
1300
|
-
this.flvExtractor = null;
|
|
1301
|
-
}
|
|
1302
|
-
if (this.ws) {
|
|
1303
|
-
this.send({ action: "stop" });
|
|
1304
|
-
this.ws.close(1e3);
|
|
1305
|
-
this.ws = null;
|
|
1306
|
-
}
|
|
1307
|
-
this._connected = false;
|
|
1308
|
-
}
|
|
1309
|
-
/**
|
|
1310
|
-
* Switch the translation target language on-the-fly.
|
|
1311
|
-
* Causes a brief interruption while the transcription engine reconfigures.
|
|
1312
|
-
*/
|
|
1313
|
-
setLanguage(language) {
|
|
1314
|
-
this._language = language;
|
|
1315
|
-
this.send({ action: "set_language", language });
|
|
1316
|
-
}
|
|
1317
|
-
/**
|
|
1318
|
-
* Request a credit balance update from the server.
|
|
1319
|
-
*/
|
|
1320
|
-
getCredits() {
|
|
1321
|
-
this.send({ action: "get_credits" });
|
|
1322
|
-
}
|
|
1323
|
-
/** Whether the WebSocket is currently connected */
|
|
1324
|
-
get connected() {
|
|
1325
|
-
return this._connected;
|
|
1326
|
-
}
|
|
1327
|
-
/** The current target language */
|
|
1328
|
-
get language() {
|
|
1329
|
-
return this._language;
|
|
1330
|
-
}
|
|
1331
|
-
on(event, listener) {
|
|
1332
|
-
return super.on(event, listener);
|
|
1333
|
-
}
|
|
1334
|
-
once(event, listener) {
|
|
1335
|
-
return super.once(event, listener);
|
|
1336
|
-
}
|
|
1337
|
-
off(event, listener) {
|
|
1338
|
-
return super.off(event, listener);
|
|
1339
|
-
}
|
|
1340
|
-
emit(event, ...args) {
|
|
1341
|
-
return super.emit(event, ...args);
|
|
1342
|
-
}
|
|
1343
|
-
buildWsUrl() {
|
|
1344
|
-
const base = this.serverUrl.replace(/^http/, "ws");
|
|
1345
|
-
const params = new URLSearchParams({
|
|
1346
|
-
uniqueId: this.uniqueId,
|
|
1347
|
-
apiKey: this.apiKey
|
|
1348
|
-
});
|
|
1349
|
-
if (this._language) params.set("language", this._language);
|
|
1350
|
-
if (this._translate) params.set("translate", this._translate);
|
|
1351
|
-
if (this._diarization !== void 0) params.set("diarization", String(this._diarization));
|
|
1352
|
-
if (this._maxDurationMinutes) params.set("max_duration_minutes", String(this._maxDurationMinutes));
|
|
1353
|
-
return `${base}/captions?${params}`;
|
|
1354
|
-
}
|
|
1355
|
-
send(msg) {
|
|
1356
|
-
if (this.ws?.readyState === WebSocket2.OPEN) {
|
|
1357
|
-
this.ws.send(JSON.stringify(msg));
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
handleMessage(raw) {
|
|
1361
|
-
try {
|
|
1362
|
-
const msg = JSON.parse(raw);
|
|
1363
|
-
switch (msg.type) {
|
|
1364
|
-
case "stream_info":
|
|
1365
|
-
if (this.debug) console.log(`[Captions] Received stream_info: flv=${!!msg.flvUrl}, hls=${!!msg.hlsUrl}, ao=${!!msg.audioOnlyUrl}`);
|
|
1366
|
-
this.connectToStream(msg);
|
|
1367
|
-
break;
|
|
1368
|
-
case "caption":
|
|
1369
|
-
this.emit("caption", {
|
|
1370
|
-
text: msg.text,
|
|
1371
|
-
language: msg.language,
|
|
1372
|
-
isFinal: msg.isFinal,
|
|
1373
|
-
confidence: msg.confidence,
|
|
1374
|
-
speaker: msg.speaker,
|
|
1375
|
-
startMs: msg.startMs,
|
|
1376
|
-
endMs: msg.endMs
|
|
1377
|
-
});
|
|
1378
|
-
break;
|
|
1379
|
-
case "translation":
|
|
1380
|
-
this.emit("translation", {
|
|
1381
|
-
text: msg.text,
|
|
1382
|
-
language: msg.language,
|
|
1383
|
-
isFinal: msg.isFinal,
|
|
1384
|
-
confidence: msg.confidence,
|
|
1385
|
-
speaker: msg.speaker
|
|
1386
|
-
});
|
|
1387
|
-
break;
|
|
1388
|
-
case "status":
|
|
1389
|
-
this.emit("status", {
|
|
1390
|
-
status: msg.status,
|
|
1391
|
-
uniqueId: msg.uniqueId,
|
|
1392
|
-
roomId: msg.roomId,
|
|
1393
|
-
language: msg.language,
|
|
1394
|
-
message: msg.message
|
|
1395
|
-
});
|
|
1396
|
-
break;
|
|
1397
|
-
case "credits":
|
|
1398
|
-
this.emit("credits", {
|
|
1399
|
-
remaining: msg.remaining,
|
|
1400
|
-
total: msg.total,
|
|
1401
|
-
used: msg.used,
|
|
1402
|
-
warning: msg.warning
|
|
1403
|
-
});
|
|
1404
|
-
break;
|
|
1405
|
-
case "credits_low":
|
|
1406
|
-
this.emit("credits_low", {
|
|
1407
|
-
remaining: msg.remaining,
|
|
1408
|
-
total: msg.total,
|
|
1409
|
-
percent: msg.percent
|
|
1410
|
-
});
|
|
1411
|
-
break;
|
|
1412
|
-
case "error":
|
|
1413
|
-
this.emit("error", {
|
|
1414
|
-
code: msg.code,
|
|
1415
|
-
message: msg.message
|
|
1416
|
-
});
|
|
1417
|
-
break;
|
|
1418
|
-
// Handle interim/final captions from server (sentence-level accumulation)
|
|
1419
|
-
case "interim":
|
|
1420
|
-
this.emit("caption", {
|
|
1421
|
-
text: msg.text,
|
|
1422
|
-
language: msg.language,
|
|
1423
|
-
isFinal: false,
|
|
1424
|
-
confidence: msg.confidence || 0,
|
|
1425
|
-
speaker: msg.speaker
|
|
1426
|
-
});
|
|
1427
|
-
break;
|
|
1428
|
-
case "final":
|
|
1429
|
-
this.emit("caption", {
|
|
1430
|
-
text: msg.text,
|
|
1431
|
-
language: msg.language,
|
|
1432
|
-
isFinal: true,
|
|
1433
|
-
confidence: msg.confidence || 0,
|
|
1434
|
-
speaker: msg.speaker
|
|
1435
|
-
});
|
|
1436
|
-
break;
|
|
1437
|
-
case "translation_interim":
|
|
1438
|
-
this.emit("translation", {
|
|
1439
|
-
text: msg.text,
|
|
1440
|
-
language: msg.language,
|
|
1441
|
-
isFinal: false,
|
|
1442
|
-
confidence: msg.confidence || 0,
|
|
1443
|
-
speaker: msg.speaker
|
|
1444
|
-
});
|
|
1445
|
-
break;
|
|
1446
|
-
case "translation_final":
|
|
1447
|
-
this.emit("translation", {
|
|
1448
|
-
text: msg.text,
|
|
1449
|
-
language: msg.language,
|
|
1450
|
-
isFinal: true,
|
|
1451
|
-
confidence: msg.confidence || 0,
|
|
1452
|
-
speaker: msg.speaker
|
|
1453
|
-
});
|
|
1454
|
-
break;
|
|
1455
|
-
default:
|
|
1456
|
-
if (this.debug) {
|
|
1457
|
-
if (this.debug) console.log(`[Captions] Unknown message type: ${msg.type}`, msg);
|
|
934
|
+
if (evt.type === "battleArmies") {
|
|
935
|
+
const unresolvedIds = [];
|
|
936
|
+
for (const team of evt.teams) {
|
|
937
|
+
const host = team.users.find((u) => u.user.id === team.hostUserId);
|
|
938
|
+
if (host) {
|
|
939
|
+
team.hostUser = host.user;
|
|
940
|
+
this._battleHosts.set(team.hostUserId, host.user);
|
|
941
|
+
} else {
|
|
942
|
+
const cached = this._battleHosts.get(team.hostUserId);
|
|
943
|
+
if (cached) {
|
|
944
|
+
team.hostUser = cached;
|
|
945
|
+
} else if (team.hostUserId && team.hostUserId !== "0") {
|
|
946
|
+
unresolvedIds.push(team.hostUserId);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (unresolvedIds.length > 0) {
|
|
951
|
+
this.resolveHostUsers(unresolvedIds, evt).catch(() => {
|
|
952
|
+
});
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
1458
955
|
}
|
|
956
|
+
this.emit("event", evt);
|
|
957
|
+
this.emit(evt.type, evt);
|
|
958
|
+
}
|
|
1459
959
|
}
|
|
1460
960
|
} catch {
|
|
1461
|
-
if (this.debug) console.error("[Captions] Failed to parse message:", raw);
|
|
1462
961
|
}
|
|
1463
962
|
}
|
|
1464
963
|
/**
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
964
|
+
* Resolve unknown host user IDs via the sign server API.
|
|
965
|
+
* Caches results and re-emits the battleArmies event with enriched hostUser data.
|
|
1467
966
|
*/
|
|
1468
|
-
async
|
|
1469
|
-
const
|
|
1470
|
-
if (
|
|
1471
|
-
this.emit("
|
|
967
|
+
async resolveHostUsers(userIds, originalEvt) {
|
|
968
|
+
const toResolve = userIds.filter((id) => !this._pendingHostResolves.has(id));
|
|
969
|
+
if (toResolve.length === 0) {
|
|
970
|
+
this.emit("event", originalEvt);
|
|
971
|
+
this.emit(originalEvt.type, originalEvt);
|
|
1472
972
|
return;
|
|
1473
973
|
}
|
|
1474
|
-
this.
|
|
1475
|
-
if (this.debug) console.log(`[Captions] connectToStream: URL selected: ${url.substring(0, 80)}...`);
|
|
1476
|
-
if (this.streamAbortController) {
|
|
1477
|
-
this.streamAbortController.abort();
|
|
1478
|
-
}
|
|
1479
|
-
this.streamAbortController = new AbortController();
|
|
1480
|
-
let audioFramesSent = 0;
|
|
1481
|
-
let audioBytesSent = 0;
|
|
1482
|
-
this.flvExtractor = new FlvAudioExtractor((adtsFrame) => {
|
|
1483
|
-
if (this.ws?.readyState === WebSocket2.OPEN) {
|
|
1484
|
-
this.ws.send(adtsFrame);
|
|
1485
|
-
audioFramesSent++;
|
|
1486
|
-
audioBytesSent += adtsFrame.length;
|
|
1487
|
-
if (this.debug && (audioFramesSent <= 3 || audioFramesSent % 100 === 0)) {
|
|
1488
|
-
if (this.debug) console.log(`[Captions] Audio frame #${audioFramesSent}: ${adtsFrame.length}b (total: ${audioBytesSent}b)`);
|
|
1489
|
-
}
|
|
1490
|
-
} else if (this.debug && audioFramesSent === 0) {
|
|
1491
|
-
if (this.debug) console.log(`[Captions] WARNING: WS not open (readyState=${this.ws?.readyState}), cannot send audio`);
|
|
1492
|
-
}
|
|
1493
|
-
});
|
|
974
|
+
for (const id of toResolve) this._pendingHostResolves.add(id);
|
|
1494
975
|
try {
|
|
1495
|
-
|
|
1496
|
-
const
|
|
1497
|
-
|
|
976
|
+
const url = `${this.signServerUrl}/webcast/resolve_user_ids`;
|
|
977
|
+
const res = await fetch(url, {
|
|
978
|
+
method: "POST",
|
|
1498
979
|
headers: {
|
|
1499
|
-
"
|
|
1500
|
-
|
|
980
|
+
"Content-Type": "application/json",
|
|
981
|
+
"x-api-key": this.apiKey
|
|
982
|
+
},
|
|
983
|
+
body: JSON.stringify({ user_ids: toResolve }),
|
|
984
|
+
signal: AbortSignal.timeout(5e3)
|
|
1501
985
|
});
|
|
1502
|
-
if (
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
const { done, value } = await reader.read();
|
|
1515
|
-
if (done || this.intentionalClose) {
|
|
1516
|
-
if (this.debug) console.log(`[Captions] FLV stream ended (done=${done}, intentionalClose=${this.intentionalClose}), chunks=${chunks}, audioFrames=${audioFramesSent}`);
|
|
1517
|
-
break;
|
|
1518
|
-
}
|
|
1519
|
-
chunks++;
|
|
1520
|
-
if (value && this.flvExtractor) {
|
|
1521
|
-
this.flvExtractor.push(value);
|
|
1522
|
-
}
|
|
1523
|
-
if (this.debug && chunks <= 3) {
|
|
1524
|
-
if (this.debug) console.log(`[Captions] FLV chunk #${chunks}: ${value?.length || 0}b`);
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
} catch (err) {
|
|
1528
|
-
if (err.name !== "AbortError" && !this.intentionalClose) {
|
|
1529
|
-
if (this.debug) console.error("[Captions] FLV stream read error:", err.message);
|
|
1530
|
-
this.emit("error", { code: "STREAM_READ_ERROR", message: err.message });
|
|
1531
|
-
} else if (this.debug) {
|
|
1532
|
-
if (this.debug) console.log(`[Captions] FLV stream aborted after ${chunks} chunks, ${audioFramesSent} audio frames`);
|
|
986
|
+
if (res.ok) {
|
|
987
|
+
const json = await res.json();
|
|
988
|
+
if (json.data) {
|
|
989
|
+
for (const [uid, info] of Object.entries(json.data)) {
|
|
990
|
+
if (info.username) {
|
|
991
|
+
const hostUser = {
|
|
992
|
+
id: uid,
|
|
993
|
+
uniqueId: info.username,
|
|
994
|
+
nickname: info.nickname || info.username,
|
|
995
|
+
profilePicture: info.avatarUrl || void 0
|
|
996
|
+
};
|
|
997
|
+
this._battleHosts.set(uid, hostUser);
|
|
1533
998
|
}
|
|
1534
999
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
try {
|
|
1541
|
-
for await (const chunk of resp.body) {
|
|
1542
|
-
if (this.intentionalClose) break;
|
|
1543
|
-
chunks++;
|
|
1544
|
-
const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
|
1545
|
-
if (this.flvExtractor) {
|
|
1546
|
-
this.flvExtractor.push(u8);
|
|
1000
|
+
if (originalEvt.type === "battleArmies") {
|
|
1001
|
+
for (const team of originalEvt.teams) {
|
|
1002
|
+
if (!team.hostUser) {
|
|
1003
|
+
const cached = this._battleHosts.get(team.hostUserId);
|
|
1004
|
+
if (cached) team.hostUser = cached;
|
|
1547
1005
|
}
|
|
1548
|
-
if (this.debug && chunks <= 3) {
|
|
1549
|
-
if (this.debug) console.log(`[Captions] FLV chunk #${chunks}: ${u8.length}b`);
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
if (this.debug) console.log(`[Captions] Node stream ended, chunks=${chunks}, audioFrames=${audioFramesSent}`);
|
|
1553
|
-
} catch (err) {
|
|
1554
|
-
if (err.name !== "AbortError" && !this.intentionalClose) {
|
|
1555
|
-
if (this.debug) console.error("[Captions] FLV stream read error:", err.message);
|
|
1556
|
-
this.emit("error", { code: "STREAM_READ_ERROR", message: err.message });
|
|
1557
|
-
} else if (this.debug) {
|
|
1558
|
-
if (this.debug) console.log(`[Captions] FLV node stream aborted after ${chunks} chunks, ${audioFramesSent} audio frames`);
|
|
1559
1006
|
}
|
|
1560
1007
|
}
|
|
1561
|
-
}
|
|
1562
|
-
processNodeStream();
|
|
1563
|
-
} else {
|
|
1564
|
-
if (this.debug) console.error(`[Captions] ERROR: resp.body has no getReader() and no asyncIterator!`);
|
|
1565
|
-
}
|
|
1566
|
-
} catch (err) {
|
|
1567
|
-
if (err.name !== "AbortError" && !this.intentionalClose) {
|
|
1568
|
-
if (this.debug) console.error("[Captions] FLV stream connect error:", err.message);
|
|
1569
|
-
this.emit("error", { code: "STREAM_CONNECT_ERROR", message: err.message });
|
|
1008
|
+
}
|
|
1570
1009
|
}
|
|
1010
|
+
} catch {
|
|
1011
|
+
} finally {
|
|
1012
|
+
for (const id of toResolve) this._pendingHostResolves.delete(id);
|
|
1013
|
+
this.emit("event", originalEvt);
|
|
1014
|
+
this.emit(originalEvt.type, originalEvt);
|
|
1571
1015
|
}
|
|
1572
1016
|
}
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
const ak = encodeURIComponent(opts.apiKey);
|
|
1581
|
-
const body = {};
|
|
1582
|
-
if (opts.uniqueId) body.unique_id = opts.uniqueId;
|
|
1583
|
-
if (opts.roomId) body.room_id = opts.roomId;
|
|
1584
|
-
if (opts.anchorId) body.anchor_id = opts.anchorId;
|
|
1585
|
-
if (opts.sessionCookie) body.session_cookie = opts.sessionCookie;
|
|
1586
|
-
if (opts.type) body.type = opts.type;
|
|
1587
|
-
if (opts.rankType) body.rank_type = opts.rankType;
|
|
1588
|
-
const resp = await fetch(`${base}/webcast/ranklist?apiKey=${ak}`, {
|
|
1589
|
-
method: "POST",
|
|
1590
|
-
headers: { "Content-Type": "application/json" },
|
|
1591
|
-
body: JSON.stringify(body)
|
|
1592
|
-
});
|
|
1593
|
-
const data = await resp.json();
|
|
1594
|
-
if (data.status_code === 20003) {
|
|
1595
|
-
throw new Error(
|
|
1596
|
-
data.message || "TikTok requires login. Provide sessionCookie with your TikTok session."
|
|
1597
|
-
);
|
|
1598
|
-
}
|
|
1599
|
-
if (data.status_code !== 0) {
|
|
1600
|
-
throw new Error(data.error || `Ranklist failed (status ${data.status_code})`);
|
|
1601
|
-
}
|
|
1602
|
-
if (data.sign_and_return) {
|
|
1603
|
-
return data;
|
|
1604
|
-
}
|
|
1605
|
-
return data.data;
|
|
1606
|
-
}
|
|
1607
|
-
async function getRegionalRanklist(opts) {
|
|
1608
|
-
const base = (opts.serverUrl || DEFAULT_SIGN_SERVER2).replace(/\/$/, "");
|
|
1609
|
-
const ak = encodeURIComponent(opts.apiKey);
|
|
1610
|
-
const body = {};
|
|
1611
|
-
if (opts.uniqueId) body.unique_id = opts.uniqueId;
|
|
1612
|
-
if (opts.roomId) body.room_id = opts.roomId;
|
|
1613
|
-
if (opts.anchorId) body.anchor_id = opts.anchorId;
|
|
1614
|
-
if (opts.rankType) body.rank_type = opts.rankType;
|
|
1615
|
-
if (opts.type) body.type = opts.type;
|
|
1616
|
-
if (opts.gapInterval) body.gap_interval = opts.gapInterval;
|
|
1617
|
-
if (opts.sessionCookie) body.session_cookie = opts.sessionCookie;
|
|
1618
|
-
if (opts.clientIp) body.client_ip = opts.clientIp;
|
|
1619
|
-
const resp = await fetch(`${base}/webcast/ranklist/regional?apiKey=${ak}`, {
|
|
1620
|
-
method: "POST",
|
|
1621
|
-
headers: { "Content-Type": "application/json" },
|
|
1622
|
-
body: JSON.stringify(body)
|
|
1623
|
-
});
|
|
1624
|
-
const data = await resp.json();
|
|
1625
|
-
if (data.status_code !== 0) {
|
|
1626
|
-
throw new Error(data.error || `Regional ranklist failed (status ${data.status_code})`);
|
|
1627
|
-
}
|
|
1628
|
-
return data;
|
|
1629
|
-
}
|
|
1630
|
-
async function getLiveFeed(opts) {
|
|
1631
|
-
const base = (opts.serverUrl || DEFAULT_SIGN_SERVER2).replace(/\/$/, "");
|
|
1632
|
-
const params = new URLSearchParams();
|
|
1633
|
-
params.set("apiKey", opts.apiKey);
|
|
1634
|
-
if (opts.region) params.set("region", opts.region);
|
|
1635
|
-
if (opts.channelId) params.set("channel_id", opts.channelId);
|
|
1636
|
-
if (opts.count !== void 0) params.set("count", String(Math.min(opts.count, 50)));
|
|
1637
|
-
if (opts.maxTime) params.set("max_time", opts.maxTime);
|
|
1638
|
-
if (opts.sessionId) params.set("session_id", opts.sessionId);
|
|
1639
|
-
if (opts.ttwid) params.set("ttwid", opts.ttwid);
|
|
1640
|
-
if (opts.msToken) params.set("ms_token", opts.msToken);
|
|
1641
|
-
const resp = await fetch(`${base}/webcast/feed?${params.toString()}`);
|
|
1642
|
-
const data = await resp.json();
|
|
1643
|
-
if (resp.status === 429) {
|
|
1644
|
-
throw new Error(data.error || "Feed daily limit reached. Upgrade your plan for more calls.");
|
|
1645
|
-
}
|
|
1646
|
-
if (!resp.ok) {
|
|
1647
|
-
throw new Error(data.error || `Feed request failed (HTTP ${resp.status})`);
|
|
1648
|
-
}
|
|
1649
|
-
return data;
|
|
1650
|
-
}
|
|
1651
|
-
async function fetchFeed(opts) {
|
|
1652
|
-
const signed = await getLiveFeed(opts);
|
|
1653
|
-
const headers = { ...signed.headers || {} };
|
|
1654
|
-
if (signed.cookies) {
|
|
1655
|
-
headers["Cookie"] = signed.cookies;
|
|
1017
|
+
startHeartbeat(roomId) {
|
|
1018
|
+
this.stopHeartbeat();
|
|
1019
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1020
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
1021
|
+
this.ws.send(buildHeartbeat(roomId));
|
|
1022
|
+
}
|
|
1023
|
+
}, this.heartbeatInterval);
|
|
1656
1024
|
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
return null;
|
|
1025
|
+
stopHeartbeat() {
|
|
1026
|
+
if (this.heartbeatTimer) {
|
|
1027
|
+
clearInterval(this.heartbeatTimer);
|
|
1028
|
+
this.heartbeatTimer = null;
|
|
1029
|
+
}
|
|
1663
1030
|
}
|
|
1664
|
-
}
|
|
1031
|
+
};
|
|
1665
1032
|
export {
|
|
1666
|
-
|
|
1667
|
-
TikTokLive,
|
|
1668
|
-
fetchFeed,
|
|
1669
|
-
getLiveFeed,
|
|
1670
|
-
getRanklist,
|
|
1671
|
-
getRegionalRanklist
|
|
1033
|
+
TikTokLive
|
|
1672
1034
|
};
|
|
1673
1035
|
//# sourceMappingURL=index.mjs.map
|