@zfadhli/tokrec 0.7.1 → 0.8.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 +129 -76
- package/dist/api/client.mjs +45 -19
- package/dist/api/client.mjs.map +1 -1
- package/dist/api/rate-limiter.mjs +29 -0
- package/dist/api/rate-limiter.mjs.map +1 -0
- package/dist/api/tiktok.mjs +180 -51
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/browser-cookies.mjs +8 -11
- package/dist/browser-cookies.mjs.map +1 -1
- package/dist/cli.mjs +10 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/config.d.mts +18 -6
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +7 -3
- package/dist/config.mjs.map +1 -1
- package/dist/index.mjs +22 -4
- package/dist/index.mjs.map +1 -1
- package/dist/monitor.mjs +12 -13
- package/dist/monitor.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/convert.mjs +25 -25
- package/dist/recorder/convert.mjs.map +1 -1
- package/dist/recorder/download-hls.mjs +77 -0
- package/dist/recorder/download-hls.mjs.map +1 -0
- package/dist/recorder/download-stream.mjs +78 -0
- package/dist/recorder/download-stream.mjs.map +1 -0
- package/dist/recorder/ffmpeg-utils.mjs +122 -0
- package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
- package/dist/recorder/index.d.mts.map +1 -1
- package/dist/recorder/index.mjs +82 -178
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/post-processing.mjs +150 -0
- package/dist/recorder/post-processing.mjs.map +1 -0
- package/dist/recorder/recorder-events.mjs +34 -0
- package/dist/recorder/recorder-events.mjs.map +1 -0
- package/dist/recorder/recorder-state.mjs +38 -0
- package/dist/recorder/recorder-state.mjs.map +1 -0
- package/dist/recorder/stream.mjs +5 -293
- package/dist/recorder/stream.mjs.map +1 -1
- package/dist/utils.mjs +19 -3
- package/dist/utils.mjs.map +1 -1
- package/package.json +6 -6
package/dist/api/tiktok.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
//#region src/api/tiktok.ts
|
|
2
|
-
const
|
|
2
|
+
const TIKTOK_BASES = ["https://www.tiktok.com", "https://m.tiktok.com"];
|
|
3
3
|
const WEBCAST_BASE = "https://webcast.tiktok.com";
|
|
4
|
-
function createTikTokApi(http) {
|
|
4
|
+
function createTikTokApi(http, logger, showDebug) {
|
|
5
|
+
const debug = (msg) => {
|
|
6
|
+
logger?.debug(msg);
|
|
7
|
+
if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\n`);
|
|
8
|
+
};
|
|
5
9
|
let cached = null;
|
|
6
10
|
let cachedUser = "";
|
|
7
11
|
async function ensureCache(user) {
|
|
@@ -21,51 +25,105 @@ function createTikTokApi(http) {
|
|
|
21
25
|
},
|
|
22
26
|
async isRoomAlive(roomId) {
|
|
23
27
|
if (cached && String(cached.roomId) === String(roomId)) return cached.isLive;
|
|
24
|
-
return (
|
|
28
|
+
return fetchCheckAlive(roomId);
|
|
25
29
|
},
|
|
26
30
|
async getLiveUrl(roomId) {
|
|
27
31
|
return (await ensureCache(cachedUser || roomId))?.streamUrl ?? null;
|
|
28
32
|
}
|
|
29
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Try to fetch a TikTok user page from multiple domains.
|
|
36
|
+
* www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com
|
|
37
|
+
* often has different bot protection and may succeed.
|
|
38
|
+
* Returns the HTML and the base URL that worked, or null if all fail.
|
|
39
|
+
*/
|
|
40
|
+
async function tryFetchPage(user, path) {
|
|
41
|
+
for (const base of TIKTOK_BASES) {
|
|
42
|
+
const url = `${base}/@${user}${path}`;
|
|
43
|
+
const res = await http.get(url);
|
|
44
|
+
debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`);
|
|
45
|
+
if (!res.ok) continue;
|
|
46
|
+
const html = await res.text();
|
|
47
|
+
if (html.length < 5e3 || html.includes("SlardarWAF")) {
|
|
48
|
+
debug(`tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes("SlardarWAF")})`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`);
|
|
52
|
+
return {
|
|
53
|
+
html,
|
|
54
|
+
baseUrl: base
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
30
59
|
async function fetchLiveInfo(user) {
|
|
31
60
|
let sigi = null;
|
|
32
61
|
let html = "";
|
|
33
62
|
try {
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
const page = await tryFetchPage(user, "/live");
|
|
64
|
+
if (!page) {
|
|
65
|
+
debug("fetchLiveInfo: all domains blocked for /live page");
|
|
66
|
+
html = "";
|
|
67
|
+
} else {
|
|
68
|
+
html = page.html;
|
|
69
|
+
debug(`fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\n/g, "\\n")}`);
|
|
70
|
+
sigi = extractSigiState(html);
|
|
71
|
+
debug(`fetchLiveInfo: SIGI_STATE ${sigi ? "found" : "not found"}`);
|
|
72
|
+
if (sigi) {
|
|
73
|
+
const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom;
|
|
74
|
+
const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user;
|
|
75
|
+
debug(`fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? "null"}`);
|
|
76
|
+
if (liveRoom && userInfo?.roomId) {
|
|
77
|
+
const roomId = String(userInfo.roomId);
|
|
78
|
+
const isLive = liveRoom.status === 2;
|
|
79
|
+
debug(`fetchLiveInfo: primary path — roomId=${roomId}, status=${liveRoom.status}, isLive=${isLive}`);
|
|
80
|
+
let streamUrl = null;
|
|
81
|
+
if (isLive) {
|
|
82
|
+
streamUrl = extractStreamUrlFast(liveRoom);
|
|
83
|
+
if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi);
|
|
84
|
+
if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
roomId,
|
|
88
|
+
isLive,
|
|
89
|
+
streamUrl,
|
|
90
|
+
title: liveRoom.title ?? null
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (userInfo?.roomId) {
|
|
94
|
+
const roomId = String(userInfo.roomId);
|
|
95
|
+
debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`);
|
|
96
|
+
const roomInfo = await fetchRoomInfoFromRoomApi(roomId);
|
|
97
|
+
debug(`fetchLiveInfo: Webcast API ${roomInfo ? "returned room info" : "returned null"}`);
|
|
98
|
+
if (roomInfo) return roomInfo;
|
|
49
99
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
title: liveRoom.title ?? null
|
|
55
|
-
};
|
|
100
|
+
debug("fetchLiveInfo: trying legacy fallback (UserModule)");
|
|
101
|
+
const legacy = await fetchLiveInfoFallback(sigi, user);
|
|
102
|
+
debug(`fetchLiveInfo: legacy fallback ${legacy ? "succeeded" : "returned null"}`);
|
|
103
|
+
if (legacy) return legacy;
|
|
56
104
|
}
|
|
57
|
-
const legacy = await fetchLiveInfoFallback(sigi, user);
|
|
58
|
-
if (legacy) return legacy;
|
|
59
105
|
}
|
|
60
106
|
} catch (_err) {
|
|
107
|
+
debug(`fetchLiveInfo: caught error — ${_err instanceof Error ? _err.message : String(_err)}`);
|
|
61
108
|
return null;
|
|
62
109
|
}
|
|
110
|
+
if (!html || html.length < 5e3) {
|
|
111
|
+
debug("fetchLiveInfo: trying API fallback (api-live/user/room)");
|
|
112
|
+
const apiResult = await fetchLiveInfoFromApi(user);
|
|
113
|
+
if (apiResult) return apiResult;
|
|
114
|
+
}
|
|
115
|
+
debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`);
|
|
63
116
|
const { roomId } = extractUserFromUniversalData(html);
|
|
117
|
+
debug(`fetchLiveInfo: universal data roomId=${roomId ?? "null"}`);
|
|
64
118
|
if (roomId) {
|
|
65
119
|
const result = await fetchRoomInfoFromRoomApi(roomId);
|
|
120
|
+
debug(`fetchLiveInfo: universal data Webcast API ${result ? "succeeded" : "returned null"}`);
|
|
66
121
|
if (result) return result;
|
|
67
122
|
}
|
|
68
|
-
|
|
123
|
+
debug("fetchLiveInfo: trying profile page fallback");
|
|
124
|
+
const profileResult = await fetchLiveInfoFromProfile(user);
|
|
125
|
+
debug(`fetchLiveInfo: profile page ${profileResult ? "succeeded" : "returned null"}`);
|
|
126
|
+
return profileResult;
|
|
69
127
|
}
|
|
70
128
|
/**
|
|
71
129
|
* Fetch the main profile page to extract user info from
|
|
@@ -76,9 +134,13 @@ function createTikTokApi(http) {
|
|
|
76
134
|
*/
|
|
77
135
|
async function fetchLiveInfoFromProfile(user) {
|
|
78
136
|
try {
|
|
79
|
-
const
|
|
80
|
-
if (!
|
|
81
|
-
|
|
137
|
+
const page = await tryFetchPage(user, "");
|
|
138
|
+
if (!page) {
|
|
139
|
+
debug("fetchLiveInfoFromProfile: all domains blocked for profile page");
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
debug(`fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\n/g, "\\n")}`);
|
|
143
|
+
const { userId, roomId } = extractUserFromUniversalData(page.html);
|
|
82
144
|
if (!userId) return null;
|
|
83
145
|
if (roomId) return fetchRoomInfoFromRoomApi(roomId);
|
|
84
146
|
return null;
|
|
@@ -87,18 +149,99 @@ function createTikTokApi(http) {
|
|
|
87
149
|
}
|
|
88
150
|
}
|
|
89
151
|
/**
|
|
90
|
-
* Fetch
|
|
91
|
-
*
|
|
152
|
+
* Fetch live info from TikTok's internal API endpoint.
|
|
153
|
+
* This bypasses the Slardar WAF that blocks www.tiktok.com HTML pages.
|
|
154
|
+
*
|
|
155
|
+
* Endpoint: /api-live/user/room/?aid=1988&uniqueId=${user}&sourceType=54
|
|
156
|
+
* Returns the roomId, user status (2 = live), and other user metadata.
|
|
157
|
+
* When the user is live, also queries the Webcast API for a stream URL.
|
|
92
158
|
*/
|
|
93
|
-
async function
|
|
159
|
+
async function fetchLiveInfoFromApi(user) {
|
|
160
|
+
try {
|
|
161
|
+
const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(user)}&sourceType=54`;
|
|
162
|
+
const res = await http.get(url);
|
|
163
|
+
debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`);
|
|
164
|
+
if (!res.ok) return null;
|
|
165
|
+
const text = await res.text();
|
|
166
|
+
debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`);
|
|
167
|
+
if (text.length === 0) return null;
|
|
168
|
+
const userData = (JSON.parse(text)?.data)?.user;
|
|
169
|
+
if (!userData?.roomId) {
|
|
170
|
+
debug("fetchLiveInfoFromApi: no roomId in API response");
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const roomId = String(userData.roomId);
|
|
174
|
+
const isLive = userData.status === 2;
|
|
175
|
+
debug(`fetchLiveInfoFromApi: roomId=${roomId}, status=${userData.status}, isLive=${isLive}`);
|
|
176
|
+
if (!isLive) return {
|
|
177
|
+
roomId,
|
|
178
|
+
isLive: false,
|
|
179
|
+
streamUrl: null,
|
|
180
|
+
title: null
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
roomId,
|
|
184
|
+
isLive: true,
|
|
185
|
+
streamUrl: await fetchStreamUrlFromApi(roomId),
|
|
186
|
+
title: null
|
|
187
|
+
};
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Shared boilerplate for Webcast room/info API calls.
|
|
194
|
+
* Handles URL construction, HTTP GET, JSON parsing, status code checks,
|
|
195
|
+
* and error handling. The caller provides an extractor for the response data.
|
|
196
|
+
*/
|
|
197
|
+
async function fetchFromRoomApi(roomId, extract) {
|
|
94
198
|
try {
|
|
95
199
|
const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`;
|
|
96
200
|
const res = await http.get(url);
|
|
201
|
+
debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`);
|
|
97
202
|
if (!res.ok) return null;
|
|
98
203
|
const text = await res.text();
|
|
204
|
+
debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`);
|
|
99
205
|
if (text.length === 0) return null;
|
|
100
206
|
const data = JSON.parse(text);
|
|
101
207
|
if (data.status_code === 4003110) return null;
|
|
208
|
+
return extract(data);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Check if a room is currently alive using the lightweight
|
|
215
|
+
* /webcast/room/check_alive/ endpoint.
|
|
216
|
+
*
|
|
217
|
+
* This is faster and cheaper than fetching the full room/info response.
|
|
218
|
+
* Returns `true` if the room is live, `false` otherwise (including errors).
|
|
219
|
+
*
|
|
220
|
+
* Endpoint: /webcast/room/check_alive/?aid=1988®ion=CH&room_ids={id}&user_is_login=true
|
|
221
|
+
* Response: { "data": [{ "alive": true/false, "room_id": "..." }] }
|
|
222
|
+
*/
|
|
223
|
+
async function fetchCheckAlive(roomId) {
|
|
224
|
+
try {
|
|
225
|
+
const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988®ion=CH&room_ids=${roomId}&user_is_login=true`;
|
|
226
|
+
const res = await http.get(url);
|
|
227
|
+
debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`);
|
|
228
|
+
if (!res.ok) return false;
|
|
229
|
+
const text = await res.text();
|
|
230
|
+
debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`);
|
|
231
|
+
if (text.length === 0) return false;
|
|
232
|
+
const alive = (JSON.parse(text)?.data)?.[0]?.alive === true;
|
|
233
|
+
debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`);
|
|
234
|
+
return alive;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
debug(`fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Fetch room info from the Webcast room/info API. Requires a known roomId.
|
|
242
|
+
*/
|
|
243
|
+
async function fetchRoomInfoFromRoomApi(roomId) {
|
|
244
|
+
return fetchFromRoomApi(roomId, (data) => {
|
|
102
245
|
const roomData = data.data;
|
|
103
246
|
if (!roomData) return null;
|
|
104
247
|
const isLive = (roomData.status ?? 4) === 2;
|
|
@@ -108,9 +251,7 @@ function createTikTokApi(http) {
|
|
|
108
251
|
streamUrl: isLive ? findStreamUrlRecursively(roomData) : null,
|
|
109
252
|
title: roomData.title ?? null
|
|
110
253
|
};
|
|
111
|
-
}
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
254
|
+
});
|
|
114
255
|
}
|
|
115
256
|
/**
|
|
116
257
|
* Secondary live-info extraction when SIGI_STATE's LiveRoom data is
|
|
@@ -135,23 +276,11 @@ function createTikTokApi(http) {
|
|
|
135
276
|
return null;
|
|
136
277
|
}
|
|
137
278
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* (e.g. async-loaded room info).
|
|
279
|
+
* Fetch stream URL from the Webcast API.
|
|
280
|
+
* Covers cases where SIGI_STATE doesn't include stream data.
|
|
141
281
|
*/
|
|
142
282
|
async function fetchStreamUrlFromApi(roomId) {
|
|
143
|
-
|
|
144
|
-
const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`;
|
|
145
|
-
const res = await http.get(url);
|
|
146
|
-
if (!res.ok) return null;
|
|
147
|
-
const text = await res.text();
|
|
148
|
-
if (text.length === 0) return null;
|
|
149
|
-
const data = JSON.parse(text);
|
|
150
|
-
if (data.status_code === 4003110) return null;
|
|
151
|
-
return findStreamUrlRecursively(data);
|
|
152
|
-
} catch {
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
283
|
+
return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data));
|
|
155
284
|
}
|
|
156
285
|
}
|
|
157
286
|
/**
|
package/dist/api/tiktok.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tiktok.mjs","names":[],"sources":["../../src/api/tiktok.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs.\n *\n * Primary path: parse SIGI_STATE from the user's /live page (fast path for\n * older TikTok pages). When SIGI_STATE is absent (TikTok's new unified page\n * structure), falls back to __UNIVERSAL_DATA_FOR_REHYDRATION__ on the profile\n * page to extract the room ID, then calls the Webcast room/info API for live\n * status and stream URL.\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n *\n * Note: The room/enter Webcast endpoint has been deprecated due to frequent\n * 403 errors. All lookups go through room/info (reliable for live + offline).\n */\n\nimport type { HttpClient } from \"./client\"\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nconst TIKTOK_BASE = \"https://www.tiktok.com\"\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\"\n\nexport function createTikTokApi(http: HttpClient): TikTokApi {\n // Per-tick cache — stores the last fetched live info so the three public\n // methods don't each fetch the page separately.\n let cached: LiveInfo | null = null\n let cachedUser = \"\"\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached\n cached = await fetchLiveInfo(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = \"\"\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user)\n return info?.roomId ?? null\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive\n }\n const info = await fetchLiveInfo(cachedUser || roomId)\n return info?.isLive ?? false\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId)\n return info?.streamUrl ?? null\n },\n }\n\n // ─── Internal: fetch and parse SIGI_STATE / Universal Data ──\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n let sigi: SigiState | null = null\n let html = \"\"\n\n try {\n const res = await http.get(`${TIKTOK_BASE}/@${user}/live`)\n if (!res.ok) return null\n\n html = await res.text()\n sigi = extractSigiState(html)\n\n if (sigi) {\n // ── Primary path: room info in LiveRoom.liveRoomUserInfo ──\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n if (liveRoom && userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n const isLive = liveRoom.status === 2 // 2 = live, 4 = offline\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom)\n if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi)\n if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId)\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null }\n }\n\n // ── Legacy fallback: async-loaded SIGI_STATE ──\n const legacy = await fetchLiveInfoFallback(sigi, user)\n if (legacy) return legacy\n }\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n return null\n }\n\n // ── New fallback: TikTok's unified page structure ──\n // TikTok migrated from SIGI_STATE to __UNIVERSAL_DATA_FOR_REHYDRATION__.\n // The /live page may not have it, so try the main profile page.\n const { roomId } = extractUserFromUniversalData(html)\n if (roomId) {\n // If the /live page has the roomId, use room/info (reliable)\n const result = await fetchRoomInfoFromRoomApi(roomId)\n if (result) return result\n }\n // No roomId available — fall through to profile page to check for\n // __UNIVERSAL_DATA_FOR_REHYDRATION__ which may have the roomId when live.\n\n // Final fallback: fetch the main profile page for webapp.user-detail\n return fetchLiveInfoFromProfile(user)\n }\n\n /**\n * Fetch the main profile page to extract user info from\n * __UNIVERSAL_DATA_FOR_REHYDRATION__. When the user is live, their\n * roomId is embedded in the data, allowing us to fetch room info and\n * stream URL via the /webcast/room/info/ endpoint (which works\n * reliably). If no roomId is present the user is offline.\n */\n async function fetchLiveInfoFromProfile(user: string): Promise<LiveInfo | null> {\n try {\n const res = await http.get(`${TIKTOK_BASE}/@${user}`)\n if (!res.ok) return null\n\n const html = await res.text()\n const { userId, roomId } = extractUserFromUniversalData(html)\n if (!userId) return null\n\n // If roomId is embedded in the profile page, the user is live.\n // Use room/info which works reliably for both live and offline rooms.\n if (roomId) {\n return fetchRoomInfoFromRoomApi(roomId)\n }\n\n // No roomId means the user is offline (room/enter was unreliable).\n return null\n } catch {\n return null\n }\n }\n\n /**\n * Fetch room info from the Webcast room/info API (reliable, works\n * for both live and offline rooms). Requires a known roomId.\n */\n async function fetchRoomInfoFromRoomApi(roomId: string): Promise<LiveInfo | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n if (!res.ok) return null\n\n const text = await res.text()\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null\n\n const roomData = (data as any).data\n if (!roomData) return null\n\n const status = roomData.status ?? 4\n const isLive = status === 2\n const streamUrl = isLive ? findStreamUrlRecursively(roomData) : null\n\n return { roomId, isLive, streamUrl, title: roomData.title ?? null }\n } catch {\n return null\n }\n }\n\n /**\n * Secondary live-info extraction when SIGI_STATE's LiveRoom data is\n * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and\n * UserModule.users[user].roomId for the room ID. Stream URL is obtained\n * from the Webcast API (room/info/) as a final fallback.\n */\n async function fetchLiveInfoFallback(sigi: SigiState, user: string): Promise<LiveInfo | null> {\n const liveRoomStatus = sigi.LiveRoom?.liveRoomStatus\n const isLive = liveRoomStatus === 2 // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[user]\n let roomId: string | null = null\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId)\n }\n\n // If we have a roomId, use Webcast room/info for the stream URL\n if (roomId) {\n const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null\n return { roomId, isLive, streamUrl, title: null }\n }\n\n // No roomId available — treat as offline (room/enter was unreliable).\n return null\n }\n\n /**\n * Fallback: fetch stream URL from the Webcast API.\n * This covers cases where SIGI_STATE doesn't include stream data\n * (e.g. async-loaded room info).\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n if (!res.ok) return null\n\n const text = await res.text()\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null\n\n return findStreamUrlRecursively(data)\n } catch {\n return null\n }\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string\n isLive: boolean\n streamUrl: string | null\n title: string | null\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n id?: string | number\n roomId?: string | number\n }\n liveRoom?: {\n status?: number // 2 = live, 4 = offline\n title?: string\n startTime?: number\n streamData?: {\n pull_data?: {\n stream_data?: string // JSON string with FLV/HLS URLs\n }\n }\n }\n }\n }\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string // numeric user ID\n uniqueId?: string\n roomId?: string | { id: string }\n }\n }\n }\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\n/**\n * @public Exported for testing.\n */\nexport function extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n/**\n * Extract the numeric user ID and optional room ID from TikTok's new\n * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).\n *\n * The profile page (/@user) includes \"webapp.user-detail\" which\n * contains the user's numeric ID. When the user is live, the roomId\n * is also embedded here.\n *\n * Returns { userId, roomId } where userId is always set when the data\n * is found, and roomId is set only when the user is currently live.\n *\n * @public Exported for testing and fixture capture.\n */\nexport function extractUserFromUniversalData(html: string): {\n userId: string | null\n roomId: string | null\n} {\n const match = html.match(\n /<script\\s+id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n )\n if (!match?.[1]) return { userId: null, roomId: null }\n try {\n const data = JSON.parse(match[1])\n const scope = (data as any).__DEFAULT_SCOPE__ ?? data\n const userDetail = scope?.[\"webapp.user-detail\"]\n const user = userDetail?.userInfo?.user\n const userId = user?.id ? String(user.id) : null\n const roomId = user?.roomId ? String(user.roomId) : null\n return { userId, roomId }\n } catch {\n return { userId: null, roomId: null }\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n *\n * Prefers FLV (lower latency) over HLS/other protocols.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<NonNullable<SigiState[\"LiveRoom\"]>[\"liveRoomUserInfo\"]>[\"liveRoom\"],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data\n if (!raw) return null\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined\n if (!data) return null\n\n // Try all known quality keys, preferring higher quality.\n // FLV is preferred (lower latency); HLS is the fallback.\n const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"]\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined\n // Prefer FLV over HLS when both are available\n const url = entry?.main?.flv ?? entry?.main?.hls\n if (url) return url\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Prefers FLV over HLS when both are present in the response.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Collect all candidate URLs with their preference\n const flvUrls: string[] = []\n const m3u8Urls: string[] = []\n\n function walk(value: unknown): void {\n if (typeof value === \"string\") {\n // Direct URL match\n if (\n (value.startsWith(\"http://\") || value.startsWith(\"https://\")) &&\n (value.includes(\".flv\") || value.includes(\".m3u8\"))\n ) {\n if (value.includes(\".flv\")) {\n flvUrls.push(value)\n } else {\n m3u8Urls.push(value)\n }\n return\n }\n // JSON-encoded string — parse and recurse\n if (value.startsWith(\"{\") || value.startsWith(\"[\")) {\n try {\n walk(JSON.parse(value))\n } catch {\n // skip\n }\n }\n return\n }\n\n if (value && typeof value === \"object\") {\n if (Array.isArray(value)) {\n for (const item of value) {\n walk(item)\n }\n } else {\n for (const val of Object.values(value as Record<string, unknown>)) {\n walk(val)\n }\n }\n }\n }\n\n walk(obj)\n\n // Prefer FLV (lower latency) over HLS\n if (flvUrls.length > 0) return flvUrls[0]!\n if (m3u8Urls.length > 0) return m3u8Urls[0]!\n return null\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tiktok.mjs","names":[],"sources":["../../src/api/tiktok.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs.\n *\n * Primary path: parse SIGI_STATE from the user's /live page (fast path for\n * older TikTok pages). When SIGI_STATE is absent (TikTok's new unified page\n * structure), falls back to __UNIVERSAL_DATA_FOR_REHYDRATION__ on the profile\n * page to extract the room ID, then calls the Webcast room/info API for live\n * status and stream URL.\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n *\n * Note: The room/enter Webcast endpoint has been deprecated due to frequent\n * 403 errors. All lookups go through room/info (reliable for live + offline).\n */\n\nimport type { Logger } from \"../logger\"\nimport type { HttpClient } from \"./client\"\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nconst TIKTOK_BASES = [\"https://www.tiktok.com\", \"https://m.tiktok.com\"]\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\"\n\nexport function createTikTokApi(http: HttpClient, logger?: Logger, showDebug?: boolean): TikTokApi {\n // Debug logging helper — writes to stderr only when --debug is passed.\n const debug = (msg: string) => {\n logger?.debug(msg)\n if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\\n`)\n }\n let cached: LiveInfo | null = null\n let cachedUser = \"\"\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached\n cached = await fetchLiveInfo(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = \"\"\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user)\n return info?.roomId ?? null\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive\n }\n // Use the lightweight check_alive endpoint instead of re-fetching\n // the full room/info response.\n return fetchCheckAlive(roomId)\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId)\n return info?.streamUrl ?? null\n },\n }\n\n // ─── Internal: fetch and parse SIGI_STATE / Universal Data ──\n\n /**\n * Try to fetch a TikTok user page from multiple domains.\n * www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com\n * often has different bot protection and may succeed.\n * Returns the HTML and the base URL that worked, or null if all fail.\n */\n async function tryFetchPage(\n user: string,\n path: string,\n ): Promise<{ html: string; baseUrl: string } | null> {\n for (const base of TIKTOK_BASES) {\n const url = `${base}/@${user}${path}`\n const res = await http.get(url)\n debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) continue\n\n const html = await res.text()\n // Reject Slardar WAF challenge pages (small + keyword)\n if (html.length < 5000 || html.includes(\"SlardarWAF\")) {\n debug(\n `tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes(\"SlardarWAF\")})`,\n )\n continue\n }\n\n debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`)\n return { html, baseUrl: base }\n }\n return null\n }\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n let sigi: SigiState | null = null\n let html = \"\"\n\n try {\n const page = await tryFetchPage(user, \"/live\")\n if (!page) {\n debug(\"fetchLiveInfo: all domains blocked for /live page\")\n // Fall through to universal data / profile page fallback\n html = \"\"\n } else {\n html = page.html\n debug(\n `fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n\n sigi = extractSigiState(html)\n debug(`fetchLiveInfo: SIGI_STATE ${sigi ? \"found\" : \"not found\"}`)\n\n if (sigi) {\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n debug(\n `fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? \"null\"}`,\n )\n\n // ── Primary path: liveRoom is populated (server-rendered) ──\n if (liveRoom && userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n const isLive = liveRoom.status === 2 // 2 = live, 4 = offline\n debug(\n `fetchLiveInfo: primary path — roomId=${roomId}, status=${liveRoom.status}, isLive=${isLive}`,\n )\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom)\n if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi)\n if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId)\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null }\n }\n\n // ── Fallback: userInfo has roomId, but liveRoom is async-loaded ──\n // TikTok may omit liveRoom from the server-rendered page and load it\n // via JavaScript. Query the Webcast API directly — it's authoritative.\n if (userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`)\n const roomInfo = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: Webcast API ${roomInfo ? \"returned room info\" : \"returned null\"}`)\n if (roomInfo) return roomInfo\n }\n\n // ── Legacy fallback: async-loaded SIGI_STATE ──\n debug(\"fetchLiveInfo: trying legacy fallback (UserModule)\")\n const legacy = await fetchLiveInfoFallback(sigi, user)\n debug(`fetchLiveInfo: legacy fallback ${legacy ? \"succeeded\" : \"returned null\"}`)\n if (legacy) return legacy\n } // end if (sigi)\n } // end else (page loaded)\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n debug(`fetchLiveInfo: caught error — ${_err instanceof Error ? _err.message : String(_err)}`)\n return null\n }\n\n // ── API fallback: when page scraping fails (WAF), use TikTok API directly ──\n // The /api-live/user/room/ endpoint returns roomId + live status without\n // the Slardar WAF that blocks www.tiktok.com HTML pages.\n if (!html || html.length < 5000) {\n debug(\"fetchLiveInfo: trying API fallback (api-live/user/room)\")\n const apiResult = await fetchLiveInfoFromApi(user)\n if (apiResult) return apiResult\n }\n\n // ── New fallback: TikTok's unified page structure ──\n // TikTok migrated from SIGI_STATE to __UNIVERSAL_DATA_FOR_REHYDRATION__.\n // The /live page may not have it, so try the main profile page.\n debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`)\n const { roomId } = extractUserFromUniversalData(html)\n debug(`fetchLiveInfo: universal data roomId=${roomId ?? \"null\"}`)\n if (roomId) {\n // If the /live page has the roomId, use room/info (reliable)\n const result = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: universal data Webcast API ${result ? \"succeeded\" : \"returned null\"}`)\n if (result) return result\n }\n // No roomId available — fall through to profile page to check for\n // __UNIVERSAL_DATA_FOR_REHYDRATION__ which may have the roomId when live.\n\n // Final fallback: fetch the main profile page for webapp.user-detail\n debug(\"fetchLiveInfo: trying profile page fallback\")\n const profileResult = await fetchLiveInfoFromProfile(user)\n debug(`fetchLiveInfo: profile page ${profileResult ? \"succeeded\" : \"returned null\"}`)\n return profileResult\n }\n\n /**\n * Fetch the main profile page to extract user info from\n * __UNIVERSAL_DATA_FOR_REHYDRATION__. When the user is live, their\n * roomId is embedded in the data, allowing us to fetch room info and\n * stream URL via the /webcast/room/info/ endpoint (which works\n * reliably). If no roomId is present the user is offline.\n */\n async function fetchLiveInfoFromProfile(user: string): Promise<LiveInfo | null> {\n try {\n const page = await tryFetchPage(user, \"\")\n if (!page) {\n debug(\"fetchLiveInfoFromProfile: all domains blocked for profile page\")\n return null\n }\n debug(\n `fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n const { userId, roomId } = extractUserFromUniversalData(page.html)\n if (!userId) return null\n\n // If roomId is embedded in the profile page, the user is live.\n // Use room/info which works reliably for both live and offline rooms.\n if (roomId) {\n return fetchRoomInfoFromRoomApi(roomId)\n }\n\n // No roomId means the user is offline (room/enter was unreliable).\n return null\n } catch {\n return null\n }\n }\n\n /**\n * Fetch live info from TikTok's internal API endpoint.\n * This bypasses the Slardar WAF that blocks www.tiktok.com HTML pages.\n *\n * Endpoint: /api-live/user/room/?aid=1988&uniqueId=${user}&sourceType=54\n * Returns the roomId, user status (2 = live), and other user metadata.\n * When the user is live, also queries the Webcast API for a stream URL.\n */\n async function fetchLiveInfoFromApi(user: string): Promise<LiveInfo | null> {\n try {\n const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(user)}&sourceType=54`\n const res = await http.get(url)\n debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n const userData = (data?.data as Record<string, unknown> | undefined)?.user as\n | { roomId?: string; status?: number; uniqueId?: string }\n | undefined\n\n if (!userData?.roomId) {\n debug(\"fetchLiveInfoFromApi: no roomId in API response\")\n return null\n }\n\n const roomId = String(userData.roomId)\n // status: 2 = live, 4 = offline, 1 = ...\n const isLive = userData.status === 2\n debug(`fetchLiveInfoFromApi: roomId=${roomId}, status=${userData.status}, isLive=${isLive}`)\n\n if (!isLive) {\n return { roomId, isLive: false, streamUrl: null, title: null }\n }\n\n // Query Webcast API for stream URL\n const streamUrl = await fetchStreamUrlFromApi(roomId)\n return { roomId, isLive: true, streamUrl, title: null }\n } catch {\n return null\n }\n }\n\n /**\n * Shared boilerplate for Webcast room/info API calls.\n * Handles URL construction, HTTP GET, JSON parsing, status code checks,\n * and error handling. The caller provides an extractor for the response data.\n */\n async function fetchFromRoomApi<T>(\n roomId: string,\n extract: (data: Record<string, unknown>) => T | null,\n ): Promise<T | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null\n\n return extract(data)\n } catch {\n return null\n }\n }\n\n /**\n * Check if a room is currently alive using the lightweight\n * /webcast/room/check_alive/ endpoint.\n *\n * This is faster and cheaper than fetching the full room/info response.\n * Returns `true` if the room is live, `false` otherwise (including errors).\n *\n * Endpoint: /webcast/room/check_alive/?aid=1988®ion=CH&room_ids={id}&user_is_login=true\n * Response: { \"data\": [{ \"alive\": true/false, \"room_id\": \"...\" }] }\n */\n async function fetchCheckAlive(roomId: string): Promise<boolean> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988®ion=CH&room_ids=${roomId}&user_is_login=true`\n const res = await http.get(url)\n debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return false\n\n const text = await res.text()\n debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return false\n\n const data = JSON.parse(text) as Record<string, unknown>\n const items = data?.data as Array<Record<string, unknown>> | undefined\n const alive = items?.[0]?.alive === true\n debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)\n return alive\n } catch (err) {\n debug(\n `fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,\n )\n return false\n }\n }\n\n /**\n * Fetch room info from the Webcast room/info API. Requires a known roomId.\n */\n async function fetchRoomInfoFromRoomApi(roomId: string): Promise<LiveInfo | null> {\n return fetchFromRoomApi(roomId, (data) => {\n const roomData = (data as any).data\n if (!roomData) return null\n\n const status = roomData.status ?? 4\n const isLive = status === 2\n const streamUrl = isLive ? findStreamUrlRecursively(roomData) : null\n\n return { roomId, isLive, streamUrl, title: roomData.title ?? null }\n })\n }\n\n /**\n * Secondary live-info extraction when SIGI_STATE's LiveRoom data is\n * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and\n * UserModule.users[user].roomId for the room ID. Stream URL is obtained\n * from the Webcast API (room/info/) as a final fallback.\n */\n async function fetchLiveInfoFallback(sigi: SigiState, user: string): Promise<LiveInfo | null> {\n const liveRoomStatus = sigi.LiveRoom?.liveRoomStatus\n const isLive = liveRoomStatus === 2 // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[user]\n let roomId: string | null = null\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId)\n }\n\n // If we have a roomId, use Webcast room/info for the stream URL\n if (roomId) {\n const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null\n return { roomId, isLive, streamUrl, title: null }\n }\n\n // No roomId available — treat as offline (room/enter was unreliable).\n return null\n }\n\n /**\n * Fetch stream URL from the Webcast API.\n * Covers cases where SIGI_STATE doesn't include stream data.\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string\n isLive: boolean\n streamUrl: string | null\n title: string | null\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n id?: string | number\n roomId?: string | number\n }\n liveRoom?: {\n status?: number // 2 = live, 4 = offline\n title?: string\n startTime?: number\n streamData?: {\n pull_data?: {\n stream_data?: string // JSON string with FLV/HLS URLs\n }\n }\n }\n }\n }\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string // numeric user ID\n uniqueId?: string\n roomId?: string | { id: string }\n }\n }\n }\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\n/**\n * @public Exported for testing.\n */\nexport function extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n/**\n * Extract the numeric user ID and optional room ID from TikTok's new\n * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).\n *\n * The profile page (/@user) includes \"webapp.user-detail\" which\n * contains the user's numeric ID. When the user is live, the roomId\n * is also embedded here.\n *\n * Returns { userId, roomId } where userId is always set when the data\n * is found, and roomId is set only when the user is currently live.\n *\n * @public Exported for testing and fixture capture.\n */\nexport function extractUserFromUniversalData(html: string): {\n userId: string | null\n roomId: string | null\n} {\n const match = html.match(\n /<script\\s+id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n )\n if (!match?.[1]) return { userId: null, roomId: null }\n try {\n const data = JSON.parse(match[1])\n const scope = (data as any).__DEFAULT_SCOPE__ ?? data\n const userDetail = scope?.[\"webapp.user-detail\"]\n const user = userDetail?.userInfo?.user\n const userId = user?.id ? String(user.id) : null\n const roomId = user?.roomId ? String(user.roomId) : null\n return { userId, roomId }\n } catch {\n return { userId: null, roomId: null }\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n *\n * Prefers FLV (lower latency) over HLS/other protocols.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<NonNullable<SigiState[\"LiveRoom\"]>[\"liveRoomUserInfo\"]>[\"liveRoom\"],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data\n if (!raw) return null\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined\n if (!data) return null\n\n // Try all known quality keys, preferring higher quality.\n // FLV is preferred (lower latency); HLS is the fallback.\n const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"]\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined\n // Prefer FLV over HLS when both are available\n const url = entry?.main?.flv ?? entry?.main?.hls\n if (url) return url\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Prefers FLV over HLS when both are present in the response.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Collect all candidate URLs with their preference\n const flvUrls: string[] = []\n const m3u8Urls: string[] = []\n\n function walk(value: unknown): void {\n if (typeof value === \"string\") {\n // Direct URL match\n if (\n (value.startsWith(\"http://\") || value.startsWith(\"https://\")) &&\n (value.includes(\".flv\") || value.includes(\".m3u8\"))\n ) {\n if (value.includes(\".flv\")) {\n flvUrls.push(value)\n } else {\n m3u8Urls.push(value)\n }\n return\n }\n // JSON-encoded string — parse and recurse\n if (value.startsWith(\"{\") || value.startsWith(\"[\")) {\n try {\n walk(JSON.parse(value))\n } catch {\n // skip\n }\n }\n return\n }\n\n if (value && typeof value === \"object\") {\n if (Array.isArray(value)) {\n for (const item of value) {\n walk(item)\n }\n } else {\n for (const val of Object.values(value as Record<string, unknown>)) {\n walk(val)\n }\n }\n }\n }\n\n walk(obj)\n\n // Prefer FLV (lower latency) over HLS\n if (flvUrls.length > 0) return flvUrls[0]!\n if (m3u8Urls.length > 0) return m3u8Urls[0]!\n return null\n}\n"],"mappings":";AAiCA,MAAM,eAAe,CAAC,0BAA0B,sBAAsB;AACtE,MAAM,eAAe;AAErB,SAAgB,gBAAgB,MAAkB,QAAiB,WAAgC;CAEjG,MAAM,SAAS,QAAgB;EAC7B,QAAQ,MAAM,GAAG;EACjB,IAAI,WAAW,QAAQ,OAAO,MAAM,eAAe,IAAI,GAAG;CAC5D;CACA,IAAI,SAA0B;CAC9B,IAAI,aAAa;CAEjB,eAAe,YAAY,MAAwC;EACjE,IAAI,UAAU,eAAe,MAAM,OAAO;EAC1C,SAAS,MAAM,cAAc,IAAI;EACjC,aAAa;EACb,OAAO;CACT;CAEA,SAAS,kBAAwB;EAC/B,SAAS;EACT,aAAa;CACf;CAEA,OAAO;EACL;EAEA,MAAM,UAAU,MAAsC;GAEpD,QAAO,MADY,YAAY,IAAI,EAAA,EACtB,UAAU;EACzB;EAEA,MAAM,YAAY,QAAkC;GAClD,IAAI,UAAU,OAAO,OAAO,MAAM,MAAM,OAAO,MAAM,GACnD,OAAO,OAAO;GAIhB,OAAO,gBAAgB,MAAM;EAC/B;EAEA,MAAM,WAAW,QAAwC;GAEvD,QAAO,MADY,YAAY,cAAc,MAAM,EAAA,EACtC,aAAa;EAC5B;CACF;;;;;;;CAUA,eAAe,aACb,MACA,MACmD;EACnD,KAAK,MAAM,QAAQ,cAAc;GAC/B,MAAM,MAAM,GAAG,KAAK,IAAI,OAAO;GAC/B,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,iBAAiB,IAAI,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC/D,IAAI,CAAC,IAAI,IAAI;GAEb,MAAM,OAAO,MAAM,IAAI,KAAK;GAE5B,IAAI,KAAK,SAAS,OAAQ,KAAK,SAAS,YAAY,GAAG;IACrD,MACE,iBAAiB,IAAI,gBAAgB,KAAK,OAAO,kBAAkB,KAAK,SAAS,YAAY,EAAE,EACjG;IACA;GACF;GAEA,MAAM,iBAAiB,IAAI,cAAc,KAAK,OAAO,QAAQ;GAC7D,OAAO;IAAE;IAAM,SAAS;GAAK;EAC/B;EACA,OAAO;CACT;CAEA,eAAe,cAAc,MAAwC;EACnE,IAAI,OAAyB;EAC7B,IAAI,OAAO;EAEX,IAAI;GACF,MAAM,OAAO,MAAM,aAAa,MAAM,OAAO;GAC7C,IAAI,CAAC,MAAM;IACT,MAAM,mDAAmD;IAEzD,OAAO;GACT,OAAO;IACL,OAAO,KAAK;IACZ,MACE,iCAAiC,KAAK,OAAO,UAAU,KAAK,QAAQ,gBAAgB,KAAK,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,OAAO,KAAK,GAC7H;IAEA,OAAO,iBAAiB,IAAI;IAC5B,MAAM,6BAA6B,OAAO,UAAU,aAAa;IAEjE,IAAI,MAAM;KACR,MAAM,WAAW,KAAK,UAAU,kBAAkB;KAClD,MAAM,WAAW,KAAK,UAAU,kBAAkB;KAElD,MACE,2BAA2B,CAAC,CAAC,SAAS,aAAa,CAAC,CAAC,SAAS,oBAAoB,UAAU,UAAU,QACxG;KAGA,IAAI,YAAY,UAAU,QAAQ;MAChC,MAAM,SAAS,OAAO,SAAS,MAAM;MACrC,MAAM,SAAS,SAAS,WAAW;MACnC,MACE,wCAAwC,OAAO,WAAW,SAAS,OAAO,WAAW,QACvF;MAGA,IAAI,YAA2B;MAC/B,IAAI,QAAQ;OACV,YAAY,qBAAqB,QAAQ;OACzC,IAAI,CAAC,WAAW,YAAY,yBAAyB,IAAI;OACzD,IAAI,CAAC,WAAW,YAAY,MAAM,sBAAsB,MAAM;MAChE;MAEA,OAAO;OAAE;OAAQ;OAAQ;OAAW,OAAO,SAAS,SAAS;MAAK;KACpE;KAKA,IAAI,UAAU,QAAQ;MACpB,MAAM,SAAS,OAAO,SAAS,MAAM;MACrC,MAAM,6CAA6C,OAAO,uBAAuB;MACjF,MAAM,WAAW,MAAM,yBAAyB,MAAM;MACtD,MAAM,8BAA8B,WAAW,uBAAuB,iBAAiB;MACvF,IAAI,UAAU,OAAO;KACvB;KAGA,MAAM,oDAAoD;KAC1D,MAAM,SAAS,MAAM,sBAAsB,MAAM,IAAI;KACrD,MAAM,kCAAkC,SAAS,cAAc,iBAAiB;KAChF,IAAI,QAAQ,OAAO;IACrB;GACF;EACF,SAAS,MAAM;GAEb,MAAM,iCAAiC,gBAAgB,QAAQ,KAAK,UAAU,OAAO,IAAI,GAAG;GAC5F,OAAO;EACT;EAKA,IAAI,CAAC,QAAQ,KAAK,SAAS,KAAM;GAC/B,MAAM,yDAAyD;GAC/D,MAAM,YAAY,MAAM,qBAAqB,IAAI;GACjD,IAAI,WAAW,OAAO;EACxB;EAKA,MAAM,8DAA8D,KAAK,OAAO,EAAE;EAClF,MAAM,EAAE,WAAW,6BAA6B,IAAI;EACpD,MAAM,wCAAwC,UAAU,QAAQ;EAChE,IAAI,QAAQ;GAEV,MAAM,SAAS,MAAM,yBAAyB,MAAM;GACpD,MAAM,6CAA6C,SAAS,cAAc,iBAAiB;GAC3F,IAAI,QAAQ,OAAO;EACrB;EAKA,MAAM,6CAA6C;EACnD,MAAM,gBAAgB,MAAM,yBAAyB,IAAI;EACzD,MAAM,+BAA+B,gBAAgB,cAAc,iBAAiB;EACpF,OAAO;CACT;;;;;;;;CASA,eAAe,yBAAyB,MAAwC;EAC9E,IAAI;GACF,MAAM,OAAO,MAAM,aAAa,MAAM,EAAE;GACxC,IAAI,CAAC,MAAM;IACT,MAAM,gEAAgE;IACtE,OAAO;GACT;GACA,MACE,sCAAsC,KAAK,KAAK,OAAO,UAAU,KAAK,QAAQ,gBAAgB,KAAK,KAAK,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,OAAO,KAAK,GAC5I;GACA,MAAM,EAAE,QAAQ,WAAW,6BAA6B,KAAK,IAAI;GACjE,IAAI,CAAC,QAAQ,OAAO;GAIpB,IAAI,QACF,OAAO,yBAAyB,MAAM;GAIxC,OAAO;EACT,QAAQ;GACN,OAAO;EACT;CACF;;;;;;;;;CAUA,eAAe,qBAAqB,MAAwC;EAC1E,IAAI;GACF,MAAM,MAAM,gEAAgE,mBAAmB,IAAI,EAAE;GACrG,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,yBAAyB,IAAI,UAAU,IAAI,QAAQ;GACzD,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,kCAAkC,KAAK,OAAO,OAAO;GAC3D,IAAI,KAAK,WAAW,GAAG,OAAO;GAG9B,MAAM,YADO,KAAK,MAAM,IACH,CAAC,EAAE,KAAA,EAA8C;GAItE,IAAI,CAAC,UAAU,QAAQ;IACrB,MAAM,iDAAiD;IACvD,OAAO;GACT;GAEA,MAAM,SAAS,OAAO,SAAS,MAAM;GAErC,MAAM,SAAS,SAAS,WAAW;GACnC,MAAM,gCAAgC,OAAO,WAAW,SAAS,OAAO,WAAW,QAAQ;GAE3F,IAAI,CAAC,QACH,OAAO;IAAE;IAAQ,QAAQ;IAAO,WAAW;IAAM,OAAO;GAAK;GAK/D,OAAO;IAAE;IAAQ,QAAQ;IAAM,WAAA,MADP,sBAAsB,MAAM;IACV,OAAO;GAAK;EACxD,QAAQ;GACN,OAAO;EACT;CACF;;;;;;CAOA,eAAe,iBACb,QACA,SACmB;EACnB,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,uCAAuC,OAAO;GAC1E,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,4BAA4B,OAAO,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC7E,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,4BAA4B,OAAO,YAAY,KAAK,OAAO,OAAO;GACxE,IAAI,KAAK,WAAW,GAAG,OAAO;GAE9B,MAAM,OAAO,KAAK,MAAM,IAAI;GAG5B,IAAK,KAAa,gBAAgB,SAAS,OAAO;GAElD,OAAO,QAAQ,IAAI;EACrB,QAAQ;GACN,OAAO;EACT;CACF;;;;;;;;;;;CAYA,eAAe,gBAAgB,QAAkC;EAC/D,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,yDAAyD,OAAO;GAC5F,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,2BAA2B,OAAO,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC5E,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,2BAA2B,OAAO,YAAY,KAAK,OAAO,OAAO;GACvE,IAAI,KAAK,WAAW,GAAG,OAAO;GAI9B,MAAM,SAFO,KAAK,MAAM,IACP,CAAC,EAAE,KAAA,GACE,EAAE,EAAE,UAAU;GACpC,MAAM,2BAA2B,OAAO,SAAS,OAAO;GACxD,OAAO;EACT,SAAS,KAAK;GACZ,MACE,2BAA2B,OAAO,WAAW,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAC9F;GACA,OAAO;EACT;CACF;;;;CAKA,eAAe,yBAAyB,QAA0C;EAChF,OAAO,iBAAiB,SAAS,SAAS;GACxC,MAAM,WAAY,KAAa;GAC/B,IAAI,CAAC,UAAU,OAAO;GAGtB,MAAM,UADS,SAAS,UAAU,OACR;GAG1B,OAAO;IAAE;IAAQ;IAAQ,WAFP,SAAS,yBAAyB,QAAQ,IAAI;IAE5B,OAAO,SAAS,SAAS;GAAK;EACpE,CAAC;CACH;;;;;;;CAQA,eAAe,sBAAsB,MAAiB,MAAwC;EAE5F,MAAM,SADiB,KAAK,UAAU,mBACJ;EAGlC,MAAM,aAAa,KAAK,YAAY,QAAQ;EAC5C,IAAI,SAAwB;EAE5B,IAAI,YAAY,QACd,SACE,OAAO,WAAW,WAAW,WACzB,OAAQ,WAAW,OAA0B,MAAM,EAAE,IACrD,OAAO,WAAW,MAAM;EAIhC,IAAI,QAAQ;GACV,MAAM,YAAY,SAAS,MAAM,sBAAsB,MAAM,IAAI;GACjE,OAAO;IAAE;IAAQ;IAAQ;IAAW,OAAO;GAAK;EAClD;EAGA,OAAO;CACT;;;;;CAMA,eAAe,sBAAsB,QAAwC;EAC3E,OAAO,iBAAiB,SAAS,SAAS,yBAAyB,IAAI,CAAC;CAC1E;AACF;;;;AA+CA,SAAgB,iBAAiB,MAAgC;CAC/D,MAAM,QAAQ,KAAK,MAAM,sEAAsE;CAC/F,IAAI,CAAC,QAAQ,IAAI,OAAO;CACxB,IAAI;EACF,OAAO,KAAK,MAAM,MAAM,EAAE;CAC5B,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;AAeA,SAAgB,6BAA6B,MAG3C;CACA,MAAM,QAAQ,KAAK,MACjB,8FACF;CACA,IAAI,CAAC,QAAQ,IAAI,OAAO;EAAE,QAAQ;EAAM,QAAQ;CAAK;CACrD,IAAI;EACF,MAAM,OAAO,KAAK,MAAM,MAAM,EAAE;EAGhC,MAAM,SAFS,KAAa,qBAAqB,KAAA,GACtB,qBAAqB,GACvB,UAAU;EAGnC,OAAO;GAAE,QAFM,MAAM,KAAK,OAAO,KAAK,EAAE,IAAI;GAE3B,QADF,MAAM,SAAS,OAAO,KAAK,MAAM,IAAI;EAC5B;CAC1B,QAAQ;EACN,OAAO;GAAE,QAAQ;GAAM,QAAQ;EAAK;CACtC;AACF;;;;;;;AAUA,SAAS,qBACP,UACe;CACf,IAAI;EACF,MAAM,MAAM,UAAU,YAAY,WAAW;EAC7C,IAAI,CAAC,KAAK,OAAO;EAEjB,MAAM,OADS,KAAK,MAAM,GACP,CAAC,EAA8B;EAClD,IAAI,CAAC,MAAM,OAAO;EAKlB,KAAK,MAAM,KAAK;GADG;GAAU;GAAO;GAAO;GAAM;GAAM;EAC/B,GAAG;GACzB,MAAM,QAAQ,KAAK;GAEnB,MAAM,MAAM,OAAO,MAAM,OAAO,OAAO,MAAM;GAC7C,IAAI,KAAK,OAAO;EAClB;EACA,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;AAUA,SAAgB,yBAAyB,KAA6B;CAEpE,MAAM,UAAoB,CAAC;CAC3B,MAAM,WAAqB,CAAC;CAE5B,SAAS,KAAK,OAAsB;EAClC,IAAI,OAAO,UAAU,UAAU;GAE7B,KACG,MAAM,WAAW,SAAS,KAAK,MAAM,WAAW,UAAU,OAC1D,MAAM,SAAS,MAAM,KAAK,MAAM,SAAS,OAAO,IACjD;IACA,IAAI,MAAM,SAAS,MAAM,GACvB,QAAQ,KAAK,KAAK;SAElB,SAAS,KAAK,KAAK;IAErB;GACF;GAEA,IAAI,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,GAAG,GAC/C,IAAI;IACF,KAAK,KAAK,MAAM,KAAK,CAAC;GACxB,QAAQ,CAER;GAEF;EACF;EAEA,IAAI,SAAS,OAAO,UAAU,UAC5B,IAAI,MAAM,QAAQ,KAAK,GACrB,KAAK,MAAM,QAAQ,OACjB,KAAK,IAAI;OAGX,KAAK,MAAM,OAAO,OAAO,OAAO,KAAgC,GAC9D,KAAK,GAAG;CAIhB;CAEA,KAAK,GAAG;CAGR,IAAI,QAAQ,SAAS,GAAG,OAAO,QAAQ;CACvC,IAAI,SAAS,SAAS,GAAG,OAAO,SAAS;CACzC,OAAO;AACT"}
|
package/dist/browser-cookies.mjs
CHANGED
|
@@ -13,11 +13,11 @@ import { homedir } from "node:os";
|
|
|
13
13
|
/**
|
|
14
14
|
* Extract TikTok session cookies from Firefox's cookie store.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Queries ALL cookies for TikTok domains from Firefox, including the
|
|
17
|
+
* Slardar WAF challenge cookie which is needed to bypass bot detection.
|
|
18
18
|
*
|
|
19
19
|
* Returns null if Firefox isn't found, cookies can't be read, or no
|
|
20
|
-
*
|
|
20
|
+
* sessionid_ss cookie exists (without which the session is useless).
|
|
21
21
|
*/
|
|
22
22
|
function extractTikTokCookiesFromFirefox() {
|
|
23
23
|
const dbPath = findFirefoxCookieDb();
|
|
@@ -26,14 +26,11 @@ function extractTikTokCookiesFromFirefox() {
|
|
|
26
26
|
const db = new Database(dbPath, { readonly: true });
|
|
27
27
|
try {
|
|
28
28
|
const rows = db.query(`SELECT name, value FROM moz_cookies
|
|
29
|
-
WHERE host LIKE '%.tiktok.com'
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const idc = rows.find((r) => r.name === "tt-target-idc");
|
|
35
|
-
if (idc?.value) result["tt-target-idc"] = idc.value;
|
|
36
|
-
return result;
|
|
29
|
+
WHERE host LIKE '%.tiktok.com' OR host LIKE '%.tiktokv.com'`).all();
|
|
30
|
+
if (!rows.some((r) => r.name === "sessionid_ss")) return null;
|
|
31
|
+
const cookies = {};
|
|
32
|
+
for (const row of rows) cookies[row.name] = row.value;
|
|
33
|
+
return cookies;
|
|
37
34
|
} finally {
|
|
38
35
|
db.close();
|
|
39
36
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser-cookies.mjs","names":[],"sources":["../src/browser-cookies.ts"],"sourcesContent":["/**\n * Browser cookie extraction — reads TikTok session cookies from Firefox's\n * cookie store using bun:sqlite (no external dependencies).\n *\n * Falls back to cookies.json if Firefox isn't available or if no TikTok\n * login session is found.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\
|
|
1
|
+
{"version":3,"file":"browser-cookies.mjs","names":[],"sources":["../src/browser-cookies.ts"],"sourcesContent":["/**\n * Browser cookie extraction — reads TikTok session cookies from Firefox's\n * cookie store using bun:sqlite (no external dependencies).\n *\n * Falls back to cookies.json if Firefox isn't available or if no TikTok\n * login session is found.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Queries ALL cookies for TikTok domains from Firefox, including the\n * Slardar WAF challenge cookie which is needed to bypass bot detection.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * sessionid_ss cookie exists (without which the session is useless).\n */\nexport function extractTikTokCookiesFromFirefox(): Record<string, string> | null {\n const dbPath = findFirefoxCookieDb()\n if (!dbPath) return null\n\n try {\n const db = new Database(dbPath, { readonly: true })\n try {\n const rows = db\n .query(\n `SELECT name, value FROM moz_cookies\n WHERE host LIKE '%.tiktok.com' OR host LIKE '%.tiktokv.com'`,\n )\n .all() as Array<{ name: string; value: string }>\n\n // Require sessionid_ss — without it the session is useless\n const hasSession = rows.some((r) => r.name === \"sessionid_ss\")\n if (!hasSession) return null\n\n const cookies: Record<string, string> = {}\n for (const row of rows) {\n cookies[row.name] = row.value\n }\n return cookies\n } finally {\n db.close()\n }\n } catch {\n return null\n }\n}\n\n/**\n * Locate the Firefox cookies.sqlite by scanning profile directories.\n *\n * Strategy:\n * 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.\n * 2. Fall back to scanning directory entries that look like Firefox profiles\n * (contain a cookies.sqlite file).\n */\n/** @public Exported for testing */\nexport function findFirefoxCookieDb(): string | null {\n const firefoxDir = join(homedir(), \".mozilla\", \"firefox\")\n if (!existsSync(firefoxDir)) return null\n\n // Try profiles.ini first for a reliable default-profile path\n const iniPath = join(firefoxDir, \"profiles.ini\")\n if (existsSync(iniPath)) {\n try {\n const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir)\n if (profilePath) {\n const dbPath = join(profilePath, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n } catch {\n // fall through to directory scan\n }\n }\n\n // Fallback: scan for any directory containing cookies.sqlite\n const entries = readdirSync(firefoxDir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory() && !entry.isSymbolicLink()) continue\n const dbPath = join(firefoxDir, entry.name, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n\n return null\n}\n\n/**\n * Parse Firefox's profiles.ini to find the default profile path.\n *\n * The INI format looks like:\n * [Profile0]\n * Name=default\n * IsRelative=1\n * Path=1pese0rl.default-release\n * Default=1\n */\n/** @public Exported for testing */\nexport function parseFirefoxProfilesIni(iniPath: string, firefoxDir: string): string | null {\n const text = readFileSync(iniPath, \"utf-8\")\n\n let defaultPath: string | null = null\n let isRelative = true\n let inProfile = false\n\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim()\n\n // Section header\n if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) {\n if (inProfile && defaultPath) break // found it\n inProfile = trimmed.toLowerCase().startsWith(\"[profile\")\n if (inProfile) {\n defaultPath = null\n isRelative = true\n }\n continue\n }\n\n if (!inProfile) continue\n\n const lower = trimmed.toLowerCase()\n if (lower.startsWith(\"path=\")) {\n defaultPath = trimmed.slice(5)\n } else if (lower.startsWith(\"isrelative=\")) {\n isRelative = trimmed.slice(11).trim() !== \"0\"\n } else if (lower.startsWith(\"default=\")) {\n if (trimmed.slice(8).trim() !== \"1\") {\n // Not the default profile — skip\n inProfile = false\n defaultPath = null\n }\n }\n }\n\n if (!defaultPath) return null\n return isRelative ? join(firefoxDir, defaultPath) : defaultPath\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,kCAAiE;CAC/E,MAAM,SAAS,oBAAoB;CACnC,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI;EACF,MAAM,KAAK,IAAI,SAAS,QAAQ,EAAE,UAAU,KAAK,CAAC;EAClD,IAAI;GACF,MAAM,OAAO,GACV,MACC;uEAEF,CAAC,CACA,IAAI;GAIP,IAAI,CADe,KAAK,MAAM,MAAM,EAAE,SAAS,cACjC,GAAG,OAAO;GAExB,MAAM,UAAkC,CAAC;GACzC,KAAK,MAAM,OAAO,MAChB,QAAQ,IAAI,QAAQ,IAAI;GAE1B,OAAO;EACT,UAAU;GACR,GAAG,MAAM;EACX;CACF,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;AAWA,SAAgB,sBAAqC;CACnD,MAAM,aAAa,KAAK,QAAQ,GAAG,YAAY,SAAS;CACxD,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;CAGpC,MAAM,UAAU,KAAK,YAAY,cAAc;CAC/C,IAAI,WAAW,OAAO,GACpB,IAAI;EACF,MAAM,cAAc,wBAAwB,SAAS,UAAU;EAC/D,IAAI,aAAa;GACf,MAAM,SAAS,KAAK,aAAa,gBAAgB;GACjD,IAAI,WAAW,MAAM,GAAG,OAAO;EACjC;CACF,QAAQ,CAER;CAIF,MAAM,UAAU,YAAY,YAAY,EAAE,eAAe,KAAK,CAAC;CAC/D,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,CAAC,MAAM,YAAY,KAAK,CAAC,MAAM,eAAe,GAAG;EACrD,MAAM,SAAS,KAAK,YAAY,MAAM,MAAM,gBAAgB;EAC5D,IAAI,WAAW,MAAM,GAAG,OAAO;CACjC;CAEA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,wBAAwB,SAAiB,YAAmC;CAC1F,MAAM,OAAO,aAAa,SAAS,OAAO;CAE1C,IAAI,cAA6B;CACjC,IAAI,aAAa;CACjB,IAAI,YAAY;CAEhB,KAAK,MAAM,QAAQ,KAAK,MAAM,IAAI,GAAG;EACnC,MAAM,UAAU,KAAK,KAAK;EAG1B,IAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG,GAAG;GACpD,IAAI,aAAa,aAAa;GAC9B,YAAY,QAAQ,YAAY,CAAC,CAAC,WAAW,UAAU;GACvD,IAAI,WAAW;IACb,cAAc;IACd,aAAa;GACf;GACA;EACF;EAEA,IAAI,CAAC,WAAW;EAEhB,MAAM,QAAQ,QAAQ,YAAY;EAClC,IAAI,MAAM,WAAW,OAAO,GAC1B,cAAc,QAAQ,MAAM,CAAC;OACxB,IAAI,MAAM,WAAW,aAAa,GACvC,aAAa,QAAQ,MAAM,EAAE,CAAC,CAAC,KAAK,MAAM;OACrC,IAAI,MAAM,WAAW,UAAU;OAChC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,KAAK;IAEnC,YAAY;IACZ,cAAc;GAChB;;CAEJ;CAEA,IAAI,CAAC,aAAa,OAAO;CACzB,OAAO,aAAa,KAAK,YAAY,WAAW,IAAI;AACtD"}
|
package/dist/cli.mjs
CHANGED
|
@@ -19,11 +19,13 @@ function parseArgs(argv = process.argv) {
|
|
|
19
19
|
cmd.option("-p, --proxy <url>", "HTTP proxy (e.g. http://127.0.0.1:8080)");
|
|
20
20
|
cmd.option("-l, --log-level <level>", "Log level: debug | info | warn | error");
|
|
21
21
|
cmd.option("-c, --cookies <path>", "Path to cookies.json");
|
|
22
|
-
cmd.option("-s, --segment-minutes <minutes>", "Segment duration in minutes
|
|
22
|
+
cmd.option("-s, --segment-minutes <minutes>", "Segment duration in minutes (default: disabled). Split recording into segments of this length.");
|
|
23
23
|
cmd.option("--normalize", "Normalize audio loudness (EBU R128)");
|
|
24
24
|
cmd.option("--normalize-loudness <num>", "Target loudness in LUFS (default: -14)");
|
|
25
25
|
cmd.option("--normalize-codec <name>", "Audio codec for normalized output (default: aac)");
|
|
26
26
|
cmd.option("--normalize-bitrate <str>", "Audio bitrate for normalized output (default: 128k)");
|
|
27
|
+
cmd.option("--rate <n>", "Max API requests per second (default: 5). Set 0 for unlimited.");
|
|
28
|
+
cmd.option("--debug", "Show API debug logging on stderr for troubleshooting");
|
|
27
29
|
cmd.action((opts) => {
|
|
28
30
|
try {
|
|
29
31
|
const user = opts.user;
|
|
@@ -54,7 +56,7 @@ function parseArgs(argv = process.argv) {
|
|
|
54
56
|
if (opts.cookies) parsed.cookiesPath = opts.cookies;
|
|
55
57
|
if (opts.segmentMinutes !== void 0) {
|
|
56
58
|
const n = Number(opts.segmentMinutes);
|
|
57
|
-
if (!Number.isFinite(n) || n <
|
|
59
|
+
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--segment-minutes must be a number >= 0");
|
|
58
60
|
parsed.segmentMinutes = n;
|
|
59
61
|
}
|
|
60
62
|
if (opts.normalize) parsed.normalizeAudio = true;
|
|
@@ -65,6 +67,12 @@ function parseArgs(argv = process.argv) {
|
|
|
65
67
|
}
|
|
66
68
|
if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec;
|
|
67
69
|
if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate;
|
|
70
|
+
if (opts.rate !== void 0) {
|
|
71
|
+
const n = Number(opts.rate);
|
|
72
|
+
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--rate must be a number >= 0");
|
|
73
|
+
parsed.ratePerSecond = n;
|
|
74
|
+
}
|
|
75
|
+
if (opts.debug) parsed.debug = true;
|
|
68
76
|
config = parsed;
|
|
69
77
|
} catch (err) {
|
|
70
78
|
error = err instanceof Error ? err.message : String(err);
|
package/dist/cli.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI(\"tiktok-live-recorder\", pkg.version).description(\n \"Minimal TikTok live stream recorder — Bun + TypeScript\",\n )\n\n cli.command(\"\", \"Record a TikTok live stream\", (cmd) => {\n cmd.option(\"-u, --user <name>\", \"TikTok username (or pass as first argument)\")\n cmd.option(\"-o, --output <dir>\", \"Output directory\", {\n default: \"./recordings\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\"-s, --segment-minutes <minutes>\"
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI(\"tiktok-live-recorder\", pkg.version).description(\n \"Minimal TikTok live stream recorder — Bun + TypeScript\",\n )\n\n cli.command(\"\", \"Record a TikTok live stream\", (cmd) => {\n cmd.option(\"-u, --user <name>\", \"TikTok username (or pass as first argument)\")\n cmd.option(\"-o, --output <dir>\", \"Output directory\", {\n default: \"./recordings\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\n \"-s, --segment-minutes <minutes>\",\n \"Segment duration in minutes (default: disabled). Split recording into segments of this length.\",\n )\n cmd.option(\"--normalize\", \"Normalize audio loudness (EBU R128)\")\n cmd.option(\"--normalize-loudness <num>\", \"Target loudness in LUFS (default: -14)\")\n cmd.option(\"--normalize-codec <name>\", \"Audio codec for normalized output (default: aac)\")\n cmd.option(\"--normalize-bitrate <str>\", \"Audio bitrate for normalized output (default: 128k)\")\n cmd.option(\"--rate <n>\", \"Max API requests per second (default: 5). Set 0 for unlimited.\")\n cmd.option(\"--debug\", \"Show API debug logging on stderr for troubleshooting\")\n\n cmd.action((opts: Record<string, unknown>) => {\n try {\n const user = opts.user as string | undefined\n if (!user) {\n throw new TikTokError(\"config-error\", \"<user> is required (positional or --user)\")\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be a number >= 1\")\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be a number >= 0 (minutes)\")\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = [\"debug\", \"info\", \"warn\", \"error\"]\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\n \"config-error\",\n `--log-level must be one of: ${levels.join(\", \")}`,\n )\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--segment-minutes must be a number >= 0\")\n }\n parsed.segmentMinutes = n\n }\n\n if (opts.normalize) parsed.normalizeAudio = true\n if (opts.normalizeLoudness !== undefined) {\n const n = Number(opts.normalizeLoudness)\n if (!Number.isFinite(n)) {\n throw new TikTokError(\"config-error\", \"--normalize-loudness must be a number\")\n }\n parsed.normalizeLoudness = n\n }\n if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec as string\n if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate as string\n if (opts.rate !== undefined) {\n const n = Number(opts.rate)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--rate must be a number >= 0\")\n }\n parsed.ratePerSecond = n\n }\n if (opts.debug) parsed.debug = true\n\n config = parsed\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n })\n })\n\n // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push(\"--user\", positionalUser)\n }\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError(\"config-error\", error)\n }\n\n if (!config) {\n throw new TikTokError(\n \"config-error\",\n \"<user> is required (positional or --user). Use --help for usage.\",\n )\n }\n\n return config\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i]!\n // -- signals end of options; anything after is a positional\n if (arg === \"--\") {\n const pos = argv.splice(i, 1)[0]!\n return pos\n }\n // If this arg is a flag (-*) the next arg is its value\n if (arg.startsWith(\"-\")) {\n // Skip the value if this flag takes one (not a boolean flag like --help/--version)\n const next = argv[i + 1]\n if (next && !next.startsWith(\"-\") && arg !== \"--help\" && arg !== \"--version\") {\n i++ // skip the value\n }\n continue\n }\n // Found a bare positional — remove it and return\n argv.splice(i, 1)\n return arg\n }\n return null\n}\n"],"mappings":"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"}
|
package/dist/config.d.mts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* Config — types, defaults, and normalization for the recorder.
|
|
4
4
|
* The single source of truth for every user-facing option.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/**
|
|
7
|
+
* TikTok cookie key-value pairs extracted from Firefox or cookies.json.
|
|
8
|
+
* Includes session cookies (sessionid_ss, tt-target-idc) and any WAF
|
|
9
|
+
* bypass cookies needed to avoid Slardar challenges on www.tiktok.com.
|
|
10
|
+
*/
|
|
11
|
+
type CookieAuth = Record<string, string>;
|
|
10
12
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
11
13
|
interface RecorderConfig {
|
|
12
14
|
/** TikTok username (required) */
|
|
@@ -27,7 +29,7 @@ interface RecorderConfig {
|
|
|
27
29
|
logLevel?: LogLevel;
|
|
28
30
|
/** Whether to print logs to console (default: true). Set false when using Display-based UI. */
|
|
29
31
|
logConsole?: boolean;
|
|
30
|
-
/** Segment duration in minutes (default:
|
|
32
|
+
/** Segment duration in minutes (default: 0 — disabled). When >= 1, the recording is split into segments of this length. */
|
|
31
33
|
segmentMinutes?: number;
|
|
32
34
|
/** Normalize audio loudness via EBU R128 (default: false). */
|
|
33
35
|
normalizeAudio?: boolean;
|
|
@@ -37,6 +39,14 @@ interface RecorderConfig {
|
|
|
37
39
|
normalizeCodec?: string;
|
|
38
40
|
/** Audio bitrate for normalized output (default: "128k"). */
|
|
39
41
|
normalizeBitrate?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Max API requests per second (default: 5).
|
|
44
|
+
* Prevents TikTok WAF triggering on rapid polling or URL refresh loops.
|
|
45
|
+
* Set to 0 for unlimited (not recommended).
|
|
46
|
+
*/
|
|
47
|
+
ratePerSecond?: number;
|
|
48
|
+
/** Show API debug logging on stderr (default: false). */
|
|
49
|
+
debug?: boolean;
|
|
40
50
|
}
|
|
41
51
|
interface RecorderController {
|
|
42
52
|
/** Start the polling loop */
|
|
@@ -47,6 +57,8 @@ interface RecorderController {
|
|
|
47
57
|
getStatus(): RecorderStatus;
|
|
48
58
|
/** Subscribe to events */
|
|
49
59
|
on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void;
|
|
60
|
+
/** Unsubscribe a previously registered event handler */
|
|
61
|
+
off<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void;
|
|
50
62
|
}
|
|
51
63
|
interface RecorderStatus {
|
|
52
64
|
state: "idle" | "polling" | "recording" | "converting" | "stopped";
|
|
@@ -117,7 +129,7 @@ interface RecorderEventHandler {
|
|
|
117
129
|
}) => void;
|
|
118
130
|
error: (err: TikTokError) => void;
|
|
119
131
|
}
|
|
120
|
-
type AppErrorKind = "user-not-live" | "room-not-found" | "stream-url-not-found" | "waf-blocked" | "country-blocked" | "network-error" | "ffmpeg-not-found" | "config-error" | "unknown";
|
|
132
|
+
type AppErrorKind = "user-not-live" | "room-not-found" | "stream-url-not-found" | "waf-blocked" | "country-blocked" | "network-error" | "ffmpeg-not-found" | "ffmpeg-error" | "aborted" | "config-error" | "unknown";
|
|
121
133
|
declare class TikTokError extends Error {
|
|
122
134
|
readonly kind: AppErrorKind;
|
|
123
135
|
readonly cause?: unknown | undefined;
|