@zfadhli/tokrec 0.2.1 → 0.3.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.
package/README.md ADDED
@@ -0,0 +1,315 @@
1
+ <div align="center">
2
+
3
+ # tokrec
4
+
5
+ **Minimal TikTok live stream recorder — Bun + TypeScript**
6
+
7
+ <br>
8
+
9
+ [![CI Status](https://img.shields.io/github/actions/workflow/status/zfadhli/tokrec/ci.yml?style=flat-square&label=CI)](https://github.com/zfadhli/tokrec/actions/workflows/ci.yml)
10
+ [![npm version](https://img.shields.io/npm/v/@zfadhli/tokrec?style=flat-square)](https://www.npmjs.com/package/@zfadhli/tokrec)
11
+ [![npm downloads](https://img.shields.io/npm/dw/@zfadhli/tokrec?style=flat-square)](https://www.npmjs.com/package/@zfadhli/tokrec)
12
+ [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.2-14151a?style=flat-square&logo=bun)](https://bun.sh)
13
+ [![License](https://img.shields.io/npm/l/@zfadhli/tokrec?style=flat-square)](LICENSE)
14
+
15
+ [CLI options](#cli-options) · [Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Authentication](#authentication) · [How it works](#how-it-works) · [Library API](#library-api) · [FAQ](#faq)
16
+
17
+ </div>
18
+
19
+ `tokrec` polls a TikTok user's live status, detects when they go live, downloads the FLV stream, and post-processes it with FFmpeg into playable MP4 segments. It is available as both a CLI tool and a TypeScript library.
20
+
21
+ > [!NOTE]
22
+ > This is **not** an all-in-one TikTok downloader. It focuses on **live stream recording** with automatic reconnection, graceful shutdown, and clean terminal output. For downloading uploaded videos, see other tools.
23
+
24
+ ---
25
+
26
+ ## Features
27
+
28
+ - **Automatic polling** — checks every N minutes (default 3) and starts recording as soon as the user goes live.
29
+ - **Stream reconnection** — TikTok stream segments are short-lived (30–60s). When one ends, `tokrec` transparently fetches a fresh URL and continues writing to the same file.
30
+ - **Time-aligned MP4 segments** — post-recording, FFmpeg splits the raw FLV into configurable-length MP4 segments (default 20 minutes), each independently playable.
31
+ - **Graceful shutdown** — responds to `SIGINT`, `SIGTERM`, and `SIGHUP` (terminal close). Aborts the current download, flushes buffered data, runs FFmpeg, and exits cleanly. A second signal force-kills.
32
+ - **Live progress display** — animated spinner with real-time byte count, elapsed time, and download speed. Built with [@zfadhli/koko-cli](https://github.com/zfadhli/koko-cli).
33
+ - **Cookie-based auth** — seeds a `sessionid_ss` cookie into the HTTP session for authenticated requests, bypassing TikTok's Slardar WAF.
34
+ - **Duration limit** — record for a fixed time (`--duration`) or unlimited (default).
35
+ - **Proxy support** — route traffic through an HTTP proxy for regional access.
36
+ - **Dual entry** — use as a CLI tool or embed `createRecorder()` in your own application.
37
+ - **45+ passing tests** — unit-tested CLI parsing, config validation, stream URL detection, and logger.
38
+
39
+ ---
40
+
41
+ ## Installation
42
+
43
+ ### CLI tool (global)
44
+
45
+ ```bash
46
+ npm install -g @zfadhli/tokrec
47
+ ```
48
+
49
+ Then run with:
50
+
51
+ ```bash
52
+ tiktok-live-recorder -u username
53
+ ```
54
+
55
+ ### One-off use (npx)
56
+
57
+ ```bash
58
+ npx @zfadhli/tokrec -u username
59
+ ```
60
+
61
+ ### As a library
62
+
63
+ ```bash
64
+ npm install @zfadhli/tokrec
65
+ ```
66
+
67
+ ```ts
68
+ import { createRecorder } from '@zfadhli/tokrec'
69
+
70
+ const recorder = createRecorder({ user: 'username' })
71
+ recorder.on('recording:end', (info) => console.log('Done:', info.file))
72
+ await recorder.start()
73
+ ```
74
+
75
+ > [!TIP]
76
+ > When using as a library, logs print to the console by default. To suppress console output (e.g., if you run your own UI), set `logConsole: false` in the config.
77
+
78
+ ---
79
+
80
+ ## Requirements
81
+
82
+ - **FFmpeg** — must be on your `$PATH` for FLV→MP4 conversion and segmenting.
83
+ - Linux: `apt install ffmpeg`
84
+ - macOS: `brew install ffmpeg`
85
+ - Windows: `choco install ffmpeg`
86
+ - **Bun** (optional, for development) — Node.js 20+ can run the built package.
87
+
88
+ ---
89
+
90
+ ## Quick start
91
+
92
+ ```bash
93
+ # Record a live stream (Ctrl+C to stop)
94
+ tiktok-live-recorder -u vierstinrovve
95
+
96
+ # Record for 10 minutes, split into 5-minute segments
97
+ tiktok-live-recorder -u vierstinrovve -d 10 -s 5
98
+
99
+ # Use a proxy and custom output directory
100
+ tiktok-live-recorder -u vierstinrovve -o ./videos -p http://127.0.0.1:8080
101
+
102
+ # Specify a cookies file (see Authentication section)
103
+ tiktok-live-recorder -u vierstinrovve -c ./my-cookies.json
104
+ ```
105
+
106
+ ---
107
+
108
+ ## CLI options
109
+
110
+ | Option | Shorthand | Default | Description |
111
+ |--------|-----------|---------|-------------|
112
+ | `--user <name>` | `-u` | _(required)_ | TikTok username (without `@`) |
113
+ | `--output <dir>` | `-o` | `./recordings` | Output directory for recordings |
114
+ | `--interval <minutes>` | `-i` | `3` | Polling interval (minimum 1) |
115
+ | `--duration <minutes>` | `-d` | unlimited | Maximum recording duration |
116
+ | `--segment-minutes <minutes>` | `-s` | `20` | Length of each output MP4 segment |
117
+ | `--proxy <url>` | `-p` | none | HTTP proxy URL |
118
+ | `--cookies <path>` | `-c` | `./cookies.json` | Path to cookies JSON file |
119
+ | `--log-level <level>` | `-l` | `info` | One of: `debug`, `info`, `warn`, `error` |
120
+
121
+ > [!TIP]
122
+ > `--duration` and `--segment-minutes` both accept values in **minutes**. The tool converts them internally.
123
+
124
+ ---
125
+
126
+ ## Authentication
127
+
128
+ TikTok's Slardar WAF blocks unauthenticated requests. You must provide a valid `sessionid_ss` cookie to bypass it.
129
+
130
+ ### Setup
131
+
132
+ 1. Open TikTok in your browser and log in.
133
+ 2. Open DevTools → **Application** → **Cookies** → `www.tiktok.com`.
134
+ 3. Copy the value of `sessionid_ss` and create a `cookies.json` file:
135
+
136
+ ```json
137
+ {
138
+ "sessionid_ss": "6c74daa215a8f34eeddf5162ca091668"
139
+ }
140
+ ```
141
+
142
+ You can also include the `tt-target-idc` cookie (optional, for regional routing):
143
+
144
+ ```json
145
+ {
146
+ "sessionid_ss": "6c74daa215a8f34eeddf5162ca091668",
147
+ "tt-target-idc": "useast2a"
148
+ }
149
+ ```
150
+
151
+ 4. Run the tool with `--cookies ./path/to/cookies.json`, or place `cookies.json` in the working directory (auto-detected).
152
+
153
+ > [!CAUTION]
154
+ > Without valid cookies, the tool will report every user as **offline** because the WAF challenge page won't contain live-stream JSON data.
155
+
156
+ ---
157
+
158
+ ## How it works
159
+
160
+ ### Pipeline
161
+
162
+ ```
163
+ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
164
+ │ Poll tick │ → │ Detect live │ → │ Download FLV │ → │ FFmpeg post │
165
+ │ (every Nm) │ │ via SIGI_ │ │ (auto-reconn.)│ │ (segment/ │
166
+ │ │ │ STATE scrape │ │ │ │ convert) │
167
+ └────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
168
+ ```
169
+
170
+ 1. **Poll tick** — every N minutes, the monitor calls the check function.
171
+ 2. **Live detection** — the TikTok profile page is fetched and the `SIGI_STATE` JSON blob is extracted. Stream URL extraction follows a three-tier fallback:
172
+ - Fast-path from the known `pull_data.stream_data` location (~95% of cases).
173
+ - Brute-force recursive search of the entire JSON (survives key name changes).
174
+ - Webcast API fallback (`/webcast/room/info/?aid=1988`).
175
+ 3. **Download** — the raw FLV is streamed to disk with a 512 KB buffer and 60-second read timeout. If the stream segment ends, the downloader fetches a fresh URL and continues (up to 100 reconnects per session). The `--duration` limit is checked against total elapsed time across reconnections.
176
+ 4. **FFmpeg post-processing** — the FLV is split into time-aligned MP4 segments using `ffmpeg -c copy -f segment -segment_time N -reset_timestamps 1`. If segmenting fails, it falls back to a simple FLV→MP4 conversion.
177
+
178
+ ### Event system
179
+
180
+ The recorder emits typed events that you can subscribe to via `recorder.on()`:
181
+
182
+ | Event | Payload | When |
183
+ |-------|---------|------|
184
+ | `checking` | `{ user }` | Before each poll tick |
185
+ | `tick` | `{ user, isLive, roomId? }` | After checking live status |
186
+ | `recording:start` | `{ user, file }` | When recording begins |
187
+ | `download:progress` | `{ bytes, elapsed, speed }` | During download (~1s intervals) |
188
+ | `download:end` | `{ file, duration, size }` | When download completes |
189
+ | `segmenting:start` | `{ input, outputPattern }` | Before FFmpeg segmenting |
190
+ | `segmenting:end` | `{ segments }` | After segmenting |
191
+ | `converting:start` | `{ input }` | Before simple conversion |
192
+ | `converted` | `{ input, output }` | After each segment/file |
193
+ | `error` | `err: TikTokError` | On non-fatal errors |
194
+
195
+ ### Output structure
196
+
197
+ Recordings are saved as:
198
+
199
+ ```
200
+ ./recordings/
201
+ vierstinrovve=20260609_143000.flv # raw download (deleted after conversion)
202
+ vierstinrovve=20260609_143000_part1.mp4 # segment 1
203
+ vierstinrovve=20260609_143000_part2.mp4 # segment 2
204
+ ...
205
+ ```
206
+
207
+ File naming: `{username}={YYYYMMDD}_{HHMMSS}[_partN].mp4`
208
+
209
+ ---
210
+
211
+ ## Library API
212
+
213
+ ### `createRecorder(config)`
214
+
215
+ ```ts
216
+ import { createRecorder } from '@zfadhli/tokrec'
217
+ import type { RecorderConfig, RecorderController, RecorderEvent } from '@zfadhli/tokrec'
218
+
219
+ const recorder: RecorderController = createRecorder({
220
+ user: 'username', // required
221
+ outputDir: './videos', // default: './recordings'
222
+ interval: 3, // polling interval in minutes
223
+ duration: 600, // max recording duration in seconds
224
+ segmentMinutes: 20, // segment length in minutes
225
+ proxy: 'http://127.0.0.1:8080',
226
+ cookies: { sessionid_ss: '...' },
227
+ logLevel: 'info',
228
+ logConsole: true,
229
+ })
230
+ ```
231
+
232
+ ### `RecorderController`
233
+
234
+ ```ts
235
+ interface RecorderController {
236
+ start(): Promise<void>
237
+ stop(): Promise<void>
238
+ getStatus(): RecorderStatus
239
+ on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void
240
+ }
241
+ ```
242
+
243
+ ### `RecorderStatus`
244
+
245
+ ```ts
246
+ interface RecorderStatus {
247
+ state: 'idle' | 'polling' | 'recording' | 'converting' | 'stopped'
248
+ user: string
249
+ currentFile?: string
250
+ sessionDuration?: number
251
+ lastPollTime?: string
252
+ lastError?: string
253
+ }
254
+ ```
255
+
256
+ ### Example: watch for live status
257
+
258
+ ```ts
259
+ import { createRecorder } from '@zfadhli/tokrec'
260
+
261
+ const recorder = createRecorder({ user: 'username' })
262
+
263
+ recorder.on('checking', ({ user }) => console.log(`Checking @${user}...`))
264
+ recorder.on('tick', ({ isLive, roomId, user }) => {
265
+ console.log(isLive ? `@${user} is LIVE!` : `@${user} is offline`)
266
+ })
267
+
268
+ recorder.on('error', (err) => console.error(`[${err.kind}] ${err.message}`))
269
+
270
+ await recorder.start()
271
+ ```
272
+
273
+ ---
274
+
275
+ ## FAQ
276
+
277
+ ### Why does the tool say "offline" for every user?
278
+
279
+ You likely don't have a valid `sessionid_ss` cookie. See the [Authentication](#authentication) section.
280
+
281
+ ### Why does the download stop after 30 seconds?
282
+
283
+ TikTok serves live streams as short FLV segments (~30–60s). The tool now reconnects automatically, but older versions stopped after one segment. Update to the latest version.
284
+
285
+ ### Can I record multiple users at the same time?
286
+
287
+ Run multiple instances of the CLI in separate terminals, each with a different `--user` and `--output`.
288
+
289
+ ### Can I use this with Node.js instead of Bun?
290
+
291
+ The package is built to ESM for Node.js 20+. The runtime dependency is `@zfadhli/koko-cli` and `wreq-js`, both of which work in Node. Bun is optional for development.
292
+
293
+ ### Where are the log files?
294
+
295
+ `tiktok-recorder.log` in the working directory, with rotating backups. File logs include full timestamps and levels for debugging.
296
+
297
+ ---
298
+
299
+ ## Troubleshooting
300
+
301
+ ### FFmpeg not found
302
+
303
+ Install FFmpeg and ensure it is on your `$PATH`:
304
+
305
+ ```bash
306
+ which ffmpeg # should print a path
307
+ ```
308
+
309
+ ### "WAF blocked" errors
310
+
311
+ Your `sessionid_ss` cookie has expired or is invalid. Generate a fresh one from your browser's DevTools.
312
+
313
+ ### The spinner freezes during download
314
+
315
+ The download has a 60-second read timeout. If TikTok stops sending data (e.g., network drop), the tool waits 60 seconds before attempting reconnection. If reconnection fails, the download ends with whatever data was buffered.
@@ -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
- const sigi = extractSigiState(await res.text());
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 (!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);
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: liveRoom.title ?? null
77
+ title: null
52
78
  };
53
- } catch (_err) {
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
  }
@@ -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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"}
package/dist/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { TikTokError } from "./config.mjs";
2
2
  import { sanitizeUser } from "./utils.mjs";
3
+ import { version } from "./package.mjs";
3
4
  import { createCLI } from "@zfadhli/koko-cli";
4
5
  //#region src/cli.ts
5
6
  /**
@@ -9,9 +10,9 @@ import { createCLI } from "@zfadhli/koko-cli";
9
10
  function parseArgs(argv = process.argv) {
10
11
  let config;
11
12
  let error;
12
- const cli = createCLI("tiktok-live-recorder", "0.2.1").description("Minimal TikTok live stream recorder — Bun + TypeScript");
13
+ const cli = createCLI("tiktok-live-recorder", version).description("Minimal TikTok live stream recorder — Bun + TypeScript");
13
14
  cli.command("", "Record a TikTok live stream", (cmd) => {
14
- cmd.option("-u, --user <name>", "TikTok username (required)");
15
+ cmd.option("-u, --user <name>", "TikTok username (or pass as first argument)");
15
16
  cmd.option("-o, --output <dir>", "Output directory", { default: "./recordings" });
16
17
  cmd.option("-i, --interval <minutes>", "Polling interval in minutes", { default: "3" });
17
18
  cmd.option("-d, --duration <minutes>", "Max recording duration in minutes");
@@ -22,7 +23,7 @@ function parseArgs(argv = process.argv) {
22
23
  cmd.action((opts) => {
23
24
  try {
24
25
  const user = opts.user;
25
- if (!user) throw new TikTokError("config-error", "--user is required");
26
+ if (!user) throw new TikTokError("config-error", "<user> is required (positional or --user)");
26
27
  const parsed = { user: sanitizeUser(user) };
27
28
  if (opts.output) parsed.outputDir = opts.output;
28
29
  if (opts.interval !== void 0) {
@@ -58,11 +59,31 @@ function parseArgs(argv = process.argv) {
58
59
  }
59
60
  });
60
61
  });
62
+ const positionalUser = extractPositional(argv);
63
+ if (positionalUser) argv.push("--user", positionalUser);
61
64
  cli.parse(argv);
62
65
  if (error) throw new TikTokError("config-error", error);
63
- if (!config) throw new TikTokError("config-error", "--user is required. Use --help for usage.");
66
+ if (!config) throw new TikTokError("config-error", "<user> is required (positional or --user). Use --help for usage.");
64
67
  return config;
65
68
  }
69
+ /**
70
+ * Scan argv for the first bare argument that is neither a flag nor a flag's value.
71
+ * Returns the value and removes it from the array, or null if none found.
72
+ */
73
+ function extractPositional(argv) {
74
+ for (let i = 2; i < argv.length; i++) {
75
+ const arg = argv[i];
76
+ if (arg === "--") return argv.splice(i, 1)[0];
77
+ if (arg.startsWith("-")) {
78
+ const next = argv[i + 1];
79
+ if (next && !next.startsWith("-") && arg !== "--help" && arg !== "--version") i++;
80
+ continue;
81
+ }
82
+ argv.splice(i, 1);
83
+ return arg;
84
+ }
85
+ return null;
86
+ }
66
87
  //#endregion
67
88
  export { parseArgs };
68
89
 
package/dist/cli.mjs.map CHANGED
@@ -1 +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"}
1
+ {"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from '@zfadhli/koko-cli'\nimport pkg from '../package.json'\nimport type { LogLevel, RecorderConfig } from './config'\nimport { TikTokError } from './config'\nimport { sanitizeUser } from './utils'\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI('tiktok-live-recorder', pkg.version).description(\n 'Minimal TikTok live stream recorder — Bun + TypeScript',\n )\n\n cli.command('', 'Record a TikTok live stream', (cmd) => {\n cmd.option('-u, --user <name>', 'TikTok username (or pass as first argument)')\n cmd.option('-o, --output <dir>', 'Output directory', { 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 (positional or --user)')\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError('config-error', '--interval must be a number >= 1')\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError('config-error', '--duration must be a number >= 0 (minutes)')\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = ['debug', 'info', 'warn', 'error']\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\n 'config-error',\n `--log-level must be one of: ${levels.join(', ')}`,\n )\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 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 // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push('--user', positionalUser)\n }\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError('config-error', error)\n }\n\n if (!config) {\n throw new TikTokError(\n 'config-error',\n '<user> is required (positional or --user). Use --help for usage.',\n )\n }\n\n return config\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i]!\n // -- signals end of options; anything after is a positional\n if (arg === '--') {\n const pos = argv.splice(i, 1)[0]!\n return pos\n }\n // If this arg is a flag (-*) the next arg is its value\n if (arg.startsWith('-')) {\n // Skip the value if this flag takes one (not a boolean flag like --help/--version)\n const next = argv[i + 1]\n if (next && !next.startsWith('-') && arg !== '--help' && arg !== '--version') {\n i++ // skip the value\n }\n continue\n }\n // Found a bare positional — remove it and return\n argv.splice(i, 1)\n return arg\n }\n return null\n}\n"],"mappings":";;;;;;;;;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,OAAO,qBAAqB,6CAA6C;EAC7E,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,2CAA2C;IAGnF,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;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,IAAI,QAAQ,CAAC,KAAK,WAAW,GAAG,KAAK,QAAQ,YAAY,QAAQ,aAC/D;GAEF;EACF;EAEA,KAAK,OAAO,GAAG,CAAC;EAChB,OAAO;CACT;CACA,OAAO;AACT"}
@@ -0,0 +1,6 @@
1
+ //#region package.json
2
+ var version = "0.3.1";
3
+ //#endregion
4
+ export { version };
5
+
6
+ //# sourceMappingURL=package.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"package.mjs","names":[],"sources":["../package.json"],"sourcesContent":[""],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zfadhli/tokrec",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Minimal TikTok live stream recorder — Bun + TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/lib.mjs",