@zfadhli/tokrec 0.4.2 → 0.5.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/dist/api/tiktok.mjs +32 -54
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/browser-cookies.mjs +6 -3
- package/dist/browser-cookies.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/convert.mjs +3 -3
- package/dist/recorder/convert.mjs.map +1 -1
- package/dist/recorder/index.mjs +2 -2
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/stream.mjs +130 -7
- package/dist/recorder/stream.mjs.map +1 -1
- package/package.json +1 -1
package/dist/api/tiktok.mjs
CHANGED
|
@@ -60,15 +60,11 @@ function createTikTokApi(http) {
|
|
|
60
60
|
} catch (_err) {
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
|
-
const {
|
|
63
|
+
const { roomId } = extractUserFromUniversalData(html);
|
|
64
64
|
if (roomId) {
|
|
65
65
|
const result = await fetchRoomInfoFromRoomApi(roomId);
|
|
66
66
|
if (result) return result;
|
|
67
67
|
}
|
|
68
|
-
if (userId) {
|
|
69
|
-
const result = await fetchRoomInfoFromApi(userId);
|
|
70
|
-
if (result) return result;
|
|
71
|
-
}
|
|
72
68
|
return fetchLiveInfoFromProfile(user);
|
|
73
69
|
}
|
|
74
70
|
/**
|
|
@@ -76,7 +72,7 @@ function createTikTokApi(http) {
|
|
|
76
72
|
* __UNIVERSAL_DATA_FOR_REHYDRATION__. When the user is live, their
|
|
77
73
|
* roomId is embedded in the data, allowing us to fetch room info and
|
|
78
74
|
* stream URL via the /webcast/room/info/ endpoint (which works
|
|
79
|
-
* reliably).
|
|
75
|
+
* reliably). If no roomId is present the user is offline.
|
|
80
76
|
*/
|
|
81
77
|
async function fetchLiveInfoFromProfile(user) {
|
|
82
78
|
try {
|
|
@@ -85,7 +81,7 @@ function createTikTokApi(http) {
|
|
|
85
81
|
const { userId, roomId } = extractUserFromUniversalData(await res.text());
|
|
86
82
|
if (!userId) return null;
|
|
87
83
|
if (roomId) return fetchRoomInfoFromRoomApi(roomId);
|
|
88
|
-
return
|
|
84
|
+
return null;
|
|
89
85
|
} catch {
|
|
90
86
|
return null;
|
|
91
87
|
}
|
|
@@ -136,37 +132,9 @@ function createTikTokApi(http) {
|
|
|
136
132
|
title: null
|
|
137
133
|
};
|
|
138
134
|
}
|
|
139
|
-
if (userModule?.id && isLive) return fetchRoomInfoFromApi(userModule.id);
|
|
140
135
|
return null;
|
|
141
136
|
}
|
|
142
137
|
/**
|
|
143
|
-
* Fallback: fetch room info + stream URL from the Webcast API.
|
|
144
|
-
* This covers cases where SIGI_STATE has no room data at all
|
|
145
|
-
* (fully async-loaded page).
|
|
146
|
-
*/
|
|
147
|
-
async function fetchRoomInfoFromApi(userId) {
|
|
148
|
-
try {
|
|
149
|
-
const url = `${WEBCAST_BASE}/webcast/room/enter/?aid=1988&user_id=${userId}`;
|
|
150
|
-
const res = await http.get(url);
|
|
151
|
-
if (!res.ok) return null;
|
|
152
|
-
const text = await res.text();
|
|
153
|
-
if (text.length === 0) return null;
|
|
154
|
-
const roomData = JSON.parse(text)?.data?.room;
|
|
155
|
-
if (!roomData) return null;
|
|
156
|
-
const roomId = String(roomData.roomId ?? "");
|
|
157
|
-
if (!roomId) return null;
|
|
158
|
-
const isLive = roomData.status === 2;
|
|
159
|
-
return {
|
|
160
|
-
roomId,
|
|
161
|
-
isLive,
|
|
162
|
-
streamUrl: isLive ? findStreamUrlRecursively(roomData) : null,
|
|
163
|
-
title: roomData.title ?? null
|
|
164
|
-
};
|
|
165
|
-
} catch {
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
138
|
* Fallback: fetch stream URL from the Webcast API.
|
|
171
139
|
* This covers cases where SIGI_STATE doesn't include stream data
|
|
172
140
|
* (e.g. async-loaded room info).
|
|
@@ -186,6 +154,9 @@ function createTikTokApi(http) {
|
|
|
186
154
|
}
|
|
187
155
|
}
|
|
188
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* @public Exported for testing.
|
|
159
|
+
*/
|
|
189
160
|
function extractSigiState(html) {
|
|
190
161
|
const match = html.match(/<script\s+id="SIGI_STATE"\s+type="application\/json">(.*?)<\/script>/);
|
|
191
162
|
if (!match?.[1]) return null;
|
|
@@ -205,6 +176,8 @@ function extractSigiState(html) {
|
|
|
205
176
|
*
|
|
206
177
|
* Returns { userId, roomId } where userId is always set when the data
|
|
207
178
|
* is found, and roomId is set only when the user is currently live.
|
|
179
|
+
*
|
|
180
|
+
* @public Exported for testing and fixture capture.
|
|
208
181
|
*/
|
|
209
182
|
function extractUserFromUniversalData(html) {
|
|
210
183
|
const match = html.match(/<script\s+id="__UNIVERSAL_DATA_FOR_REHYDRATION__"\s+type="application\/json">(.*?)<\/script>/);
|
|
@@ -229,6 +202,8 @@ function extractUserFromUniversalData(html) {
|
|
|
229
202
|
/**
|
|
230
203
|
* Fast-path: extract stream URL from the known SIGI_STATE location.
|
|
231
204
|
* This covers ~95% of cases without needing a full recursive search.
|
|
205
|
+
*
|
|
206
|
+
* Prefers FLV (lower latency) over HLS/other protocols.
|
|
232
207
|
*/
|
|
233
208
|
function extractStreamUrlFast(liveRoom) {
|
|
234
209
|
try {
|
|
@@ -244,7 +219,8 @@ function extractStreamUrlFast(liveRoom) {
|
|
|
244
219
|
"sd",
|
|
245
220
|
"ld"
|
|
246
221
|
]) {
|
|
247
|
-
const
|
|
222
|
+
const entry = data[q];
|
|
223
|
+
const url = entry?.main?.flv ?? entry?.main?.hls;
|
|
248
224
|
if (url) return url;
|
|
249
225
|
}
|
|
250
226
|
return null;
|
|
@@ -256,29 +232,31 @@ function extractStreamUrlFast(liveRoom) {
|
|
|
256
232
|
* Brute-force recursive search for a stream URL anywhere in a JSON value.
|
|
257
233
|
* Survives TikTok changing key names or nesting structure.
|
|
258
234
|
*
|
|
235
|
+
* Prefers FLV over HLS when both are present in the response.
|
|
236
|
+
*
|
|
259
237
|
* Inspired by PR #430 (Michele0303/tiktok-live-recorder).
|
|
260
238
|
*/
|
|
261
239
|
function findStreamUrlRecursively(obj) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
240
|
+
const flvUrls = [];
|
|
241
|
+
const m3u8Urls = [];
|
|
242
|
+
function walk(value) {
|
|
243
|
+
if (typeof value === "string") {
|
|
244
|
+
if ((value.startsWith("http://") || value.startsWith("https://")) && (value.includes(".flv") || value.includes(".m3u8"))) {
|
|
245
|
+
if (value.includes(".flv")) flvUrls.push(value);
|
|
246
|
+
else m3u8Urls.push(value);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (value.startsWith("{") || value.startsWith("[")) try {
|
|
250
|
+
walk(JSON.parse(value));
|
|
251
|
+
} catch {}
|
|
252
|
+
return;
|
|
275
253
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (Array.isArray(obj)) for (const item of obj) {
|
|
279
|
-
const found = findStreamUrlRecursively(item);
|
|
280
|
-
if (found) return found;
|
|
254
|
+
if (value && typeof value === "object") if (Array.isArray(value)) for (const item of value) walk(item);
|
|
255
|
+
else for (const val of Object.values(value)) walk(val);
|
|
281
256
|
}
|
|
257
|
+
walk(obj);
|
|
258
|
+
if (flvUrls.length > 0) return flvUrls[0];
|
|
259
|
+
if (m3u8Urls.length > 0) return m3u8Urls[0];
|
|
282
260
|
return null;
|
|
283
261
|
}
|
|
284
262
|
//#endregion
|
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 numeric user ID, then calls the Webcast API\n * (room/enter/) for live 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\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 { userId, 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 if (userId) {\n // Fallback: try room/enter with the numeric user ID\n const result = await fetchRoomInfoFromApi(userId)\n if (result) return result\n }\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). Falls back to room/enter/ via the numeric userId.\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 // Fallback: try Webcast room/enter with the numeric userId\n return fetchRoomInfoFromApi(userId)\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 // Final fallback: Webcast room/enter API using the numeric user ID\n if (userModule?.id && isLive) {\n return fetchRoomInfoFromApi(userModule.id)\n }\n\n return null // truly offline / unknown\n }\n\n /**\n * Fallback: fetch room info + stream URL from the Webcast API.\n * This covers cases where SIGI_STATE has no room data at all\n * (fully async-loaded page).\n */\n async function fetchRoomInfoFromApi(userId: string): Promise<LiveInfo | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/enter/?aid=1988&user_id=${userId}`\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 const roomData = (data as any)?.data?.room\n if (!roomData) return null\n\n const roomId = String(roomData.roomId ?? \"\")\n if (!roomId) return null\n\n const roomStatus = roomData.status // 2 = live\n const isLive = roomStatus === 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 * 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\nfunction 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 */\nfunction 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 */\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 const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"]\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string } } | undefined\n const url = entry?.main?.flv\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 * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Base case: a string\n if (typeof obj === \"string\") {\n // Direct URL match\n if (\n (obj.startsWith(\"http://\") || obj.startsWith(\"https://\")) &&\n (obj.includes(\".flv\") || obj.includes(\".m3u8\"))\n ) {\n return obj\n }\n // JSON-encoded string — parse and recurse (covers stream_data, etc.)\n if (obj.startsWith(\"{\") || obj.startsWith(\"[\")) {\n try {\n return findStreamUrlRecursively(JSON.parse(obj))\n } catch {\n return null\n }\n }\n return null\n }\n\n // Dict: recurse into all values\n if (obj && typeof obj === \"object\" && !Array.isArray(obj)) {\n for (const val of Object.values(obj as Record<string, unknown>)) {\n const found = findStreamUrlRecursively(val)\n if (found) return found\n }\n return null\n }\n\n // Array: recurse into all elements\n if (Array.isArray(obj)) {\n for (const item of obj) {\n const found = findStreamUrlRecursively(item)\n if (found) return found\n }\n }\n\n return null\n}\n"],"mappings":";AA6BA,MAAM,cAAc;AACpB,MAAM,eAAe;AAErB,SAAgB,gBAAgB,MAA6B;CAG3D,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;GAGhB,QAAO,MADY,cAAc,cAAc,MAAM,EAAA,EACxC,UAAU;EACzB;EAEA,MAAM,WAAW,QAAwC;GAEvD,QAAO,MADY,YAAY,cAAc,MAAM,EAAA,EACtC,aAAa;EAC5B;CACF;CAIA,eAAe,cAAc,MAAwC;EACnE,IAAI,OAAyB;EAC7B,IAAI,OAAO;EAEX,IAAI;GACF,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG,YAAY,IAAI,KAAK,MAAM;GACzD,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,OAAO,MAAM,IAAI,KAAK;GACtB,OAAO,iBAAiB,IAAI;GAE5B,IAAI,MAAM;IAER,MAAM,WAAW,KAAK,UAAU,kBAAkB;IAClD,MAAM,WAAW,KAAK,UAAU,kBAAkB;IAElD,IAAI,YAAY,UAAU,QAAQ;KAChC,MAAM,SAAS,OAAO,SAAS,MAAM;KACrC,MAAM,SAAS,SAAS,WAAW;KAGnC,IAAI,YAA2B;KAC/B,IAAI,QAAQ;MACV,YAAY,qBAAqB,QAAQ;MACzC,IAAI,CAAC,WAAW,YAAY,yBAAyB,IAAI;MACzD,IAAI,CAAC,WAAW,YAAY,MAAM,sBAAsB,MAAM;KAChE;KAEA,OAAO;MAAE;MAAQ;MAAQ;MAAW,OAAO,SAAS,SAAS;KAAK;IACpE;IAGA,MAAM,SAAS,MAAM,sBAAsB,MAAM,IAAI;IACrD,IAAI,QAAQ,OAAO;GACrB;EACF,SAAS,MAAM;GAEb,OAAO;EACT;EAKA,MAAM,EAAE,QAAQ,WAAW,6BAA6B,IAAI;EAC5D,IAAI,QAAQ;GAEV,MAAM,SAAS,MAAM,yBAAyB,MAAM;GACpD,IAAI,QAAQ,OAAO;EACrB;EACA,IAAI,QAAQ;GAEV,MAAM,SAAS,MAAM,qBAAqB,MAAM;GAChD,IAAI,QAAQ,OAAO;EACrB;EAGA,OAAO,yBAAyB,IAAI;CACtC;;;;;;;;CASA,eAAe,yBAAyB,MAAwC;EAC9E,IAAI;GACF,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG,YAAY,IAAI,MAAM;GACpD,IAAI,CAAC,IAAI,IAAI,OAAO;GAGpB,MAAM,EAAE,QAAQ,WAAW,6BAA6B,MADrC,IAAI,KAAK,CACgC;GAC5D,IAAI,CAAC,QAAQ,OAAO;GAIpB,IAAI,QACF,OAAO,yBAAyB,MAAM;GAIxC,OAAO,qBAAqB,MAAM;EACpC,QAAQ;GACN,OAAO;EACT;CACF;;;;;CAMA,eAAe,yBAAyB,QAA0C;EAChF,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,uCAAuC,OAAO;GAC1E,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,IAAI,KAAK,WAAW,GAAG,OAAO;GAE9B,MAAM,OAAO,KAAK,MAAM,IAAI;GAG5B,IAAK,KAAa,gBAAgB,SAAS,OAAO;GAElD,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,QAAQ;GACN,OAAO;EACT;CACF;;;;;;;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,IAAI,YAAY,MAAM,QACpB,OAAO,qBAAqB,WAAW,EAAE;EAG3C,OAAO;CACT;;;;;;CAOA,eAAe,qBAAqB,QAA0C;EAC5E,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,wCAAwC;GACpE,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,IAAI,KAAK,WAAW,GAAG,OAAO;GAG9B,MAAM,WADO,KAAK,MAAM,IACH,CAAC,EAAU,MAAM;GACtC,IAAI,CAAC,UAAU,OAAO;GAEtB,MAAM,SAAS,OAAO,SAAS,UAAU,EAAE;GAC3C,IAAI,CAAC,QAAQ,OAAO;GAGpB,MAAM,SADa,SAAS,WACE;GAG9B,OAAO;IAAE;IAAQ;IAAQ,WAFP,SAAS,yBAAyB,QAAQ,IAAI;IAE5B,OAAO,SAAS,SAAS;GAAK;EACpE,QAAQ;GACN,OAAO;EACT;CACF;;;;;;CAOA,eAAe,sBAAsB,QAAwC;EAC3E,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,uCAAuC,OAAO;GAC1E,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,IAAI,KAAK,WAAW,GAAG,OAAO;GAE9B,MAAM,OAAO,KAAK,MAAM,IAAI;GAG5B,IAAK,KAAa,gBAAgB,SAAS,OAAO;GAElD,OAAO,yBAAyB,IAAI;EACtC,QAAQ;GACN,OAAO;EACT;CACF;AACF;AA4CA,SAAS,iBAAiB,MAAgC;CACxD,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;;;;;;;;;;;;AAaA,SAAS,6BAA6B,MAGpC;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;;;;;AAQA,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;EAIlB,KAAK,MAAM,KAAK;GADG;GAAU;GAAO;GAAO;GAAM;GAAM;EAC/B,GAAG;GAEzB,MAAM,MADQ,KAAK,EACF,EAAE,MAAM;GACzB,IAAI,KAAK,OAAO;EAClB;EACA,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;AAQA,SAAgB,yBAAyB,KAA6B;CAEpE,IAAI,OAAO,QAAQ,UAAU;EAE3B,KACG,IAAI,WAAW,SAAS,KAAK,IAAI,WAAW,UAAU,OACtD,IAAI,SAAS,MAAM,KAAK,IAAI,SAAS,OAAO,IAE7C,OAAO;EAGT,IAAI,IAAI,WAAW,GAAG,KAAK,IAAI,WAAW,GAAG,GAC3C,IAAI;GACF,OAAO,yBAAyB,KAAK,MAAM,GAAG,CAAC;EACjD,QAAQ;GACN,OAAO;EACT;EAEF,OAAO;CACT;CAGA,IAAI,OAAO,OAAO,QAAQ,YAAY,CAAC,MAAM,QAAQ,GAAG,GAAG;EACzD,KAAK,MAAM,OAAO,OAAO,OAAO,GAA8B,GAAG;GAC/D,MAAM,QAAQ,yBAAyB,GAAG;GAC1C,IAAI,OAAO,OAAO;EACpB;EACA,OAAO;CACT;CAGA,IAAI,MAAM,QAAQ,GAAG,GACnB,KAAK,MAAM,QAAQ,KAAK;EACtB,MAAM,QAAQ,yBAAyB,IAAI;EAC3C,IAAI,OAAO,OAAO;CACpB;CAGF,OAAO;AACT"}
|
|
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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"}
|
package/dist/browser-cookies.mjs
CHANGED
|
@@ -49,6 +49,7 @@ function extractTikTokCookiesFromFirefox() {
|
|
|
49
49
|
* 2. Fall back to scanning directory entries that look like Firefox profiles
|
|
50
50
|
* (contain a cookies.sqlite file).
|
|
51
51
|
*/
|
|
52
|
+
/** @public Exported for testing */
|
|
52
53
|
function findFirefoxCookieDb() {
|
|
53
54
|
const firefoxDir = join(homedir(), ".mozilla", "firefox");
|
|
54
55
|
if (!existsSync(firefoxDir)) return null;
|
|
@@ -78,6 +79,7 @@ function findFirefoxCookieDb() {
|
|
|
78
79
|
* Path=1pese0rl.default-release
|
|
79
80
|
* Default=1
|
|
80
81
|
*/
|
|
82
|
+
/** @public Exported for testing */
|
|
81
83
|
function parseFirefoxProfilesIni(iniPath, firefoxDir) {
|
|
82
84
|
const text = readFileSync(iniPath, "utf-8");
|
|
83
85
|
let defaultPath = null;
|
|
@@ -95,9 +97,10 @@ function parseFirefoxProfilesIni(iniPath, firefoxDir) {
|
|
|
95
97
|
continue;
|
|
96
98
|
}
|
|
97
99
|
if (!inProfile) continue;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
else if (
|
|
100
|
+
const lower = trimmed.toLowerCase();
|
|
101
|
+
if (lower.startsWith("path=")) defaultPath = trimmed.slice(5);
|
|
102
|
+
else if (lower.startsWith("isrelative=")) isRelative = trimmed.slice(11).trim() !== "0";
|
|
103
|
+
else if (lower.startsWith("default=")) {
|
|
101
104
|
if (trimmed.slice(8).trim() !== "1") {
|
|
102
105
|
inProfile = false;
|
|
103
106
|
defaultPath = null;
|
|
@@ -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, readFileSync, readdirSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\nexport interface TikTokCookies {\n sessionid_ss: string\n \"tt-target-idc\"?: string\n}\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Searches all profile directories under ~/.mozilla/firefox/ for a\n * cookies.sqlite file, then queries it for TikTok session cookies.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * TikTok login session exists.\n */\nexport function extractTikTokCookiesFromFirefox(): TikTokCookies | 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'\n AND name IN ('sessionid_ss', 'tt-target-idc')`,\n )\n .all() as Array<{ name: string; value: string }>\n\n const session = rows.find((r) => r.name === \"sessionid_ss\")\n if (!session?.value) return null\n\n const result: TikTokCookies = { sessionid_ss: session.value }\n const idc = rows.find((r) => r.name === \"tt-target-idc\")\n if (idc?.value) result[\"tt-target-idc\"] = idc.value\n\n return result\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 */\
|
|
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, readFileSync, readdirSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\nexport interface TikTokCookies {\n sessionid_ss: string\n \"tt-target-idc\"?: string\n}\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Searches all profile directories under ~/.mozilla/firefox/ for a\n * cookies.sqlite file, then queries it for TikTok session cookies.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * TikTok login session exists.\n */\nexport function extractTikTokCookiesFromFirefox(): TikTokCookies | 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'\n AND name IN ('sessionid_ss', 'tt-target-idc')`,\n )\n .all() as Array<{ name: string; value: string }>\n\n const session = rows.find((r) => r.name === \"sessionid_ss\")\n if (!session?.value) return null\n\n const result: TikTokCookies = { sessionid_ss: session.value }\n const idc = rows.find((r) => r.name === \"tt-target-idc\")\n if (idc?.value) result[\"tt-target-idc\"] = idc.value\n\n return result\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":";;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,kCAAwD;CACtE,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;;yDAGF,CAAC,CACA,IAAI;GAEP,MAAM,UAAU,KAAK,MAAM,MAAM,EAAE,SAAS,cAAc;GAC1D,IAAI,CAAC,SAAS,OAAO,OAAO;GAE5B,MAAM,SAAwB,EAAE,cAAc,QAAQ,MAAM;GAC5D,MAAM,MAAM,KAAK,MAAM,MAAM,EAAE,SAAS,eAAe;GACvD,IAAI,KAAK,OAAO,OAAO,mBAAmB,IAAI;GAE9C,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/package.mjs
CHANGED
|
@@ -2,12 +2,12 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { statSync, unlinkSync } from "node:fs";
|
|
3
3
|
//#region src/recorder/convert.ts
|
|
4
4
|
/**
|
|
5
|
-
* Converter — spawns FFmpeg to convert FLV → MP4 (stream copy by default).
|
|
6
|
-
* Deletes the original
|
|
5
|
+
* Converter — spawns FFmpeg to convert FLV/TS → MP4 (stream copy by default).
|
|
6
|
+
* Deletes the original file on success.
|
|
7
7
|
*/
|
|
8
8
|
function createConverter(logger) {
|
|
9
9
|
async function convert(input) {
|
|
10
|
-
const output = input.replace(/\.flv$/i, ".mp4");
|
|
10
|
+
const output = input.replace(/\.(flv|ts)$/i, ".mp4");
|
|
11
11
|
logger?.info(`Converting: ${input} → ${output}`);
|
|
12
12
|
const ffmpegPath = findFfmpeg();
|
|
13
13
|
if (!ffmpegPath) throw new Error("FFmpeg not found. Install it:\n Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n macOS: brew install ffmpeg\n Windows: choco install ffmpeg");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert FLV → MP4 (stream copy by default).\n * Deletes the original
|
|
1
|
+
{"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert FLV/TS → MP4 (stream copy by default).\n * Deletes the original file on success.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { statSync, unlinkSync } from \"node:fs\"\nimport type { Logger } from \"../logger\"\n\nexport interface Converter {\n /** Convert a FLV or TS file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.(flv|ts)$/i, \".mp4\")\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n const ffmpegPath = findFfmpeg()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found. Install it:\\n\" +\n \" Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\\n\" +\n \" macOS: brew install ffmpeg\\n\" +\n \" Windows: choco install ffmpeg\",\n )\n }\n\n await runFfmpeg(ffmpegPath, input, output)\n\n // Delete original FLV\n try {\n unlinkSync(input)\n logger?.info(`Deleted original: ${input}`)\n } catch {\n logger?.warn(`Could not delete original: ${input}`)\n }\n\n logger?.info(`Converted: ${output}`)\n return output\n }\n\n return { convert }\n}\n\nfunction findFfmpeg(): string | null {\n // Bun.which searches PATH like `which`\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n return bunWhich(\"ffmpeg\") as string | null\n }\n\n // Fallback: manual PATH search\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // eslint-disable-line no-empty\n }\n }\n return null\n}\n\nfunction runFfmpeg(ffmpegPath: string, input: string, output: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const args = [\n \"-i\",\n input,\n \"-c\",\n \"copy\", // stream copy — no re-encode, fast\n \"-y\", // overwrite output\n output,\n ]\n\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(new Error(`FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`))\n }\n })\n\n proc.on(\"error\", (err) => {\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n}\n"],"mappings":";;;;;;;AAcA,SAAgB,gBAAgB,QAA4B;CAC1D,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,gBAAgB,MAAM;EAEnD,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,WAAW;EAC9B,IAAI,CAAC,YACH,MAAM,IAAI,MACR,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,MAAM;EAGzC,IAAI;GACF,WAAW,KAAK;GAChB,QAAQ,KAAK,qBAAqB,OAAO;EAC3C,QAAQ;GACN,QAAQ,KAAK,8BAA8B,OAAO;EACpD;EAEA,QAAQ,KAAK,cAAc,QAAQ;EACnC,OAAO;CACT;CAEA,OAAO,EAAE,QAAQ;AACnB;AAEA,SAAS,aAA4B;CAEnC,MAAM,WAAY,KAAa;CAC/B,IAAI,OAAO,aAAa,YACtB,OAAO,SAAS,QAAQ;CAI1B,MAAM,QAAQ,QAAQ,IAAI,MAAM,MAAM,GAAG,KAAK,CAAC;CAC/C,KAAK,MAAM,OAAO,OAChB,IAAI;EACF,MAAM,OAAO,GAAG,IAAI;EACpB,SAAS,IAAI;EACb,OAAO;CACT,QAAQ,CAER;CAEF,OAAO;AACT;AAEA,SAAS,UAAU,YAAoB,OAAe,QAA+B;CACnF,OAAO,IAAI,SAAS,SAAS,WAAW;EAUtC,MAAM,OAAO,MAAM,YAAY;GAR7B;GACA;GACA;GACA;GACA;GACA;EAGgC,GAAG,EACnC,OAAO;GAAC;GAAU;GAAQ;EAAM,EAClC,CAAC;EAED,IAAI,SAAS;EAEb,KAAK,QAAQ,GAAG,SAAS,UAAkB;GACzC,UAAU,MAAM,SAAS;EAC3B,CAAC;EAED,KAAK,GAAG,UAAU,SAAS;GACzB,IAAI,SAAS,GACX,QAAQ;QAER,uBAAO,IAAI,MAAM,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CAAC;EAE9E,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,uBAAO,IAAI,MAAM,2BAA2B,IAAI,SAAS,CAAC;EAC5D,CAAC;CACH,CAAC;AACH"}
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -13,8 +13,8 @@ import { join, parse } from "node:path";
|
|
|
13
13
|
* Recorder orchestrator — wires together the API, monitor, stream downloader,
|
|
14
14
|
* and converter into a single RecorderController.
|
|
15
15
|
*
|
|
16
|
-
* Downloads the full stream
|
|
17
|
-
* into time-aligned MP4 segments.
|
|
16
|
+
* Downloads the full stream (FLV via fetch, HLS via FFmpeg), then uses FFmpeg
|
|
17
|
+
* to split it into time-aligned MP4 segments.
|
|
18
18
|
*/
|
|
19
19
|
function createRecorder(config) {
|
|
20
20
|
const cfg = normalizeConfig(config);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n *\n * Downloads the full stream as a single FLV, then uses FFmpeg to split it\n * into time-aligned MP4 segments.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport { createHttpClient } from \"../api/client\"\nimport { type TikTokApi, createTikTokApi } from \"../api/tiktok\"\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from \"../config\"\nimport { TikTokError, normalizeConfig } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { type PollingMonitor, createPollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type StreamDownloader, createStreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n // Internal event registry\n const eventHandlers = new Map<string, Array<(...args: any[]) => void>>()\n\n let state: RecorderStatus = {\n state: \"idle\",\n user: cfg.user,\n }\n\n // State machine\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let stopRequested = false\n\n function setState(partial: Partial<RecorderStatus>): void {\n state = { ...state, ...partial }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = eventHandlers.get(event) ?? []\n list.push(handler as (...args: any[]) => void)\n eventHandlers.set(event, list)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = eventHandlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function getFfmpegPath(): string {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n const path = bunWhich(\"ffmpeg\") as string | undefined\n if (path) return path\n }\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function runFfmpeg(args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n stopRequested = false\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger)\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emit(\"tick\", { user, isLive: true, roomId })\n\n // Record the stream\n setState({ state: \"recording\" })\n emit(\"recording:start\", { user, file: \"\" })\n\n // Create a reconnection callback that fetches a fresh stream URL\n // when the current segment ends. This handles TikTok's short-lived\n // stream URLs (typically 30-60s per segment).\n let reconnectCount = 0\n const getNextUrl = async (): Promise<string | null> => {\n reconnectCount++\n if (reconnectCount > 100) {\n // Safety valve: don't reconnect indefinitely\n logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`)\n return null\n }\n // Invalidate cache so we get fresh data from TikTok\n api!.invalidateCache()\n // Check if the user is still live with the same room\n const stillLive = await api!.getRoomId(user)\n if (!stillLive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Get a fresh stream URL for the continuing stream\n return api!.getLiveUrl(stillLive)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n setState({ state: \"converting\" })\n\n if (result.size > 0 && cfg.segmentMinutes >= 1) {\n // Split the FLV into timed MP4 segments using FFmpeg\n const parsed = parse(result.file)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n const segmentTime = Math.max(1, cfg.segmentMinutes * 60)\n\n logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`,\n )\n\n emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n await runFfmpeg([\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ])\n\n // Capture FLV timestamp before deletion (used to timestamp segments)\n const flvMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = flvMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original FLV\n try {\n unlinkSync(result.file)\n logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n logger?.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n return na - nb\n })\n\n const segmentDurationSec = cfg.segmentMinutes * 60\n const totalDuration = result.duration\n\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!)\n const stats = statSync(filePath)\n // Estimate duration: first N-1 are full segments, last is remainder\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n emit(\"recording:end\", {\n file: filePath,\n duration: estDuration,\n size: stats.size,\n })\n emit(\"converted\", {\n input: result.file,\n output: filePath,\n })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i]!)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n logger.error(`Segmenting failed: ${msg}`)\n // Fallback: try simple conversion of the whole FLV\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n } else if (result.size > 0) {\n // No segmenting — simple FLV → MP4 conversion\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"recording:end\", {\n file: mp4File,\n duration: result.duration,\n size: result.size,\n })\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n\n setState({ state: \"polling\" })\n },\n })\n\n setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n if (httpClient) await httpClient.close()\n setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus,\n on,\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n *\n * Downloads the full stream (FLV via fetch, HLS via FFmpeg), then uses FFmpeg\n * to split it into time-aligned MP4 segments.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport { createHttpClient } from \"../api/client\"\nimport { type TikTokApi, createTikTokApi } from \"../api/tiktok\"\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from \"../config\"\nimport { TikTokError, normalizeConfig } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { type PollingMonitor, createPollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type StreamDownloader, createStreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n // Internal event registry\n const eventHandlers = new Map<string, Array<(...args: any[]) => void>>()\n\n let state: RecorderStatus = {\n state: \"idle\",\n user: cfg.user,\n }\n\n // State machine\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let stopRequested = false\n\n function setState(partial: Partial<RecorderStatus>): void {\n state = { ...state, ...partial }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = eventHandlers.get(event) ?? []\n list.push(handler as (...args: any[]) => void)\n eventHandlers.set(event, list)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = eventHandlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function getFfmpegPath(): string {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n const path = bunWhich(\"ffmpeg\") as string | undefined\n if (path) return path\n }\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function runFfmpeg(args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n stopRequested = false\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger)\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emit(\"tick\", { user, isLive: true, roomId })\n\n // Record the stream\n setState({ state: \"recording\" })\n emit(\"recording:start\", { user, file: \"\" })\n\n // Create a reconnection callback that fetches a fresh stream URL\n // when the current segment ends. This handles TikTok's short-lived\n // stream URLs (typically 30-60s per segment).\n let reconnectCount = 0\n const getNextUrl = async (): Promise<string | null> => {\n reconnectCount++\n if (reconnectCount > 100) {\n // Safety valve: don't reconnect indefinitely\n logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`)\n return null\n }\n // Invalidate cache so we get fresh data from TikTok\n api!.invalidateCache()\n // Check if the user is still live with the same room\n const stillLive = await api!.getRoomId(user)\n if (!stillLive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Get a fresh stream URL for the continuing stream\n return api!.getLiveUrl(stillLive)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n setState({ state: \"converting\" })\n\n if (result.size > 0 && cfg.segmentMinutes >= 1) {\n // Split the FLV into timed MP4 segments using FFmpeg\n const parsed = parse(result.file)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n const segmentTime = Math.max(1, cfg.segmentMinutes * 60)\n\n logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`,\n )\n\n emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n await runFfmpeg([\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ])\n\n // Capture FLV timestamp before deletion (used to timestamp segments)\n const flvMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = flvMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original FLV\n try {\n unlinkSync(result.file)\n logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n logger?.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n return na - nb\n })\n\n const segmentDurationSec = cfg.segmentMinutes * 60\n const totalDuration = result.duration\n\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!)\n const stats = statSync(filePath)\n // Estimate duration: first N-1 are full segments, last is remainder\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n emit(\"recording:end\", {\n file: filePath,\n duration: estDuration,\n size: stats.size,\n })\n emit(\"converted\", {\n input: result.file,\n output: filePath,\n })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i]!)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n logger.error(`Segmenting failed: ${msg}`)\n // Fallback: try simple conversion of the whole FLV\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n } else if (result.size > 0) {\n // No segmenting — simple FLV → MP4 conversion\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"recording:end\", {\n file: mp4File,\n duration: result.duration,\n size: result.size,\n })\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n\n setState({ state: \"polling\" })\n },\n })\n\n setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n if (httpClient) await httpClient.close()\n setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus,\n on,\n }\n}\n"],"mappings":"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"}
|
package/dist/recorder/stream.mjs
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import { bytesToHuman, ensureDir, formatFilename } from "../utils.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createWriteStream, statSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
//#region src/recorder/stream.ts
|
|
5
6
|
/**
|
|
6
|
-
* Stream downloader — fetches a
|
|
7
|
-
*
|
|
7
|
+
* Stream downloader — fetches a live stream and writes it to disk with buffering.
|
|
8
|
+
*
|
|
9
|
+
* FLV streams: downloaded via raw fetch() with byte buffering and transparent
|
|
10
|
+
* reconnection when TikTok stream segments end naturally.
|
|
11
|
+
* HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).
|
|
8
12
|
*/
|
|
9
13
|
function createStreamDownloader(logger) {
|
|
10
14
|
let abortFlag = false;
|
|
11
15
|
let streamReader = null;
|
|
16
|
+
let ffmpegProcess = null;
|
|
12
17
|
function abort() {
|
|
13
18
|
abortFlag = true;
|
|
14
19
|
streamReader?.cancel().catch(() => {});
|
|
20
|
+
if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill("SIGTERM");
|
|
21
|
+
}
|
|
22
|
+
/** Detect whether a URL is an HLS (.m3u8) playlist. */
|
|
23
|
+
function isHlsUrl(url) {
|
|
24
|
+
return url.includes(".m3u8");
|
|
15
25
|
}
|
|
16
26
|
async function openReader(url) {
|
|
17
27
|
const response = await fetch(url);
|
|
18
28
|
if (!response.ok || !response.body) throw new Error(`Failed to fetch stream: ${response.status}`);
|
|
19
29
|
return response.body.getReader();
|
|
20
30
|
}
|
|
21
|
-
async function
|
|
22
|
-
abortFlag = false;
|
|
23
|
-
ensureDir(outputDir);
|
|
31
|
+
async function downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl) {
|
|
24
32
|
const filename = formatFilename(user, "flv");
|
|
25
33
|
const filepath = join(outputDir, filename);
|
|
26
34
|
logger?.info(`Recording: ${filename}`);
|
|
@@ -35,7 +43,6 @@ function createStreamDownloader(logger) {
|
|
|
35
43
|
}
|
|
36
44
|
streamReader = reader;
|
|
37
45
|
const writer = createWriteStream(filepath);
|
|
38
|
-
/** Shared across try (read loop) and catch (error handler). */
|
|
39
46
|
function elapsed() {
|
|
40
47
|
return (Date.now() - startTime) / 1e3;
|
|
41
48
|
}
|
|
@@ -136,11 +143,127 @@ function createStreamDownloader(logger) {
|
|
|
136
143
|
streamReader = null;
|
|
137
144
|
}
|
|
138
145
|
}
|
|
146
|
+
async function downloadHls(liveUrl, user, outputDir, maxDuration, onProgress) {
|
|
147
|
+
const filename = formatFilename(user, "ts");
|
|
148
|
+
const filepath = join(outputDir, filename);
|
|
149
|
+
logger?.info(`Recording (HLS): ${filename}`);
|
|
150
|
+
const startTime = Date.now();
|
|
151
|
+
let lastProgressTime = 0;
|
|
152
|
+
const ffmpegPath = findFfmpegPath();
|
|
153
|
+
if (!ffmpegPath) throw new Error("FFmpeg not found — required for HLS streams. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
|
|
154
|
+
const args = [
|
|
155
|
+
"-i",
|
|
156
|
+
liveUrl,
|
|
157
|
+
"-c",
|
|
158
|
+
"copy",
|
|
159
|
+
"-y",
|
|
160
|
+
filepath
|
|
161
|
+
];
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const proc = spawn(ffmpegPath, args, { stdio: [
|
|
164
|
+
"ignore",
|
|
165
|
+
"pipe",
|
|
166
|
+
"pipe"
|
|
167
|
+
] });
|
|
168
|
+
ffmpegProcess = proc;
|
|
169
|
+
let stderr = "";
|
|
170
|
+
proc.stderr?.on("data", (chunk) => {
|
|
171
|
+
stderr += chunk.toString();
|
|
172
|
+
});
|
|
173
|
+
const progressTimer = setInterval(() => {
|
|
174
|
+
if (onProgress) try {
|
|
175
|
+
const stats = statSync(filepath);
|
|
176
|
+
const elapsed = (Date.now() - startTime) / 1e3;
|
|
177
|
+
const speed = elapsed > 0 ? stats.size / elapsed : 0;
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (now - lastProgressTime >= 1e3) {
|
|
180
|
+
lastProgressTime = now;
|
|
181
|
+
onProgress({
|
|
182
|
+
bytes: stats.size,
|
|
183
|
+
elapsed,
|
|
184
|
+
speed
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
} catch {}
|
|
188
|
+
}, 1e3);
|
|
189
|
+
let maxDurationTimer = null;
|
|
190
|
+
if (maxDuration > 0) maxDurationTimer = setTimeout(() => {
|
|
191
|
+
logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`);
|
|
192
|
+
if (!proc.killed) proc.kill("SIGTERM");
|
|
193
|
+
}, maxDuration * 1e3);
|
|
194
|
+
proc.on("close", (code) => {
|
|
195
|
+
clearInterval(progressTimer);
|
|
196
|
+
if (maxDurationTimer) clearTimeout(maxDurationTimer);
|
|
197
|
+
ffmpegProcess = null;
|
|
198
|
+
if (abortFlag) {
|
|
199
|
+
try {
|
|
200
|
+
const stats = statSync(filepath);
|
|
201
|
+
const duration = (Date.now() - startTime) / 1e3;
|
|
202
|
+
logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`);
|
|
203
|
+
resolve({
|
|
204
|
+
file: filepath,
|
|
205
|
+
duration,
|
|
206
|
+
size: stats.size
|
|
207
|
+
});
|
|
208
|
+
} catch {
|
|
209
|
+
resolve({
|
|
210
|
+
file: filepath,
|
|
211
|
+
duration: 0,
|
|
212
|
+
size: 0
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const stats = statSync(filepath);
|
|
219
|
+
const duration = (Date.now() - startTime) / 1e3;
|
|
220
|
+
if (onProgress) onProgress({
|
|
221
|
+
bytes: stats.size,
|
|
222
|
+
elapsed: duration,
|
|
223
|
+
speed: duration > 0 ? stats.size / duration : 0
|
|
224
|
+
});
|
|
225
|
+
logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`);
|
|
226
|
+
resolve({
|
|
227
|
+
file: filepath,
|
|
228
|
+
duration,
|
|
229
|
+
size: stats.size
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
const msg = `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`;
|
|
233
|
+
reject(new Error(msg));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
proc.on("error", (err) => {
|
|
237
|
+
clearInterval(progressTimer);
|
|
238
|
+
if (maxDurationTimer) clearTimeout(maxDurationTimer);
|
|
239
|
+
ffmpegProcess = null;
|
|
240
|
+
reject(/* @__PURE__ */ new Error(`Failed to spawn FFmpeg: ${err.message}`));
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async function download(liveUrl, user, outputDir, maxDuration = 0, onProgress, getNextUrl) {
|
|
245
|
+
abortFlag = false;
|
|
246
|
+
ensureDir(outputDir);
|
|
247
|
+
if (isHlsUrl(liveUrl)) return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress);
|
|
248
|
+
return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl);
|
|
249
|
+
}
|
|
139
250
|
return {
|
|
140
251
|
download,
|
|
141
252
|
abort
|
|
142
253
|
};
|
|
143
254
|
}
|
|
255
|
+
/** Find FFmpeg binary via Bun.which() or PATH search. */
|
|
256
|
+
function findFfmpegPath() {
|
|
257
|
+
const bunWhich = Bun?.which;
|
|
258
|
+
if (typeof bunWhich === "function") return bunWhich("ffmpeg") ?? null;
|
|
259
|
+
const paths = process.env.PATH?.split(":") ?? [];
|
|
260
|
+
for (const dir of paths) try {
|
|
261
|
+
const full = `${dir}/ffmpeg`;
|
|
262
|
+
statSync(full);
|
|
263
|
+
return full;
|
|
264
|
+
} catch {}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
144
267
|
function writeBuffer(writer, buffer) {
|
|
145
268
|
return new Promise((resolve, reject) => {
|
|
146
269
|
writer.write(buffer, (err) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a FLV stream and writes it to disk with buffering.\n * Supports transparent reconnection when TikTok stream segments end naturally.\n */\n\nimport { type WriteStream, createWriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortFlag = false\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n\n function abort(): void {\n abortFlag = true\n streamReader?.cancel().catch(() => {})\n }\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortFlag = false\n ensureDir(outputDir)\n\n const filename = formatFilename(user, \"flv\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n /** Shared across try (read loop) and catch (error handler). */\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n try {\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n // Throttle to at most once per second to avoid flooding the UI\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info(\"Stream ended, attempting reconnection...\")\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n // Cancel old reader before opening new connection\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info(\"Reconnected successfully\")\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await timeout(reader.read(), 60_000, \"Stream read timed out\")\n chunk = result\n } catch (err) {\n // Read timeout — the TCP connection may have dropped silently.\n // Give TikTok a fresh URL if the stream is still live.\n logger?.info(\"Read timed out, attempting reconnection...\")\n if (await tryReconnect()) {\n continue\n }\n // No reconnection available — propagate the error so the caller\n // at least gets whatever bytes were buffered.\n throw err\n }\n\n if (chunk.done) {\n // TikTok stream segments are short-lived (30-60s).\n // When one ends, fetch a fresh URL and keep recording.\n if (await tryReconnect()) {\n continue\n }\n break // no reconnection → recording is truly done\n }\n\n // Accumulate into buffer. chunk.value is guaranteed defined when done=false.\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n // Flush when buffer is full\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n // Check duration limit\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n // Report progress after each chunk (throttled to 1s internally)\n reportProgress(totalBytes)\n }\n\n // Flush remaining buffer and ensure all data is on disk\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n // Final progress report\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n return { download, abort }\n}\n\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a live stream and writes it to disk with buffering.\n *\n * FLV streams: downloaded via raw fetch() with byte buffering and transparent\n * reconnection when TikTok stream segments end naturally.\n * HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport { type WriteStream, createWriteStream, statSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortFlag = false\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n let ffmpegProcess: ChildProcess | null = null\n\n function abort(): void {\n abortFlag = true\n streamReader?.cancel().catch(() => {})\n if (ffmpegProcess && !ffmpegProcess.killed) {\n ffmpegProcess.kill(\"SIGTERM\")\n }\n }\n\n /** Detect whether a URL is an HLS (.m3u8) playlist. */\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n // ─── FLV downloader (existing fetch-based path) ────────────────\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function downloadFlv(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"flv\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n try {\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info(\"Stream ended, attempting reconnection...\")\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info(\"Reconnected successfully\")\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await timeout(reader.read(), 60_000, \"Stream read timed out\")\n chunk = result\n } catch (err) {\n logger?.info(\"Read timed out, attempting reconnection...\")\n if (await tryReconnect()) {\n continue\n }\n throw err\n }\n\n if (chunk.done) {\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n reportProgress(totalBytes)\n }\n\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n // ─── HLS downloader (FFmpeg-based path) ───────────────────────\n\n async function downloadHls(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording (HLS): ${filename}`)\n\n const startTime = Date.now()\n let lastProgressTime = 0\n\n // Find FFmpeg\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found — required for HLS streams. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n // Spawn FFmpeg with the M3U8 URL as input.\n // FFmpeg handles downloading all .ts segments and concatenating them.\n const args = [\"-i\", liveUrl, \"-c\", \"copy\", \"-y\", filepath]\n\n return new Promise<DownloadResult>((resolve, reject) => {\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n ffmpegProcess = proc\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n // Poll output file size for progress reporting\n const progressTimer = setInterval(() => {\n if (onProgress) {\n try {\n const stats = statSync(filepath)\n const elapsed = (Date.now() - startTime) / 1000\n const speed = elapsed > 0 ? stats.size / elapsed : 0\n const now = Date.now()\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes: stats.size, elapsed, speed })\n }\n } catch {\n // File not created yet (FFmpeg still initializing) — skip\n }\n }\n }, 1000)\n\n // Max duration timer\n let maxDurationTimer: ReturnType<typeof setTimeout> | null = null\n if (maxDuration > 0) {\n maxDurationTimer = setTimeout(() => {\n logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`)\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }, maxDuration * 1000)\n }\n\n proc.on(\"close\", (code) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n\n if (abortFlag) {\n // Aborted by user — return whatever we have\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n resolve({ file: filepath, duration: 0, size: 0 })\n }\n return\n }\n\n // Normal completion or error\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n if (onProgress) {\n onProgress({\n bytes: stats.size,\n elapsed: duration,\n speed: duration > 0 ? stats.size / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n const msg = `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`\n reject(new Error(msg))\n }\n })\n\n proc.on(\"error\", (err) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n // ─── Main download entry point ──────────────────────────────\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortFlag = false\n ensureDir(outputDir)\n\n if (isHlsUrl(liveUrl)) {\n // HLS: FFmpeg handles playlist + segments natively.\n // Reconnection is not needed (FFmpeg refreshes the playlist internally).\n return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress)\n }\n\n // FLV: existing fetch-based path with reconnection support\n return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl)\n }\n\n return { download, abort }\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nfunction findFfmpegPath(): string | null {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n return (bunWhich(\"ffmpeg\") as string) ?? null\n }\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // not found in this directory\n }\n }\n return null\n}\n\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
|