@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,233 @@
1
+ import { planTick } from "../core/plan.mjs";
2
+ import { verdictOf } from "../core/verdict.mjs";
3
+ import { createHttpClient } from "../io/http.mjs";
4
+ import { createConverter } from "../io/media/convert.mjs";
5
+ import { downloadStream } from "../io/media/download.mjs";
6
+ import { createAudioNormalizer } from "../io/media/normalize.mjs";
7
+ import { createTikTokClient } from "../io/tiktok/client.mjs";
8
+ import { abortScope } from "./abort.mjs";
9
+ import { resolveConfig } from "./config.mjs";
10
+ import { createEventEmitter } from "./events.mjs";
11
+ import { createRecordingJob } from "./job.mjs";
12
+ import { createLogger } from "./logger.mjs";
13
+ import { createPollingMonitor } from "./loop.mjs";
14
+ //#region src/app/recorder.ts
15
+ /**
16
+ * Recorder orchestrator — wires the TikTok client, stream download, and
17
+ * RecordingJob into a single RecorderController.
18
+ *
19
+ * Policy lives in `policy/tick.ts`; this file maps a TickPlan to effects and
20
+ * owns the session state those effects mutate. Everything a tick needs is
21
+ * constructed as a `const` in `start()` and passed in, so no call site has to
22
+ * assert a non-null module-level `let` back into existence.
23
+ */
24
+ /** Max wait for a stop-time remux before aborting it (matches the pre-#32 cap). */
25
+ const POST_REMUX_GRACE_MS = 600 * 1e3;
26
+ function createRecorder(config) {
27
+ const cfg = resolveConfig(config);
28
+ const logger = createLogger({
29
+ level: cfg.logLevel,
30
+ logFile: "tiktok-recorder.log",
31
+ console: cfg.logConsole
32
+ });
33
+ const emitter = createEventEmitter();
34
+ const status = {
35
+ state: "idle",
36
+ user: cfg.user
37
+ };
38
+ let running = null;
39
+ let postScope = null;
40
+ let currentJob = null;
41
+ let recordedSec = 0;
42
+ let consecutiveErrors = 0;
43
+ function setState(partial) {
44
+ const prev = status.state;
45
+ Object.assign(status, partial);
46
+ if (partial.state !== void 0 && partial.state !== prev) {
47
+ logger.info(`State: ${prev} → ${partial.state}`);
48
+ if (partial.state === "polling") status.lastPollTime = (/* @__PURE__ */ new Date()).toISOString();
49
+ }
50
+ }
51
+ async function tick(d) {
52
+ const { client, monitor, scope, post } = d;
53
+ if (scope.signal.aborted) return;
54
+ const user = cfg.user;
55
+ client.invalidateCache();
56
+ logger.info(`Checking @${user}...`);
57
+ const verdict = await client.getVerdict(user);
58
+ const facts = {
59
+ recording: currentJob !== null,
60
+ recordedSec,
61
+ oneShot: cfg.duration > 0,
62
+ consecutiveErrors
63
+ };
64
+ consecutiveErrors = verdict.reason === "error" || verdict.reason === "no-room-id" ? consecutiveErrors + 1 : 0;
65
+ emitter.emit("verdict", verdict);
66
+ const plan = planTick(verdict, facts);
67
+ switch (plan.kind) {
68
+ case "poll":
69
+ logger.info(`@${user} ${plan.note} (reason: ${verdict.reason}, source: ${lastSource(verdict)})`);
70
+ return;
71
+ case "hold":
72
+ if (plan.reason === "restricted") logger.warn(`@${user} is live but age-protected/restricted — stream unavailable (reason: restricted, status_code 4003110, room: ${plan.room.id})`);
73
+ else logger.warn(`@${user} is live but no stream URL found (reason: no-stream-url, room: ${plan.room.id})`);
74
+ return;
75
+ case "stop":
76
+ if (plan.why === "one-shot") {
77
+ logger.info("Duration limit reached — exiting");
78
+ monitor.stopAfterCurrentTick();
79
+ } else if (plan.why === "gave-up") {
80
+ logger.error(`@${user} gave up after ${consecutiveErrors} consecutive errors`);
81
+ monitor.stopAfterCurrentTick();
82
+ }
83
+ return;
84
+ case "record": {
85
+ const { room, stream } = plan;
86
+ logger.info(`@${user} is LIVE! (room: ${room.id}, reason: live)`);
87
+ setState({ state: "recording" });
88
+ emitter.emit("record", {
89
+ phase: "start",
90
+ user,
91
+ file: ""
92
+ });
93
+ const getNextUrl = async () => {
94
+ if (scope.signal.aborted) return null;
95
+ if (!await client.isRoomAlive(room.id)) {
96
+ logger.info(`@${user} is no longer live — stopping`);
97
+ return null;
98
+ }
99
+ client.invalidateCache();
100
+ return client.getLiveUrl(room.id);
101
+ };
102
+ const result = await downloadStream(stream.url, user, cfg.outputDir, cfg.duration, (info) => emitter.emit("progress", {
103
+ kind: "download",
104
+ ...info
105
+ }), getNextUrl, scope.signal, logger, cfg.cookies);
106
+ emitter.emit("record", {
107
+ phase: "download",
108
+ ...result
109
+ });
110
+ recordedSec += result.duration;
111
+ const job = createRecordingJob({
112
+ result,
113
+ cfg: { segmentMinutes: cfg.segmentMinutes },
114
+ logger,
115
+ converter: d.converter,
116
+ audioNormalizer: d.audioNormalizer,
117
+ signal: post.signal,
118
+ emitRecord: (event) => emitter.emit("record", event),
119
+ emitProgress: (event) => emitter.emit("progress", event),
120
+ emitFailure: (err) => emitter.emit("failure", err),
121
+ setState
122
+ });
123
+ currentJob = job;
124
+ try {
125
+ await job.finish();
126
+ } finally {
127
+ currentJob = null;
128
+ }
129
+ if (cfg.duration > 0) {
130
+ logger.info("Duration limit reached — exiting");
131
+ monitor.stopAfterCurrentTick();
132
+ } else if (!scope.signal.aborted) {
133
+ if (!await client.isRoomAlive(room.id)) {
134
+ logger.info(`@${user} is offline`);
135
+ emitter.emit("verdict", verdictOf({
136
+ room: {
137
+ ...room,
138
+ live: false
139
+ },
140
+ evidence: verdict.evidence
141
+ }));
142
+ }
143
+ }
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ async function start() {
149
+ if (status.state !== "idle") {
150
+ logger.warn(`start() ignored: already in state ${status.state}`);
151
+ return;
152
+ }
153
+ const scope = abortScope();
154
+ const post = abortScope();
155
+ postScope = post;
156
+ currentJob = null;
157
+ recordedSec = 0;
158
+ consecutiveErrors = 0;
159
+ logger.info(`Starting recorder for @${cfg.user}`);
160
+ const http = await createHttpClient(cfg);
161
+ const client = createTikTokClient(http, logger, cfg.debug, scope.signal);
162
+ const converter = createConverter(logger, post.signal);
163
+ const audioNormalizer = cfg.normalizeAudio ? createAudioNormalizer({
164
+ loudness: cfg.normalizeLoudness,
165
+ audioCodec: cfg.normalizeCodec,
166
+ audioBitrate: cfg.normalizeBitrate,
167
+ onProgress: (file, percent, phase) => emitter.emit("progress", {
168
+ kind: "normalize",
169
+ file,
170
+ percent,
171
+ phase
172
+ })
173
+ }, logger) : null;
174
+ let monitor;
175
+ monitor = createPollingMonitor({
176
+ intervalMinutes: cfg.interval,
177
+ signal: scope.signal,
178
+ logger,
179
+ onTick: () => tick({
180
+ client,
181
+ monitor,
182
+ scope,
183
+ post,
184
+ converter,
185
+ audioNormalizer
186
+ })
187
+ });
188
+ running = {
189
+ monitor,
190
+ http,
191
+ scope
192
+ };
193
+ setState({ state: "polling" });
194
+ await monitor.start();
195
+ }
196
+ async function stop() {
197
+ if (status.state === "stopped") {
198
+ logger.warn(`stop() ignored: already stopped`);
199
+ return;
200
+ }
201
+ logger.info("Stopping recorder...");
202
+ running?.scope.close("user");
203
+ const grace = setTimeout(() => postScope?.close("stop-timeout"), POST_REMUX_GRACE_MS);
204
+ try {
205
+ const job = currentJob;
206
+ if (job) {
207
+ logger.info("Waiting for active recording to finish...");
208
+ await job.finish();
209
+ }
210
+ if (running) await running.monitor.stop();
211
+ } finally {
212
+ clearTimeout(grace);
213
+ }
214
+ if (running) await running.http.close();
215
+ emitter.clear();
216
+ setState({ state: "stopped" });
217
+ logger.info("Recorder stopped");
218
+ }
219
+ return {
220
+ start,
221
+ stop,
222
+ getStatus: () => ({ ...status }),
223
+ on: emitter.on,
224
+ off: emitter.off
225
+ };
226
+ }
227
+ function lastSource(verdict) {
228
+ return [...verdict.evidence].reverse().find((e) => e.outcome === "hit" || e.outcome === "denied")?.source ?? "none";
229
+ }
230
+ //#endregion
231
+ export { createRecorder };
232
+
233
+ //# sourceMappingURL=recorder.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.mjs","names":[],"sources":["../../src/app/recorder.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires the TikTok client, stream download, and\n * RecordingJob into a single RecorderController.\n *\n * Policy lives in `policy/tick.ts`; this file maps a TickPlan to effects and\n * owns the session state those effects mutate. Everything a tick needs is\n * constructed as a `const` in `start()` and passed in, so no call site has to\n * assert a non-null module-level `let` back into existence.\n */\n\nimport { planTick, type TickFacts } from \"../core/plan\"\nimport { verdictOf } from \"../core/verdict\"\nimport { createHttpClient, type HttpClient } from \"../io/http\"\nimport { type Converter, createConverter } from \"../io/media/convert\"\nimport { downloadStream } from \"../io/media/download\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"../io/media/normalize\"\nimport { createTikTokClient, type TikTokClient } from \"../io/tiktok/client\"\nimport { type AbortScope, abortScope } from \"./abort\"\nimport type { RecorderConfig, RecorderController, RecorderStatus } from \"./config\"\nimport { resolveConfig } from \"./config\"\nimport { createEventEmitter } from \"./events\"\nimport { createRecordingJob, type RecordingJob } from \"./job\"\nimport { createLogger } from \"./logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"./loop\"\n\n/** Max wait for a stop-time remux before aborting it (matches the pre-#32 cap). */\nconst POST_REMUX_GRACE_MS = 10 * 60 * 1000\n\n/** Everything one poll tick needs, built by `start()` before the tick can run. */\ninterface TickDeps {\n client: TikTokClient\n monitor: PollingMonitor\n scope: AbortScope\n post: AbortScope\n converter: Converter\n audioNormalizer: AudioNormalizer | null\n}\n\n/** What `stop()` has to join and release. */\ninterface Running {\n monitor: PollingMonitor\n http: HttpClient\n scope: AbortScope\n}\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = resolveConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n const emitter = createEventEmitter()\n\n // Session state — written by ticks and by the job lifecycle, read by getStatus().\n const status: RecorderStatus = { state: \"idle\", user: cfg.user }\n let running: Running | null = null\n let postScope: AbortScope | null = null\n let currentJob: RecordingJob | null = null\n let recordedSec = 0\n let consecutiveErrors = 0\n\n function setState(partial: Partial<RecorderStatus>): void {\n const prev = status.state\n Object.assign(status, partial)\n if (partial.state !== undefined && partial.state !== prev) {\n logger.info(`State: ${prev} → ${partial.state}`)\n if (partial.state === \"polling\") status.lastPollTime = new Date().toISOString()\n }\n }\n\n async function tick(d: TickDeps): Promise<void> {\n const { client, monitor, scope, post } = d\n if (scope.signal.aborted) return\n const user = cfg.user\n\n client.invalidateCache()\n logger.info(`Checking @${user}...`)\n\n const verdict = await client.getVerdict(user)\n const facts: TickFacts = {\n recording: currentJob !== null,\n recordedSec,\n oneShot: cfg.duration > 0,\n consecutiveErrors,\n }\n consecutiveErrors =\n verdict.reason === \"error\" || verdict.reason === \"no-room-id\" ? consecutiveErrors + 1 : 0\n\n emitter.emit(\"verdict\", verdict)\n const plan = planTick(verdict, facts)\n\n switch (plan.kind) {\n case \"poll\": {\n logger.info(\n `@${user} ${plan.note} (reason: ${verdict.reason}, source: ${lastSource(verdict)})`,\n )\n return\n }\n case \"hold\": {\n if (plan.reason === \"restricted\") {\n logger.warn(\n `@${user} is live but age-protected/restricted — stream unavailable (reason: restricted, status_code 4003110, room: ${plan.room.id})`,\n )\n } else {\n logger.warn(\n `@${user} is live but no stream URL found (reason: no-stream-url, room: ${plan.room.id})`,\n )\n }\n return\n }\n case \"stop\": {\n if (plan.why === \"one-shot\") {\n logger.info(\"Duration limit reached — exiting\")\n monitor.stopAfterCurrentTick()\n } else if (plan.why === \"gave-up\") {\n logger.error(`@${user} gave up after ${consecutiveErrors} consecutive errors`)\n monitor.stopAfterCurrentTick()\n }\n return\n }\n case \"record\": {\n const { room, stream } = plan\n logger.info(`@${user} is LIVE! (room: ${room.id}, reason: live)`)\n setState({ state: \"recording\" })\n emitter.emit(\"record\", { phase: \"start\", user, file: \"\" })\n\n const getNextUrl = async (): Promise<string | null> => {\n if (scope.signal.aborted) return null\n // Use the lightweight check_alive endpoint first — avoids a full\n // page re-scan + room/info call when the stream is still active.\n const alive = await client.isRoomAlive(room.id)\n if (!alive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n client.invalidateCache()\n return client.getLiveUrl(room.id)\n }\n\n const result = await downloadStream(\n stream.url,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"progress\", { kind: \"download\", ...info }),\n getNextUrl,\n scope.signal,\n logger,\n cfg.cookies,\n )\n\n emitter.emit(\"record\", { phase: \"download\", ...result })\n recordedSec += result.duration\n\n const job = createRecordingJob({\n result,\n cfg: { segmentMinutes: cfg.segmentMinutes },\n logger,\n converter: d.converter,\n audioNormalizer: d.audioNormalizer,\n signal: post.signal,\n emitRecord: (event) => emitter.emit(\"record\", event),\n emitProgress: (event) => emitter.emit(\"progress\", event),\n emitFailure: (err) => emitter.emit(\"failure\", err),\n setState,\n })\n currentJob = job\n try {\n await job.finish()\n } finally {\n currentJob = null\n }\n\n // One-shot mode: if a duration was set, stop after this recording\n if (cfg.duration > 0) {\n logger.info(\"Duration limit reached — exiting\")\n monitor.stopAfterCurrentTick()\n } else if (!scope.signal.aborted) {\n // Recording ended because the stream went offline (not user-initiated\n // stop). Immediately re-check via the lightweight endpoint.\n const alive = await client.isRoomAlive(room.id)\n if (!alive) {\n logger.info(`@${user} is offline`)\n emitter.emit(\n \"verdict\",\n verdictOf({ room: { ...room, live: false }, evidence: verdict.evidence }),\n )\n }\n }\n return\n }\n }\n }\n\n async function start(): Promise<void> {\n if (status.state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${status.state}`)\n return\n }\n const scope = abortScope()\n // Post-processing outlives a user stop: Ctrl+C aborts the download, but the\n // partial .ts is still remuxed to .mp4 before the process exits.\n const post = abortScope()\n postScope = post\n currentJob = null\n recordedSec = 0\n consecutiveErrors = 0\n\n logger.info(`Starting recorder for @${cfg.user}`)\n const http = await createHttpClient(cfg)\n const client = createTikTokClient(http, logger, cfg.debug, scope.signal)\n const converter = createConverter(logger, post.signal)\n const audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onProgress: (file, percent, phase) =>\n emitter.emit(\"progress\", {\n kind: \"normalize\",\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n },\n logger,\n )\n : null\n\n // Self-reference inside `onTick` is safe: the monitor calls it from\n // `start()`, never during construction.\n let monitor: PollingMonitor\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n signal: scope.signal,\n logger,\n onTick: () => tick({ client, monitor, scope, post, converter, audioNormalizer }),\n })\n running = { monitor, http, scope }\n\n setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n if (status.state === \"stopped\") {\n logger.warn(`stop() ignored: already stopped`)\n return\n }\n logger.info(\"Stopping recorder...\")\n running?.scope.close(\"user\")\n\n const grace = setTimeout(() => postScope?.close(\"stop-timeout\"), POST_REMUX_GRACE_MS)\n try {\n const job = currentJob\n if (job) {\n logger.info(\"Waiting for active recording to finish...\")\n await job.finish()\n }\n\n if (running) await running.monitor.stop()\n } finally {\n clearTimeout(grace)\n }\n if (running) await running.http.close()\n emitter.clear()\n setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus: () => ({ ...status }),\n on: emitter.on,\n off: emitter.off,\n }\n}\n\nfunction lastSource(verdict: ReturnType<typeof verdictOf>): string {\n return (\n [...verdict.evidence].reverse().find((e) => e.outcome === \"hit\" || e.outcome === \"denied\")\n ?.source ?? \"none\"\n )\n}\n"],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ //#region src/core/errors.d.ts
2
+ /**
3
+ * The single error type the library throws, plus its code union.
4
+ *
5
+ * Pure band: `io/` constructs it (`ffmpeg-error`, `aborted`) and `app/`
6
+ * constructs it (`config-error`), so it belongs below both.
7
+ */
8
+ type TikTokErrorCode = "config-error" | "ffmpeg-not-found" | "ffmpeg-error" | "aborted" | "unknown";
9
+ declare class TikTokError extends Error {
10
+ readonly code: TikTokErrorCode;
11
+ override readonly cause?: unknown;
12
+ constructor(code: TikTokErrorCode, message: string, cause?: unknown);
13
+ }
14
+ //#endregion
15
+ export { TikTokError, TikTokErrorCode };
16
+ //# sourceMappingURL=errors.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.mts","names":[],"sources":["../../src/core/errors.ts"],"mappings":";;AAOA;;;;AAAY;KAAA,eAAA;AAAA,cAOC,WAAA,SAAoB,KAAA;EAAA,SAE7B,IAAA,EAAsB,eAAA;EAAA,kBAEtB,KAAA;EAHF,WAAA,CACE,IAAA,EAAsB,eAAA,EACtB,OAAA,UACA,KAAA;AAAA"}
@@ -0,0 +1,15 @@
1
+ //#region src/core/errors.ts
2
+ var TikTokError = class extends Error {
3
+ code;
4
+ cause;
5
+ constructor(code, message, cause) {
6
+ super(message);
7
+ this.code = code;
8
+ this.cause = cause;
9
+ this.name = "TikTokError";
10
+ }
11
+ };
12
+ //#endregion
13
+ export { TikTokError };
14
+
15
+ //# sourceMappingURL=errors.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.mjs","names":[],"sources":["../../src/core/errors.ts"],"sourcesContent":["/**\n * The single error type the library throws, plus its code union.\n *\n * Pure band: `io/` constructs it (`ffmpeg-error`, `aborted`) and `app/`\n * constructs it (`config-error`), so it belongs below both.\n */\n\nexport type TikTokErrorCode =\n | \"config-error\"\n | \"ffmpeg-not-found\"\n | \"ffmpeg-error\"\n | \"aborted\"\n | \"unknown\"\n\nexport class TikTokError extends Error {\n constructor(\n public readonly code: TikTokErrorCode,\n message: string,\n public override readonly cause?: unknown,\n ) {\n super(message)\n this.name = \"TikTokError\"\n }\n}\n"],"mappings":";AAcA,IAAa,cAAb,cAAiC,MAAM;CAEnB;CAES;CAH3B,YACE,MACA,SACA,OACA;EACA,MAAM,OAAO;EAJG,KAAA,OAAA;EAES,KAAA,QAAA;EAGzB,KAAK,OAAO;CACd;AACF"}
@@ -0,0 +1,12 @@
1
+ //#region src/core/format.d.ts
2
+ /** Format bytes to human-readable size */
3
+ declare function bytesToHuman(bytes: number): string;
4
+ /** Strip leading @ from a username */
5
+ declare function sanitizeUser(user: string): string;
6
+ /** Format a millisecond elapsed time as a relative human-readable string. */
7
+ declare function relativeTime(ms: number): string;
8
+ /** Format seconds into a compact human-readable duration (e.g. "1m 5s", "30s"). */
9
+ declare function formatDuration(seconds: number): string;
10
+ //#endregion
11
+ export { bytesToHuman, formatDuration, relativeTime, sanitizeUser };
12
+ //# sourceMappingURL=format.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.mts","names":[],"sources":["../../src/core/format.ts"],"mappings":";;iBAiBgB,YAAA,CAAa,KAAA;;iBAQb,YAAA,CAAa,IAAA;AAK7B;AAAA,iBAAgB,YAAA,CAAa,EAAA;;iBAcb,cAAA,CAAe,OAAA"}
@@ -1,7 +1,8 @@
1
- import { mkdirSync, statSync } from "node:fs";
2
- //#region src/lib/utils.ts
1
+ //#region src/core/format.ts
3
2
  /**
4
- * Utility helpers — pure functions, no side effects.
3
+ * Pure formatting helpers — no I/O, no imports. Output filenames, byte counts,
4
+ * and human durations, shared by `io/` (download logging and output names) and
5
+ * the public API.
5
6
  */
6
7
  /** Format a filename like username=2025.06.09_14-30-00_1.flv */
7
8
  function formatFilename(user, ext = "flv", part) {
@@ -9,30 +10,6 @@ function formatFilename(user, ext = "flv", part) {
9
10
  const pad = (n) => String(n).padStart(2, "0");
10
11
  return `${user}=${`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`}_${`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`}${part !== void 0 ? `_part${part}` : ""}.${ext}`;
11
12
  }
12
- /** Ensure a directory exists (recursive) */
13
- function ensureDir(dir) {
14
- mkdirSync(dir, { recursive: true });
15
- }
16
- /**
17
- * Sleep for `ms` milliseconds.
18
- *
19
- * When an `AbortSignal` is provided and fires before the timer elapses,
20
- * the promise resolves immediately and the underlying timeout is cleared.
21
- * This prevents orphaned timers in Promise.race patterns.
22
- */
23
- function sleep(ms, signal) {
24
- return new Promise((resolve) => {
25
- if (signal?.aborted) {
26
- resolve();
27
- return;
28
- }
29
- const timer = setTimeout(resolve, ms);
30
- if (signal) signal.addEventListener("abort", () => {
31
- clearTimeout(timer);
32
- resolve();
33
- }, { once: true });
34
- });
35
- }
36
13
  /** Format bytes to human-readable size */
37
14
  function bytesToHuman(bytes) {
38
15
  if (bytes === 0) return "0 B";
@@ -68,19 +45,7 @@ function formatDuration(seconds) {
68
45
  if (m > 0) return `${m}m ${s}s`;
69
46
  return `${s}s`;
70
47
  }
71
- /** Find FFmpeg binary via Bun.which() or PATH search. */
72
- function findFfmpegPath() {
73
- const bunWhich = globalThis.Bun?.which;
74
- if (typeof bunWhich === "function") return bunWhich("ffmpeg") ?? null;
75
- const paths = process.env.PATH?.split(":") ?? [];
76
- for (const dir of paths) try {
77
- const full = `${dir}/ffmpeg`;
78
- statSync(full);
79
- return full;
80
- } catch {}
81
- return null;
82
- }
83
48
  //#endregion
84
- export { bytesToHuman, ensureDir, findFfmpegPath, formatDuration, formatFilename, relativeTime, sanitizeUser, sleep };
49
+ export { bytesToHuman, formatDuration, formatFilename, relativeTime, sanitizeUser };
85
50
 
86
- //# sourceMappingURL=utils.mjs.map
51
+ //# sourceMappingURL=format.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.mjs","names":[],"sources":["../../src/core/format.ts"],"sourcesContent":["/**\n * Pure formatting helpers — no I/O, no imports. Output filenames, byte counts,\n * and human durations, shared by `io/` (download logging and output names) and\n * the public API.\n */\n\n/** Format a filename like username=2025.06.09_14-30-00_1.flv */\nexport function formatFilename(user: string, ext = \"flv\", part?: number): string {\n const now = new Date()\n const pad = (n: number) => String(n).padStart(2, \"0\")\n const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`\n const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`\n const partSuffix = part !== undefined ? `_part${part}` : \"\"\n return `${user}=${date}_${time}${partSuffix}.${ext}`\n}\n\n/** Format bytes to human-readable size */\nexport function bytesToHuman(bytes: number): string {\n if (bytes === 0) return \"0 B\"\n const units = [\"B\", \"KB\", \"MB\", \"GB\"]\n const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)\n return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`\n}\n\n/** Strip leading @ from a username */\nexport function sanitizeUser(user: string): string {\n return user.replace(/^@+/, \"\").trim()\n}\n\n/** Format a millisecond elapsed time as a relative human-readable string. */\nexport function relativeTime(ms: number): string {\n const sec = Math.floor(ms / 1000)\n if (sec < 5) return \"just now\"\n if (sec < 60) return `${sec}s ago`\n const min = Math.floor(sec / 60)\n if (min < 60) return `${min}m ago`\n const hours = Math.floor(min / 60)\n const mins = min % 60\n if (hours < 24) return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`\n const days = Math.floor(hours / 24)\n return `${days}d ago`\n}\n\n/** Format seconds into a compact human-readable duration (e.g. \"1m 5s\", \"30s\"). */\nexport function formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n if (m > 0) return `${m}m ${s}s`\n return `${s}s`\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,eAAe,MAAc,MAAM,OAAO,MAAuB;CAC/E,MAAM,sBAAM,IAAI,KAAK;CACrB,MAAM,OAAO,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;CAIpD,OAAO,GAAG,KAAK,GAAG,GAHF,IAAI,YAAY,IAAI,IAAI,IAAI,SAAS,IAAI,CAAC,IAAI,IAAI,IAAI,QAAQ,CAAC,IAGxD,GAAG,GAFV,IAAI,IAAI,SAAS,CAAC,IAAI,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,IAAI,WAAW,CAAC,MAC/D,SAAS,KAAA,IAAY,QAAQ,SAAS,GACb,GAAG;AACjD;;AAGA,SAAgB,aAAa,OAAuB;CAClD,IAAI,UAAU,GAAG,OAAO;CACxB,MAAM,QAAQ;EAAC;EAAK;EAAM;EAAM;CAAI;CACpC,MAAM,IAAI,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,IAAI,CAAC,GAAG,MAAM,SAAS,CAAC;CACjF,OAAO,IAAI,QAAQ,QAAQ,EAAA,CAAG,QAAQ,CAAC,EAAE,GAAG,MAAM;AACpD;;AAGA,SAAgB,aAAa,MAAsB;CACjD,OAAO,KAAK,QAAQ,OAAO,EAAE,CAAC,CAAC,KAAK;AACtC;;AAGA,SAAgB,aAAa,IAAoB;CAC/C,MAAM,MAAM,KAAK,MAAM,KAAK,GAAI;CAChC,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,MAAM,IAAI,OAAO,GAAG,IAAI;CAC5B,MAAM,MAAM,KAAK,MAAM,MAAM,EAAE;CAC/B,IAAI,MAAM,IAAI,OAAO,GAAG,IAAI;CAC5B,MAAM,QAAQ,KAAK,MAAM,MAAM,EAAE;CACjC,MAAM,OAAO,MAAM;CACnB,IAAI,QAAQ,IAAI,OAAO,OAAO,IAAI,GAAG,MAAM,IAAI,KAAK,SAAS,GAAG,MAAM;CAEtE,OAAO,GADM,KAAK,MAAM,QAAQ,EACnB,EAAE;AACjB;;AAGA,SAAgB,eAAe,SAAyB;CACtD,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;CACjC,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;CACjC,IAAI,IAAI,GAAG,OAAO,GAAG,EAAE,IAAI,EAAE;CAC7B,OAAO,GAAG,EAAE;AACd"}
@@ -0,0 +1,52 @@
1
+ //#region src/core/plan.ts
2
+ function planTick(v, f) {
3
+ switch (v.reason) {
4
+ case "live":
5
+ if (f.recording) return {
6
+ kind: "poll",
7
+ note: "already recording"
8
+ };
9
+ if (!v.room || !v.stream) return {
10
+ kind: "poll",
11
+ note: "live without refs"
12
+ };
13
+ return {
14
+ kind: "record",
15
+ room: v.room,
16
+ stream: v.stream
17
+ };
18
+ case "no-stream-url":
19
+ case "restricted":
20
+ if (!v.room) return {
21
+ kind: "poll",
22
+ note: "missing room"
23
+ };
24
+ return {
25
+ kind: "hold",
26
+ reason: v.reason,
27
+ room: v.room
28
+ };
29
+ case "offline": return f.oneShot && f.recordedSec > 0 ? {
30
+ kind: "stop",
31
+ why: "one-shot"
32
+ } : {
33
+ kind: "poll",
34
+ note: "offline"
35
+ };
36
+ case "no-room-id": return f.consecutiveErrors >= 10 ? {
37
+ kind: "stop",
38
+ why: "gave-up"
39
+ } : {
40
+ kind: "poll",
41
+ note: "no room"
42
+ };
43
+ case "error": return {
44
+ kind: "poll",
45
+ note: v.failure?.message ?? "error"
46
+ };
47
+ }
48
+ }
49
+ //#endregion
50
+ export { planTick };
51
+
52
+ //# sourceMappingURL=plan.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan.mjs","names":[],"sources":["../../src/core/plan.ts"],"sourcesContent":["/**\n * Tick policy — pure mapping from a `Verdict` plus facts to an action.\n *\n * No I/O, no clock, no promises. `session/` executes the returned plan.\n */\n\nimport type { RoomRef, StreamRef, Verdict } from \"./verdict\"\n\nexport interface TickFacts {\n readonly recording: boolean\n readonly recordedSec: number\n readonly oneShot: boolean\n readonly consecutiveErrors: number\n}\n\nexport type TickPlan =\n | { kind: \"poll\"; note: string }\n | { kind: \"hold\"; reason: \"restricted\" | \"no-stream-url\"; room: RoomRef }\n | { kind: \"record\"; room: RoomRef; stream: StreamRef }\n | { kind: \"stop\"; why: \"one-shot\" | \"gave-up\" }\n\nexport function planTick(v: Verdict, f: TickFacts): TickPlan {\n // No user-stop arm: `stop()` aborts the scope, which ends the poll loop before\n // the next tick. A user stop is a signal, never a verdict.\n switch (v.reason) {\n case \"live\":\n if (f.recording) return { kind: \"poll\", note: \"already recording\" }\n if (!v.room || !v.stream) return { kind: \"poll\", note: \"live without refs\" }\n return { kind: \"record\", room: v.room, stream: v.stream }\n\n case \"no-stream-url\":\n case \"restricted\":\n if (!v.room) return { kind: \"poll\", note: \"missing room\" }\n return { kind: \"hold\", reason: v.reason, room: v.room }\n\n case \"offline\":\n return f.oneShot && f.recordedSec > 0\n ? { kind: \"stop\", why: \"one-shot\" }\n : { kind: \"poll\", note: \"offline\" }\n\n case \"no-room-id\":\n return f.consecutiveErrors >= 10\n ? { kind: \"stop\", why: \"gave-up\" }\n : { kind: \"poll\", note: \"no room\" }\n\n case \"error\":\n return { kind: \"poll\", note: v.failure?.message ?? \"error\" }\n }\n}\n"],"mappings":";AAqBA,SAAgB,SAAS,GAAY,GAAwB;CAG3D,QAAQ,EAAE,QAAV;EACE,KAAK;GACH,IAAI,EAAE,WAAW,OAAO;IAAE,MAAM;IAAQ,MAAM;GAAoB;GAClE,IAAI,CAAC,EAAE,QAAQ,CAAC,EAAE,QAAQ,OAAO;IAAE,MAAM;IAAQ,MAAM;GAAoB;GAC3E,OAAO;IAAE,MAAM;IAAU,MAAM,EAAE;IAAM,QAAQ,EAAE;GAAO;EAE1D,KAAK;EACL,KAAK;GACH,IAAI,CAAC,EAAE,MAAM,OAAO;IAAE,MAAM;IAAQ,MAAM;GAAe;GACzD,OAAO;IAAE,MAAM;IAAQ,QAAQ,EAAE;IAAQ,MAAM,EAAE;GAAK;EAExD,KAAK,WACH,OAAO,EAAE,WAAW,EAAE,cAAc,IAChC;GAAE,MAAM;GAAQ,KAAK;EAAW,IAChC;GAAE,MAAM;GAAQ,MAAM;EAAU;EAEtC,KAAK,cACH,OAAO,EAAE,qBAAqB,KAC1B;GAAE,MAAM;GAAQ,KAAK;EAAU,IAC/B;GAAE,MAAM;GAAQ,MAAM;EAAU;EAEtC,KAAK,SACH,OAAO;GAAE,MAAM;GAAQ,MAAM,EAAE,SAAS,WAAW;EAAQ;CAC/D;AACF"}
@@ -0,0 +1,44 @@
1
+ //#region src/core/verdict.d.ts
2
+ /**
3
+ * Verdict — the per-tick liveness result and its machine-readable reason.
4
+ *
5
+ * Pure domain type: no I/O, no imports outside `domain/`. `reason` is derived
6
+ * by `verdictOf`, never passed by callers; discovery reports facts, one total
7
+ * function decides what they mean.
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
+ type LiveReason = "live" | "offline" | "restricted" | "no-stream-url" | "no-room-id" | "error";
18
+ interface RoomRef {
19
+ readonly id: string;
20
+ readonly title: string | null;
21
+ readonly live: boolean;
22
+ }
23
+ interface StreamRef {
24
+ readonly url: string;
25
+ }
26
+ /** Where a fact came from. Legacy `LiveInfo.source` values stay valid. */
27
+ type SourceId = "page" | "sigi" | "room-info" | "api-live" | "profile" | "none";
28
+ type EvidenceOutcome = "hit" | "miss" | "denied" | "error";
29
+ interface Evidence {
30
+ readonly source: SourceId;
31
+ readonly outcome: EvidenceOutcome;
32
+ }
33
+ interface Verdict {
34
+ readonly reason: LiveReason;
35
+ readonly room: RoomRef | null;
36
+ readonly stream: StreamRef | null;
37
+ readonly evidence: readonly Evidence[];
38
+ readonly failure: Error | null;
39
+ }
40
+ /** Whether a reason means the room itself is live (holding or recording). */
41
+ declare function isLiveReason(reason: LiveReason): boolean;
42
+ //#endregion
43
+ export { Evidence, EvidenceOutcome, LiveReason, RoomRef, SourceId, StreamRef, Verdict, isLiveReason };
44
+ //# sourceMappingURL=verdict.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verdict.d.mts","names":[],"sources":["../../src/core/verdict.ts"],"mappings":";;AAgBA;;;;AAAY;AAQZ;;;;;;;;AAGW;KAXC,UAAA;AAAA,UAQK,OAAA;EAAA,SACN,EAAA;EAAA,SACA,KAAA;EAAA,SACA,IAAA;AAAA;AAAA,UAGM,SAAA;EAAA,SACN,GAAA;AAAA;AAIC;AAAA,KAAA,QAAA;AAAA,KAEA,eAAA;AAAA,UAEK,QAAA;EAAA,SACN,MAAA,EAAQ,QAAA;EAAA,SACR,OAAA,EAAS,eAAA;AAAA;AAAA,UAGH,OAAA;EAAA,SACN,MAAA,EAAQ,UAAA;EAAA,SACR,IAAA,EAAM,OAAA;EAAA,SACN,MAAA,EAAQ,SAAA;EAAA,SACR,QAAA,WAAmB,QAAA;EAAA,SACnB,OAAA,EAAS,KAAA;AAAA;;iBAIJ,YAAA,CAAa,MAAQ,EAAA,UAAA"}
@@ -0,0 +1,47 @@
1
+ //#region src/core/verdict.ts
2
+ /** Whether a reason means the room itself is live (holding or recording). */
3
+ function isLiveReason(reason) {
4
+ return reason === "live" || reason === "restricted" || reason === "no-stream-url";
5
+ }
6
+ /**
7
+ * The single constructor for `Verdict`. Six branches cover every reason;
8
+ * precedence is failure → no room → offline → denied → live → no URL.
9
+ */
10
+ function verdictOf(x) {
11
+ const room = x.room ?? null;
12
+ const stream = x.stream ?? null;
13
+ const base = {
14
+ room,
15
+ stream,
16
+ evidence: x.evidence ?? [],
17
+ failure: x.failure ?? null
18
+ };
19
+ if (x.failure) return {
20
+ ...base,
21
+ reason: "error"
22
+ };
23
+ if (!room) return {
24
+ ...base,
25
+ reason: "no-room-id"
26
+ };
27
+ if (!room.live) return {
28
+ ...base,
29
+ reason: "offline"
30
+ };
31
+ if (x.denied) return {
32
+ ...base,
33
+ reason: "restricted"
34
+ };
35
+ if (stream) return {
36
+ ...base,
37
+ reason: "live"
38
+ };
39
+ return {
40
+ ...base,
41
+ reason: "no-stream-url"
42
+ };
43
+ }
44
+ //#endregion
45
+ export { isLiveReason, verdictOf };
46
+
47
+ //# sourceMappingURL=verdict.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verdict.mjs","names":[],"sources":["../../src/core/verdict.ts"],"sourcesContent":["/**\n * Verdict — the per-tick liveness result and its machine-readable reason.\n *\n * Pure domain type: no I/O, no imports outside `domain/`. `reason` is derived\n * by `verdictOf`, never passed by callers; discovery reports facts, one total\n * function decides what they mean.\n */\n\n/**\n * Machine-readable reason for a LIVE status verdict.\n * \"live\" = stream URL resolved; \"offline\" = room not live;\n * \"restricted\" = live but age-protected/gated (stream refused);\n * \"no-stream-url\" = live but no URL and no gate evidence;\n * \"no-room-id\" = no discovery path produced a room ID;\n * \"error\" = network/parse failure across paths.\n */\nexport type LiveReason =\n | \"live\"\n | \"offline\"\n | \"restricted\"\n | \"no-stream-url\"\n | \"no-room-id\"\n | \"error\"\n\nexport interface RoomRef {\n readonly id: string\n readonly title: string | null\n readonly live: boolean\n}\n\nexport interface StreamRef {\n readonly url: string\n}\n\n/** Where a fact came from. Legacy `LiveInfo.source` values stay valid. */\nexport type SourceId = \"page\" | \"sigi\" | \"room-info\" | \"api-live\" | \"profile\" | \"none\"\n\nexport type EvidenceOutcome = \"hit\" | \"miss\" | \"denied\" | \"error\"\n\nexport interface Evidence {\n readonly source: SourceId\n readonly outcome: EvidenceOutcome\n}\n\nexport interface Verdict {\n readonly reason: LiveReason\n readonly room: RoomRef | null\n readonly stream: StreamRef | null\n readonly evidence: readonly Evidence[]\n readonly failure: Error | null\n}\n\n/** Whether a reason means the room itself is live (holding or recording). */\nexport function isLiveReason(reason: LiveReason): boolean {\n return reason === \"live\" || reason === \"restricted\" || reason === \"no-stream-url\"\n}\n\n/**\n * The single constructor for `Verdict`. Six branches cover every reason;\n * precedence is failure → no room → offline → denied → live → no URL.\n */\nexport function verdictOf(x: {\n room?: RoomRef | null\n stream?: StreamRef | null\n denied?: boolean\n failure?: Error | null\n evidence?: readonly Evidence[]\n}): Verdict {\n const room = x.room ?? null\n const stream = x.stream ?? null\n const base = { room, stream, evidence: x.evidence ?? [], failure: x.failure ?? null }\n\n if (x.failure) return { ...base, reason: \"error\" }\n if (!room) return { ...base, reason: \"no-room-id\" }\n if (!room.live) return { ...base, reason: \"offline\" }\n if (x.denied) return { ...base, reason: \"restricted\" }\n if (stream) return { ...base, reason: \"live\" }\n return { ...base, reason: \"no-stream-url\" }\n}\n"],"mappings":";;AAqDA,SAAgB,aAAa,QAA6B;CACxD,OAAO,WAAW,UAAU,WAAW,gBAAgB,WAAW;AACpE;;;;;AAMA,SAAgB,UAAU,GAMd;CACV,MAAM,OAAO,EAAE,QAAQ;CACvB,MAAM,SAAS,EAAE,UAAU;CAC3B,MAAM,OAAO;EAAE;EAAM;EAAQ,UAAU,EAAE,YAAY,CAAC;EAAG,SAAS,EAAE,WAAW;CAAK;CAEpF,IAAI,EAAE,SAAS,OAAO;EAAE,GAAG;EAAM,QAAQ;CAAQ;CACjD,IAAI,CAAC,MAAM,OAAO;EAAE,GAAG;EAAM,QAAQ;CAAa;CAClD,IAAI,CAAC,KAAK,MAAM,OAAO;EAAE,GAAG;EAAM,QAAQ;CAAU;CACpD,IAAI,EAAE,QAAQ,OAAO;EAAE,GAAG;EAAM,QAAQ;CAAa;CACrD,IAAI,QAAQ,OAAO;EAAE,GAAG;EAAM,QAAQ;CAAO;CAC7C,OAAO;EAAE,GAAG;EAAM,QAAQ;CAAgB;AAC5C"}
package/dist/index.d.mts CHANGED
@@ -1,3 +1,6 @@
1
- import { AppErrorKind, RecorderConfig, RecorderController, RecorderEvent, RecorderEventHandler, RecorderStatus, TikTokError } from "./lib/config.mjs";
2
- import { createRecorder } from "./core/recorder/index.mjs";
3
- export { type AppErrorKind, type RecorderConfig, type RecorderController, type RecorderEvent, type RecorderEventHandler, type RecorderStatus, type TikTokError, createRecorder };
1
+ import { TikTokError, TikTokErrorCode } from "./core/errors.mjs";
2
+ import { Evidence, EvidenceOutcome, LiveReason, RoomRef, SourceId, StreamRef, Verdict, isLiveReason } from "./core/verdict.mjs";
3
+ import { LogLevel, Phase, ProgressEvent, RecordEvent, RecordedFile, RecorderConfig, RecorderController, RecorderEvent, RecorderEventHandler, RecorderStatus } from "./app/config.mjs";
4
+ import { createRecorder } from "./app/recorder.mjs";
5
+ import { bytesToHuman, formatDuration, relativeTime, sanitizeUser } from "./core/format.mjs";
6
+ export { type Evidence, type EvidenceOutcome, type LiveReason, type LogLevel, type Phase, type ProgressEvent, type RecordEvent, type RecordedFile, type RecorderConfig, type RecorderController, type RecorderEvent, type RecorderEventHandler, type RecorderStatus, type RoomRef, type SourceId, type StreamRef, TikTokError, type TikTokErrorCode, type Verdict, bytesToHuman, createRecorder, formatDuration, isLiveReason, relativeTime, sanitizeUser };
package/dist/index.mjs CHANGED
@@ -1,2 +1,5 @@
1
- import { createRecorder } from "./core/recorder/index.mjs";
2
- export { createRecorder };
1
+ import { isLiveReason } from "./core/verdict.mjs";
2
+ import { TikTokError } from "./core/errors.mjs";
3
+ import { bytesToHuman, formatDuration, relativeTime, sanitizeUser } from "./core/format.mjs";
4
+ import { createRecorder } from "./app/recorder.mjs";
5
+ export { TikTokError, bytesToHuman, createRecorder, formatDuration, isLiveReason, relativeTime, sanitizeUser };