@zfadhli/tokrec 0.7.1 → 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.
Files changed (44) hide show
  1. package/README.md +129 -76
  2. package/dist/api/client.mjs +45 -19
  3. package/dist/api/client.mjs.map +1 -1
  4. package/dist/api/rate-limiter.mjs +29 -0
  5. package/dist/api/rate-limiter.mjs.map +1 -0
  6. package/dist/api/tiktok.mjs +180 -51
  7. package/dist/api/tiktok.mjs.map +1 -1
  8. package/dist/browser-cookies.mjs +8 -11
  9. package/dist/browser-cookies.mjs.map +1 -1
  10. package/dist/cli.mjs +10 -2
  11. package/dist/cli.mjs.map +1 -1
  12. package/dist/config.d.mts +18 -6
  13. package/dist/config.d.mts.map +1 -1
  14. package/dist/config.mjs +8 -4
  15. package/dist/config.mjs.map +1 -1
  16. package/dist/index.mjs +22 -4
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/monitor.mjs +12 -13
  19. package/dist/monitor.mjs.map +1 -1
  20. package/dist/package.mjs +1 -1
  21. package/dist/recorder/convert.mjs +25 -25
  22. package/dist/recorder/convert.mjs.map +1 -1
  23. package/dist/recorder/download-hls.mjs +77 -0
  24. package/dist/recorder/download-hls.mjs.map +1 -0
  25. package/dist/recorder/download-stream.mjs +78 -0
  26. package/dist/recorder/download-stream.mjs.map +1 -0
  27. package/dist/recorder/ffmpeg-utils.mjs +122 -0
  28. package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
  29. package/dist/recorder/index.d.mts.map +1 -1
  30. package/dist/recorder/index.mjs +88 -176
  31. package/dist/recorder/index.mjs.map +1 -1
  32. package/dist/recorder/post-processing.mjs +150 -0
  33. package/dist/recorder/post-processing.mjs.map +1 -0
  34. package/dist/recorder/recorder-events.mjs +34 -0
  35. package/dist/recorder/recorder-events.mjs.map +1 -0
  36. package/dist/recorder/recorder-state.mjs +38 -0
  37. package/dist/recorder/recorder-state.mjs.map +1 -0
  38. package/dist/recorder/stream.mjs +5 -293
  39. package/dist/recorder/stream.mjs.map +1 -1
  40. package/dist/ui.mjs +16 -25
  41. package/dist/ui.mjs.map +1 -1
  42. package/dist/utils.mjs +19 -3
  43. package/dist/utils.mjs.map +1 -1
  44. package/package.json +6 -6
@@ -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;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"}
1
+ {"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AAUA;;;;AAA+B;AAE/B;;;KAFY,UAAA,GAAa,MAAM;AAAA,KAEnB,QAAA;AAAA,UAEK,cAAA;EAAc;EAE7B,IAAA;EAcmB;EAZnB,SAAA;EAAA;EAEA,QAAA;EAEA;EAAA,QAAA;EAIA;EAFA,KAAA;EAIU;EAFV,WAAA;EAIW;EAFX,OAAA,GAAU,UAAA;EAMV;EAJA,QAAA,GAAW,QAAQ;EAQnB;EANA,UAAA;EAUA;EARA,cAAA;EAgBA;EAdA,cAAA;EAcK;EAZL,iBAAA;EAeiC;EAbjC,cAAA;EAeS;EAbT,gBAAA;EAiBa;;;;;EAXb,aAAA;EAeiD;EAbjD,KAAA;AAAA;AAAA,UAGe,kBAAA;EAUoE;EARnF,KAAA,IAAS,OAAA;EAAA;EAET,IAAA,IAAQ,OAAA;EAAA;EAER,SAAA,IAAa,cAAA;EAAA;EAEb,EAAA,iBAAmB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;EAA9E;EAEH,GAAA,iBAAoB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;AAAA;AAAA,UAGnE,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,cAaC,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,11 +15,13 @@ const DEFAULTS = {
15
15
  duration: 0,
16
16
  logLevel: "info",
17
17
  logConsole: true,
18
- segmentMinutes: 20,
19
- normalizeAudio: false,
18
+ segmentMinutes: 0,
19
+ normalizeAudio: true,
20
20
  normalizeLoudness: -14,
21
21
  normalizeCodec: "aac",
22
- normalizeBitrate: "128k"
22
+ normalizeBitrate: "128k",
23
+ ratePerSecond: 5,
24
+ debug: false
23
25
  };
