@zfadhli/tokrec 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.mjs CHANGED
@@ -16,7 +16,7 @@ const DEFAULTS = {
16
16
  logLevel: "info",
17
17
  logConsole: true,
18
18
  segmentMinutes: 0,
19
- normalizeAudio: false,
19
+ normalizeAudio: true,
20
20
  normalizeLoudness: -14,
21
21
  normalizeCodec: "aac",
22
22
  normalizeBitrate: "128k",
@@ -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\n/**\n * TikTok cookie key-value pairs extracted from Firefox or cookies.json.\n * Includes session cookies (sessionid_ss, tt-target-idc) and any WAF\n * bypass cookies needed to avoid Slardar challenges on www.tiktok.com.\n */\nexport type CookieAuth = Record<string, string>\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: 0 — disabled). When >= 1, the recording is split 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 * Max API requests per second (default: 5).\n * Prevents TikTok WAF triggering on rapid polling or URL refresh loops.\n * Set to 0 for unlimited (not recommended).\n */\n ratePerSecond?: number\n /** Show API debug logging on stderr (default: false). */\n debug?: boolean\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 /** Unsubscribe a previously registered event handler */\n off<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 | \"ffmpeg-error\"\n | \"aborted\"\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: 0,\n normalizeAudio: false,\n normalizeLoudness: -14,\n normalizeCodec: \"aac\",\n normalizeBitrate: \"128k\",\n ratePerSecond: 5,\n debug: false,\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 ratePerSecond: config.ratePerSecond ?? DEFAULTS.ratePerSecond,\n debug: config.debug ?? DEFAULTS.debug,\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":";AAgHA,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;CAClB,eAAe;CACf,OAAO;AACT;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;EACtD,eAAe,OAAO,iBAAiB,SAAS;EAChD,OAAO,OAAO,SAAS,SAAS;CAClC;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\n/**\n * TikTok cookie key-value pairs extracted from Firefox or cookies.json.\n * Includes session cookies (sessionid_ss, tt-target-idc) and any WAF\n * bypass cookies needed to avoid Slardar challenges on www.tiktok.com.\n */\nexport type CookieAuth = Record<string, string>\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: 0 — disabled). When >= 1, the recording is split 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 * Max API requests per second (default: 5).\n * Prevents TikTok WAF triggering on rapid polling or URL refresh loops.\n * Set to 0 for unlimited (not recommended).\n */\n ratePerSecond?: number\n /** Show API debug logging on stderr (default: false). */\n debug?: boolean\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 /** Unsubscribe a previously registered event handler */\n off<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 | \"ffmpeg-error\"\n | \"aborted\"\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: 0,\n normalizeAudio: true,\n normalizeLoudness: -14,\n normalizeCodec: \"aac\",\n normalizeBitrate: \"128k\",\n ratePerSecond: 5,\n debug: false,\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 ratePerSecond: config.ratePerSecond ?? DEFAULTS.ratePerSecond,\n debug: config.debug ?? DEFAULTS.debug,\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":";AAgHA,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;CAClB,eAAe;CACf,OAAO;AACT;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;EACtD,eAAe,OAAO,iBAAiB,SAAS;EAChD,OAAO,OAAO,SAAS,SAAS;CAClC;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/package.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.8.0";
2
+ var version = "0.9.0";
3
3
  //#endregion
4
4
  export { version };
5
5
 
@@ -171,6 +171,14 @@ function createRecorder(config) {
171
171
  if (cfg.duration > 0) {
172
172
  logger.info("Duration limit reached — exiting");
173
173
  monitor.stopAfterCurrentTick();
174
+ } else if (!stopRequested && roomId) {
175
+ if (!await api.isRoomAlive(roomId)) {
176
+ logger.info(`@${user} is offline`);
177
+ emitter.emit("tick", {
178
+ user,
179
+ isLive: false
180
+ });
181
+ }
174
182
  }
175
183
  }
