@zfadhli/tokrec 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.mjs.map +1 -1
- package/dist/api/tiktok.mjs +64 -11
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/browser-cookies.mjs +113 -0
- package/dist/browser-cookies.mjs.map +1 -0
- package/dist/cli.mjs.map +1 -1
- package/dist/config.d.mts +11 -11
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs.map +1 -1
- package/dist/index.mjs +3 -2
- package/dist/index.mjs.map +1 -1
- package/dist/logger.mjs.map +1 -1
- package/dist/monitor.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/convert.mjs.map +1 -1
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/stream.mjs.map +1 -1
- package/dist/ui.mjs.map +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, and proxy support.\n */\n\nimport { type Session, createSession } from
|
|
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, and proxy support.\n */\n\nimport { type Session, createSession } from \"wreq-js\";\nimport type { RecorderConfig } from \"../config\";\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>;\n post: (\n url: string,\n body?: BodyInit,\n headers?: Record<string, string>,\n ) => Promise<Response>;\n close: () => Promise<void>;\n}\n\nexport async function createHttpClient(\n config: RecorderConfig,\n): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"chrome_142\",\n os: \"windows\",\n proxy: config.proxy,\n } as any);\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 if (config.cookies?.sessionid_ss) {\n session.setCookie(\n \"sessionid_ss\",\n config.cookies.sessionid_ss,\n \"https://www.tiktok.com\",\n );\n if (config.cookies[\"tt-target-idc\"]) {\n session.setCookie(\n \"tt-target-idc\",\n config.cookies[\"tt-target-idc\"],\n \"https://www.tiktok.com\",\n );\n }\n }\n\n return {\n get: async (url: string) => {\n const res = await session.fetch(url, {\n signal: AbortSignal.timeout(15000),\n } as any);\n return res as unknown as Response;\n },\n post: async (\n url: string,\n body?: BodyInit,\n headers?: Record<string, string>,\n ) => {\n const res = await session.fetch(url, {\n method: \"POST\",\n body,\n signal: AbortSignal.timeout(15000),\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n ...headers,\n },\n } as any);\n return res as unknown as Response;\n },\n close: async () => {\n await session.close();\n },\n };\n}\n"],"mappings":";;;;;;AAkBA,eAAsB,iBACpB,QACqB;CACrB,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAQ;CAKR,IAAI,OAAO,SAAS,cAAc;EAChC,QAAQ,UACN,gBACA,OAAO,QAAQ,cACf,wBACF;EACA,IAAI,OAAO,QAAQ,kBACjB,QAAQ,UACN,iBACA,OAAO,QAAQ,kBACf,wBACF;CAEJ;CAEA,OAAO;EACL,KAAK,OAAO,QAAgB;GAI1B,OAAO,MAHW,QAAQ,MAAM,KAAK,EACnC,QAAQ,YAAY,QAAQ,IAAK,EACnC,CAAQ;EAEV;EACA,MAAM,OACJ,KACA,MACA,YACG;GAUH,OAAO,MATW,QAAQ,MAAM,KAAK;IACnC,QAAQ;IACR;IACA,QAAQ,YAAY,QAAQ,IAAK;IACjC,SAAS;KACP,gBAAgB;KAChB,GAAG;IACL;GACF,CAAQ;EAEV;EACA,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF"}
|
package/dist/api/tiktok.mjs
CHANGED
|
@@ -28,29 +28,82 @@ function createTikTokApi(http) {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
async function fetchLiveInfo(user) {
|
|
31
|
+
let sigi = null;
|
|
31
32
|
try {
|
|
32
33
|
const res = await http.get(`${TIKTOK_BASE}/@${user}/live`);
|
|
33
34
|
if (!res.ok) return null;
|
|
34
|
-
|
|
35
|
+
sigi = extractSigiState(await res.text());
|
|
35
36
|
if (!sigi) return null;
|
|
36
37
|
const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom;
|
|
37
38
|
const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user;
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
if (liveRoom && userInfo?.roomId) {
|
|
40
|
+
const roomId = String(userInfo.roomId);
|
|
41
|
+
const isLive = liveRoom.status === 2;
|
|
42
|
+
let streamUrl = null;
|
|
43
|
+
if (isLive) {
|
|
44
|
+
streamUrl = extractStreamUrlFast(liveRoom);
|
|
45
|
+
if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi);
|
|
46
|
+
if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
roomId,
|
|
50
|
+
isLive,
|
|
51
|
+
streamUrl,
|
|
52
|
+
title: liveRoom.title ?? null
|
|
53
|
+
};
|
|
46
54
|
}
|
|
55
|
+
} catch (_err) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return fetchLiveInfoFallback(sigi, user);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Secondary live-info extraction when SIGI_STATE's LiveRoom data is
|
|
62
|
+
* async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and
|
|
63
|
+
* UserModule.users[user].roomId for the room ID. Stream URL is obtained
|
|
64
|
+
* from the Webcast API (room/info/) as a final fallback.
|
|
65
|
+
*/
|
|
66
|
+
async function fetchLiveInfoFallback(sigi, user) {
|
|
67
|
+
const isLive = sigi.LiveRoom?.liveRoomStatus === 2;
|
|
68
|
+
const userModule = sigi.UserModule?.users?.[user];
|
|
69
|
+
let roomId = null;
|
|
70
|
+
if (userModule?.roomId) roomId = typeof userModule.roomId === "object" ? String(userModule.roomId.id ?? "") : String(userModule.roomId);
|
|
71
|
+
if (roomId) {
|
|
72
|
+
const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null;
|
|
47
73
|
return {
|
|
48
74
|
roomId,
|
|
49
75
|
isLive,
|
|
50
76
|
streamUrl,
|
|
51
|
-
title:
|
|
77
|
+
title: null
|
|
52
78
|
};
|
|
53
|
-
}
|
|
79
|
+
}
|
|
80
|
+
if (userModule?.id && isLive) return fetchRoomInfoFromApi(userModule.id);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fallback: fetch room info + stream URL from the Webcast API.
|
|
85
|
+
* This covers cases where SIGI_STATE has no room data at all
|
|
86
|
+
* (fully async-loaded page).
|
|
87
|
+
*/
|
|
88
|
+
async function fetchRoomInfoFromApi(userId) {
|
|
89
|
+
try {
|
|
90
|
+
const url = `${WEBCAST_BASE}/webcast/room/enter/?aid=1988&user_id=${userId}`;
|
|
91
|
+
const res = await http.get(url);
|
|
92
|
+
if (!res.ok) return null;
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
if (text.length === 0) return null;
|
|
95
|
+
const roomData = JSON.parse(text)?.data?.room;
|
|
96
|
+
if (!roomData) return null;
|
|
97
|
+
const roomId = String(roomData.roomId ?? "");
|
|
98
|
+
if (!roomId) return null;
|
|
99
|
+
const isLive = roomData.status === 2;
|
|
100
|
+
return {
|
|
101
|
+
roomId,
|
|
102
|
+
isLive,
|
|
103
|
+
streamUrl: isLive ? findStreamUrlRecursively(roomData) : null,
|
|
104
|
+
title: roomData.title ?? null
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
54
107
|
return null;
|
|
55
108
|
}
|
|
56
109
|
}
|
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 from a single\n * SIGI_STATE HTML scrape. No webcast API calls needed.\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n */\n\nimport type { HttpClient } from './client'\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nconst TIKTOK_BASE = 'https://www.tiktok.com'\nconst WEBCAST_BASE = 'https://webcast.tiktok.com'\n\nexport function createTikTokApi(http: HttpClient): TikTokApi {\n // Per-tick cache — stores the last fetched live info so the three public\n // methods don't each fetch the page separately.\n let cached: LiveInfo | null = null\n let cachedUser = ''\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached\n cached = await fetchLiveInfo(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = ''\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user)\n return info?.roomId ?? null\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive\n }\n const info = await fetchLiveInfo(cachedUser || roomId)\n return info?.isLive ?? false\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId)\n return info?.streamUrl ?? null\n },\n }\n\n // ─── Internal: fetch and parse SIGI_STATE ─────────────────\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n try {\n const res = await http.get(`${TIKTOK_BASE}/@${user}/live`)\n if (!res.ok) return null\n\n const html = await res.text()\n const sigi = extractSigiState(html)\n if (!sigi) return null\n\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n if (!liveRoom || !userInfo?.roomId) return null\n\n const roomId = String(userInfo.roomId)\n const isLive = liveRoom.status === 2 // 2 = live, 4 = offline\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom)\n if (!streamUrl) {\n streamUrl = findStreamUrlRecursively(sigi)\n }\n if (!streamUrl) {\n streamUrl = await fetchStreamUrlFromApi(roomId)\n }\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null }\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n return null\n }\n }\n\n /**\n * Fallback: fetch stream URL from the Webcast API.\n * This covers cases where SIGI_STATE doesn't include stream data\n * (e.g. async-loaded room info).\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n if (!res.ok) return null\n\n const text = await res.text()\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null\n\n return findStreamUrlRecursively(data)\n } catch {\n return null\n }\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string\n isLive: boolean\n streamUrl: string | null\n title: string | null\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n 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}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\nfunction extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<NonNullable<SigiState['LiveRoom']>['liveRoomUserInfo']>['liveRoom'],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data\n if (!raw) return null\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined\n if (!data) return null\n\n // Try all known quality keys, preferring higher quality\n const qualities = ['origin', 'fhd', 'uhd', 'hd', 'sd', 'ld']\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string } } | undefined\n const url = entry?.main?.flv\n if (url) return url\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Base case: a string\n if (typeof obj === 'string') {\n // Direct URL match\n if (\n (obj.startsWith('http://') || obj.startsWith('https://')) &&\n (obj.includes('.flv') || obj.includes('.m3u8'))\n ) {\n return obj\n }\n // JSON-encoded string — parse and recurse (covers stream_data, etc.)\n if (obj.startsWith('{') || obj.startsWith('[')) {\n try {\n return findStreamUrlRecursively(JSON.parse(obj))\n } catch {\n return null\n }\n }\n return null\n }\n\n // Dict: recurse into all values\n if (obj && typeof obj === 'object' && !Array.isArray(obj)) {\n for (const val of Object.values(obj as Record<string, unknown>)) {\n const found = findStreamUrlRecursively(val)\n if (found) return found\n }\n return null\n }\n\n // Array: recurse into all elements\n if (Array.isArray(obj)) {\n for (const item of obj) {\n const found = findStreamUrlRecursively(item)\n if (found) return found\n }\n }\n\n return null\n}\n"],"mappings":"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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, ~95% of\n * cases). Fallback paths handle async-loaded room info by reading\n * UserModule / LiveRoom.liveRoomStatus from SIGI_STATE and, when needed,\n * calling the Webcast API (room/info/ or room/enter/).\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n */\n\nimport type { HttpClient } from \"./client\";\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>;\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>;\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>;\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void;\n}\n\nconst TIKTOK_BASE = \"https://www.tiktok.com\";\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\";\n\nexport function createTikTokApi(http: HttpClient): TikTokApi {\n // Per-tick cache — stores the last fetched live info so the three public\n // methods don't each fetch the page separately.\n let cached: LiveInfo | null = null;\n let cachedUser = \"\";\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached;\n cached = await fetchLiveInfo(user);\n cachedUser = user;\n return cached;\n }\n\n function invalidateCache(): void {\n cached = null;\n cachedUser = \"\";\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user);\n return info?.roomId ?? null;\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive;\n }\n const info = await fetchLiveInfo(cachedUser || roomId);\n return info?.isLive ?? false;\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId);\n return info?.streamUrl ?? null;\n },\n };\n\n // ─── Internal: fetch and parse SIGI_STATE ─────────────────\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n // Track whether SIGI_STATE actually existed (for retry decisions)\n let sigi: SigiState | null = null;\n\n try {\n const res = await http.get(`${TIKTOK_BASE}/@${user}/live`);\n if (!res.ok) return null;\n\n const html = await res.text();\n sigi = extractSigiState(html);\n if (!sigi) return null;\n\n // ── Primary path: room info in LiveRoom.liveRoomUserInfo ──\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom;\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user;\n\n if (liveRoom && userInfo?.roomId) {\n const roomId = String(userInfo.roomId);\n const isLive = liveRoom.status === 2; // 2 = live, 4 = offline\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null;\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom);\n if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi);\n if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId);\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null };\n }\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n return null;\n }\n\n // ── Fallback path: async-loaded room info ──\n // When LiveRoom.liveRoomUserInfo is absent, check LiveRoom.liveRoomStatus\n // and UserModule.users[user].roomId, then use the Webcast API for stream URL.\n return fetchLiveInfoFallback(sigi, user);\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(\n sigi: SigiState,\n user: string,\n ): Promise<LiveInfo | null> {\n const liveRoomStatus = sigi.LiveRoom?.liveRoomStatus;\n const isLive = liveRoomStatus === 2; // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[user];\n let roomId: string | null = null;\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId);\n }\n\n // If we have a roomId, use Webcast room/info for the stream URL\n if (roomId) {\n const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null;\n return { roomId, isLive, streamUrl, title: null };\n }\n\n // Final fallback: Webcast room/enter API using the numeric user ID\n if (userModule?.id && isLive) {\n return fetchRoomInfoFromApi(userModule.id);\n }\n\n return null; // truly offline / unknown\n }\n\n /**\n * Fallback: fetch room info + stream URL from the Webcast API.\n * This covers cases where SIGI_STATE has no room data at all\n * (fully async-loaded page).\n */\n async function fetchRoomInfoFromApi(\n userId: string,\n ): Promise<LiveInfo | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/enter/?aid=1988&user_id=${userId}`;\n const res = await http.get(url);\n if (!res.ok) return null;\n\n const text = await res.text();\n if (text.length === 0) return null;\n\n const data = JSON.parse(text) as Record<string, unknown>;\n const roomData = (data as any)?.data?.room;\n if (!roomData) return null;\n\n const roomId = String(roomData.roomId ?? \"\");\n if (!roomId) return null;\n\n const roomStatus = roomData.status; // 2 = live\n const isLive = roomStatus === 2;\n const streamUrl = isLive ? findStreamUrlRecursively(roomData) : null;\n\n return { roomId, isLive, streamUrl, title: roomData.title ?? null };\n } catch {\n return null;\n }\n }\n\n /**\n * Fallback: fetch stream URL from the Webcast API.\n * This covers cases where SIGI_STATE doesn't include stream data\n * (e.g. async-loaded room info).\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`;\n const res = await http.get(url);\n if (!res.ok) return null;\n\n const text = await res.text();\n if (text.length === 0) return null;\n\n const data = JSON.parse(text) as Record<string, unknown>;\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null;\n\n return findStreamUrlRecursively(data);\n } catch {\n return null;\n }\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string;\n isLive: boolean;\n streamUrl: string | null;\n title: string | null;\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number;\n liveRoomUserInfo?: {\n user?: {\n id?: string | number;\n roomId?: string | number;\n };\n liveRoom?: {\n status?: number; // 2 = live, 4 = offline\n title?: string;\n startTime?: number;\n streamData?: {\n pull_data?: {\n stream_data?: string; // JSON string with FLV/HLS URLs\n };\n };\n };\n };\n };\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string; // numeric user ID\n uniqueId?: string;\n roomId?: string | { id: string };\n };\n };\n };\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\nfunction extractSigiState(html: string): SigiState | null {\n const match = html.match(\n /<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n );\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// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<\n NonNullable<SigiState[\"LiveRoom\"]>[\"liveRoomUserInfo\"]\n >[\"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\n | Record<string, unknown>\n | undefined;\n if (!data) return null;\n\n // Try all known quality keys, preferring higher quality\n const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"];\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string } } | undefined;\n const url = entry?.main?.flv;\n if (url) return url;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Base case: a string\n if (typeof obj === \"string\") {\n // Direct URL match\n if (\n (obj.startsWith(\"http://\") || obj.startsWith(\"https://\")) &&\n (obj.includes(\".flv\") || obj.includes(\".m3u8\"))\n ) {\n return obj;\n }\n // JSON-encoded string — parse and recurse (covers stream_data, etc.)\n if (obj.startsWith(\"{\") || obj.startsWith(\"[\")) {\n try {\n return findStreamUrlRecursively(JSON.parse(obj));\n } catch {\n return null;\n }\n }\n return null;\n }\n\n // Dict: recurse into all values\n if (obj && typeof obj === \"object\" && !Array.isArray(obj)) {\n for (const val of Object.values(obj as Record<string, unknown>)) {\n const found = findStreamUrlRecursively(val);\n if (found) return found;\n }\n return null;\n }\n\n // Array: recurse into all elements\n if (Array.isArray(obj)) {\n for (const item of obj) {\n const found = findStreamUrlRecursively(item);\n if (found) return found;\n }\n }\n\n return null;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
//#region src/browser-cookies.ts
|
|
6
|
+
/**
|
|
7
|
+
* Browser cookie extraction — reads TikTok session cookies from Firefox's
|
|
8
|
+
* cookie store using bun:sqlite (no external dependencies).
|
|
9
|
+
*
|
|
10
|
+
* Falls back to cookies.json if Firefox isn't available or if no TikTok
|
|
11
|
+
* login session is found.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Extract TikTok session cookies from Firefox's cookie store.
|
|
15
|
+
*
|
|
16
|
+
* Searches all profile directories under ~/.mozilla/firefox/ for a
|
|
17
|
+
* cookies.sqlite file, then queries it for TikTok session cookies.
|
|
18
|
+
*
|
|
19
|
+
* Returns null if Firefox isn't found, cookies can't be read, or no
|
|
20
|
+
* TikTok login session exists.
|
|
21
|
+
*/
|
|
22
|
+
function extractTikTokCookiesFromFirefox() {
|
|
23
|
+
const dbPath = findFirefoxCookieDb();
|
|
24
|
+
if (!dbPath) return null;
|
|
25
|
+
try {
|
|
26
|
+
const db = new Database(dbPath, { readonly: true });
|
|
27
|
+
try {
|
|
28
|
+
const rows = db.query(`SELECT name, value FROM moz_cookies
|
|
29
|
+
WHERE host LIKE '%.tiktok.com'
|
|
30
|
+
AND name IN ('sessionid_ss', 'tt-target-idc')`).all();
|
|
31
|
+
const session = rows.find((r) => r.name === "sessionid_ss");
|
|
32
|
+
if (!session?.value) return null;
|
|
33
|
+
const result = { sessionid_ss: session.value };
|
|
34
|
+
const idc = rows.find((r) => r.name === "tt-target-idc");
|
|
35
|
+
if (idc?.value) result["tt-target-idc"] = idc.value;
|
|
36
|
+
return result;
|
|
37
|
+
} finally {
|
|
38
|
+
db.close();
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Locate the Firefox cookies.sqlite by scanning profile directories.
|
|
46
|
+
*
|
|
47
|
+
* Strategy:
|
|
48
|
+
* 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.
|
|
49
|
+
* 2. Fall back to scanning directory entries that look like Firefox profiles
|
|
50
|
+
* (contain a cookies.sqlite file).
|
|
51
|
+
*/
|
|
52
|
+
function findFirefoxCookieDb() {
|
|
53
|
+
const firefoxDir = join(homedir(), ".mozilla", "firefox");
|
|
54
|
+
if (!existsSync(firefoxDir)) return null;
|
|
55
|
+
const iniPath = join(firefoxDir, "profiles.ini");
|
|
56
|
+
if (existsSync(iniPath)) try {
|
|
57
|
+
const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir);
|
|
58
|
+
if (profilePath) {
|
|
59
|
+
const dbPath = join(profilePath, "cookies.sqlite");
|
|
60
|
+
if (existsSync(dbPath)) return dbPath;
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
const entries = readdirSync(firefoxDir, { withFileTypes: true });
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
66
|
+
const dbPath = join(firefoxDir, entry.name, "cookies.sqlite");
|
|
67
|
+
if (existsSync(dbPath)) return dbPath;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse Firefox's profiles.ini to find the default profile path.
|
|
73
|
+
*
|
|
74
|
+
* The INI format looks like:
|
|
75
|
+
* [Profile0]
|
|
76
|
+
* Name=default
|
|
77
|
+
* IsRelative=1
|
|
78
|
+
* Path=1pese0rl.default-release
|
|
79
|
+
* Default=1
|
|
80
|
+
*/
|
|
81
|
+
function parseFirefoxProfilesIni(iniPath, firefoxDir) {
|
|
82
|
+
const text = readFileSync(iniPath, "utf-8");
|
|
83
|
+
let defaultPath = null;
|
|
84
|
+
let isRelative = true;
|
|
85
|
+
let inProfile = false;
|
|
86
|
+
for (const line of text.split("\n")) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
89
|
+
if (inProfile && defaultPath) break;
|
|
90
|
+
inProfile = trimmed.toLowerCase().startsWith("[profile");
|
|
91
|
+
if (inProfile) {
|
|
92
|
+
defaultPath = null;
|
|
93
|
+
isRelative = true;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!inProfile) continue;
|
|
98
|
+
if (trimmed.startsWith("path=")) defaultPath = trimmed.slice(5);
|
|
99
|
+
else if (trimmed.startsWith("isrelative=")) isRelative = trimmed.slice(11).trim() !== "0";
|
|
100
|
+
else if (trimmed.startsWith("default=")) {
|
|
101
|
+
if (trimmed.slice(8).trim() !== "1") {
|
|
102
|
+
inProfile = false;
|
|
103
|
+
defaultPath = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!defaultPath) return null;
|
|
108
|
+
return isRelative ? join(firefoxDir, defaultPath) : defaultPath;
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
export { extractTikTokCookiesFromFirefox };
|
|
112
|
+
|
|
113
|
+
//# sourceMappingURL=browser-cookies.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-cookies.mjs","names":[],"sources":["../src/browser-cookies.ts"],"sourcesContent":["/**\n * Browser cookie extraction — reads TikTok session cookies from Firefox's\n * cookie store using bun:sqlite (no external dependencies).\n *\n * Falls back to cookies.json if Firefox isn't available or if no TikTok\n * login session is found.\n */\n\nimport { Database } from \"bun:sqlite\";\nimport { existsSync, readFileSync, readdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport interface TikTokCookies {\n sessionid_ss: string;\n \"tt-target-idc\"?: string;\n}\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Searches all profile directories under ~/.mozilla/firefox/ for a\n * cookies.sqlite file, then queries it for TikTok session cookies.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * TikTok login session exists.\n */\nexport function extractTikTokCookiesFromFirefox(): TikTokCookies | null {\n const dbPath = findFirefoxCookieDb();\n if (!dbPath) return null;\n\n try {\n const db = new Database(dbPath, { readonly: true });\n try {\n const rows = db\n .query(\n `SELECT name, value FROM moz_cookies\n WHERE host LIKE '%.tiktok.com'\n AND name IN ('sessionid_ss', 'tt-target-idc')`,\n )\n .all() as Array<{ name: string; value: string }>;\n\n const session = rows.find((r) => r.name === \"sessionid_ss\");\n if (!session?.value) return null;\n\n const result: TikTokCookies = { sessionid_ss: session.value };\n const idc = rows.find((r) => r.name === \"tt-target-idc\");\n if (idc?.value) result[\"tt-target-idc\"] = idc.value;\n\n return result;\n } finally {\n db.close();\n }\n } catch {\n return null;\n }\n}\n\n/**\n * Locate the Firefox cookies.sqlite by scanning profile directories.\n *\n * Strategy:\n * 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.\n * 2. Fall back to scanning directory entries that look like Firefox profiles\n * (contain a cookies.sqlite file).\n */\nfunction findFirefoxCookieDb(): string | null {\n const firefoxDir = join(homedir(), \".mozilla\", \"firefox\");\n if (!existsSync(firefoxDir)) return null;\n\n // Try profiles.ini first for a reliable default-profile path\n const iniPath = join(firefoxDir, \"profiles.ini\");\n if (existsSync(iniPath)) {\n try {\n const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir);\n if (profilePath) {\n const dbPath = join(profilePath, \"cookies.sqlite\");\n if (existsSync(dbPath)) return dbPath;\n }\n } catch {\n // fall through to directory scan\n }\n }\n\n // Fallback: scan for any directory containing cookies.sqlite\n const entries = readdirSync(firefoxDir, { withFileTypes: true });\n for (const entry of entries) {\n if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;\n const dbPath = join(firefoxDir, entry.name, \"cookies.sqlite\");\n if (existsSync(dbPath)) return dbPath;\n }\n\n return null;\n}\n\n/**\n * Parse Firefox's profiles.ini to find the default profile path.\n *\n * The INI format looks like:\n * [Profile0]\n * Name=default\n * IsRelative=1\n * Path=1pese0rl.default-release\n * Default=1\n */\nfunction parseFirefoxProfilesIni(\n iniPath: string,\n firefoxDir: string,\n): string | null {\n const text = readFileSync(iniPath, \"utf-8\");\n\n let defaultPath: string | null = null;\n let isRelative = true;\n let inProfile = false;\n\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim();\n\n // Section header\n if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) {\n if (inProfile && defaultPath) break; // found it\n inProfile = trimmed.toLowerCase().startsWith(\"[profile\");\n if (inProfile) {\n defaultPath = null;\n isRelative = true;\n }\n continue;\n }\n\n if (!inProfile) continue;\n\n if (trimmed.startsWith(\"path=\")) {\n defaultPath = trimmed.slice(5);\n } else if (trimmed.startsWith(\"isrelative=\")) {\n isRelative = trimmed.slice(11).trim() !== \"0\";\n } else if (trimmed.startsWith(\"default=\")) {\n if (trimmed.slice(8).trim() !== \"1\") {\n // Not the default profile — skip\n inProfile = false;\n defaultPath = null;\n }\n }\n }\n\n if (!defaultPath) return null;\n return isRelative ? join(firefoxDir, defaultPath) : defaultPath;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,kCAAwD;CACtE,MAAM,SAAS,oBAAoB;CACnC,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI;EACF,MAAM,KAAK,IAAI,SAAS,QAAQ,EAAE,UAAU,KAAK,CAAC;EAClD,IAAI;GACF,MAAM,OAAO,GACV,MACC;;yDAGF,CAAC,CACA,IAAI;GAEP,MAAM,UAAU,KAAK,MAAM,MAAM,EAAE,SAAS,cAAc;GAC1D,IAAI,CAAC,SAAS,OAAO,OAAO;GAE5B,MAAM,SAAwB,EAAE,cAAc,QAAQ,MAAM;GAC5D,MAAM,MAAM,KAAK,MAAM,MAAM,EAAE,SAAS,eAAe;GACvD,IAAI,KAAK,OAAO,OAAO,mBAAmB,IAAI;GAE9C,OAAO;EACT,UAAU;GACR,GAAG,MAAM;EACX;CACF,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;AAUA,SAAS,sBAAqC;CAC5C,MAAM,aAAa,KAAK,QAAQ,GAAG,YAAY,SAAS;CACxD,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;CAGpC,MAAM,UAAU,KAAK,YAAY,cAAc;CAC/C,IAAI,WAAW,OAAO,GACpB,IAAI;EACF,MAAM,cAAc,wBAAwB,SAAS,UAAU;EAC/D,IAAI,aAAa;GACf,MAAM,SAAS,KAAK,aAAa,gBAAgB;GACjD,IAAI,WAAW,MAAM,GAAG,OAAO;EACjC;CACF,QAAQ,CAER;CAIF,MAAM,UAAU,YAAY,YAAY,EAAE,eAAe,KAAK,CAAC;CAC/D,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,CAAC,MAAM,YAAY,KAAK,CAAC,MAAM,eAAe,GAAG;EACrD,MAAM,SAAS,KAAK,YAAY,MAAM,MAAM,gBAAgB;EAC5D,IAAI,WAAW,MAAM,GAAG,OAAO;CACjC;CAEA,OAAO;AACT;;;;;;;;;;;AAYA,SAAS,wBACP,SACA,YACe;CACf,MAAM,OAAO,aAAa,SAAS,OAAO;CAE1C,IAAI,cAA6B;CACjC,IAAI,aAAa;CACjB,IAAI,YAAY;CAEhB,KAAK,MAAM,QAAQ,KAAK,MAAM,IAAI,GAAG;EACnC,MAAM,UAAU,KAAK,KAAK;EAG1B,IAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG,GAAG;GACpD,IAAI,aAAa,aAAa;GAC9B,YAAY,QAAQ,YAAY,CAAC,CAAC,WAAW,UAAU;GACvD,IAAI,WAAW;IACb,cAAc;IACd,aAAa;GACf;GACA;EACF;EAEA,IAAI,CAAC,WAAW;EAEhB,IAAI,QAAQ,WAAW,OAAO,GAC5B,cAAc,QAAQ,MAAM,CAAC;OACxB,IAAI,QAAQ,WAAW,aAAa,GACzC,aAAa,QAAQ,MAAM,EAAE,CAAC,CAAC,KAAK,MAAM;OACrC,IAAI,QAAQ,WAAW,UAAU;OAClC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,KAAK;IAEnC,YAAY;IACZ,cAAc;GAChB;;CAEJ;CAEA,IAAI,CAAC,aAAa,OAAO;CACzB,OAAO,aAAa,KAAK,YAAY,WAAW,IAAI;AACtD"}
|
package/dist/cli.mjs.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
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from \"@zfadhli/koko-cli\";\nimport pkg from \"../package.json\";\nimport type { LogLevel, RecorderConfig } from \"./config\";\nimport { TikTokError } from \"./config\";\nimport { sanitizeUser } from \"./utils\";\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined;\n let error: string | undefined;\n\n const cli = createCLI(\"tiktok-live-recorder\", pkg.version).description(\n \"Minimal TikTok live stream recorder — Bun + TypeScript\",\n );\n\n cli.command(\"\", \"Record a TikTok live stream\", (cmd) => {\n cmd.option(\n \"-u, --user <name>\",\n \"TikTok username (or pass as first argument)\",\n );\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(\n \"-l, --log-level <level>\",\n \"Log level: debug | info | warn | error\",\n );\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\");\n cmd.option(\n \"-s, --segment-minutes <minutes>\",\n \"Segment duration in minutes\",\n { default: \"20\" },\n );\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(\n \"config-error\",\n \"<user> is required (positional or --user)\",\n );\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(\n \"config-error\",\n \"--interval must be a number >= 1\",\n );\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(\n \"config-error\",\n \"--duration must be a number >= 0 (minutes)\",\n );\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 < 1) {\n throw new TikTokError(\n \"config-error\",\n \"--segment-minutes must be a number >= 1\",\n );\n }\n parsed.segmentMinutes = n;\n }\n\n config = parsed;\n } catch (err) {\n error = err instanceof Error ? err.message : String(err);\n }\n });\n });\n\n // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n const positionalUser = extractPositional(argv);\n if (positionalUser) {\n argv.push(\"--user\", positionalUser);\n }\n\n cli.parse(argv);\n\n if (error) {\n throw new TikTokError(\"config-error\", error);\n }\n\n if (!config) {\n throw new TikTokError(\n \"config-error\",\n \"<user> is required (positional or --user). Use --help for usage.\",\n );\n }\n\n return config;\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i]!;\n // -- signals end of options; anything after is a positional\n if (arg === \"--\") {\n const pos = argv.splice(i, 1)[0]!;\n return pos;\n }\n // If this arg is a flag (-*) the next arg is its value\n if (arg.startsWith(\"-\")) {\n // Skip the value if this flag takes one (not a boolean flag like --help/--version)\n const next = argv[i + 1];\n if (\n next &&\n !next.startsWith(\"-\") &&\n arg !== \"--help\" &&\n arg !== \"--version\"\n ) {\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":";;;;;;;;;AAWA,SAAgB,UAAU,OAAiB,QAAQ,MAAsB;CACvE,IAAI;CACJ,IAAI;CAEJ,MAAM,MAAM,UAAU,wBAAwBA,OAAW,CAAC,CAAC,YACzD,wDACF;CAEA,IAAI,QAAQ,IAAI,gCAAgC,QAAQ;EACtD,IAAI,OACF,qBACA,6CACF;EACA,IAAI,OAAO,sBAAsB,oBAAoB,EACnD,SAAS,eACX,CAAC;EACD,IAAI,OAAO,4BAA4B,+BAA+B,EACpE,SAAS,IACX,CAAC;EACD,IAAI,OAAO,4BAA4B,mCAAmC;EAC1E,IAAI,OAAO,qBAAqB,yCAAyC;EACzE,IAAI,OACF,2BACA,wCACF;EACA,IAAI,OAAO,wBAAwB,sBAAsB;EACzD,IAAI,OACF,mCACA,+BACA,EAAE,SAAS,KAAK,CAClB;EAEA,IAAI,QAAQ,SAAkC;GAC5C,IAAI;IACF,MAAM,OAAO,KAAK;IAClB,IAAI,CAAC,MACH,MAAM,IAAI,YACR,gBACA,2CACF;IAGF,MAAM,SAAyB,EAC7B,MAAM,aAAa,IAAI,EACzB;IAEA,IAAI,KAAK,QAAQ,OAAO,YAAY,KAAK;IACzC,IAAI,KAAK,aAAa,KAAA,GAAW;KAC/B,MAAM,IAAI,OAAO,KAAK,QAAQ;KAC9B,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YACR,gBACA,kCACF;KAEF,OAAO,WAAW;IACpB;IACA,IAAI,KAAK,aAAa,KAAA,GAAW;KAC/B,MAAM,IAAI,OAAO,KAAK,QAAQ;KAC9B,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YACR,gBACA,4CACF;KAEF,OAAO,WAAW,IAAI;IACxB;IACA,IAAI,KAAK,OAAO,OAAO,QAAQ,KAAK;IACpC,IAAI,KAAK,UAAU;KACjB,MAAM,SAAS;MAAC;MAAS;MAAQ;MAAQ;KAAO;KAChD,IAAI,CAAC,OAAO,SAAS,KAAK,QAAkB,GAC1C,MAAM,IAAI,YACR,gBACA,+BAA+B,OAAO,KAAK,IAAI,GACjD;KAEF,OAAO,WAAW,KAAK;IACzB;IACA,IAAI,KAAK,SAAS,OAAO,cAAc,KAAK;IAC5C,IAAI,KAAK,mBAAmB,KAAA,GAAW;KACrC,MAAM,IAAI,OAAO,KAAK,cAAc;KACpC,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YACR,gBACA,yCACF;KAEF,OAAO,iBAAiB;IAC1B;IAEA,SAAS;GACX,SAAS,KAAK;IACZ,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GACzD;EACF,CAAC;CACH,CAAC;CAID,MAAM,iBAAiB,kBAAkB,IAAI;CAC7C,IAAI,gBACF,KAAK,KAAK,UAAU,cAAc;CAGpC,IAAI,MAAM,IAAI;CAEd,IAAI,OACF,MAAM,IAAI,YAAY,gBAAgB,KAAK;CAG7C,IAAI,CAAC,QACH,MAAM,IAAI,YACR,gBACA,kEACF;CAGF,OAAO;AACT;;;;;AAMA,SAAS,kBAAkB,MAA+B;CACxD,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EACpC,MAAM,MAAM,KAAK;EAEjB,IAAI,QAAQ,MAEV,OADY,KAAK,OAAO,GAAG,CAAC,CAAC,CAAC;EAIhC,IAAI,IAAI,WAAW,GAAG,GAAG;GAEvB,MAAM,OAAO,KAAK,IAAI;GACtB,IACE,QACA,CAAC,KAAK,WAAW,GAAG,KACpB,QAAQ,YACR,QAAQ,aAER;GAEF;EACF;EAEA,KAAK,OAAO,GAAG,CAAC;EAChB,OAAO;CACT;CACA,OAAO;AACT"}
|
package/dist/config.d.mts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
interface CookieAuth {
|
|
7
7
|
sessionid_ss: string;
|
|
8
|
-
|
|
8
|
+
"tt-target-idc"?: string;
|
|
9
9
|
}
|
|
10
|
-
type LogLevel =
|
|
10
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
11
11
|
interface RecorderConfig {
|
|
12
12
|
/** TikTok username (required) */
|
|
13
13
|
user: string;
|
|
@@ -41,7 +41,7 @@ interface RecorderController {
|
|
|
41
41
|
on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void;
|
|
42
42
|
}
|
|
43
43
|
interface RecorderStatus {
|
|
44
|
-
state:
|
|
44
|
+
state: "idle" | "polling" | "recording" | "converting" | "stopped";
|
|
45
45
|
user: string;
|
|
46
46
|
currentFile?: string;
|
|
47
47
|
sessionDuration?: number;
|
|
@@ -58,33 +58,33 @@ interface RecorderEventHandler {
|
|
|
58
58
|
isLive: boolean;
|
|
59
59
|
roomId?: string;
|
|
60
60
|
}) => void;
|
|
61
|
-
|
|
61
|
+
"recording:start": (info: {
|
|
62
62
|
user: string;
|
|
63
63
|
file: string;
|
|
64
64
|
}) => void;
|
|
65
|
-
|
|
65
|
+
"download:progress": (info: {
|
|
66
66
|
bytes: number;
|
|
67
67
|
elapsed: number;
|
|
68
68
|
speed: number;
|
|
69
69
|
}) => void;
|
|
70
|
-
|
|
70
|
+
"download:end": (info: {
|
|
71
71
|
file: string;
|
|
72
72
|
duration: number;
|
|
73
73
|
size: number;
|
|
74
74
|
}) => void;
|
|
75
|
-
|
|
75
|
+
"recording:end": (info: {
|
|
76
76
|
file: string;
|
|
77
77
|
duration: number;
|
|
78
78
|
size: number;
|
|
79
79
|
}) => void;
|
|
80
|
-
|
|
80
|
+
"segmenting:start": (info: {
|
|
81
81
|
input: string;
|
|
82
82
|
outputPattern: string;
|
|
83
83
|
}) => void;
|
|
84
|
-
|
|
84
|
+
"segmenting:end": (info: {
|
|
85
85
|
segments: number;
|
|
86
86
|
}) => void;
|
|
87
|
-
|
|
87
|
+
"converting:start": (info: {
|
|
88
88
|
input: string;
|
|
89
89
|
}) => void;
|
|
90
90
|
converted: (info: {
|
|
@@ -93,7 +93,7 @@ interface RecorderEventHandler {
|
|
|
93
93
|
}) => void;
|
|
94
94
|
error: (err: TikTokError) => void;
|
|
95
95
|
}
|
|
96
|
-
type AppErrorKind =
|
|
96
|
+
type AppErrorKind = "user-not-live" | "room-not-found" | "stream-url-not-found" | "waf-blocked" | "country-blocked" | "network-error" | "ffmpeg-not-found" | "config-error" | "unknown";
|
|
97
97
|
declare class TikTokError extends Error {
|
|
98
98
|
readonly kind: AppErrorKind;
|
|
99
99
|
readonly cause?: unknown | undefined;
|
package/dist/config.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AAKA;;;UAAiB,UAAA;EACf,YAAA;EACA,eAAe;AAAA;AAAA,KAGL,QAAA;AAAA,UAEK,cAAA;EAFG;EAIlB,IAAA;EAF6B;EAI7B,SAAA;EAYmB;EAVnB,QAAA;EAFA;EAIA,QAAA;EAAA;EAEA,KAAA;EAEA;EAAA,WAAA;EAEU;EAAV,OAAA,GAAU,UAAA;EAEC;EAAX,QAAA,GAAW,QAAQ;EAInB;EAFA,UAAA;EAEc;EAAd,cAAA;AAAA;AAAA,UAGe,kBAAA;EAEN;EAAT,KAAA,IAAS,OAAA;EAII;EAFb,IAAA,IAAQ,OAAA;
|
|
1
|
+
{"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AAKA;;;UAAiB,UAAA;EACf,YAAA;EACA,eAAe;AAAA;AAAA,KAGL,QAAA;AAAA,UAEK,cAAA;EAFG;EAIlB,IAAA;EAF6B;EAI7B,SAAA;EAYmB;EAVnB,QAAA;EAFA;EAIA,QAAA;EAAA;EAEA,KAAA;EAEA;EAAA,WAAA;EAEU;EAAV,OAAA,GAAU,UAAA;EAEC;EAAX,QAAA,GAAW,QAAQ;EAInB;EAFA,UAAA;EAEc;EAAd,cAAA;AAAA;AAAA,UAGe,kBAAA;EAEN;EAAT,KAAA,IAAS,OAAA;EAII;EAFb,IAAA,IAAQ,OAAA;EAKC;EAHT,SAAA,IAAa,cAAA;EAImB;EAFhC,EAAA,iBAAmB,oBAAA,EACjB,KAAA,EAAO,CAAA,EACP,OAAA,EAAS,oBAAA,CAAqB,CAAA;AAAA;AAAA,UAIjB,cAAA;EACf,KAAA;EACA,IAAA;EACA,WAAA;EACA,eAAA;EACA,YAAA;EACA,SAAA;AAAA;AAAA,KAGU,aAAA,SAAsB,oBAAoB;AAAA,UAErC,oBAAA;EACf,QAAA,GAAW,IAAA;IAAQ,IAAA;EAAA;EACnB,IAAA,GAAO,IAAA;IAAQ,IAAA;IAAc,MAAA;IAAiB,MAAA;EAAA;EAC9C,iBAAA,GAAoB,IAAA;IAAQ,IAAA;IAAc,IAAA;EAAA;EAC1C,mBAAA,GAAsB,IAAA;IACpB,KAAA;IACA,OAAA;IACA,KAAA;EAAA;EAEF,cAAA,GAAiB,IAAA;IACf,IAAA;IACA,QAAA;IACA,IAAA;EAAA;EAEF,eAAA,GAAkB,IAAA;IAChB,IAAA;IACA,QAAA;IACA,IAAA;EAAA;EAEF,kBAAA,GAAqB,IAAA;IAAQ,KAAA;IAAe,aAAA;EAAA;EAC5C,gBAAA,GAAmB,IAAA;IAAQ,QAAA;EAAA;EAC3B,kBAAA,GAAqB,IAAA;IAAQ,KAAA;EAAA;EAC7B,SAAA,GAAY,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACnC,KAAA,GAAQ,GAAA,EAAK,WAAW;AAAA;AAAA,KAGd,YAAA;AAAA,cAWC,WAAA,SAAoB,KAAA;EAAA,SAEb,IAAA,EAAM,YAAA;EAAA,SAEN,KAAA;cAFA,IAAA,EAAM,YAAA,EACtB,OAAA,UACgB,KAAA;AAAA"}
|
package/dist/config.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\nexport interface CookieAuth {\n sessionid_ss: string
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\nexport interface CookieAuth {\n sessionid_ss: string;\n \"tt-target-idc\"?: string;\n}\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport interface RecorderConfig {\n /** TikTok username (required) */\n user: string;\n /** Output directory for recordings (default: ./recordings) */\n outputDir?: string;\n /** Polling interval in minutes (default: 3) */\n interval?: number;\n /** Max recording duration in seconds (default: 0 — unlimited). CLI accepts minutes, converted internally. */\n duration?: number;\n /** HTTP proxy URL (e.g. http://127.0.0.1:8080) */\n proxy?: string;\n /** Path to cookies.json (default: ./cookies.json) */\n cookiesPath?: string;\n /** Cookie auth loaded from cookies.json */\n cookies?: CookieAuth;\n /** Log level (default: info) */\n logLevel?: LogLevel;\n /** Whether to print logs to console (default: true). Set false when using Display-based UI. */\n logConsole?: boolean;\n /** Segment duration in minutes (default: 20). Stream is cut into segments of this length. */\n segmentMinutes?: number;\n}\n\nexport interface RecorderController {\n /** Start the polling loop */\n start(): Promise<void>;\n /** Gracefully stop (joins active recording, then exits) */\n stop(): Promise<void>;\n /** Snapshot of current state */\n getStatus(): RecorderStatus;\n /** Subscribe to events */\n on<E extends keyof RecorderEventHandler>(\n event: E,\n handler: RecorderEventHandler[E],\n ): void;\n}\n\nexport interface RecorderStatus {\n state: \"idle\" | \"polling\" | \"recording\" | \"converting\" | \"stopped\";\n user: string;\n currentFile?: string;\n sessionDuration?: number;\n lastPollTime?: string;\n lastError?: string;\n}\n\nexport type RecorderEvent = keyof RecorderEventHandler;\n\nexport interface RecorderEventHandler {\n checking: (info: { user: string }) => void;\n tick: (info: { user: string; isLive: boolean; roomId?: string }) => void;\n \"recording:start\": (info: { user: string; file: string }) => void;\n \"download:progress\": (info: {\n bytes: number;\n elapsed: number;\n speed: number;\n }) => void;\n \"download:end\": (info: {\n file: string;\n duration: number;\n size: number;\n }) => void;\n \"recording:end\": (info: {\n file: string;\n duration: number;\n size: number;\n }) => void;\n \"segmenting:start\": (info: { input: string; outputPattern: string }) => void;\n \"segmenting:end\": (info: { segments: number }) => void;\n \"converting:start\": (info: { input: string }) => void;\n converted: (info: { input: string; output: string }) => void;\n error: (err: TikTokError) => void;\n}\n\nexport type AppErrorKind =\n | \"user-not-live\"\n | \"room-not-found\"\n | \"stream-url-not-found\"\n | \"waf-blocked\"\n | \"country-blocked\"\n | \"network-error\"\n | \"ffmpeg-not-found\"\n | \"config-error\"\n | \"unknown\";\n\nexport class TikTokError extends Error {\n constructor(\n public readonly kind: AppErrorKind,\n message: string,\n public readonly cause?: unknown,\n ) {\n super(message);\n this.name = \"TikTokError\";\n }\n}\n\nconst DEFAULTS = {\n outputDir: \"./recordings\",\n interval: 3,\n duration: 0,\n logLevel: \"info\" as LogLevel,\n logConsole: true,\n segmentMinutes: 20,\n};\n\nexport function normalizeConfig(\n config: RecorderConfig,\n): Required<Omit<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\">> &\n Pick<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\"> {\n return {\n user: config.user,\n outputDir: config.outputDir ?? DEFAULTS.outputDir,\n interval: config.interval ?? DEFAULTS.interval,\n duration: config.duration ?? DEFAULTS.duration,\n logLevel: config.logLevel ?? DEFAULTS.logLevel,\n logConsole: config.logConsole ?? DEFAULTS.logConsole,\n proxy: config.proxy,\n cookies: config.cookies,\n cookiesPath: config.cookiesPath,\n segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes,\n };\n}\n\nexport function validateConfig(config: RecorderConfig): void {\n if (!config.user || config.user.trim().length === 0) {\n throw new TikTokError(\"config-error\", \"--user is required\");\n }\n if (config.interval !== undefined && config.interval < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be >= 1 minute\");\n }\n if (config.duration !== undefined && config.duration < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be >= 0 seconds\");\n }\n}\n"],"mappings":";AAiGA,IAAa,cAAb,cAAiC,MAAM;CAEnB;CAEA;CAHlB,YACE,MACA,SACA,OACA;EACA,MAAM,OAAO;EAJG,KAAA,OAAA;EAEA,KAAA,QAAA;EAGhB,KAAK,OAAO;CACd;AACF;AAEA,MAAM,WAAW;CACf,WAAW;CACX,UAAU;CACV,UAAU;CACV,UAAU;CACV,YAAY;CACZ,gBAAgB;AAClB;AAEA,SAAgB,gBACd,QAE0D;CAC1D,OAAO;EACL,MAAM,OAAO;EACb,WAAW,OAAO,aAAa,SAAS;EACxC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,YAAY,OAAO,cAAc,SAAS;EAC1C,OAAO,OAAO;EACd,SAAS,OAAO;EAChB,aAAa,OAAO;EACpB,gBAAgB,OAAO,kBAAkB,SAAS;CACpD;AACF;AAEA,SAAgB,eAAe,QAA8B;CAC3D,IAAI,CAAC,OAAO,QAAQ,OAAO,KAAK,KAAK,CAAC,CAAC,WAAW,GAChD,MAAM,IAAI,YAAY,gBAAgB,oBAAoB;CAE5D,IAAI,OAAO,aAAa,KAAA,KAAa,OAAO,WAAW,GACrD,MAAM,IAAI,YAAY,gBAAgB,gCAAgC;CAExE,IAAI,OAAO,aAAa,KAAA,KAAa,OAAO,WAAW,GACrD,MAAM,IAAI,YAAY,gBAAgB,iCAAiC;AAE3E"}
|
package/dist/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { TikTokError, validateConfig } from "./config.mjs";
|
|
|
3
3
|
import { bytesToHuman } from "./utils.mjs";
|
|
4
4
|
import { createRecorder } from "./recorder/index.mjs";
|
|
5
5
|
import "./lib.mjs";
|
|
6
|
+
import { extractTikTokCookiesFromFirefox } from "./browser-cookies.mjs";
|
|
6
7
|
import { parseArgs } from "./cli.mjs";
|
|
7
8
|
import { createDisplay } from "./ui.mjs";
|
|
8
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -37,10 +38,10 @@ async function main() {
|
|
|
37
38
|
}
|
|
38
39
|
throw err;
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
-
if (cookies) config.cookies = cookies;
|
|
41
|
+
config.cookies = extractTikTokCookiesFromFirefox() ?? await loadCookies(config.cookiesPath);
|
|
42
42
|
config.logConsole = false;
|
|
43
43
|
const display = createDisplay();
|
|
44
|
+
if (!config.cookies) display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
|
|
44
45
|
const recorder = createRecorder(config);
|
|
45
46
|
recorder.on("checking", (info) => {
|
|
46
47
|
display.checkingUser(info.user);
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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
|
|
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 { TikTokError, validateConfig } from \"./config\";\nimport type { RecorderConfig } from \"./config\";\nimport { createRecorder } from \"./lib\";\nimport { createDisplay } from \"./ui\";\nimport { bytesToHuman } from \"./utils\";\n\nasync function loadCookies(\n cookiesPath?: string,\n): Promise<{ sessionid_ss: string; \"tt-target-idc\"?: 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 {\n sessionid_ss?: string;\n \"tt-target-idc\"?: string;\n };\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as { sessionid_ss: string; \"tt-target-idc\"?: 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 config.cookies =\n extractTikTokCookiesFromFirefox() ??\n (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 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 // 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 {\n display.userOffline(info.user);\n }\n });\n\n recorder.on(\"recording:start\", () => {\n display.startRecording();\n });\n\n recorder.on(\"download:progress\", (info) => {\n display.updateProgress(info.bytes, info.elapsed, info.speed);\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\", () => {\n // Individual conversion results acknowledged via segmenting:end or recording:end\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 // Signal handling\n let stopping = false;\n const handleSignal = async () => {\n if (stopping) {\n process.stdout.write(\" Force stopping...\\n\");\n process.exit(1);\n }\n stopping = true;\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(\n `Unexpected error: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n await recorder.stop();\n display.stop();\n process.exit(1);\n }\n}\n\nmain();\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,eAAe,YACb,aACyE;CACzE,MAAM,OAAO,eAAe,KAAK,QAAQ,IAAI,GAAG,cAAc;CAC9D,IAAI,CAAC,WAAW,IAAI,GAClB;CAEF,IAAI;EACF,MAAM,UAAU,aAAa,MAAM,OAAO;EAC1C,MAAM,OAAO,KAAK,MAAM,OAAO;EAI/B,IAAI,KAAK,gBAAgB,KAAK,aAAa,SAAS,GAClD,OAAO;EAET;CACF,QAAQ;EACN;CACF;AACF;AAEA,eAAe,OAAsB;CACnC,IAAI;CAEJ,IAAI;EACF,SAAS,UAAU;EACnB,eAAe,MAAM;CACvB,SAAS,KAAK;EACZ,IAAI,eAAe,eAAe,IAAI,SAAS,gBAAgB;GAC7D,QAAQ,MAAM,GAAG,MAAM,IAAI,QAAQ,EAAE,GAAG,IAAI,SAAS;GACrD,QAAQ,KAAK,CAAC;EAChB;EACA,MAAM;CACR;CAGA,OAAO,UACL,gCAAgC,KAC/B,MAAM,YAAY,OAAO,WAAW;CAGvC,OAAO,aAAa;CAGpB,MAAM,UAAU,cAAc;CAE9B,IAAI,CAAC,OAAO,SACV,QAAQ,YACN,kFACF;CAGF,MAAM,WAAW,eAAe,MAAM;CAGtC,SAAS,GAAG,aAAa,SAAS;EAChC,QAAQ,aAAa,KAAK,IAAI;CAChC,CAAC;CAED,SAAS,GAAG,SAAS,SAAS;EAC5B,IAAI,KAAK,QACP,QAAQ,SAAS,KAAK,MAAM,KAAK,UAAU,GAAG;OAE9C,QAAQ,YAAY,KAAK,IAAI;CAEjC,CAAC;CAED,SAAS,GAAG,yBAAyB;EACnC,QAAQ,eAAe;CACzB,CAAC;CAED,SAAS,GAAG,sBAAsB,SAAS;EACzC,QAAQ,eAAe,KAAK,OAAO,KAAK,SAAS,KAAK,KAAK;CAC7D,CAAC;CAED,SAAS,GAAG,iBAAiB,SAAS;EACpC,MAAM,SAAS,KAAK,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,KAAK;EAClD,MAAM,UAAU,aAAa,KAAK,IAAI;EACtC,QAAQ,gBAAgB,QAAQ,KAAK,UAAU,OAAO;CACxD,CAAC;CAED,SAAS,GAAG,0BAA0B;EACpC,QAAQ,gBAAgB;CAC1B,CAAC;CAED,SAAS,GAAG,mBAAmB,SAAS;EACtC,QAAQ,gBAAgB,KAAK,QAAQ;CACvC,CAAC;CAED,SAAS,GAAG,0BAA0B;EACpC,QAAQ,gBAAgB;CAC1B,CAAC;CAED,SAAS,GAAG,mBAAmB,CAE/B,CAAC;CAED,SAAS,GAAG,uBAAuB,CAKnC,CAAC;CAED,SAAS,GAAG,UAAU,QAAQ;EAC5B,QAAQ,UAAU,IAAI,IAAI,KAAK,IAAI,IAAI,SAAS;CAClD,CAAC;CAGD,IAAI,WAAW;CACf,MAAM,eAAe,YAAY;EAC/B,IAAI,UAAU;GACZ,QAAQ,OAAO,MAAM,uBAAuB;GAC5C,QAAQ,KAAK,CAAC;EAChB;EACA,WAAW;EACX,QAAQ,SAAS,6BAA6B;EAC9C,MAAM,SAAS,KAAK;EACpB,QAAQ,KAAK;EACb,QAAQ,KAAK,CAAC;CAChB;CAEA,QAAQ,GAAG,UAAU,YAAY;CACjC,QAAQ,GAAG,WAAW,YAAY;CAClC,QAAQ,GAAG,UAAU,YAAY;CAGjC,QAAQ,eAAe,OAAO,YAAY,CAAC;CAC3C,IAAI;EACF,MAAM,SAAS,MAAM;CACvB,SAAS,KAAK;EACZ,IAAI,eAAe,aACjB,QAAQ,UAAU,IAAI,IAAI,KAAK,IAAI,IAAI,SAAS;OAEhD,QAAQ,UACN,qBAAqB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GACtE;EAEF,MAAM,SAAS,KAAK;EACpB,QAAQ,KAAK;EACb,QAAQ,KAAK,CAAC;CAChB;AACF;AAEA,KAAK"}
|
package/dist/logger.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logger.mjs","names":[],"sources":["../src/logger.ts"],"sourcesContent":["/**\n * Logger — rotating file + console with levels.\n * Zero-dependency minimal implementation (~60 lines).\n */\n\nimport {
|
|
1
|
+
{"version":3,"file":"logger.mjs","names":[],"sources":["../src/logger.ts"],"sourcesContent":["/**\n * Logger — rotating file + console with levels.\n * Zero-dependency minimal implementation (~60 lines).\n */\n\nimport {\n appendFileSync,\n existsSync,\n mkdirSync,\n renameSync,\n statSync,\n writeFileSync,\n} from \"node:fs\";\nimport { dirname } from \"node:path\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n};\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: \"\\x1b[90m\", // gray\n info: \"\\x1b[36m\", // cyan\n warn: \"\\x1b[33m\", // yellow\n error: \"\\x1b[31m\", // red\n};\n\nconst RESET = \"\\x1b[0m\";\n\nexport interface Logger {\n debug: (msg: string, ...args: unknown[]) => void;\n info: (msg: string, ...args: unknown[]) => void;\n warn: (msg: string, ...args: unknown[]) => void;\n error: (msg: string, ...args: unknown[]) => void;\n}\n\nexport function createLogger(opts: {\n level?: LogLevel;\n logFile?: string;\n maxSize?: number;\n backups?: number;\n /** When true (default), writes to stdout/stderr. Set false for file-only logging. */\n console?: boolean;\n}): Logger {\n const level = opts.level ?? \"info\";\n const logFile = opts.logFile ?? \"tiktok-recorder.log\";\n const maxSize = opts.maxSize ?? 5 * 1024 * 1024; // 5 MB\n const backups = opts.backups ?? 3;\n const showConsole = opts.console !== false; // default true\n\n // Ensure parent directory exists\n const logDir = dirname(logFile);\n if (logDir !== \".\") {\n mkdirSync(logDir, { recursive: true });\n }\n\n function rotateIfNeeded(): void {\n try {\n if (existsSync(logFile)) {\n const size = statSync(logFile).size;\n if (size >= maxSize) {\n // Remove the oldest backup\n const lastBackup = `${logFile}.${backups}`;\n if (existsSync(lastBackup)) {\n try {\n renameSync(lastBackup, `${lastBackup}.old`);\n } catch {\n // Best-effort cleanup\n }\n }\n // Shift backups\n for (let i = backups - 1; i >= 1; i--) {\n const src = `${logFile}.${i}`;\n const dst = `${logFile}.${i + 1}`;\n if (existsSync(src)) {\n try {\n renameSync(src, dst);\n } catch {\n // Best-effort\n }\n }\n }\n // Rename current → .1\n try {\n renameSync(logFile, `${logFile}.1`);\n } catch {\n // Best-effort\n }\n }\n }\n } catch {\n // Silent — never throw in logger\n }\n }\n\n // Touch the file\n try {\n if (!existsSync(logFile)) {\n writeFileSync(logFile, \"\", \"utf-8\");\n }\n } catch {\n // Best-effort\n }\n\n function write(level: LogLevel, msg: string, args: unknown[]): void {\n const ts = new Date().toISOString();\n const suffix =\n args.length > 0 ? ` ${args.map((a) => String(a)).join(\" \")}` : \"\";\n const line = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`;\n const formatted = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`;\n\n // Console (optional — can be suppressed for file-only logging)\n if (showConsole) {\n if (level === \"error\") {\n process.stderr.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\\n`);\n } else {\n process.stdout.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\\n`);\n }\n }\n\n // File\n try {\n rotateIfNeeded();\n appendFileSync(logFile, `${line}\\n`, \"utf-8\");\n } catch {\n // Best-effort\n }\n }\n\n return {\n debug: (msg, ...args) => {\n if (LEVEL_ORDER.debug >= LEVEL_ORDER[level]) write(\"debug\", msg, args);\n },\n info: (msg, ...args) => {\n if (LEVEL_ORDER.info >= LEVEL_ORDER[level]) write(\"info\", msg, args);\n },\n warn: (msg, ...args) => {\n if (LEVEL_ORDER.warn >= LEVEL_ORDER[level]) write(\"warn\", msg, args);\n },\n error: (msg, ...args) => {\n if (LEVEL_ORDER.error >= LEVEL_ORDER[level]) write(\"error\", msg, args);\n },\n };\n}\n"],"mappings":";;;;;;;AAiBA,MAAM,cAAwC;CAC5C,OAAO;CACP,MAAM;CACN,MAAM;CACN,OAAO;AACT;AAEA,MAAM,eAAyC;CAC7C,OAAO;CACP,MAAM;CACN,MAAM;CACN,OAAO;AACT;AAEA,MAAM,QAAQ;AASd,SAAgB,aAAa,MAOlB;CACT,MAAM,QAAQ,KAAK,SAAS;CAC5B,MAAM,UAAU,KAAK,WAAW;CAChC,MAAM,UAAU,KAAK,WAAW,IAAI,OAAO;CAC3C,MAAM,UAAU,KAAK,WAAW;CAChC,MAAM,cAAc,KAAK,YAAY;CAGrC,MAAM,SAAS,QAAQ,OAAO;CAC9B,IAAI,WAAW,KACb,UAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;CAGvC,SAAS,iBAAuB;EAC9B,IAAI;GACF,IAAI,WAAW,OAAO;QACP,SAAS,OAAO,CAAC,CAAC,QACnB,SAAS;KAEnB,MAAM,aAAa,GAAG,QAAQ,GAAG;KACjC,IAAI,WAAW,UAAU,GACvB,IAAI;MACF,WAAW,YAAY,GAAG,WAAW,KAAK;KAC5C,QAAQ,CAER;KAGF,KAAK,IAAI,IAAI,UAAU,GAAG,KAAK,GAAG,KAAK;MACrC,MAAM,MAAM,GAAG,QAAQ,GAAG;MAC1B,MAAM,MAAM,GAAG,QAAQ,GAAG,IAAI;MAC9B,IAAI,WAAW,GAAG,GAChB,IAAI;OACF,WAAW,KAAK,GAAG;MACrB,QAAQ,CAER;KAEJ;KAEA,IAAI;MACF,WAAW,SAAS,GAAG,QAAQ,GAAG;KACpC,QAAQ,CAER;IACF;;EAEJ,QAAQ,CAER;CACF;CAGA,IAAI;EACF,IAAI,CAAC,WAAW,OAAO,GACrB,cAAc,SAAS,IAAI,OAAO;CAEtC,QAAQ,CAER;CAEA,SAAS,MAAM,OAAiB,KAAa,MAAuB;EAClE,MAAM,sBAAK,IAAI,KAAK,EAAA,CAAE,YAAY;EAClC,MAAM,SACJ,KAAK,SAAS,IAAI,IAAI,KAAK,KAAK,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,MAAM;EACjE,MAAM,OAAO,IAAI,GAAG,KAAK,MAAM,YAAY,EAAE,IAAI,MAAM;EACvD,MAAM,YAAY,IAAI,GAAG,KAAK,MAAM,YAAY,EAAE,IAAI,MAAM;EAG5D,IAAI,aACF,IAAI,UAAU,SACZ,QAAQ,OAAO,MAAM,GAAG,aAAa,SAAS,YAAY,MAAM,GAAG;OAEnE,QAAQ,OAAO,MAAM,GAAG,aAAa,SAAS,YAAY,MAAM,GAAG;EAKvE,IAAI;GACF,eAAe;GACf,eAAe,SAAS,GAAG,KAAK,KAAK,OAAO;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EACL,QAAQ,KAAK,GAAG,SAAS;GACvB,IAAI,YAAY,SAAS,YAAY,QAAQ,MAAM,SAAS,KAAK,IAAI;EACvE;EACA,OAAO,KAAK,GAAG,SAAS;GACtB,IAAI,YAAY,QAAQ,YAAY,QAAQ,MAAM,QAAQ,KAAK,IAAI;EACrE;EACA,OAAO,KAAK,GAAG,SAAS;GACtB,IAAI,YAAY,QAAQ,YAAY,QAAQ,MAAM,QAAQ,KAAK,IAAI;EACrE;EACA,QAAQ,KAAK,GAAG,SAAS;GACvB,IAAI,YAAY,SAAS,YAAY,QAAQ,MAAM,SAAS,KAAK,IAAI;EACvE;CACF;AACF"}
|
package/dist/monitor.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monitor.mjs","names":[],"sources":["../src/monitor.ts"],"sourcesContent":["/**\n * Polling monitor — calls a tick function on a configurable interval.\n * Fires the first tick immediately, then every N minutes.\n */\n\nimport type { Logger } from
|
|
1
|
+
{"version":3,"file":"monitor.mjs","names":[],"sources":["../src/monitor.ts"],"sourcesContent":["/**\n * Polling monitor — calls a tick function on a configurable interval.\n * Fires the first tick immediately, then every N minutes.\n */\n\nimport type { Logger } from \"./logger\";\nimport { sleep } from \"./utils\";\n\nexport interface PollingMonitor {\n start: () => Promise<void>;\n stop: () => Promise<void>;\n}\n\nexport function createPollingMonitor(opts: {\n intervalMinutes: number;\n onTick: () => Promise<void>;\n logger?: Logger;\n}): PollingMonitor {\n const intervalMs = opts.intervalMinutes * 60 * 1000;\n let active = true;\n let currentTick: Promise<void> | null = null;\n\n async function start(): Promise<void> {\n active = true;\n opts.logger?.info(\n `Polling started (interval: ${opts.intervalMinutes} min)`,\n );\n\n while (active) {\n currentTick = opts.onTick();\n try {\n await currentTick;\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n opts.logger?.error(`Tick error: ${msg}`);\n } finally {\n currentTick = null;\n }\n\n if (!active) break;\n\n // Wait for the interval, checking periodically if we should stop\n const checkInterval = 1000; // check every second\n let waited = 0;\n while (waited < intervalMs) {\n if (!active) break;\n await sleep(Math.min(checkInterval, intervalMs - waited));\n waited += checkInterval;\n }\n }\n\n opts.logger?.info(\"Polling stopped\");\n }\n\n async function stop(): Promise<void> {\n active = false;\n // Wait for in-flight tick to finish (e.g. recording → converting → MP4)\n if (currentTick) {\n await currentTick;\n }\n }\n\n return { start, stop };\n}\n"],"mappings":";;AAaA,SAAgB,qBAAqB,MAIlB;CACjB,MAAM,aAAa,KAAK,kBAAkB,KAAK;CAC/C,IAAI,SAAS;CACb,IAAI,cAAoC;CAExC,eAAe,QAAuB;EACpC,SAAS;EACT,KAAK,QAAQ,KACX,8BAA8B,KAAK,gBAAgB,MACrD;EAEA,OAAO,QAAQ;GACb,cAAc,KAAK,OAAO;GAC1B,IAAI;IACF,MAAM;GACR,SAAS,KAAK;IACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IAC3D,KAAK,QAAQ,MAAM,eAAe,KAAK;GACzC,UAAU;IACR,cAAc;GAChB;GAEA,IAAI,CAAC,QAAQ;GAGb,MAAM,gBAAgB;GACtB,IAAI,SAAS;GACb,OAAO,SAAS,YAAY;IAC1B,IAAI,CAAC,QAAQ;IACb,MAAM,MAAM,KAAK,IAAI,eAAe,aAAa,MAAM,CAAC;IACxD,UAAU;GACZ;EACF;EAEA,KAAK,QAAQ,KAAK,iBAAiB;CACrC;CAEA,eAAe,OAAsB;EACnC,SAAS;EAET,IAAI,aACF,MAAM;CAEV;CAEA,OAAO;EAAE;EAAO;CAAK;AACvB"}
|
package/dist/package.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert FLV → MP4 (stream copy by default).\n * Deletes the original FLV on success.\n */\n\nimport { spawn } from
|
|
1
|
+
{"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert FLV → MP4 (stream copy by default).\n * Deletes the original FLV on success.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { statSync, unlinkSync } from \"node:fs\";\nimport type { Logger } from \"../logger\";\n\nexport interface Converter {\n /** Convert a FLV file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>;\n}\n\nexport function createConverter(logger?: Logger): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.flv$/i, \".mp4\");\n\n logger?.info(`Converting: ${input} → ${output}`);\n\n const ffmpegPath = findFfmpeg();\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found. Install it:\\n\" +\n \" Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\\n\" +\n \" macOS: brew install ffmpeg\\n\" +\n \" Windows: choco install ffmpeg\",\n );\n }\n\n await runFfmpeg(ffmpegPath, input, output);\n\n // Delete original FLV\n try {\n unlinkSync(input);\n logger?.info(`Deleted original: ${input}`);\n } catch {\n logger?.warn(`Could not delete original: ${input}`);\n }\n\n logger?.info(`Converted: ${output}`);\n return output;\n }\n\n return { convert };\n}\n\nfunction findFfmpeg(): string | null {\n // Bun.which searches PATH like `which`\n const bunWhich = (Bun as any)?.which;\n if (typeof bunWhich === \"function\") {\n return bunWhich(\"ffmpeg\") as string | null;\n }\n\n // Fallback: manual PATH search\n const paths = process.env.PATH?.split(\":\") ?? [];\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`;\n statSync(full);\n return full;\n } catch {\n // eslint-disable-line no-empty\n }\n }\n return null;\n}\n\nfunction runFfmpeg(\n ffmpegPath: string,\n input: string,\n output: string,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n const args = [\n \"-i\",\n input,\n \"-c\",\n \"copy\", // stream copy — no re-encode, fast\n \"-y\", // overwrite output\n output,\n ];\n\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stderr = \"\";\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve();\n } else {\n reject(\n new Error(`FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n );\n }\n });\n\n proc.on(\"error\", (err) => {\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`));\n });\n });\n}\n"],"mappings":";;;;;;;AAcA,SAAgB,gBAAgB,QAA4B;CAC1D,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,WAAW,MAAM;EAE9C,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,WAAW;EAC9B,IAAI,CAAC,YACH,MAAM,IAAI,MACR,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,MAAM;EAGzC,IAAI;GACF,WAAW,KAAK;GAChB,QAAQ,KAAK,qBAAqB,OAAO;EAC3C,QAAQ;GACN,QAAQ,KAAK,8BAA8B,OAAO;EACpD;EAEA,QAAQ,KAAK,cAAc,QAAQ;EACnC,OAAO;CACT;CAEA,OAAO,EAAE,QAAQ;AACnB;AAEA,SAAS,aAA4B;CAEnC,MAAM,WAAY,KAAa;CAC/B,IAAI,OAAO,aAAa,YACtB,OAAO,SAAS,QAAQ;CAI1B,MAAM,QAAQ,QAAQ,IAAI,MAAM,MAAM,GAAG,KAAK,CAAC;CAC/C,KAAK,MAAM,OAAO,OAChB,IAAI;EACF,MAAM,OAAO,GAAG,IAAI;EACpB,SAAS,IAAI;EACb,OAAO;CACT,QAAQ,CAER;CAEF,OAAO;AACT;AAEA,SAAS,UACP,YACA,OACA,QACe;CACf,OAAO,IAAI,SAAS,SAAS,WAAW;EAUtC,MAAM,OAAO,MAAM,YAAY;GAR7B;GACA;GACA;GACA;GACA;GACA;EAGgC,GAAG,EACnC,OAAO;GAAC;GAAU;GAAQ;EAAM,EAClC,CAAC;EAED,IAAI,SAAS;EAEb,KAAK,QAAQ,GAAG,SAAS,UAAkB;GACzC,UAAU,MAAM,SAAS;EAC3B,CAAC;EAED,KAAK,GAAG,UAAU,SAAS;GACzB,IAAI,SAAS,GACX,QAAQ;QAER,uBACE,IAAI,MAAM,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CACpE;EAEJ,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,uBAAO,IAAI,MAAM,2BAA2B,IAAI,SAAS,CAAC;EAC5D,CAAC;CACH,CAAC;AACH"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n *\n * Downloads the full stream as a single FLV, then uses FFmpeg to split it\n * into time-aligned MP4 segments.\n */\n\nimport { spawn } from 'node:child_process'\nimport { readdirSync, statSync, unlinkSync } from 'node:fs'\nimport { join, parse } from 'node:path'\nimport { createHttpClient } from '../api/client'\nimport { type TikTokApi, createTikTokApi } from '../api/tiktok'\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from '../config'\nimport { TikTokError, normalizeConfig } from '../config'\nimport { createLogger } from '../logger'\nimport { type PollingMonitor, createPollingMonitor } from '../monitor'\nimport { type Converter, createConverter } from './convert'\nimport { type StreamDownloader, createStreamDownloader } from './stream'\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: 'tiktok-recorder.log',\n console: cfg.logConsole,\n })\n\n // Internal event registry\n const eventHandlers = new Map<string, Array<(...args: any[]) => void>>()\n\n let state: RecorderStatus = {\n state: 'idle',\n user: cfg.user,\n }\n\n // State machine\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let stopRequested = false\n\n function setState(partial: Partial<RecorderStatus>): void {\n state = { ...state, ...partial }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = eventHandlers.get(event) ?? []\n list.push(handler as (...args: any[]) => void)\n eventHandlers.set(event, list)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = eventHandlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function getFfmpegPath(): string {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === 'function') {\n const path = bunWhich('ffmpeg') as string | undefined\n if (path) return path\n }\n throw new TikTokError(\n 'ffmpeg-not-found',\n 'FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg',\n )\n }\n\n function runFfmpeg(args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: ['ignore', 'pipe', 'pipe'],\n })\n\n let stderr = ''\n proc.stderr?.on('data', (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on('close', (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError('unknown', `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on('error', (err) => {\n reject(new TikTokError('unknown', `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n stopRequested = false\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger)\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emit('checking', { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emit('tick', { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emit('tick', { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emit('tick', { user, isLive: true, roomId })\n\n // Record the stream\n setState({ state: 'recording' })\n emit('recording:start', { user, file: '' })\n\n // Create a reconnection callback that fetches a fresh stream URL\n // when the current segment ends. This handles TikTok's short-lived\n // stream URLs (typically 30-60s per segment).\n let reconnectCount = 0\n const getNextUrl = async (): Promise<string | null> => {\n reconnectCount++\n if (reconnectCount > 100) {\n // Safety valve: don't reconnect indefinitely\n logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`)\n return null\n }\n // Invalidate cache so we get fresh data from TikTok\n api!.invalidateCache()\n // Check if the user is still live with the same room\n const stillLive = await api!.getRoomId(user)\n if (!stillLive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Get a fresh stream URL for the continuing stream\n return api!.getLiveUrl(stillLive)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emit('download:progress', info),\n getNextUrl,\n )\n\n emit('download:end', { file: result.file, duration: result.duration, size: result.size })\n setState({ state: 'converting' })\n\n if (result.size > 0 && cfg.segmentMinutes >= 1) {\n // Split the FLV into timed MP4 segments using FFmpeg\n const parsed = parse(result.file)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n const segmentTime = Math.max(1, cfg.segmentMinutes * 60)\n\n logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`,\n )\n\n emit('segmenting:start', { input: result.file, outputPattern })\n\n try {\n await runFfmpeg([\n '-i',\n result.file,\n '-c',\n 'copy',\n '-f',\n 'segment',\n '-segment_time',\n String(segmentTime),\n '-reset_timestamps',\n '1',\n outputPattern,\n ])\n\n // Delete the original FLV\n try {\n unlinkSync(result.file)\n logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n logger?.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith('.mp4'))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? '0', 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? '0', 10)\n return na - nb\n })\n\n const segmentDurationSec = cfg.segmentMinutes * 60\n const totalDuration = result.duration\n\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!)\n const stats = statSync(filePath)\n // Estimate duration: first N-1 are full segments, last is remainder\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n emit('recording:end', {\n file: filePath,\n duration: estDuration,\n size: stats.size,\n })\n emit('converted', {\n input: result.file,\n output: filePath,\n })\n }\n\n emit('segmenting:end', { segments: segmentFiles.length })\n\n setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n logger.error(`Segmenting failed: ${msg}`)\n // Fallback: try simple conversion of the whole FLV\n emit('converting:start', { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit('converted', { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError('unknown', String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit('error', tkErr)\n }\n }\n } else if (result.size > 0) {\n // No segmenting — simple FLV → MP4 conversion\n emit('converting:start', { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit('recording:end', {\n file: mp4File,\n duration: result.duration,\n size: result.size,\n })\n emit('converted', { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError('unknown', String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit('error', tkErr)\n }\n }\n\n setState({ state: 'polling' })\n },\n })\n\n setState({ state: 'polling' })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n stopRequested = true\n logger.info('Stopping recorder...')\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n if (httpClient) await httpClient.close()\n setState({ state: 'stopped' })\n logger.info('Recorder stopped')\n }\n\n return {\n start,\n stop,\n getStatus,\n on,\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n *\n * Downloads the full stream as a single FLV, then uses FFmpeg to split it\n * into time-aligned MP4 segments.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { readdirSync, statSync, unlinkSync } from \"node:fs\";\nimport { join, parse } from \"node:path\";\nimport { createHttpClient } from \"../api/client\";\nimport { type TikTokApi, createTikTokApi } from \"../api/tiktok\";\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from \"../config\";\nimport { TikTokError, normalizeConfig } from \"../config\";\nimport { createLogger } from \"../logger\";\nimport { type PollingMonitor, createPollingMonitor } from \"../monitor\";\nimport { type Converter, createConverter } from \"./convert\";\nimport { type StreamDownloader, createStreamDownloader } from \"./stream\";\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config);\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n });\n\n // Internal event registry\n const eventHandlers = new Map<string, Array<(...args: any[]) => void>>();\n\n let state: RecorderStatus = {\n state: \"idle\",\n user: cfg.user,\n };\n\n // State machine\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null;\n let api: TikTokApi | null = null;\n let monitor: PollingMonitor | null = null;\n let downloader: StreamDownloader | null = null;\n let converter: Converter | null = null;\n let stopRequested = false;\n\n function setState(partial: Partial<RecorderStatus>): void {\n state = { ...state, ...partial };\n }\n\n function getStatus(): RecorderStatus {\n return { ...state };\n }\n\n function on<E extends RecorderEvent>(\n event: E,\n handler: RecorderEventHandler[E],\n ): void {\n const list = eventHandlers.get(event) ?? [];\n list.push(handler as (...args: any[]) => void);\n eventHandlers.set(event, list);\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = eventHandlers.get(event);\n if (list) {\n for (const h of list) {\n try {\n h(...args);\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function getFfmpegPath(): string {\n const bunWhich = (Bun as any)?.which;\n if (typeof bunWhich === \"function\") {\n const path = bunWhich(\"ffmpeg\") as string | undefined;\n if (path) return path;\n }\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n );\n }\n\n function runFfmpeg(args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath();\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n\n let stderr = \"\";\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve();\n } else {\n reject(\n new TikTokError(\n \"unknown\",\n `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`,\n ),\n );\n }\n });\n\n proc.on(\"error\", (err) => {\n reject(\n new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`),\n );\n });\n });\n }\n\n async function start(): Promise<void> {\n stopRequested = false;\n\n logger.info(`Starting recorder for @${cfg.user}`);\n httpClient = await createHttpClient(cfg);\n api = createTikTokApi(httpClient);\n downloader = createStreamDownloader(logger);\n converter = createConverter(logger);\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return;\n\n const user = cfg.user;\n api!.invalidateCache();\n\n emit(\"checking\", { user });\n logger.info(`Checking @${user}...`);\n\n const roomId = await api!.getRoomId(user);\n if (!roomId) {\n logger.info(`@${user} is offline`);\n emit(\"tick\", { user, isLive: false });\n return;\n }\n\n const liveUrl = await api!.getLiveUrl(roomId);\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`);\n emit(\"tick\", { user, isLive: false, roomId });\n return;\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`);\n emit(\"tick\", { user, isLive: true, roomId });\n\n // Record the stream\n setState({ state: \"recording\" });\n emit(\"recording:start\", { user, file: \"\" });\n\n // Create a reconnection callback that fetches a fresh stream URL\n // when the current segment ends. This handles TikTok's short-lived\n // stream URLs (typically 30-60s per segment).\n let reconnectCount = 0;\n const getNextUrl = async (): Promise<string | null> => {\n reconnectCount++;\n if (reconnectCount > 100) {\n // Safety valve: don't reconnect indefinitely\n logger.warn(\n `Reconnection limit reached (${reconnectCount} attempts)`,\n );\n return null;\n }\n // Invalidate cache so we get fresh data from TikTok\n api!.invalidateCache();\n // Check if the user is still live with the same room\n const stillLive = await api!.getRoomId(user);\n if (!stillLive) {\n logger.info(`@${user} is no longer live — stopping`);\n return null;\n }\n // Get a fresh stream URL for the continuing stream\n return api!.getLiveUrl(stillLive);\n };\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emit(\"download:progress\", info),\n getNextUrl,\n );\n\n emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n });\n setState({ state: \"converting\" });\n\n if (result.size > 0 && cfg.segmentMinutes >= 1) {\n // Split the FLV into timed MP4 segments using FFmpeg\n const parsed = parse(result.file);\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`);\n const segmentTime = Math.max(1, cfg.segmentMinutes * 60);\n\n logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`,\n );\n\n emit(\"segmenting:start\", { input: result.file, outputPattern });\n\n try {\n await runFfmpeg([\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n outputPattern,\n ]);\n\n // Delete the original FLV\n try {\n unlinkSync(result.file);\n logger.info(`Deleted original: ${parsed.base}`);\n } catch {\n logger?.warn(`Could not delete original: ${parsed.base}`);\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir);\n const prefix = `${parsed.name}_part`;\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(\n a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\",\n 10,\n );\n const nb = Number.parseInt(\n b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\",\n 10,\n );\n return na - nb;\n });\n\n const segmentDurationSec = cfg.segmentMinutes * 60;\n const totalDuration = result.duration;\n\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!);\n const stats = statSync(filePath);\n // Estimate duration: first N-1 are full segments, last is remainder\n const isLast = i === segmentFiles.length - 1;\n const estDuration = isLast\n ? Math.max(\n 0,\n totalDuration -\n segmentDurationSec * (segmentFiles.length - 1),\n )\n : segmentDurationSec;\n\n emit(\"recording:end\", {\n file: filePath,\n duration: estDuration,\n size: stats.size,\n });\n emit(\"converted\", {\n input: result.file,\n output: filePath,\n });\n }\n\n emit(\"segmenting:end\", { segments: segmentFiles.length });\n\n setState({\n currentFile:\n segmentFiles.length > 0\n ? join(parsed.dir, segmentFiles.at(-1)!)\n : result.file,\n sessionDuration: result.duration,\n });\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n logger.error(`Segmenting failed: ${msg}`);\n // Fallback: try simple conversion of the whole FLV\n emit(\"converting:start\", { input: result.file });\n try {\n const mp4File = await converter!.convert(result.file);\n emit(\"converted\", { input: result.file, output: mp4File });\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError(\"unknown\", String(convErr));\n logger.error(`Conversion failed: ${tkErr.message}`);\n emit(\"error\", tkErr);\n }\n }\n } else if (result.size > 0) {\n // No segmenting — simple FLV → MP4 conversion\n emit(\"converting:start\", { input: result.file });\n try {\n const mp4File = await converter!.convert(result.file);\n emit(\"recording:end\", {\n file: mp4File,\n duration: result.duration,\n size: result.size,\n });\n emit(\"converted\", { input: result.file, output: mp4File });\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError(\"unknown\", String(convErr));\n logger.error(`Conversion failed: ${tkErr.message}`);\n emit(\"error\", tkErr);\n }\n }\n\n setState({ state: \"polling\" });\n },\n });\n\n setState({ state: \"polling\" });\n await monitor.start();\n }\n\n async function stop(): Promise<void> {\n stopRequested = true;\n logger.info(\"Stopping recorder...\");\n if (downloader) downloader.abort();\n if (monitor) await monitor.stop();\n if (httpClient) await httpClient.close();\n setState({ state: \"stopped\" });\n logger.info(\"Recorder stopped\");\n }\n\n return {\n start,\n stop,\n getStatus,\n on,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a FLV stream and writes it to disk with buffering.\n * Supports transparent reconnection when TikTok stream segments end naturally.\n */\n\nimport { type WriteStream, createWriteStream } from 'node:fs'\nimport { join } from 'node:path'\nimport type { Logger } from '../logger'\nimport { bytesToHuman, ensureDir, formatFilename } from '../utils'\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortFlag = false\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n\n function abort(): void {\n abortFlag = true\n streamReader?.cancel().catch(() => {})\n }\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortFlag = false\n ensureDir(outputDir)\n\n const filename = formatFilename(user, 'flv')\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n /** Shared across try (read loop) and catch (error handler). */\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n try {\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n // Throttle to at most once per second to avoid flooding the UI\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info('Stream ended, attempting reconnection...')\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info('No new stream URL — recording finished')\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n // Cancel old reader before opening new connection\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info('Reconnected successfully')\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await timeout(reader.read(), 60_000, 'Stream read timed out')\n chunk = result\n } catch (err) {\n // Read timeout — the TCP connection may have dropped silently.\n // Give TikTok a fresh URL if the stream is still live.\n logger?.info('Read timed out, attempting reconnection...')\n if (await tryReconnect()) {\n continue\n }\n // No reconnection available — propagate the error so the caller\n // at least gets whatever bytes were buffered.\n throw err\n }\n\n if (chunk.done) {\n // TikTok stream segments are short-lived (30-60s).\n // When one ends, fetch a fresh URL and keep recording.\n if (await tryReconnect()) {\n continue\n }\n break // no reconnection → recording is truly done\n }\n\n // Accumulate into buffer. chunk.value is guaranteed defined when done=false.\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n // Flush when buffer is full\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n // Check duration limit\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n // Report progress after each chunk (throttled to 1s internally)\n reportProgress(totalBytes)\n }\n\n // Flush remaining buffer and ensure all data is on disk\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n // Final progress report\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n return { download, abort }\n}\n\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a FLV stream and writes it to disk with buffering.\n * Supports transparent reconnection when TikTok stream segments end naturally.\n */\n\nimport { type WriteStream, createWriteStream } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { Logger } from \"../logger\";\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\";\n\nexport interface ProgressInfo {\n bytes: number;\n elapsed: number;\n speed: number;\n}\n\nexport interface DownloadResult {\n file: string;\n duration: number;\n size: number;\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>;\n /** Abort an active download */\n abort: () => void;\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortFlag = false;\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;\n\n function abort(): void {\n abortFlag = true;\n streamReader?.cancel().catch(() => {});\n }\n\n async function openReader(\n url: string,\n ): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url);\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`);\n }\n return response.body.getReader();\n }\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortFlag = false;\n ensureDir(outputDir);\n\n const filename = formatFilename(user, \"flv\");\n const filepath = join(outputDir, filename);\n logger?.info(`Recording: ${filename}`);\n\n const startTime = Date.now();\n let totalBytes = 0;\n let lastProgressTime = 0;\n\n let reader: ReadableStreamDefaultReader<Uint8Array>;\n try {\n reader = await openReader(liveUrl);\n } catch (err) {\n throw new Error(\n `Failed to open stream: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n streamReader = reader;\n\n const writer = createWriteStream(filepath);\n\n /** Shared across try (read loop) and catch (error handler). */\n function elapsed(): number {\n return (Date.now() - startTime) / 1000;\n }\n\n try {\n const bufferSize = 512 * 1024; // 512 KB buffer\n let buffer = Buffer.alloc(0);\n\n function reportProgress(bytes: number): void {\n if (!onProgress) return;\n const now = Date.now();\n const e = elapsed();\n const speed = e > 0 ? bytes / e : 0;\n // Throttle to at most once per second to avoid flooding the UI\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now;\n onProgress({ bytes, elapsed: e, speed });\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false;\n if (!getNextUrl) return false;\n if (maxDuration > 0 && elapsed() >= maxDuration) return false;\n\n logger?.info(\"Stream ended, attempting reconnection...\");\n const nextUrl = await getNextUrl();\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\");\n return false;\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`);\n try {\n // Cancel old reader before opening new connection\n reader.cancel().catch(() => {});\n reader = await openReader(nextUrl);\n streamReader = reader;\n logger?.info(\"Reconnected successfully\");\n return true;\n } catch (err) {\n logger?.error(\n `Reconnection failed: ${err instanceof Error ? err.message : String(err)}`,\n );\n return false;\n }\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array };\n\n try {\n const result = await timeout(\n reader.read(),\n 60_000,\n \"Stream read timed out\",\n );\n chunk = result;\n } catch (err) {\n // Read timeout — the TCP connection may have dropped silently.\n // Give TikTok a fresh URL if the stream is still live.\n logger?.info(\"Read timed out, attempting reconnection...\");\n if (await tryReconnect()) {\n continue;\n }\n // No reconnection available — propagate the error so the caller\n // at least gets whatever bytes were buffered.\n throw err;\n }\n\n if (chunk.done) {\n // TikTok stream segments are short-lived (30-60s).\n // When one ends, fetch a fresh URL and keep recording.\n if (await tryReconnect()) {\n continue;\n }\n break; // no reconnection → recording is truly done\n }\n\n // Accumulate into buffer. chunk.value is guaranteed defined when done=false.\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)]);\n\n // Flush when buffer is full\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer);\n totalBytes += buffer.length;\n buffer = Buffer.alloc(0);\n }\n\n // Check duration limit\n if (maxDuration > 0) {\n const e = elapsed();\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`);\n break;\n }\n }\n\n // Report progress after each chunk (throttled to 1s internally)\n reportProgress(totalBytes);\n }\n\n // Flush remaining buffer and ensure all data is on disk\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer);\n totalBytes += buffer.length;\n }\n await new Promise<void>((resolve) => writer.end(resolve));\n\n const duration = elapsed();\n // Final progress report\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n });\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n );\n\n return { file: filepath, duration, size: totalBytes };\n } catch (err) {\n abort();\n const duration = elapsed();\n logger?.error(\n `Recording error: ${err instanceof Error ? err.message : String(err)}`,\n );\n return { file: filepath, duration, size: totalBytes };\n } finally {\n streamReader = null;\n }\n }\n\n return { download, abort };\n}\n\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err);\n else resolve();\n });\n });\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return `${m}m ${s}s`;\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(\n promise: Promise<T>,\n ms: number,\n label: string,\n): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms);\n promise.then(\n (result) => {\n clearTimeout(timer);\n resolve(result);\n },\n (err) => {\n clearTimeout(timer);\n reject(err);\n },\n );\n });\n}\n"],"mappings":"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"}
|
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 {\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n color,\n createSpinner,\n} from
|
|
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 {\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n color,\n createSpinner,\n} from \"@zfadhli/koko-cli\";\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\";\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 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 /** Update the recording spinner with live counters. */\n updateProgress(bytes: number, elapsed: number, speed: number): 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 /** 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\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\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 /** Format seconds into a compact human-readable duration. */\n function fmtDuration(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 /** Format bytes into human-readable size. */\n function fmtBytes(bytes: number): string {\n if (bytes === 0) return \"0 B\";\n const units = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.min(\n Math.floor(Math.log(bytes) / Math.log(1024)),\n units.length - 1,\n );\n return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;\n }\n\n /** Format bytes per second into a readable speed string. */\n function fmtSpeed(bytesPerSec: number): string {\n if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;\n if (bytesPerSec < 1024 * 1024)\n return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;\n return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(\n ICON_SUCCESS,\n color.dim(`Polling started (every ${intervalMinutes} 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(ICON_INFO, color.dim(`@${user} is offline`));\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner();\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 activeSpinner = createSpinner(color.cyan(\"Recording stream...\"));\n activeSpinner.start();\n },\n\n updateProgress(bytes: number, elapsed: number, speed: number): void {\n if (!activeSpinner || !activeSpinner.isSpinning) return;\n activeSpinner.text = ` ${fmtBytes(bytes)} ${color.dim(\"•\")} ${fmtDuration(elapsed)} ${color.dim(\"•\")} ${fmtSpeed(speed)}`;\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 ${fmtDuration(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(\n ICON_SUCCESS,\n color.green(`Created ${count} 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(ICON_SUCCESS, color.green(`Converted: ${output}`));\n },\n\n showError(message: string): void {\n clearSpinner();\n process.stdout.write(` ${ICON_ERROR} ${color.red(message)}\\n`);\n },\n\n showInfo(message: string): void {\n clearSpinner();\n process.stdout.write(` ${ICON_INFO} ${color.dim(message)}\\n`);\n },\n\n showWarning(message: string): void {\n clearSpinner();\n process.stdout.write(` ${ICON_WARN} ${color.yellow(message)}\\n`);\n },\n\n stop(): void {\n clearSpinner();\n },\n };\n}\n"],"mappings":"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"}
|
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 } from
|
|
1
|
+
{"version":3,"file":"utils.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/**\n * Utility helpers — pure functions, no side effects.\n */\n\nimport { mkdirSync } from \"node:fs\";\n\n/** Format a filename like username=2025.06.09_14-30-00_1.flv */\nexport function formatFilename(\n user: string,\n ext = \"flv\",\n part?: number,\n): 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/** Sleep for ms milliseconds */\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\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(\n Math.floor(Math.log(bytes) / Math.log(1024)),\n units.length - 1,\n );\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"],"mappings":";;;;;;AAOA,SAAgB,eACd,MACA,MAAM,OACN,MACQ;CACR,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;;AAGA,SAAgB,MAAM,IAA2B;CAC/C,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACzD;;AAGA,SAAgB,aAAa,OAAuB;CAClD,IAAI,UAAU,GAAG,OAAO;CACxB,MAAM,QAAQ;EAAC;EAAK;EAAM;EAAM;CAAI;CACpC,MAAM,IAAI,KAAK,IACb,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,IAAI,CAAC,GAC3C,MAAM,SAAS,CACjB;CACA,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"}
|