24
26
  function normalizeConfig(config) {
25
27
  return {
@@ -36,7 +38,9 @@ function normalizeConfig(config) {
36
38
  normalizeAudio: config.normalizeAudio ?? DEFAULTS.normalizeAudio,
37
39
  normalizeLoudness: config.normalizeLoudness ?? DEFAULTS.normalizeLoudness,
38
40
  normalizeCodec: config.normalizeCodec ?? DEFAULTS.normalizeCodec,
39
- normalizeBitrate: config.normalizeBitrate ?? DEFAULTS.normalizeBitrate
41
+ normalizeBitrate: config.normalizeBitrate ?? DEFAULTS.normalizeBitrate,
42
+ ratePerSecond: config.ratePerSecond ?? DEFAULTS.ratePerSecond,
43
+ debug: config.debug ?? DEFAULTS.debug
40
44
  };
41
45
  }
42
46
  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 /** 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"}
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/index.mjs CHANGED
@@ -38,10 +38,16 @@ async function main() {
38
38
  }
39
39
  throw err;
40
40
  }
41
- config.cookies = extractTikTokCookiesFromFirefox() ?? await loadCookies(config.cookiesPath);
41
+ const firefoxCookies = extractTikTokCookiesFromFirefox();
42
+ config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
42
43
  config.logConsole = false;
43
44
  const display = createDisplay();
44
- if (!config.cookies) display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
45
+ if (config.cookies) {
46
+ const source = firefoxCookies ? "Firefox" : "cookies.json";
47
+ const names = Object.keys(config.cookies);
48
+ const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
49
+ display.showInfo(`${source} cookies loaded${detail}`);
50
+ } else display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
45
51
  const recorder = createRecorder(config);
46
52
  let lastWasLive = null;
47
53
  let firstOfflineTime = 0;
@@ -79,7 +85,10 @@ async function main() {
79
85
  recorder.on("converting:start", () => {
80
86
  display.startConverting();
81
87
  });
82
- recorder.on("converted", () => {});
88
+ recorder.on("converted", (info) => {
89
+ const parsed = info.output.split("/").pop() ?? info.output;
90
+ display.conversionDone(parsed);
91
+ });
83
92
  recorder.on("recording:end", () => {});
84
93
  recorder.on("error", (err) => {
85
94
  display.showError(`[${err.kind}] ${err.message}`);
@@ -100,6 +109,9 @@ async function main() {
100
109
  const handleSignal = async () => {
101
110
  if (stopping) return;
102
111
  stopping = true;
112
+ process.off("SIGINT", handleSignal);
113
+ process.off("SIGTERM", handleSignal);
114
+ process.off("SIGHUP", handleSignal);
103
115
  display.showInfo("Shutting down gracefully...");
104
116
  await recorder.stop();
105
117
  display.stop();
@@ -114,12 +126,18 @@ async function main() {
114
126
  } catch (err) {
115
127
  if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
116
128
  else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
129
+ process.off("SIGINT", handleSignal);
130
+ process.off("SIGTERM", handleSignal);
131
+ process.off("SIGHUP", handleSignal);
117
132
  await recorder.stop();
118
133
  display.stop();
119
134
  process.exit(1);
120
135
  }
121
136
  }
122
- main();
137
+ main().catch((err) => {
138
+ console.error("Fatal:", err instanceof Error ? err.message : String(err));
139
+ process.exit(1);
140
+ });
123
141
  //#endregion
124
142
  export {};
125
143
 
@@ -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 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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"}
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(cookiesPath?: string): Promise<Record<string, 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 Record<string, string | undefined>\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as Record<string, 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 const firefoxCookies = extractTikTokCookiesFromFirefox()\n config.cookies = firefoxCookies ?? (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 const source = firefoxCookies ? \"Firefox\" : \"cookies.json\"\n const names = Object.keys(config.cookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n display.showInfo(`${source} cookies loaded${detail}`)\n } else {\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\", (info) => {\n const parsed = info.output.split(\"/\").pop() ?? info.output\n display.conversionDone(parsed)\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 // Remove signal handlers so they don't accumulate if main() is called again\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\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 process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain().catch((err) => {\n console.error(\"Fatal:\", err instanceof Error ? err.message : String(err))\n process.exit(1)\n})\n"],"mappings":"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"}
package/dist/monitor.mjs CHANGED
@@ -2,12 +2,12 @@ import { sleep } from "./utils.mjs";
2
2
  //#region src/monitor.ts
3
3
  function createPollingMonitor(opts) {
4
4
  const intervalMs = opts.intervalMinutes * 60 * 1e3;
5
- let active = true;
5
+ const stopSignal = new AbortController();
6
6
  let currentTick = null;
7
+ let stopAfterTick = false;
7
8
  async function start() {
8
- active = true;
9
9
  opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`);
10
- while (active) {
10
+ while (!stopSignal.signal.aborted) {
11
11
  currentTick = opts.onTick();
12
12
  try {
13
13
  await currentTick;
@@ -17,24 +17,23 @@ function createPollingMonitor(opts) {
17
17
  } finally {
18
18
  currentTick = null;
19
19
  }
20
- if (!active) break;
21
- const checkInterval = 1e3;
22
- let waited = 0;
23
- while (waited < intervalMs) {
24
- if (!active) break;
25
- await sleep(Math.min(checkInterval, intervalMs - waited));
26
- waited += checkInterval;
27
- }
20
+ if (stopAfterTick) stopSignal.abort();
21
+ if (stopSignal.signal.aborted) break;
22
+ await sleep(intervalMs, stopSignal.signal);
28
23
  }
29
24
  opts.logger?.info("Polling stopped");
30
25
  }
31
26
  async function stop() {
32
- active = false;
27
+ stopSignal.abort();
33
28
  if (currentTick) await currentTick;
34
29
  }
30
+ function stopAfterCurrentTick() {
31
+ stopAfterTick = true;
32
+ }
35
33
  return {
36
34
  start,
37
- stop
35
+ stop,
36
+ stopAfterCurrentTick
38
37
  };
39
38
  }
40
39
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"monitor.mjs","names":[],"sources":["../src/monitor.ts"],"sourcesContent":["/**\n * Polling monitor — calls a tick function on a configurable interval.\n * Fires the first tick immediately, then every N minutes.\n */\n\nimport type { Logger } from \"./logger\"\nimport { sleep } from \"./utils\"\n\nexport interface PollingMonitor {\n start: () => Promise<void>\n stop: () => Promise<void>\n}\n\nexport function createPollingMonitor(opts: {\n intervalMinutes: number\n onTick: () => Promise<void>\n logger?: Logger\n}): PollingMonitor {\n const intervalMs = opts.intervalMinutes * 60 * 1000\n let active = true\n let currentTick: Promise<void> | null = null\n\n async function start(): Promise<void> {\n active = true\n opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`)\n\n while (active) {\n currentTick = opts.onTick()\n try {\n await currentTick\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n opts.logger?.error(`Tick error: ${msg}`)\n } finally {\n currentTick = null\n }\n\n if (!active) break\n\n // Wait for the interval, checking periodically if we should stop\n const checkInterval = 1000 // check every second\n let waited = 0\n while (waited < intervalMs) {\n if (!active) break\n await sleep(Math.min(checkInterval, intervalMs - waited))\n waited += checkInterval\n }\n }\n\n opts.logger?.info(\"Polling stopped\")\n }\n\n async function stop(): Promise<void> {\n active = false\n // Wait for in-flight tick to finish (e.g. recording → converting → MP4)\n if (currentTick) {\n await currentTick\n }\n }\n\n return { start, stop }\n}\n"],"mappings":";;AAaA,SAAgB,qBAAqB,MAIlB;CACjB,MAAM,aAAa,KAAK,kBAAkB,KAAK;CAC/C,IAAI,SAAS;CACb,IAAI,cAAoC;CAExC,eAAe,QAAuB;EACpC,SAAS;EACT,KAAK,QAAQ,KAAK,8BAA8B,KAAK,gBAAgB,MAAM;EAE3E,OAAO,QAAQ;GACb,cAAc,KAAK,OAAO;GAC1B,IAAI;IACF,MAAM;GACR,SAAS,KAAK;IACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IAC3D,KAAK,QAAQ,MAAM,eAAe,KAAK;GACzC,UAAU;IACR,cAAc;GAChB;GAEA,IAAI,CAAC,QAAQ;GAGb,MAAM,gBAAgB;GACtB,IAAI,SAAS;GACb,OAAO,SAAS,YAAY;IAC1B,IAAI,CAAC,QAAQ;IACb,MAAM,MAAM,KAAK,IAAI,eAAe,aAAa,MAAM,CAAC;IACxD,UAAU;GACZ;EACF;EAEA,KAAK,QAAQ,KAAK,iBAAiB;CACrC;CAEA,eAAe,OAAsB;EACnC,SAAS;EAET,IAAI,aACF,MAAM;CAEV;CAEA,OAAO;EAAE;EAAO;CAAK;AACvB"}
1
+ {"version":3,"file":"monitor.mjs","names":[],"sources":["../src/monitor.ts"],"sourcesContent":["/**\n * Polling monitor — calls a tick function on a configurable interval.\n * Fires the first tick immediately, then every N minutes.\n *\n * Uses an AbortController so stop() can interrupt the sleep between\n * ticks immediately, avoiding a busy 1-second polling loop.\n */\n\nimport type { Logger } from \"./logger\"\nimport { sleep } from \"./utils\"\n\nexport interface PollingMonitor {\n start: () => Promise<void>\n stop: () => Promise<void>\n /** Schedule the monitor to stop after the current tick completes. */\n stopAfterCurrentTick: () => void\n}\n\nexport function createPollingMonitor(opts: {\n intervalMinutes: number\n onTick: () => Promise<void>\n logger?: Logger\n}): PollingMonitor {\n const intervalMs = opts.intervalMinutes * 60 * 1000\n const stopSignal = new AbortController()\n let currentTick: Promise<void> | null = null\n let stopAfterTick = false\n\n async function start(): Promise<void> {\n opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`)\n\n while (!stopSignal.signal.aborted) {\n currentTick = opts.onTick()\n try {\n await currentTick\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n opts.logger?.error(`Tick error: ${msg}`)\n } finally {\n currentTick = null\n }\n\n if (stopAfterTick) {\n stopSignal.abort()\n }\n if (stopSignal.signal.aborted) break\n\n // Sleep for the full interval — stop() aborts the signal to wake us up early\n await sleep(intervalMs, stopSignal.signal)\n }\n\n opts.logger?.info(\"Polling stopped\")\n }\n\n async function stop(): Promise<void> {\n // Signal start() to exit the loop immediately\n stopSignal.abort()\n // Wait for in-flight tick to finish (e.g. recording → converting → MP4)\n if (currentTick) {\n await currentTick\n }\n }\n\n function stopAfterCurrentTick(): void {\n stopAfterTick = true\n }\n\n return { start, stop, stopAfterCurrentTick }\n}\n"],"mappings":";;AAkBA,SAAgB,qBAAqB,MAIlB;CACjB,MAAM,aAAa,KAAK,kBAAkB,KAAK;CAC/C,MAAM,aAAa,IAAI,gBAAgB;CACvC,IAAI,cAAoC;CACxC,IAAI,gBAAgB;CAEpB,eAAe,QAAuB;EACpC,KAAK,QAAQ,KAAK,8BAA8B,KAAK,gBAAgB,MAAM;EAE3E,OAAO,CAAC,WAAW,OAAO,SAAS;GACjC,cAAc,KAAK,OAAO;GAC1B,IAAI;IACF,MAAM;GACR,SAAS,KAAK;IACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IAC3D,KAAK,QAAQ,MAAM,eAAe,KAAK;GACzC,UAAU;IACR,cAAc;GAChB;GAEA,IAAI,eACF,WAAW,MAAM;GAEnB,IAAI,WAAW,OAAO,SAAS;GAG/B,MAAM,MAAM,YAAY,WAAW,MAAM;EAC3C;EAEA,KAAK,QAAQ,KAAK,iBAAiB;CACrC;CAEA,eAAe,OAAsB;EAEnC,WAAW,MAAM;EAEjB,IAAI,aACF,MAAM;CAEV;CAEA,SAAS,uBAA6B;EACpC,gBAAgB;CAClB;CAEA,OAAO;EAAE;EAAO;EAAM;CAAqB;AAC7C"}
package/dist/package.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.7.1";
2
+ var version = "0.9.0";
3
3
  //#endregion
