@zfadhli/tokrec 0.13.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -403
- package/dist/app/abort.mjs +14 -0
- package/dist/app/abort.mjs.map +1 -0
- package/dist/{lib → app}/config.d.mts +73 -71
- package/dist/app/config.d.mts.map +1 -0
- package/dist/app/config.mjs +26 -0
- package/dist/app/config.mjs.map +1 -0
- package/dist/app/events.mjs +36 -0
- package/dist/app/events.mjs.map +1 -0
- package/dist/app/job.mjs +190 -0
- package/dist/app/job.mjs.map +1 -0
- package/dist/{lib → app}/logger.mjs +1 -1
- package/dist/app/logger.mjs.map +1 -0
- package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
- package/dist/app/loop.mjs.map +1 -0
- package/dist/app/recorder.d.mts +7 -0
- package/dist/app/recorder.d.mts.map +1 -0
- package/dist/app/recorder.mjs +233 -0
- package/dist/app/recorder.mjs.map +1 -0
- package/dist/core/errors.d.mts +16 -0
- package/dist/core/errors.d.mts.map +1 -0
- package/dist/core/errors.mjs +15 -0
- package/dist/core/errors.mjs.map +1 -0
- package/dist/core/format.d.mts +12 -0
- package/dist/core/format.d.mts.map +1 -0
- package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
- package/dist/core/format.mjs.map +1 -0
- package/dist/core/plan.mjs +52 -0
- package/dist/core/plan.mjs.map +1 -0
- package/dist/core/verdict.d.mts +44 -0
- package/dist/core/verdict.d.mts.map +1 -0
- package/dist/core/verdict.mjs +47 -0
- package/dist/core/verdict.mjs.map +1 -0
- package/dist/index.d.mts +6 -3
- package/dist/index.mjs +5 -2
- package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
- package/dist/io/http.mjs.map +1 -0
- package/dist/io/media/convert.mjs +60 -0
- package/dist/io/media/convert.mjs.map +1 -0
- package/dist/{core/recorder → io/media}/download.mjs +8 -7
- package/dist/io/media/download.mjs.map +1 -0
- package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
- package/dist/io/media/ffmpeg.mjs.map +1 -0
- package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
- package/dist/io/media/normalize.mjs.map +1 -0
- package/dist/io/tiktok/client.mjs +77 -0
- package/dist/io/tiktok/client.mjs.map +1 -0
- package/dist/io/tiktok/discovery/chain.mjs +117 -0
- package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
- package/dist/io/tiktok/discovery/fallback.mjs +64 -0
- package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
- package/dist/io/tiktok/discovery/page.mjs +102 -0
- package/dist/io/tiktok/discovery/page.mjs.map +1 -0
- package/dist/io/tiktok/page.mjs +34 -0
- package/dist/io/tiktok/page.mjs.map +1 -0
- package/dist/io/tiktok/parse.mjs +110 -0
- package/dist/io/tiktok/parse.mjs.map +1 -0
- package/dist/io/tiktok/webcast.mjs +132 -0
- package/dist/io/tiktok/webcast.mjs.map +1 -0
- package/package.json +5 -26
- package/src/app/abort.ts +18 -0
- package/src/app/config.ts +178 -0
- package/src/app/events.ts +46 -0
- package/src/app/job.ts +228 -0
- package/src/app/logger.ts +133 -0
- package/src/app/loop.ts +84 -0
- package/src/app/recorder.ts +288 -0
- package/src/core/errors.ts +24 -0
- package/src/core/format.ts +50 -0
- package/src/core/log.ts +13 -0
- package/src/core/plan.ts +49 -0
- package/src/core/verdict.ts +79 -0
- package/src/index.ts +38 -0
- package/src/io/http.ts +167 -0
- package/src/io/media/convert.ts +69 -0
- package/src/io/media/download.ts +120 -0
- package/src/io/media/ffmpeg.ts +201 -0
- package/src/io/media/normalize.ts +64 -0
- package/src/io/tiktok/client.ts +114 -0
- package/src/io/tiktok/discovery/chain.ts +146 -0
- package/src/io/tiktok/discovery/fallback.ts +88 -0
- package/src/io/tiktok/discovery/page.ts +142 -0
- package/src/io/tiktok/page.ts +42 -0
- package/src/io/tiktok/parse.ts +171 -0
- package/src/io/tiktok/webcast.ts +162 -0
- package/dist/bin.d.mts +0 -1
- package/dist/bin.mjs +0 -215
- package/dist/bin.mjs.map +0 -1
- package/dist/core/api/client.mjs.map +0 -1
- package/dist/core/api/rate-limiter.mjs +0 -29
- package/dist/core/api/rate-limiter.mjs.map +0 -1
- package/dist/core/api/tiktok.mjs +0 -394
- package/dist/core/api/tiktok.mjs.map +0 -1
- package/dist/core/monitor.mjs.map +0 -1
- package/dist/core/recorder/convert.mjs +0 -55
- package/dist/core/recorder/convert.mjs.map +0 -1
- package/dist/core/recorder/download.mjs.map +0 -1
- package/dist/core/recorder/ffmpeg-utils.mjs.map +0 -1
- package/dist/core/recorder/index.d.mts +0 -7
- package/dist/core/recorder/index.d.mts.map +0 -1
- package/dist/core/recorder/index.mjs +0 -205
- package/dist/core/recorder/index.mjs.map +0 -1
- package/dist/core/recorder/normalize.mjs.map +0 -1
- package/dist/core/recorder/post-processing.mjs +0 -154
- package/dist/core/recorder/post-processing.mjs.map +0 -1
- package/dist/core/recorder/recorder-events.mjs +0 -34
- package/dist/core/recorder/recorder-events.mjs.map +0 -1
- package/dist/core/recorder/recorder-state.mjs +0 -38
- package/dist/core/recorder/recorder-state.mjs.map +0 -1
- package/dist/core/recorder/stream.mjs +0 -25
- package/dist/core/recorder/stream.mjs.map +0 -1
- package/dist/lib/browser-cookies.mjs +0 -113
- package/dist/lib/browser-cookies.mjs.map +0 -1
- package/dist/lib/cli.mjs +0 -137
- package/dist/lib/cli.mjs.map +0 -1
- package/dist/lib/config.d.mts.map +0 -1
- package/dist/lib/config.mjs +0 -47
- package/dist/lib/config.mjs.map +0 -1
- package/dist/lib/logger.mjs.map +0 -1
- package/dist/lib/tmux.mjs +0 -84
- package/dist/lib/tmux.mjs.map +0 -1
- package/dist/lib/ui.mjs +0 -136
- package/dist/lib/ui.mjs.map +0 -1
- package/dist/lib/utils.mjs.map +0 -1
- package/dist/package.mjs +0 -6
- 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
|
+
}
|
package/src/app/loop.ts
ADDED
|
@@ -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
|
+
}
|