@zfadhli/tokrec 0.2.1

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.
@@ -0,0 +1,40 @@
1
+ import { createSession } from "wreq-js";
2
+ //#region src/api/client.ts
3
+ /**
4
+ * HTTP client — thin wrapper around wreq-js createSession.
5
+ * Provides TLS fingerprint impersonation, cookie jar, and proxy support.
6
+ */
7
+ async function createHttpClient(config) {
8
+ const session = await createSession({
9
+ browser: "chrome_142",
10
+ os: "windows",
11
+ proxy: config.proxy
12
+ });
13
+ if (config.cookies?.sessionid_ss) {
14
+ session.setCookie("sessionid_ss", config.cookies.sessionid_ss, "https://www.tiktok.com");
15
+ if (config.cookies["tt-target-idc"]) session.setCookie("tt-target-idc", config.cookies["tt-target-idc"], "https://www.tiktok.com");
16
+ }
17
+ return {
18
+ get: async (url) => {
19
+ return await session.fetch(url, { signal: AbortSignal.timeout(15e3) });
20
+ },
21
+ post: async (url, body, headers) => {
22
+ return await session.fetch(url, {
23
+ method: "POST",
24
+ body,
25
+ signal: AbortSignal.timeout(15e3),
26
+ headers: {
27
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
28
+ ...headers
29
+ }
30
+ });
31
+ },
32
+ close: async () => {
33
+ await session.close();
34
+ }
35
+ };
36
+ }
37
+ //#endregion
38
+ export { createHttpClient };
39
+
40
+ //# sourceMappingURL=client.mjs.map
@@ -0,0 +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 'wreq-js'\nimport type { RecorderConfig } from '../config'\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>\n post: (url: string, body?: BodyInit, headers?: Record<string, string>) => Promise<Response>\n close: () => Promise<void>\n}\n\nexport async function createHttpClient(config: RecorderConfig): 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('sessionid_ss', config.cookies.sessionid_ss, 'https://www.tiktok.com')\n if (config.cookies['tt-target-idc']) {\n session.setCookie('tt-target-idc', config.cookies['tt-target-idc'], 'https://www.tiktok.com')\n }\n }\n\n return {\n get: async (url: string) => {\n const res = await session.fetch(url, { signal: AbortSignal.timeout(15000) } as any)\n return res as unknown as Response\n },\n post: async (url: string, body?: BodyInit, headers?: Record<string, string>) => {\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":";;;;;;AAcA,eAAsB,iBAAiB,QAA6C;CAClF,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAQ;CAKR,IAAI,OAAO,SAAS,cAAc;EAChC,QAAQ,UAAU,gBAAgB,OAAO,QAAQ,cAAc,wBAAwB;EACvF,IAAI,OAAO,QAAQ,kBACjB,QAAQ,UAAU,iBAAiB,OAAO,QAAQ,kBAAkB,wBAAwB;CAEhG;CAEA,OAAO;EACL,KAAK,OAAO,QAAgB;GAE1B,OAAO,MADW,QAAQ,MAAM,KAAK,EAAE,QAAQ,YAAY,QAAQ,IAAK,EAAE,CAAQ;EAEpF;EACA,MAAM,OAAO,KAAa,MAAiB,YAAqC;GAU9E,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"}
@@ -0,0 +1,144 @@
1
+ //#region src/api/tiktok.ts
2
+ const TIKTOK_BASE = "https://www.tiktok.com";
3
+ const WEBCAST_BASE = "https://webcast.tiktok.com";
4
+ function createTikTokApi(http) {
5
+ let cached = null;
6
+ let cachedUser = "";
7
+ async function ensureCache(user) {
8
+ if (cached && cachedUser === user) return cached;
9
+ cached = await fetchLiveInfo(user);
10
+ cachedUser = user;
11
+ return cached;
12
+ }
13
+ function invalidateCache() {
14
+ cached = null;
15
+ cachedUser = "";
16
+ }
17
+ return {
18
+ invalidateCache,
19
+ async getRoomId(user) {
20
+ return (await ensureCache(user))?.roomId ?? null;
21
+ },
22
+ async isRoomAlive(roomId) {
23
+ if (cached && String(cached.roomId) === String(roomId)) return cached.isLive;
24
+ return (await fetchLiveInfo(cachedUser || roomId))?.isLive ?? false;
25
+ },
26
+ async getLiveUrl(roomId) {
27
+ return (await ensureCache(cachedUser || roomId))?.streamUrl ?? null;
28
+ }
29
+ };
30
+ async function fetchLiveInfo(user) {
31
+ try {
32
+ const res = await http.get(`${TIKTOK_BASE}/@${user}/live`);
33
+ if (!res.ok) return null;
34
+ const sigi = extractSigiState(await res.text());
35
+ if (!sigi) return null;
36
+ const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom;
37
+ const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user;
38
+ if (!liveRoom || !userInfo?.roomId) return null;
39
+ const roomId = String(userInfo.roomId);
40
+ const isLive = liveRoom.status === 2;
41
+ let streamUrl = null;
42
+ if (isLive) {
43
+ streamUrl = extractStreamUrlFast(liveRoom);
44
+ if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi);
45
+ if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId);
46
+ }
47
+ return {
48
+ roomId,
49
+ isLive,
50
+ streamUrl,
51
+ title: liveRoom.title ?? null
52
+ };
53
+ } catch (_err) {
54
+ return null;
55
+ }
56
+ }
57
+ /**
58
+ * Fallback: fetch stream URL from the Webcast API.
59
+ * This covers cases where SIGI_STATE doesn't include stream data
60
+ * (e.g. async-loaded room info).
61
+ */
62
+ async function fetchStreamUrlFromApi(roomId) {
63
+ try {
64
+ const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`;
65
+ const res = await http.get(url);
66
+ if (!res.ok) return null;
67
+ const text = await res.text();
68
+ if (text.length === 0) return null;
69
+ const data = JSON.parse(text);
70
+ if (data.status_code === 4003110) return null;
71
+ return findStreamUrlRecursively(data);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+ }
77
+ function extractSigiState(html) {
78
+ const match = html.match(/<script\s+id="SIGI_STATE"\s+type="application\/json">(.*?)<\/script>/);
79
+ if (!match?.[1]) return null;
80
+ try {
81
+ return JSON.parse(match[1]);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Fast-path: extract stream URL from the known SIGI_STATE location.
88
+ * This covers ~95% of cases without needing a full recursive search.
89
+ */
90
+ function extractStreamUrlFast(liveRoom) {
91
+ try {
92
+ const raw = liveRoom?.streamData?.pull_data?.stream_data;
93
+ if (!raw) return null;
94
+ const data = JSON.parse(raw)?.data;
95
+ if (!data) return null;
96
+ for (const q of [
97
+ "origin",
98
+ "fhd",
99
+ "uhd",
100
+ "hd",
101
+ "sd",
102
+ "ld"
103
+ ]) {
104
+ const url = data[q]?.main?.flv;
105
+ if (url) return url;
106
+ }
107
+ return null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * Brute-force recursive search for a stream URL anywhere in a JSON value.
114
+ * Survives TikTok changing key names or nesting structure.
115
+ *
116
+ * Inspired by PR #430 (Michele0303/tiktok-live-recorder).
117
+ */
118
+ function findStreamUrlRecursively(obj) {
119
+ if (typeof obj === "string") {
120
+ if ((obj.startsWith("http://") || obj.startsWith("https://")) && (obj.includes(".flv") || obj.includes(".m3u8"))) return obj;
121
+ if (obj.startsWith("{") || obj.startsWith("[")) try {
122
+ return findStreamUrlRecursively(JSON.parse(obj));
123
+ } catch {
124
+ return null;
125
+ }
126
+ return null;
127
+ }
128
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
129
+ for (const val of Object.values(obj)) {
130
+ const found = findStreamUrlRecursively(val);
131
+ if (found) return found;
132
+ }
133
+ return null;
134
+ }
135
+ if (Array.isArray(obj)) for (const item of obj) {
136
+ const found = findStreamUrlRecursively(item);
137
+ if (found) return found;
138
+ }
139
+ return null;
140
+ }
141
+ //#endregion
142
+ export { createTikTokApi };
143
+
144
+ //# sourceMappingURL=tiktok.mjs.map
@@ -0,0 +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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"}
package/dist/cli.mjs ADDED
@@ -0,0 +1,69 @@
1
+ import { TikTokError } from "./config.mjs";
2
+ import { sanitizeUser } from "./utils.mjs";
3
+ import { createCLI } from "@zfadhli/koko-cli";
4
+ //#region src/cli.ts
5
+ /**
6
+ * CLI argument parser — uses koko-cli's createCLI (wraps cac).
7
+ * Exports a single parseArgs() function.
8
+ */
9
+ function parseArgs(argv = process.argv) {
10
+ let config;
11
+ let error;
12
+ const cli = createCLI("tiktok-live-recorder", "0.2.1").description("Minimal TikTok live stream recorder — Bun + TypeScript");
13
+ cli.command("", "Record a TikTok live stream", (cmd) => {
14
+ cmd.option("-u, --user <name>", "TikTok username (required)");
15
+ cmd.option("-o, --output <dir>", "Output directory", { default: "./recordings" });
16
+ cmd.option("-i, --interval <minutes>", "Polling interval in minutes", { default: "3" });
17
+ cmd.option("-d, --duration <minutes>", "Max recording duration in minutes");
18
+ cmd.option("-p, --proxy <url>", "HTTP proxy (e.g. http://127.0.0.1:8080)");
19
+ cmd.option("-l, --log-level <level>", "Log level: debug | info | warn | error");
20
+ cmd.option("-c, --cookies <path>", "Path to cookies.json");
21
+ cmd.option("-s, --segment-minutes <minutes>", "Segment duration in minutes", { default: "20" });
22
+ cmd.action((opts) => {
23
+ try {
24
+ const user = opts.user;
25
+ if (!user) throw new TikTokError("config-error", "--user is required");
26
+ const parsed = { user: sanitizeUser(user) };
27
+ if (opts.output) parsed.outputDir = opts.output;
28
+ if (opts.interval !== void 0) {
29
+ const n = Number(opts.interval);
30
+ if (!Number.isFinite(n) || n < 1) throw new TikTokError("config-error", "--interval must be a number >= 1");
31
+ parsed.interval = n;
32
+ }
33
+ if (opts.duration !== void 0) {
34
+ const n = Number(opts.duration);
35
+ if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--duration must be a number >= 0 (minutes)");
36
+ parsed.duration = n * 60;
37
+ }
38
+ if (opts.proxy) parsed.proxy = opts.proxy;
39
+ if (opts.logLevel) {
40
+ const levels = [
41
+ "debug",
42
+ "info",
43
+ "warn",
44
+ "error"
45
+ ];
46
+ if (!levels.includes(opts.logLevel)) throw new TikTokError("config-error", `--log-level must be one of: ${levels.join(", ")}`);
47
+ parsed.logLevel = opts.logLevel;
48
+ }
49
+ if (opts.cookies) parsed.cookiesPath = opts.cookies;
50
+ if (opts.segmentMinutes !== void 0) {
51
+ const n = Number(opts.segmentMinutes);
52
+ if (!Number.isFinite(n) || n < 1) throw new TikTokError("config-error", "--segment-minutes must be a number >= 1");
53
+ parsed.segmentMinutes = n;
54
+ }
55
+ config = parsed;
56
+ } catch (err) {
57
+ error = err instanceof Error ? err.message : String(err);
58
+ }
59
+ });
60
+ });
61
+ cli.parse(argv);
62
+ if (error) throw new TikTokError("config-error", error);
63
+ if (!config) throw new TikTokError("config-error", "--user is required. Use --help for usage.");
64
+ return config;
65
+ }
66
+ //#endregion
67
+ export { parseArgs };
68
+
69
+ //# sourceMappingURL=cli.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.mjs","names":[],"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 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', '0.2.1').description(\n 'Minimal TikTok live stream recorder — Bun + TypeScript',\n )\n\n cli.command('', 'Record a TikTok live stream', (cmd) => {\n cmd.option('-u, --user <name>', 'TikTok username (required)')\n cmd.option('-o, --output <dir>', 'Output directory', { default: './recordings' })\n cmd.option('-i, --interval <minutes>', 'Polling interval in minutes', { default: '3' })\n cmd.option('-d, --duration <minutes>', 'Max recording duration in minutes')\n cmd.option('-p, --proxy <url>', 'HTTP proxy (e.g. http://127.0.0.1:8080)')\n cmd.option('-l, --log-level <level>', 'Log level: debug | info | warn | error')\n cmd.option('-c, --cookies <path>', 'Path to cookies.json')\n cmd.option('-s, --segment-minutes <minutes>', 'Segment duration in minutes', { default: '20' })\n\n cmd.action((opts: Record<string, unknown>) => {\n try {\n const user = opts.user as string | undefined\n if (!user) {\n throw new TikTokError('config-error', '--user is required')\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError('config-error', '--interval must be a number >= 1')\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError('config-error', '--duration must be a number >= 0 (minutes)')\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = ['debug', 'info', 'warn', 'error']\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\n 'config-error',\n `--log-level must be one of: ${levels.join(', ')}`,\n )\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError('config-error', '--segment-minutes must be a number >= 1')\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 cli.parse(argv)\n\n if (error) {\n throw new TikTokError('config-error', error)\n }\n\n if (!config) {\n throw new TikTokError('config-error', '--user is required. Use --help for usage.')\n }\n\n return config\n}\n"],"mappings":";;;;;;;;AAUA,SAAgB,UAAU,OAAiB,QAAQ,MAAsB;CACvE,IAAI;CACJ,IAAI;CAEJ,MAAM,MAAM,UAAU,wBAAwB,OAAO,CAAC,CAAC,YACrD,wDACF;CAEA,IAAI,QAAQ,IAAI,gCAAgC,QAAQ;EACtD,IAAI,OAAO,qBAAqB,4BAA4B;EAC5D,IAAI,OAAO,sBAAsB,oBAAoB,EAAE,SAAS,eAAe,CAAC;EAChF,IAAI,OAAO,4BAA4B,+BAA+B,EAAE,SAAS,IAAI,CAAC;EACtF,IAAI,OAAO,4BAA4B,mCAAmC;EAC1E,IAAI,OAAO,qBAAqB,yCAAyC;EACzE,IAAI,OAAO,2BAA2B,wCAAwC;EAC9E,IAAI,OAAO,wBAAwB,sBAAsB;EACzD,IAAI,OAAO,mCAAmC,+BAA+B,EAAE,SAAS,KAAK,CAAC;EAE9F,IAAI,QAAQ,SAAkC;GAC5C,IAAI;IACF,MAAM,OAAO,KAAK;IAClB,IAAI,CAAC,MACH,MAAM,IAAI,YAAY,gBAAgB,oBAAoB;IAG5D,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,YAAY,gBAAgB,kCAAkC;KAE1E,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,YAAY,gBAAgB,4CAA4C;KAEpF,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,YAAY,gBAAgB,yCAAyC;KAEjF,OAAO,iBAAiB;IAC1B;IAEA,SAAS;GACX,SAAS,KAAK;IACZ,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GACzD;EACF,CAAC;CACH,CAAC;CAED,IAAI,MAAM,IAAI;CAEd,IAAI,OACF,MAAM,IAAI,YAAY,gBAAgB,KAAK;CAG7C,IAAI,CAAC,QACH,MAAM,IAAI,YAAY,gBAAgB,2CAA2C;CAGnF,OAAO;AACT"}
@@ -0,0 +1,104 @@
1
+ //#region src/config.d.ts
2
+ /**
3
+ * Config — types, defaults, and normalization for the recorder.
4
+ * The single source of truth for every user-facing option.
5
+ */
6
+ interface CookieAuth {
7
+ sessionid_ss: string;
8
+ 'tt-target-idc'?: string;
9
+ }
10
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
11
+ interface RecorderConfig {
12
+ /** TikTok username (required) */
13
+ user: string;
14
+ /** Output directory for recordings (default: ./recordings) */
15
+ outputDir?: string;
16
+ /** Polling interval in minutes (default: 3) */
17
+ interval?: number;
18
+ /** Max recording duration in seconds (default: 0 — unlimited). CLI accepts minutes, converted internally. */
19
+ duration?: number;
20
+ /** HTTP proxy URL (e.g. http://127.0.0.1:8080) */
21
+ proxy?: string;
22
+ /** Path to cookies.json (default: ./cookies.json) */
23
+ cookiesPath?: string;
24
+ /** Cookie auth loaded from cookies.json */
25
+ cookies?: CookieAuth;
26
+ /** Log level (default: info) */
27
+ logLevel?: LogLevel;
28
+ /** Whether to print logs to console (default: true). Set false when using Display-based UI. */
29
+ logConsole?: boolean;
30
+ /** Segment duration in minutes (default: 20). Stream is cut into segments of this length. */
31
+ segmentMinutes?: number;
32
+ }
33
+ interface RecorderController {
34
+ /** Start the polling loop */
35
+ start(): Promise<void>;
36
+ /** Gracefully stop (joins active recording, then exits) */
37
+ stop(): Promise<void>;
38
+ /** Snapshot of current state */
39
+ getStatus(): RecorderStatus;
40
+ /** Subscribe to events */
41
+ on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void;
42
+ }
43
+ interface RecorderStatus {
44
+ state: 'idle' | 'polling' | 'recording' | 'converting' | 'stopped';
45
+ user: string;
46
+ currentFile?: string;
47
+ sessionDuration?: number;
48
+ lastPollTime?: string;
49
+ lastError?: string;
50
+ }
51
+ type RecorderEvent = keyof RecorderEventHandler;
52
+ interface RecorderEventHandler {
53
+ checking: (info: {
54
+ user: string;
55
+ }) => void;
56
+ tick: (info: {
57
+ user: string;
58
+ isLive: boolean;
59
+ roomId?: string;
60
+ }) => void;
61
+ 'recording:start': (info: {
62
+ user: string;
63
+ file: string;
64
+ }) => void;
65
+ 'download:progress': (info: {
66
+ bytes: number;
67
+ elapsed: number;
68
+ speed: number;
69
+ }) => void;
70
+ 'download:end': (info: {
71
+ file: string;
72
+ duration: number;
73
+ size: number;
74
+ }) => void;
75
+ 'recording:end': (info: {
76
+ file: string;
77
+ duration: number;
78
+ size: number;
79
+ }) => void;
80
+ 'segmenting:start': (info: {
81
+ input: string;
82
+ outputPattern: string;
83
+ }) => void;
84
+ 'segmenting:end': (info: {
85
+ segments: number;
86
+ }) => void;
87
+ 'converting:start': (info: {
88
+ input: string;
89
+ }) => void;
90
+ converted: (info: {
91
+ input: string;
92
+ output: string;
93
+ }) => void;
94
+ error: (err: TikTokError) => void;
95
+ }
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
+ declare class TikTokError extends Error {
98
+ readonly kind: AppErrorKind;
99
+ readonly cause?: unknown | undefined;
100
+ constructor(kind: AppErrorKind, message: string, cause?: unknown | undefined);
101
+ }
102
+ //#endregion
103
+ export { AppErrorKind, RecorderConfig, RecorderController, RecorderEvent, RecorderEventHandler, RecorderStatus, TikTokError };
104
+ //# sourceMappingURL=config.d.mts.map
@@ -0,0 +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;EAIwC;EAFhD,SAAA,IAAa,cAAA;EAEoE;EAAjF,EAAA,iBAAmB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;AAAA;AAAA,UAGlE,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;IAAQ,KAAA;IAAe,OAAA;IAAiB,KAAA;EAAA;EAC9D,cAAA,GAAiB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EACzD,eAAA,GAAkB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EAC1D,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"}
@@ -0,0 +1,42 @@
1
+ //#region src/config.ts
2
+ var TikTokError = class extends Error {
3
+ kind;
4
+ cause;
5
+ constructor(kind, message, cause) {
6
+ super(message);
7
+ this.kind = kind;
8
+ this.cause = cause;
9
+ this.name = "TikTokError";
10
+ }
11
+ };
12
+ const DEFAULTS = {
13
+ outputDir: "./recordings",
14
+ interval: 3,
15
+ duration: 0,
16
+ logLevel: "info",
17
+ logConsole: true,
18
+ segmentMinutes: 20
19
+ };
20
+ function normalizeConfig(config) {
21
+ return {
22
+ user: config.user,
23
+ outputDir: config.outputDir ?? DEFAULTS.outputDir,
24
+ interval: config.interval ?? DEFAULTS.interval,
25
+ duration: config.duration ?? DEFAULTS.duration,
26
+ logLevel: config.logLevel ?? DEFAULTS.logLevel,
27
+ logConsole: config.logConsole ?? DEFAULTS.logConsole,
28
+ proxy: config.proxy,
29
+ cookies: config.cookies,
30
+ cookiesPath: config.cookiesPath,
31
+ segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes
32
+ };
33
+ }
34
+ function validateConfig(config) {
35
+ if (!config.user || config.user.trim().length === 0) throw new TikTokError("config-error", "--user is required");
36
+ if (config.interval !== void 0 && config.interval < 1) throw new TikTokError("config-error", "--interval must be >= 1 minute");
37
+ if (config.duration !== void 0 && config.duration < 0) throw new TikTokError("config-error", "--duration must be >= 0 seconds");
38
+ }
39
+ //#endregion
40
+ export { TikTokError, normalizeConfig, validateConfig };
41
+
42
+ //# sourceMappingURL=config.mjs.map
@@ -0,0 +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\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>(event: E, handler: RecorderEventHandler[E]): 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: { bytes: number; elapsed: number; speed: number }) => void\n 'download:end': (info: { file: string; duration: number; size: number }) => void\n 'recording:end': (info: { file: string; duration: number; size: number }) => 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":";AAkFA,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"}
@@ -0,0 +1 @@
1
+ export { };
package/dist/index.mjs ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bun
2
+ import { TikTokError, validateConfig } from "./config.mjs";
3
+ import { bytesToHuman } from "./utils.mjs";
4
+ import { createRecorder } from "./recorder/index.mjs";
5
+ import "./lib.mjs";
6
+ import { parseArgs } from "./cli.mjs";
7
+ import { createDisplay } from "./ui.mjs";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { color } from "@zfadhli/koko-cli";
11
+ //#region src/index.ts
12
+ /**
13
+ * Entry point — parses CLI args, creates the recorder, and handles signals.
14
+ * Uses the terminal Display manager for beautiful console output.
15
+ */
16
+ async function loadCookies(cookiesPath) {
17
+ const path = cookiesPath ?? join(process.cwd(), "cookies.json");
18
+ if (!existsSync(path)) return;
19
+ try {
20
+ const content = readFileSync(path, "utf-8");
21
+ const data = JSON.parse(content);
22
+ if (data.sessionid_ss && data.sessionid_ss.length > 0) return data;
23
+ return;
24
+ } catch {
25
+ return;
26
+ }
27
+ }
28
+ async function main() {
29
+ let config;
30
+ try {
31
+ config = parseArgs();
32
+ validateConfig(config);
33
+ } catch (err) {
34
+ if (err instanceof TikTokError && err.kind === "config-error") {
35
+ console.error(`${color.red("Error:")} ${err.message}`);
36
+ process.exit(1);
37
+ }
38
+ throw err;
39
+ }
40
+ const cookies = await loadCookies(config.cookiesPath);
41
+ if (cookies) config.cookies = cookies;
42
+ config.logConsole = false;
43
+ const display = createDisplay();
44
+ const recorder = createRecorder(config);
45
+ recorder.on("checking", (info) => {
46
+ display.checkingUser(info.user);
47
+ });
48
+ recorder.on("tick", (info) => {
49
+ if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
50
+ else display.userOffline(info.user);
51
+ });
52
+ recorder.on("recording:start", () => {
53
+ display.startRecording();
54
+ });
55
+ recorder.on("download:progress", (info) => {
56
+ display.updateProgress(info.bytes, info.elapsed, info.speed);
57
+ });
58
+ recorder.on("download:end", (info) => {
59
+ const parsed = info.file.split("/").pop() ?? info.file;
60
+ const sizeStr = bytesToHuman(info.size);
61
+ display.finishRecording(parsed, info.duration, sizeStr);
62
+ });
63
+ recorder.on("segmenting:start", () => {
64
+ display.startSegmenting();
65
+ });
66
+ recorder.on("segmenting:end", (info) => {
67
+ display.segmentsCreated(info.segments);
68
+ });
69
+ recorder.on("converting:start", () => {
70
+ display.startConverting();
71
+ });
72
+ recorder.on("converted", () => {});
73
+ recorder.on("recording:end", () => {});
74
+ recorder.on("error", (err) => {
75
+ display.showError(`[${err.kind}] ${err.message}`);
76
+ });
77
+ let stopping = false;
78
+ const handleSignal = async () => {
79
+ if (stopping) {
80
+ process.stdout.write(" Force stopping...\n");
81
+ process.exit(1);
82
+ }
83
+ stopping = true;
84
+ display.showInfo("Shutting down gracefully...");
85
+ await recorder.stop();
86
+ display.stop();
87
+ process.exit(0);
88
+ };
89
+ process.on("SIGINT", handleSignal);
90
+ process.on("SIGTERM", handleSignal);
91
+ process.on("SIGHUP", handleSignal);
92
+ display.pollingStarted(config.interval ?? 3);
93
+ try {
94
+ await recorder.start();
95
+ } catch (err) {
96
+ if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
97
+ else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
98
+ await recorder.stop();
99
+ display.stop();
100
+ process.exit(1);
101
+ }
102
+ }
103
+ main();
104
+ //#endregion
105
+ export {};
106
+
107
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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 'node:fs'\nimport { join } from 'node:path'\nimport { color } from '@zfadhli/koko-cli'\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 { sessionid_ss?: string; 'tt-target-idc'?: string }\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\n const cookies = await loadCookies(config.cookiesPath)\n if (cookies) {\n config.cookies = cookies\n }\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 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(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`)\n }\n await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain()\n"],"mappings":";;;;;;;;;;;;;;;AAgBA,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;EAC/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,MAAM,UAAU,MAAM,YAAY,OAAO,WAAW;CACpD,IAAI,SACF,OAAO,UAAU;CAInB,OAAO,aAAa;CAGpB,MAAM,UAAU,cAAc;CAE9B,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,UAAU,qBAAqB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;EAE3F,MAAM,SAAS,KAAK;EACpB,QAAQ,KAAK;EACb,QAAQ,KAAK,CAAC;CAChB;AACF;AAEA,KAAK"}
package/dist/lib.d.mts ADDED
@@ -0,0 +1,3 @@
1
+ import { AppErrorKind, RecorderConfig, RecorderController, RecorderEvent, RecorderEventHandler, RecorderStatus, TikTokError } from "./config.mjs";
2
+ import { createRecorder } from "./recorder/index.mjs";
3
+ export { type AppErrorKind, type RecorderConfig, type RecorderController, type RecorderEvent, type RecorderEventHandler, type RecorderStatus, type TikTokError, createRecorder };
package/dist/lib.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { createRecorder } from "./recorder/index.mjs";
2
+ export { createRecorder };