@zfadhli/tokrec 0.7.1 → 0.9.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/README.md +129 -76
- package/dist/api/client.mjs +45 -19
- package/dist/api/client.mjs.map +1 -1
- package/dist/api/rate-limiter.mjs +29 -0
- package/dist/api/rate-limiter.mjs.map +1 -0
- package/dist/api/tiktok.mjs +180 -51
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/browser-cookies.mjs +8 -11
- package/dist/browser-cookies.mjs.map +1 -1
- package/dist/cli.mjs +10 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/config.d.mts +18 -6
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +8 -4
- package/dist/config.mjs.map +1 -1
- package/dist/index.mjs +22 -4
- package/dist/index.mjs.map +1 -1
- package/dist/monitor.mjs +12 -13
- package/dist/monitor.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/convert.mjs +25 -25
- package/dist/recorder/convert.mjs.map +1 -1
- package/dist/recorder/download-hls.mjs +77 -0
- package/dist/recorder/download-hls.mjs.map +1 -0
- package/dist/recorder/download-stream.mjs +78 -0
- package/dist/recorder/download-stream.mjs.map +1 -0
- package/dist/recorder/ffmpeg-utils.mjs +122 -0
- package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
- package/dist/recorder/index.d.mts.map +1 -1
- package/dist/recorder/index.mjs +88 -176
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/post-processing.mjs +150 -0
- package/dist/recorder/post-processing.mjs.map +1 -0
- package/dist/recorder/recorder-events.mjs +34 -0
- package/dist/recorder/recorder-events.mjs.map +1 -0
- package/dist/recorder/recorder-state.mjs +38 -0
- package/dist/recorder/recorder-state.mjs.map +1 -0
- package/dist/recorder/stream.mjs +5 -293
- package/dist/recorder/stream.mjs.map +1 -1
- package/dist/ui.mjs +16 -25
- package/dist/ui.mjs.map +1 -1
- package/dist/utils.mjs +19 -3
- package/dist/utils.mjs.map +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { TikTokError } from "../config.mjs";
|
|
2
|
+
import { readdirSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
|
3
|
+
import { join, parse } from "node:path";
|
|
4
|
+
//#region src/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
|
+
if (result.size === 0) return;
|
|
17
|
+
deps.setState({ state: "converting" });
|
|
18
|
+
if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) await processSegmenting(result, cfg, deps);
|
|
19
|
+
else await processConversion(result, cfg, deps);
|
|
20
|
+
deps.setState({ state: "polling" });
|
|
21
|
+
}
|
|
22
|
+
async function processSegmenting(result, cfg, deps) {
|
|
23
|
+
const parsed = parse(result.file);
|
|
24
|
+
const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60);
|
|
25
|
+
const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`);
|
|
26
|
+
deps.logger.info(`Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`);
|
|
27
|
+
deps.emit("segmenting:start", {
|
|
28
|
+
input: result.file,
|
|
29
|
+
outputPattern
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
const segmentPromise = deps.runFfmpeg([
|
|
33
|
+
"-i",
|
|
34
|
+
result.file,
|
|
35
|
+
"-c",
|
|
36
|
+
"copy",
|
|
37
|
+
"-f",
|
|
38
|
+
"segment",
|
|
39
|
+
"-segment_time",
|
|
40
|
+
String(segmentTime),
|
|
41
|
+
"-reset_timestamps",
|
|
42
|
+
"1",
|
|
43
|
+
"-segment_start_number",
|
|
44
|
+
"1",
|
|
45
|
+
outputPattern
|
|
46
|
+
], deps.stopAbortController.signal);
|
|
47
|
+
deps.pendingRemuxes.push(segmentPromise);
|
|
48
|
+
await segmentPromise;
|
|
49
|
+
const recStartMs = statSync(result.file).mtimeMs - (result.duration ?? 0) * 1e3;
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(result.file);
|
|
52
|
+
deps.logger.info(`Deleted original: ${parsed.base}`);
|
|
53
|
+
} catch {
|
|
54
|
+
deps.logger.warn(`Could not delete original: ${parsed.base}`);
|
|
55
|
+
}
|
|
56
|
+
const allFiles = readdirSync(parsed.dir);
|
|
57
|
+
const prefix = `${parsed.name}_part`;
|
|
58
|
+
const segmentFiles = allFiles.filter((f) => f.startsWith(prefix) && f.endsWith(".mp4")).sort((a, b) => {
|
|
59
|
+
return Number.parseInt(a.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10) - Number.parseInt(b.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10);
|
|
60
|
+
});
|
|
61
|
+
const segmentDurationSec = segmentTime;
|
|
62
|
+
const totalDuration = result.duration;
|
|
63
|
+
for (let i = 0; i < segmentFiles.length; i++) {
|
|
64
|
+
const filePath = join(parsed.dir, segmentFiles[i]);
|
|
65
|
+
const stats = statSync(filePath);
|
|
66
|
+
const estDuration = i === segmentFiles.length - 1 ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1)) : segmentDurationSec;
|
|
67
|
+
deps.emit("recording:end", {
|
|
68
|
+
file: filePath,
|
|
69
|
+
duration: estDuration,
|
|
70
|
+
size: stats.size
|
|
71
|
+
});
|
|
72
|
+
deps.emit("converted", {
|
|
73
|
+
input: result.file,
|
|
74
|
+
output: filePath
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
for (let i = 0; i < segmentFiles.length; i++) {
|
|
78
|
+
const fp = join(parsed.dir, segmentFiles[i]);
|
|
79
|
+
const segStart = recStartMs + i * segmentDurationSec * 1e3;
|
|
80
|
+
utimesSync(fp, new Date(segStart), new Date(segStart));
|
|
81
|
+
}
|
|
82
|
+
deps.emit("segmenting:end", { segments: segmentFiles.length });
|
|
83
|
+
if (deps.audioNormalizer) {
|
|
84
|
+
const normalizer = deps.audioNormalizer;
|
|
85
|
+
const normalizePromises = segmentFiles.map(async (segFile) => {
|
|
86
|
+
const segPath = join(parsed.dir, segFile);
|
|
87
|
+
try {
|
|
88
|
+
await normalizer.normalize(segPath);
|
|
89
|
+
} catch {}
|
|
90
|
+
});
|
|
91
|
+
deps.pendingRemuxes.push(...normalizePromises);
|
|
92
|
+
await Promise.allSettled(normalizePromises);
|
|
93
|
+
}
|
|
94
|
+
deps.setState({
|
|
95
|
+
currentFile: segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)) : result.file,
|
|
96
|
+
sessionDuration: result.duration
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (deps.stopRequested()) return;
|
|
100
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
101
|
+
deps.logger.error(`Segmenting failed: ${msg}`);
|
|
102
|
+
deps.setState({ lastError: `Segmenting failed: ${msg}` });
|
|
103
|
+
await runFallbackConversion(result, deps);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Convert a downloaded file to MP4 and optionally normalize audio.
|
|
108
|
+
* Shared by the direct conversion and fallback paths.
|
|
109
|
+
*
|
|
110
|
+
* @param emitRecordingEnd When true, also emit a `recording:end` event
|
|
111
|
+
* (used by the direct conversion path; the segmenting path emits its
|
|
112
|
+
* own `recording:end` events per segment).
|
|
113
|
+
*/
|
|
114
|
+
async function convertFile(result, deps, emitRecordingEnd) {
|
|
115
|
+
deps.emit("converting:start", { input: result.file });
|
|
116
|
+
try {
|
|
117
|
+
const convertPromise = deps.converter.convert(result.file);
|
|
118
|
+
deps.pendingRemuxes.push(convertPromise);
|
|
119
|
+
const mp4File = await convertPromise;
|
|
120
|
+
if (deps.audioNormalizer) {
|
|
121
|
+
const normalizePromise = deps.audioNormalizer.normalize(mp4File).catch(() => {});
|
|
122
|
+
deps.pendingRemuxes.push(normalizePromise);
|
|
123
|
+
await normalizePromise;
|
|
124
|
+
}
|
|
125
|
+
if (emitRecordingEnd) deps.emit("recording:end", {
|
|
126
|
+
file: mp4File,
|
|
127
|
+
duration: result.duration,
|
|
128
|
+
size: result.size
|
|
129
|
+
});
|
|
130
|
+
deps.emit("converted", {
|
|
131
|
+
input: result.file,
|
|
132
|
+
output: mp4File
|
|
133
|
+
});
|
|
134
|
+
} catch (convErr) {
|
|
135
|
+
const tkErr = convErr instanceof TikTokError ? convErr : new TikTokError("unknown", String(convErr));
|
|
136
|
+
deps.logger.error(`Conversion failed: ${tkErr.message}`);
|
|
137
|
+
deps.setState({ lastError: `Conversion failed: ${tkErr.message}` });
|
|
138
|
+
deps.emit("error", tkErr);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function processConversion(result, _cfg, deps) {
|
|
142
|
+
return convertFile(result, deps, true);
|
|
143
|
+
}
|
|
144
|
+
async function runFallbackConversion(result, deps) {
|
|
145
|
+
return convertFile(result, deps);
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
export { processRecording };
|
|
149
|
+
|
|
150
|
+
//# sourceMappingURL=post-processing.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"post-processing.mjs","names":[],"sources":["../../src/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 \"../config\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../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: any[]) => 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 if (result.size === 0) return\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]!)\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]!)\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: segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : 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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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region src/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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-events.mjs","names":[],"sources":["../../src/recorder/recorder-events.ts"],"sourcesContent":["/**\n * Typed event emitter for recorder lifecycle events.\n */\n\nimport type { RecorderEvent, RecorderEventHandler } from \"../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: any[]) => 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: any[]) => 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: any[]) => 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,IAA6C;CAElE,SAAS,GAA4B,OAAU,SAAwC;EACrF,MAAM,OAAO,SAAS,IAAI,KAAK,KAAK,CAAC;EACrC,KAAK,KAAK,OAAmC;EAC7C,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,OAAmC;EAC5D,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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-state.mjs","names":[],"sources":["../../src/recorder/recorder-state.ts"],"sourcesContent":["/**\n * State machine for the recorder lifecycle.\n */\n\nimport type { RecorderStatus } from \"../config\"\nimport type { Logger } from \"../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"}
|
package/dist/recorder/stream.mjs
CHANGED
|
@@ -1,314 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { join } from "node:path";
|
|
1
|
+
import { ensureDir } from "../utils.mjs";
|
|
2
|
+
import { downloadHls } from "./download-hls.mjs";
|
|
3
|
+
import { downloadFlv } from "./download-stream.mjs";
|
|
5
4
|
//#region src/recorder/stream.ts
|
|
6
|
-
/**
|
|
7
|
-
* Stream downloader — fetches a live stream and writes it to disk with buffering.
|
|
8
|
-
*
|
|
9
|
-
* FLV streams: downloaded via raw fetch() with byte buffering and transparent
|
|
10
|
-
* reconnection when TikTok stream segments end naturally.
|
|
11
|
-
* HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).
|
|
12
|
-
*/
|
|
13
5
|
function createStreamDownloader(logger) {
|
|
14
|
-
let abortFlag = false;
|
|
15
6
|
let abortController = new AbortController();
|
|
16
|
-
let streamReader = null;
|
|
17
|
-
let ffmpegProcess = null;
|
|
18
7
|
function abort() {
|
|
19
|
-
abortFlag = true;
|
|
20
8
|
abortController.abort();
|
|
21
|
-
streamReader?.cancel().catch(() => {});
|
|
22
|
-
if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill("SIGTERM");
|
|
23
9
|
}
|
|
24
|
-
/** Detect whether a URL is an HLS (.m3u8) playlist. */
|
|
25
10
|
function isHlsUrl(url) {
|
|
26
11
|
return url.includes(".m3u8");
|
|
27
12
|
}
|
|
28
|
-
async function openReader(url) {
|
|
29
|
-
const response = await fetch(url);
|
|
30
|
-
if (!response.ok || !response.body) throw new Error(`Failed to fetch stream: ${response.status}`);
|
|
31
|
-
return response.body.getReader();
|
|
32
|
-
}
|
|
33
|
-
async function downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl) {
|
|
34
|
-
const filename = formatFilename(user, "flv");
|
|
35
|
-
const filepath = join(outputDir, filename);
|
|
36
|
-
logger?.info(`Recording: ${filename}`);
|
|
37
|
-
const startTime = Date.now();
|
|
38
|
-
let totalBytes = 0;
|
|
39
|
-
let lastProgressTime = 0;
|
|
40
|
-
let reader;
|
|
41
|
-
try {
|
|
42
|
-
reader = await openReader(liveUrl);
|
|
43
|
-
} catch (err) {
|
|
44
|
-
throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`);
|
|
45
|
-
}
|
|
46
|
-
streamReader = reader;
|
|
47
|
-
const writer = createWriteStream(filepath);
|
|
48
|
-
function elapsed() {
|
|
49
|
-
return (Date.now() - startTime) / 1e3;
|
|
50
|
-
}
|
|
51
|
-
const bufferSize = 512 * 1024;
|
|
52
|
-
let buffer = Buffer.alloc(0);
|
|
53
|
-
try {
|
|
54
|
-
function reportProgress(bytes) {
|
|
55
|
-
if (!onProgress) return;
|
|
56
|
-
const now = Date.now();
|
|
57
|
-
const e = elapsed();
|
|
58
|
-
const speed = e > 0 ? bytes / e : 0;
|
|
59
|
-
if (now - lastProgressTime >= 1e3) {
|
|
60
|
-
lastProgressTime = now;
|
|
61
|
-
onProgress({
|
|
62
|
-
bytes,
|
|
63
|
-
elapsed: e,
|
|
64
|
-
speed
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
/** Try to reconnect to a fresh stream URL. Returns true if reconnected. */
|
|
69
|
-
async function tryReconnect() {
|
|
70
|
-
if (abortFlag) return false;
|
|
71
|
-
if (!getNextUrl) return false;
|
|
72
|
-
if (maxDuration > 0 && elapsed() >= maxDuration) return false;
|
|
73
|
-
logger?.info("Stream ended, attempting reconnection...");
|
|
74
|
-
const nextUrl = await getNextUrl();
|
|
75
|
-
if (!nextUrl) {
|
|
76
|
-
logger?.info("No new stream URL — recording finished");
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
logger?.info(`Reconnecting to new stream URL: ${nextUrl}`);
|
|
80
|
-
try {
|
|
81
|
-
reader.cancel().catch(() => {});
|
|
82
|
-
reader = await openReader(nextUrl);
|
|
83
|
-
streamReader = reader;
|
|
84
|
-
logger?.info("Reconnected successfully");
|
|
85
|
-
return true;
|
|
86
|
-
} catch (err) {
|
|
87
|
-
logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
/** Resolves if the abort signal fires — used to break out of read waits instantly. */
|
|
92
|
-
function waitForAbort() {
|
|
93
|
-
return new Promise((_, reject) => {
|
|
94
|
-
if (abortController.signal.aborted) {
|
|
95
|
-
reject(/* @__PURE__ */ new Error("Aborted"));
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
abortController.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("Aborted")), { once: true });
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
while (!abortFlag) {
|
|
102
|
-
let chunk;
|
|
103
|
-
try {
|
|
104
|
-
chunk = await Promise.race([timeout(reader.read(), 6e4, "Stream read timed out"), waitForAbort()]);
|
|
105
|
-
} catch (_err) {
|
|
106
|
-
if (abortFlag) break;
|
|
107
|
-
logger?.info("Stream ended or timed out, attempting reconnection...");
|
|
108
|
-
if (await tryReconnect()) continue;
|
|
109
|
-
break;
|
|
110
|
-
}
|
|
111
|
-
if (chunk.done) {
|
|
112
|
-
if (await tryReconnect()) continue;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
buffer = Buffer.concat([buffer, Buffer.from(chunk.value)]);
|
|
116
|
-
if (buffer.length >= bufferSize) {
|
|
117
|
-
await writeBuffer(writer, buffer);
|
|
118
|
-
totalBytes += buffer.length;
|
|
119
|
-
buffer = Buffer.alloc(0);
|
|
120
|
-
}
|
|
121
|
-
if (maxDuration > 0) {
|
|
122
|
-
if (elapsed() >= maxDuration) {
|
|
123
|
-
logger?.info(`Duration limit reached (${maxDuration}s)`);
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
reportProgress(totalBytes);
|
|
128
|
-
}
|
|
129
|
-
if (buffer.length > 0) {
|
|
130
|
-
await writeBuffer(writer, buffer);
|
|
131
|
-
totalBytes += buffer.length;
|
|
132
|
-
}
|
|
133
|
-
await new Promise((resolve) => writer.end(resolve));
|
|
134
|
-
const duration = elapsed();
|
|
135
|
-
if (onProgress) onProgress({
|
|
136
|
-
bytes: totalBytes,
|
|
137
|
-
elapsed: duration,
|
|
138
|
-
speed: duration > 0 ? totalBytes / duration : 0
|
|
139
|
-
});
|
|
140
|
-
logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`);
|
|
141
|
-
return {
|
|
142
|
-
file: filepath,
|
|
143
|
-
duration,
|
|
144
|
-
size: totalBytes
|
|
145
|
-
};
|
|
146
|
-
} catch (err) {
|
|
147
|
-
abort();
|
|
148
|
-
try {
|
|
149
|
-
if (buffer.length > 0) {
|
|
150
|
-
await writeBuffer(writer, buffer);
|
|
151
|
-
totalBytes += buffer.length;
|
|
152
|
-
}
|
|
153
|
-
await new Promise((resolve) => writer.end(resolve));
|
|
154
|
-
} catch {}
|
|
155
|
-
const duration = elapsed();
|
|
156
|
-
logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`);
|
|
157
|
-
return {
|
|
158
|
-
file: filepath,
|
|
159
|
-
duration,
|
|
160
|
-
size: totalBytes
|
|
161
|
-
};
|
|
162
|
-
} finally {
|
|
163
|
-
streamReader = null;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
async function downloadHls(liveUrl, user, outputDir, maxDuration, onProgress) {
|
|
167
|
-
const filename = formatFilename(user, "ts");
|
|
168
|
-
const filepath = join(outputDir, filename);
|
|
169
|
-
logger?.info(`Recording (HLS): ${filename}`);
|
|
170
|
-
const startTime = Date.now();
|
|
171
|
-
let lastProgressTime = 0;
|
|
172
|
-
const ffmpegPath = findFfmpegPath();
|
|
173
|
-
if (!ffmpegPath) throw new Error("FFmpeg not found — required for HLS streams. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
|
|
174
|
-
const args = [
|
|
175
|
-
"-i",
|
|
176
|
-
liveUrl,
|
|
177
|
-
"-c",
|
|
178
|
-
"copy",
|
|
179
|
-
"-y",
|
|
180
|
-
filepath
|
|
181
|
-
];
|
|
182
|
-
return new Promise((resolve, reject) => {
|
|
183
|
-
const proc = spawn(ffmpegPath, args, { stdio: [
|
|
184
|
-
"ignore",
|
|
185
|
-
"pipe",
|
|
186
|
-
"pipe"
|
|
187
|
-
] });
|
|
188
|
-
ffmpegProcess = proc;
|
|
189
|
-
let stderr = "";
|
|
190
|
-
proc.stderr?.on("data", (chunk) => {
|
|
191
|
-
stderr += chunk.toString();
|
|
192
|
-
});
|
|
193
|
-
const progressTimer = setInterval(() => {
|
|
194
|
-
if (onProgress) try {
|
|
195
|
-
const stats = statSync(filepath);
|
|
196
|
-
const elapsed = (Date.now() - startTime) / 1e3;
|
|
197
|
-
const speed = elapsed > 0 ? stats.size / elapsed : 0;
|
|
198
|
-
const now = Date.now();
|
|
199
|
-
if (now - lastProgressTime >= 1e3) {
|
|
200
|
-
lastProgressTime = now;
|
|
201
|
-
onProgress({
|
|
202
|
-
bytes: stats.size,
|
|
203
|
-
elapsed,
|
|
204
|
-
speed
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
} catch {}
|
|
208
|
-
}, 1e3);
|
|
209
|
-
let maxDurationTimer = null;
|
|
210
|
-
if (maxDuration > 0) maxDurationTimer = setTimeout(() => {
|
|
211
|
-
logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`);
|
|
212
|
-
if (!proc.killed) proc.kill("SIGTERM");
|
|
213
|
-
}, maxDuration * 1e3);
|
|
214
|
-
proc.on("close", (code) => {
|
|
215
|
-
clearInterval(progressTimer);
|
|
216
|
-
if (maxDurationTimer) clearTimeout(maxDurationTimer);
|
|
217
|
-
ffmpegProcess = null;
|
|
218
|
-
if (abortFlag) {
|
|
219
|
-
try {
|
|
220
|
-
const stats = statSync(filepath);
|
|
221
|
-
const duration = (Date.now() - startTime) / 1e3;
|
|
222
|
-
logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`);
|
|
223
|
-
resolve({
|
|
224
|
-
file: filepath,
|
|
225
|
-
duration,
|
|
226
|
-
size: stats.size
|
|
227
|
-
});
|
|
228
|
-
} catch {
|
|
229
|
-
resolve({
|
|
230
|
-
file: filepath,
|
|
231
|
-
duration: 0,
|
|
232
|
-
size: 0
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
try {
|
|
238
|
-
const stats = statSync(filepath);
|
|
239
|
-
const duration = (Date.now() - startTime) / 1e3;
|
|
240
|
-
if (onProgress) onProgress({
|
|
241
|
-
bytes: stats.size,
|
|
242
|
-
elapsed: duration,
|
|
243
|
-
speed: duration > 0 ? stats.size / duration : 0
|
|
244
|
-
});
|
|
245
|
-
logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`);
|
|
246
|
-
resolve({
|
|
247
|
-
file: filepath,
|
|
248
|
-
duration,
|
|
249
|
-
size: stats.size
|
|
250
|
-
});
|
|
251
|
-
} catch {
|
|
252
|
-
const msg = `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`;
|
|
253
|
-
reject(new Error(msg));
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
proc.on("error", (err) => {
|
|
257
|
-
clearInterval(progressTimer);
|
|
258
|
-
if (maxDurationTimer) clearTimeout(maxDurationTimer);
|
|
259
|
-
ffmpegProcess = null;
|
|
260
|
-
reject(/* @__PURE__ */ new Error(`Failed to spawn FFmpeg: ${err.message}`));
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
13
|
async function download(liveUrl, user, outputDir, maxDuration = 0, onProgress, getNextUrl) {
|
|
265
|
-
abortFlag = false;
|
|
266
14
|
abortController = new AbortController();
|
|
267
15
|
ensureDir(outputDir);
|
|
268
|
-
if (isHlsUrl(liveUrl)) return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress);
|
|
269
|
-
return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl);
|
|
16
|
+
if (isHlsUrl(liveUrl)) return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, abortController.signal, logger);
|
|
17
|
+
return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, abortController.signal, logger);
|
|
270
18
|
}
|
|
271
19
|
return {
|
|
272
20
|
download,
|
|
273
21
|
abort
|
|
274
22
|
};
|
|
275
23
|
}
|
|
276
|
-
/** Find FFmpeg binary via Bun.which() or PATH search. */
|
|
277
|
-
function findFfmpegPath() {
|
|
278
|
-
const bunWhich = Bun?.which;
|
|
279
|
-
if (typeof bunWhich === "function") return bunWhich("ffmpeg") ?? null;
|
|
280
|
-
const paths = process.env.PATH?.split(":") ?? [];
|
|
281
|
-
for (const dir of paths) try {
|
|
282
|
-
const full = `${dir}/ffmpeg`;
|
|
283
|
-
statSync(full);
|
|
284
|
-
return full;
|
|
285
|
-
} catch {}
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
function writeBuffer(writer, buffer) {
|
|
289
|
-
return new Promise((resolve, reject) => {
|
|
290
|
-
writer.write(buffer, (err) => {
|
|
291
|
-
if (err) reject(err);
|
|
292
|
-
else resolve();
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
function formatDuration(seconds) {
|
|
297
|
-
return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
|
|
298
|
-
}
|
|
299
|
-
/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */
|
|
300
|
-
function timeout(promise, ms, label) {
|
|
301
|
-
return new Promise((resolve, reject) => {
|
|
302
|
-
const timer = setTimeout(() => reject(new Error(label)), ms);
|
|
303
|
-
promise.then((result) => {
|
|
304
|
-
clearTimeout(timer);
|
|
305
|
-
resolve(result);
|
|
306
|
-
}, (err) => {
|
|
307
|
-
clearTimeout(timer);
|
|
308
|
-
reject(err);
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
24
|
//#endregion
|
|
313
25
|
export { createStreamDownloader };
|
|
314
26
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a live stream and writes it to disk with buffering.\n *\n * FLV streams: downloaded via raw fetch() with byte buffering and transparent\n * reconnection when TikTok stream segments end naturally.\n * HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport { createWriteStream, statSync, type WriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\"\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 ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortFlag = false\n let abortController = new AbortController()\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n let ffmpegProcess: ChildProcess | null = null\n\n function abort(): void {\n abortFlag = true\n abortController.abort()\n streamReader?.cancel().catch(() => {})\n if (ffmpegProcess && !ffmpegProcess.killed) {\n ffmpegProcess.kill(\"SIGTERM\")\n }\n }\n\n /** Detect whether a URL is an HLS (.m3u8) playlist. */\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n // ─── FLV downloader (existing fetch-based path) ────────────────\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function downloadFlv(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"flv\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n try {\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info(\"Stream ended, attempting reconnection...\")\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info(\"Reconnected successfully\")\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n /** Resolves if the abort signal fires — used to break out of read waits instantly. */\n function waitForAbort(): Promise<never> {\n return new Promise((_, reject) => {\n if (abortController.signal.aborted) {\n reject(new Error(\"Aborted\"))\n return\n }\n abortController.signal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")), {\n once: true,\n })\n })\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await Promise.race([\n timeout(reader.read(), 60_000, \"Stream read timed out\"),\n waitForAbort(),\n ])\n chunk = result\n } catch (_err) {\n if (abortFlag) break\n logger?.info(\"Stream ended or timed out, attempting reconnection...\")\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n if (chunk.done) {\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n reportProgress(totalBytes)\n }\n\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n // Flush remaining buffer and close the write stream so the partial file is valid\n try {\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n } catch {\n // best effort — file may be truncated but readable\n }\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n // ─── HLS downloader (FFmpeg-based path) ───────────────────────\n\n async function downloadHls(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording (HLS): ${filename}`)\n\n const startTime = Date.now()\n let lastProgressTime = 0\n\n // Find FFmpeg\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found — required for HLS streams. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n // Spawn FFmpeg with the M3U8 URL as input.\n // FFmpeg handles downloading all .ts segments and concatenating them.\n const args = [\"-i\", liveUrl, \"-c\", \"copy\", \"-y\", filepath]\n\n return new Promise<DownloadResult>((resolve, reject) => {\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n ffmpegProcess = proc\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n // Poll output file size for progress reporting\n const progressTimer = setInterval(() => {\n if (onProgress) {\n try {\n const stats = statSync(filepath)\n const elapsed = (Date.now() - startTime) / 1000\n const speed = elapsed > 0 ? stats.size / elapsed : 0\n const now = Date.now()\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes: stats.size, elapsed, speed })\n }\n } catch {\n // File not created yet (FFmpeg still initializing) — skip\n }\n }\n }, 1000)\n\n // Max duration timer\n let maxDurationTimer: ReturnType<typeof setTimeout> | null = null\n if (maxDuration > 0) {\n maxDurationTimer = setTimeout(() => {\n logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`)\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }, maxDuration * 1000)\n }\n\n proc.on(\"close\", (code) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n\n if (abortFlag) {\n // Aborted by user — return whatever we have\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n resolve({ file: filepath, duration: 0, size: 0 })\n }\n return\n }\n\n // Normal completion or error\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n if (onProgress) {\n onProgress({\n bytes: stats.size,\n elapsed: duration,\n speed: duration > 0 ? stats.size / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n const msg = `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`\n reject(new Error(msg))\n }\n })\n\n proc.on(\"error\", (err) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n // ─── Main download entry point ──────────────────────────────\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 ): Promise<DownloadResult> {\n abortFlag = false\n abortController = new AbortController()\n ensureDir(outputDir)\n\n if (isHlsUrl(liveUrl)) {\n // HLS: FFmpeg handles playlist + segments natively.\n // Reconnection is not needed (FFmpeg refreshes the playlist internally).\n return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress)\n }\n\n // FLV: existing fetch-based path with reconnection support\n return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl)\n }\n\n return { download, abort }\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nfunction findFfmpegPath(): string | null {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n return (bunWhich(\"ffmpeg\") as string) ?? null\n }\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // not found in this directory\n }\n }\n return null\n}\n\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/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 \"../logger\"\nimport { ensureDir } from \"../utils\"\nimport { downloadHls } from \"./download-hls\"\nimport { downloadFlv } from \"./download-stream\"\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 ) => 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 ): Promise<DownloadResult> {\n abortController = new AbortController()\n ensureDir(outputDir)\n\n if (isHlsUrl(liveUrl)) {\n return downloadHls(\n liveUrl,\n user,\n outputDir,\n maxDuration,\n onProgress,\n getNextUrl,\n abortController.signal,\n logger,\n )\n }\n\n return downloadFlv(\n liveUrl,\n user,\n outputDir,\n maxDuration,\n onProgress,\n getNextUrl,\n abortController.signal,\n logger,\n )\n }\n\n return { download, abort }\n}\n"],"mappings":";;;;AAwCA,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,YACyB;EACzB,kBAAkB,IAAI,gBAAgB;EACtC,UAAU,SAAS;EAEnB,IAAI,SAAS,OAAO,GAClB,OAAO,YACL,SACA,MACA,WACA,aACA,YACA,YACA,gBAAgB,QAChB,MACF;EAGF,OAAO,YACL,SACA,MACA,WACA,aACA,YACA,YACA,gBAAgB,QAChB,MACF;CACF;CAEA,OAAO;EAAE;EAAU;CAAM;AAC3B"}
|