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