4
4
  export { version };
5
5
 
@@ -1,17 +1,19 @@
1
+ import { TikTokError } from "../config.mjs";
2
+ import { findFfmpegPath } from "./ffmpeg-utils.mjs";
1
3
  import { spawn } from "node:child_process";
2
- import { statSync, unlinkSync } from "node:fs";
4
+ import { unlinkSync } from "node:fs";
3
5
  //#region src/recorder/convert.ts
4
6
  /**
5
- * Converter — spawns FFmpeg to convert FLV/TS → MP4 (stream copy by default).
7
+ * Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).
6
8
  * Deletes the original file on success.
7
9
  */
8
- function createConverter(logger) {
10
+ function createConverter(logger, signal) {
9
11
  async function convert(input) {
10
12
  const output = input.replace(/\.(flv|ts)$/i, ".mp4");
11
13
  logger?.info(`Converting: ${input} → ${output}`);
12
- const ffmpegPath = findFfmpeg();
13
- if (!ffmpegPath) throw new Error("FFmpeg not found. Install it:\n Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n macOS: brew install ffmpeg\n Windows: choco install ffmpeg");
14
- await runFfmpeg(ffmpegPath, input, output);
14
+ const ffmpegPath = findFfmpegPath();
15
+ if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n macOS: brew install ffmpeg\n Windows: choco install ffmpeg");
16
+ await runFfmpeg(ffmpegPath, input, output, signal);
15
17
  try {
16
18
  unlinkSync(input);
17
19
  logger?.info(`Deleted original: ${input}`);
@@ -23,18 +25,7 @@ function createConverter(logger) {
23
25
  }
24
26
  return { convert };
25
27
  }
26
- function findFfmpeg() {
27
- const bunWhich = Bun?.which;
28
- if (typeof bunWhich === "function") return bunWhich("ffmpeg");
29
- const paths = process.env.PATH?.split(":") ?? [];
30
- for (const dir of paths) try {
31
- const full = `${dir}/ffmpeg`;
32
- statSync(full);
33
- return full;
34
- } catch {}
35
- return null;
36
- }
37
- function runFfmpeg(ffmpegPath, input, output) {
28
+ function runFfmpeg(ffmpegPath, input, output, signal) {
38
29
  return new Promise((resolve, reject) => {
39
30
  const proc = spawn(ffmpegPath, [
40
31
  "-i",
@@ -43,21 +34,30 @@ function runFfmpeg(ffmpegPath, input, output) {
43
34
  "copy",
44
35
  "-y",
45
36
  output
46
- ], { stdio: [
47
- "ignore",
48
- "pipe",
49
- "pipe"
50
- ] });
37
+ ], {
38
+ stdio: [
39
+ "ignore",
40
+ "pipe",
41
+ "pipe"
42
+ ],
43
+ signal
44
+ });
51
45
  let stderr = "";
52
46
  proc.stderr?.on("data", (chunk) => {
53
47
  stderr += chunk.toString();
48
+ if (stderr.length > 1e4) stderr = stderr.slice(-5e3);
54
49
  });
55
50
  proc.on("close", (code) => {
51
+ if (signal?.aborted) {
52
+ reject(new TikTokError("aborted", "Aborted by user"));
53
+ return;
54
+ }
56
55
  if (code === 0) resolve();
57
- else reject(/* @__PURE__ */ new Error(`FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
56
+ else reject(new TikTokError("ffmpeg-error", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
58
57
  });
59
58
  proc.on("error", (err) => {
60
- reject(/* @__PURE__ */ new Error(`Failed to spawn FFmpeg: ${err.message}`));
59
+ if (err instanceof Error && err.name === "AbortError") return;
60
+ reject(new TikTokError("ffmpeg-error", `Failed to spawn FFmpeg: ${err.message}`));
61
61
  });
62
62
  });
63
63
  }
@@ -1 +1 @@
1
- {"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert FLV/TS → MP4 (stream copy by default).\n * Deletes the original file on success.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { statSync, unlinkSync } from \"node:fs\"\nimport type { Logger } from \"../logger\"\n\nexport interface Converter {\n /** Convert a FLV or TS file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.(flv|ts)$/i, \".mp4\")\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n const ffmpegPath = findFfmpeg()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found. Install it:\\n\" +\n \" Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\\n\" +\n \" macOS: brew install ffmpeg\\n\" +\n \" Windows: choco install ffmpeg\",\n )\n }\n\n await runFfmpeg(ffmpegPath, input, output)\n\n // Delete original FLV\n try {\n unlinkSync(input)\n logger?.info(`Deleted original: ${input}`)\n } catch {\n logger?.warn(`Could not delete original: ${input}`)\n }\n\n logger?.info(`Converted: ${output}`)\n return output\n }\n\n return { convert }\n}\n\nfunction findFfmpeg(): string | null {\n // Bun.which searches PATH like `which`\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n return bunWhich(\"ffmpeg\") as string | null\n }\n\n // Fallback: manual PATH search\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // eslint-disable-line no-empty\n }\n }\n return null\n}\n\nfunction runFfmpeg(ffmpegPath: string, input: string, output: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const args = [\n \"-i\",\n input,\n \"-c\",\n \"copy\", // stream copy — no re-encode, fast\n \"-y\", // overwrite output\n output,\n ]\n\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(new Error(`FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`))\n }\n })\n\n proc.on(\"error\", (err) => {\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n}\n"],"mappings":";;;;;;;AAcA,SAAgB,gBAAgB,QAA4B;CAC1D,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,gBAAgB,MAAM;EAEnD,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,WAAW;EAC9B,IAAI,CAAC,YACH,MAAM,IAAI,MACR,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,MAAM;EAGzC,IAAI;GACF,WAAW,KAAK;GAChB,QAAQ,KAAK,qBAAqB,OAAO;EAC3C,QAAQ;GACN,QAAQ,KAAK,8BAA8B,OAAO;EACpD;EAEA,QAAQ,KAAK,cAAc,QAAQ;EACnC,OAAO;CACT;CAEA,OAAO,EAAE,QAAQ;AACnB;AAEA,SAAS,aAA4B;CAEnC,MAAM,WAAY,KAAa;CAC/B,IAAI,OAAO,aAAa,YACtB,OAAO,SAAS,QAAQ;CAI1B,MAAM,QAAQ,QAAQ,IAAI,MAAM,MAAM,GAAG,KAAK,CAAC;CAC/C,KAAK,MAAM,OAAO,OAChB,IAAI;EACF,MAAM,OAAO,GAAG,IAAI;EACpB,SAAS,IAAI;EACb,OAAO;CACT,QAAQ,CAER;CAEF,OAAO;AACT;AAEA,SAAS,UAAU,YAAoB,OAAe,QAA+B;CACnF,OAAO,IAAI,SAAS,SAAS,WAAW;EAUtC,MAAM,OAAO,MAAM,YAAY;GAR7B;GACA;GACA;GACA;GACA;GACA;EAGgC,GAAG,EACnC,OAAO;GAAC;GAAU;GAAQ;EAAM,EAClC,CAAC;EAED,IAAI,SAAS;EAEb,KAAK,QAAQ,GAAG,SAAS,UAAkB;GACzC,UAAU,MAAM,SAAS;EAC3B,CAAC;EAED,KAAK,GAAG,UAAU,SAAS;GACzB,IAAI,SAAS,GACX,QAAQ;QAER,uBAAO,IAAI,MAAM,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CAAC;EAE9E,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,uBAAO,IAAI,MAAM,2BAA2B,IAAI,SAAS,CAAC;EAC5D,CAAC;CACH,CAAC;AACH"}
1
+ {"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).\n * Deletes the original file on success.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { unlinkSync } from \"node:fs\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../logger\"\nimport { findFfmpegPath } from \"./ffmpeg-utils\"\n\nexport interface Converter {\n /** Convert a TS file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger, signal?: AbortSignal): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.(flv|ts)$/i, \".mp4\")\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n\" +\n \" Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\\n\" +\n \" macOS: brew install ffmpeg\\n\" +\n \" Windows: choco install ffmpeg\",\n )\n }\n\n await runFfmpeg(ffmpegPath, input, output, signal)\n\n // Delete original TS\n try {\n unlinkSync(input)\n logger?.info(`Deleted original: ${input}`)\n } catch {\n logger?.warn(`Could not delete original: ${input}`)\n }\n\n logger?.info(`Converted: ${output}`)\n return output\n }\n\n return { convert }\n}\n\nfunction runFfmpeg(\n ffmpegPath: string,\n input: string,\n output: string,\n signal?: AbortSignal,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n const args = [\n \"-i\",\n input,\n \"-c\",\n \"copy\", // stream copy — no re-encode, fast\n \"-y\", // overwrite output\n output,\n ]\n\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n\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(\"aborted\", \"Aborted by user\"))\n return\n }\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"ffmpeg-error\", `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(\"ffmpeg-error\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n}\n"],"mappings":";;;;;;;;;AAgBA,SAAgB,gBAAgB,QAAiB,QAAiC;CAChF,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,gBAAgB,MAAM;EAEnD,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,eAAe;EAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,QAAQ,MAAM;EAGjD,IAAI;GACF,WAAW,KAAK;GAChB,QAAQ,KAAK,qBAAqB,OAAO;EAC3C,QAAQ;GACN,QAAQ,KAAK,8BAA8B,OAAO;EACpD;EAEA,QAAQ,KAAK,cAAc,QAAQ;EACnC,OAAO;CACT;CAEA,OAAO,EAAE,QAAQ;AACnB;AAEA,SAAS,UACP,YACA,OACA,QACA,QACe;CACf,OAAO,IAAI,SAAS,SAAS,WAAW;EAUtC,MAAM,OAAO,MAAM,YAAY;GAR7B;GACA;GACA;GACA;GACA;GACA;EAGgC,GAAG;GACnC,OAAO;IAAC;IAAU;IAAQ;GAAM;GAChC;EACF,CAAC;EAED,IAAI,SAAS;EAEb,KAAK,QAAQ,GAAG,SAAS,UAAkB;GACzC,UAAU,MAAM,SAAS;GACzB,IAAI,OAAO,SAAS,KAAO,SAAS,OAAO,MAAM,IAAK;EACxD,CAAC;EAED,KAAK,GAAG,UAAU,SAAS;GACzB,IAAI,QAAQ,SAAS;IACnB,OAAO,IAAI,YAAY,WAAW,iBAAiB,CAAC;IACpD;GACF;GACA,IAAI,SAAS,GACX,QAAQ;QAER,OACE,IAAI,YAAY,gBAAgB,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CAC1F;EAEJ,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,IAAI,eAAe,SAAS,IAAI,SAAS,cAAc;GACvD,OAAO,IAAI,YAAY,gBAAgB,2BAA2B,IAAI,SAAS,CAAC;EAClF,CAAC;CACH,CAAC;AACH"}
@@ -0,0 +1,77 @@
1
+ import { TikTokError } from "../config.mjs";
2
+ import { bytesToHuman, formatFilename } from "../utils.mjs";
3
+ import { findFfmpegPath, formatDuration, pipeFfmpegSegment } from "./ffmpeg-utils.mjs";
4
+ import { createWriteStream } from "node:fs";
5
+ import { join } from "node:path";
6
+ //#region src/recorder/download-hls.ts
7
+ /**
8
+ * HLS stream downloader — downloads a live HLS stream via FFmpeg subprocess.
9
+ *
10
+ * FFmpeg handles M3U8 playlist fetching and .ts segment concatenation.
11
+ * The -reconnect flags handle transient CDN drops. When the playlist URL
12
+ * expires, getNextUrl fetches a fresh URL and FFmpeg is respawned.
13
+ */
14
+ async function downloadHls(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger) {
15
+ const filename = formatFilename(user, "ts");
16
+ const filepath = join(outputDir, filename);
17
+ logger?.info(`Recording (HLS): ${filename}`);
18
+ const startTime = Date.now();
19
+ let totalBytes = 0;
20
+ let lastProgressTime = 0;
21
+ const ffmpegPath = findFfmpegPath();
22
+ if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found — required for HLS streams. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
23
+ function elapsed() {
24
+ return (Date.now() - startTime) / 1e3;
25
+ }
26
+ const writer = createWriteStream(filepath);
27
+ try {
28
+ while (!signal.aborted) {
29
+ const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : void 0;
30
+ const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining);
31
+ totalBytes += bytes;
32
+ const now = Date.now();
33
+ const e = elapsed();
34
+ if (onProgress && now - lastProgressTime >= 1e3) {
35
+ lastProgressTime = now;
36
+ const speed = e > 0 ? totalBytes / e : 0;
37
+ onProgress({
38
+ bytes: totalBytes,
39
+ elapsed: e,
40
+ speed
41
+ });
42
+ }
43
+ if (signal.aborted) break;
44
+ if (maxDuration > 0 && e >= maxDuration) {
45
+ logger?.info(`Duration limit reached (${maxDuration}s)`);
46
+ break;
47
+ }
48
+ if (!getNextUrl) break;
49
+ const nextUrl = await getNextUrl();
50
+ if (!nextUrl) {
51
+ logger?.info("No new stream URL — recording finished");
52
+ break;
53
+ }
54
+ liveUrl = nextUrl;
55
+ }
56
+ } catch (err) {
57
+ logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`);
58
+ } finally {
59
+ await new Promise((resolve) => writer.end(resolve));
60
+ }
61
+ const duration = elapsed();
62
+ if (onProgress) onProgress({
63
+ bytes: totalBytes,
64
+ elapsed: duration,
65
+ speed: duration > 0 ? totalBytes / duration : 0
66
+ });
67
+ logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`);
68
+ return {
69
+ file: filepath,
70
+ duration,
71
+ size: totalBytes
72
+ };
73
+ }
74
+ //#endregion
75
+ export { downloadHls };
76
+
77
+ //# sourceMappingURL=download-hls.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-hls.mjs","names":[],"sources":["../../src/recorder/download-hls.ts"],"sourcesContent":["/**\n * HLS stream downloader — downloads a live HLS stream via FFmpeg subprocess.\n *\n * FFmpeg handles M3U8 playlist fetching and .ts segment concatenation.\n * The -reconnect flags handle transient CDN drops. When the playlist URL\n * expires, getNextUrl fetches a fresh URL and FFmpeg is respawned.\n */\n\nimport { createWriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, formatFilename } from \"../utils\"\nimport { findFfmpegPath, formatDuration, pipeFfmpegSegment } from \"./ffmpeg-utils\"\nimport type { DownloadResult, ProgressInfo } from \"./stream\"\n\nexport async function downloadHls(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress: ((info: ProgressInfo) => void) | undefined,\n getNextUrl: (() => Promise<string | null>) | undefined,\n signal: AbortSignal,\n logger?: Logger,\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 totalBytes = 0\n let lastProgressTime = 0\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\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 function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const writer = createWriteStream(filepath)\n\n try {\n while (!signal.aborted) {\n const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : undefined\n const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining)\n totalBytes += bytes\n\n // Report progress after each segment\n const now = Date.now()\n const e = elapsed()\n if (onProgress && now - lastProgressTime >= 1000) {\n lastProgressTime = now\n const speed = e > 0 ? totalBytes / e : 0\n onProgress({ bytes: totalBytes, elapsed: e, speed })\n }\n\n if (signal.aborted) break\n if (maxDuration > 0 && e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n if (!getNextUrl) break\n\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n break\n }\n liveUrl = nextUrl\n }\n } catch (err) {\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n } finally {\n await new Promise<void>((resolve) => writer.end(resolve))\n }\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}\n"],"mappings":";;;;;;;;;;;;;AAgBA,eAAsB,YACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACyB;CACzB,MAAM,WAAW,eAAe,MAAM,IAAI;CAC1C,MAAM,WAAW,KAAK,WAAW,QAAQ;CACzC,QAAQ,KAAK,oBAAoB,UAAU;CAE3C,MAAM,YAAY,KAAK,IAAI;CAC3B,IAAI,aAAa;CACjB,IAAI,mBAAmB;CAEvB,MAAM,aAAa,eAAe;CAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,uHAEF;CAGF,SAAS,UAAkB;EACzB,QAAQ,KAAK,IAAI,IAAI,aAAa;CACpC;CAEA,MAAM,SAAS,kBAAkB,QAAQ;CAEzC,IAAI;EACF,OAAO,CAAC,OAAO,SAAS;GACtB,MAAM,YAAY,cAAc,IAAI,KAAK,IAAI,GAAG,cAAc,QAAQ,CAAC,IAAI,KAAA;GAC3E,MAAM,EAAE,UAAU,MAAM,kBAAkB,YAAY,SAAS,QAAQ,QAAQ,SAAS;GACxF,cAAc;GAGd,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,IAAI,QAAQ;GAClB,IAAI,cAAc,MAAM,oBAAoB,KAAM;IAChD,mBAAmB;IACnB,MAAM,QAAQ,IAAI,IAAI,aAAa,IAAI;IACvC,WAAW;KAAE,OAAO;KAAY,SAAS;KAAG;IAAM,CAAC;GACrD;GAEA,IAAI,OAAO,SAAS;GACpB,IAAI,cAAc,KAAK,KAAK,aAAa;IACvC,QAAQ,KAAK,2BAA2B,YAAY,GAAG;IACvD;GACF;GACA,IAAI,CAAC,YAAY;GAEjB,MAAM,UAAU,MAAM,WAAW;GACjC,IAAI,CAAC,SAAS;IACZ,QAAQ,KAAK,wCAAwC;IACrD;GACF;GACA,UAAU;EACZ;CACF,SAAS,KAAK;EACZ,QAAQ,MAAM,oBAAoB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;CACtF,UAAU;EACR,MAAM,IAAI,SAAe,YAAY,OAAO,IAAI,OAAO,CAAC;CAC1D;CAEA,MAAM,WAAW,QAAQ;CACzB,IAAI,YACF,WAAW;EACT,OAAO;EACP,SAAS;EACT,OAAO,WAAW,IAAI,aAAa,WAAW;CAChD,CAAC;CAEH,QAAQ,KACN,uBAAuB,SAAS,IAAI,eAAe,QAAQ,EAAE,IAAI,aAAa,UAAU,EAAE,EAC5F;CAEA,OAAO;EAAE,MAAM;EAAU;EAAU,MAAM;CAAW;AACtD"}
@@ -0,0 +1,78 @@
1
+ import { TikTokError } from "../config.mjs";
2
+ import { bytesToHuman, formatFilename } from "../utils.mjs";
3
+ import { findFfmpegPath, formatDuration, pipeFfmpegSegment } from "./ffmpeg-utils.mjs";
4
+ import { createWriteStream } from "node:fs";
5
+ import { join } from "node:path";
6
+ //#region src/recorder/download-stream.ts
7
+ /**
8
+ * Live stream downloader — downloads a live stream via FFmpeg stdout pipe.
9
+ *
10
+ * Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.
11
+ * FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's
12
+ * short-lived stream URL expires, FFmpeg exits and the outer loop fetches
13
+ * a fresh URL via getNextUrl.
14
+ */
15
+ async function downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger) {
16
+ const filename = formatFilename(user, "ts");
17
+ const filepath = join(outputDir, filename);
18
+ logger?.info(`Recording: ${filename}`);
19
+ const startTime = Date.now();
20
+ let totalBytes = 0;
21
+ let lastProgressTime = 0;
22
+ const ffmpegPath = findFfmpegPath();
23
+ if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found — required for stream download. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
24
+ function elapsed() {
25
+ return (Date.now() - startTime) / 1e3;
26
+ }
27
+ const writer = createWriteStream(filepath);
28
+ try {
29
+ while (!signal.aborted) {
30
+ const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : void 0;
31
+ const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining);
32
+ totalBytes += bytes;
33
+ const now = Date.now();
34
+ const e = elapsed();
35
+ if (onProgress && now - lastProgressTime >= 1e3) {
36
+ lastProgressTime = now;
37
+ const speed = e > 0 ? totalBytes / e : 0;
38
+ onProgress({
39
+ bytes: totalBytes,
40
+ elapsed: e,
41
+ speed
42
+ });
43
+ }
44
+ if (signal.aborted) break;
45
+ if (maxDuration > 0 && e >= maxDuration) {
46
+ logger?.info(`Duration limit reached (${maxDuration}s)`);
47
+ break;
48
+ }
49
+ if (!getNextUrl) break;
50
+ const nextUrl = await getNextUrl();
51
+ if (!nextUrl) {
52
+ logger?.info("No new stream URL — recording finished");
53
+ break;
54
+ }
55
+ liveUrl = nextUrl;
56
+ }
57
+ } catch (err) {
58
+ logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`);
59
+ } finally {
60
+ await new Promise((resolve) => writer.end(resolve));
61
+ }
62
+ const duration = elapsed();
63
+ if (onProgress) onProgress({
64
+ bytes: totalBytes,
65
+ elapsed: duration,
66
+ speed: duration > 0 ? totalBytes / duration : 0
67
+ });
68
+ logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`);
69
+ return {
70
+ file: filepath,
71
+ duration,
72
+ size: totalBytes
73
+ };
74
+ }
75
+ //#endregion
76
+ export { downloadFlv };
77
+
78
+ //# sourceMappingURL=download-stream.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-stream.mjs","names":[],"sources":["../../src/recorder/download-stream.ts"],"sourcesContent":["/**\n * Live stream downloader — downloads a live stream via FFmpeg stdout pipe.\n *\n * Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.\n * FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's\n * short-lived stream URL expires, FFmpeg exits and the outer loop fetches\n * a fresh URL via getNextUrl.\n */\n\nimport { createWriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, formatFilename } from \"../utils\"\nimport { findFfmpegPath, formatDuration, pipeFfmpegSegment } from \"./ffmpeg-utils\"\nimport type { DownloadResult, ProgressInfo } from \"./stream\"\n\nexport async function downloadFlv(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress: ((info: ProgressInfo) => void) | undefined,\n getNextUrl: (() => Promise<string | null>) | undefined,\n signal: AbortSignal,\n logger?: Logger,\n): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\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 const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found — required for stream download. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const writer = createWriteStream(filepath)\n\n try {\n while (!signal.aborted) {\n const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : undefined\n const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining)\n totalBytes += bytes\n\n // Report progress after each segment\n const now = Date.now()\n const e = elapsed()\n if (onProgress && now - lastProgressTime >= 1000) {\n lastProgressTime = now\n const speed = e > 0 ? totalBytes / e : 0\n onProgress({ bytes: totalBytes, elapsed: e, speed })\n }\n\n if (signal.aborted) break\n if (maxDuration > 0 && e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n if (!getNextUrl) break\n\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n break\n }\n liveUrl = nextUrl\n }\n } catch (err) {\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n } finally {\n await new Promise<void>((resolve) => writer.end(resolve))\n }\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}\n"],"mappings":";;;;;;;;;;;;;;AAiBA,eAAsB,YACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACyB;CACzB,MAAM,WAAW,eAAe,MAAM,IAAI;CAC1C,MAAM,WAAW,KAAK,WAAW,QAAQ;CACzC,QAAQ,KAAK,cAAc,UAAU;CAErC,MAAM,YAAY,KAAK,IAAI;CAC3B,IAAI,aAAa;CACjB,IAAI,mBAAmB;CAEvB,MAAM,aAAa,eAAe;CAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,2HAEF;CAGF,SAAS,UAAkB;EACzB,QAAQ,KAAK,IAAI,IAAI,aAAa;CACpC;CAEA,MAAM,SAAS,kBAAkB,QAAQ;CAEzC,IAAI;EACF,OAAO,CAAC,OAAO,SAAS;GACtB,MAAM,YAAY,cAAc,IAAI,KAAK,IAAI,GAAG,cAAc,QAAQ,CAAC,IAAI,KAAA;GAC3E,MAAM,EAAE,UAAU,MAAM,kBAAkB,YAAY,SAAS,QAAQ,QAAQ,SAAS;GACxF,cAAc;GAGd,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,IAAI,QAAQ;GAClB,IAAI,cAAc,MAAM,oBAAoB,KAAM;IAChD,mBAAmB;IACnB,MAAM,QAAQ,IAAI,IAAI,aAAa,IAAI;IACvC,WAAW;KAAE,OAAO;KAAY,SAAS;KAAG;IAAM,CAAC;GACrD;GAEA,IAAI,OAAO,SAAS;GACpB,IAAI,cAAc,KAAK,KAAK,aAAa;IACvC,QAAQ,KAAK,2BAA2B,YAAY,GAAG;IACvD;GACF;GACA,IAAI,CAAC,YAAY;GAEjB,MAAM,UAAU,MAAM,WAAW;GACjC,IAAI,CAAC,SAAS;IACZ,QAAQ,KAAK,wCAAwC;IACrD;GACF;GACA,UAAU;EACZ;CACF,SAAS,KAAK;EACZ,QAAQ,MAAM,oBAAoB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;CACtF,UAAU;EACR,MAAM,IAAI,SAAe,YAAY,OAAO,IAAI,OAAO,CAAC;CAC1D;CAEA,MAAM,WAAW,QAAQ;CACzB,IAAI,YACF,WAAW;EACT,OAAO;EACP,SAAS;EACT,OAAO,WAAW,IAAI,aAAa,WAAW;CAChD,CAAC;CAEH,QAAQ,KACN,uBAAuB,SAAS,IAAI,eAAe,QAAQ,EAAE,IAAI,aAAa,UAAU,EAAE,EAC5F;CAEA,OAAO;EAAE,MAAM;EAAU;EAAU,MAAM;CAAW;AACtD"}