@zfadhli/tokrec 0.12.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/README.md +0 -430
- 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,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
|
-
|
|
2
|
-
//#region src/lib/utils.ts
|
|
1
|
+
//#region src/core/format.ts
|
|
3
2
|
/**
|
|
4
|
-
*
|
|
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,
|
|
49
|
+
export { bytesToHuman, formatDuration, formatFilename, relativeTime, sanitizeUser };
|
|
85
50
|
|
|
86
|
-
//# sourceMappingURL=
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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 {
|
|
2
|
-
|
|
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 };
|