@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.
Files changed (126) hide show
  1. package/README.md +99 -403
  2. package/dist/app/abort.mjs +14 -0
  3. package/dist/app/abort.mjs.map +1 -0
  4. package/dist/{lib → app}/config.d.mts +73 -71
  5. package/dist/app/config.d.mts.map +1 -0
  6. package/dist/app/config.mjs +26 -0
  7. package/dist/app/config.mjs.map +1 -0
  8. package/dist/app/events.mjs +36 -0
  9. package/dist/app/events.mjs.map +1 -0
  10. package/dist/app/job.mjs +190 -0
  11. package/dist/app/job.mjs.map +1 -0
  12. package/dist/{lib → app}/logger.mjs +1 -1
  13. package/dist/app/logger.mjs.map +1 -0
  14. package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
  15. package/dist/app/loop.mjs.map +1 -0
  16. package/dist/app/recorder.d.mts +7 -0
  17. package/dist/app/recorder.d.mts.map +1 -0
  18. package/dist/app/recorder.mjs +233 -0
  19. package/dist/app/recorder.mjs.map +1 -0
  20. package/dist/core/errors.d.mts +16 -0
  21. package/dist/core/errors.d.mts.map +1 -0
  22. package/dist/core/errors.mjs +15 -0
  23. package/dist/core/errors.mjs.map +1 -0
  24. package/dist/core/format.d.mts +12 -0
  25. package/dist/core/format.d.mts.map +1 -0
  26. package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
  27. package/dist/core/format.mjs.map +1 -0
  28. package/dist/core/plan.mjs +52 -0
  29. package/dist/core/plan.mjs.map +1 -0
  30. package/dist/core/verdict.d.mts +44 -0
  31. package/dist/core/verdict.d.mts.map +1 -0
  32. package/dist/core/verdict.mjs +47 -0
  33. package/dist/core/verdict.mjs.map +1 -0
  34. package/dist/index.d.mts +6 -3
  35. package/dist/index.mjs +5 -2
  36. package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
  37. package/dist/io/http.mjs.map +1 -0
  38. package/dist/io/media/convert.mjs +60 -0
  39. package/dist/io/media/convert.mjs.map +1 -0
  40. package/dist/{core/recorder → io/media}/download.mjs +8 -7
  41. package/dist/io/media/download.mjs.map +1 -0
  42. package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
  43. package/dist/io/media/ffmpeg.mjs.map +1 -0
  44. package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
  45. package/dist/io/media/normalize.mjs.map +1 -0
  46. package/dist/io/tiktok/client.mjs +77 -0
  47. package/dist/io/tiktok/client.mjs.map +1 -0
  48. package/dist/io/tiktok/discovery/chain.mjs +117 -0
  49. package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
  50. package/dist/io/tiktok/discovery/fallback.mjs +64 -0
  51. package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
  52. package/dist/io/tiktok/discovery/page.mjs +102 -0
  53. package/dist/io/tiktok/discovery/page.mjs.map +1 -0
  54. package/dist/io/tiktok/page.mjs +34 -0
  55. package/dist/io/tiktok/page.mjs.map +1 -0
  56. package/dist/io/tiktok/parse.mjs +110 -0
  57. package/dist/io/tiktok/parse.mjs.map +1 -0
  58. package/dist/io/tiktok/webcast.mjs +132 -0
  59. package/dist/io/tiktok/webcast.mjs.map +1 -0
  60. package/package.json +5 -26
  61. package/src/app/abort.ts +18 -0
  62. package/src/app/config.ts +178 -0
  63. package/src/app/events.ts +46 -0
  64. package/src/app/job.ts +228 -0
  65. package/src/app/logger.ts +133 -0
  66. package/src/app/loop.ts +84 -0
  67. package/src/app/recorder.ts +288 -0
  68. package/src/core/errors.ts +24 -0
  69. package/src/core/format.ts +50 -0
  70. package/src/core/log.ts +13 -0
  71. package/src/core/plan.ts +49 -0
  72. package/src/core/verdict.ts +79 -0
  73. package/src/index.ts +38 -0
  74. package/src/io/http.ts +167 -0
  75. package/src/io/media/convert.ts +69 -0
  76. package/src/io/media/download.ts +120 -0
  77. package/src/io/media/ffmpeg.ts +201 -0
  78. package/src/io/media/normalize.ts +64 -0
  79. package/src/io/tiktok/client.ts +114 -0
  80. package/src/io/tiktok/discovery/chain.ts +146 -0
  81. package/src/io/tiktok/discovery/fallback.ts +88 -0
  82. package/src/io/tiktok/discovery/page.ts +142 -0
  83. package/src/io/tiktok/page.ts +42 -0
  84. package/src/io/tiktok/parse.ts +171 -0
  85. package/src/io/tiktok/webcast.ts +162 -0
  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,288 @@
