@zfadhli/tokrec 0.11.3 → 0.11.4
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/client.mjs.map +1 -1
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/cli.mjs.map +1 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/index.mjs +13 -10
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/post-processing.mjs.map +1 -1
- package/dist/recorder/recorder-events.mjs.map +1 -1
- package/dist/ui.mjs +31 -21
- package/dist/ui.mjs.map +1 -1
- package/dist/utils.mjs +1 -1
- package/dist/utils.mjs.map +1 -1
- package/package.json +1 -1
package/dist/api/client.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"sources":["../../src/api/client.ts"],"sourcesContent":["/**\n * HTTP client — thin wrapper around wreq-js createSession.\n * Provides TLS fingerprint impersonation, cookie jar, proxy support,\n * and configurable rate limiting to prevent WAF triggering.\n */\n\nimport { createSession, type Session } from \"wreq-js\"\nimport type { RecorderConfig } from \"../config\"\nimport { createRateLimiter } from \"./rate-limiter\"\n\n// BodyInit is only available inside declare module \"bun\" in bun-types, not as a global\n// under the ES2022 lib. We explicitly import it to keep lib focused on ES2022.\ntype _BodyInit = import(\"bun\").BodyInit\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>\n post: (url: string, body?: _BodyInit, headers?: Record<string, string>) => Promise<Response>\n close: () => Promise<void>\n}\n\nconst REQUEST_TIMEOUT_MS = 15_000\n\n/** Standard browser headers to avoid TikTok WAF detection. */\nconst BROWSER_HEADERS: Record<string, string> = {\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.5\",\n \"Accept-Encoding\": \"gzip, deflate, br\",\n \"Upgrade-Insecure-Requests\": \"1\",\n \"Sec-Fetch-Dest\": \"document\",\n \"Sec-Fetch-Mode\": \"navigate\",\n \"Sec-Fetch-Site\": \"none\",\n \"Sec-Fetch-User\": \"?1\",\n}\n\nexport async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"firefox_149\",\n os: \"windows\",\n proxy: config.proxy,\n })\n\n // Seed cookies into the session jar so all subsequent requests are authenticated.\n // NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —\n // we must use session.setCookie() instead.\n // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies && \"sessionid_ss\" in config.cookies) {\n const domains = [\"https://www.tiktok.com\", \"https://webcast.tiktok.com\", \"https://m.tiktok.com\"]\n for (const domain of domains) {\n for (const [name, value] of Object.entries(config.cookies)) {\n session.setCookie(name, value, domain)\n }\n }\n }\n\n const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5)\n\n async function rateLimitedFetch(\n url: string,\n options?: { method?: string; body?: unknown; headers?: Record<string, string> },\n ): Promise<Response> {\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)\n try {\n await rateLimiter.acquire(controller.signal)\n const res = await session.fetch(url, {\n method: options?.method,\n body: options?.body as
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../../src/api/client.ts"],"sourcesContent":["/**\n * HTTP client — thin wrapper around wreq-js createSession.\n * Provides TLS fingerprint impersonation, cookie jar, proxy support,\n * and configurable rate limiting to prevent WAF triggering.\n */\n\nimport { createSession, type Session } from \"wreq-js\"\nimport type { RecorderConfig } from \"../config\"\nimport { createRateLimiter } from \"./rate-limiter\"\n\n// BodyInit is only available inside declare module \"bun\" in bun-types, not as a global\n// under the ES2022 lib. We explicitly import it to keep lib focused on ES2022.\ntype _BodyInit = import(\"bun\").BodyInit\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>\n post: (url: string, body?: _BodyInit, headers?: Record<string, string>) => Promise<Response>\n close: () => Promise<void>\n}\n\nconst REQUEST_TIMEOUT_MS = 15_000\n\n/** Standard browser headers to avoid TikTok WAF detection. */\nconst BROWSER_HEADERS: Record<string, string> = {\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.5\",\n \"Accept-Encoding\": \"gzip, deflate, br\",\n \"Upgrade-Insecure-Requests\": \"1\",\n \"Sec-Fetch-Dest\": \"document\",\n \"Sec-Fetch-Mode\": \"navigate\",\n \"Sec-Fetch-Site\": \"none\",\n \"Sec-Fetch-User\": \"?1\",\n}\n\nexport async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"firefox_149\",\n os: \"windows\",\n proxy: config.proxy,\n })\n\n // Seed cookies into the session jar so all subsequent requests are authenticated.\n // NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —\n // we must use session.setCookie() instead.\n // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies && \"sessionid_ss\" in config.cookies) {\n const domains = [\"https://www.tiktok.com\", \"https://webcast.tiktok.com\", \"https://m.tiktok.com\"]\n for (const domain of domains) {\n for (const [name, value] of Object.entries(config.cookies)) {\n session.setCookie(name, value, domain)\n }\n }\n }\n\n const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5)\n\n async function rateLimitedFetch(\n url: string,\n options?: { method?: string; body?: unknown; headers?: Record<string, string> },\n ): Promise<Response> {\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)\n try {\n await rateLimiter.acquire(controller.signal)\n const res = await session.fetch(url, {\n method: options?.method,\n body: options?.body as never,\n headers: {\n ...BROWSER_HEADERS,\n ...options?.headers,\n },\n signal: controller.signal,\n })\n return res as unknown as Response\n } finally {\n clearTimeout(timeout)\n }\n }\n\n return {\n get: async (url: string) => rateLimitedFetch(url),\n post: async (url: string, body?: _BodyInit, headers?: Record<string, string>) =>\n rateLimitedFetch(url, {\n method: \"POST\",\n body: body as never,\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n ...headers,\n },\n }),\n close: async () => {\n await session.close()\n },\n }\n}\n"],"mappings":";;;;;;;;AAoBA,MAAM,qBAAqB;;AAG3B,MAAM,kBAA0C;CAC9C,cAAc;CACd,QAAQ;CACR,mBAAmB;CACnB,mBAAmB;CACnB,6BAA6B;CAC7B,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;AACpB;AAEA,eAAsB,iBAAiB,QAA6C;CAClF,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAC;CAOD,IAAI,OAAO,WAAW,kBAAkB,OAAO,SAE7C,KAAK,MAAM,UAAU;EADJ;EAA0B;EAA8B;CAC9C,GACzB,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,OAAO,GACvD,QAAQ,UAAU,MAAM,OAAO,MAAM;CAK3C,MAAM,cAAc,kBAAkB,OAAO,iBAAiB,CAAC;CAE/D,eAAe,iBACb,KACA,SACmB;EACnB,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,UAAU,iBAAiB,WAAW,MAAM,GAAG,kBAAkB;EACvE,IAAI;GACF,MAAM,YAAY,QAAQ,WAAW,MAAM;GAU3C,OAAO,MATW,QAAQ,MAAM,KAAK;IACnC,QAAQ,SAAS;IACjB,MAAM,SAAS;IACf,SAAS;KACP,GAAG;KACH,GAAG,SAAS;IACd;IACA,QAAQ,WAAW;GACrB,CAAC;EAEH,UAAU;GACR,aAAa,OAAO;EACtB;CACF;CAEA,OAAO;EACL,KAAK,OAAO,QAAgB,iBAAiB,GAAG;EAChD,MAAM,OAAO,KAAa,MAAkB,YAC1C,iBAAiB,KAAK;GACpB,QAAQ;GACF;GACN,SAAS;IACP,gBAAgB;IAChB,GAAG;GACL;EACF,CAAC;EACH,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF"}
|
package/dist/api/tiktok.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tiktok.mjs","names":[],"sources":["../../src/api/tiktok.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs.\n *\n * Primary path: parse SIGI_STATE from the user's /live page (fast path for\n * older TikTok pages). When SIGI_STATE is absent (TikTok's new unified page\n * structure), falls back to __UNIVERSAL_DATA_FOR_REHYDRATION__ on the profile\n * page to extract the room ID, then calls the Webcast room/info API for live\n * status and stream URL.\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n *\n * Note: The room/enter Webcast endpoint has been deprecated due to frequent\n * 403 errors. All lookups go through room/info (reliable for live + offline).\n */\n\nimport type { Logger } from \"../logger\"\nimport type { HttpClient } from \"./client\"\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nconst TIKTOK_BASES = [\"https://www.tiktok.com\", \"https://m.tiktok.com\"]\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\"\n\nexport function createTikTokApi(http: HttpClient, logger?: Logger, showDebug?: boolean): TikTokApi {\n // Debug logging helper — writes to stderr only when --debug is passed.\n const debug = (msg: string) => {\n logger?.debug(msg)\n if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\\n`)\n }\n let cached: LiveInfo | null = null\n let cachedUser = \"\"\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached\n cached = await fetchLiveInfo(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = \"\"\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user)\n return info?.roomId ?? null\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive\n }\n // Use the lightweight check_alive endpoint instead of re-fetching\n // the full room/info response.\n return fetchCheckAlive(roomId)\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId)\n return info?.streamUrl ?? null\n },\n }\n\n // ─── Internal: fetch and parse SIGI_STATE / Universal Data ──\n\n /**\n * Try to fetch a TikTok user page from multiple domains.\n * www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com\n * often has different bot protection and may succeed.\n * Returns the HTML and the base URL that worked, or null if all fail.\n */\n async function tryFetchPage(\n user: string,\n path: string,\n ): Promise<{ html: string; baseUrl: string } | null> {\n for (const base of TIKTOK_BASES) {\n const url = `${base}/@${user}${path}`\n const res = await http.get(url)\n debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) continue\n\n const html = await res.text()\n // Reject Slardar WAF challenge pages (small + keyword)\n if (html.length < 5000 || html.includes(\"SlardarWAF\")) {\n debug(\n `tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes(\"SlardarWAF\")})`,\n )\n continue\n }\n\n debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`)\n return { html, baseUrl: base }\n }\n return null\n }\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n let sigi: SigiState | null = null\n let html = \"\"\n\n try {\n const page = await tryFetchPage(user, \"/live\")\n if (!page) {\n debug(\"fetchLiveInfo: all domains blocked for /live page\")\n // Fall through to universal data / profile page fallback\n html = \"\"\n } else {\n html = page.html\n debug(\n `fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n\n sigi = extractSigiState(html)\n debug(`fetchLiveInfo: SIGI_STATE ${sigi ? \"found\" : \"not found\"}`)\n\n if (sigi) {\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n debug(\n `fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? \"null\"}`,\n )\n\n // ── Primary path: liveRoom is populated (server-rendered) ──\n if (liveRoom && userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n const isLive = liveRoom.status === 2 // 2 = live, 4 = offline\n debug(\n `fetchLiveInfo: primary path — roomId=${roomId}, status=${liveRoom.status}, isLive=${isLive}`,\n )\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom)\n if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi)\n if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId)\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null }\n }\n\n // ── Fallback: userInfo has roomId, but liveRoom is async-loaded ──\n // TikTok may omit liveRoom from the server-rendered page and load it\n // via JavaScript. Query the Webcast API directly — it's authoritative.\n if (userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`)\n const roomInfo = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: Webcast API ${roomInfo ? \"returned room info\" : \"returned null\"}`)\n if (roomInfo) return roomInfo\n }\n\n // ── Legacy fallback: async-loaded SIGI_STATE ──\n debug(\"fetchLiveInfo: trying legacy fallback (UserModule)\")\n const legacy = await fetchLiveInfoFallback(sigi, user)\n debug(`fetchLiveInfo: legacy fallback ${legacy ? \"succeeded\" : \"returned null\"}`)\n if (legacy) return legacy\n } // end if (sigi)\n } // end else (page loaded)\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n debug(`fetchLiveInfo: caught error — ${_err instanceof Error ? _err.message : String(_err)}`)\n return null\n }\n\n // ── API fallback: when page scraping fails (WAF), use TikTok API directly ──\n // The /api-live/user/room/ endpoint returns roomId + live status without\n // the Slardar WAF that blocks www.tiktok.com HTML pages.\n if (!html || html.length < 5000) {\n debug(\"fetchLiveInfo: trying API fallback (api-live/user/room)\")\n const apiResult = await fetchLiveInfoFromApi(user)\n if (apiResult) return apiResult\n }\n\n // ── New fallback: TikTok's unified page structure ──\n // TikTok migrated from SIGI_STATE to __UNIVERSAL_DATA_FOR_REHYDRATION__.\n // The /live page may not have it, so try the main profile page.\n debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`)\n const { roomId } = extractUserFromUniversalData(html)\n debug(`fetchLiveInfo: universal data roomId=${roomId ?? \"null\"}`)\n if (roomId) {\n // If the /live page has the roomId, use room/info (reliable)\n const result = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: universal data Webcast API ${result ? \"succeeded\" : \"returned null\"}`)\n if (result) return result\n }\n // No roomId available — fall through to profile page to check for\n // __UNIVERSAL_DATA_FOR_REHYDRATION__ which may have the roomId when live.\n\n // Final fallback: fetch the main profile page for webapp.user-detail\n debug(\"fetchLiveInfo: trying profile page fallback\")\n const profileResult = await fetchLiveInfoFromProfile(user)\n debug(`fetchLiveInfo: profile page ${profileResult ? \"succeeded\" : \"returned null\"}`)\n return profileResult\n }\n\n /**\n * Fetch the main profile page to extract user info from\n * __UNIVERSAL_DATA_FOR_REHYDRATION__. When the user is live, their\n * roomId is embedded in the data, allowing us to fetch room info and\n * stream URL via the /webcast/room/info/ endpoint (which works\n * reliably). If no roomId is present the user is offline.\n */\n async function fetchLiveInfoFromProfile(user: string): Promise<LiveInfo | null> {\n try {\n const page = await tryFetchPage(user, \"\")\n if (!page) {\n debug(\"fetchLiveInfoFromProfile: all domains blocked for profile page\")\n return null\n }\n debug(\n `fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n const { userId, roomId } = extractUserFromUniversalData(page.html)\n if (!userId) return null\n\n // If roomId is embedded in the profile page, the user is live.\n // Use room/info which works reliably for both live and offline rooms.\n if (roomId) {\n return fetchRoomInfoFromRoomApi(roomId)\n }\n\n // No roomId means the user is offline (room/enter was unreliable).\n return null\n } catch {\n return null\n }\n }\n\n /**\n * Fetch live info from TikTok's internal API endpoint.\n * This bypasses the Slardar WAF that blocks www.tiktok.com HTML pages.\n *\n * Endpoint: /api-live/user/room/?aid=1988&uniqueId=${user}&sourceType=54\n * Returns the roomId, user status (2 = live), and other user metadata.\n * When the user is live, also queries the Webcast API for a stream URL.\n */\n async function fetchLiveInfoFromApi(user: string): Promise<LiveInfo | null> {\n try {\n const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(user)}&sourceType=54`\n const res = await http.get(url)\n debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n const userData = (data?.data as Record<string, unknown> | undefined)?.user as\n | { roomId?: string; status?: number; uniqueId?: string }\n | undefined\n\n if (!userData?.roomId) {\n debug(\"fetchLiveInfoFromApi: no roomId in API response\")\n return null\n }\n\n const roomId = String(userData.roomId)\n // status: 2 = live, 4 = offline, 1 = ...\n const isLive = userData.status === 2\n debug(`fetchLiveInfoFromApi: roomId=${roomId}, status=${userData.status}, isLive=${isLive}`)\n\n if (!isLive) {\n return { roomId, isLive: false, streamUrl: null, title: null }\n }\n\n // Query Webcast API for stream URL\n const streamUrl = await fetchStreamUrlFromApi(roomId)\n return { roomId, isLive: true, streamUrl, title: null }\n } catch {\n return null\n }\n }\n\n /**\n * Shared boilerplate for Webcast room/info API calls.\n * Handles URL construction, HTTP GET, JSON parsing, status code checks,\n * and error handling. The caller provides an extractor for the response data.\n */\n async function fetchFromRoomApi<T>(\n roomId: string,\n extract: (data: Record<string, unknown>) => T | null,\n ): Promise<T | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null\n\n return extract(data)\n } catch {\n return null\n }\n }\n\n /**\n * Check if a room is currently alive using the lightweight\n * /webcast/room/check_alive/ endpoint.\n *\n * This is faster and cheaper than fetching the full room/info response.\n * Returns `true` if the room is live, `false` otherwise (including errors).\n *\n * Endpoint: /webcast/room/check_alive/?aid=1988®ion=CH&room_ids={id}&user_is_login=true\n * Response: { \"data\": [{ \"alive\": true/false, \"room_id\": \"...\" }] }\n */\n async function fetchCheckAlive(roomId: string): Promise<boolean> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988®ion=CH&room_ids=${roomId}&user_is_login=true`\n const res = await http.get(url)\n debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return false\n\n const text = await res.text()\n debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return false\n\n const data = JSON.parse(text) as Record<string, unknown>\n const items = data?.data as Array<Record<string, unknown>> | undefined\n const alive = items?.[0]?.alive === true\n debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)\n return alive\n } catch (err) {\n debug(\n `fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,\n )\n return false\n }\n }\n\n /**\n * Fetch room info from the Webcast room/info API. Requires a known roomId.\n */\n async function fetchRoomInfoFromRoomApi(roomId: string): Promise<LiveInfo | null> {\n return fetchFromRoomApi(roomId, (data) => {\n const roomData = (data as any).data\n if (!roomData) return null\n\n const status = roomData.status ?? 4\n const isLive = status === 2\n const streamUrl = isLive ? findStreamUrlRecursively(roomData) : null\n\n return { roomId, isLive, streamUrl, title: roomData.title ?? null }\n })\n }\n\n /**\n * Secondary live-info extraction when SIGI_STATE's LiveRoom data is\n * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and\n * UserModule.users[user].roomId for the room ID. Stream URL is obtained\n * from the Webcast API (room/info/) as a final fallback.\n */\n async function fetchLiveInfoFallback(sigi: SigiState, user: string): Promise<LiveInfo | null> {\n const liveRoomStatus = sigi.LiveRoom?.liveRoomStatus\n const isLive = liveRoomStatus === 2 // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[user]\n let roomId: string | null = null\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId)\n }\n\n // If we have a roomId, use Webcast room/info for the stream URL\n if (roomId) {\n const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null\n return { roomId, isLive, streamUrl, title: null }\n }\n\n // No roomId available — treat as offline (room/enter was unreliable).\n return null\n }\n\n /**\n * Fetch stream URL from the Webcast API.\n * Covers cases where SIGI_STATE doesn't include stream data.\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string\n isLive: boolean\n streamUrl: string | null\n title: string | null\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n id?: string | number\n roomId?: string | number\n }\n liveRoom?: {\n status?: number // 2 = live, 4 = offline\n title?: string\n startTime?: number\n streamData?: {\n pull_data?: {\n stream_data?: string // JSON string with FLV/HLS URLs\n }\n }\n }\n }\n }\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string // numeric user ID\n uniqueId?: string\n roomId?: string | { id: string }\n }\n }\n }\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\n/**\n * @public Exported for testing.\n */\nexport function extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n/**\n * Extract the numeric user ID and optional room ID from TikTok's new\n * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).\n *\n * The profile page (/@user) includes \"webapp.user-detail\" which\n * contains the user's numeric ID. When the user is live, the roomId\n * is also embedded here.\n *\n * Returns { userId, roomId } where userId is always set when the data\n * is found, and roomId is set only when the user is currently live.\n *\n * @public Exported for testing and fixture capture.\n */\nexport function extractUserFromUniversalData(html: string): {\n userId: string | null\n roomId: string | null\n} {\n const match = html.match(\n /<script\\s+id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n )\n if (!match?.[1]) return { userId: null, roomId: null }\n try {\n const data = JSON.parse(match[1])\n const scope = (data as any).__DEFAULT_SCOPE__ ?? data\n const userDetail = scope?.[\"webapp.user-detail\"]\n const user = userDetail?.userInfo?.user\n const userId = user?.id ? String(user.id) : null\n const roomId = user?.roomId ? String(user.roomId) : null\n return { userId, roomId }\n } catch {\n return { userId: null, roomId: null }\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n *\n * Prefers FLV (lower latency) over HLS/other protocols.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<NonNullable<SigiState[\"LiveRoom\"]>[\"liveRoomUserInfo\"]>[\"liveRoom\"],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data\n if (!raw) return null\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined\n if (!data) return null\n\n // Try all known quality keys, preferring higher quality.\n // FLV is preferred (lower latency); HLS is the fallback.\n const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"]\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined\n // Prefer FLV over HLS when both are available\n const url = entry?.main?.flv ?? entry?.main?.hls\n if (url) return url\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Prefers FLV over HLS when both are present in the response.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Collect all candidate URLs with their preference\n const flvUrls: string[] = []\n const m3u8Urls: string[] = []\n\n function walk(value: unknown): void {\n if (typeof value === \"string\") {\n // Direct URL match\n if (\n (value.startsWith(\"http://\") || value.startsWith(\"https://\")) &&\n (value.includes(\".flv\") || value.includes(\".m3u8\"))\n ) {\n if (value.includes(\".flv\")) {\n flvUrls.push(value)\n } else {\n m3u8Urls.push(value)\n }\n return\n }\n // JSON-encoded string — parse and recurse\n if (value.startsWith(\"{\") || value.startsWith(\"[\")) {\n try {\n walk(JSON.parse(value))\n } catch {\n // skip\n }\n }\n return\n }\n\n if (value && typeof value === \"object\") {\n if (Array.isArray(value)) {\n for (const item of value) {\n walk(item)\n }\n } else {\n for (const val of Object.values(value as Record<string, unknown>)) {\n walk(val)\n }\n }\n }\n }\n\n walk(obj)\n\n // Prefer FLV (lower latency) over HLS\n if (flvUrls.length > 0) return flvUrls[0]!\n if (m3u8Urls.length > 0) return m3u8Urls[0]!\n return null\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tiktok.mjs","names":[],"sources":["../../src/api/tiktok.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs.\n *\n * Primary path: parse SIGI_STATE from the user's /live page (fast path for\n * older TikTok pages). When SIGI_STATE is absent (TikTok's new unified page\n * structure), falls back to __UNIVERSAL_DATA_FOR_REHYDRATION__ on the profile\n * page to extract the room ID, then calls the Webcast room/info API for live\n * status and stream URL.\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n *\n * Note: The room/enter Webcast endpoint has been deprecated due to frequent\n * 403 errors. All lookups go through room/info (reliable for live + offline).\n */\n\nimport type { Logger } from \"../logger\"\nimport type { HttpClient } from \"./client\"\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nconst TIKTOK_BASES = [\"https://www.tiktok.com\", \"https://m.tiktok.com\"]\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\"\n\nexport function createTikTokApi(http: HttpClient, logger?: Logger, showDebug?: boolean): TikTokApi {\n // Debug logging helper — writes to stderr only when --debug is passed.\n const debug = (msg: string) => {\n logger?.debug(msg)\n if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\\n`)\n }\n let cached: LiveInfo | null = null\n let cachedUser = \"\"\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached\n cached = await fetchLiveInfo(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = \"\"\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user)\n return info?.roomId ?? null\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive\n }\n // Use the lightweight check_alive endpoint instead of re-fetching\n // the full room/info response.\n return fetchCheckAlive(roomId)\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId)\n return info?.streamUrl ?? null\n },\n }\n\n // ─── Internal: fetch and parse SIGI_STATE / Universal Data ──\n\n /**\n * Try to fetch a TikTok user page from multiple domains.\n * www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com\n * often has different bot protection and may succeed.\n * Returns the HTML and the base URL that worked, or null if all fail.\n */\n async function tryFetchPage(\n user: string,\n path: string,\n ): Promise<{ html: string; baseUrl: string } | null> {\n for (const base of TIKTOK_BASES) {\n const url = `${base}/@${user}${path}`\n const res = await http.get(url)\n debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) continue\n\n const html = await res.text()\n // Reject Slardar WAF challenge pages (small + keyword)\n if (html.length < 5000 || html.includes(\"SlardarWAF\")) {\n debug(\n `tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes(\"SlardarWAF\")})`,\n )\n continue\n }\n\n debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`)\n return { html, baseUrl: base }\n }\n return null\n }\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n let sigi: SigiState | null = null\n let html = \"\"\n\n try {\n const page = await tryFetchPage(user, \"/live\")\n if (!page) {\n debug(\"fetchLiveInfo: all domains blocked for /live page\")\n // Fall through to universal data / profile page fallback\n html = \"\"\n } else {\n html = page.html\n debug(\n `fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n\n sigi = extractSigiState(html)\n debug(`fetchLiveInfo: SIGI_STATE ${sigi ? \"found\" : \"not found\"}`)\n\n if (sigi) {\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n debug(\n `fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? \"null\"}`,\n )\n\n // ── Primary path: liveRoom is populated (server-rendered) ──\n if (liveRoom && userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n const isLive = liveRoom.status === 2 // 2 = live, 4 = offline\n debug(\n `fetchLiveInfo: primary path — roomId=${roomId}, status=${liveRoom.status}, isLive=${isLive}`,\n )\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom)\n if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi)\n if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId)\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null }\n }\n\n // ── Fallback: userInfo has roomId, but liveRoom is async-loaded ──\n // TikTok may omit liveRoom from the server-rendered page and load it\n // via JavaScript. Query the Webcast API directly — it's authoritative.\n if (userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`)\n const roomInfo = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: Webcast API ${roomInfo ? \"returned room info\" : \"returned null\"}`)\n if (roomInfo) return roomInfo\n }\n\n // ── Legacy fallback: async-loaded SIGI_STATE ──\n debug(\"fetchLiveInfo: trying legacy fallback (UserModule)\")\n const legacy = await fetchLiveInfoFallback(sigi, user)\n debug(`fetchLiveInfo: legacy fallback ${legacy ? \"succeeded\" : \"returned null\"}`)\n if (legacy) return legacy\n } // end if (sigi)\n } // end else (page loaded)\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n debug(`fetchLiveInfo: caught error — ${_err instanceof Error ? _err.message : String(_err)}`)\n return null\n }\n\n // ── API fallback: when page scraping fails (WAF), use TikTok API directly ──\n // The /api-live/user/room/ endpoint returns roomId + live status without\n // the Slardar WAF that blocks www.tiktok.com HTML pages.\n if (!html || html.length < 5000) {\n debug(\"fetchLiveInfo: trying API fallback (api-live/user/room)\")\n const apiResult = await fetchLiveInfoFromApi(user)\n if (apiResult) return apiResult\n }\n\n // ── New fallback: TikTok's unified page structure ──\n // TikTok migrated from SIGI_STATE to __UNIVERSAL_DATA_FOR_REHYDRATION__.\n // The /live page may not have it, so try the main profile page.\n debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`)\n const { roomId } = extractUserFromUniversalData(html)\n debug(`fetchLiveInfo: universal data roomId=${roomId ?? \"null\"}`)\n if (roomId) {\n // If the /live page has the roomId, use room/info (reliable)\n const result = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: universal data Webcast API ${result ? \"succeeded\" : \"returned null\"}`)\n if (result) return result\n }\n // No roomId available — fall through to profile page to check for\n // __UNIVERSAL_DATA_FOR_REHYDRATION__ which may have the roomId when live.\n\n // Final fallback: fetch the main profile page for webapp.user-detail\n debug(\"fetchLiveInfo: trying profile page fallback\")\n const profileResult = await fetchLiveInfoFromProfile(user)\n debug(`fetchLiveInfo: profile page ${profileResult ? \"succeeded\" : \"returned null\"}`)\n return profileResult\n }\n\n /**\n * Fetch the main profile page to extract user info from\n * __UNIVERSAL_DATA_FOR_REHYDRATION__. When the user is live, their\n * roomId is embedded in the data, allowing us to fetch room info and\n * stream URL via the /webcast/room/info/ endpoint (which works\n * reliably). If no roomId is present the user is offline.\n */\n async function fetchLiveInfoFromProfile(user: string): Promise<LiveInfo | null> {\n try {\n const page = await tryFetchPage(user, \"\")\n if (!page) {\n debug(\"fetchLiveInfoFromProfile: all domains blocked for profile page\")\n return null\n }\n debug(\n `fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n const { userId, roomId } = extractUserFromUniversalData(page.html)\n if (!userId) return null\n\n // If roomId is embedded in the profile page, the user is live.\n // Use room/info which works reliably for both live and offline rooms.\n if (roomId) {\n return fetchRoomInfoFromRoomApi(roomId)\n }\n\n // No roomId means the user is offline (room/enter was unreliable).\n return null\n } catch {\n return null\n }\n }\n\n /**\n * Fetch live info from TikTok's internal API endpoint.\n * This bypasses the Slardar WAF that blocks www.tiktok.com HTML pages.\n *\n * Endpoint: /api-live/user/room/?aid=1988&uniqueId=${user}&sourceType=54\n * Returns the roomId, user status (2 = live), and other user metadata.\n * When the user is live, also queries the Webcast API for a stream URL.\n */\n async function fetchLiveInfoFromApi(user: string): Promise<LiveInfo | null> {\n try {\n const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(user)}&sourceType=54`\n const res = await http.get(url)\n debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n const userData = (data?.data as Record<string, unknown> | undefined)?.user as\n | { roomId?: string; status?: number; uniqueId?: string }\n | undefined\n\n if (!userData?.roomId) {\n debug(\"fetchLiveInfoFromApi: no roomId in API response\")\n return null\n }\n\n const roomId = String(userData.roomId)\n // status: 2 = live, 4 = offline, 1 = ...\n const isLive = userData.status === 2\n debug(`fetchLiveInfoFromApi: roomId=${roomId}, status=${userData.status}, isLive=${isLive}`)\n\n if (!isLive) {\n return { roomId, isLive: false, streamUrl: null, title: null }\n }\n\n // Query Webcast API for stream URL\n const streamUrl = await fetchStreamUrlFromApi(roomId)\n return { roomId, isLive: true, streamUrl, title: null }\n } catch {\n return null\n }\n }\n\n /**\n * Shared boilerplate for Webcast room/info API calls.\n * Handles URL construction, HTTP GET, JSON parsing, status code checks,\n * and error handling. The caller provides an extractor for the response data.\n */\n async function fetchFromRoomApi<T>(\n roomId: string,\n extract: (data: Record<string, unknown>) => T | null,\n ): Promise<T | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as { status_code?: unknown }).status_code === 4003110) return null\n\n return extract(data)\n } catch {\n return null\n }\n }\n\n /**\n * Check if a room is currently alive using the lightweight\n * /webcast/room/check_alive/ endpoint.\n *\n * This is faster and cheaper than fetching the full room/info response.\n * Returns `true` if the room is live, `false` otherwise (including errors).\n *\n * Endpoint: /webcast/room/check_alive/?aid=1988®ion=CH&room_ids={id}&user_is_login=true\n * Response: { \"data\": [{ \"alive\": true/false, \"room_id\": \"...\" }] }\n */\n async function fetchCheckAlive(roomId: string): Promise<boolean> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988®ion=CH&room_ids=${roomId}&user_is_login=true`\n const res = await http.get(url)\n debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return false\n\n const text = await res.text()\n debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return false\n\n const data = JSON.parse(text) as Record<string, unknown>\n const items = data?.data as Array<Record<string, unknown>> | undefined\n const alive = items?.[0]?.alive === true\n debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)\n return alive\n } catch (err) {\n debug(\n `fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,\n )\n return false\n }\n }\n\n /**\n * Fetch room info from the Webcast room/info API. Requires a known roomId.\n */\n async function fetchRoomInfoFromRoomApi(roomId: string): Promise<LiveInfo | null> {\n return fetchFromRoomApi(roomId, (data) => {\n const roomData = (data as { data?: Record<string, unknown> }).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 as string | undefined) ?? null }\n })\n }\n\n /**\n * Secondary live-info extraction when SIGI_STATE's LiveRoom data is\n * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and\n * UserModule.users[user].roomId for the room ID. Stream URL is obtained\n * from the Webcast API (room/info/) as a final fallback.\n */\n async function fetchLiveInfoFallback(sigi: SigiState, user: string): Promise<LiveInfo | null> {\n const liveRoomStatus = sigi.LiveRoom?.liveRoomStatus\n const isLive = liveRoomStatus === 2 // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[user]\n let roomId: string | null = null\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId)\n }\n\n // If we have a roomId, use Webcast room/info for the stream URL\n if (roomId) {\n const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null\n return { roomId, isLive, streamUrl, title: null }\n }\n\n // No roomId available — treat as offline (room/enter was unreliable).\n return null\n }\n\n /**\n * Fetch stream URL from the Webcast API.\n * Covers cases where SIGI_STATE doesn't include stream data.\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string\n isLive: boolean\n streamUrl: string | null\n title: string | null\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n id?: string | number\n roomId?: string | number\n }\n liveRoom?: {\n status?: number // 2 = live, 4 = offline\n title?: string\n startTime?: number\n streamData?: {\n pull_data?: {\n stream_data?: string // JSON string with FLV/HLS URLs\n }\n }\n }\n }\n }\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string // numeric user ID\n uniqueId?: string\n roomId?: string | { id: string }\n }\n }\n }\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\n/**\n * @public Exported for testing.\n */\nexport function extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n/**\n * Extract the numeric user ID and optional room ID from TikTok's new\n * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).\n *\n * The profile page (/@user) includes \"webapp.user-detail\" which\n * contains the user's numeric ID. When the user is live, the roomId\n * is also embedded here.\n *\n * Returns { userId, roomId } where userId is always set when the data\n * is found, and roomId is set only when the user is currently live.\n *\n * @public Exported for testing and fixture capture.\n */\nexport function extractUserFromUniversalData(html: string): {\n userId: string | null\n roomId: string | null\n} {\n const match = html.match(\n /<script\\s+id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n )\n if (!match?.[1]) return { userId: null, roomId: null }\n try {\n const data = JSON.parse(match[1])\n const scope = (data as Record<string, unknown>).__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] as string\n if (m3u8Urls.length > 0) return m3u8Urls[0] as string\n return null\n}\n"],"mappings":"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"}
|
package/dist/cli.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI(\"tokrec\", pkg.version).description(\n \"Minimal TikTok live stream recorder — Bun + TypeScript\",\n )\n\n cli.command(\"\", \"Record a TikTok live stream\", (cmd) => {\n cmd.option(\"-u, --user <name>\", \"TikTok username (or pass as first argument)\")\n cmd.option(\"-o, --output <dir>\", \"Output directory\", {\n default: \"./recordings\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\n \"-s, --segment-minutes <minutes>\",\n \"Segment duration in minutes (default: disabled). Split recording into segments of this length.\",\n )\n cmd.option(\"--normalize\", \"Normalize audio loudness (EBU R128)\")\n cmd.option(\"--normalize-loudness <num>\", \"Target loudness in LUFS (default: -14)\")\n cmd.option(\"--normalize-codec <name>\", \"Audio codec for normalized output (default: libopus)\")\n cmd.option(\"--normalize-bitrate <str>\", \"Audio bitrate for normalized output (default: 96k)\")\n cmd.option(\"--rate <n>\", \"Max API requests per second (default: 5). Set 0 for unlimited.\")\n cmd.option(\"--debug\", \"Show API debug logging on stderr for troubleshooting\")\n\n cmd.action((opts: Record<string, unknown>) => {\n try {\n const user = opts.user as string | undefined\n if (!user) {\n throw new TikTokError(\"config-error\", \"<user> is required (positional or --user)\")\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be a number >= 1\")\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be a number >= 0 (minutes)\")\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = [\"debug\", \"info\", \"warn\", \"error\"]\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\n \"config-error\",\n `--log-level must be one of: ${levels.join(\", \")}`,\n )\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--segment-minutes must be a number >= 0\")\n }\n parsed.segmentMinutes = n\n }\n\n if (opts.normalize !== undefined) parsed.normalizeAudio = opts.normalize as boolean\n if (opts.normalizeLoudness !== undefined) {\n const n = Number(opts.normalizeLoudness)\n if (!Number.isFinite(n)) {\n throw new TikTokError(\"config-error\", \"--normalize-loudness must be a number\")\n }\n parsed.normalizeLoudness = n\n }\n if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec as string\n if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate as string\n if (opts.rate !== undefined) {\n const n = Number(opts.rate)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--rate must be a number >= 0\")\n }\n parsed.ratePerSecond = n\n }\n if (opts.debug) parsed.debug = true\n\n config = parsed\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n })\n })\n\n // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push(\"--user\", positionalUser)\n }\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError(\"config-error\", error)\n }\n\n if (!config) {\n throw new TikTokError(\n \"config-error\",\n \"<user> is required (positional or --user). Use --help for usage.\",\n )\n }\n\n return config\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i]
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI(\"tokrec\", pkg.version).description(\n \"Minimal TikTok live stream recorder — Bun + TypeScript\",\n )\n\n cli.command(\"\", \"Record a TikTok live stream\", (cmd) => {\n cmd.option(\"-u, --user <name>\", \"TikTok username (or pass as first argument)\")\n cmd.option(\"-o, --output <dir>\", \"Output directory\", {\n default: \"./recordings\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\n \"-s, --segment-minutes <minutes>\",\n \"Segment duration in minutes (default: disabled). Split recording into segments of this length.\",\n )\n cmd.option(\"--normalize\", \"Normalize audio loudness (EBU R128)\")\n cmd.option(\"--normalize-loudness <num>\", \"Target loudness in LUFS (default: -14)\")\n cmd.option(\"--normalize-codec <name>\", \"Audio codec for normalized output (default: libopus)\")\n cmd.option(\"--normalize-bitrate <str>\", \"Audio bitrate for normalized output (default: 96k)\")\n cmd.option(\"--rate <n>\", \"Max API requests per second (default: 5). Set 0 for unlimited.\")\n cmd.option(\"--debug\", \"Show API debug logging on stderr for troubleshooting\")\n\n cmd.action((opts: Record<string, unknown>) => {\n try {\n const user = opts.user as string | undefined\n if (!user) {\n throw new TikTokError(\"config-error\", \"<user> is required (positional or --user)\")\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be a number >= 1\")\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be a number >= 0 (minutes)\")\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = [\"debug\", \"info\", \"warn\", \"error\"]\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\n \"config-error\",\n `--log-level must be one of: ${levels.join(\", \")}`,\n )\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--segment-minutes must be a number >= 0\")\n }\n parsed.segmentMinutes = n\n }\n\n if (opts.normalize !== undefined) parsed.normalizeAudio = opts.normalize as boolean\n if (opts.normalizeLoudness !== undefined) {\n const n = Number(opts.normalizeLoudness)\n if (!Number.isFinite(n)) {\n throw new TikTokError(\"config-error\", \"--normalize-loudness must be a number\")\n }\n parsed.normalizeLoudness = n\n }\n if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec as string\n if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate as string\n if (opts.rate !== undefined) {\n const n = Number(opts.rate)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--rate must be a number >= 0\")\n }\n parsed.ratePerSecond = n\n }\n if (opts.debug) parsed.debug = true\n\n config = parsed\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n })\n })\n\n // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push(\"--user\", positionalUser)\n }\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError(\"config-error\", error)\n }\n\n if (!config) {\n throw new TikTokError(\n \"config-error\",\n \"<user> is required (positional or --user). Use --help for usage.\",\n )\n }\n\n return config\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i] as string\n // -- signals end of options; anything after is a positional\n if (arg === \"--\") {\n const pos = argv.splice(i, 1)[0] as string\n return pos\n }\n // If this arg is a flag (-*) the next arg is its value\n if (arg.startsWith(\"-\")) {\n // Skip the value if this flag takes one (not a boolean flag like --help/--version)\n const next = argv[i + 1]\n if (next && !next.startsWith(\"-\") && arg !== \"--help\" && arg !== \"--version\") {\n i++ // skip the value\n }\n continue\n }\n // Found a bare positional — remove it and return\n argv.splice(i, 1)\n return arg\n }\n return null\n}\n"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -3,7 +3,6 @@ import { TikTokError, validateConfig } from "./config.mjs";
|
|
|
3
3
|
import { bytesToHuman, relativeTime } from "./utils.mjs";
|
|
4
4
|
import { createRecorder } from "./recorder/index.mjs";
|
|
5
5
|
import "./lib.mjs";
|
|
6
|
-
import { version } from "./package.mjs";
|
|
7
6
|
import { extractTikTokCookiesFromFirefox } from "./browser-cookies.mjs";
|
|
8
7
|
import { parseArgs } from "./cli.mjs";
|
|
9
8
|
import { createDisplay } from "./ui.mjs";
|
|
@@ -43,7 +42,6 @@ async function main() {
|
|
|
43
42
|
config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
|
|
44
43
|
config.logConsole = false;
|
|
45
44
|
const display = createDisplay();
|
|
46
|
-
display.showVersion("tokrec", version);
|
|
47
45
|
if (config.cookies) {
|
|
48
46
|
const source = firefoxCookies ? "Firefox" : "cookies.json";
|
|
49
47
|
const names = Object.keys(config.cookies);
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { extractTikTokCookiesFromFirefox } from \"./browser-cookies\"\nimport { parseArgs } from \"./cli\"\nimport type { RecorderConfig } from \"./config\"\nimport { TikTokError, validateConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./utils\"\n\nasync function loadCookies(cookiesPath?: string): Promise<Record<string, string> | undefined> {\n const path = cookiesPath ?? join(process.cwd(), \"cookies.json\")\n if (!existsSync(path)) {\n return undefined\n }\n try {\n const content = readFileSync(path, \"utf-8\")\n const data = JSON.parse(content) as Record<string, string | undefined>\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as Record<string, string>\n }\n return undefined\n } catch {\n return undefined\n }\n}\n\nasync function main(): Promise<void> {\n let config: RecorderConfig\n\n try {\n config = parseArgs()\n validateConfig(config)\n } catch (err) {\n if (err instanceof TikTokError && err.kind === \"config-error\") {\n console.error(`${color.red(\"Error:\")} ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n\n // Load cookies: try Firefox browser first, then fall back to cookies.json\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n config.cookies = firefoxCookies ?? (await loadCookies(config.cookiesPath))\n\n // Suppress internal logger's console output — the Display owns the terminal\n config.logConsole = false\n\n // Beautiful terminal display\n const display = createDisplay()\n\n if (config.cookies) {\n const source = firefoxCookies ? \"Firefox\" : \"cookies.json\"\n const names = Object.keys(config.cookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n display.showInfo(`${source} cookies loaded${detail}`)\n } else {\n display.showWarning(\n \"No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json\",\n )\n }\n\n const recorder = createRecorder(config)\n\n let lastWasLive: boolean | null = null\n let firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download:end\", (info) => {\n const parsed = info.file.split(\"/\").pop() ?? info.file\n const sizeStr = bytesToHuman(info.size)\n display.finishRecording(parsed, info.duration, sizeStr)\n })\n\n recorder.on(\"segmenting:start\", () => {\n display.startSegmenting()\n })\n\n recorder.on(\"segmenting:end\", (info) => {\n display.segmentsCreated(info.segments)\n })\n\n recorder.on(\"converting:start\", () => {\n display.startConverting()\n })\n\n recorder.on(\"converted\", (info) => {\n const parsed = info.output.split(\"/\").pop() ?? info.output\n display.conversionDone(parsed)\n })\n\n recorder.on(\"recording:end\", () => {\n // For simple conversion (no segmenting), recording:end fires once.\n // For segmenting path, segmenting:end already acknowledges the result.\n // This is a no-op since we handle the terminal output through download:end\n // and segmenting:end / converted events.\n })\n\n recorder.on(\"error\", (err) => {\n display.showError(`[${err.kind}] ${err.message}`)\n })\n\n recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) return // ignore duplicate signals (e.g. SIGINT + SIGTERM from one Ctrl-C)\n stopping = true\n // Remove signal handlers so they don't accumulate if main() is called again\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n display.showInfo(\"Shutting down gracefully...\")\n await recorder.stop()\n display.stop()\n process.exit(0)\n }\n\n process.on(\"SIGINT\", handleSignal)\n process.on(\"SIGTERM\", handleSignal)\n process.on(\"SIGHUP\", handleSignal)\n\n // Show initial status and start the recorder\n display.pollingStarted(config.interval ?? 3)\n try {\n await recorder.start()\n } catch (err) {\n if (err instanceof TikTokError) {\n display.showError(`[${err.kind}] ${err.message}`)\n } else {\n display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`)\n }\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain().catch((err) => {\n console.error(\"Fatal:\", err instanceof Error ? err.message : String(err))\n process.exit(1)\n})\n"],"mappings":"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"}
|
package/dist/package.mjs
CHANGED
package/dist/recorder/index.mjs
CHANGED
|
@@ -108,11 +108,14 @@ function createRecorder(config) {
|
|
|
108
108
|
logger,
|
|
109
109
|
onTick: async () => {
|
|
110
110
|
if (stopRequested) return;
|
|
111
|
+
const tikTokApi = api;
|
|
112
|
+
const streamDownloader = downloader;
|
|
113
|
+
const pollingMonitor = monitor;
|
|
111
114
|
const user = cfg.user;
|
|
112
|
-
|
|
115
|
+
tikTokApi.invalidateCache();
|
|
113
116
|
emitter.emit("checking", { user });
|
|
114
117
|
logger.info(`Checking @${user}...`);
|
|
115
|
-
const roomId = await
|
|
118
|
+
const roomId = await tikTokApi.getRoomId(user);
|
|
116
119
|
if (!roomId) {
|
|
117
120
|
logger.info(`@${user} is offline`);
|
|
118
121
|
emitter.emit("tick", {
|
|
@@ -121,7 +124,7 @@ function createRecorder(config) {
|
|
|
121
124
|
});
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
|
-
const liveUrl = await
|
|
127
|
+
const liveUrl = await tikTokApi.getLiveUrl(roomId);
|
|
125
128
|
if (!liveUrl) {
|
|
126
129
|
logger.warn(`@${user} is live but no stream URL found`);
|
|
127
130
|
emitter.emit("tick", {
|
|
@@ -143,14 +146,14 @@ function createRecorder(config) {
|
|
|
143
146
|
file: ""
|
|
144
147
|
});
|
|
145
148
|
const getNextUrl = async () => {
|
|
146
|
-
if (!await
|
|
149
|
+
if (!await tikTokApi.isRoomAlive(roomId)) {
|
|
147
150
|
logger.info(`@${user} is no longer live — stopping`);
|
|
148
151
|
return null;
|
|
149
152
|
}
|
|
150
|
-
|
|
151
|
-
return
|
|
153
|
+
tikTokApi.invalidateCache();
|
|
154
|
+
return tikTokApi.getLiveUrl(roomId);
|
|
152
155
|
};
|
|
153
|
-
const result = await
|
|
156
|
+
const result = await streamDownloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emitter.emit("download:progress", info), getNextUrl);
|
|
154
157
|
emitter.emit("download:end", {
|
|
155
158
|
file: result.file,
|
|
156
159
|
duration: result.duration,
|
|
@@ -162,16 +165,16 @@ function createRecorder(config) {
|
|
|
162
165
|
audioNormalizer,
|
|
163
166
|
stopAbortController,
|
|
164
167
|
pendingRemuxes,
|
|
165
|
-
emit:
|
|
168
|
+
emit: emitter.emit.bind(emitter),
|
|
166
169
|
setState: stateManager.setState,
|
|
167
170
|
stopRequested: () => stopRequested,
|
|
168
171
|
logger
|
|
169
172
|
});
|
|
170
173
|
if (cfg.duration > 0) {
|
|
171
174
|
logger.info("Duration limit reached — exiting");
|
|
172
|
-
|
|
175
|
+
pollingMonitor.stopAfterCurrentTick();
|
|
173
176
|
} else if (!stopRequested && roomId) {
|
|
174
|
-
if (!await
|
|
177
|
+
if (!await tikTokApi.isRoomAlive(roomId)) {
|
|
175
178
|
logger.info(`@${user} is offline`);
|
|
176
179
|
emitter.emit("tick", {
|
|
177
180
|
user,
|
|
@@ -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\nimport { spawn } from \"node:child_process\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport type { RecorderConfig, RecorderController } from \"../config\"\nimport { normalizeConfig, TikTokError } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { findFfmpegPath, sleep } from \"../utils\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { processRecording } from \"./post-processing\"\nimport { createEventEmitter } from \"./recorder-events\"\nimport { createRecorderState } from \"./recorder-state\"\nimport type { DownloadResult } from \"./stream\"\nimport { createStreamDownloader, type StreamDownloader } 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 const emitter = createEventEmitter()\n const stateManager = createRecorderState({ state: \"idle\" as const, user: cfg.user }, logger)\n\n // State variables\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 audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n let stopAbortController = new AbortController()\n let pendingRemuxes: Promise<unknown>[] = []\n\n function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n reject(\n 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 return\n }\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.on(\"close\", (code) => {\n if (signal?.aborted) {\n reject(new TikTokError(\"unknown\", \"Aborted by user\"))\n return\n }\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 if (err instanceof Error && err.name === \"AbortError\") return\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n if (stateManager.getStatus().state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`)\n return\n }\n stopRequested = false\n pendingRemuxes = []\n stopAbortController = new AbortController()\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient, logger, cfg.debug)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger, stopAbortController.signal)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emitter.emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emitter.emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emitter.emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) =>\n emitter.emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\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 emitter.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 emitter.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 emitter.emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emitter.emit(\"tick\", { user, isLive: true, roomId })\n\n stateManager.setState({ state: \"recording\" })\n emitter.emit(\"recording:start\", { user, file: \"\" })\n\n const getNextUrl = async (): Promise<string | null> => {\n // Use the lightweight check_alive endpoint first — avoids a full\n // page re-scan + room/info call when the stream is still active.\n const alive = await api!.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Still alive — invalidate cache and get a fresh stream URL.\n api!.invalidateCache()\n return api!.getLiveUrl(roomId)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emitter.emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n\n await processRecording(result as DownloadResult, cfg, {\n runFfmpeg,\n converter: converter!,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (event: string, ...args: any[]) => (emitter as any).emit(event, ...args),\n setState: stateManager.setState,\n stopRequested: () => stopRequested,\n logger,\n })\n\n // One-shot mode: if a duration was set, stop after this recording\n if (cfg.duration > 0) {\n logger.info(\"Duration limit reached — exiting\")\n monitor!.stopAfterCurrentTick()\n } else if (!stopRequested && roomId) {\n // Recording ended because the stream went offline (not user-initiated stop).\n // Immediately re-check via lightweight check_alive endpoint instead of\n // waiting for the next poll interval.\n const alive = await api!.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n }\n }\n },\n })\n\n stateManager.setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n if (stateManager.getStatus().state === \"stopped\") {\n logger.warn(`stop() ignored: already stopped`)\n return\n }\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n\n if (pendingRemuxes.length > 0) {\n logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 60s)...`)\n const abortTimeout = new AbortController()\n await Promise.race([\n Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()),\n sleep(60_000, abortTimeout.signal),\n ])\n pendingRemuxes = []\n }\n\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n stopAbortController.abort()\n if (httpClient) await httpClient.close()\n emitter.clear()\n stateManager.setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus: stateManager.getStatus,\n on: emitter.on,\n off: emitter.off,\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\nimport { spawn } from \"node:child_process\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport type { RecorderConfig, RecorderController } from \"../config\"\nimport { normalizeConfig, TikTokError } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { findFfmpegPath, sleep } from \"../utils\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { processRecording } from \"./post-processing\"\nimport { createEventEmitter } from \"./recorder-events\"\nimport { createRecorderState } from \"./recorder-state\"\nimport type { DownloadResult } from \"./stream\"\nimport { createStreamDownloader, type StreamDownloader } 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 const emitter = createEventEmitter()\n const stateManager = createRecorderState({ state: \"idle\" as const, user: cfg.user }, logger)\n\n // State variables\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 audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n let stopAbortController = new AbortController()\n let pendingRemuxes: Promise<unknown>[] = []\n\n function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n reject(\n 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 return\n }\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.on(\"close\", (code) => {\n if (signal?.aborted) {\n reject(new TikTokError(\"unknown\", \"Aborted by user\"))\n return\n }\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 if (err instanceof Error && err.name === \"AbortError\") return\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n if (stateManager.getStatus().state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`)\n return\n }\n stopRequested = false\n pendingRemuxes = []\n stopAbortController = new AbortController()\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient, logger, cfg.debug)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger, stopAbortController.signal)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emitter.emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emitter.emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emitter.emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) =>\n emitter.emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n // All dependencies are initialized by start() before onTick runs\n const tikTokApi = api as TikTokApi\n const streamDownloader = downloader as StreamDownloader\n const pollingMonitor = monitor as PollingMonitor\n\n const user = cfg.user\n tikTokApi.invalidateCache()\n\n emitter.emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await tikTokApi.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await tikTokApi.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emitter.emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emitter.emit(\"tick\", { user, isLive: true, roomId })\n\n stateManager.setState({ state: \"recording\" })\n emitter.emit(\"recording:start\", { user, file: \"\" })\n\n const getNextUrl = async (): Promise<string | null> => {\n // Use the lightweight check_alive endpoint first — avoids a full\n // page re-scan + room/info call when the stream is still active.\n const alive = await tikTokApi.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Still alive — invalidate cache and get a fresh stream URL.\n tikTokApi.invalidateCache()\n return tikTokApi.getLiveUrl(roomId)\n }\n\n const result = await streamDownloader.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emitter.emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n\n await processRecording(result as DownloadResult, cfg, {\n runFfmpeg,\n converter: converter as Converter,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (emitter.emit as (event: string, ...args: unknown[]) => void).bind(emitter),\n setState: stateManager.setState,\n stopRequested: () => stopRequested,\n logger,\n })\n\n // One-shot mode: if a duration was set, stop after this recording\n if (cfg.duration > 0) {\n logger.info(\"Duration limit reached — exiting\")\n pollingMonitor.stopAfterCurrentTick()\n } else if (!stopRequested && roomId) {\n // Recording ended because the stream went offline (not user-initiated stop).\n // Immediately re-check via lightweight check_alive endpoint instead of\n // waiting for the next poll interval.\n const alive = await tikTokApi.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n }\n }\n },\n })\n\n stateManager.setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n if (stateManager.getStatus().state === \"stopped\") {\n logger.warn(`stop() ignored: already stopped`)\n return\n }\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n\n if (pendingRemuxes.length > 0) {\n logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 60s)...`)\n const abortTimeout = new AbortController()\n await Promise.race([\n Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()),\n sleep(60_000, abortTimeout.signal),\n ])\n pendingRemuxes = []\n }\n\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n stopAbortController.abort()\n if (httpClient) await httpClient.close()\n emitter.clear()\n stateManager.setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus: stateManager.getStatus,\n on: emitter.on,\n off: emitter.off,\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"post-processing.mjs","names":[],"sources":["../../src/recorder/post-processing.ts"],"sourcesContent":["/**\n * Post-recording processing — segmenting, conversion, and audio normalization.\n *\n * After a stream download completes, this module handles:\n * - Splitting the downloaded file into timed MP4 segments\n * - Converting TS to MP4 (fallback or direct)\n * - Audio normalization via EBU R128\n * - File management (deleting originals, setting timestamps)\n * - Event emissions for each step\n */\n\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport type { RecorderConfig } from \"../config\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../logger\"\nimport type { Converter } from \"./convert\"\nimport type { AudioNormalizer } from \"./normalize\"\nimport type { DownloadResult } from \"./stream\"\n\nexport interface ProcessingDeps {\n runFfmpeg: (args: string[], signal?: AbortSignal) => Promise<void>\n converter: Converter\n audioNormalizer: AudioNormalizer | null\n stopAbortController: AbortController\n pendingRemuxes: Promise<unknown>[]\n emit: (event: string, ...args: any[]) => void\n setState: (partial: Record<string, unknown>) => void\n stopRequested: () => boolean\n logger: Logger\n}\n\nexport async function processRecording(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n try {\n if (statSync(result.file).size === 0) return\n } catch {\n return // file doesn't exist or can't be accessed\n }\n\n deps.setState({ state: \"converting\" })\n\n if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) {\n await processSegmenting(result, cfg, deps)\n } else {\n await processConversion(result, cfg, deps)\n }\n\n deps.setState({ state: \"polling\" })\n}\n\n// ─── Segmenting path ──────────────────────────────────────────────\n\nasync function processSegmenting(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n const parsed = parse(result.file)\n const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n\n deps.logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`,\n )\n deps.emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n const segmentPromise = deps.runFfmpeg(\n [\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 deps.stopAbortController.signal,\n )\n deps.pendingRemuxes.push(segmentPromise)\n await segmentPromise\n\n // Capture file timestamp before deletion\n const fileMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = fileMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original TS\n try {\n unlinkSync(result.file)\n deps.logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n deps.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 = segmentTime\n const totalDuration = result.duration\n\n // Emit events for each segment\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!)\n const stats = statSync(filePath)\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n deps.emit(\"recording:end\", { file: filePath, duration: estDuration, size: stats.size })\n deps.emit(\"converted\", { input: result.file, output: filePath })\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 deps.emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n // Normalize audio for each segment if enabled\n if (deps.audioNormalizer) {\n const normalizer = deps.audioNormalizer\n const normalizePromises = segmentFiles.map(async (segFile) => {\n const segPath = join(parsed.dir, segFile)\n try {\n await normalizer.normalize(segPath)\n } catch {\n // Error already emitted via onError callback\n }\n })\n deps.pendingRemuxes.push(...normalizePromises)\n await Promise.allSettled(normalizePromises)\n }\n\n deps.setState({\n currentFile: segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n if (deps.stopRequested()) return\n const msg = err instanceof Error ? err.message : String(err)\n deps.logger.error(`Segmenting failed: ${msg}`)\n deps.setState({ lastError: `Segmenting failed: ${msg}` })\n // Fallback: try simple conversion of the whole TS\n await runFallbackConversion(result, deps)\n }\n}\n\n// ─── Shared converter helper ────────────────────────────────────\n\n/**\n * Convert a downloaded file to MP4 and optionally normalize audio.\n * Shared by the direct conversion and fallback paths.\n *\n * @param emitRecordingEnd When true, also emit a `recording:end` event\n * (used by the direct conversion path; the segmenting path emits its\n * own `recording:end` events per segment).\n */\nasync function convertFile(\n result: DownloadResult,\n deps: ProcessingDeps,\n emitRecordingEnd?: boolean,\n): Promise<void> {\n deps.emit(\"converting:start\", { input: result.file })\n try {\n const convertPromise = deps.converter.convert(result.file)\n deps.pendingRemuxes.push(convertPromise)\n const mp4File = await convertPromise\n\n if (deps.audioNormalizer) {\n const normalizePromise = deps.audioNormalizer.normalize(mp4File).catch(() => {})\n deps.pendingRemuxes.push(normalizePromise)\n await normalizePromise\n }\n\n if (emitRecordingEnd) {\n deps.emit(\"recording:end\", { file: mp4File, duration: result.duration, size: result.size })\n }\n deps.emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n deps.logger.error(`Conversion failed: ${tkErr.message}`)\n deps.setState({ lastError: `Conversion failed: ${tkErr.message}` })\n deps.emit(\"error\", tkErr)\n }\n}\n\n// ─── Conversion path (no segmenting) ─────────────────────────────\n\nasync function processConversion(\n result: DownloadResult,\n _cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n return convertFile(result, deps, true)\n}\n\n// ─── Fallback conversion (when segmenting fails) ─────────────────\n\nasync function runFallbackConversion(result: DownloadResult, deps: ProcessingDeps): Promise<void> {\n return convertFile(result, deps)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"post-processing.mjs","names":[],"sources":["../../src/recorder/post-processing.ts"],"sourcesContent":["/**\n * Post-recording processing — segmenting, conversion, and audio normalization.\n *\n * After a stream download completes, this module handles:\n * - Splitting the downloaded file into timed MP4 segments\n * - Converting TS to MP4 (fallback or direct)\n * - Audio normalization via EBU R128\n * - File management (deleting originals, setting timestamps)\n * - Event emissions for each step\n */\n\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport type { RecorderConfig } from \"../config\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../logger\"\nimport type { Converter } from \"./convert\"\nimport type { AudioNormalizer } from \"./normalize\"\nimport type { DownloadResult } from \"./stream\"\n\nexport interface ProcessingDeps {\n runFfmpeg: (args: string[], signal?: AbortSignal) => Promise<void>\n converter: Converter\n audioNormalizer: AudioNormalizer | null\n stopAbortController: AbortController\n pendingRemuxes: Promise<unknown>[]\n emit: (event: string, ...args: unknown[]) => void\n setState: (partial: Record<string, unknown>) => void\n stopRequested: () => boolean\n logger: Logger\n}\n\nexport async function processRecording(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n try {\n if (statSync(result.file).size === 0) return\n } catch {\n return // file doesn't exist or can't be accessed\n }\n\n deps.setState({ state: \"converting\" })\n\n if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) {\n await processSegmenting(result, cfg, deps)\n } else {\n await processConversion(result, cfg, deps)\n }\n\n deps.setState({ state: \"polling\" })\n}\n\n// ─── Segmenting path ──────────────────────────────────────────────\n\nasync function processSegmenting(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n const parsed = parse(result.file)\n const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n\n deps.logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`,\n )\n deps.emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n const segmentPromise = deps.runFfmpeg(\n [\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 deps.stopAbortController.signal,\n )\n deps.pendingRemuxes.push(segmentPromise)\n await segmentPromise\n\n // Capture file timestamp before deletion\n const fileMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = fileMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original TS\n try {\n unlinkSync(result.file)\n deps.logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n deps.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 = segmentTime\n const totalDuration = result.duration\n\n // Emit events for each segment\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i] as string)\n const stats = statSync(filePath)\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n deps.emit(\"recording:end\", { file: filePath, duration: estDuration, size: stats.size })\n deps.emit(\"converted\", { input: result.file, output: filePath })\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] as string)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n deps.emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n // Normalize audio for each segment if enabled\n if (deps.audioNormalizer) {\n const normalizer = deps.audioNormalizer\n const normalizePromises = segmentFiles.map(async (segFile) => {\n const segPath = join(parsed.dir, segFile)\n try {\n await normalizer.normalize(segPath)\n } catch {\n // Error already emitted via onError callback\n }\n })\n deps.pendingRemuxes.push(...normalizePromises)\n await Promise.allSettled(normalizePromises)\n }\n\n deps.setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1) as string) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n if (deps.stopRequested()) return\n const msg = err instanceof Error ? err.message : String(err)\n deps.logger.error(`Segmenting failed: ${msg}`)\n deps.setState({ lastError: `Segmenting failed: ${msg}` })\n // Fallback: try simple conversion of the whole TS\n await runFallbackConversion(result, deps)\n }\n}\n\n// ─── Shared converter helper ────────────────────────────────────\n\n/**\n * Convert a downloaded file to MP4 and optionally normalize audio.\n * Shared by the direct conversion and fallback paths.\n *\n * @param emitRecordingEnd When true, also emit a `recording:end` event\n * (used by the direct conversion path; the segmenting path emits its\n * own `recording:end` events per segment).\n */\nasync function convertFile(\n result: DownloadResult,\n deps: ProcessingDeps,\n emitRecordingEnd?: boolean,\n): Promise<void> {\n deps.emit(\"converting:start\", { input: result.file })\n try {\n const convertPromise = deps.converter.convert(result.file)\n deps.pendingRemuxes.push(convertPromise)\n const mp4File = await convertPromise\n\n if (deps.audioNormalizer) {\n const normalizePromise = deps.audioNormalizer.normalize(mp4File).catch(() => {})\n deps.pendingRemuxes.push(normalizePromise)\n await normalizePromise\n }\n\n if (emitRecordingEnd) {\n deps.emit(\"recording:end\", { file: mp4File, duration: result.duration, size: result.size })\n }\n deps.emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n deps.logger.error(`Conversion failed: ${tkErr.message}`)\n deps.setState({ lastError: `Conversion failed: ${tkErr.message}` })\n deps.emit(\"error\", tkErr)\n }\n}\n\n// ─── Conversion path (no segmenting) ─────────────────────────────\n\nasync function processConversion(\n result: DownloadResult,\n _cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n return convertFile(result, deps, true)\n}\n\n// ─── Fallback conversion (when segmenting fails) ─────────────────\n\nasync function runFallbackConversion(result: DownloadResult, deps: ProcessingDeps): Promise<void> {\n return convertFile(result, deps)\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"recorder-events.mjs","names":[],"sources":["../../src/recorder/recorder-events.ts"],"sourcesContent":["/**\n * Typed event emitter for recorder lifecycle events.\n */\n\nimport type { RecorderEvent, RecorderEventHandler } from \"../config\"\n\nexport interface RecorderEventEmitter {\n on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n emit<E extends RecorderEvent>(event: E, ...args: Parameters<RecorderEventHandler[E]>): void\n clear(): void\n}\n\nexport function createEventEmitter(): RecorderEventEmitter {\n const handlers = new Map<string, Array<(...args:
|
|
1
|
+
{"version":3,"file":"recorder-events.mjs","names":[],"sources":["../../src/recorder/recorder-events.ts"],"sourcesContent":["/**\n * Typed event emitter for recorder lifecycle events.\n */\n\nimport type { RecorderEvent, RecorderEventHandler } from \"../config\"\n\nexport interface RecorderEventEmitter {\n on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n emit<E extends RecorderEvent>(event: E, ...args: Parameters<RecorderEventHandler[E]>): void\n clear(): void\n}\n\nexport function createEventEmitter(): RecorderEventEmitter {\n const handlers = new Map<string, Array<(...args: unknown[]) => void>>()\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event) ?? []\n list.push(handler as (...args: unknown[]) => void)\n handlers.set(event, list)\n }\n\n function off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event)\n if (!list) return\n const idx = list.indexOf(handler as (...args: unknown[]) => void)\n if (idx !== -1) list.splice(idx, 1)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = handlers.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 clear(): void {\n handlers.clear()\n }\n\n return { on, off, emit, clear }\n}\n"],"mappings":";AAaA,SAAgB,qBAA2C;CACzD,MAAM,2BAAW,IAAI,IAAiD;CAEtE,SAAS,GAA4B,OAAU,SAAwC;EACrF,MAAM,OAAO,SAAS,IAAI,KAAK,KAAK,CAAC;EACrC,KAAK,KAAK,OAAuC;EACjD,SAAS,IAAI,OAAO,IAAI;CAC1B;CAEA,SAAS,IAA6B,OAAU,SAAwC;EACtF,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,CAAC,MAAM;EACX,MAAM,MAAM,KAAK,QAAQ,OAAuC;EAChE,IAAI,QAAQ,IAAI,KAAK,OAAO,KAAK,CAAC;CACpC;CAEA,SAAS,KACP,OACA,GAAG,MACG;EACN,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,MACF,KAAK,MAAM,KAAK,MACd,IAAI;GACF,EAAE,GAAG,IAAI;EACX,QAAQ,CAER;CAGN;CAEA,SAAS,QAAc;EACrB,SAAS,MAAM;CACjB;CAEA,OAAO;EAAE;EAAI;EAAK;EAAM;CAAM;AAChC"}
|
package/dist/ui.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { formatDuration } from "./utils.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { color, createSpinner } from "@zfadhli/koko-cli";
|
|
3
3
|
//#region src/ui.ts
|
|
4
|
+
const tag = {
|
|
5
|
+
ok: color.bold(color.green("[OK] ")),
|
|
6
|
+
done: color.bold(color.green("[DONE] ")),
|
|
7
|
+
live: color.bold(color.green("[LIVE] ")),
|
|
8
|
+
info: color.bold(color.blue("[INFO] ")),
|
|
9
|
+
off: color.bold(color.blue("[OFF] ")),
|
|
10
|
+
warn: color.bold(color.yellow("[WARN] ")),
|
|
11
|
+
err: color.bold(color.red("[ERR] "))
|
|
12
|
+
};
|
|
4
13
|
function createDisplay() {
|
|
5
14
|
let activeSpinner = null;
|
|
6
15
|
let offlineLineShown = false;
|
|
@@ -15,14 +24,14 @@ function createDisplay() {
|
|
|
15
24
|
if (activeSpinner?.isSpinning) activeSpinner.stop();
|
|
16
25
|
activeSpinner = null;
|
|
17
26
|
}
|
|
18
|
-
/** Stop the current spinner and print a one-liner with the given
|
|
19
|
-
function finalize(
|
|
27
|
+
/** Stop the current spinner and print a one-liner with the given tag prefix. */
|
|
28
|
+
function finalize(prefix, text) {
|
|
20
29
|
clearSpinner();
|
|
21
|
-
process.stdout.write(`${
|
|
30
|
+
process.stdout.write(`${prefix}${text}\n`);
|
|
22
31
|
}
|
|
23
32
|
return {
|
|
24
33
|
pollingStarted(intervalMinutes) {
|
|
25
|
-
finalize(
|
|
34
|
+
finalize(tag.info, `${color.dim("Polling started (every ")}${color.bold(color.yellow(String(intervalMinutes)))}${color.dim(" min)")}`);
|
|
26
35
|
},
|
|
27
36
|
checkingUser(user) {
|
|
28
37
|
clearSpinner();
|
|
@@ -30,38 +39,39 @@ function createDisplay() {
|
|
|
30
39
|
activeSpinner.start();
|
|
31
40
|
},
|
|
32
41
|
userOffline(user) {
|
|
33
|
-
finalize(
|
|
42
|
+
finalize(tag.off, `${color.bold(color.yellow(`@${user}`))}${color.dim(" is offline")}`);
|
|
34
43
|
offlineLineShown = true;
|
|
35
44
|
},
|
|
36
45
|
userOfflineRepeat(user, lastCheck) {
|
|
37
46
|
clearSpinner();
|
|
38
|
-
|
|
47
|
+
const text = `${tag.off}${color.bold(color.yellow(`@${user}`))}${color.dim(" is offline")} ${color.dim(`[last online: ${color.bold(color.yellow(lastCheck))}]`)}`;
|
|
48
|
+
if (offlineLineShown) process.stdout.write(`\x1b[1A\r${text}\x1b[K\n`);
|
|
39
49
|
else {
|
|
40
|
-
process.stdout.write(`${
|
|
50
|
+
process.stdout.write(`${text}\n`);
|
|
41
51
|
offlineLineShown = true;
|
|
42
52
|
}
|
|
43
53
|
},
|
|
44
54
|
userLive(user, roomId) {
|
|
45
55
|
clearSpinner();
|
|
46
56
|
offlineLineShown = false;
|
|
47
|
-
process.stdout.write(`${
|
|
57
|
+
process.stdout.write(`${tag.live}${color.bold(color.green(`@${user}`))} ${color.dim(`(room: ${color.bold(color.yellow(roomId))})`)}\n`);
|
|
48
58
|
},
|
|
49
59
|
startRecording() {
|
|
50
60
|
clearSpinner();
|
|
51
61
|
recordingStartTime = Date.now();
|
|
52
|
-
activeSpinner = createSpinner(`${color.cyan("Recording...")} ${color.dim("
|
|
62
|
+
activeSpinner = createSpinner(`${color.cyan("Recording...")} ${color.dim(`[${color.bold(color.yellow("0s"))}]`)}`);
|
|
53
63
|
activeSpinner.start();
|
|
54
64
|
recordingTimer = setInterval(() => {
|
|
55
65
|
if (!activeSpinner?.isSpinning || !recordingStartTime) return;
|
|
56
66
|
const elapsed = (Date.now() - recordingStartTime) / 1e3;
|
|
57
|
-
activeSpinner.text = `${color.cyan("Recording...")} ${color.dim(`[${formatDuration(elapsed)}]`)}`;
|
|
67
|
+
activeSpinner.text = `${color.cyan("Recording...")} ${color.dim(`[${color.bold(color.yellow(formatDuration(elapsed)))}]`)}`;
|
|
58
68
|
}, 1e3);
|
|
59
69
|
},
|
|
60
70
|
finishRecording(filename, duration, size) {
|
|
61
|
-
finalize(
|
|
71
|
+
finalize(tag.done, `${color.cyan(filename)}${color.dim(" — ")}${color.bold(color.yellow(size))}${color.dim(` in ${color.bold(color.yellow(formatDuration(duration)))}`)}`);
|
|
62
72
|
},
|
|
63
73
|
recordingError(message) {
|
|
64
|
-
finalize(
|
|
74
|
+
finalize(tag.err, color.red(`Recording failed: ${message}`));
|
|
65
75
|
},
|
|
66
76
|
startSegmenting() {
|
|
67
77
|
clearSpinner();
|
|
@@ -69,7 +79,7 @@ function createDisplay() {
|
|
|
69
79
|
activeSpinner.start();
|
|
70
80
|
},
|
|
71
81
|
segmentsCreated(count) {
|
|
72
|
-
finalize(
|
|
82
|
+
finalize(tag.ok, `${color.dim("Created ")}${color.bold(color.yellow(String(count)))}${color.dim(` MP4 segment${count !== 1 ? "s" : ""}`)}`);
|
|
73
83
|
},
|
|
74
84
|
startConverting() {
|
|
75
85
|
clearSpinner();
|
|
@@ -77,7 +87,7 @@ function createDisplay() {
|
|
|
77
87
|
activeSpinner.start();
|
|
78
88
|
},
|
|
79
89
|
conversionDone(output) {
|
|
80
|
-
finalize(
|
|
90
|
+
finalize(tag.done, `${color.cyan(output)}`);
|
|
81
91
|
},
|
|
82
92
|
normalizeStart() {
|
|
83
93
|
clearSpinner();
|
|
@@ -86,13 +96,13 @@ function createDisplay() {
|
|
|
86
96
|
},
|
|
87
97
|
normalizeProgress(percent, phase) {
|
|
88
98
|
if (!activeSpinner?.isSpinning) return;
|
|
89
|
-
activeSpinner.text = ` ${phase === "analyzing" ? "Analyzing" : "Normalizing"}... ${percent}
|
|
99
|
+
activeSpinner.text = ` ${phase === "analyzing" ? "Analyzing" : "Normalizing"}... ${color.bold(color.yellow(`${percent}%`))}`;
|
|
90
100
|
},
|
|
91
101
|
normalizeComplete() {
|
|
92
|
-
finalize(
|
|
102
|
+
finalize(tag.ok, color.green("Audio normalized"));
|
|
93
103
|
},
|
|
94
104
|
normalizeError(message) {
|
|
95
|
-
finalize(
|
|
105
|
+
finalize(tag.err, color.red(`Normalization failed: ${message}`));
|
|
96
106
|
},
|
|
97
107
|
showVersion(name, version) {
|
|
98
108
|
clearSpinner();
|
|
@@ -102,17 +112,17 @@ function createDisplay() {
|
|
|
102
112
|
showError(message) {
|
|
103
113
|
clearSpinner();
|
|
104
114
|
offlineLineShown = false;
|
|
105
|
-
process.stdout.write(`${
|
|
115
|
+
process.stdout.write(`${tag.err}${color.red(message)}\n`);
|
|
106
116
|
},
|
|
107
117
|
showInfo(message) {
|
|
108
118
|
clearSpinner();
|
|
109
119
|
offlineLineShown = false;
|
|
110
|
-
process.stdout.write(`${
|
|
120
|
+
process.stdout.write(`${tag.info}${color.dim(message)}\n`);
|
|
111
121
|
},
|
|
112
122
|
showWarning(message) {
|
|
113
123
|
clearSpinner();
|
|
114
124
|
offlineLineShown = false;
|
|
115
|
-
process.stdout.write(`${
|
|
125
|
+
process.stdout.write(`${tag.warn}${color.yellow(message)}\n`);
|
|
116
126
|
},
|
|
117
127
|
stop() {
|
|
118
128
|
clearSpinner();
|
package/dist/ui.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui.mjs","names":[],"sources":["../src/ui.ts"],"sourcesContent":["/**\n * Terminal Display Manager — beautiful console output using koko-cli's\n * spinner, icons, and color utilities.\n *\n * This module owns the terminal and should be the only thing writing to\n * stdout/stderr (the file logger handles persistent logs).\n */\n\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\nimport {\n color,\n createSpinner,\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n} from \"@zfadhli/koko-cli\"\nimport { formatDuration } from \"./utils\"\n\nexport interface Display {\n /** Called once when polling begins. */\n pollingStarted(intervalMinutes: number): void\n /** Spinner while checking if a user is live. */\n checkingUser(user: string): void\n /** User is offline (no active display). */\n userOffline(user: string): void\n /** User is still offline — updates the previous offline line with a timestamp. */\n userOfflineRepeat(user: string, lastCheck: string): void\n /** User is live! Clears the checking spinner. */\n userLive(user: string, roomId: string): void\n\n /** Recording starts: spinner without filename (known only post-download). */\n startRecording(): void\n /** Recording finished successfully. Shows filename from the result. */\n finishRecording(filename: string, duration: number, size: string): void\n /** Recording encountered a non-fatal error. */\n recordingError(message: string): void\n\n /** FFmpeg segmenting starts. */\n startSegmenting(): void\n /** FFmpeg segmenting done. */\n segmentsCreated(count: number): void\n\n /** FFmpeg simple conversion starts (fallback path). */\n startConverting(): void\n /** Simple conversion done. */\n conversionDone(output: string): void\n\n /** Audio normalization starts (EBU R128 via peaknorm). */\n normalizeStart(): void\n /** Audio normalization progress update. */\n normalizeProgress(percent: number, phase: string): void\n /** Audio normalization completed successfully. */\n normalizeComplete(): void\n /** Audio normalization failed. */\n normalizeError(message: string): void\n\n /** Show version banner at startup. */\n showVersion(name: string, version: string): void\n\n /** General-purpose status helpers. */\n showError(message: string): void\n showInfo(message: string): void\n showWarning(message: string): void\n\n /** Stop all active spinners and clean up the terminal. */\n stop(): void\n}\n\nexport function createDisplay(): Display {\n let activeSpinner: SpinnerInstance | null = null\n let offlineLineShown = false\n\n // Recording timer state\n let recordingStartTime: number | null = null\n let recordingTimer: ReturnType<typeof setInterval> | null = null\n\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\n if (recordingTimer) {\n clearInterval(recordingTimer)\n recordingTimer = null\n }\n if (activeSpinner?.isSpinning) {\n activeSpinner.stop()\n }\n activeSpinner = null\n }\n\n /** Stop the current spinner and print a one-liner with the given icon. */\n function finalize(icon: string, text: string): void {\n clearSpinner()\n process.stdout.write(`${icon}${text}\\n`)\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`))\n },\n\n checkingUser(user: string): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(`Checking @${user}...`))\n activeSpinner.start()\n },\n\n userOffline(user: string): void {\n finalize(ICON_INFO, color.dim(`@${user} is offline`))\n offlineLineShown = true\n },\n\n userOfflineRepeat(user: string, lastCheck: string): void {\n clearSpinner()\n if (offlineLineShown) {\n // Move cursor up one line and overwrite the previous offline message\n process.stdout.write(\n `\\x1b[1A\\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\\x1b[K\\n`,\n )\n } else {\n // Fallback if state is out of sync\n process.stdout.write(\n `${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\\n`,\n )\n offlineLineShown = true\n }\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(\n `${ICON_SUCCESS}${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n recordingStartTime = Date.now()\n activeSpinner = createSpinner(`${color.cyan(\"Recording...\")} ${color.dim(\"[0s]\")}`)\n activeSpinner.start()\n\n recordingTimer = setInterval(() => {\n if (!activeSpinner?.isSpinning || !recordingStartTime) return\n const elapsed = (Date.now() - recordingStartTime) / 1000\n activeSpinner.text = `${color.cyan(\"Recording...\")} ${color.dim(`[${formatDuration(elapsed)}]`)}`\n }, 1000)\n },\n\n finishRecording(filename: string, duration: number, size: string): void {\n finalize(\n ICON_SUCCESS,\n `${color.green(\"Recording finished\")} ${color.dim(`${filename} — ${size} in ${formatDuration(duration)}`)}`,\n )\n },\n\n recordingError(message: string): void {\n finalize(ICON_ERROR, color.red(`Recording failed: ${message}`))\n },\n\n startSegmenting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Segmenting...\"))\n activeSpinner.start()\n },\n\n segmentsCreated(count: number): void {\n finalize(ICON_SUCCESS, color.green(`Created ${count} MP4 segment${count !== 1 ? \"s\" : \"\"}`))\n },\n\n startConverting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Converting to MP4...\"))\n activeSpinner.start()\n },\n\n conversionDone(output: string): void {\n finalize(ICON_SUCCESS, color.green(`Converted: ${output}`))\n },\n\n normalizeStart(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Normalizing audio...\"))\n activeSpinner.start()\n },\n\n normalizeProgress(percent: number, phase: string): void {\n if (!activeSpinner?.isSpinning) return\n const label = phase === \"analyzing\" ? \"Analyzing\" : \"Normalizing\"\n activeSpinner.text = ` ${label}... ${percent}%`\n },\n\n normalizeComplete(): void {\n finalize(ICON_SUCCESS, color.green(\"Audio normalized\"))\n },\n\n normalizeError(message: string): void {\n finalize(ICON_ERROR, color.red(`Normalization failed: ${message}`))\n },\n\n showVersion(name: string, version: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${color.dim(`${name} ${version}`)}\\n`)\n },\n\n showError(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${ICON_ERROR}${color.red(message)}\\n`)\n },\n\n showInfo(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${ICON_INFO}${color.dim(message)}\\n`)\n },\n\n showWarning(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${ICON_WARN}${color.yellow(message)}\\n`)\n },\n\n stop(): void {\n clearSpinner()\n offlineLineShown = false\n },\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ui.mjs","names":[],"sources":["../src/ui.ts"],"sourcesContent":["/**\n * Terminal Display Manager — beautiful console output using koko-cli's\n * spinner, icons, and color utilities.\n *\n * This module owns the terminal and should be the only thing writing to\n * stdout/stderr (the file logger handles persistent logs).\n */\n\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\nimport { color, createSpinner } from \"@zfadhli/koko-cli\"\nimport { formatDuration } from \"./utils\"\n\n// ─── Tag helpers ──────────────────────────────────────────\nconst tag = {\n ok: color.bold(color.green(\"[OK] \")),\n done: color.bold(color.green(\"[DONE] \")),\n live: color.bold(color.green(\"[LIVE] \")),\n info: color.bold(color.blue(\"[INFO] \")),\n off: color.bold(color.blue(\"[OFF] \")),\n warn: color.bold(color.yellow(\"[WARN] \")),\n err: color.bold(color.red(\"[ERR] \")),\n}\n\nexport interface Display {\n /** Called once when polling begins. */\n pollingStarted(intervalMinutes: number): void\n /** Spinner while checking if a user is live. */\n checkingUser(user: string): void\n /** User is offline (no active display). */\n userOffline(user: string): void\n /** User is still offline — updates the previous offline line with a timestamp. */\n userOfflineRepeat(user: string, lastCheck: string): void\n /** User is live! Clears the checking spinner. */\n userLive(user: string, roomId: string): void\n\n /** Recording starts: spinner without filename (known only post-download). */\n startRecording(): void\n /** Recording finished successfully. Shows filename from the result. */\n finishRecording(filename: string, duration: number, size: string): void\n /** Recording encountered a non-fatal error. */\n recordingError(message: string): void\n\n /** FFmpeg segmenting starts. */\n startSegmenting(): void\n /** FFmpeg segmenting done. */\n segmentsCreated(count: number): void\n\n /** FFmpeg simple conversion starts (fallback path). */\n startConverting(): void\n /** Simple conversion done. */\n conversionDone(output: string): void\n\n /** Audio normalization starts (EBU R128 via peaknorm). */\n normalizeStart(): void\n /** Audio normalization progress update. */\n normalizeProgress(percent: number, phase: string): void\n /** Audio normalization completed successfully. */\n normalizeComplete(): void\n /** Audio normalization failed. */\n normalizeError(message: string): void\n\n /** Show version banner at startup. */\n showVersion(name: string, version: string): void\n\n /** General-purpose status helpers. */\n showError(message: string): void\n showInfo(message: string): void\n showWarning(message: string): void\n\n /** Stop all active spinners and clean up the terminal. */\n stop(): void\n}\n\nexport function createDisplay(): Display {\n let activeSpinner: SpinnerInstance | null = null\n let offlineLineShown = false\n\n // Recording timer state\n let recordingStartTime: number | null = null\n let recordingTimer: ReturnType<typeof setInterval> | null = null\n\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\n if (recordingTimer) {\n clearInterval(recordingTimer)\n recordingTimer = null\n }\n if (activeSpinner?.isSpinning) {\n activeSpinner.stop()\n }\n activeSpinner = null\n }\n\n /** Stop the current spinner and print a one-liner with the given tag prefix. */\n function finalize(prefix: string, text: string): void {\n clearSpinner()\n process.stdout.write(`${prefix}${text}\\n`)\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(\n tag.info,\n `${color.dim(\"Polling started (every \")}${color.bold(color.yellow(String(intervalMinutes)))}${color.dim(\" min)\")}`,\n )\n },\n\n checkingUser(user: string): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(`Checking @${user}...`))\n activeSpinner.start()\n },\n\n userOffline(user: string): void {\n finalize(tag.off, `${color.bold(color.yellow(`@${user}`))}${color.dim(\" is offline\")}`)\n offlineLineShown = true\n },\n\n userOfflineRepeat(user: string, lastCheck: string): void {\n clearSpinner()\n const text = `${tag.off}${color.bold(color.yellow(`@${user}`))}${color.dim(\" is offline\")} ${color.dim(`[last online: ${color.bold(color.yellow(lastCheck))}]`)}`\n if (offlineLineShown) {\n // Move cursor up one line and overwrite the previous offline message\n process.stdout.write(`\\x1b[1A\\r${text}\\x1b[K\\n`)\n } else {\n process.stdout.write(`${text}\\n`)\n offlineLineShown = true\n }\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(\n `${tag.live}${color.bold(color.green(`@${user}`))} ${color.dim(`(room: ${color.bold(color.yellow(roomId))})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n recordingStartTime = Date.now()\n activeSpinner = createSpinner(\n `${color.cyan(\"Recording...\")} ${color.dim(`[${color.bold(color.yellow(\"0s\"))}]`)}`,\n )\n activeSpinner.start()\n\n recordingTimer = setInterval(() => {\n if (!activeSpinner?.isSpinning || !recordingStartTime) return\n const elapsed = (Date.now() - recordingStartTime) / 1000\n activeSpinner.text = `${color.cyan(\"Recording...\")} ${color.dim(`[${color.bold(color.yellow(formatDuration(elapsed)))}]`)}`\n }, 1000)\n },\n\n finishRecording(filename: string, duration: number, size: string): void {\n finalize(\n tag.done,\n `${color.cyan(filename)}${color.dim(\" — \")}${color.bold(color.yellow(size))}${color.dim(` in ${color.bold(color.yellow(formatDuration(duration)))}`)}`,\n )\n },\n\n recordingError(message: string): void {\n finalize(tag.err, color.red(`Recording failed: ${message}`))\n },\n\n startSegmenting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Segmenting...\"))\n activeSpinner.start()\n },\n\n segmentsCreated(count: number): void {\n finalize(\n tag.ok,\n `${color.dim(\"Created \")}${color.bold(color.yellow(String(count)))}${color.dim(` MP4 segment${count !== 1 ? \"s\" : \"\"}`)}`,\n )\n },\n\n startConverting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Converting to MP4...\"))\n activeSpinner.start()\n },\n\n conversionDone(output: string): void {\n finalize(tag.done, `${color.cyan(output)}`)\n },\n\n normalizeStart(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Normalizing audio...\"))\n activeSpinner.start()\n },\n\n normalizeProgress(percent: number, phase: string): void {\n if (!activeSpinner?.isSpinning) return\n const label = phase === \"analyzing\" ? \"Analyzing\" : \"Normalizing\"\n activeSpinner.text = ` ${label}... ${color.bold(color.yellow(`${percent}%`))}`\n },\n\n normalizeComplete(): void {\n finalize(tag.ok, color.green(\"Audio normalized\"))\n },\n\n normalizeError(message: string): void {\n finalize(tag.err, color.red(`Normalization failed: ${message}`))\n },\n\n showVersion(name: string, version: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${color.dim(`${name} ${version}`)}\\n`)\n },\n\n showError(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${tag.err}${color.red(message)}\\n`)\n },\n\n showInfo(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${tag.info}${color.dim(message)}\\n`)\n },\n\n showWarning(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${tag.warn}${color.yellow(message)}\\n`)\n },\n\n stop(): void {\n clearSpinner()\n offlineLineShown = false\n },\n }\n}\n"],"mappings":"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"}
|
package/dist/utils.mjs
CHANGED
|
@@ -70,7 +70,7 @@ function formatDuration(seconds) {
|
|
|
70
70
|
}
|
|
71
71
|
/** Find FFmpeg binary via Bun.which() or PATH search. */
|
|
72
72
|
function findFfmpegPath() {
|
|
73
|
-
const bunWhich = Bun?.which;
|
|
73
|
+
const bunWhich = globalThis.Bun?.which;
|
|
74
74
|
if (typeof bunWhich === "function") return bunWhich("ffmpeg") ?? null;
|
|
75
75
|
const paths = process.env.PATH?.split(":") ?? [];
|
|
76
76
|
for (const dir of paths) try {
|
package/dist/utils.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/**\n * Utility helpers — pure functions, no side effects.\n */\n\nimport { mkdirSync, statSync } from \"node:fs\"\n\n/** Format a filename like username=2025.06.09_14-30-00_1.flv */\nexport function formatFilename(user: string, ext = \"flv\", part?: number): string {\n const now = new Date()\n const pad = (n: number) => String(n).padStart(2, \"0\")\n const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`\n const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`\n const partSuffix = part !== undefined ? `_part${part}` : \"\"\n return `${user}=${date}_${time}${partSuffix}.${ext}`\n}\n\n/** Ensure a directory exists (recursive) */\nexport function ensureDir(dir: string): void {\n mkdirSync(dir, { recursive: true })\n}\n\n/**\n * Sleep for `ms` milliseconds.\n *\n * When an `AbortSignal` is provided and fires before the timer elapses,\n * the promise resolves immediately and the underlying timeout is cleared.\n * This prevents orphaned timers in Promise.race patterns.\n */\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n if (signal?.aborted) {\n resolve()\n return\n }\n const timer = setTimeout(resolve, ms)\n if (signal) {\n signal.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n resolve()\n },\n { once: true },\n )\n }\n })\n}\n\n/** Format bytes to human-readable size */\nexport function bytesToHuman(bytes: number): string {\n if (bytes === 0) return \"0 B\"\n const units = [\"B\", \"KB\", \"MB\", \"GB\"]\n const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)\n return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`\n}\n\n/** Strip leading @ from a username */\nexport function sanitizeUser(user: string): string {\n return user.replace(/^@+/, \"\").trim()\n}\n\n/** Format a millisecond elapsed time as a relative human-readable string. */\nexport function relativeTime(ms: number): string {\n const sec = Math.floor(ms / 1000)\n if (sec < 5) return \"just now\"\n if (sec < 60) return `${sec}s ago`\n const min = Math.floor(sec / 60)\n if (min < 60) return `${min}m ago`\n const hours = Math.floor(min / 60)\n const mins = min % 60\n if (hours < 24) return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`\n const days = Math.floor(hours / 24)\n return `${days}d ago`\n}\n\n/** Format seconds into a compact human-readable duration (e.g. \"1m 5s\", \"30s\"). */\nexport function formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n if (m > 0) return `${m}m ${s}s`\n return `${s}s`\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nexport function findFfmpegPath(): string | null {\n const bunWhich = (
|
|
1
|
+
{"version":3,"file":"utils.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/**\n * Utility helpers — pure functions, no side effects.\n */\n\nimport { mkdirSync, statSync } from \"node:fs\"\n\n/** Format a filename like username=2025.06.09_14-30-00_1.flv */\nexport function formatFilename(user: string, ext = \"flv\", part?: number): string {\n const now = new Date()\n const pad = (n: number) => String(n).padStart(2, \"0\")\n const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`\n const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`\n const partSuffix = part !== undefined ? `_part${part}` : \"\"\n return `${user}=${date}_${time}${partSuffix}.${ext}`\n}\n\n/** Ensure a directory exists (recursive) */\nexport function ensureDir(dir: string): void {\n mkdirSync(dir, { recursive: true })\n}\n\n/**\n * Sleep for `ms` milliseconds.\n *\n * When an `AbortSignal` is provided and fires before the timer elapses,\n * the promise resolves immediately and the underlying timeout is cleared.\n * This prevents orphaned timers in Promise.race patterns.\n */\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n if (signal?.aborted) {\n resolve()\n return\n }\n const timer = setTimeout(resolve, ms)\n if (signal) {\n signal.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n resolve()\n },\n { once: true },\n )\n }\n })\n}\n\n/** Format bytes to human-readable size */\nexport function bytesToHuman(bytes: number): string {\n if (bytes === 0) return \"0 B\"\n const units = [\"B\", \"KB\", \"MB\", \"GB\"]\n const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)\n return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`\n}\n\n/** Strip leading @ from a username */\nexport function sanitizeUser(user: string): string {\n return user.replace(/^@+/, \"\").trim()\n}\n\n/** Format a millisecond elapsed time as a relative human-readable string. */\nexport function relativeTime(ms: number): string {\n const sec = Math.floor(ms / 1000)\n if (sec < 5) return \"just now\"\n if (sec < 60) return `${sec}s ago`\n const min = Math.floor(sec / 60)\n if (min < 60) return `${min}m ago`\n const hours = Math.floor(min / 60)\n const mins = min % 60\n if (hours < 24) return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`\n const days = Math.floor(hours / 24)\n return `${days}d ago`\n}\n\n/** Format seconds into a compact human-readable duration (e.g. \"1m 5s\", \"30s\"). */\nexport function formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n if (m > 0) return `${m}m ${s}s`\n return `${s}s`\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nexport function findFfmpegPath(): string | null {\n const bunWhich = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.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"],"mappings":";;;;;;AAOA,SAAgB,eAAe,MAAc,MAAM,OAAO,MAAuB;CAC/E,MAAM,sBAAM,IAAI,KAAK;CACrB,MAAM,OAAO,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;CAIpD,OAAO,GAAG,KAAK,GAAG,GAHF,IAAI,YAAY,IAAI,IAAI,IAAI,SAAS,IAAI,CAAC,IAAI,IAAI,IAAI,QAAQ,CAAC,IAGxD,GAAG,GAFV,IAAI,IAAI,SAAS,CAAC,IAAI,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,IAAI,WAAW,CAAC,MAC/D,SAAS,KAAA,IAAY,QAAQ,SAAS,GACb,GAAG;AACjD;;AAGA,SAAgB,UAAU,KAAmB;CAC3C,UAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AACpC;;;;;;;;AASA,SAAgB,MAAM,IAAY,QAAqC;CACrE,OAAO,IAAI,SAAS,YAAY;EAC9B,IAAI,QAAQ,SAAS;GACnB,QAAQ;GACR;EACF;EACA,MAAM,QAAQ,WAAW,SAAS,EAAE;EACpC,IAAI,QACF,OAAO,iBACL,eACM;GACJ,aAAa,KAAK;GAClB,QAAQ;EACV,GACA,EAAE,MAAM,KAAK,CACf;CAEJ,CAAC;AACH;;AAGA,SAAgB,aAAa,OAAuB;CAClD,IAAI,UAAU,GAAG,OAAO;CACxB,MAAM,QAAQ;EAAC;EAAK;EAAM;EAAM;CAAI;CACpC,MAAM,IAAI,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,IAAI,CAAC,GAAG,MAAM,SAAS,CAAC;CACjF,OAAO,IAAI,QAAQ,QAAQ,EAAA,CAAG,QAAQ,CAAC,EAAE,GAAG,MAAM;AACpD;;AAGA,SAAgB,aAAa,MAAsB;CACjD,OAAO,KAAK,QAAQ,OAAO,EAAE,CAAC,CAAC,KAAK;AACtC;;AAGA,SAAgB,aAAa,IAAoB;CAC/C,MAAM,MAAM,KAAK,MAAM,KAAK,GAAI;CAChC,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,MAAM,IAAI,OAAO,GAAG,IAAI;CAC5B,MAAM,MAAM,KAAK,MAAM,MAAM,EAAE;CAC/B,IAAI,MAAM,IAAI,OAAO,GAAG,IAAI;CAC5B,MAAM,QAAQ,KAAK,MAAM,MAAM,EAAE;CACjC,MAAM,OAAO,MAAM;CACnB,IAAI,QAAQ,IAAI,OAAO,OAAO,IAAI,GAAG,MAAM,IAAI,KAAK,SAAS,GAAG,MAAM;CAEtE,OAAO,GADM,KAAK,MAAM,QAAQ,EACnB,EAAE;AACjB;;AAGA,SAAgB,eAAe,SAAyB;CACtD,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;CACjC,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;CACjC,IAAI,IAAI,GAAG,OAAO,GAAG,EAAE,IAAI,EAAE;CAC7B,OAAO,GAAG,EAAE;AACd;;AAGA,SAAgB,iBAAgC;CAC9C,MAAM,WAAY,WAAoE,KAAK;CAC3F,IAAI,OAAO,aAAa,YACtB,OAAQ,SAAS,QAAQ,KAAgB;CAE3C,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"}
|