@zfadhli/tokrec 0.13.0 → 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
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { TikTokError, normalizeConfig } from "../../lib/config.mjs";
|
|
2
|
-
import { createLogger } from "../../lib/logger.mjs";
|
|
3
|
-
import { findFfmpegPath, sleep } from "../../lib/utils.mjs";
|
|
4
|
-
import { createHttpClient } from "../api/client.mjs";
|
|
5
|
-
import { createTikTokApi } from "../api/tiktok.mjs";
|
|
6
|
-
import { createPollingMonitor } from "../monitor.mjs";
|
|
7
|
-
import { createConverter } from "./convert.mjs";
|
|
8
|
-
import { createAudioNormalizer } from "./normalize.mjs";
|
|
9
|
-
import { processRecording } from "./post-processing.mjs";
|
|
10
|
-
import { createEventEmitter } from "./recorder-events.mjs";
|
|
11
|
-
import { createRecorderState } from "./recorder-state.mjs";
|
|
12
|
-
import { createStreamDownloader } from "./stream.mjs";
|
|
13
|
-
import { execa } from "execa";
|
|
14
|
-
//#region src/core/recorder/index.ts
|
|
15
|
-
/**
|
|
16
|
-
* Recorder orchestrator — wires together the API, monitor, stream downloader,
|
|
17
|
-
* and converter into a single RecorderController.
|
|
18
|
-
*/
|
|
19
|
-
function createRecorder(config) {
|
|
20
|
-
const cfg = normalizeConfig(config);
|
|
21
|
-
const logger = createLogger({
|
|
22
|
-
level: cfg.logLevel,
|
|
23
|
-
logFile: "tiktok-recorder.log",
|
|
24
|
-
console: cfg.logConsole
|
|
25
|
-
});
|
|
26
|
-
const emitter = createEventEmitter();
|
|
27
|
-
const stateManager = createRecorderState({
|
|
28
|
-
state: "idle",
|
|
29
|
-
user: cfg.user
|
|
30
|
-
}, logger);
|
|
31
|
-
let httpClient = null;
|
|
32
|
-
let api = null;
|
|
33
|
-
let monitor = null;
|
|
34
|
-
let downloader = null;
|
|
35
|
-
let converter = null;
|
|
36
|
-
let audioNormalizer = null;
|
|
37
|
-
let stopRequested = false;
|
|
38
|
-
let stopAbortController = new AbortController();
|
|
39
|
-
let pendingRemuxes = [];
|
|
40
|
-
async function runFfmpeg(args, signal) {
|
|
41
|
-
const ffmpegPath = findFfmpegPath();
|
|
42
|
-
if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
|
|
43
|
-
try {
|
|
44
|
-
await execa(ffmpegPath, args, {
|
|
45
|
-
cancelSignal: signal,
|
|
46
|
-
stdio: [
|
|
47
|
-
"ignore",
|
|
48
|
-
"pipe",
|
|
49
|
-
"pipe"
|
|
50
|
-
]
|
|
51
|
-
});
|
|
52
|
-
} catch (err) {
|
|
53
|
-
if (signal?.aborted) throw new TikTokError("unknown", "Aborted by user");
|
|
54
|
-
throw new TikTokError("unknown", `FFmpeg failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
async function start() {
|
|
58
|
-
if (stateManager.getStatus().state !== "idle") {
|
|
59
|
-
logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
stopRequested = false;
|
|
63
|
-
pendingRemuxes = [];
|
|
64
|
-
stopAbortController = new AbortController();
|
|
65
|
-
logger.info(`Starting recorder for @${cfg.user}`);
|
|
66
|
-
httpClient = await createHttpClient(cfg);
|
|
67
|
-
api = createTikTokApi(httpClient, logger, cfg.debug);
|
|
68
|
-
downloader = createStreamDownloader(logger);
|
|
69
|
-
converter = createConverter(logger, stopAbortController.signal);
|
|
70
|
-
audioNormalizer = cfg.normalizeAudio ? createAudioNormalizer({
|
|
71
|
-
loudness: cfg.normalizeLoudness,
|
|
72
|
-
audioCodec: cfg.normalizeCodec,
|
|
73
|
-
audioBitrate: cfg.normalizeBitrate,
|
|
74
|
-
onStart: (file) => emitter.emit("normalize:start", { file }),
|
|
75
|
-
onProgress: (file, percent, phase) => emitter.emit("normalize:progress", {
|
|
76
|
-
file,
|
|
77
|
-
percent,
|
|
78
|
-
phase
|
|
79
|
-
}),
|
|
80
|
-
onComplete: (result) => emitter.emit("normalize:end", {
|
|
81
|
-
input: result.input,
|
|
82
|
-
output: result.output
|
|
83
|
-
}),
|
|
84
|
-
onError: (file, err) => emitter.emit("normalize:error", {
|
|
85
|
-
input: file,
|
|
86
|
-
error: err.message
|
|
87
|
-
})
|
|
88
|
-
}, logger) : null;
|
|
89
|
-
monitor = createPollingMonitor({
|
|
90
|
-
intervalMinutes: cfg.interval,
|
|
91
|
-
logger,
|
|
92
|
-
onTick: async () => {
|
|
93
|
-
if (stopRequested) return;
|
|
94
|
-
const tikTokApi = api;
|
|
95
|
-
const streamDownloader = downloader;
|
|
96
|
-
const pollingMonitor = monitor;
|
|
97
|
-
const user = cfg.user;
|
|
98
|
-
tikTokApi.invalidateCache();
|
|
99
|
-
emitter.emit("checking", { user });
|
|
100
|
-
logger.info(`Checking @${user}...`);
|
|
101
|
-
const roomId = await tikTokApi.getRoomId(user);
|
|
102
|
-
if (!roomId) {
|
|
103
|
-
logger.info(`@${user} is offline`);
|
|
104
|
-
emitter.emit("tick", {
|
|
105
|
-
user,
|
|
106
|
-
isLive: false
|
|
107
|
-
});
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
const liveUrl = await tikTokApi.getLiveUrl(roomId);
|
|
111
|
-
if (!liveUrl) {
|
|
112
|
-
logger.warn(`@${user} is live but no stream URL found`);
|
|
113
|
-
emitter.emit("tick", {
|
|
114
|
-
user,
|
|
115
|
-
isLive: false,
|
|
116
|
-
roomId
|
|
117
|
-
});
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
logger.info(`@${user} is LIVE! (room: ${roomId})`);
|
|
121
|
-
emitter.emit("tick", {
|
|
122
|
-
user,
|
|
123
|
-
isLive: true,
|
|
124
|
-
roomId
|
|
125
|
-
});
|
|
126
|
-
stateManager.setState({ state: "recording" });
|
|
127
|
-
emitter.emit("recording:start", {
|
|
128
|
-
user,
|
|
129
|
-
file: ""
|
|
130
|
-
});
|
|
131
|
-
const getNextUrl = async () => {
|
|
132
|
-
if (!await tikTokApi.isRoomAlive(roomId)) {
|
|
133
|
-
logger.info(`@${user} is no longer live — stopping`);
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
tikTokApi.invalidateCache();
|
|
137
|
-
return tikTokApi.getLiveUrl(roomId);
|
|
138
|
-
};
|
|
139
|
-
const result = await streamDownloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emitter.emit("download:progress", info), getNextUrl, cfg.cookies);
|
|
140
|
-
emitter.emit("download:end", {
|
|
141
|
-
file: result.file,
|
|
142
|
-
duration: result.duration,
|
|
143
|
-
size: result.size
|
|
144
|
-
});
|
|
145
|
-
await processRecording(result, cfg, {
|
|
146
|
-
runFfmpeg,
|
|
147
|
-
converter,
|
|
148
|
-
audioNormalizer,
|
|
149
|
-
stopAbortController,
|
|
150
|
-
pendingRemuxes,
|
|
151
|
-
emit: emitter.emit.bind(emitter),
|
|
152
|
-
setState: stateManager.setState,
|
|
153
|
-
stopRequested: () => stopRequested,
|
|
154
|
-
logger
|
|
155
|
-
});
|
|
156
|
-
if (cfg.duration > 0) {
|
|
157
|
-
logger.info("Duration limit reached — exiting");
|
|
158
|
-
pollingMonitor.stopAfterCurrentTick();
|
|
159
|
-
} else if (!stopRequested && roomId) {
|
|
160
|
-
if (!await tikTokApi.isRoomAlive(roomId)) {
|
|
161
|
-
logger.info(`@${user} is offline`);
|
|
162
|
-
emitter.emit("tick", {
|
|
163
|
-
user,
|
|
164
|
-
isLive: false
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
stateManager.setState({ state: "polling" });
|
|
171
|
-
await monitor.start();
|
|
172
|
-
}
|
|
173
|
-
async function stop() {
|
|
174
|
-
if (stateManager.getStatus().state === "stopped") {
|
|
175
|
-
logger.warn(`stop() ignored: already stopped`);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
stopRequested = true;
|
|
179
|
-
logger.info("Stopping recorder...");
|
|
180
|
-
if (pendingRemuxes.length > 0) {
|
|
181
|
-
logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 10 min)...`);
|
|
182
|
-
const abortTimeout = new AbortController();
|
|
183
|
-
await Promise.race([Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()), sleep(6e5, abortTimeout.signal)]);
|
|
184
|
-
pendingRemuxes = [];
|
|
185
|
-
}
|
|
186
|
-
if (downloader) downloader.abort();
|
|
187
|
-
if (monitor) await monitor.stop();
|
|
188
|
-
stopAbortController.abort();
|
|
189
|
-
if (httpClient) await httpClient.close();
|
|
190
|
-
emitter.clear();
|
|
191
|
-
stateManager.setState({ state: "stopped" });
|
|
192
|
-
logger.info("Recorder stopped");
|
|
193
|
-
}
|
|
194
|
-
return {
|
|
195
|
-
start,
|
|
196
|
-
stop,
|
|
197
|
-
getStatus: stateManager.getStatus,
|
|
198
|
-
on: emitter.on,
|
|
199
|
-
off: emitter.off
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
//#endregion
|
|
203
|
-
export { createRecorder };
|
|
204
|
-
|
|
205
|
-
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../../src/core/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n */\n\nimport { execa } from \"execa\"\nimport type { RecorderConfig, RecorderController } from \"../../lib/config\"\nimport { normalizeConfig, TikTokError } from \"../../lib/config\"\nimport { createLogger } from \"../../lib/logger\"\nimport { findFfmpegPath, sleep } from \"../../lib/utils\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { processRecording } from \"./post-processing\"\nimport { createEventEmitter } from \"./recorder-events\"\nimport { createRecorderState } from \"./recorder-state\"\nimport type { DownloadResult } from \"./stream\"\nimport { createStreamDownloader, type StreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(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 const stateManager = createRecorderState({ state: \"idle\" as const, user: cfg.user }, logger)\n\n // State variables\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n let stopAbortController = new AbortController()\n let pendingRemuxes: Promise<unknown>[] = []\n\n async function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n try {\n await execa(ffmpegPath, args, { cancelSignal: signal, stdio: [\"ignore\", \"pipe\", \"pipe\"] })\n } catch (err: unknown) {\n if (signal?.aborted) {\n throw new TikTokError(\"unknown\", \"Aborted by user\")\n }\n const message = err instanceof Error ? err.message : String(err)\n throw new TikTokError(\"unknown\", `FFmpeg failed: ${message}`)\n }\n }\n\n async function start(): Promise<void> {\n if (stateManager.getStatus().state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`)\n return\n }\n stopRequested = false\n pendingRemuxes = []\n stopAbortController = new AbortController()\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient, logger, cfg.debug)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger, stopAbortController.signal)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emitter.emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emitter.emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emitter.emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) =>\n emitter.emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n // All dependencies are initialized by start() before onTick runs\n const tikTokApi = api as TikTokApi\n const streamDownloader = downloader as StreamDownloader\n const pollingMonitor = monitor as PollingMonitor\n\n const user = cfg.user\n tikTokApi.invalidateCache()\n\n emitter.emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await tikTokApi.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await tikTokApi.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emitter.emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emitter.emit(\"tick\", { user, isLive: true, roomId })\n\n stateManager.setState({ state: \"recording\" })\n emitter.emit(\"recording:start\", { user, file: \"\" })\n\n const getNextUrl = async (): Promise<string | 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 tikTokApi.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Still alive — invalidate cache and get a fresh stream URL.\n tikTokApi.invalidateCache()\n return tikTokApi.getLiveUrl(roomId)\n }\n\n const result = await streamDownloader.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"download:progress\", info),\n getNextUrl,\n cfg.cookies,\n )\n\n emitter.emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n\n await processRecording(result as DownloadResult, cfg, {\n runFfmpeg,\n converter: converter as Converter,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (emitter.emit as (event: string, ...args: unknown[]) => void).bind(emitter),\n setState: stateManager.setState,\n stopRequested: () => stopRequested,\n logger,\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 pollingMonitor.stopAfterCurrentTick()\n } else if (!stopRequested && roomId) {\n // Recording ended because the stream went offline (not user-initiated stop).\n // Immediately re-check via lightweight check_alive endpoint instead of\n // waiting for the next poll interval.\n const alive = await tikTokApi.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n }\n }\n },\n })\n\n stateManager.setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n if (stateManager.getStatus().state === \"stopped\") {\n logger.warn(`stop() ignored: already stopped`)\n return\n }\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n\n if (pendingRemuxes.length > 0) {\n logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 10 min)...`)\n const abortTimeout = new AbortController()\n await Promise.race([\n Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()),\n sleep(600_000, abortTimeout.signal),\n ])\n pendingRemuxes = []\n }\n\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n stopAbortController.abort()\n if (httpClient) await httpClient.close()\n emitter.clear()\n stateManager.setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus: stateManager.getStatus,\n on: emitter.on,\n off: emitter.off,\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"normalize.mjs","names":[],"sources":["../../../src/core/recorder/normalize.ts"],"sourcesContent":["/**\n * Audio normalizer — wraps peaknorm's EBU R128 loudness normalization.\n * Runs after conversion/segmenting to normalize the final MP4 audio.\n */\n\nimport type { NormalizeOptions, NormalizeResult } from \"peaknorm\"\nimport { normalizeFile } from \"peaknorm\"\nimport type { Logger } from \"../../lib/logger\"\n\nexport interface AudioNormalizer {\n /** Normalize a single audio/video file. Returns the normalize result. */\n normalize(file: string): Promise<NormalizeResult>\n}\n\nexport interface AudioNormalizerOptions {\n /** Target loudness in LUFS (default: -14). */\n loudness: number\n /** Audio codec for output (default: peaknorm's default, \"libopus\"). */\n audioCodec?: string\n /** Audio bitrate for output (default: peaknorm's default, \"96k\"). */\n audioBitrate?: string\n /** Called when a file starts normalization. */\n onStart?: (file: string) => void\n /** Called with progress updates (percent 0-100, phase \"analyzing\"|\"normalizing\"). */\n onProgress?: (file: string, percent: number, phase: string) => void\n /** Called when a file completes normalization successfully. */\n onComplete?: (result: NormalizeResult) => void\n /** Called when a file errors during normalization. */\n onError?: (file: string, error: Error) => void\n}\n\nexport function createAudioNormalizer(\n opts: AudioNormalizerOptions,\n logger?: Logger,\n): AudioNormalizer {\n return {\n async normalize(file: string): Promise<NormalizeResult> {\n logger?.info(`Normalizing audio: ${file}`)\n opts.onStart?.(file)\n\n try {\n const normalizeOpts: NormalizeOptions = {\n loudness: opts.loudness,\n backup: false,\n onFileProgress: (f, percent, phase) => {\n opts.onProgress?.(f, percent, phase)\n },\n }\n if (opts.audioCodec !== undefined) normalizeOpts.audioCodec = opts.audioCodec\n if (opts.audioBitrate !== undefined) normalizeOpts.audioBitrate = opts.audioBitrate\n const result = await normalizeFile(file, normalizeOpts)\n\n logger?.info(`Audio normalized: ${result.output}`)\n opts.onComplete?.(result)\n return result\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err))\n logger?.error(`Audio normalization failed: ${error.message}`)\n opts.onError?.(file, error)\n throw error\n }\n },\n }\n}\n"],"mappings":";;AA+BA,SAAgB,sBACd,MACA,QACiB;CACjB,OAAO,EACL,MAAM,UAAU,MAAwC;EACtD,QAAQ,KAAK,sBAAsB,MAAM;EACzC,KAAK,UAAU,IAAI;EAEnB,IAAI;GACF,MAAM,gBAAkC;IACtC,UAAU,KAAK;IACf,QAAQ;IACR,iBAAiB,GAAG,SAAS,UAAU;KACrC,KAAK,aAAa,GAAG,SAAS,KAAK;IACrC;GACF;GACA,IAAI,KAAK,eAAe,KAAA,GAAW,cAAc,aAAa,KAAK;GACnE,IAAI,KAAK,iBAAiB,KAAA,GAAW,cAAc,eAAe,KAAK;GACvE,MAAM,SAAS,MAAM,cAAc,MAAM,aAAa;GAEtD,QAAQ,KAAK,qBAAqB,OAAO,QAAQ;GACjD,KAAK,aAAa,MAAM;GACxB,OAAO;EACT,SAAS,KAAK;GACZ,MAAM,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;GAChE,QAAQ,MAAM,+BAA+B,MAAM,SAAS;GAC5D,KAAK,UAAU,MAAM,KAAK;GAC1B,MAAM;EACR;CACF,EACF;AACF"}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { TikTokError } from "../../lib/config.mjs";
|
|
2
|
-
import { readdirSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
|
3
|
-
import { join, parse } from "node:path";
|
|
4
|
-
//#region src/core/recorder/post-processing.ts
|
|
5
|
-
/**
|
|
6
|
-
* Post-recording processing — segmenting, conversion, and audio normalization.
|
|
7
|
-
*
|
|
8
|
-
* After a stream download completes, this module handles:
|
|
9
|
-
* - Splitting the downloaded file into timed MP4 segments
|
|
10
|
-
* - Converting TS to MP4 (fallback or direct)
|
|
11
|
-
* - Audio normalization via EBU R128
|
|
12
|
-
* - File management (deleting originals, setting timestamps)
|
|
13
|
-
* - Event emissions for each step
|
|
14
|
-
*/
|
|
15
|
-
async function processRecording(result, cfg, deps) {
|
|
16
|
-
try {
|
|
17
|
-
if (statSync(result.file).size === 0) return;
|
|
18
|
-
} catch {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
deps.setState({ state: "converting" });
|
|
22
|
-
if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) await processSegmenting(result, cfg, deps);
|
|
23
|
-
else await processConversion(result, cfg, deps);
|
|
24
|
-
deps.setState({ state: "polling" });
|
|
25
|
-
}
|
|
26
|
-
async function processSegmenting(result, cfg, deps) {
|
|
27
|
-
const parsed = parse(result.file);
|
|
28
|
-
const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60);
|
|
29
|
-
const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`);
|
|
30
|
-
deps.logger.info(`Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`);
|
|
31
|
-
deps.emit("segmenting:start", {
|
|
32
|
-
input: result.file,
|
|
33
|
-
outputPattern
|
|
34
|
-
});
|
|
35
|
-
try {
|
|
36
|
-
const segmentPromise = deps.runFfmpeg([
|
|
37
|
-
"-i",
|
|
38
|
-
result.file,
|
|
39
|
-
"-c",
|
|
40
|
-
"copy",
|
|
41
|
-
"-f",
|
|
42
|
-
"segment",
|
|
43
|
-
"-segment_time",
|
|
44
|
-
String(segmentTime),
|
|
45
|
-
"-reset_timestamps",
|
|
46
|
-
"1",
|
|
47
|
-
"-segment_start_number",
|
|
48
|
-
"1",
|
|
49
|
-
outputPattern
|
|
50
|
-
], deps.stopAbortController.signal);
|
|
51
|
-
deps.pendingRemuxes.push(segmentPromise);
|
|
52
|
-
await segmentPromise;
|
|
53
|
-
const recStartMs = statSync(result.file).mtimeMs - (result.duration ?? 0) * 1e3;
|
|
54
|
-
try {
|
|
55
|
-
unlinkSync(result.file);
|
|
56
|
-
deps.logger.info(`Deleted original: ${parsed.base}`);
|
|
57
|
-
} catch {
|
|
58
|
-
deps.logger.warn(`Could not delete original: ${parsed.base}`);
|
|
59
|
-
}
|
|
60
|
-
const allFiles = readdirSync(parsed.dir);
|
|
61
|
-
const prefix = `${parsed.name}_part`;
|
|
62
|
-
const segmentFiles = allFiles.filter((f) => f.startsWith(prefix) && f.endsWith(".mp4")).sort((a, b) => {
|
|
63
|
-
return Number.parseInt(a.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10) - Number.parseInt(b.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10);
|
|
64
|
-
});
|
|
65
|
-
const segmentDurationSec = segmentTime;
|
|
66
|
-
const totalDuration = result.duration;
|
|
67
|
-
for (let i = 0; i < segmentFiles.length; i++) {
|
|
68
|
-
const filePath = join(parsed.dir, segmentFiles[i]);
|
|
69
|
-
const stats = statSync(filePath);
|
|
70
|
-
const estDuration = i === segmentFiles.length - 1 ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1)) : segmentDurationSec;
|
|
71
|
-
deps.emit("recording:end", {
|
|
72
|
-
file: filePath,
|
|
73
|
-
duration: estDuration,
|
|
74
|
-
size: stats.size
|
|
75
|
-
});
|
|
76
|
-
deps.emit("converted", {
|
|
77
|
-
input: result.file,
|
|
78
|
-
output: filePath
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
for (let i = 0; i < segmentFiles.length; i++) {
|
|
82
|
-
const fp = join(parsed.dir, segmentFiles[i]);
|
|
83
|
-
const segStart = recStartMs + i * segmentDurationSec * 1e3;
|
|
84
|
-
utimesSync(fp, new Date(segStart), new Date(segStart));
|
|
85
|
-
}
|
|
86
|
-
deps.emit("segmenting:end", { segments: segmentFiles.length });
|
|
87
|
-
if (deps.audioNormalizer) {
|
|
88
|
-
const normalizer = deps.audioNormalizer;
|
|
89
|
-
const normalizePromises = segmentFiles.map(async (segFile) => {
|
|
90
|
-
const segPath = join(parsed.dir, segFile);
|
|
91
|
-
try {
|
|
92
|
-
await normalizer.normalize(segPath);
|
|
93
|
-
} catch {}
|
|
94
|
-
});
|
|
95
|
-
deps.pendingRemuxes.push(...normalizePromises);
|
|
96
|
-
await Promise.allSettled(normalizePromises);
|
|
97
|
-
}
|
|
98
|
-
deps.setState({
|
|
99
|
-
currentFile: segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)) : result.file,
|
|
100
|
-
sessionDuration: result.duration
|
|
101
|
-
});
|
|
102
|
-
} catch (err) {
|
|
103
|
-
if (deps.stopRequested()) return;
|
|
104
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
-
deps.logger.error(`Segmenting failed: ${msg}`);
|
|
106
|
-
deps.setState({ lastError: `Segmenting failed: ${msg}` });
|
|
107
|
-
await runFallbackConversion(result, deps);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Convert a downloaded file to MP4 and optionally normalize audio.
|
|
112
|
-
* Shared by the direct conversion and fallback paths.
|
|
113
|
-
*
|
|
114
|
-
* @param emitRecordingEnd When true, also emit a `recording:end` event
|
|
115
|
-
* (used by the direct conversion path; the segmenting path emits its
|
|
116
|
-
* own `recording:end` events per segment).
|
|
117
|
-
*/
|
|
118
|
-
async function convertFile(result, deps, emitRecordingEnd) {
|
|
119
|
-
deps.emit("converting:start", { input: result.file });
|
|
120
|
-
try {
|
|
121
|
-
const convertPromise = deps.converter.convert(result.file);
|
|
122
|
-
deps.pendingRemuxes.push(convertPromise);
|
|
123
|
-
const mp4File = await convertPromise;
|
|
124
|
-
if (deps.audioNormalizer) {
|
|
125
|
-
const normalizePromise = deps.audioNormalizer.normalize(mp4File).catch(() => {});
|
|
126
|
-
deps.pendingRemuxes.push(normalizePromise);
|
|
127
|
-
await normalizePromise;
|
|
128
|
-
}
|
|
129
|
-
if (emitRecordingEnd) deps.emit("recording:end", {
|
|
130
|
-
file: mp4File,
|
|
131
|
-
duration: result.duration,
|
|
132
|
-
size: result.size
|
|
133
|
-
});
|
|
134
|
-
deps.emit("converted", {
|
|
135
|
-
input: result.file,
|
|
136
|
-
output: mp4File
|
|
137
|
-
});
|
|
138
|
-
} catch (convErr) {
|
|
139
|
-
const tkErr = convErr instanceof TikTokError ? convErr : new TikTokError("unknown", String(convErr));
|
|
140
|
-
deps.logger.error(`Conversion failed: ${tkErr.message}`);
|
|
141
|
-
deps.setState({ lastError: `Conversion failed: ${tkErr.message}` });
|
|
142
|
-
deps.emit("error", tkErr);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
async function processConversion(result, _cfg, deps) {
|
|
146
|
-
return convertFile(result, deps, true);
|
|
147
|
-
}
|
|
148
|
-
async function runFallbackConversion(result, deps) {
|
|
149
|
-
return convertFile(result, deps);
|
|
150
|
-
}
|
|
151
|
-
//#endregion
|
|
152
|
-
export { processRecording };
|
|
153
|
-
|
|
154
|
-
//# sourceMappingURL=post-processing.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"post-processing.mjs","names":[],"sources":["../../../src/core/recorder/post-processing.ts"],"sourcesContent":["/**\n * Post-recording processing — segmenting, conversion, and audio normalization.\n *\n * After a stream download completes, this module handles:\n * - Splitting the downloaded file into timed MP4 segments\n * - Converting TS to MP4 (fallback or direct)\n * - Audio normalization via EBU R128\n * - File management (deleting originals, setting timestamps)\n * - Event emissions for each step\n */\n\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport type { RecorderConfig } from \"../../lib/config\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport type { Converter } from \"./convert\"\nimport type { AudioNormalizer } from \"./normalize\"\nimport type { DownloadResult } from \"./stream\"\n\nexport interface ProcessingDeps {\n runFfmpeg: (args: string[], signal?: AbortSignal) => Promise<void>\n converter: Converter\n audioNormalizer: AudioNormalizer | null\n stopAbortController: AbortController\n pendingRemuxes: Promise<unknown>[]\n emit: (event: string, ...args: unknown[]) => void\n setState: (partial: Record<string, unknown>) => void\n stopRequested: () => boolean\n logger: Logger\n}\n\nexport async function processRecording(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n try {\n if (statSync(result.file).size === 0) return\n } catch {\n return // file doesn't exist or can't be accessed\n }\n\n deps.setState({ state: \"converting\" })\n\n if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) {\n await processSegmenting(result, cfg, deps)\n } else {\n await processConversion(result, cfg, deps)\n }\n\n deps.setState({ state: \"polling\" })\n}\n\n// ─── Segmenting path ──────────────────────────────────────────────\n\nasync function processSegmenting(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n const parsed = parse(result.file)\n const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n\n deps.logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`,\n )\n deps.emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n const segmentPromise = deps.runFfmpeg(\n [\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ],\n deps.stopAbortController.signal,\n )\n deps.pendingRemuxes.push(segmentPromise)\n await segmentPromise\n\n // Capture file timestamp before deletion\n const fileMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = fileMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original TS\n try {\n unlinkSync(result.file)\n deps.logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n deps.logger.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n return na - nb\n })\n\n const segmentDurationSec = segmentTime\n const totalDuration = result.duration\n\n // Emit events for each segment\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i] as string)\n const stats = statSync(filePath)\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n deps.emit(\"recording:end\", { file: filePath, duration: estDuration, size: stats.size })\n deps.emit(\"converted\", { input: result.file, output: filePath })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i] as string)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n deps.emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n // Normalize audio for each segment if enabled\n if (deps.audioNormalizer) {\n const normalizer = deps.audioNormalizer\n const normalizePromises = segmentFiles.map(async (segFile) => {\n const segPath = join(parsed.dir, segFile)\n try {\n await normalizer.normalize(segPath)\n } catch {\n // Error already emitted via onError callback\n }\n })\n deps.pendingRemuxes.push(...normalizePromises)\n await Promise.allSettled(normalizePromises)\n }\n\n deps.setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1) as string) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n if (deps.stopRequested()) return\n const msg = err instanceof Error ? err.message : String(err)\n deps.logger.error(`Segmenting failed: ${msg}`)\n deps.setState({ lastError: `Segmenting failed: ${msg}` })\n // Fallback: try simple conversion of the whole TS\n await runFallbackConversion(result, deps)\n }\n}\n\n// ─── Shared converter helper ────────────────────────────────────\n\n/**\n * Convert a downloaded file to MP4 and optionally normalize audio.\n * Shared by the direct conversion and fallback paths.\n *\n * @param emitRecordingEnd When true, also emit a `recording:end` event\n * (used by the direct conversion path; the segmenting path emits its\n * own `recording:end` events per segment).\n */\nasync function convertFile(\n result: DownloadResult,\n deps: ProcessingDeps,\n emitRecordingEnd?: boolean,\n): Promise<void> {\n deps.emit(\"converting:start\", { input: result.file })\n try {\n const convertPromise = deps.converter.convert(result.file)\n deps.pendingRemuxes.push(convertPromise)\n const mp4File = await convertPromise\n\n if (deps.audioNormalizer) {\n const normalizePromise = deps.audioNormalizer.normalize(mp4File).catch(() => {})\n deps.pendingRemuxes.push(normalizePromise)\n await normalizePromise\n }\n\n if (emitRecordingEnd) {\n deps.emit(\"recording:end\", { file: mp4File, duration: result.duration, size: result.size })\n }\n deps.emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n deps.logger.error(`Conversion failed: ${tkErr.message}`)\n deps.setState({ lastError: `Conversion failed: ${tkErr.message}` })\n deps.emit(\"error\", tkErr)\n }\n}\n\n// ─── Conversion path (no segmenting) ─────────────────────────────\n\nasync function processConversion(\n result: DownloadResult,\n _cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n return convertFile(result, deps, true)\n}\n\n// ─── Fallback conversion (when segmenting fails) ─────────────────\n\nasync function runFallbackConversion(result: DownloadResult, deps: ProcessingDeps): Promise<void> {\n return convertFile(result, deps)\n}\n"],"mappings":"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"}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
//#region src/core/recorder/recorder-events.ts
|
|
2
|
-
function createEventEmitter() {
|
|
3
|
-
const handlers = /* @__PURE__ */ new Map();
|
|
4
|
-
function on(event, handler) {
|
|
5
|
-
const list = handlers.get(event) ?? [];
|
|
6
|
-
list.push(handler);
|
|
7
|
-
handlers.set(event, list);
|
|
8
|
-
}
|
|
9
|
-
function off(event, handler) {
|
|
10
|
-
const list = handlers.get(event);
|
|
11
|
-
if (!list) return;
|
|
12
|
-
const idx = list.indexOf(handler);
|
|
13
|
-
if (idx !== -1) list.splice(idx, 1);
|
|
14
|
-
}
|
|
15
|
-
function emit(event, ...args) {
|
|
16
|
-
const list = handlers.get(event);
|
|
17
|
-
if (list) for (const h of list) try {
|
|
18
|
-
h(...args);
|
|
19
|
-
} catch {}
|
|
20
|
-
}
|
|
21
|
-
function clear() {
|
|
22
|
-
handlers.clear();
|
|
23
|
-
}
|
|
24
|
-
return {
|
|
25
|
-
on,
|
|
26
|
-
off,
|
|
27
|
-
emit,
|
|
28
|
-
clear
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
//#endregion
|
|
32
|
-
export { createEventEmitter };
|
|
33
|
-
|
|
34
|
-
//# sourceMappingURL=recorder-events.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"recorder-events.mjs","names":[],"sources":["../../../src/core/recorder/recorder-events.ts"],"sourcesContent":["/**\n * Typed event emitter for recorder lifecycle events.\n */\n\nimport type { RecorderEvent, RecorderEventHandler } from \"../../lib/config\"\n\nexport interface RecorderEventEmitter {\n on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n emit<E extends RecorderEvent>(event: E, ...args: Parameters<RecorderEventHandler[E]>): void\n clear(): void\n}\n\nexport function createEventEmitter(): RecorderEventEmitter {\n const handlers = new Map<string, Array<(...args: unknown[]) => void>>()\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event) ?? []\n list.push(handler as (...args: unknown[]) => void)\n handlers.set(event, list)\n }\n\n function off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event)\n if (!list) return\n const idx = list.indexOf(handler as (...args: unknown[]) => void)\n if (idx !== -1) list.splice(idx, 1)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = handlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function clear(): void {\n handlers.clear()\n }\n\n return { on, off, emit, clear }\n}\n"],"mappings":";AAaA,SAAgB,qBAA2C;CACzD,MAAM,2BAAW,IAAI,IAAiD;CAEtE,SAAS,GAA4B,OAAU,SAAwC;EACrF,MAAM,OAAO,SAAS,IAAI,KAAK,KAAK,CAAC;EACrC,KAAK,KAAK,OAAuC;EACjD,SAAS,IAAI,OAAO,IAAI;CAC1B;CAEA,SAAS,IAA6B,OAAU,SAAwC;EACtF,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,CAAC,MAAM;EACX,MAAM,MAAM,KAAK,QAAQ,OAAuC;EAChE,IAAI,QAAQ,IAAI,KAAK,OAAO,KAAK,CAAC;CACpC;CAEA,SAAS,KACP,OACA,GAAG,MACG;EACN,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,MACF,KAAK,MAAM,KAAK,MACd,IAAI;GACF,EAAE,GAAG,IAAI;EACX,QAAQ,CAER;CAGN;CAEA,SAAS,QAAc;EACrB,SAAS,MAAM;CACjB;CAEA,OAAO;EAAE;EAAI;EAAK;EAAM;CAAM;AAChC"}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
//#region src/core/recorder/recorder-state.ts
|
|
2
|
-
const TRANSITIONS = {
|
|
3
|
-
idle: ["polling"],
|
|
4
|
-
polling: ["recording", "stopped"],
|
|
5
|
-
recording: ["converting", "stopped"],
|
|
6
|
-
converting: ["polling", "stopped"],
|
|
7
|
-
stopped: []
|
|
8
|
-
};
|
|
9
|
-
function createRecorderState(initial, logger) {
|
|
10
|
-
let state = { ...initial };
|
|
11
|
-
function setState(partial) {
|
|
12
|
-
const prevState = state.state;
|
|
13
|
-
const newState = partial.state;
|
|
14
|
-
if (newState && newState !== prevState) {
|
|
15
|
-
if (!TRANSITIONS[prevState]?.includes(newState)) {
|
|
16
|
-
logger.error(`Invalid state transition: ${prevState} → ${newState} (ignored)`);
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
logger.info(`State: ${prevState} → ${newState}`);
|
|
20
|
-
}
|
|
21
|
-
state = {
|
|
22
|
-
...state,
|
|
23
|
-
...partial
|
|
24
|
-
};
|
|
25
|
-
if (newState === "polling" && newState !== prevState) state.lastPollTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
26
|
-
}
|
|
27
|
-
function getStatus() {
|
|
28
|
-
return { ...state };
|
|
29
|
-
}
|
|
30
|
-
return {
|
|
31
|
-
setState,
|
|
32
|
-
getStatus
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
//#endregion
|
|
36
|
-
export { createRecorderState };
|
|
37
|
-
|
|
38
|
-
//# sourceMappingURL=recorder-state.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"recorder-state.mjs","names":[],"sources":["../../../src/core/recorder/recorder-state.ts"],"sourcesContent":["/**\n * State machine for the recorder lifecycle.\n */\n\nimport type { RecorderStatus } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\n\n// Valid state transitions: [from] → [to, to, ...]\nconst TRANSITIONS: Record<string, string[]> = {\n idle: [\"polling\"],\n polling: [\"recording\", \"stopped\"],\n recording: [\"converting\", \"stopped\"],\n converting: [\"polling\", \"stopped\"],\n stopped: [],\n}\n\nexport interface RecorderStateManager {\n setState(partial: Partial<RecorderStatus>): void\n getStatus(): RecorderStatus\n}\n\nexport function createRecorderState(initial: RecorderStatus, logger: Logger): RecorderStateManager {\n let state: RecorderStatus = { ...initial }\n\n function setState(partial: Partial<RecorderStatus>): void {\n const prevState = state.state\n const newState = partial.state\n\n // Validate state transitions\n if (newState && newState !== prevState) {\n const allowed = TRANSITIONS[prevState]\n if (!allowed?.includes(newState)) {\n logger.error(`Invalid state transition: ${prevState} → ${newState} (ignored)`)\n return\n }\n logger.info(`State: ${prevState} → ${newState}`)\n }\n\n state = { ...state, ...partial }\n\n // Auto-track timestamps on state entry\n if (newState === \"polling\" && newState !== prevState) {\n state.lastPollTime = new Date().toISOString()\n }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n return { setState, getStatus }\n}\n"],"mappings":";AAQA,MAAM,cAAwC;CAC5C,MAAM,CAAC,SAAS;CAChB,SAAS,CAAC,aAAa,SAAS;CAChC,WAAW,CAAC,cAAc,SAAS;CACnC,YAAY,CAAC,WAAW,SAAS;CACjC,SAAS,CAAC;AACZ;AAOA,SAAgB,oBAAoB,SAAyB,QAAsC;CACjG,IAAI,QAAwB,EAAE,GAAG,QAAQ;CAEzC,SAAS,SAAS,SAAwC;EACxD,MAAM,YAAY,MAAM;EACxB,MAAM,WAAW,QAAQ;EAGzB,IAAI,YAAY,aAAa,WAAW;GAEtC,IAAI,CADY,YAAY,UAChB,EAAE,SAAS,QAAQ,GAAG;IAChC,OAAO,MAAM,6BAA6B,UAAU,KAAK,SAAS,WAAW;IAC7E;GACF;GACA,OAAO,KAAK,UAAU,UAAU,KAAK,UAAU;EACjD;EAEA,QAAQ;GAAE,GAAG;GAAO,GAAG;EAAQ;EAG/B,IAAI,aAAa,aAAa,aAAa,WACzC,MAAM,gCAAe,IAAI,KAAK,EAAA,CAAE,YAAY;CAEhD;CAEA,SAAS,YAA4B;EACnC,OAAO,EAAE,GAAG,MAAM;CACpB;CAEA,OAAO;EAAE;EAAU;CAAU;AAC/B"}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { ensureDir } from "../../lib/utils.mjs";
|
|
2
|
-
import { downloadStream } from "./download.mjs";
|
|
3
|
-
//#region src/core/recorder/stream.ts
|
|
4
|
-
function createStreamDownloader(logger) {
|
|
5
|
-
let abortController = new AbortController();
|
|
6
|
-
function abort() {
|
|
7
|
-
abortController.abort();
|
|
8
|
-
}
|
|
9
|
-
function isHlsUrl(url) {
|
|
10
|
-
return url.includes(".m3u8");
|
|
11
|
-
}
|
|
12
|
-
async function download(liveUrl, user, outputDir, maxDuration = 0, onProgress, getNextUrl, cookies) {
|
|
13
|
-
abortController = new AbortController();
|
|
14
|
-
ensureDir(outputDir);
|
|
15
|
-
return downloadStream(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, abortController.signal, logger, isHlsUrl(liveUrl) ? "HLS" : void 0, cookies);
|
|
16
|
-
}
|
|
17
|
-
return {
|
|
18
|
-
download,
|
|
19
|
-
abort
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
//#endregion
|
|
23
|
-
export { createStreamDownloader };
|
|
24
|
-
|
|
25
|
-
//# sourceMappingURL=stream.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"stream.mjs","names":[],"sources":["../../../src/core/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — downloads a live stream to disk using FFmpeg.\n *\n * Both FLV and HLS streams are downloaded via FFmpeg subprocess with\n * transparent reconnection for transient network drops. Routes to the\n * appropriate downloader based on URL format.\n */\n\nimport type { Logger } from \"../../lib/logger\"\nimport { ensureDir } from \"../../lib/utils\"\nimport { downloadStream } from \"./download\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n /** Cookies to pass to FFmpeg for CDN auth. */\n cookies?: Record<string, string>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortController = new AbortController()\n\n function abort(): void {\n abortController.abort()\n }\n\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n cookies?: Record<string, string>,\n ): Promise<DownloadResult> {\n abortController = new AbortController()\n ensureDir(outputDir)\n\n return downloadStream(\n liveUrl,\n user,\n outputDir,\n maxDuration,\n onProgress,\n getNextUrl,\n abortController.signal,\n logger,\n isHlsUrl(liveUrl) ? \"HLS\" : undefined,\n cookies,\n )\n }\n\n return { download, abort }\n}\n"],"mappings":";;;AAyCA,SAAgB,uBAAuB,QAAmC;CACxE,IAAI,kBAAkB,IAAI,gBAAgB;CAE1C,SAAS,QAAc;EACrB,gBAAgB,MAAM;CACxB;CAEA,SAAS,SAAS,KAAsB;EACtC,OAAO,IAAI,SAAS,OAAO;CAC7B;CAEA,eAAe,SACb,SACA,MACA,WACA,cAAc,GACd,YACA,YACA,SACyB;EACzB,kBAAkB,IAAI,gBAAgB;EACtC,UAAU,SAAS;EAEnB,OAAO,eACL,SACA,MACA,WACA,aACA,YACA,YACA,gBAAgB,QAChB,QACA,SAAS,OAAO,IAAI,QAAQ,KAAA,GAC5B,OACF;CACF;CAEA,OAAO;EAAE;EAAU;CAAM;AAC3B"}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { Database } from "bun:sqlite";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
//#region src/lib/browser-cookies.ts
|
|
6
|
-
/**
|
|
7
|
-
* Browser cookie extraction — reads TikTok session cookies from Firefox's
|
|
8
|
-
* cookie store using bun:sqlite (no external dependencies).
|
|
9
|
-
*
|
|
10
|
-
* Falls back to cookies.json if Firefox isn't available or if no TikTok
|
|
11
|
-
* login session is found.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* Extract TikTok session cookies from Firefox's cookie store.
|
|
15
|
-
*
|
|
16
|
-
* Queries ALL cookies for TikTok domains from Firefox, including the
|
|
17
|
-
* Slardar WAF challenge cookie which is needed to bypass bot detection.
|
|
18
|
-
*
|
|
19
|
-
* Returns null if Firefox isn't found, cookies can't be read, or no
|
|
20
|
-
* sessionid_ss cookie exists (without which the session is useless).
|
|
21
|
-
*/
|
|
22
|
-
function extractTikTokCookiesFromFirefox() {
|
|
23
|
-
const dbPath = findFirefoxCookieDb();
|
|
24
|
-
if (!dbPath) return null;
|
|
25
|
-
try {
|
|
26
|
-
const db = new Database(dbPath, { readonly: true });
|
|
27
|
-
try {
|
|
28
|
-
const rows = db.query(`SELECT name, value FROM moz_cookies
|
|
29
|
-
WHERE host LIKE '%.tiktok.com' OR host LIKE '%.tiktokv.com'`).all();
|
|
30
|
-
if (!rows.some((r) => r.name === "sessionid_ss")) return null;
|
|
31
|
-
const cookies = {};
|
|
32
|
-
for (const row of rows) cookies[row.name] = row.value;
|
|
33
|
-
return cookies;
|
|
34
|
-
} finally {
|
|
35
|
-
db.close();
|
|
36
|
-
}
|
|
37
|
-
} catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Locate the Firefox cookies.sqlite by scanning profile directories.
|
|
43
|
-
*
|
|
44
|
-
* Strategy:
|
|
45
|
-
* 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.
|
|
46
|
-
* 2. Fall back to scanning directory entries that look like Firefox profiles
|
|
47
|
-
* (contain a cookies.sqlite file).
|
|
48
|
-
*/
|
|
49
|
-
/** @public Exported for testing */
|
|
50
|
-
function findFirefoxCookieDb() {
|
|
51
|
-
const firefoxDir = join(homedir(), ".mozilla", "firefox");
|
|
52
|
-
if (!existsSync(firefoxDir)) return null;
|
|
53
|
-
const iniPath = join(firefoxDir, "profiles.ini");
|
|
54
|
-
if (existsSync(iniPath)) try {
|
|
55
|
-
const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir);
|
|
56
|
-
if (profilePath) {
|
|
57
|
-
const dbPath = join(profilePath, "cookies.sqlite");
|
|
58
|
-
if (existsSync(dbPath)) return dbPath;
|
|
59
|
-
}
|
|
60
|
-
} catch {}
|
|
61
|
-
const entries = readdirSync(firefoxDir, { withFileTypes: true });
|
|
62
|
-
for (const entry of entries) {
|
|
63
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
64
|
-
const dbPath = join(firefoxDir, entry.name, "cookies.sqlite");
|
|
65
|
-
if (existsSync(dbPath)) return dbPath;
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Parse Firefox's profiles.ini to find the default profile path.
|
|
71
|
-
*
|
|
72
|
-
* The INI format looks like:
|
|
73
|
-
* [Profile0]
|
|
74
|
-
* Name=default
|
|
75
|
-
* IsRelative=1
|
|
76
|
-
* Path=1pese0rl.default-release
|
|
77
|
-
* Default=1
|
|
78
|
-
*/
|
|
79
|
-
/** @public Exported for testing */
|
|
80
|
-
function parseFirefoxProfilesIni(iniPath, firefoxDir) {
|
|
81
|
-
const text = readFileSync(iniPath, "utf-8");
|
|
82
|
-
let defaultPath = null;
|
|
83
|
-
let isRelative = true;
|
|
84
|
-
let inProfile = false;
|
|
85
|
-
for (const line of text.split("\n")) {
|
|
86
|
-
const trimmed = line.trim();
|
|
87
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
88
|
-
if (inProfile && defaultPath) break;
|
|
89
|
-
inProfile = trimmed.toLowerCase().startsWith("[profile");
|
|
90
|
-
if (inProfile) {
|
|
91
|
-
defaultPath = null;
|
|
92
|
-
isRelative = true;
|
|
93
|
-
}
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (!inProfile) continue;
|
|
97
|
-
const lower = trimmed.toLowerCase();
|
|
98
|
-
if (lower.startsWith("path=")) defaultPath = trimmed.slice(5);
|
|
99
|
-
else if (lower.startsWith("isrelative=")) isRelative = trimmed.slice(11).trim() !== "0";
|
|
100
|
-
else if (lower.startsWith("default=")) {
|
|
101
|
-
if (trimmed.slice(8).trim() !== "1") {
|
|
102
|
-
inProfile = false;
|
|
103
|
-
defaultPath = null;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (!defaultPath) return null;
|
|
108
|
-
return isRelative ? join(firefoxDir, defaultPath) : defaultPath;
|
|
109
|
-
}
|
|
110
|
-
//#endregion
|
|
111
|
-
export { extractTikTokCookiesFromFirefox };
|
|
112
|
-
|
|
113
|
-
//# sourceMappingURL=browser-cookies.mjs.map
|