@zfadhli/tokrec 0.6.0 → 0.7.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.
@@ -1 +1 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../../src/api/client.ts"],"sourcesContent":["/**\n * HTTP client — thin wrapper around wreq-js createSession.\n * Provides TLS fingerprint impersonation, cookie jar, and proxy support.\n */\n\nimport { type Session, createSession } from \"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 // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies?.sessionid_ss) {\n for (const domain of [\n \"https://www.tiktok.com\",\n \"https://webcast.tiktok.com\",\n \"https://m.tiktok.com\",\n ]) {\n session.setCookie(\"sessionid_ss\", config.cookies.sessionid_ss, domain)\n if (config.cookies[\"tt-target-idc\"]) {\n session.setCookie(\"tt-target-idc\", config.cookies[\"tt-target-idc\"], domain)\n }\n }\n }\n\n return {\n get: async (url: string) => {\n const res = await session.fetch(url, {\n signal: AbortSignal.timeout(15000),\n } as any)\n return res as unknown as Response\n },\n post: async (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;CAOR,IAAI,OAAO,SAAS,cAClB,KAAK,MAAM,UAAU;EACnB;EACA;EACA;CACF,GAAG;EACD,QAAQ,UAAU,gBAAgB,OAAO,QAAQ,cAAc,MAAM;EACrE,IAAI,OAAO,QAAQ,kBACjB,QAAQ,UAAU,iBAAiB,OAAO,QAAQ,kBAAkB,MAAM;CAE9E;CAGF,OAAO;EACL,KAAK,OAAO,QAAgB;GAI1B,OAAO,MAHW,QAAQ,MAAM,KAAK,EACnC,QAAQ,YAAY,QAAQ,IAAK,EACnC,CAAQ;EAEV;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"}
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 { createSession, type Session } 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 // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies?.sessionid_ss) {\n for (const domain of [\n \"https://www.tiktok.com\",\n \"https://webcast.tiktok.com\",\n \"https://m.tiktok.com\",\n ]) {\n session.setCookie(\"sessionid_ss\", config.cookies.sessionid_ss, domain)\n if (config.cookies[\"tt-target-idc\"]) {\n session.setCookie(\"tt-target-idc\", config.cookies[\"tt-target-idc\"], domain)\n }\n }\n }\n\n return {\n get: async (url: string) => {\n const res = await session.fetch(url, {\n signal: AbortSignal.timeout(15000),\n } as any)\n return res as unknown as Response\n },\n post: async (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;CAOR,IAAI,OAAO,SAAS,cAClB,KAAK,MAAM,UAAU;EACnB;EACA;EACA;CACF,GAAG;EACD,QAAQ,UAAU,gBAAgB,OAAO,QAAQ,cAAc,MAAM;EACrE,IAAI,OAAO,QAAQ,kBACjB,QAAQ,UAAU,iBAAiB,OAAO,QAAQ,kBAAkB,MAAM;CAE9E;CAGF,OAAO;EACL,KAAK,OAAO,QAAgB;GAI1B,OAAO,MAHW,QAAQ,MAAM,KAAK,EACnC,QAAQ,YAAY,QAAQ,IAAK,EACnC,CAAQ;EAEV;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"}
@@ -1 +1 @@
1
- {"version":3,"file":"browser-cookies.mjs","names":[],"sources":["../src/browser-cookies.ts"],"sourcesContent":["/**\n * Browser cookie extraction — reads TikTok session cookies from Firefox's\n * cookie store using bun:sqlite (no external dependencies).\n *\n * Falls back to cookies.json if Firefox isn't available or if no TikTok\n * login session is found.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport { existsSync, readFileSync, readdirSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\nexport interface TikTokCookies {\n sessionid_ss: string\n \"tt-target-idc\"?: string\n}\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Searches all profile directories under ~/.mozilla/firefox/ for a\n * cookies.sqlite file, then queries it for TikTok session cookies.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * TikTok login session exists.\n */\nexport function extractTikTokCookiesFromFirefox(): TikTokCookies | null {\n const dbPath = findFirefoxCookieDb()\n if (!dbPath) return null\n\n try {\n const db = new Database(dbPath, { readonly: true })\n try {\n const rows = db\n .query(\n `SELECT name, value FROM moz_cookies\n WHERE host LIKE '%.tiktok.com'\n AND name IN ('sessionid_ss', 'tt-target-idc')`,\n )\n .all() as Array<{ name: string; value: string }>\n\n const session = rows.find((r) => r.name === \"sessionid_ss\")\n if (!session?.value) return null\n\n const result: TikTokCookies = { sessionid_ss: session.value }\n const idc = rows.find((r) => r.name === \"tt-target-idc\")\n if (idc?.value) result[\"tt-target-idc\"] = idc.value\n\n return result\n } finally {\n db.close()\n }\n } catch {\n return null\n }\n}\n\n/**\n * Locate the Firefox cookies.sqlite by scanning profile directories.\n *\n * Strategy:\n * 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.\n * 2. Fall back to scanning directory entries that look like Firefox profiles\n * (contain a cookies.sqlite file).\n */\n/** @public Exported for testing */\nexport function findFirefoxCookieDb(): string | null {\n const firefoxDir = join(homedir(), \".mozilla\", \"firefox\")\n if (!existsSync(firefoxDir)) return null\n\n // Try profiles.ini first for a reliable default-profile path\n const iniPath = join(firefoxDir, \"profiles.ini\")\n if (existsSync(iniPath)) {\n try {\n const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir)\n if (profilePath) {\n const dbPath = join(profilePath, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n } catch {\n // fall through to directory scan\n }\n }\n\n // Fallback: scan for any directory containing cookies.sqlite\n const entries = readdirSync(firefoxDir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory() && !entry.isSymbolicLink()) continue\n const dbPath = join(firefoxDir, entry.name, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n\n return null\n}\n\n/**\n * Parse Firefox's profiles.ini to find the default profile path.\n *\n * The INI format looks like:\n * [Profile0]\n * Name=default\n * IsRelative=1\n * Path=1pese0rl.default-release\n * Default=1\n */\n/** @public Exported for testing */\nexport function parseFirefoxProfilesIni(iniPath: string, firefoxDir: string): string | null {\n const text = readFileSync(iniPath, \"utf-8\")\n\n let defaultPath: string | null = null\n let isRelative = true\n let inProfile = false\n\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim()\n\n // Section header\n if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) {\n if (inProfile && defaultPath) break // found it\n inProfile = trimmed.toLowerCase().startsWith(\"[profile\")\n if (inProfile) {\n defaultPath = null\n isRelative = true\n }\n continue\n }\n\n if (!inProfile) continue\n\n const lower = trimmed.toLowerCase()\n if (lower.startsWith(\"path=\")) {\n defaultPath = trimmed.slice(5)\n } else if (lower.startsWith(\"isrelative=\")) {\n isRelative = trimmed.slice(11).trim() !== \"0\"\n } else if (lower.startsWith(\"default=\")) {\n if (trimmed.slice(8).trim() !== \"1\") {\n // Not the default profile — skip\n inProfile = false\n defaultPath = null\n }\n }\n }\n\n if (!defaultPath) return null\n return isRelative ? join(firefoxDir, defaultPath) : defaultPath\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,kCAAwD;CACtE,MAAM,SAAS,oBAAoB;CACnC,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI;EACF,MAAM,KAAK,IAAI,SAAS,QAAQ,EAAE,UAAU,KAAK,CAAC;EAClD,IAAI;GACF,MAAM,OAAO,GACV,MACC;;yDAGF,CAAC,CACA,IAAI;GAEP,MAAM,UAAU,KAAK,MAAM,MAAM,EAAE,SAAS,cAAc;GAC1D,IAAI,CAAC,SAAS,OAAO,OAAO;GAE5B,MAAM,SAAwB,EAAE,cAAc,QAAQ,MAAM;GAC5D,MAAM,MAAM,KAAK,MAAM,MAAM,EAAE,SAAS,eAAe;GACvD,IAAI,KAAK,OAAO,OAAO,mBAAmB,IAAI;GAE9C,OAAO;EACT,UAAU;GACR,GAAG,MAAM;EACX;CACF,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;AAWA,SAAgB,sBAAqC;CACnD,MAAM,aAAa,KAAK,QAAQ,GAAG,YAAY,SAAS;CACxD,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;CAGpC,MAAM,UAAU,KAAK,YAAY,cAAc;CAC/C,IAAI,WAAW,OAAO,GACpB,IAAI;EACF,MAAM,cAAc,wBAAwB,SAAS,UAAU;EAC/D,IAAI,aAAa;GACf,MAAM,SAAS,KAAK,aAAa,gBAAgB;GACjD,IAAI,WAAW,MAAM,GAAG,OAAO;EACjC;CACF,QAAQ,CAER;CAIF,MAAM,UAAU,YAAY,YAAY,EAAE,eAAe,KAAK,CAAC;CAC/D,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,CAAC,MAAM,YAAY,KAAK,CAAC,MAAM,eAAe,GAAG;EACrD,MAAM,SAAS,KAAK,YAAY,MAAM,MAAM,gBAAgB;EAC5D,IAAI,WAAW,MAAM,GAAG,OAAO;CACjC;CAEA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,wBAAwB,SAAiB,YAAmC;CAC1F,MAAM,OAAO,aAAa,SAAS,OAAO;CAE1C,IAAI,cAA6B;CACjC,IAAI,aAAa;CACjB,IAAI,YAAY;CAEhB,KAAK,MAAM,QAAQ,KAAK,MAAM,IAAI,GAAG;EACnC,MAAM,UAAU,KAAK,KAAK;EAG1B,IAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG,GAAG;GACpD,IAAI,aAAa,aAAa;GAC9B,YAAY,QAAQ,YAAY,CAAC,CAAC,WAAW,UAAU;GACvD,IAAI,WAAW;IACb,cAAc;IACd,aAAa;GACf;GACA;EACF;EAEA,IAAI,CAAC,WAAW;EAEhB,MAAM,QAAQ,QAAQ,YAAY;EAClC,IAAI,MAAM,WAAW,OAAO,GAC1B,cAAc,QAAQ,MAAM,CAAC;OACxB,IAAI,MAAM,WAAW,aAAa,GACvC,aAAa,QAAQ,MAAM,EAAE,CAAC,CAAC,KAAK,MAAM;OACrC,IAAI,MAAM,WAAW,UAAU;OAChC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,KAAK;IAEnC,YAAY;IACZ,cAAc;GAChB;;CAEJ;CAEA,IAAI,CAAC,aAAa,OAAO;CACzB,OAAO,aAAa,KAAK,YAAY,WAAW,IAAI;AACtD"}
1
+ {"version":3,"file":"browser-cookies.mjs","names":[],"sources":["../src/browser-cookies.ts"],"sourcesContent":["/**\n * Browser cookie extraction — reads TikTok session cookies from Firefox's\n * cookie store using bun:sqlite (no external dependencies).\n *\n * Falls back to cookies.json if Firefox isn't available or if no TikTok\n * login session is found.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\nexport interface TikTokCookies {\n sessionid_ss: string\n \"tt-target-idc\"?: string\n}\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Searches all profile directories under ~/.mozilla/firefox/ for a\n * cookies.sqlite file, then queries it for TikTok session cookies.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * TikTok login session exists.\n */\nexport function extractTikTokCookiesFromFirefox(): TikTokCookies | null {\n const dbPath = findFirefoxCookieDb()\n if (!dbPath) return null\n\n try {\n const db = new Database(dbPath, { readonly: true })\n try {\n const rows = db\n .query(\n `SELECT name, value FROM moz_cookies\n WHERE host LIKE '%.tiktok.com'\n AND name IN ('sessionid_ss', 'tt-target-idc')`,\n )\n .all() as Array<{ name: string; value: string }>\n\n const session = rows.find((r) => r.name === \"sessionid_ss\")\n if (!session?.value) return null\n\n const result: TikTokCookies = { sessionid_ss: session.value }\n const idc = rows.find((r) => r.name === \"tt-target-idc\")\n if (idc?.value) result[\"tt-target-idc\"] = idc.value\n\n return result\n } finally {\n db.close()\n }\n } catch {\n return null\n }\n}\n\n/**\n * Locate the Firefox cookies.sqlite by scanning profile directories.\n *\n * Strategy:\n * 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.\n * 2. Fall back to scanning directory entries that look like Firefox profiles\n * (contain a cookies.sqlite file).\n */\n/** @public Exported for testing */\nexport function findFirefoxCookieDb(): string | null {\n const firefoxDir = join(homedir(), \".mozilla\", \"firefox\")\n if (!existsSync(firefoxDir)) return null\n\n // Try profiles.ini first for a reliable default-profile path\n const iniPath = join(firefoxDir, \"profiles.ini\")\n if (existsSync(iniPath)) {\n try {\n const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir)\n if (profilePath) {\n const dbPath = join(profilePath, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n } catch {\n // fall through to directory scan\n }\n }\n\n // Fallback: scan for any directory containing cookies.sqlite\n const entries = readdirSync(firefoxDir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory() && !entry.isSymbolicLink()) continue\n const dbPath = join(firefoxDir, entry.name, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n\n return null\n}\n\n/**\n * Parse Firefox's profiles.ini to find the default profile path.\n *\n * The INI format looks like:\n * [Profile0]\n * Name=default\n * IsRelative=1\n * Path=1pese0rl.default-release\n * Default=1\n */\n/** @public Exported for testing */\nexport function parseFirefoxProfilesIni(iniPath: string, firefoxDir: string): string | null {\n const text = readFileSync(iniPath, \"utf-8\")\n\n let defaultPath: string | null = null\n let isRelative = true\n let inProfile = false\n\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim()\n\n // Section header\n if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) {\n if (inProfile && defaultPath) break // found it\n inProfile = trimmed.toLowerCase().startsWith(\"[profile\")\n if (inProfile) {\n defaultPath = null\n isRelative = true\n }\n continue\n }\n\n if (!inProfile) continue\n\n const lower = trimmed.toLowerCase()\n if (lower.startsWith(\"path=\")) {\n defaultPath = trimmed.slice(5)\n } else if (lower.startsWith(\"isrelative=\")) {\n isRelative = trimmed.slice(11).trim() !== \"0\"\n } else if (lower.startsWith(\"default=\")) {\n if (trimmed.slice(8).trim() !== \"1\") {\n // Not the default profile — skip\n inProfile = false\n defaultPath = null\n }\n }\n }\n\n if (!defaultPath) return null\n return isRelative ? join(firefoxDir, defaultPath) : defaultPath\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,kCAAwD;CACtE,MAAM,SAAS,oBAAoB;CACnC,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI;EACF,MAAM,KAAK,IAAI,SAAS,QAAQ,EAAE,UAAU,KAAK,CAAC;EAClD,IAAI;GACF,MAAM,OAAO,GACV,MACC;;yDAGF,CAAC,CACA,IAAI;GAEP,MAAM,UAAU,KAAK,MAAM,MAAM,EAAE,SAAS,cAAc;GAC1D,IAAI,CAAC,SAAS,OAAO,OAAO;GAE5B,MAAM,SAAwB,EAAE,cAAc,QAAQ,MAAM;GAC5D,MAAM,MAAM,KAAK,MAAM,MAAM,EAAE,SAAS,eAAe;GACvD,IAAI,KAAK,OAAO,OAAO,mBAAmB,IAAI;GAE9C,OAAO;EACT,UAAU;GACR,GAAG,MAAM;EACX;CACF,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;AAWA,SAAgB,sBAAqC;CACnD,MAAM,aAAa,KAAK,QAAQ,GAAG,YAAY,SAAS;CACxD,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;CAGpC,MAAM,UAAU,KAAK,YAAY,cAAc;CAC/C,IAAI,WAAW,OAAO,GACpB,IAAI;EACF,MAAM,cAAc,wBAAwB,SAAS,UAAU;EAC/D,IAAI,aAAa;GACf,MAAM,SAAS,KAAK,aAAa,gBAAgB;GACjD,IAAI,WAAW,MAAM,GAAG,OAAO;EACjC;CACF,QAAQ,CAER;CAIF,MAAM,UAAU,YAAY,YAAY,EAAE,eAAe,KAAK,CAAC;CAC/D,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,CAAC,MAAM,YAAY,KAAK,CAAC,MAAM,eAAe,GAAG;EACrD,MAAM,SAAS,KAAK,YAAY,MAAM,MAAM,gBAAgB;EAC5D,IAAI,WAAW,MAAM,GAAG,OAAO;CACjC;CAEA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,wBAAwB,SAAiB,YAAmC;CAC1F,MAAM,OAAO,aAAa,SAAS,OAAO;CAE1C,IAAI,cAA6B;CACjC,IAAI,aAAa;CACjB,IAAI,YAAY;CAEhB,KAAK,MAAM,QAAQ,KAAK,MAAM,IAAI,GAAG;EACnC,MAAM,UAAU,KAAK,KAAK;EAG1B,IAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG,GAAG;GACpD,IAAI,aAAa,aAAa;GAC9B,YAAY,QAAQ,YAAY,CAAC,CAAC,WAAW,UAAU;GACvD,IAAI,WAAW;IACb,cAAc;IACd,aAAa;GACf;GACA;EACF;EAEA,IAAI,CAAC,WAAW;EAEhB,MAAM,QAAQ,QAAQ,YAAY;EAClC,IAAI,MAAM,WAAW,OAAO,GAC1B,cAAc,QAAQ,MAAM,CAAC;OACxB,IAAI,MAAM,WAAW,aAAa,GACvC,aAAa,QAAQ,MAAM,EAAE,CAAC,CAAC,KAAK,MAAM;OACrC,IAAI,MAAM,WAAW,UAAU;OAChC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,KAAK;IAEnC,YAAY;IACZ,cAAc;GAChB;;CAEJ;CAEA,IAAI,CAAC,aAAa,OAAO;CACzB,OAAO,aAAa,KAAK,YAAY,WAAW,IAAI;AACtD"}
package/dist/cli.mjs CHANGED
@@ -20,6 +20,10 @@ function parseArgs(argv = process.argv) {
20
20
  cmd.option("-l, --log-level <level>", "Log level: debug | info | warn | error");
21
21
  cmd.option("-c, --cookies <path>", "Path to cookies.json");
22
22
  cmd.option("-s, --segment-minutes <minutes>", "Segment duration in minutes", { default: "20" });
23
+ cmd.option("--normalize", "Normalize audio loudness (EBU R128)");
24
+ cmd.option("--normalize-loudness <num>", "Target loudness in LUFS (default: -14)");
25
+ cmd.option("--normalize-codec <name>", "Audio codec for normalized output (default: aac)");
26
+ cmd.option("--normalize-bitrate <str>", "Audio bitrate for normalized output (default: 128k)");
23
27
  cmd.action((opts) => {
24
28
  try {
25
29
  const user = opts.user;
@@ -53,6 +57,14 @@ function parseArgs(argv = process.argv) {
53
57
  if (!Number.isFinite(n) || n < 1) throw new TikTokError("config-error", "--segment-minutes must be a number >= 1");
54
58
  parsed.segmentMinutes = n;
55
59
  }
60
+ if (opts.normalize) parsed.normalizeAudio = true;
61
+ if (opts.normalizeLoudness !== void 0) {
62
+ const n = Number(opts.normalizeLoudness);
63
+ if (!Number.isFinite(n)) throw new TikTokError("config-error", "--normalize-loudness must be a number");
64
+ parsed.normalizeLoudness = n;
65
+ }
66
+ if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec;
67
+ if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate;
56
68
  config = parsed;
57
69
  } catch (err) {
58
70
  error = err instanceof Error ? err.message : String(err);
package/dist/cli.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI(\"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\", {\n default: \"./recordings\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\"-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,EACnD,SAAS,eACX,CAAC;EACD,IAAI,OAAO,4BAA4B,+BAA+B,EACpE,SAAS,IACX,CAAC;EACD,IAAI,OAAO,4BAA4B,mCAAmC;EAC1E,IAAI,OAAO,qBAAqB,yCAAyC;EACzE,IAAI,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"}
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\", {\n default: \"./recordings\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\"-s, --segment-minutes <minutes>\", \"Segment duration in minutes\", { default: \"20\" })\n cmd.option(\"--normalize\", \"Normalize audio loudness (EBU R128)\")\n cmd.option(\"--normalize-loudness <num>\", \"Target loudness in LUFS (default: -14)\")\n cmd.option(\"--normalize-codec <name>\", \"Audio codec for normalized output (default: aac)\")\n cmd.option(\"--normalize-bitrate <str>\", \"Audio bitrate for normalized output (default: 128k)\")\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 if (opts.normalize) parsed.normalizeAudio = true\n if (opts.normalizeLoudness !== undefined) {\n const n = Number(opts.normalizeLoudness)\n if (!Number.isFinite(n)) {\n throw new TikTokError(\"config-error\", \"--normalize-loudness must be a number\")\n }\n parsed.normalizeLoudness = n\n }\n if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec as string\n if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate as string\n\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":"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"}
package/dist/config.d.mts CHANGED
@@ -29,6 +29,14 @@ interface RecorderConfig {
29
29
  logConsole?: boolean;
30
30
  /** Segment duration in minutes (default: 20). Stream is cut into segments of this length. */
31
31
  segmentMinutes?: number;
32
+ /** Normalize audio loudness via EBU R128 (default: false). */
33
+ normalizeAudio?: boolean;
34
+ /** Target loudness in LUFS (default: -14). */
35
+ normalizeLoudness?: number;
36
+ /** Audio codec for normalized output (default: "aac"). */
37
+ normalizeCodec?: string;
38
+ /** Audio bitrate for normalized output (default: "128k"). */
39
+ normalizeBitrate?: string;
32
40
  }
33
41
  interface RecorderController {
34
42
  /** Start the polling loop */
@@ -91,6 +99,22 @@ interface RecorderEventHandler {
91
99
  input: string;
92
100
  output: string;
93
101
  }) => void;
102
+ "normalize:start": (info: {
103
+ file: string;
104
+ }) => void;
105
+ "normalize:progress": (info: {
106
+ file: string;
107
+ percent: number;
108
+ phase: "analyzing" | "normalizing";
109
+ }) => void;
110
+ "normalize:end": (info: {
111
+ input: string;
112
+ output: string;
113
+ }) => void;
114
+ "normalize:error": (info: {
115
+ input: string;
116
+ error: string;
117
+ }) => void;
94
118
  error: (err: TikTokError) => void;
95
119
  }
96
120
  type AppErrorKind = "user-not-live" | "room-not-found" | "stream-url-not-found" | "waf-blocked" | "country-blocked" | "network-error" | "ffmpeg-not-found" | "config-error" | "unknown";
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AAKA;;;UAAiB,UAAA;EACf,YAAA;EACA,eAAe;AAAA;AAAA,KAGL,QAAA;AAAA,UAEK,cAAA;EAFG;EAIlB,IAAA;EAF6B;EAI7B,SAAA;EAYmB;EAVnB,QAAA;EAFA;EAIA,QAAA;EAAA;EAEA,KAAA;EAEA;EAAA,WAAA;EAEU;EAAV,OAAA,GAAU,UAAA;EAEC;EAAX,QAAA,GAAW,QAAQ;EAInB;EAFA,UAAA;EAEc;EAAd,cAAA;AAAA;AAAA,UAGe,kBAAA;EAEN;EAAT,KAAA,IAAS,OAAA;EAII;EAFb,IAAA,IAAQ,OAAA;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;IACpB,KAAA;IACA,OAAA;IACA,KAAA;EAAA;EAEF,cAAA,GAAiB,IAAA;IACf,IAAA;IACA,QAAA;IACA,IAAA;EAAA;EAEF,eAAA,GAAkB,IAAA;IAChB,IAAA;IACA,QAAA;IACA,IAAA;EAAA;EAEF,kBAAA,GAAqB,IAAA;IAAQ,KAAA;IAAe,aAAA;EAAA;EAC5C,gBAAA,GAAmB,IAAA;IAAQ,QAAA;EAAA;EAC3B,kBAAA,GAAqB,IAAA;IAAQ,KAAA;EAAA;EAC7B,SAAA,GAAY,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACnC,KAAA,GAAQ,GAAA,EAAK,WAAW;AAAA;AAAA,KAGd,YAAA;AAAA,cAWC,WAAA,SAAoB,KAAA;EAAA,SAEb,IAAA,EAAM,YAAA;EAAA,SAEN,KAAA;cAFA,IAAA,EAAM,YAAA,EACtB,OAAA,UACgB,KAAA;AAAA"}
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;EAMA;EAJA,cAAA;EAQA;EANA,cAAA;EAMgB;EAJhB,iBAAA;EAOiC;EALjC,cAAA;EAOS;EALT,gBAAA;AAAA;AAAA,UAGe,kBAAA;EAQiC;EANhD,KAAA,IAAS,OAAA;EAMwE;EAJjF,IAAA,IAAQ,OAAA;EAI0E;EAFlF,SAAA,IAAa,cAAA;EAJJ;EAMT,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,iBAAA,GAAoB,IAAA;IAAQ,IAAA;EAAA;EAC5B,oBAAA,GAAuB,IAAA;IACrB,IAAA;IACA,OAAA;IACA,KAAA;EAAA;EAEF,eAAA,GAAkB,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACzC,iBAAA,GAAoB,IAAA;IAAQ,KAAA;IAAe,KAAA;EAAA;EAC3C,KAAA,GAAQ,GAAA,EAAK,WAAW;AAAA;AAAA,KAGd,YAAA;AAAA,cAWC,WAAA,SAAoB,KAAA;EAAA,SAEb,IAAA,EAAM,YAAA;EAAA,SAEN,KAAA;cAFA,IAAA,EAAM,YAAA,EACtB,OAAA,UACgB,KAAA;AAAA"}
package/dist/config.mjs CHANGED
@@ -15,7 +15,11 @@ const DEFAULTS = {
15
15
  duration: 0,
16
16
  logLevel: "info",
17
17
  logConsole: true,
18
- segmentMinutes: 20
18
+ segmentMinutes: 20,
19
+ normalizeAudio: false,
20
+ normalizeLoudness: -14,
21
+ normalizeCodec: "aac",
22
+ normalizeBitrate: "128k"
19
23
  };
20
24
  function normalizeConfig(config) {
21
25
  return {
@@ -28,7 +32,11 @@ function normalizeConfig(config) {
28
32
  proxy: config.proxy,
29
33
  cookies: config.cookies,
30
34
  cookiesPath: config.cookiesPath,
31
- segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes
35
+ segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes,
36
+ normalizeAudio: config.normalizeAudio ?? DEFAULTS.normalizeAudio,
37
+ normalizeLoudness: config.normalizeLoudness ?? DEFAULTS.normalizeLoudness,
38
+ normalizeCodec: config.normalizeCodec ?? DEFAULTS.normalizeCodec,
39
+ normalizeBitrate: config.normalizeBitrate ?? DEFAULTS.normalizeBitrate
32
40
  };
33
41
  }
34
42
  function validateConfig(config) {
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\nexport interface CookieAuth {\n sessionid_ss: string\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: {\n bytes: number\n elapsed: number\n speed: number\n }) => void\n \"download:end\": (info: {\n file: string\n duration: number\n size: number\n }) => void\n \"recording:end\": (info: {\n file: string\n duration: number\n size: number\n }) => void\n \"segmenting:start\": (info: { input: string; outputPattern: string }) => void\n \"segmenting:end\": (info: { segments: number }) => void\n \"converting:start\": (info: { input: string }) => void\n converted: (info: { input: string; output: string }) => void\n error: (err: TikTokError) => void\n}\n\nexport type AppErrorKind =\n | \"user-not-live\"\n | \"room-not-found\"\n | \"stream-url-not-found\"\n | \"waf-blocked\"\n | \"country-blocked\"\n | \"network-error\"\n | \"ffmpeg-not-found\"\n | \"config-error\"\n | \"unknown\"\n\nexport class TikTokError extends Error {\n constructor(\n public readonly kind: AppErrorKind,\n message: string,\n public readonly cause?: unknown,\n ) {\n super(message)\n this.name = \"TikTokError\"\n }\n}\n\nconst DEFAULTS = {\n outputDir: \"./recordings\",\n interval: 3,\n duration: 0,\n logLevel: \"info\" as LogLevel,\n logConsole: true,\n segmentMinutes: 20,\n}\n\nexport function normalizeConfig(\n config: RecorderConfig,\n): Required<Omit<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\">> &\n Pick<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\"> {\n return {\n user: config.user,\n outputDir: config.outputDir ?? DEFAULTS.outputDir,\n interval: config.interval ?? DEFAULTS.interval,\n duration: config.duration ?? DEFAULTS.duration,\n logLevel: config.logLevel ?? DEFAULTS.logLevel,\n logConsole: config.logConsole ?? DEFAULTS.logConsole,\n proxy: config.proxy,\n cookies: config.cookies,\n cookiesPath: config.cookiesPath,\n segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes,\n }\n}\n\nexport function validateConfig(config: RecorderConfig): void {\n if (!config.user || config.user.trim().length === 0) {\n throw new TikTokError(\"config-error\", \"--user is required\")\n }\n if (config.interval !== undefined && config.interval < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be >= 1 minute\")\n }\n if (config.duration !== undefined && config.duration < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be >= 0 seconds\")\n }\n}\n"],"mappings":";AA8FA,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"}
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 /** Normalize audio loudness via EBU R128 (default: false). */\n normalizeAudio?: boolean\n /** Target loudness in LUFS (default: -14). */\n normalizeLoudness?: number\n /** Audio codec for normalized output (default: \"aac\"). */\n normalizeCodec?: string\n /** Audio bitrate for normalized output (default: \"128k\"). */\n normalizeBitrate?: string\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 \"normalize:start\": (info: { file: string }) => void\n \"normalize:progress\": (info: {\n file: string\n percent: number\n phase: \"analyzing\" | \"normalizing\"\n }) => void\n \"normalize:end\": (info: { input: string; output: string }) => void\n \"normalize:error\": (info: { input: string; error: 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 normalizeAudio: false,\n normalizeLoudness: -14,\n normalizeCodec: \"aac\",\n normalizeBitrate: \"128k\",\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 normalizeAudio: config.normalizeAudio ?? DEFAULTS.normalizeAudio,\n normalizeLoudness: config.normalizeLoudness ?? DEFAULTS.normalizeLoudness,\n normalizeCodec: config.normalizeCodec ?? DEFAULTS.normalizeCodec,\n normalizeBitrate: config.normalizeBitrate ?? DEFAULTS.normalizeBitrate,\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":";AAkGA,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;CAChB,gBAAgB;CAChB,mBAAmB;CACnB,gBAAgB;CAChB,kBAAkB;AACpB;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;EAClD,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,mBAAmB,OAAO,qBAAqB,SAAS;EACxD,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,kBAAkB,OAAO,oBAAoB,SAAS;CACxD;AACF;AAEA,SAAgB,eAAe,QAA8B;CAC3D,IAAI,CAAC,OAAO,QAAQ,OAAO,KAAK,KAAK,CAAC,CAAC,WAAW,GAChD,MAAM,IAAI,YAAY,gBAAgB,oBAAoB;CAE5D,IAAI,OAAO,aAAa,KAAA,KAAa,OAAO,WAAW,GACrD,MAAM,IAAI,YAAY,gBAAgB,gCAAgC;CAExE,IAAI,OAAO,aAAa,KAAA,KAAa,OAAO,WAAW,GACrD,MAAM,IAAI,YAAY,gBAAgB,iCAAiC;AAE3E"}
package/dist/index.mjs CHANGED
@@ -44,19 +44,20 @@ async function main() {
44
44
  if (!config.cookies) display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
45
45
  const recorder = createRecorder(config);
46
46
  let lastWasLive = null;
47
- let lastCheckTime = Date.now();
47
+ let firstOfflineTime = 0;
48
48
  recorder.on("checking", (info) => {
49
49
  display.checkingUser(info.user);
50
50
  });
51
51
  recorder.on("tick", (info) => {
52
52
  if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
53
- else if (lastWasLive === null || lastWasLive === true) display.userOffline(info.user);
54
- else {
55
- const elapsed = Date.now() - lastCheckTime;
53
+ else if (lastWasLive === null || lastWasLive === true) {
54
+ display.userOffline(info.user);
55
+ firstOfflineTime = Date.now();
56
+ } else {
57
+ const elapsed = Date.now() - firstOfflineTime;
56
58
  display.userOfflineRepeat(info.user, relativeTime(elapsed));
57
59
  }
58
60
  lastWasLive = info.isLive;
59
- lastCheckTime = Date.now();
60
61
  });
61
62
  recorder.on("recording:start", () => {
62
63
  display.startRecording();
@@ -83,12 +84,21 @@ async function main() {
83
84
  recorder.on("error", (err) => {
84
85
  display.showError(`[${err.kind}] ${err.message}`);
85
86
  });
87
+ recorder.on("normalize:start", () => {
88
+ display.normalizeStart();
89
+ });
90
+ recorder.on("normalize:progress", (info) => {
91
+ display.normalizeProgress(info.percent, info.phase);
92
+ });
93
+ recorder.on("normalize:end", () => {
94
+ display.normalizeComplete();
95
+ });
96
+ recorder.on("normalize:error", (info) => {
97
+ display.normalizeError(info.error);
98
+ });
86
99
  let stopping = false;
87
100
  const handleSignal = async () => {
88
- if (stopping) {
89
- process.stdout.write(" Force stopping...\n");
90
- process.exit(1);
91
- }
101
+ if (stopping) return;
92
102
  stopping = true;
93
103
  display.showInfo("Shutting down gracefully...");
94
104
  await recorder.stop();
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { extractTikTokCookiesFromFirefox } from \"./browser-cookies\"\nimport { parseArgs } from \"./cli\"\nimport { TikTokError, validateConfig } from \"./config\"\nimport type { RecorderConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./utils\"\n\nasync function loadCookies(\n cookiesPath?: string,\n): Promise<{ sessionid_ss: string; \"tt-target-idc\"?: string } | undefined> {\n const path = cookiesPath ?? join(process.cwd(), \"cookies.json\")\n if (!existsSync(path)) {\n return undefined\n }\n try {\n const content = readFileSync(path, \"utf-8\")\n const data = JSON.parse(content) as {\n sessionid_ss?: string\n \"tt-target-idc\"?: string\n }\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as { sessionid_ss: string; \"tt-target-idc\"?: string }\n }\n return undefined\n } catch {\n return undefined\n }\n}\n\nasync function main(): Promise<void> {\n let config: RecorderConfig\n\n try {\n config = parseArgs()\n validateConfig(config)\n } catch (err) {\n if (err instanceof TikTokError && err.kind === \"config-error\") {\n console.error(`${color.red(\"Error:\")} ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n\n // Load cookies: try Firefox browser first, then fall back to cookies.json\n config.cookies = extractTikTokCookiesFromFirefox() ?? (await loadCookies(config.cookiesPath))\n\n // Suppress internal logger's console output — the Display owns the terminal\n config.logConsole = false\n\n // Beautiful terminal display\n const display = createDisplay()\n\n if (!config.cookies) {\n display.showWarning(\n \"No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json\",\n )\n }\n\n const recorder = createRecorder(config)\n\n let lastWasLive: boolean | null = null\n let lastCheckTime = Date.now()\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n } else {\n const elapsed = Date.now() - lastCheckTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n lastCheckTime = Date.now()\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":";;;;;;;;;;;;;;;;AAiBA,eAAe,YACb,aACyE;CACzE,MAAM,OAAO,eAAe,KAAK,QAAQ,IAAI,GAAG,cAAc;CAC9D,IAAI,CAAC,WAAW,IAAI,GAClB;CAEF,IAAI;EACF,MAAM,UAAU,aAAa,MAAM,OAAO;EAC1C,MAAM,OAAO,KAAK,MAAM,OAAO;EAI/B,IAAI,KAAK,gBAAgB,KAAK,aAAa,SAAS,GAClD,OAAO;EAET;CACF,QAAQ;EACN;CACF;AACF;AAEA,eAAe,OAAsB;CACnC,IAAI;CAEJ,IAAI;EACF,SAAS,UAAU;EACnB,eAAe,MAAM;CACvB,SAAS,KAAK;EACZ,IAAI,eAAe,eAAe,IAAI,SAAS,gBAAgB;GAC7D,QAAQ,MAAM,GAAG,MAAM,IAAI,QAAQ,EAAE,GAAG,IAAI,SAAS;GACrD,QAAQ,KAAK,CAAC;EAChB;EACA,MAAM;CACR;CAGA,OAAO,UAAU,gCAAgC,KAAM,MAAM,YAAY,OAAO,WAAW;CAG3F,OAAO,aAAa;CAGpB,MAAM,UAAU,cAAc;CAE9B,IAAI,CAAC,OAAO,SACV,QAAQ,YACN,kFACF;CAGF,MAAM,WAAW,eAAe,MAAM;CAEtC,IAAI,cAA8B;CAClC,IAAI,gBAAgB,KAAK,IAAI;CAG7B,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;OACzC,IAAI,gBAAgB,QAAQ,gBAAgB,MACjD,QAAQ,YAAY,KAAK,IAAI;OACxB;GACL,MAAM,UAAU,KAAK,IAAI,IAAI;GAC7B,QAAQ,kBAAkB,KAAK,MAAM,aAAa,OAAO,CAAC;EAC5D;EACA,cAAc,KAAK;EACnB,gBAAgB,KAAK,IAAI;CAC3B,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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { extractTikTokCookiesFromFirefox } from \"./browser-cookies\"\nimport { parseArgs } from \"./cli\"\nimport type { RecorderConfig } from \"./config\"\nimport { TikTokError, validateConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./utils\"\n\nasync function loadCookies(\n cookiesPath?: string,\n): Promise<{ sessionid_ss: string; \"tt-target-idc\"?: string } | undefined> {\n const path = cookiesPath ?? join(process.cwd(), \"cookies.json\")\n if (!existsSync(path)) {\n return undefined\n }\n try {\n const content = readFileSync(path, \"utf-8\")\n const data = JSON.parse(content) as {\n sessionid_ss?: string\n \"tt-target-idc\"?: string\n }\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as { sessionid_ss: string; \"tt-target-idc\"?: string }\n }\n return undefined\n } catch {\n return undefined\n }\n}\n\nasync function main(): Promise<void> {\n let config: RecorderConfig\n\n try {\n config = parseArgs()\n validateConfig(config)\n } catch (err) {\n if (err instanceof TikTokError && err.kind === \"config-error\") {\n console.error(`${color.red(\"Error:\")} ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n\n // Load cookies: try Firefox browser first, then fall back to cookies.json\n config.cookies = extractTikTokCookiesFromFirefox() ?? (await loadCookies(config.cookiesPath))\n\n // Suppress internal logger's console output — the Display owns the terminal\n config.logConsole = false\n\n // Beautiful terminal display\n const display = createDisplay()\n\n if (!config.cookies) {\n display.showWarning(\n \"No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json\",\n )\n }\n\n const recorder = createRecorder(config)\n\n let lastWasLive: boolean | null = null\n let firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download: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 recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) return // ignore duplicate signals (e.g. SIGINT + SIGTERM from one Ctrl-C)\n stopping = true\n 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":"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"}
package/dist/package.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.6.0";
2
+ var version = "0.7.1";
3
3
  //#endregion
4
4
  export { version };
5
5
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/recorder/index.ts"],"mappings":";;;iBA0BgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAkB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/recorder/index.ts"],"mappings":";;;iBA2BgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAkB"}
@@ -4,6 +4,7 @@ import { TikTokError, normalizeConfig } from "../config.mjs";
4
4
  import { createLogger } from "../logger.mjs";
5
5
  import { createPollingMonitor } from "../monitor.mjs";
6
6
  import { createConverter } from "./convert.mjs";
7
+ import { createAudioNormalizer } from "./normalize.mjs";
7
8
  import { createStreamDownloader } from "./stream.mjs";
8
9
  import { spawn } from "node:child_process";
9
10
  import { readdirSync, statSync, unlinkSync, utimesSync } from "node:fs";
@@ -33,6 +34,7 @@ function createRecorder(config) {
33
34
  let monitor = null;
34
35
  let downloader = null;
35
36
  let converter = null;
37
+ let audioNormalizer = null;
36
38
  let stopRequested = false;
37
39
  function setState(partial) {
38
40
  state = {
@@ -89,6 +91,25 @@ function createRecorder(config) {
89
91
  api = createTikTokApi(httpClient);
90
92
  downloader = createStreamDownloader(logger);
91
93
  converter = createConverter(logger);
94
+ audioNormalizer = cfg.normalizeAudio ? createAudioNormalizer({
95
+ loudness: cfg.normalizeLoudness,
96
+ audioCodec: cfg.normalizeCodec,
97
+ audioBitrate: cfg.normalizeBitrate,
98
+ onStart: (file) => emit("normalize:start", { file }),
99
+ onProgress: (file, percent, phase) => emit("normalize:progress", {
100
+ file,
101
+ percent,
102
+ phase
103
+ }),
104
+ onComplete: (result) => emit("normalize:end", {
105
+ input: result.input,
106
+ output: result.output
107
+ }),
108
+ onError: (file, err) => emit("normalize:error", {
109
+ input: file,
110
+ error: err.message
111
+ })
112
+ }, logger) : null;
92
113
  monitor = createPollingMonitor({
93
114
  intervalMinutes: cfg.interval,
94
115
  logger,
@@ -208,6 +229,12 @@ function createRecorder(config) {
208
229
  utimesSync(fp, new Date(segStart), new Date(segStart));
209
230
  }
210
231
  emit("segmenting:end", { segments: segmentFiles.length });
232
+ if (audioNormalizer) for (const segFile of segmentFiles) {
233
+ const segPath = join(parsed.dir, segFile);
234
+ try {
235
+ await audioNormalizer.normalize(segPath);
236
+ } catch {}
237
+ }
211
238
  setState({
212
239
  currentFile: segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)) : result.file,
213
240
  sessionDuration: result.duration
@@ -222,6 +249,9 @@ function createRecorder(config) {
222
249
  input: result.file,
223
250
  output: mp4File
224
251
  });
252
+ if (audioNormalizer) try {
253
+ await audioNormalizer.normalize(mp4File);
254
+ } catch {}
225
255
  } catch (convErr) {
226
256
  const tkErr = convErr instanceof TikTokError ? convErr : new TikTokError("unknown", String(convErr));
227
257
  logger.error(`Conversion failed: ${tkErr.message}`);
@@ -232,6 +262,9 @@ function createRecorder(config) {
232
262
  emit("converting:start", { input: result.file });
233
263
  try {
234
264
  const mp4File = await converter.convert(result.file);
265
+ if (audioNormalizer) try {
266
+ await audioNormalizer.normalize(mp4File);
267
+ } catch {}
235
268
  emit("recording:end", {
236
269
  file: mp4File,
237
270
  duration: result.duration,
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n *\n * Downloads the full stream (FLV via fetch, HLS via FFmpeg), then uses FFmpeg\n * to split it into time-aligned MP4 segments.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport { createHttpClient } from \"../api/client\"\nimport { type TikTokApi, createTikTokApi } from \"../api/tiktok\"\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from \"../config\"\nimport { TikTokError, normalizeConfig } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { type PollingMonitor, createPollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type StreamDownloader, createStreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n // Internal event registry\n const eventHandlers = new Map<string, Array<(...args: any[]) => void>>()\n\n let state: RecorderStatus = {\n state: \"idle\",\n user: cfg.user,\n }\n\n // State machine\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let stopRequested = false\n\n function setState(partial: Partial<RecorderStatus>): void {\n state = { ...state, ...partial }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = eventHandlers.get(event) ?? []\n list.push(handler as (...args: any[]) => void)\n eventHandlers.set(event, list)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = eventHandlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function getFfmpegPath(): string {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n const path = bunWhich(\"ffmpeg\") as string | undefined\n if (path) return path\n }\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function runFfmpeg(args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n stopRequested = false\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger)\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emit(\"tick\", { user, isLive: true, roomId })\n\n // Record the stream\n setState({ state: \"recording\" })\n emit(\"recording:start\", { user, file: \"\" })\n\n // Create a reconnection callback that fetches a fresh stream URL\n // when the current segment ends. This handles TikTok's short-lived\n // stream URLs (typically 30-60s per segment).\n let reconnectCount = 0\n const getNextUrl = async (): Promise<string | null> => {\n reconnectCount++\n if (reconnectCount > 100) {\n // Safety valve: don't reconnect indefinitely\n logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`)\n return null\n }\n // Invalidate cache so we get fresh data from TikTok\n api!.invalidateCache()\n // Check if the user is still live with the same room\n const stillLive = await api!.getRoomId(user)\n if (!stillLive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Get a fresh stream URL for the continuing stream\n return api!.getLiveUrl(stillLive)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n setState({ state: \"converting\" })\n\n if (result.size > 0 && cfg.segmentMinutes >= 1) {\n // Split the FLV into timed MP4 segments using FFmpeg\n const parsed = parse(result.file)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n const segmentTime = Math.max(1, cfg.segmentMinutes * 60)\n\n logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`,\n )\n\n emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n await runFfmpeg([\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ])\n\n // Capture FLV timestamp before deletion (used to timestamp segments)\n const flvMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = flvMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original FLV\n try {\n unlinkSync(result.file)\n logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n logger?.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n return na - nb\n })\n\n const segmentDurationSec = cfg.segmentMinutes * 60\n const totalDuration = result.duration\n\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!)\n const stats = statSync(filePath)\n // Estimate duration: first N-1 are full segments, last is remainder\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n emit(\"recording:end\", {\n file: filePath,\n duration: estDuration,\n size: stats.size,\n })\n emit(\"converted\", {\n input: result.file,\n output: filePath,\n })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i]!)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n logger.error(`Segmenting failed: ${msg}`)\n // Fallback: try simple conversion of the whole FLV\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n } else if (result.size > 0) {\n // No segmenting — simple FLV → MP4 conversion\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"recording:end\", {\n file: mp4File,\n duration: result.duration,\n size: result.size,\n })\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n\n setState({ state: \"polling\" })\n },\n })\n\n setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n if (httpClient) await httpClient.close()\n setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus,\n on,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n *\n * Downloads the full stream (FLV via fetch, HLS via FFmpeg), then uses FFmpeg\n * to split it into time-aligned MP4 segments.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from \"../config\"\nimport { normalizeConfig, TikTokError } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { createStreamDownloader, type StreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n // Internal event registry\n const eventHandlers = new Map<string, Array<(...args: any[]) => void>>()\n\n let state: RecorderStatus = {\n state: \"idle\",\n user: cfg.user,\n }\n\n // State machine\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n\n function setState(partial: Partial<RecorderStatus>): void {\n state = { ...state, ...partial }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = eventHandlers.get(event) ?? []\n list.push(handler as (...args: any[]) => void)\n eventHandlers.set(event, list)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = eventHandlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function getFfmpegPath(): string {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n const path = bunWhich(\"ffmpeg\") as string | undefined\n if (path) return path\n }\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function runFfmpeg(args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n stopRequested = false\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) => emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emit(\"tick\", { user, isLive: true, roomId })\n\n // Record the stream\n setState({ state: \"recording\" })\n emit(\"recording:start\", { user, file: \"\" })\n\n // Create a reconnection callback that fetches a fresh stream URL\n // when the current segment ends. This handles TikTok's short-lived\n // stream URLs (typically 30-60s per segment).\n let reconnectCount = 0\n const getNextUrl = async (): Promise<string | null> => {\n reconnectCount++\n if (reconnectCount > 100) {\n // Safety valve: don't reconnect indefinitely\n logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`)\n return null\n }\n // Invalidate cache so we get fresh data from TikTok\n api!.invalidateCache()\n // Check if the user is still live with the same room\n const stillLive = await api!.getRoomId(user)\n if (!stillLive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Get a fresh stream URL for the continuing stream\n return api!.getLiveUrl(stillLive)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n setState({ state: \"converting\" })\n\n if (result.size > 0 && cfg.segmentMinutes >= 1) {\n // Split the FLV into timed MP4 segments using FFmpeg\n const parsed = parse(result.file)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n const segmentTime = Math.max(1, cfg.segmentMinutes * 60)\n\n logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`,\n )\n\n emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n await runFfmpeg([\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ])\n\n // Capture FLV timestamp before deletion (used to timestamp segments)\n const flvMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = flvMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original FLV\n try {\n unlinkSync(result.file)\n logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n logger?.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n return na - nb\n })\n\n const segmentDurationSec = cfg.segmentMinutes * 60\n const totalDuration = result.duration\n\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!)\n const stats = statSync(filePath)\n // Estimate duration: first N-1 are full segments, last is remainder\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n emit(\"recording:end\", {\n file: filePath,\n duration: estDuration,\n size: stats.size,\n })\n emit(\"converted\", {\n input: result.file,\n output: filePath,\n })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i]!)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n // Normalize audio for each segment if enabled\n if (audioNormalizer) {\n for (const segFile of segmentFiles) {\n const segPath = join(parsed.dir, segFile)\n try {\n await audioNormalizer.normalize(segPath)\n } catch {\n // Error already emitted via onError callback — continue to next segment\n }\n }\n }\n\n setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n logger.error(`Segmenting failed: ${msg}`)\n // Fallback: try simple conversion of the whole FLV\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"converted\", { input: result.file, output: mp4File })\n if (audioNormalizer) {\n try {\n await audioNormalizer.normalize(mp4File)\n } catch {\n // Error already emitted via onError callback\n }\n }\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n } else if (result.size > 0) {\n // No segmenting — simple FLV → MP4 conversion\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n if (audioNormalizer) {\n try {\n await audioNormalizer.normalize(mp4File)\n } catch {\n // Error already emitted via onError callback\n }\n }\n emit(\"recording:end\", {\n file: mp4File,\n duration: result.duration,\n size: result.size,\n })\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n\n setState({ state: \"polling\" })\n },\n })\n\n setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n if (httpClient) await httpClient.close()\n setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus,\n on,\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ import { normalizeFile } from "peaknorm";
2
+ //#region src/recorder/normalize.ts
3
+ function createAudioNormalizer(opts, logger) {
4
+ return { async normalize(file) {
5
+ logger?.info(`Normalizing audio: ${file}`);
6
+ opts.onStart?.(file);
7
+ try {
8
+ const result = await normalizeFile(file, {
9
+ loudness: opts.loudness,
10
+ audioCodec: opts.audioCodec,
11
+ audioBitrate: opts.audioBitrate,
12
+ backup: false,
13
+ onFileProgress: (f, percent, phase) => {
14
+ opts.onProgress?.(f, percent, phase);
15
+ }
16
+ });
17
+ logger?.info(`Audio normalized: ${result.output}`);
18
+ opts.onComplete?.(result);
19
+ return result;
20
+ } catch (err) {
21
+ const error = err instanceof Error ? err : new Error(String(err));
22
+ logger?.error(`Audio normalization failed: ${error.message}`);
23
+ opts.onError?.(file, error);
24
+ throw error;
25
+ }
26
+ } };
27
+ }
28
+ //#endregion
29
+ export { createAudioNormalizer };
30
+
31
+ //# sourceMappingURL=normalize.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.mjs","names":[],"sources":["../../src/recorder/normalize.ts"],"sourcesContent":["/**\n * Audio normalizer — wraps peaknorm's EBU R128 loudness normalization.\n * Runs after conversion/segmenting to normalize the final MP4 audio.\n */\n\nimport type { NormalizeResult } from \"peaknorm\"\nimport { normalizeFile } from \"peaknorm\"\nimport type { Logger } from \"../logger\"\n\nexport interface AudioNormalizer {\n /** Normalize a single audio/video file. Returns the normalize result. */\n normalize(file: string): Promise<NormalizeResult>\n}\n\nexport interface AudioNormalizerOptions {\n /** Target loudness in LUFS (default: -14). */\n loudness: number\n /** Audio codec for output (default: \"aac\"). */\n audioCodec: string\n /** Audio bitrate for output (default: \"128k\"). */\n audioBitrate: string\n /** Called when a file starts normalization. */\n onStart?: (file: string) => void\n /** Called with progress updates (percent 0-100, phase \"analyzing\"|\"normalizing\"). */\n onProgress?: (file: string, percent: number, phase: string) => void\n /** Called when a file completes normalization successfully. */\n onComplete?: (result: NormalizeResult) => void\n /** Called when a file errors during normalization. */\n onError?: (file: string, error: Error) => void\n}\n\nexport function createAudioNormalizer(\n opts: AudioNormalizerOptions,\n logger?: Logger,\n): AudioNormalizer {\n return {\n async normalize(file: string): Promise<NormalizeResult> {\n logger?.info(`Normalizing audio: ${file}`)\n opts.onStart?.(file)\n\n try {\n const result = await normalizeFile(file, {\n loudness: opts.loudness,\n audioCodec: opts.audioCodec,\n audioBitrate: opts.audioBitrate,\n backup: false,\n onFileProgress: (f, percent, phase) => {\n opts.onProgress?.(f, percent, phase)\n },\n })\n\n logger?.info(`Audio normalized: ${result.output}`)\n opts.onComplete?.(result)\n return result\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err))\n logger?.error(`Audio normalization failed: ${error.message}`)\n opts.onError?.(file, error)\n throw error\n }\n },\n }\n}\n"],"mappings":";;AA+BA,SAAgB,sBACd,MACA,QACiB;CACjB,OAAO,EACL,MAAM,UAAU,MAAwC;EACtD,QAAQ,KAAK,sBAAsB,MAAM;EACzC,KAAK,UAAU,IAAI;EAEnB,IAAI;GACF,MAAM,SAAS,MAAM,cAAc,MAAM;IACvC,UAAU,KAAK;IACf,YAAY,KAAK;IACjB,cAAc,KAAK;IACnB,QAAQ;IACR,iBAAiB,GAAG,SAAS,UAAU;KACrC,KAAK,aAAa,GAAG,SAAS,KAAK;IACrC;GACF,CAAC;GAED,QAAQ,KAAK,qBAAqB,OAAO,QAAQ;GACjD,KAAK,aAAa,MAAM;GACxB,OAAO;EACT,SAAS,KAAK;GACZ,MAAM,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;GAChE,QAAQ,MAAM,+BAA+B,MAAM,SAAS;GAC5D,KAAK,UAAU,MAAM,KAAK;GAC1B,MAAM;EACR;CACF,EACF;AACF"}
@@ -12,10 +12,12 @@ import { join } from "node:path";
12
12
  */
13
13
  function createStreamDownloader(logger) {
14
14
  let abortFlag = false;
15
+ let abortController = new AbortController();
15
16
  let streamReader = null;
16
17
  let ffmpegProcess = null;
17
18
  function abort() {
18
19
  abortFlag = true;
20
+ abortController.abort();
19
21
  streamReader?.cancel().catch(() => {});
20
22
  if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill("SIGTERM");
21
23
  }
@@ -46,9 +48,9 @@ function createStreamDownloader(logger) {
46
48
  function elapsed() {
47
49
  return (Date.now() - startTime) / 1e3;
48
50
  }
51
+ const bufferSize = 512 * 1024;
52
+ let buffer = Buffer.alloc(0);
49
53
  try {
50
- const bufferSize = 512 * 1024;
51
- let buffer = Buffer.alloc(0);
52
54
  function reportProgress(bytes) {
53
55
  if (!onProgress) return;
54
56
  const now = Date.now();
@@ -86,14 +88,25 @@ function createStreamDownloader(logger) {
86
88
  return false;
87
89
  }
88
90
  }
91
+ /** Resolves if the abort signal fires — used to break out of read waits instantly. */
92
+ function waitForAbort() {
93
+ return new Promise((_, reject) => {
94
+ if (abortController.signal.aborted) {
95
+ reject(/* @__PURE__ */ new Error("Aborted"));
96
+ return;
97
+ }
98
+ abortController.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("Aborted")), { once: true });
99
+ });
100
+ }
89
101
  while (!abortFlag) {
90
102
  let chunk;
91
103
  try {
92
- chunk = await timeout(reader.read(), 6e4, "Stream read timed out");
93
- } catch (err) {
94
- logger?.info("Read timed out, attempting reconnection...");
104
+ chunk = await Promise.race([timeout(reader.read(), 6e4, "Stream read timed out"), waitForAbort()]);
105
+ } catch (_err) {
106
+ if (abortFlag) break;
107
+ logger?.info("Stream ended or timed out, attempting reconnection...");
95
108
  if (await tryReconnect()) continue;
96
- throw err;
109
+ break;
97
110
  }
98
111
  if (chunk.done) {
99
112
  if (await tryReconnect()) continue;
@@ -132,6 +145,13 @@ function createStreamDownloader(logger) {
132
145
  };
133
146
  } catch (err) {
134
147
  abort();
148
+ try {
149
+ if (buffer.length > 0) {
150
+ await writeBuffer(writer, buffer);
151
+ totalBytes += buffer.length;
152
+ }
153
+ await new Promise((resolve) => writer.end(resolve));
154
+ } catch {}
135
155
  const duration = elapsed();
136
156
  logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`);
137
157
  return {
@@ -243,6 +263,7 @@ function createStreamDownloader(logger) {
243
263
  }
244
264
  async function download(liveUrl, user, outputDir, maxDuration = 0, onProgress, getNextUrl) {
245
265
  abortFlag = false;
266
+ abortController = new AbortController();
246
267
  ensureDir(outputDir);
247
268
  if (isHlsUrl(liveUrl)) return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress);
248
269
  return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl);
@@ -1 +1 @@
1
- {"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a live stream and writes it to disk with buffering.\n *\n * FLV streams: downloaded via raw fetch() with byte buffering and transparent\n * reconnection when TikTok stream segments end naturally.\n * HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport { type WriteStream, createWriteStream, statSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortFlag = false\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n let ffmpegProcess: ChildProcess | null = null\n\n function abort(): void {\n abortFlag = true\n streamReader?.cancel().catch(() => {})\n if (ffmpegProcess && !ffmpegProcess.killed) {\n ffmpegProcess.kill(\"SIGTERM\")\n }\n }\n\n /** Detect whether a URL is an HLS (.m3u8) playlist. */\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n // ─── FLV downloader (existing fetch-based path) ────────────────\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function downloadFlv(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"flv\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n try {\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info(\"Stream ended, attempting reconnection...\")\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info(\"Reconnected successfully\")\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await timeout(reader.read(), 60_000, \"Stream read timed out\")\n chunk = result\n } catch (err) {\n logger?.info(\"Read timed out, attempting reconnection...\")\n if (await tryReconnect()) {\n continue\n }\n throw err\n }\n\n if (chunk.done) {\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n reportProgress(totalBytes)\n }\n\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n // ─── HLS downloader (FFmpeg-based path) ───────────────────────\n\n async function downloadHls(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording (HLS): ${filename}`)\n\n const startTime = Date.now()\n let lastProgressTime = 0\n\n // Find FFmpeg\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found — required for HLS streams. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n // Spawn FFmpeg with the M3U8 URL as input.\n // FFmpeg handles downloading all .ts segments and concatenating them.\n const args = [\"-i\", liveUrl, \"-c\", \"copy\", \"-y\", filepath]\n\n return new Promise<DownloadResult>((resolve, reject) => {\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n ffmpegProcess = proc\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n // Poll output file size for progress reporting\n const progressTimer = setInterval(() => {\n if (onProgress) {\n try {\n const stats = statSync(filepath)\n const elapsed = (Date.now() - startTime) / 1000\n const speed = elapsed > 0 ? stats.size / elapsed : 0\n const now = Date.now()\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes: stats.size, elapsed, speed })\n }\n } catch {\n // File not created yet (FFmpeg still initializing) — skip\n }\n }\n }, 1000)\n\n // Max duration timer\n let maxDurationTimer: ReturnType<typeof setTimeout> | null = null\n if (maxDuration > 0) {\n maxDurationTimer = setTimeout(() => {\n logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`)\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }, maxDuration * 1000)\n }\n\n proc.on(\"close\", (code) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n\n if (abortFlag) {\n // Aborted by user — return whatever we have\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n resolve({ file: filepath, duration: 0, size: 0 })\n }\n return\n }\n\n // Normal completion or error\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n if (onProgress) {\n onProgress({\n bytes: stats.size,\n elapsed: duration,\n speed: duration > 0 ? stats.size / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n const msg = `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`\n reject(new Error(msg))\n }\n })\n\n proc.on(\"error\", (err) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n // ─── Main download entry point ──────────────────────────────\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortFlag = false\n ensureDir(outputDir)\n\n if (isHlsUrl(liveUrl)) {\n // HLS: FFmpeg handles playlist + segments natively.\n // Reconnection is not needed (FFmpeg refreshes the playlist internally).\n return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress)\n }\n\n // FLV: existing fetch-based path with reconnection support\n return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl)\n }\n\n return { download, abort }\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nfunction findFfmpegPath(): string | null {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n return (bunWhich(\"ffmpeg\") as string) ?? null\n }\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // not found in this directory\n }\n }\n return null\n}\n\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a live stream and writes it to disk with buffering.\n *\n * FLV streams: downloaded via raw fetch() with byte buffering and transparent\n * reconnection when TikTok stream segments end naturally.\n * HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport { createWriteStream, statSync, type WriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortFlag = false\n let abortController = new AbortController()\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n let ffmpegProcess: ChildProcess | null = null\n\n function abort(): void {\n abortFlag = true\n abortController.abort()\n streamReader?.cancel().catch(() => {})\n if (ffmpegProcess && !ffmpegProcess.killed) {\n ffmpegProcess.kill(\"SIGTERM\")\n }\n }\n\n /** Detect whether a URL is an HLS (.m3u8) playlist. */\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n // ─── FLV downloader (existing fetch-based path) ────────────────\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function downloadFlv(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"flv\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n try {\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info(\"Stream ended, attempting reconnection...\")\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info(\"Reconnected successfully\")\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n /** Resolves if the abort signal fires — used to break out of read waits instantly. */\n function waitForAbort(): Promise<never> {\n return new Promise((_, reject) => {\n if (abortController.signal.aborted) {\n reject(new Error(\"Aborted\"))\n return\n }\n abortController.signal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")), {\n once: true,\n })\n })\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await Promise.race([\n timeout(reader.read(), 60_000, \"Stream read timed out\"),\n waitForAbort(),\n ])\n chunk = result\n } catch (_err) {\n if (abortFlag) break\n logger?.info(\"Stream ended or timed out, attempting reconnection...\")\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n if (chunk.done) {\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n reportProgress(totalBytes)\n }\n\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n // Flush remaining buffer and close the write stream so the partial file is valid\n try {\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n } catch {\n // best effort — file may be truncated but readable\n }\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n // ─── HLS downloader (FFmpeg-based path) ───────────────────────\n\n async function downloadHls(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording (HLS): ${filename}`)\n\n const startTime = Date.now()\n let lastProgressTime = 0\n\n // Find FFmpeg\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found — required for HLS streams. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n // Spawn FFmpeg with the M3U8 URL as input.\n // FFmpeg handles downloading all .ts segments and concatenating them.\n const args = [\"-i\", liveUrl, \"-c\", \"copy\", \"-y\", filepath]\n\n return new Promise<DownloadResult>((resolve, reject) => {\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n ffmpegProcess = proc\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n // Poll output file size for progress reporting\n const progressTimer = setInterval(() => {\n if (onProgress) {\n try {\n const stats = statSync(filepath)\n const elapsed = (Date.now() - startTime) / 1000\n const speed = elapsed > 0 ? stats.size / elapsed : 0\n const now = Date.now()\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes: stats.size, elapsed, speed })\n }\n } catch {\n // File not created yet (FFmpeg still initializing) — skip\n }\n }\n }, 1000)\n\n // Max duration timer\n let maxDurationTimer: ReturnType<typeof setTimeout> | null = null\n if (maxDuration > 0) {\n maxDurationTimer = setTimeout(() => {\n logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`)\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }, maxDuration * 1000)\n }\n\n proc.on(\"close\", (code) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n\n if (abortFlag) {\n // Aborted by user — return whatever we have\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n resolve({ file: filepath, duration: 0, size: 0 })\n }\n return\n }\n\n // Normal completion or error\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n if (onProgress) {\n onProgress({\n bytes: stats.size,\n elapsed: duration,\n speed: duration > 0 ? stats.size / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n const msg = `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`\n reject(new Error(msg))\n }\n })\n\n proc.on(\"error\", (err) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n // ─── Main download entry point ──────────────────────────────\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortFlag = false\n abortController = new AbortController()\n ensureDir(outputDir)\n\n if (isHlsUrl(liveUrl)) {\n // HLS: FFmpeg handles playlist + segments natively.\n // Reconnection is not needed (FFmpeg refreshes the playlist internally).\n return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress)\n }\n\n // FLV: existing fetch-based path with reconnection support\n return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl)\n }\n\n return { download, abort }\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nfunction findFfmpegPath(): string | null {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n return (bunWhich(\"ffmpeg\") as string) ?? null\n }\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // not found in this directory\n }\n }\n return null\n}\n\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
package/dist/ui.mjs CHANGED
@@ -1,12 +1,5 @@
1
1
  import { ICON_ERROR, ICON_INFO, ICON_SUCCESS, ICON_WARN, color, createSpinner } from "@zfadhli/koko-cli";
2
2
  //#region src/ui.ts
3
- /**
4
- * Terminal Display Manager — beautiful console output using koko-cli's
5
- * spinner, icons, and color utilities.
6
- *
7
- * This module owns the terminal and should be the only thing writing to
8
- * stdout/stderr (the file logger handles persistent logs).
9
- */
10
3
  function createDisplay() {
11
4
  let activeSpinner = null;
12
5
  let offlineLineShown = false;
@@ -18,7 +11,7 @@ function createDisplay() {
18
11
  /** Stop the current spinner and print a one-liner with the given icon. */
19
12
  function finalize(icon, text) {
20
13
  clearSpinner();
21
- process.stdout.write(` ${icon} ${text}\n`);
14
+ process.stdout.write(`${icon}${text}\n`);
22
15
  }
23
16
  /** Format seconds into a compact human-readable duration. */
24
17
  function fmtDuration(seconds) {
@@ -60,16 +53,16 @@ function createDisplay() {
60
53
  },
61
54
  userOfflineRepeat(user, lastCheck) {
62
55
  clearSpinner();
63
- if (offlineLineShown) process.stdout.write(`\x1b[1A\r ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\x1b[K\n`);
56
+ if (offlineLineShown) process.stdout.write(`\x1b[1A\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\x1b[K\n`);
64
57
  else {
65
- process.stdout.write(` ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\n`);
58
+ process.stdout.write(`${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\n`);
66
59
  offlineLineShown = true;
67
60
  }
68
61
  },
69
62
  userLive(user, roomId) {
70
63
  clearSpinner();
71
64
  offlineLineShown = false;
72
- process.stdout.write(` ${ICON_SUCCESS} ${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\n`);
65
+ process.stdout.write(`${ICON_SUCCESS}${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\n`);
73
66
  },
74
67
  startRecording() {
75
68
  clearSpinner();
@@ -77,7 +70,7 @@ function createDisplay() {
77
70
  activeSpinner.start();
78
71
  },
79
72
  updateProgress(bytes, elapsed, speed) {
80
- if (!activeSpinner || !activeSpinner.isSpinning) return;
73
+ if (!activeSpinner?.isSpinning) return;
81
74
  activeSpinner.text = ` ${fmtBytes(bytes)} ${color.dim("•")} ${fmtDuration(elapsed)} ${color.dim("•")} ${fmtSpeed(speed)}`;
82
75
  },
83
76
  finishRecording(filename, duration, size) {
@@ -102,20 +95,35 @@ function createDisplay() {
102
95
  conversionDone(output) {
103
96
  finalize(ICON_SUCCESS, color.green(`Converted: ${output}`));
104
97
  },
98
+ normalizeStart() {
99
+ clearSpinner();
100
+ activeSpinner = createSpinner(color.cyan("Normalizing audio..."));
101
+ activeSpinner.start();
102
+ },
103
+ normalizeProgress(percent, phase) {
104
+ if (!activeSpinner?.isSpinning) return;
105
+ activeSpinner.text = ` ${phase === "analyzing" ? "Analyzing" : "Normalizing"}... ${percent}%`;
106
+ },
107
+ normalizeComplete() {
108
+ finalize(ICON_SUCCESS, color.green("Audio normalized"));
109
+ },
110
+ normalizeError(message) {
111
+ finalize(ICON_ERROR, color.red(`Normalization failed: ${message}`));
112
+ },
105
113
  showError(message) {
106
114
  clearSpinner();
107
115
  offlineLineShown = false;
108
- process.stdout.write(` ${ICON_ERROR} ${color.red(message)}\n`);
116
+ process.stdout.write(`${ICON_ERROR}${color.red(message)}\n`);
109
117
  },
110
118
  showInfo(message) {
111
119
  clearSpinner();
112
120
  offlineLineShown = false;
113
- process.stdout.write(` ${ICON_INFO} ${color.dim(message)}\n`);
121
+ process.stdout.write(`${ICON_INFO}${color.dim(message)}\n`);
114
122
  },
115
123
  showWarning(message) {
116
124
  clearSpinner();
117
125
  offlineLineShown = false;
118
- process.stdout.write(` ${ICON_WARN} ${color.yellow(message)}\n`);
126
+ process.stdout.write(`${ICON_WARN}${color.yellow(message)}\n`);
119
127
  },
120
128
  stop() {
121
129
  clearSpinner();
package/dist/ui.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ui.mjs","names":[],"sources":["../src/ui.ts"],"sourcesContent":["/**\n * Terminal Display Manager — beautiful console output using koko-cli's\n * spinner, icons, and color utilities.\n *\n * This module owns the terminal and should be the only thing writing to\n * stdout/stderr (the file logger handles persistent logs).\n */\n\nimport {\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n color,\n createSpinner,\n} from \"@zfadhli/koko-cli\"\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\n\nexport interface Display {\n /** Called once when polling begins. */\n pollingStarted(intervalMinutes: number): void\n /** Spinner while checking if a user is live. */\n checkingUser(user: string): void\n /** User is offline (no active display). */\n userOffline(user: string): void\n /** User is still offline — updates the previous offline line with a timestamp. */\n userOfflineRepeat(user: string, lastCheck: string): void\n /** User is live! Clears the checking spinner. */\n userLive(user: string, roomId: string): void\n\n /** Recording starts: spinner without filename (known only post-download). */\n startRecording(): void\n /** Update the recording spinner with live counters. */\n updateProgress(bytes: number, elapsed: number, speed: number): void\n /** Recording finished successfully. Shows filename from the result. */\n finishRecording(filename: string, duration: number, size: string): void\n /** Recording encountered a non-fatal error. */\n recordingError(message: string): void\n\n /** FFmpeg segmenting starts. */\n startSegmenting(): void\n /** FFmpeg segmenting done. */\n segmentsCreated(count: number): void\n\n /** FFmpeg simple conversion starts (fallback path). */\n startConverting(): void\n /** Simple conversion done. */\n conversionDone(output: string): void\n\n /** General-purpose status helpers. */\n showError(message: string): void\n showInfo(message: string): void\n showWarning(message: string): void\n\n /** Stop all active spinners and clean up the terminal. */\n stop(): void\n}\n\nexport function createDisplay(): Display {\n let activeSpinner: SpinnerInstance | null = null\n let offlineLineShown = false\n\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\n if (activeSpinner?.isSpinning) {\n activeSpinner.stop()\n }\n activeSpinner = null\n }\n\n /** Stop the current spinner and print a one-liner with the given icon. */\n function finalize(icon: string, text: string): void {\n clearSpinner()\n process.stdout.write(` ${icon} ${text}\\n`)\n }\n\n /** Format seconds into a compact human-readable duration. */\n function fmtDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n if (m > 0) return `${m}m ${s}s`\n return `${s}s`\n }\n\n /** Format bytes into human-readable size. */\n function fmtBytes(bytes: number): string {\n if (bytes === 0) return \"0 B\"\n const units = [\"B\", \"KB\", \"MB\", \"GB\"]\n const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)\n return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`\n }\n\n /** Format bytes per second into a readable speed string. */\n function fmtSpeed(bytesPerSec: number): string {\n if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`\n if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`\n return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`))\n },\n\n checkingUser(user: string): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(`Checking @${user}...`))\n activeSpinner.start()\n },\n\n userOffline(user: string): void {\n finalize(ICON_INFO, color.dim(`@${user} is offline`))\n offlineLineShown = true\n },\n\n userOfflineRepeat(user: string, lastCheck: string): void {\n clearSpinner()\n if (offlineLineShown) {\n // Move cursor up one line and overwrite the previous offline message\n process.stdout.write(\n `\\x1b[1A\\r ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\\x1b[K\\n`,\n )\n } else {\n // Fallback if state is out of sync\n process.stdout.write(\n ` ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\\n`,\n )\n offlineLineShown = true\n }\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(\n ` ${ICON_SUCCESS} ${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Recording stream...\"))\n activeSpinner.start()\n },\n\n updateProgress(bytes: number, elapsed: number, speed: number): void {\n if (!activeSpinner || !activeSpinner.isSpinning) return\n activeSpinner.text = ` ${fmtBytes(bytes)} ${color.dim(\"•\")} ${fmtDuration(elapsed)} ${color.dim(\"•\")} ${fmtSpeed(speed)}`\n },\n\n finishRecording(filename: string, duration: number, size: string): void {\n finalize(\n ICON_SUCCESS,\n `${color.green(\"Recording finished\")} ${color.dim(`${filename} — ${size} in ${fmtDuration(duration)}`)}`,\n )\n },\n\n recordingError(message: string): void {\n finalize(ICON_ERROR, color.red(`Recording failed: ${message}`))\n },\n\n startSegmenting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Segmenting...\"))\n activeSpinner.start()\n },\n\n segmentsCreated(count: number): void {\n finalize(ICON_SUCCESS, color.green(`Created ${count} MP4 segment${count !== 1 ? \"s\" : \"\"}`))\n },\n\n startConverting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Converting to MP4...\"))\n activeSpinner.start()\n },\n\n conversionDone(output: string): void {\n finalize(ICON_SUCCESS, color.green(`Converted: ${output}`))\n },\n\n showError(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(` ${ICON_ERROR} ${color.red(message)}\\n`)\n },\n\n showInfo(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(` ${ICON_INFO} ${color.dim(message)}\\n`)\n },\n\n showWarning(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(` ${ICON_WARN} ${color.yellow(message)}\\n`)\n },\n\n stop(): void {\n clearSpinner()\n offlineLineShown = false\n },\n }\n}\n"],"mappings":";;;;;;;;;AA0DA,SAAgB,gBAAyB;CACvC,IAAI,gBAAwC;CAC5C,IAAI,mBAAmB;;CAGvB,SAAS,eAAqB;EAC5B,IAAI,eAAe,YACjB,cAAc,KAAK;EAErB,gBAAgB;CAClB;;CAGA,SAAS,SAAS,MAAc,MAAoB;EAClD,aAAa;EACb,QAAQ,OAAO,MAAM,KAAK,KAAK,GAAG,KAAK,GAAG;CAC5C;;CAGA,SAAS,YAAY,SAAyB;EAC5C,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;EACjC,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;EACjC,IAAI,IAAI,GAAG,OAAO,GAAG,EAAE,IAAI,EAAE;EAC7B,OAAO,GAAG,EAAE;CACd;;CAGA,SAAS,SAAS,OAAuB;EACvC,IAAI,UAAU,GAAG,OAAO;EACxB,MAAM,QAAQ;GAAC;GAAK;GAAM;GAAM;EAAI;EACpC,MAAM,IAAI,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,IAAI,CAAC,GAAG,MAAM,SAAS,CAAC;EACjF,OAAO,IAAI,QAAQ,QAAQ,EAAA,CAAG,QAAQ,CAAC,EAAE,GAAG,MAAM;CACpD;;CAGA,SAAS,SAAS,aAA6B;EAC7C,IAAI,cAAc,MAAM,OAAO,GAAG,YAAY,QAAQ,CAAC,EAAE;EACzD,IAAI,cAAc,OAAO,MAAM,OAAO,IAAI,cAAc,KAAA,CAAM,QAAQ,CAAC,EAAE;EACzE,OAAO,IAAI,eAAe,OAAO,MAAA,CAAO,QAAQ,CAAC,EAAE;CACrD;CAEA,OAAO;EACL,eAAe,iBAA+B;GAC5C,SAAS,cAAc,MAAM,IAAI,0BAA0B,gBAAgB,MAAM,CAAC;EACpF;EAEA,aAAa,MAAoB;GAC/B,aAAa;GACb,gBAAgB,cAAc,MAAM,KAAK,aAAa,KAAK,IAAI,CAAC;GAChE,cAAc,MAAM;EACtB;EAEA,YAAY,MAAoB;GAC9B,SAAS,WAAW,MAAM,IAAI,IAAI,KAAK,YAAY,CAAC;GACpD,mBAAmB;EACrB;EAEA,kBAAkB,MAAc,WAAyB;GACvD,aAAa;GACb,IAAI,kBAEF,QAAQ,OAAO,MACb,cAAc,UAAU,GAAG,MAAM,IAAI,IAAI,KAAK,YAAY,EAAE,GAAG,MAAM,IAAI,gBAAgB,UAAU,EAAE,EAAE,SACzG;QACK;IAEL,QAAQ,OAAO,MACb,KAAK,UAAU,GAAG,MAAM,IAAI,IAAI,KAAK,YAAY,EAAE,GAAG,MAAM,IAAI,gBAAgB,UAAU,EAAE,EAAE,GAChG;IACA,mBAAmB;GACrB;EACF;EAEA,SAAS,MAAc,QAAsB;GAC3C,aAAa;GACb,mBAAmB;GACnB,QAAQ,OAAO,MACb,KAAK,aAAa,GAAG,MAAM,MAAM,IAAI,KAAK,UAAU,EAAE,GAAG,MAAM,IAAI,UAAU,OAAO,EAAE,EAAE,GAC1F;EACF;EAEA,iBAAuB;GACrB,aAAa;GACb,gBAAgB,cAAc,MAAM,KAAK,qBAAqB,CAAC;GAC/D,cAAc,MAAM;EACtB;EAEA,eAAe,OAAe,SAAiB,OAAqB;GAClE,IAAI,CAAC,iBAAiB,CAAC,cAAc,YAAY;GACjD,cAAc,OAAO,IAAI,SAAS,KAAK,EAAE,GAAG,MAAM,IAAI,GAAG,EAAE,GAAG,YAAY,OAAO,EAAE,GAAG,MAAM,IAAI,GAAG,EAAE,GAAG,SAAS,KAAK;EACxH;EAEA,gBAAgB,UAAkB,UAAkB,MAAoB;GACtE,SACE,cACA,GAAG,MAAM,MAAM,oBAAoB,EAAE,GAAG,MAAM,IAAI,GAAG,SAAS,KAAK,KAAK,MAAM,YAAY,QAAQ,GAAG,GACvG;EACF;EAEA,eAAe,SAAuB;GACpC,SAAS,YAAY,MAAM,IAAI,qBAAqB,SAAS,CAAC;EAChE;EAEA,kBAAwB;GACtB,aAAa;GACb,gBAAgB,cAAc,MAAM,KAAK,eAAe,CAAC;GACzD,cAAc,MAAM;EACtB;EAEA,gBAAgB,OAAqB;GACnC,SAAS,cAAc,MAAM,MAAM,WAAW,MAAM,cAAc,UAAU,IAAI,MAAM,IAAI,CAAC;EAC7F;EAEA,kBAAwB;GACtB,aAAa;GACb,gBAAgB,cAAc,MAAM,KAAK,sBAAsB,CAAC;GAChE,cAAc,MAAM;EACtB;EAEA,eAAe,QAAsB;GACnC,SAAS,cAAc,MAAM,MAAM,cAAc,QAAQ,CAAC;EAC5D;EAEA,UAAU,SAAuB;GAC/B,aAAa;GACb,mBAAmB;GACnB,QAAQ,OAAO,MAAM,KAAK,WAAW,GAAG,MAAM,IAAI,OAAO,EAAE,GAAG;EAChE;EAEA,SAAS,SAAuB;GAC9B,aAAa;GACb,mBAAmB;GACnB,QAAQ,OAAO,MAAM,KAAK,UAAU,GAAG,MAAM,IAAI,OAAO,EAAE,GAAG;EAC/D;EAEA,YAAY,SAAuB;GACjC,aAAa;GACb,mBAAmB;GACnB,QAAQ,OAAO,MAAM,KAAK,UAAU,GAAG,MAAM,OAAO,OAAO,EAAE,GAAG;EAClE;EAEA,OAAa;GACX,aAAa;GACb,mBAAmB;EACrB;CACF;AACF"}
1
+ {"version":3,"file":"ui.mjs","names":[],"sources":["../src/ui.ts"],"sourcesContent":["/**\n * Terminal Display Manager — beautiful console output using koko-cli's\n * spinner, icons, and color utilities.\n *\n * This module owns the terminal and should be the only thing writing to\n * stdout/stderr (the file logger handles persistent logs).\n */\n\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\nimport {\n color,\n createSpinner,\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n} from \"@zfadhli/koko-cli\"\n\nexport interface Display {\n /** Called once when polling begins. */\n pollingStarted(intervalMinutes: number): void\n /** Spinner while checking if a user is live. */\n checkingUser(user: string): void\n /** User is offline (no active display). */\n userOffline(user: string): void\n /** User is still offline — updates the previous offline line with a timestamp. */\n userOfflineRepeat(user: string, lastCheck: string): void\n /** User is live! Clears the checking spinner. */\n userLive(user: string, roomId: string): void\n\n /** Recording starts: spinner without filename (known only post-download). */\n startRecording(): void\n /** Update the recording spinner with live counters. */\n updateProgress(bytes: number, elapsed: number, speed: number): void\n /** Recording finished successfully. Shows filename from the result. */\n finishRecording(filename: string, duration: number, size: string): void\n /** Recording encountered a non-fatal error. */\n recordingError(message: string): void\n\n /** FFmpeg segmenting starts. */\n startSegmenting(): void\n /** FFmpeg segmenting done. */\n segmentsCreated(count: number): void\n\n /** FFmpeg simple conversion starts (fallback path). */\n startConverting(): void\n /** Simple conversion done. */\n conversionDone(output: string): void\n\n /** Audio normalization starts (EBU R128 via peaknorm). */\n normalizeStart(): void\n /** Audio normalization progress update. */\n normalizeProgress(percent: number, phase: string): void\n /** Audio normalization completed successfully. */\n normalizeComplete(): void\n /** Audio normalization failed. */\n normalizeError(message: string): void\n\n /** General-purpose status helpers. */\n showError(message: string): void\n showInfo(message: string): void\n showWarning(message: string): void\n\n /** Stop all active spinners and clean up the terminal. */\n stop(): void\n}\n\nexport function createDisplay(): Display {\n let activeSpinner: SpinnerInstance | null = null\n let offlineLineShown = false\n\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\n if (activeSpinner?.isSpinning) {\n activeSpinner.stop()\n }\n activeSpinner = null\n }\n\n /** Stop the current spinner and print a one-liner with the given icon. */\n function finalize(icon: string, text: string): void {\n clearSpinner()\n process.stdout.write(`${icon}${text}\\n`)\n }\n\n /** Format seconds into a compact human-readable duration. */\n function fmtDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n if (m > 0) return `${m}m ${s}s`\n return `${s}s`\n }\n\n /** Format bytes into human-readable size. */\n function fmtBytes(bytes: number): string {\n if (bytes === 0) return \"0 B\"\n const units = [\"B\", \"KB\", \"MB\", \"GB\"]\n const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)\n return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`\n }\n\n /** Format bytes per second into a readable speed string. */\n function fmtSpeed(bytesPerSec: number): string {\n if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`\n if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`\n return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`))\n },\n\n checkingUser(user: string): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(`Checking @${user}...`))\n activeSpinner.start()\n },\n\n userOffline(user: string): void {\n finalize(ICON_INFO, color.dim(`@${user} is offline`))\n offlineLineShown = true\n },\n\n userOfflineRepeat(user: string, lastCheck: string): void {\n clearSpinner()\n if (offlineLineShown) {\n // Move cursor up one line and overwrite the previous offline message\n process.stdout.write(\n `\\x1b[1A\\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\\x1b[K\\n`,\n )\n } else {\n // Fallback if state is out of sync\n process.stdout.write(\n `${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\\n`,\n )\n offlineLineShown = true\n }\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(\n `${ICON_SUCCESS}${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Recording stream...\"))\n activeSpinner.start()\n },\n\n updateProgress(bytes: number, elapsed: number, speed: number): void {\n if (!activeSpinner?.isSpinning) return\n activeSpinner.text = ` ${fmtBytes(bytes)} ${color.dim(\"•\")} ${fmtDuration(elapsed)} ${color.dim(\"•\")} ${fmtSpeed(speed)}`\n },\n\n finishRecording(filename: string, duration: number, size: string): void {\n finalize(\n ICON_SUCCESS,\n `${color.green(\"Recording finished\")} ${color.dim(`${filename} — ${size} in ${fmtDuration(duration)}`)}`,\n )\n },\n\n recordingError(message: string): void {\n finalize(ICON_ERROR, color.red(`Recording failed: ${message}`))\n },\n\n startSegmenting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Segmenting...\"))\n activeSpinner.start()\n },\n\n segmentsCreated(count: number): void {\n finalize(ICON_SUCCESS, color.green(`Created ${count} MP4 segment${count !== 1 ? \"s\" : \"\"}`))\n },\n\n startConverting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Converting to MP4...\"))\n activeSpinner.start()\n },\n\n conversionDone(output: string): void {\n finalize(ICON_SUCCESS, color.green(`Converted: ${output}`))\n },\n\n normalizeStart(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Normalizing audio...\"))\n activeSpinner.start()\n },\n\n normalizeProgress(percent: number, phase: string): void {\n if (!activeSpinner?.isSpinning) return\n const label = phase === \"analyzing\" ? \"Analyzing\" : \"Normalizing\"\n activeSpinner.text = ` ${label}... ${percent}%`\n },\n\n normalizeComplete(): void {\n finalize(ICON_SUCCESS, color.green(\"Audio normalized\"))\n },\n\n normalizeError(message: string): void {\n finalize(ICON_ERROR, color.red(`Normalization failed: ${message}`))\n },\n\n showError(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${ICON_ERROR}${color.red(message)}\\n`)\n },\n\n showInfo(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${ICON_INFO}${color.dim(message)}\\n`)\n },\n\n showWarning(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${ICON_WARN}${color.yellow(message)}\\n`)\n },\n\n stop(): void {\n clearSpinner()\n offlineLineShown = false\n },\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zfadhli/tokrec",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Minimal TikTok live stream recorder — Bun + TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/lib.mjs",
@@ -32,13 +32,14 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@zfadhli/koko-cli": "^0.1.0",
35
+ "peaknorm": "^0.2.4",
35
36
  "wreq-js": "^2.3"
36
37
  },
37
38
  "devDependencies": {
38
- "@biomejs/biome": "^1.9",
39
+ "@biomejs/biome": "^2.4",
39
40
  "@types/bun": "latest",
40
41
  "tsdown": "^0.22",
41
- "typescript": "^5.8"
42
+ "typescript": "^6.0"
42
43
  },
43
44
  "engines": {
44
45
  "bun": ">=1.2"