1
+ /**
2
+ * Recorder orchestrator — wires the TikTok client, stream download, and
3
+ * RecordingJob into a single RecorderController.
4
+ *
5
+ * Policy lives in `policy/tick.ts`; this file maps a TickPlan to effects and
6
+ * owns the session state those effects mutate. Everything a tick needs is
7
+ * constructed as a `const` in `start()` and passed in, so no call site has to
8
+ * assert a non-null module-level `let` back into existence.
9
+ */
10
+
11
+ import { planTick, type TickFacts } from "../core/plan"
12
+ import { verdictOf } from "../core/verdict"
13
+ import { createHttpClient, type HttpClient } from "../io/http"
14
+ import { type Converter, createConverter } from "../io/media/convert"
15
+ import { downloadStream } from "../io/media/download"
16
+ import { type AudioNormalizer, createAudioNormalizer } from "../io/media/normalize"
17
+ import { createTikTokClient, type TikTokClient } from "../io/tiktok/client"
18
+ import { type AbortScope, abortScope } from "./abort"
19
+ import type { RecorderConfig, RecorderController, RecorderStatus } from "./config"
20
+ import { resolveConfig } from "./config"
21
+ import { createEventEmitter } from "./events"
22
+ import { createRecordingJob, type RecordingJob } from "./job"
23
+ import { createLogger } from "./logger"
24
+ import { createPollingMonitor, type PollingMonitor } from "./loop"
25
+
26
+ /** Max wait for a stop-time remux before aborting it (matches the pre-#32 cap). */
27
+ const POST_REMUX_GRACE_MS = 10 * 60 * 1000
28
+
29
+ /** Everything one poll tick needs, built by `start()` before the tick can run. */
30
+ interface TickDeps {
31
+ client: TikTokClient
32
+ monitor: PollingMonitor
33
+ scope: AbortScope
34
+ post: AbortScope
35
+ converter: Converter
36
+ audioNormalizer: AudioNormalizer | null
37
+ }
38
+
39
+ /** What `stop()` has to join and release. */
40
+ interface Running {
41
+ monitor: PollingMonitor
42
+ http: HttpClient
43
+ scope: AbortScope
44
+ }
45
+
46
+ export function createRecorder(config: RecorderConfig): RecorderController {
47
+ const cfg = resolveConfig(config)
48
+ const logger = createLogger({
49
+ level: cfg.logLevel,
50
+ logFile: "tiktok-recorder.log",
51
+ console: cfg.logConsole,
52
+ })
53
+
54
+ const emitter = createEventEmitter()
55
+
56
+ // Session state — written by ticks and by the job lifecycle, read by getStatus().
57
+ const status: RecorderStatus = { state: "idle", user: cfg.user }
58
+ let running: Running | null = null
59
+ let postScope: AbortScope | null = null
60
+ let currentJob: RecordingJob | null = null
61
+ let recordedSec = 0
62
+ let consecutiveErrors = 0
63
+
64
+ function setState(partial: Partial<RecorderStatus>): void {
65
+ const prev = status.state
66
+ Object.assign(status, partial)
67
+ if (partial.state !== undefined && partial.state !== prev) {
68
+ logger.info(`State: ${prev} → ${partial.state}`)
69
+ if (partial.state === "polling") status.lastPollTime = new Date().toISOString()
70
+ }
71
+ }
72
+
73
+ async function tick(d: TickDeps): Promise<void> {
74
+ const { client, monitor, scope, post } = d
75
+ if (scope.signal.aborted) return
76
+ const user = cfg.user
77
+
78
+ client.invalidateCache()
79
+ logger.info(`Checking @${user}...`)
80
+
81
+ const verdict = await client.getVerdict(user)
82
+ const facts: TickFacts = {
83
+ recording: currentJob !== null,
84
+ recordedSec,
85
+ oneShot: cfg.duration > 0,
86
+ consecutiveErrors,
87
+ }
88
+ consecutiveErrors =
89
+ verdict.reason === "error" || verdict.reason === "no-room-id" ? consecutiveErrors + 1 : 0
90
+
91
+ emitter.emit("verdict", verdict)
92
+ const plan = planTick(verdict, facts)
93
+
94
+ switch (plan.kind) {
95
+ case "poll": {
96
+ logger.info(
97
+ `@${user} ${plan.note} (reason: ${verdict.reason}, source: ${lastSource(verdict)})`,
98
+ )
99
+ return
100
+ }
101
+ case "hold": {
102
+ if (plan.reason === "restricted") {
103
+ logger.warn(
104
+ `@${user} is live but age-protected/restricted — stream unavailable (reason: restricted, status_code 4003110, room: ${plan.room.id})`,
105
+ )
106
+ } else {
107
+ logger.warn(
108
+ `@${user} is live but no stream URL found (reason: no-stream-url, room: ${plan.room.id})`,
109
+ )
110
+ }
111
+ return
112
+ }
113
+ case "stop": {
114
+ if (plan.why === "one-shot") {
115
+ logger.info("Duration limit reached — exiting")
116
+ monitor.stopAfterCurrentTick()
117
+ } else if (plan.why === "gave-up") {
118
+ logger.error(`@${user} gave up after ${consecutiveErrors} consecutive errors`)
119
+ monitor.stopAfterCurrentTick()
120
+ }
121
+ return
122
+ }
123
+ case "record": {
124
+ const { room, stream } = plan
125
+ logger.info(`@${user} is LIVE! (room: ${room.id}, reason: live)`)
126
+ setState({ state: "recording" })
127
+ emitter.emit("record", { phase: "start", user, file: "" })
128
+
129
+ const getNextUrl = async (): Promise<string | null> => {
130
+ if (scope.signal.aborted) return null
131
+ // Use the lightweight check_alive endpoint first — avoids a full
132
+ // page re-scan + room/info call when the stream is still active.
133
+ const alive = await client.isRoomAlive(room.id)
134
+ if (!alive) {
135
+ logger.info(`@${user} is no longer live — stopping`)
136
+ return null
137
+ }
138
+ client.invalidateCache()
139
+ return client.getLiveUrl(room.id)
140
+ }
141
+
142
+ const result = await downloadStream(
143
+ stream.url,
144
+ user,
145
+ cfg.outputDir,
146
+ cfg.duration,
147
+ (info) => emitter.emit("progress", { kind: "download", ...info }),
148
+ getNextUrl,
149
+ scope.signal,
150
+ logger,
151
+ cfg.cookies,
152
+ )
153
+
154
+ emitter.emit("record", { phase: "download", ...result })
155
+ recordedSec += result.duration
156
+
157
+ const job = createRecordingJob({
158
+ result,
159
+ cfg: { segmentMinutes: cfg.segmentMinutes },
160
+ logger,
161
+ converter: d.converter,
162
+ audioNormalizer: d.audioNormalizer,
163
+ signal: post.signal,
164
+ emitRecord: (event) => emitter.emit("record", event),
165
+ emitProgress: (event) => emitter.emit("progress", event),
166
+ emitFailure: (err) => emitter.emit("failure", err),
167
+ setState,
168
+ })
169
+ currentJob = job
170
+ try {
171
+ await job.finish()
172
+ } finally {
173
+ currentJob = null
174
+ }
175
+
176
+ // One-shot mode: if a duration was set, stop after this recording
177
+ if (cfg.duration > 0) {
178
+ logger.info("Duration limit reached — exiting")
179
+ monitor.stopAfterCurrentTick()
180
+ } else if (!scope.signal.aborted) {
181
+ // Recording ended because the stream went offline (not user-initiated
182
+ // stop). Immediately re-check via the lightweight endpoint.
183
+ const alive = await client.isRoomAlive(room.id)
184
+ if (!alive) {
185
+ logger.info(`@${user} is offline`)
186
+ emitter.emit(
187
+ "verdict",
188
+ verdictOf({ room: { ...room, live: false }, evidence: verdict.evidence }),
189
+ )
190
+ }
191
+ }
192
+ return
193
+ }
194
+ }
195
+ }
196
+
197
+ async function start(): Promise<void> {
198
+ if (status.state !== "idle") {
199
+ logger.warn(`start() ignored: already in state ${status.state}`)
200
+ return
201
+ }
202
+ const scope = abortScope()
203
+ // Post-processing outlives a user stop: Ctrl+C aborts the download, but the
204
+ // partial .ts is still remuxed to .mp4 before the process exits.
205
+ const post = abortScope()
206
+ postScope = post
207
+ currentJob = null
208
+ recordedSec = 0
209
+ consecutiveErrors = 0
210
+
211
+ logger.info(`Starting recorder for @${cfg.user}`)
212
+ const http = await createHttpClient(cfg)
213
+ const client = createTikTokClient(http, logger, cfg.debug, scope.signal)
214
+ const converter = createConverter(logger, post.signal)
215
+ const audioNormalizer = cfg.normalizeAudio
216
+ ? createAudioNormalizer(
217
+ {
218
+ loudness: cfg.normalizeLoudness,
219
+ audioCodec: cfg.normalizeCodec,
220
+ audioBitrate: cfg.normalizeBitrate,
221
+ onProgress: (file, percent, phase) =>
222
+ emitter.emit("progress", {
223
+ kind: "normalize",
224
+ file,
225
+ percent,
226
+ phase: phase as "analyzing" | "normalizing",
227
+ }),
228
+ },
229
+ logger,
230
+ )
231
+ : null
232
+
233
+ // Self-reference inside `onTick` is safe: the monitor calls it from
234
+ // `start()`, never during construction.
235
+ let monitor: PollingMonitor
236
+ monitor = createPollingMonitor({
237
+ intervalMinutes: cfg.interval,
238
+ signal: scope.signal,
239
+ logger,
240
+ onTick: () => tick({ client, monitor, scope, post, converter, audioNormalizer }),
241
+ })
242
+ running = { monitor, http, scope }
243
+
244
+ setState({ state: "polling" })
245
+ await monitor.start()
246
+ }
247
+
248
+ async function stop(): Promise<void> {
249
+ if (status.state === "stopped") {
250
+ logger.warn(`stop() ignored: already stopped`)
251
+ return
252
+ }
253
+ logger.info("Stopping recorder...")
254
+ running?.scope.close("user")
255
+
256
+ const grace = setTimeout(() => postScope?.close("stop-timeout"), POST_REMUX_GRACE_MS)
257
+ try {
258
+ const job = currentJob
259
+ if (job) {
260
+ logger.info("Waiting for active recording to finish...")
261
+ await job.finish()
262
+ }
263
+
264
+ if (running) await running.monitor.stop()
265
+ } finally {
266
+ clearTimeout(grace)
267
+ }
268
+ if (running) await running.http.close()
269
+ emitter.clear()
270
+ setState({ state: "stopped" })
271
+ logger.info("Recorder stopped")
272
+ }
273
+
274
+ return {
275
+ start,
276
+ stop,
277
+ getStatus: () => ({ ...status }),
278
+ on: emitter.on,
279
+ off: emitter.off,
280
+ }
281
+ }
282
+
283
+ function lastSource(verdict: ReturnType<typeof verdictOf>): string {
284
+ return (
285
+ [...verdict.evidence].reverse().find((e) => e.outcome === "hit" || e.outcome === "denied")
286
+ ?.source ?? "none"
287
+ )
288
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The single error type the library throws, plus its code union.
3
+ *
4
+ * Pure band: `io/` constructs it (`ffmpeg-error`, `aborted`) and `app/`
5
+ * constructs it (`config-error`), so it belongs below both.
6
+ */
7
+
8
+ export type TikTokErrorCode =
9
+ | "config-error"
10
+ | "ffmpeg-not-found"
11
+ | "ffmpeg-error"
12
+ | "aborted"
13
+ | "unknown"
14
+
15
+ export class TikTokError extends Error {
16
+ constructor(
17
+ public readonly code: TikTokErrorCode,
18
+ message: string,
19
+ public override readonly cause?: unknown,
20
+ ) {
21
+ super(message)
22
+ this.name = "TikTokError"
23
+ }
24
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pure formatting helpers — no I/O, no imports. Output filenames, byte counts,
3
+ * and human durations, shared by `io/` (download logging and output names) and
4
+ * the public API.
5
+ */
6
+
7
+ /** Format a filename like username=2025.06.09_14-30-00_1.flv */
8
+ export function formatFilename(user: string, ext = "flv", part?: number): string {
9
+ const now = new Date()
10
+ const pad = (n: number) => String(n).padStart(2, "0")
11
+ const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
12
+ const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
13
+ const partSuffix = part !== undefined ? `_part${part}` : ""
14
+ return `${user}=${date}_${time}${partSuffix}.${ext}`
15
+ }
16
+
17
+ /** Format bytes to human-readable size */
18
+ export function bytesToHuman(bytes: number): string {
19
+ if (bytes === 0) return "0 B"
20
+ const units = ["B", "KB", "MB", "GB"]
21
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
22
+ return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`
23
+ }
24
+
25
+ /** Strip leading @ from a username */
26
+ export function sanitizeUser(user: string): string {
27
+ return user.replace(/^@+/, "").trim()
28
+ }
29
+
30
+ /** Format a millisecond elapsed time as a relative human-readable string. */
31
+ export function relativeTime(ms: number): string {
32
+ const sec = Math.floor(ms / 1000)
33
+ if (sec < 5) return "just now"
34
+ if (sec < 60) return `${sec}s ago`
35
+ const min = Math.floor(sec / 60)
36
+ if (min < 60) return `${min}m ago`
37
+ const hours = Math.floor(min / 60)
38
+ const mins = min % 60
39
+ if (hours < 24) return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`
40
+ const days = Math.floor(hours / 24)
41
+ return `${days}d ago`
42
+ }
43
+
44
+ /** Format seconds into a compact human-readable duration (e.g. "1m 5s", "30s"). */
45
+ export function formatDuration(seconds: number): string {
46
+ const m = Math.floor(seconds / 60)
47
+ const s = Math.floor(seconds % 60)
48
+ if (m > 0) return `${m}m ${s}s`
49
+ return `${s}s`
50
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Logger contract — the interface every adapter writes through.
3
+ *
4
+ * Pure band: `io/` may not import `app/`, and the adapters all take a logger.
5
+ * The rotating-file implementation is `app/logger.ts`.
6
+ */
7
+
8
+ export interface Logger {
9
+ debug: (msg: string, ...args: unknown[]) => void
10
+ info: (msg: string, ...args: unknown[]) => void
11
+ warn: (msg: string, ...args: unknown[]) => void
12
+ error: (msg: string, ...args: unknown[]) => void
13
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tick policy — pure mapping from a `Verdict` plus facts to an action.
3
+ *
4
+ * No I/O, no clock, no promises. `session/` executes the returned plan.
5
+ */
6
+
7
+ import type { RoomRef, StreamRef, Verdict } from "./verdict"
8
+
9
+ export interface TickFacts {
10
+ readonly recording: boolean
11
+ readonly recordedSec: number
12
+ readonly oneShot: boolean
13
+ readonly consecutiveErrors: number
14
+ }
15
+
16
+ export type TickPlan =
17
+ | { kind: "poll"; note: string }
18
+ | { kind: "hold"; reason: "restricted" | "no-stream-url"; room: RoomRef }
19
+ | { kind: "record"; room: RoomRef; stream: StreamRef }
20
+ | { kind: "stop"; why: "one-shot" | "gave-up" }
21
+
22
+ export function planTick(v: Verdict, f: TickFacts): TickPlan {
23
+ // No user-stop arm: `stop()` aborts the scope, which ends the poll loop before
24
+ // the next tick. A user stop is a signal, never a verdict.
25
+ switch (v.reason) {
26
+ case "live":
27
+ if (f.recording) return { kind: "poll", note: "already recording" }
28
+ if (!v.room || !v.stream) return { kind: "poll", note: "live without refs" }
29
+ return { kind: "record", room: v.room, stream: v.stream }
30
+
31
+ case "no-stream-url":
32
+ case "restricted":
33
+ if (!v.room) return { kind: "poll", note: "missing room" }
34
+ return { kind: "hold", reason: v.reason, room: v.room }
35
+
36
+ case "offline":
37
+ return f.oneShot && f.recordedSec > 0
38
+ ? { kind: "stop", why: "one-shot" }
39
+ : { kind: "poll", note: "offline" }
40
+
41
+ case "no-room-id":
42
+ return f.consecutiveErrors >= 10
43
+ ? { kind: "stop", why: "gave-up" }
44
+ : { kind: "poll", note: "no room" }
45
+
46
+ case "error":
47
+ return { kind: "poll", note: v.failure?.message ?? "error" }
48
+ }
49
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Verdict — the per-tick liveness result and its machine-readable reason.
3
+ *
4
+ * Pure domain type: no I/O, no imports outside `domain/`. `reason` is derived
5
+ * by `verdictOf`, never passed by callers; discovery reports facts, one total
6
+ * function decides what they mean.
7
+ */
8
+
9
+ /**
10
+ * Machine-readable reason for a LIVE status verdict.
11
+ * "live" = stream URL resolved; "offline" = room not live;
12
+ * "restricted" = live but age-protected/gated (stream refused);
13
+ * "no-stream-url" = live but no URL and no gate evidence;
14
+ * "no-room-id" = no discovery path produced a room ID;
15
+ * "error" = network/parse failure across paths.
16
+ */
17
+ export type LiveReason =
18
+ | "live"
19
+ | "offline"
20
+ | "restricted"
21
+ | "no-stream-url"
22
+ | "no-room-id"
23
+ | "error"
24
+
25
+ export interface RoomRef {
26
+ readonly id: string
27
+ readonly title: string | null
28
+ readonly live: boolean
29
+ }
30
+
31
+ export interface StreamRef {
32
+ readonly url: string
33
+ }
34
+
35
+ /** Where a fact came from. Legacy `LiveInfo.source` values stay valid. */
36
+ export type SourceId = "page" | "sigi" | "room-info" | "api-live" | "profile" | "none"
37
+
38
+ export type EvidenceOutcome = "hit" | "miss" | "denied" | "error"
39
+
40
+ export interface Evidence {
41
+ readonly source: SourceId
42
+ readonly outcome: EvidenceOutcome
43
+ }
44
+
45
+ export interface Verdict {
46
+ readonly reason: LiveReason
47
+ readonly room: RoomRef | null
48
+ readonly stream: StreamRef | null
49
+ readonly evidence: readonly Evidence[]
50
+ readonly failure: Error | null
51
+ }
52
+
53
+ /** Whether a reason means the room itself is live (holding or recording). */
54
+ export function isLiveReason(reason: LiveReason): boolean {
55
+ return reason === "live" || reason === "restricted" || reason === "no-stream-url"
56
+ }
57
+
58
+ /**
59
+ * The single constructor for `Verdict`. Six branches cover every reason;
60
+ * precedence is failure → no room → offline → denied → live → no URL.
61
+ */
62
+ export function verdictOf(x: {
63
+ room?: RoomRef | null
64
+ stream?: StreamRef | null
65
+ denied?: boolean
66
+ failure?: Error | null
67
+ evidence?: readonly Evidence[]
68
+ }): Verdict {
69
+ const room = x.room ?? null
70
+ const stream = x.stream ?? null
71
+ const base = { room, stream, evidence: x.evidence ?? [], failure: x.failure ?? null }
72
+
73
+ if (x.failure) return { ...base, reason: "error" }
74
+ if (!room) return { ...base, reason: "no-room-id" }
75
+ if (!room.live) return { ...base, reason: "offline" }
76
+ if (x.denied) return { ...base, reason: "restricted" }
77
+ if (stream) return { ...base, reason: "live" }
78
+ return { ...base, reason: "no-stream-url" }
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Library entry point — programmatic API for the TikTok Live Recorder.
3
+ *
4
+ * ```ts
5
+ * import { createRecorder } from 'tiktok-live-recorder-bun'
6
+ *
7
+ * const recorder = createRecorder({ user: 'username', outputDir: './videos' })
8
+ * recorder.on('recording:end', (info) => console.log('Done:', info.file))
9
+ * await recorder.start()
10
+ * ```
11
+ */
12
+
13
+ export type {
14
+ LiveReason,
15
+ LogLevel,
16
+ Phase,
17
+ ProgressEvent,
18
+ RecordEvent,
19
+ RecordedFile,
20
+ RecorderConfig,
21
+ RecorderController,
22
+ RecorderEvent,
23
+ RecorderEventHandler,
24
+ RecorderStatus,
25
+ } from "./app/config"
26
+ export { createRecorder } from "./app/recorder"
27
+ export type { TikTokErrorCode } from "./core/errors"
28
+ export { TikTokError } from "./core/errors"
29
+ export { bytesToHuman, formatDuration, relativeTime, sanitizeUser } from "./core/format"
30
+ export type {
31
+ Evidence,
32
+ EvidenceOutcome,
33
+ RoomRef,
34
+ SourceId,
35
+ StreamRef,
36
+ Verdict,
37
+ } from "./core/verdict"
38
+ export { isLiveReason } from "./core/verdict"