@zfadhli/tokrec 0.12.2 → 0.15.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 (126) hide show
  1. package/dist/app/abort.mjs +14 -0
  2. package/dist/app/abort.mjs.map +1 -0
  3. package/dist/{lib → app}/config.d.mts +73 -71
  4. package/dist/app/config.d.mts.map +1 -0
  5. package/dist/app/config.mjs +26 -0
  6. package/dist/app/config.mjs.map +1 -0
  7. package/dist/app/events.mjs +36 -0
  8. package/dist/app/events.mjs.map +1 -0
  9. package/dist/app/job.mjs +190 -0
  10. package/dist/app/job.mjs.map +1 -0
  11. package/dist/{lib → app}/logger.mjs +1 -1
  12. package/dist/app/logger.mjs.map +1 -0
  13. package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
  14. package/dist/app/loop.mjs.map +1 -0
  15. package/dist/app/recorder.d.mts +7 -0
  16. package/dist/app/recorder.d.mts.map +1 -0
  17. package/dist/app/recorder.mjs +233 -0
  18. package/dist/app/recorder.mjs.map +1 -0
  19. package/dist/core/errors.d.mts +16 -0
  20. package/dist/core/errors.d.mts.map +1 -0
  21. package/dist/core/errors.mjs +15 -0
  22. package/dist/core/errors.mjs.map +1 -0
  23. package/dist/core/format.d.mts +12 -0
  24. package/dist/core/format.d.mts.map +1 -0
  25. package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
  26. package/dist/core/format.mjs.map +1 -0
  27. package/dist/core/plan.mjs +52 -0
  28. package/dist/core/plan.mjs.map +1 -0
  29. package/dist/core/verdict.d.mts +44 -0
  30. package/dist/core/verdict.d.mts.map +1 -0
  31. package/dist/core/verdict.mjs +47 -0
  32. package/dist/core/verdict.mjs.map +1 -0
  33. package/dist/index.d.mts +6 -3
  34. package/dist/index.mjs +5 -2
  35. package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
  36. package/dist/io/http.mjs.map +1 -0
  37. package/dist/io/media/convert.mjs +60 -0
  38. package/dist/io/media/convert.mjs.map +1 -0
  39. package/dist/{core/recorder → io/media}/download.mjs +8 -7
  40. package/dist/io/media/download.mjs.map +1 -0
  41. package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
  42. package/dist/io/media/ffmpeg.mjs.map +1 -0
  43. package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
  44. package/dist/io/media/normalize.mjs.map +1 -0
  45. package/dist/io/tiktok/client.mjs +77 -0
  46. package/dist/io/tiktok/client.mjs.map +1 -0
  47. package/dist/io/tiktok/discovery/chain.mjs +117 -0
  48. package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
  49. package/dist/io/tiktok/discovery/fallback.mjs +64 -0
  50. package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
  51. package/dist/io/tiktok/discovery/page.mjs +102 -0
  52. package/dist/io/tiktok/discovery/page.mjs.map +1 -0
  53. package/dist/io/tiktok/page.mjs +34 -0
  54. package/dist/io/tiktok/page.mjs.map +1 -0
  55. package/dist/io/tiktok/parse.mjs +110 -0
  56. package/dist/io/tiktok/parse.mjs.map +1 -0
  57. package/dist/io/tiktok/webcast.mjs +132 -0
  58. package/dist/io/tiktok/webcast.mjs.map +1 -0
  59. package/package.json +5 -26
  60. package/src/app/abort.ts +18 -0
  61. package/src/app/config.ts +178 -0
  62. package/src/app/events.ts +46 -0
  63. package/src/app/job.ts +228 -0
  64. package/src/app/logger.ts +133 -0
  65. package/src/app/loop.ts +84 -0
  66. package/src/app/recorder.ts +288 -0
  67. package/src/core/errors.ts +24 -0
  68. package/src/core/format.ts +50 -0
  69. package/src/core/log.ts +13 -0
  70. package/src/core/plan.ts +49 -0
  71. package/src/core/verdict.ts +79 -0
  72. package/src/index.ts +38 -0
  73. package/src/io/http.ts +167 -0
  74. package/src/io/media/convert.ts +69 -0
  75. package/src/io/media/download.ts +120 -0
  76. package/src/io/media/ffmpeg.ts +201 -0
  77. package/src/io/media/normalize.ts +64 -0
  78. package/src/io/tiktok/client.ts +114 -0
  79. package/src/io/tiktok/discovery/chain.ts +146 -0
  80. package/src/io/tiktok/discovery/fallback.ts +88 -0
  81. package/src/io/tiktok/discovery/page.ts +142 -0
  82. package/src/io/tiktok/page.ts +42 -0
  83. package/src/io/tiktok/parse.ts +171 -0
  84. package/src/io/tiktok/webcast.ts +162 -0
  85. package/README.md +0 -430
  86. package/dist/bin.d.mts +0 -1
  87. package/dist/bin.mjs +0 -215
  88. package/dist/bin.mjs.map +0 -1
  89. package/dist/core/api/client.mjs.map +0 -1
  90. package/dist/core/api/rate-limiter.mjs +0 -29
  91. package/dist/core/api/rate-limiter.mjs.map +0 -1
  92. package/dist/core/api/tiktok.mjs +0 -394
  93. package/dist/core/api/tiktok.mjs.map +0 -1
  94. package/dist/core/monitor.mjs.map +0 -1
  95. package/dist/core/recorder/convert.mjs +0 -55
  96. package/dist/core/recorder/convert.mjs.map +0 -1
  97. package/dist/core/recorder/download.mjs.map +0 -1
  98. package/dist/core/recorder/ffmpeg-utils.mjs.map +0 -1
  99. package/dist/core/recorder/index.d.mts +0 -7
  100. package/dist/core/recorder/index.d.mts.map +0 -1
  101. package/dist/core/recorder/index.mjs +0 -205
  102. package/dist/core/recorder/index.mjs.map +0 -1
  103. package/dist/core/recorder/normalize.mjs.map +0 -1
  104. package/dist/core/recorder/post-processing.mjs +0 -154
  105. package/dist/core/recorder/post-processing.mjs.map +0 -1
  106. package/dist/core/recorder/recorder-events.mjs +0 -34
  107. package/dist/core/recorder/recorder-events.mjs.map +0 -1
  108. package/dist/core/recorder/recorder-state.mjs +0 -38
  109. package/dist/core/recorder/recorder-state.mjs.map +0 -1
  110. package/dist/core/recorder/stream.mjs +0 -25
  111. package/dist/core/recorder/stream.mjs.map +0 -1
  112. package/dist/lib/browser-cookies.mjs +0 -113
  113. package/dist/lib/browser-cookies.mjs.map +0 -1
  114. package/dist/lib/cli.mjs +0 -137
  115. package/dist/lib/cli.mjs.map +0 -1
  116. package/dist/lib/config.d.mts.map +0 -1
  117. package/dist/lib/config.mjs +0 -47
  118. package/dist/lib/config.mjs.map +0 -1
  119. package/dist/lib/logger.mjs.map +0 -1
  120. package/dist/lib/tmux.mjs +0 -84
  121. package/dist/lib/tmux.mjs.map +0 -1
  122. package/dist/lib/ui.mjs +0 -136
  123. package/dist/lib/ui.mjs.map +0 -1
  124. package/dist/lib/utils.mjs.map +0 -1
  125. package/dist/package.mjs +0 -6
  126. package/dist/package.mjs.map +0 -1
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Config — types, defaults, and normalization for the recorder.
3
+ * The single source of truth for every user-facing option.
4
+ */
5
+
6
+ import { TikTokError } from "../core/errors"
7
+ import type { Verdict } from "../core/verdict"
8
+
9
+ /**
10
+ * TikTok cookie key-value pairs extracted from Firefox or cookies.json.
11
+ * Includes session cookies (sessionid_ss, tt-target-idc) and any WAF
12
+ * bypass cookies needed to avoid Slardar challenges on www.tiktok.com.
13
+ */
14
+ export type CookieAuth = Record<string, string>
15
+
16
+ export type LogLevel = "debug" | "info" | "warn" | "error"
17
+
18
+ /**
19
+ * Machine-readable reason for a LIVE status verdict — lives in `domain/verdict.ts`.
20
+ */
21
+ export type { LiveReason } from "../core/verdict"
22
+
23
+ export interface RecorderConfig {
24
+ /** TikTok username (required) */
25
+ user: string
26
+ /** Output directory for recordings (default: ./recordings) */
27
+ outputDir?: string
28
+ /** Polling interval in minutes (default: 3) */
29
+ interval?: number
30
+ /** Max recording duration in seconds (default: 0 — unlimited). CLI accepts minutes, converted internally. */
31
+ duration?: number
32
+ /** HTTP proxy URL (e.g. http://127.0.0.1:8080) */
33
+ proxy?: string
34
+ /** Path to cookies.json (default: ./cookies.json) */
35
+ cookiesPath?: string
36
+ /** Cookie auth loaded from cookies.json */
37
+ cookies?: CookieAuth
38
+ /** Log level (default: info) */
39
+ logLevel?: LogLevel
40
+ /** Whether to print logs to console (default: true). Set false when using Display-based UI. */
41
+ logConsole?: boolean
42
+ /** Segment duration in minutes (default: 0 — disabled). When >= 1, the recording is split into segments of this length. */
43
+ segmentMinutes?: number
44
+ /** Normalize audio loudness via EBU R128 (default: false). */
45
+ normalizeAudio?: boolean
46
+ /** Target loudness in LUFS (default: -14). */
47
+ normalizeLoudness?: number
48
+ /** Audio codec for normalized output (default: peaknorm's default, "libopus"). */
49
+ normalizeCodec?: string
50
+ /** Audio bitrate for normalized output (default: peaknorm's default, "96k"). */
51
+ normalizeBitrate?: string
52
+ /**
53
+ * Max API requests per second (default: 5).
54
+ * Prevents TikTok WAF triggering on rapid polling or URL refresh loops.
55
+ * Set to 0 for unlimited (not recommended).
56
+ */
57
+ ratePerSecond?: number
58
+ /** Show API debug logging on stderr (default: false). */
59
+ debug?: boolean
60
+ }
61
+
62
+ export interface RecorderController {
63
+ /** Start the polling loop */
64
+ start(): Promise<void>
65
+ /** Gracefully stop (joins active recording, then exits) */
66
+ stop(): Promise<void>
67
+ /** Snapshot of current state */
68
+ getStatus(): RecorderStatus
69
+ /** Subscribe to events */
70
+ on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void
71
+ /** Unsubscribe a previously registered event handler */
72
+ off<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void
73
+ }
74
+
75
+ export type Phase = "idle" | "polling" | "recording" | "segmenting" | "converting" | "stopped"
76
+
77
+ export interface RecorderStatus {
78
+ state: Phase
79
+ user: string
80
+ currentFile?: string
81
+ sessionDuration?: number
82
+ lastPollTime?: string
83
+ lastError?: string
84
+ }
85
+
86
+ export type RecorderEvent = keyof RecorderEventHandler
87
+
88
+ export interface RecordedFile {
89
+ file: string
90
+ duration: number
91
+ size: number
92
+ }
93
+
94
+ /** One recording lifecycle channel: start → download → processing → end. */
95
+ export type RecordEvent =
96
+ | { phase: "start"; user: string; file: string }
97
+ | ({ phase: "download" } & RecordedFile)
98
+ | { phase: "segment"; status: "start"; input: string; outputPattern: string }
99
+ | { phase: "segment"; status: "end"; segments: number }
100
+ | { phase: "convert"; status: "start"; input: string }
101
+ | { phase: "convert"; status: "done"; input: string; output: string }
102
+ | { phase: "normalize"; status: "start"; file: string }
103
+ | { phase: "normalize"; status: "done"; input: string; output: string }
104
+ | { phase: "normalize"; status: "error"; input: string; error: string }
105
+ | ({ phase: "end" } & RecordedFile)
106
+
107
+ /** One download progress sample. The finished-download shape is `RecordedFile`. */
108
+ export interface DownloadProgress {
109
+ bytes: number
110
+ elapsed: number
111
+ speed: number
112
+ }
113
+
114
+ /** Download and normalize progress share one spinner in the CLI. */
115
+ export type ProgressEvent =
116
+ | ({ kind: "download" } & DownloadProgress)
117
+ | { kind: "normalize"; file: string; percent: number; phase: "analyzing" | "normalizing" }
118
+
119
+ export interface RecorderEventHandler {
120
+ verdict: (verdict: Verdict) => void
121
+ record: (event: RecordEvent) => void
122
+ progress: (event: ProgressEvent) => void
123
+ failure: (err: TikTokError) => void
124
+ }
125
+
126
+ /** A `RecorderConfig` with every default applied — what the recorder runs on. */
127
+ export interface ResolvedConfig {
128
+ user: string
129
+ outputDir: string
130
+ interval: number
131
+ duration: number
132
+ proxy?: string
133
+ cookiesPath?: string
134
+ cookies?: CookieAuth
135
+ logLevel: LogLevel
136
+ logConsole: boolean
137
+ segmentMinutes: number
138
+ normalizeAudio: boolean
139
+ normalizeLoudness: number
140
+ normalizeCodec?: string
141
+ normalizeBitrate?: string
142
+ ratePerSecond: number
143
+ debug: boolean
144
+ }
145
+
146
+ /** Apply defaults once, at the entry point; nothing downstream re-reads `??`. */
147
+ export function resolveConfig(config: RecorderConfig): ResolvedConfig {
148
+ return {
149
+ user: config.user,
150
+ outputDir: config.outputDir ?? ".",
151
+ interval: config.interval ?? 3,
152
+ duration: config.duration ?? 0,
153
+ proxy: config.proxy,
154
+ cookies: config.cookies,
155
+ cookiesPath: config.cookiesPath,
156
+ logLevel: config.logLevel ?? "info",
157
+ logConsole: config.logConsole ?? true,
158
+ segmentMinutes: config.segmentMinutes ?? 0,
159
+ normalizeAudio: config.normalizeAudio ?? false,
160
+ normalizeLoudness: config.normalizeLoudness ?? -14,
161
+ normalizeCodec: config.normalizeCodec,
162
+ normalizeBitrate: config.normalizeBitrate,
163
+ ratePerSecond: config.ratePerSecond ?? 5,
164
+ debug: config.debug ?? false,
165
+ }
166
+ }
167
+
168
+ export function validateConfig(config: RecorderConfig): void {
169
+ if (!config.user || config.user.trim().length === 0) {
170
+ throw new TikTokError("config-error", "--user is required")
171
+ }
172
+ if (config.interval !== undefined && config.interval < 1) {
173
+ throw new TikTokError("config-error", "--interval must be >= 1 minute")
174
+ }
175
+ if (config.duration !== undefined && config.duration < 0) {
176
+ throw new TikTokError("config-error", "--duration must be >= 0 seconds")
177
+ }
178
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Typed emitter over `node:events` — the four-channel recorder contract
3
+ * (`verdict`, `record`, `progress`, `failure`) with no listener bookkeeping of
4
+ * our own. `emit` swallows handler throws: a bad subscriber must not kill a
5
+ * recording.
6
+ */
7
+
8
+ import { EventEmitter } from "node:events"
9
+ import type { RecorderEvent, RecorderEventHandler } from "./config"
10
+
11
+ export interface RecorderEventEmitter {
12
+ on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void
13
+ off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void
14
+ emit<E extends RecorderEvent>(event: E, ...args: Parameters<RecorderEventHandler[E]>): void
15
+ clear(): void
16
+ }
17
+
18
+ export function createEventEmitter(): RecorderEventEmitter {
19
+ const emitter = new EventEmitter()
20
+ const asListener = (handler: unknown) => handler as (...args: unknown[]) => void
21
+
22
+ function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {
23
+ emitter.on(event, asListener(handler))
24
+ }
25
+
26
+ function off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {
27
+ emitter.off(event, asListener(handler))
28
+ }
29
+
30
+ function emit<E extends RecorderEvent>(
31
+ event: E,
32
+ ...args: Parameters<RecorderEventHandler[E]>
33
+ ): void {
34
+ try {
35
+ emitter.emit(event, ...args)
36
+ } catch {
37
+ // Never let an event handler crash the recorder.
38
+ }
39
+ }
40
+
41
+ function clear(): void {
42
+ emitter.removeAllListeners()
43
+ }
44
+
45
+ return { on, off, emit, clear }
46
+ }
package/src/app/job.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * RecordingJob — owns one download's post-processing and the promises it
3
+ * spawns. `finish()` is idempotent so `stop()` can join without the recorder
4
+ * reaching into a shared array.
5
+ */
6
+
7
+ import { statSync, unlinkSync, utimesSync } from "node:fs"
8
+ import { join, parse } from "node:path"
9
+ import { TikTokError } from "../core/errors"
10
+ import type { Logger } from "../core/log"
11
+ import type { Converter } from "../io/media/convert"
12
+ import type { AudioNormalizer } from "../io/media/normalize"
13
+ import type {
14
+ ProgressEvent,
15
+ RecordEvent,
16
+ RecordedFile,
17
+ RecorderConfig,
18
+ RecorderStatus,
19
+ } from "./config"
20
+
21
+ export interface RecordingJob {
22
+ readonly file: string
23
+ finish(): Promise<RecordedFile[]>
24
+ }
25
+
26
+ export interface RecordingJobDeps {
27
+ result: RecordedFile
28
+ cfg: Pick<RecorderConfig, "segmentMinutes">
29
+ logger: Logger
30
+ converter: Converter
31
+ audioNormalizer: AudioNormalizer | null
32
+ signal: AbortSignal
33
+ emitRecord: (event: RecordEvent) => void
34
+ emitProgress: (event: ProgressEvent) => void
35
+ emitFailure: (err: TikTokError) => void
36
+ setState: (partial: Partial<RecorderStatus>) => void
37
+ }
38
+
39
+ export function createRecordingJob(deps: RecordingJobDeps): RecordingJob {
40
+ let promise: Promise<RecordedFile[]> | null = null
41
+
42
+ return {
43
+ file: deps.result.file,
44
+ finish(): Promise<RecordedFile[]> {
45
+ promise ??= run(deps)
46
+ return promise
47
+ },
48
+ }
49
+ }
50
+
51
+ async function run(deps: RecordingJobDeps): Promise<RecordedFile[]> {
52
+ const { result, cfg, logger, signal } = deps
53
+ try {
54
+ if (statSync(result.file).size === 0) return []
55
+ } catch {
56
+ return []
57
+ }
58
+
59
+ try {
60
+ if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) return await segment(deps)
61
+ return await convert(deps)
62
+ } catch (err) {
63
+ if (!signal.aborted) {
64
+ const tkErr = err instanceof TikTokError ? err : new TikTokError("unknown", String(err))
65
+ logger.error(`Recording processing failed: ${tkErr.message}`)
66
+ deps.setState({ lastError: tkErr.message })
67
+ deps.emitFailure(tkErr)
68
+ }
69
+ return []
70
+ } finally {
71
+ if (!signal.aborted) deps.setState({ state: "polling" })
72
+ }
73
+ }
74
+
75
+ async function segment(deps: RecordingJobDeps): Promise<RecordedFile[]> {
76
+ const { result, cfg, logger, signal } = deps
77
+ const parsed = parse(result.file)
78
+ const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60)
79
+ const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)
80
+
81
+ logger.info(
82
+ `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`,
83
+ )
84
+ deps.setState({ state: "segmenting" })
85
+ deps.emitRecord({
86
+ phase: "segment",
87
+ status: "start",
88
+ input: result.file,
89
+ outputPattern,
90
+ })
91
+
92
+ let listing: string
93
+ try {
94
+ listing = await deps.converter.segment(result.file, segmentTime, outputPattern)
95
+ } catch (err) {
96
+ if (signal.aborted) return []
97
+ const msg = err instanceof Error ? err.message : String(err)
98
+ logger.error(`Segmenting failed: ${msg}`)
99
+ deps.setState({ lastError: `Segmenting failed: ${msg}` })
100
+ return convert(deps)
101
+ }
102
+
103
+ const segments = parseSegmentList(listing, segmentTime)
104
+ if (segments.length === 0) {
105
+ logger.warn("Segmenting produced no files — falling back to whole-file conversion")
106
+ return convert(deps)
107
+ }
108
+
109
+ const fileMtimeMs = statSync(result.file).mtimeMs
110
+ const recStartMs = fileMtimeMs - result.duration * 1000
111
+
112
+ try {
113
+ unlinkSync(result.file)
114
+ logger.info(`Deleted original: ${parsed.base}`)
115
+ } catch {
116
+ logger.warn(`Could not delete original: ${parsed.base}`)
117
+ }
118
+
119
+ const files: RecordedFile[] = []
120
+ for (let i = 0; i < segments.length; i++) {
121
+ const seg = segments[i]
122
+ if (!seg) continue
123
+ const stats = statSync(seg.file)
124
+ const recorded: RecordedFile = {
125
+ file: seg.file,
126
+ duration: seg.duration,
127
+ size: stats.size,
128
+ }
129
+ files.push(recorded)
130
+ deps.emitRecord({ phase: "end", ...recorded })
131
+ deps.emitRecord({
132
+ phase: "convert",
133
+ status: "done",
134
+ input: result.file,
135
+ output: seg.file,
136
+ })
137
+
138
+ const segStart = recStartMs + i * segmentTime * 1000
139
+ utimesSync(seg.file, new Date(segStart), new Date(segStart))
140
+ }
141
+
142
+ deps.emitRecord({ phase: "segment", status: "end", segments: files.length })
143
+
144
+ if (deps.audioNormalizer) {
145
+ for (const recorded of files) {
146
+ await normalize(deps, recorded.file)
147
+ }
148
+ }
149
+
150
+ deps.setState({
151
+ currentFile: files.at(-1)?.file ?? result.file,
152
+ sessionDuration: result.duration,
153
+ })
154
+ return files
155
+ }
156
+
157
+ async function convert(deps: RecordingJobDeps): Promise<RecordedFile[]> {
158
+ const { result, logger, signal } = deps
159
+ deps.setState({ state: "converting" })
160
+ deps.emitRecord({ phase: "convert", status: "start", input: result.file })
161
+
162
+ let mp4File: string
163
+ try {
164
+ mp4File = await deps.converter.convert(result.file)
165
+ } catch (err) {
166
+ if (signal.aborted) return []
167
+ throw err
168
+ }
169
+
170
+ const recorded: RecordedFile = {
171
+ file: mp4File,
172
+ duration: result.duration,
173
+ size: result.size,
174
+ }
175
+ deps.emitRecord({ phase: "end", ...recorded })
176
+
177
+ if (deps.audioNormalizer) await normalize(deps, mp4File)
178
+
179
+ deps.emitRecord({
180
+ phase: "convert",
181
+ status: "done",
182
+ input: result.file,
183
+ output: mp4File,
184
+ })
185
+ logger.info(`Converted: ${mp4File}`)
186
+ deps.setState({ currentFile: mp4File, sessionDuration: result.duration })
187
+ return [recorded]
188
+ }
189
+
190
+ async function normalize(deps: RecordingJobDeps, file: string): Promise<void> {
191
+ if (!deps.audioNormalizer) return
192
+ deps.emitRecord({ phase: "normalize", status: "start", file })
193
+ try {
194
+ const result = await deps.audioNormalizer.normalize(file)
195
+ deps.emitRecord({
196
+ phase: "normalize",
197
+ status: "done",
198
+ input: file,
199
+ output: result.output,
200
+ })
201
+ } catch (err) {
202
+ const message = err instanceof Error ? err.message : String(err)
203
+ deps.emitRecord({
204
+ phase: "normalize",
205
+ status: "error",
206
+ input: file,
207
+ error: message,
208
+ })
209
+ }
210
+ }
211
+
212
+ function parseSegmentList(
213
+ listing: string,
214
+ fallbackDuration: number,
215
+ ): { file: string; duration: number }[] {
216
+ return listing
217
+ .split("\n")
218
+ .map((line) => line.trim())
219
+ .filter((line) => line.length > 0)
220
+ .flatMap((line) => {
221
+ const [file, start, end] = line.split(",").map((field) => field.replace(/^"|"$/g, ""))
222
+ if (!file) return []
223
+ const duration = Number(end) - Number(start)
224
+ return [
225
+ { file, duration: Number.isFinite(duration) && duration > 0 ? duration : fallbackDuration },
226
+ ]
227
+ })
228
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Logger — rotating file + console with levels.
3
+ * Zero-dependency minimal implementation (~60 lines).
4
+ */
5
+
6
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs"
7
+ import { dirname } from "node:path"
8
+ import type { Logger } from "../core/log"
9
+ import type { LogLevel } from "./config"
10
+
11
+ const LEVEL_ORDER: Record<LogLevel, number> = {
12
+ debug: 0,
13
+ info: 1,
14
+ warn: 2,
15
+ error: 3,
16
+ }
17
+
18
+ const LEVEL_COLORS: Record<LogLevel, string> = {
19
+ debug: "\x1b[90m", // gray
20
+ info: "\x1b[36m", // cyan
21
+ warn: "\x1b[33m", // yellow
22
+ error: "\x1b[31m", // red
23
+ }
24
+
25
+ const RESET = "\x1b[0m"
26
+
27
+ export function createLogger(opts: {
28
+ level?: LogLevel
29
+ logFile?: string
30
+ maxSize?: number
31
+ backups?: number
32
+ /** When true (default), writes to stdout/stderr. Set false for file-only logging. */
33
+ console?: boolean
34
+ }): Logger {
35
+ const level = opts.level ?? "info"
36
+ const logFile = opts.logFile ?? "tiktok-recorder.log"
37
+ const maxSize = opts.maxSize ?? 5 * 1024 * 1024 // 5 MB
38
+ const backups = opts.backups ?? 3
39
+ const showConsole = opts.console !== false // default true
40
+
41
+ // Ensure parent directory exists
42
+ const logDir = dirname(logFile)
43
+ if (logDir !== ".") {
44
+ mkdirSync(logDir, { recursive: true })
45
+ }
46
+
47
+ function rotateIfNeeded(): void {
48
+ try {
49
+ if (existsSync(logFile)) {
50
+ const size = statSync(logFile).size
51
+ if (size >= maxSize) {
52
+ // Remove the oldest backup
53
+ const lastBackup = `${logFile}.${backups}`
54
+ if (existsSync(lastBackup)) {
55
+ try {
56
+ renameSync(lastBackup, `${lastBackup}.old`)
57
+ } catch {
58
+ // Best-effort cleanup
59
+ }
60
+ }
61
+ // Shift backups
62
+ for (let i = backups - 1; i >= 1; i--) {
63
+ const src = `${logFile}.${i}`
64
+ const dst = `${logFile}.${i + 1}`
65
+ if (existsSync(src)) {
66
+ try {
67
+ renameSync(src, dst)
68
+ } catch {
69
+ // Best-effort
70
+ }
71
+ }
72
+ }
73
+ // Rename current → .1
74
+ try {
75
+ renameSync(logFile, `${logFile}.1`)
76
+ } catch {
77
+ // Best-effort
78
+ }
79
+ }
80
+ }
81
+ } catch {
82
+ // Silent — never throw in logger
83
+ }
84
+ }
85
+
86
+ // Touch the file
87
+ try {
88
+ if (!existsSync(logFile)) {
89
+ writeFileSync(logFile, "", "utf-8")
90
+ }
91
+ } catch {
92
+ // Best-effort
93
+ }
94
+
95
+ function write(level: LogLevel, msg: string, args: unknown[]): void {
96
+ const ts = new Date().toISOString()
97
+ const suffix = args.length > 0 ? ` ${args.map((a) => String(a)).join(" ")}` : ""
98
+ const line = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`
99
+ const formatted = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`
100
+
101
+ // Console (optional — can be suppressed for file-only logging)
102
+ if (showConsole) {
103
+ if (level === "error") {
104
+ process.stderr.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\n`)
105
+ } else {
106
+ process.stdout.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\n`)
107
+ }
108
+ }
109
+
110
+ // File
111
+ try {
112
+ rotateIfNeeded()
113
+ appendFileSync(logFile, `${line}\n`, "utf-8")
114
+ } catch {
115
+ // Best-effort
116
+ }
117
+ }
118
+
119
+ return {
120
+ debug: (msg, ...args) => {
121
+ if (LEVEL_ORDER.debug >= LEVEL_ORDER[level]) write("debug", msg, args)
122
+ },
123
+ info: (msg, ...args) => {
124
+ if (LEVEL_ORDER.info >= LEVEL_ORDER[level]) write("info", msg, args)
125
+ },
126
+ warn: (msg, ...args) => {
127
+ if (LEVEL_ORDER.warn >= LEVEL_ORDER[level]) write("warn", msg, args)
128
+ },
129
+ error: (msg, ...args) => {
130
+ if (LEVEL_ORDER.error >= LEVEL_ORDER[level]) write("error", msg, args)
131
+ },
132
+ }
133
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Polling monitor — calls a tick function on a configurable interval.
3
+ * Fires the first tick immediately, then every N minutes.
4
+ *
5
+ * One external `AbortSignal` (the session's AbortScope) interrupts both the
6
+ * sleep and any in-flight tick; there is no private controller.
7
+ */
8
+
9
+ import type { Logger } from "../core/log"
10
+
11
+ /** Sleep for `ms`, resolving early when `signal` aborts (timer cleared, no orphan). */
12
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
13
+ return new Promise((resolve) => {
14
+ if (signal?.aborted) {
15
+ resolve()
16
+ return
17
+ }
18
+ const timer = setTimeout(resolve, ms)
19
+ if (signal) {
20
+ signal.addEventListener(
21
+ "abort",
22
+ () => {
23
+ clearTimeout(timer)
24
+ resolve()
25
+ },
26
+ { once: true },
27
+ )
28
+ }
29
+ })
30
+ }
31
+
32
+ export interface PollingMonitor {
33
+ start: () => Promise<void>
34
+ stop: () => Promise<void>
35
+ /** Schedule the monitor to stop after the current tick completes. */
36
+ stopAfterCurrentTick: () => void
37
+ }
38
+
39
+ export function createPollingMonitor(opts: {
40
+ intervalMinutes: number
41
+ signal: AbortSignal
42
+ onTick: () => Promise<void>
43
+ logger?: Logger
44
+ }): PollingMonitor {
45
+ const intervalMs = opts.intervalMinutes * 60 * 1000
46
+ let currentTick: Promise<void> | null = null
47
+ let stopAfterTick = false
48
+
49
+ async function start(): Promise<void> {
50
+ opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`)
51
+
52
+ while (!opts.signal.aborted) {
53
+ currentTick = opts.onTick()
54
+ try {
55
+ await currentTick
56
+ } catch (err) {
57
+ const msg = err instanceof Error ? err.message : String(err)
58
+ opts.logger?.error(`Tick error: ${msg}`)
59
+ } finally {
60
+ currentTick = null
61
+ }
62
+
63
+ if (stopAfterTick || opts.signal.aborted) break
64
+
65
+ // Sleep for the full interval — close() aborts the signal to wake us up early
66
+ await sleep(intervalMs, opts.signal)
67
+ }
68
+
69
+ opts.logger?.info("Polling stopped")
70
+ }
71
+
72
+ async function stop(): Promise<void> {
73
+ // Wait for in-flight tick to finish; close() only aborts its I/O.
74
+ if (currentTick) {
75
+ await currentTick
76
+ }
77
+ }
78
+
79
+ function stopAfterCurrentTick(): void {
80
+ stopAfterTick = true
81
+ }
82
+
83
+ return { start, stop, stopAfterCurrentTick }
84
+ }