176
184
  });
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport type { RecorderConfig, RecorderController } from \"../config\"\nimport { normalizeConfig, TikTokError } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { sleep } from \"../utils\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { findFfmpegPath } from \"./ffmpeg-utils\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { processRecording } from \"./post-processing\"\nimport { createEventEmitter } from \"./recorder-events\"\nimport { createRecorderState } from \"./recorder-state\"\nimport type { DownloadResult } from \"./stream\"\nimport { createStreamDownloader, type StreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n const emitter = createEventEmitter()\n const stateManager = createRecorderState({ state: \"idle\" as const, user: cfg.user }, logger)\n\n // State variables\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n let stopAbortController = new AbortController()\n let pendingRemuxes: Promise<unknown>[] = []\n\n function getFfmpegPath(): string {\n const path = findFfmpegPath()\n if (!path) {\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 return path\n }\n\n function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.on(\"close\", (code) => {\n if (signal?.aborted) {\n reject(new TikTokError(\"unknown\", \"Aborted by user\"))\n return\n }\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n if (err instanceof Error && err.name === \"AbortError\") return\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n if (stateManager.getStatus().state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`)\n return\n }\n stopRequested = false\n pendingRemuxes = []\n stopAbortController = new AbortController()\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient, logger, cfg.debug)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger, stopAbortController.signal)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emitter.emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emitter.emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emitter.emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) =>\n emitter.emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emitter.emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emitter.emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emitter.emit(\"tick\", { user, isLive: true, roomId })\n\n stateManager.setState({ state: \"recording\" })\n emitter.emit(\"recording:start\", { user, file: \"\" })\n\n const getNextUrl = async (): Promise<string | null> => {\n // Use the lightweight check_alive endpoint first — avoids a full\n // page re-scan + room/info call when the stream is still active.\n const alive = await api!.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Still alive — invalidate cache and get a fresh stream URL.\n api!.invalidateCache()\n return api!.getLiveUrl(roomId)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emitter.emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n\n await processRecording(result as DownloadResult, cfg, {\n runFfmpeg,\n converter: converter!,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (event: string, ...args: any[]) => (emitter as any).emit(event, ...args),\n setState: stateManager.setState,\n stopRequested: () => stopRequested,\n logger,\n })\n\n // One-shot mode: if a duration was set, stop after this recording\n if (cfg.duration > 0) {\n logger.info(\"Duration limit reached — exiting\")\n monitor!.stopAfterCurrentTick()\n }\n },\n })\n\n stateManager.setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n if (stateManager.getStatus().state === \"stopped\") {\n logger.warn(`stop() ignored: already stopped`)\n return\n }\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n\n if (pendingRemuxes.length > 0) {\n logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 60s)...`)\n const abortTimeout = new AbortController()\n await Promise.race([\n Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()),\n sleep(60_000, abortTimeout.signal),\n ])\n pendingRemuxes = []\n }\n\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n stopAbortController.abort()\n if (httpClient) await httpClient.close()\n emitter.clear()\n stateManager.setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus: stateManager.getStatus,\n on: emitter.on,\n off: emitter.off,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport type { RecorderConfig, RecorderController } from \"../config\"\nimport { normalizeConfig, TikTokError } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { sleep } from \"../utils\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { findFfmpegPath } from \"./ffmpeg-utils\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { processRecording } from \"./post-processing\"\nimport { createEventEmitter } from \"./recorder-events\"\nimport { createRecorderState } from \"./recorder-state\"\nimport type { DownloadResult } from \"./stream\"\nimport { createStreamDownloader, type StreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n const emitter = createEventEmitter()\n const stateManager = createRecorderState({ state: \"idle\" as const, user: cfg.user }, logger)\n\n // State variables\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n let stopAbortController = new AbortController()\n let pendingRemuxes: Promise<unknown>[] = []\n\n function getFfmpegPath(): string {\n const path = findFfmpegPath()\n if (!path) {\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 return path\n }\n\n function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.on(\"close\", (code) => {\n if (signal?.aborted) {\n reject(new TikTokError(\"unknown\", \"Aborted by user\"))\n return\n }\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n if (err instanceof Error && err.name === \"AbortError\") return\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n if (stateManager.getStatus().state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`)\n return\n }\n stopRequested = false\n pendingRemuxes = []\n stopAbortController = new AbortController()\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient, logger, cfg.debug)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger, stopAbortController.signal)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emitter.emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emitter.emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emitter.emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) =>\n emitter.emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emitter.emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emitter.emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emitter.emit(\"tick\", { user, isLive: true, roomId })\n\n stateManager.setState({ state: \"recording\" })\n emitter.emit(\"recording:start\", { user, file: \"\" })\n\n const getNextUrl = async (): Promise<string | null> => {\n // Use the lightweight check_alive endpoint first — avoids a full\n // page re-scan + room/info call when the stream is still active.\n const alive = await api!.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Still alive — invalidate cache and get a fresh stream URL.\n api!.invalidateCache()\n return api!.getLiveUrl(roomId)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emitter.emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n\n await processRecording(result as DownloadResult, cfg, {\n runFfmpeg,\n converter: converter!,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (event: string, ...args: any[]) => (emitter as any).emit(event, ...args),\n setState: stateManager.setState,\n stopRequested: () => stopRequested,\n logger,\n })\n\n // One-shot mode: if a duration was set, stop after this recording\n if (cfg.duration > 0) {\n logger.info(\"Duration limit reached — exiting\")\n monitor!.stopAfterCurrentTick()\n } else if (!stopRequested && roomId) {\n // Recording ended because the stream went offline (not user-initiated stop).\n // Immediately re-check via lightweight check_alive endpoint instead of\n // waiting for the next poll interval.\n const alive = await api!.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n }\n }\n },\n })\n\n stateManager.setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n if (stateManager.getStatus().state === \"stopped\") {\n logger.warn(`stop() ignored: already stopped`)\n return\n }\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n\n if (pendingRemuxes.length > 0) {\n logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 60s)...`)\n const abortTimeout = new AbortController()\n await Promise.race([\n Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()),\n sleep(60_000, abortTimeout.signal),\n ])\n pendingRemuxes = []\n }\n\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n stopAbortController.abort()\n if (httpClient) await httpClient.close()\n emitter.clear()\n stateManager.setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus: stateManager.getStatus,\n on: emitter.on,\n off: emitter.off,\n }\n}\n"],"mappings":"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"}
package/dist/ui.mjs CHANGED
@@ -3,8 +3,14 @@ import { ICON_ERROR, ICON_INFO, ICON_SUCCESS, ICON_WARN, color, createSpinner }
3
3
  function createDisplay() {
4
4
  let activeSpinner = null;
5
5
  let offlineLineShown = false;
6
+ let recordingStartTime = null;
7
+ let recordingTimer = null;
6
8
  /** Stop the currently active spinner (if any) without printing anything. */
7
9
  function clearSpinner() {
10
+ if (recordingTimer) {
11
+ clearInterval(recordingTimer);
12
+ recordingTimer = null;
13
+ }
8
14
  if (activeSpinner?.isSpinning) activeSpinner.stop();
9
15
  activeSpinner = null;
10
16
  }
@@ -20,24 +26,6 @@ function createDisplay() {
20
26
  if (m > 0) return `${m}m ${s}s`;
21
27
  return `${s}s`;
22
28
  }
23
- /** Format bytes into human-readable size. */
24
- function fmtBytes(bytes) {
25
- if (bytes === 0) return "0 B";
26
- const units = [
27
- "B",
28
- "KB",
29
- "MB",
30
- "GB"
31
- ];
32
- const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
33
- return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
34
- }
35
- /** Format bytes per second into a readable speed string. */
36
- function fmtSpeed(bytesPerSec) {
37
- if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;
38
- if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
39
- return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
40
- }
41
29
  return {
42
30
  pollingStarted(intervalMinutes) {
43
31
  finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`));
@@ -53,9 +41,9 @@ function createDisplay() {
53
41
  },
54
42
  userOfflineRepeat(user, lastCheck) {
55
43
  clearSpinner();
56
- if (offlineLineShown) process.stdout.write(`\x1b[1A\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\x1b[K\n`);
44
+ if (offlineLineShown) process.stdout.write(`\x1b[1A\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\x1b[K\n`);
57
45
  else {
58
- process.stdout.write(`${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\n`);
46
+ process.stdout.write(`${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\n`);
59
47
  offlineLineShown = true;
60
48
  }
61
49
  },
@@ -66,13 +54,16 @@ function createDisplay() {
66
54
  },
67
55
  startRecording() {
68
56
  clearSpinner();
69
- activeSpinner = createSpinner(color.cyan("Recording stream..."));
57
+ recordingStartTime = Date.now();
58
+ activeSpinner = createSpinner(`${color.cyan("Recording...")} ${color.dim("[0s]")}`);
70
59
  activeSpinner.start();
60
+ recordingTimer = setInterval(() => {
61
+ if (!activeSpinner?.isSpinning || !recordingStartTime) return;
62
+ const elapsed = (Date.now() - recordingStartTime) / 1e3;
63
+ activeSpinner.text = `${color.cyan("Recording...")} ${color.dim(`[${fmtDuration(elapsed)}]`)}`;
64
+ }, 1e3);
71
65
  },
72
- updateProgress(bytes, elapsed, speed) {
73
- if (!activeSpinner?.isSpinning) return;
74
- activeSpinner.text = ` ${fmtBytes(bytes)} ${color.dim("•")} ${fmtDuration(elapsed)} ${color.dim("•")} ${fmtSpeed(speed)}`;
75
- },
66
+ updateProgress(_bytes, _elapsed, _speed) {},
76
67
  finishRecording(filename, duration, size) {
77
68
  finalize(ICON_SUCCESS, `${color.green("Recording finished")} ${color.dim(`${filename} — ${size} in ${fmtDuration(duration)}`)}`);
78
69
  },
package/dist/ui.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ui.mjs","names":[],"sources":["../src/ui.ts"],"sourcesContent":["/**\n * Terminal Display Manager — beautiful console output using koko-cli's\n * spinner, icons, and color utilities.\n *\n * This module owns the terminal and should be the only thing writing to\n * stdout/stderr (the file logger handles persistent logs).\n */\n\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\nimport {\n color,\n createSpinner,\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n} from \"@zfadhli/koko-cli\"\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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"}
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 // Recording timer state\n let recordingStartTime: number | null = null\n let recordingTimer: ReturnType<typeof setInterval> | null = null\n\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\n if (recordingTimer) {\n clearInterval(recordingTimer)\n recordingTimer = null\n }\n if (activeSpinner?.isSpinning) {\n activeSpinner.stop()\n }\n activeSpinner = null\n }\n\n /** Stop the current spinner and print a one-liner with the given icon. */\n function finalize(icon: string, text: string): void {\n clearSpinner()\n process.stdout.write(`${icon}${text}\\n`)\n }\n\n /** 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 return {\n pollingStarted(intervalMinutes: number): void {\n finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`))\n },\n\n checkingUser(user: string): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(`Checking @${user}...`))\n activeSpinner.start()\n },\n\n userOffline(user: string): void {\n finalize(ICON_INFO, color.dim(`@${user} is offline`))\n offlineLineShown = true\n },\n\n userOfflineRepeat(user: string, lastCheck: string): void {\n clearSpinner()\n if (offlineLineShown) {\n // Move cursor up one line and overwrite the previous offline message\n process.stdout.write(\n `\\x1b[1A\\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\\x1b[K\\n`,\n )\n } else {\n // Fallback if state is out of sync\n process.stdout.write(\n `${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\\n`,\n )\n offlineLineShown = true\n }\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(\n `${ICON_SUCCESS}${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n recordingStartTime = Date.now()\n activeSpinner = createSpinner(`${color.cyan(\"Recording...\")} ${color.dim(\"[0s]\")}`)\n activeSpinner.start()\n\n recordingTimer = setInterval(() => {\n if (!activeSpinner?.isSpinning || !recordingStartTime) return\n const elapsed = (Date.now() - recordingStartTime) / 1000\n activeSpinner.text = `${color.cyan(\"Recording...\")} ${color.dim(`[${fmtDuration(elapsed)}]`)}`\n }, 1000)\n },\n\n updateProgress(_bytes: number, _elapsed: number, _speed: number): void {\n // Timer is handled by the interval in startRecording()\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.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Minimal TikTok live stream recorder — Bun + TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/lib.mjs",