@torrent-tv/proxy 1.0.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/.dockerignore +5 -0
- package/Dockerfile +30 -0
- package/LICENSE +16 -0
- package/README.md +230 -0
- package/bin/cli.js +180 -0
- package/package.json +31 -0
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -0
- package/routes/api/playback-plan/post.js +31 -0
- package/routes/api/sources/post.js +18 -0
- package/routes/api/transcode-sessions/post.js +39 -0
- package/routes/api/transcode-sessions/progress/get.js +13 -0
- package/routes/api/transcode-sessions/release/post.js +22 -0
- package/routes/health/get.js +3 -0
- package/routes/healthz/get.js +3 -0
- package/routes/stream/get.js +76 -0
- package/routes/transcode/session-file/get.js +42 -0
- package/server.js +108 -0
- package/services/hls-session-manager.js +570 -0
- package/services/playback-planner.js +130 -0
- package/services/registry-api.js +43 -0
- package/services/torrent-pool.js +123 -0
- package/store/source-registry.js +32 -0
- package/utils/parse-range.js +12 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { access, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
9
|
+
const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
|
|
10
|
+
const CLEANUP_INTERVAL_MS = 60_000;
|
|
11
|
+
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
12
|
+
const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
13
|
+
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
14
|
+
const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
15
|
+
const PROGRESS_LOG_INTERVAL_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
function delay(ms) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
setTimeout(resolve, ms);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function waitForChildExit(child, timeoutMs = 2_000) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let settled = false;
|
|
26
|
+
const finish = () => {
|
|
27
|
+
if (settled) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
settled = true;
|
|
31
|
+
resolve();
|
|
32
|
+
};
|
|
33
|
+
child.once("exit", finish);
|
|
34
|
+
setTimeout(finish, timeoutMs);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toLoopbackHost(host) {
|
|
39
|
+
if (host === "0.0.0.0" || host === "::") {
|
|
40
|
+
return "127.0.0.1";
|
|
41
|
+
}
|
|
42
|
+
return host;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildHttpBaseUrl(host, port) {
|
|
46
|
+
const url = new URL("http://localhost");
|
|
47
|
+
url.hostname = toLoopbackHost(host);
|
|
48
|
+
url.port = String(port);
|
|
49
|
+
return url.origin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createSessionDirPath(sessionId) {
|
|
53
|
+
return path.join(os.tmpdir(), "torrent-tv-hls", sessionId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSafeSessionId(value) {
|
|
57
|
+
return /^[a-f0-9-]{36}$/i.test(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSafeFileName(fileName) {
|
|
61
|
+
return fileName === PLAYLIST_FILE_NAME || SEGMENT_FILE_NAME_PATTERN.test(fileName);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseFfmpegTimestamp(value) {
|
|
65
|
+
if (!value || typeof value !== "string") {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const parts = value.split(":");
|
|
69
|
+
if (parts.length !== 3) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const hours = Number(parts[0]);
|
|
73
|
+
const minutes = Number(parts[1]);
|
|
74
|
+
const seconds = Number(parts[2]);
|
|
75
|
+
if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return hours * 3600 + minutes * 60 + seconds;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseFfmpegDurationSeconds(stderrText) {
|
|
82
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const match = stderrText.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
|
|
86
|
+
if (!match) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const hours = Number(match[1]);
|
|
90
|
+
const minutes = Number(match[2]);
|
|
91
|
+
const seconds = Number(match[3]);
|
|
92
|
+
if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return hours * 3600 + minutes * 60 + seconds;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatSeconds(seconds) {
|
|
99
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
100
|
+
return "n/a";
|
|
101
|
+
}
|
|
102
|
+
const total = Math.floor(seconds);
|
|
103
|
+
const hours = Math.floor(total / 3600);
|
|
104
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
105
|
+
const rest = total % 60;
|
|
106
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function computeProgressMetrics(processedSeconds, totalSeconds) {
|
|
110
|
+
const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
|
|
111
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
|
112
|
+
return { totalSeconds: null, percent: null, remainingSeconds: null, processedSeconds: processed };
|
|
113
|
+
}
|
|
114
|
+
const safeTotal = totalSeconds;
|
|
115
|
+
const percent = Math.max(0, Math.min(100, (processed / safeTotal) * 100));
|
|
116
|
+
const remainingSeconds = Math.max(0, safeTotal - processed);
|
|
117
|
+
return {
|
|
118
|
+
totalSeconds: safeTotal,
|
|
119
|
+
percent,
|
|
120
|
+
remainingSeconds,
|
|
121
|
+
processedSeconds: processed
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function probeInputDurationSeconds(ffmpegBin, inputUrl) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
|
|
128
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
129
|
+
windowsHide: true
|
|
130
|
+
});
|
|
131
|
+
let stderr = "";
|
|
132
|
+
let settled = false;
|
|
133
|
+
const finish = (value) => {
|
|
134
|
+
if (settled) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
settled = true;
|
|
138
|
+
resolve(value);
|
|
139
|
+
};
|
|
140
|
+
const timeoutId = setTimeout(() => {
|
|
141
|
+
if (!ffmpeg.killed) {
|
|
142
|
+
ffmpeg.kill("SIGTERM");
|
|
143
|
+
}
|
|
144
|
+
finish(parseFfmpegDurationSeconds(stderr));
|
|
145
|
+
}, 8_000);
|
|
146
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
147
|
+
stderr += String(chunk);
|
|
148
|
+
});
|
|
149
|
+
ffmpeg.on("error", () => {
|
|
150
|
+
clearTimeout(timeoutId);
|
|
151
|
+
finish(null);
|
|
152
|
+
});
|
|
153
|
+
ffmpeg.on("exit", () => {
|
|
154
|
+
clearTimeout(timeoutId);
|
|
155
|
+
finish(parseFfmpegDurationSeconds(stderr));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isWarmupTimeoutError(error) {
|
|
161
|
+
if (!(error instanceof Error)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return error.message === "HLS playlist is still warming up.";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeLogFileName(fileName, fileIndex) {
|
|
168
|
+
const fallback = `file#${fileIndex}`;
|
|
169
|
+
if (typeof fileName !== "string") {
|
|
170
|
+
return fallback;
|
|
171
|
+
}
|
|
172
|
+
const value = fileName.trim();
|
|
173
|
+
if (value.length === 0) {
|
|
174
|
+
return fallback;
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class HlsSessionManager {
|
|
180
|
+
constructor({
|
|
181
|
+
enabled,
|
|
182
|
+
ffmpegBin,
|
|
183
|
+
localBindHost,
|
|
184
|
+
localPort,
|
|
185
|
+
segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
|
|
186
|
+
sessionTtlMs = DEFAULT_SESSION_TTL_MS,
|
|
187
|
+
startupWaitMs = DEFAULT_STARTUP_WAIT_MS
|
|
188
|
+
}) {
|
|
189
|
+
this.enabled = Boolean(enabled);
|
|
190
|
+
this.ffmpegBin = ffmpegBin;
|
|
191
|
+
this.segmentDurationSec = segmentDurationSec;
|
|
192
|
+
this.sessionTtlMs = sessionTtlMs;
|
|
193
|
+
this.startupWaitMs = startupWaitMs;
|
|
194
|
+
this.localBaseUrl = buildHttpBaseUrl(localBindHost, localPort);
|
|
195
|
+
this.sessionsById = new Map();
|
|
196
|
+
this.sessionIdBySource = new Map();
|
|
197
|
+
this.cleanupTimer = setInterval(() => {
|
|
198
|
+
void this.cleanupExpired();
|
|
199
|
+
}, CLEANUP_INTERVAL_MS);
|
|
200
|
+
this.cleanupTimer.unref();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async createOrGetSession({ sourceKey, fileIndex, transcodeVideo = false, consumerId = "", fileName = "" }) {
|
|
204
|
+
if (!this.enabled) {
|
|
205
|
+
const error = new Error("Audio transcoding is disabled on this proxy.");
|
|
206
|
+
error.code = "TRANSCODE_DISABLED";
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sourceMapKey = `${sourceKey}:${fileIndex}:${transcodeVideo ? "video" : "audio"}`;
|
|
211
|
+
const existingId = this.sessionIdBySource.get(sourceMapKey);
|
|
212
|
+
if (existingId) {
|
|
213
|
+
const existing = this.sessionsById.get(existingId);
|
|
214
|
+
if (existing && existing.state !== "failed") {
|
|
215
|
+
existing.fileName = normalizeLogFileName(fileName, fileIndex);
|
|
216
|
+
if (consumerId) {
|
|
217
|
+
existing.consumers.add(consumerId);
|
|
218
|
+
}
|
|
219
|
+
existing.lastAccessedAt = Date.now();
|
|
220
|
+
try {
|
|
221
|
+
await this.waitUntilReady(existing);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (!isWarmupTimeoutError(error)) {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
// Keep session reusable while ffmpeg is still warming up.
|
|
227
|
+
}
|
|
228
|
+
return existing;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sessionId = randomUUID();
|
|
233
|
+
const sessionDir = createSessionDirPath(sessionId);
|
|
234
|
+
await mkdir(sessionDir, { recursive: true });
|
|
235
|
+
const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
|
|
236
|
+
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
237
|
+
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
238
|
+
const durationSeconds = await probeInputDurationSeconds(this.ffmpegBin, inputUrl.toString());
|
|
239
|
+
|
|
240
|
+
const videoCodecArgs = transcodeVideo
|
|
241
|
+
? ["-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"]
|
|
242
|
+
: ["-c:v", "copy"];
|
|
243
|
+
|
|
244
|
+
const args = [
|
|
245
|
+
"-hide_banner",
|
|
246
|
+
"-nostats",
|
|
247
|
+
"-loglevel",
|
|
248
|
+
"error",
|
|
249
|
+
"-progress",
|
|
250
|
+
"pipe:1",
|
|
251
|
+
"-i",
|
|
252
|
+
inputUrl.toString(),
|
|
253
|
+
"-map",
|
|
254
|
+
"0:v:0?",
|
|
255
|
+
"-map",
|
|
256
|
+
"0:a:0?",
|
|
257
|
+
...videoCodecArgs,
|
|
258
|
+
"-c:a",
|
|
259
|
+
"aac",
|
|
260
|
+
"-ac",
|
|
261
|
+
"2",
|
|
262
|
+
"-b:a",
|
|
263
|
+
"160k",
|
|
264
|
+
"-f",
|
|
265
|
+
"hls",
|
|
266
|
+
"-hls_time",
|
|
267
|
+
String(this.segmentDurationSec),
|
|
268
|
+
"-hls_list_size",
|
|
269
|
+
"0",
|
|
270
|
+
"-hls_playlist_type",
|
|
271
|
+
"vod",
|
|
272
|
+
"-hls_flags",
|
|
273
|
+
"independent_segments+temp_file",
|
|
274
|
+
"-hls_segment_filename",
|
|
275
|
+
"segment-%05d.ts",
|
|
276
|
+
PLAYLIST_FILE_NAME
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
280
|
+
cwd: sessionDir,
|
|
281
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const session = {
|
|
285
|
+
id: sessionId,
|
|
286
|
+
sourceMapKey,
|
|
287
|
+
fileName: normalizeLogFileName(fileName, fileIndex),
|
|
288
|
+
dirPath: sessionDir,
|
|
289
|
+
state: "starting",
|
|
290
|
+
startedAt: Date.now(),
|
|
291
|
+
lastAccessedAt: Date.now(),
|
|
292
|
+
ffmpeg,
|
|
293
|
+
lastError: "",
|
|
294
|
+
consumers: new Set(consumerId ? [consumerId] : []),
|
|
295
|
+
progress: {
|
|
296
|
+
state: "starting",
|
|
297
|
+
processedSeconds: 0,
|
|
298
|
+
totalSeconds: Number.isFinite(durationSeconds) ? durationSeconds : null,
|
|
299
|
+
percent: null,
|
|
300
|
+
remainingSeconds: Number.isFinite(durationSeconds) ? durationSeconds : null,
|
|
301
|
+
speed: "",
|
|
302
|
+
updatedAt: Date.now(),
|
|
303
|
+
lastLoggedAt: 0
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
this.sessionsById.set(sessionId, session);
|
|
307
|
+
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
308
|
+
|
|
309
|
+
ffmpeg.stdout.on("data", (chunk) => {
|
|
310
|
+
const lines = String(chunk).split(/\r?\n/);
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
const normalized = line.trim();
|
|
313
|
+
if (!normalized) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const separator = normalized.indexOf("=");
|
|
317
|
+
if (separator <= 0) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const key = normalized.slice(0, separator);
|
|
321
|
+
const value = normalized.slice(separator + 1);
|
|
322
|
+
|
|
323
|
+
if (key === "out_time_ms") {
|
|
324
|
+
const numeric = Number(value);
|
|
325
|
+
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
326
|
+
session.progress.processedSeconds = numeric / MICROSECONDS_PER_SECOND;
|
|
327
|
+
}
|
|
328
|
+
} else if (key === "out_time") {
|
|
329
|
+
const parsed = parseFfmpegTimestamp(value);
|
|
330
|
+
if (parsed != null) {
|
|
331
|
+
session.progress.processedSeconds = parsed;
|
|
332
|
+
}
|
|
333
|
+
} else if (key === "speed") {
|
|
334
|
+
session.progress.speed = value;
|
|
335
|
+
} else if (key === "progress") {
|
|
336
|
+
session.progress.state = value === "end" ? "ready" : "running";
|
|
337
|
+
}
|
|
338
|
+
const metrics = computeProgressMetrics(
|
|
339
|
+
session.progress.processedSeconds,
|
|
340
|
+
session.progress.totalSeconds
|
|
341
|
+
);
|
|
342
|
+
session.progress.percent = metrics.percent;
|
|
343
|
+
session.progress.remainingSeconds = metrics.remainingSeconds;
|
|
344
|
+
session.progress.updatedAt = Date.now();
|
|
345
|
+
const shouldLog =
|
|
346
|
+
session.progress.percent != null &&
|
|
347
|
+
session.progress.updatedAt - session.progress.lastLoggedAt >= PROGRESS_LOG_INTERVAL_MS;
|
|
348
|
+
if (shouldLog) {
|
|
349
|
+
session.progress.lastLoggedAt = session.progress.updatedAt;
|
|
350
|
+
console.log(
|
|
351
|
+
`[proxy-client] transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
|
|
352
|
+
`(${formatSeconds(session.progress.processedSeconds)} / ${formatSeconds(session.progress.totalSeconds)})` +
|
|
353
|
+
` speed=${session.progress.speed || "n/a"}`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
360
|
+
const line = String(chunk).trim();
|
|
361
|
+
if (line.length > 0) {
|
|
362
|
+
session.lastError = line;
|
|
363
|
+
console.warn(`[proxy-client] ffmpeg: ${line}`);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
ffmpeg.on("error", (error) => {
|
|
368
|
+
session.state = "failed";
|
|
369
|
+
session.lastError = error instanceof Error ? error.message : String(error);
|
|
370
|
+
session.progress.state = "failed";
|
|
371
|
+
session.progress.updatedAt = Date.now();
|
|
372
|
+
console.error(`[proxy-client] ffmpeg process error: ${session.lastError}`);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
ffmpeg.on("exit", (code) => {
|
|
376
|
+
if (session.state === "disposed") {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (code === 0) {
|
|
380
|
+
session.state = "ready";
|
|
381
|
+
session.progress.state = "ready";
|
|
382
|
+
session.progress.updatedAt = Date.now();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
session.state = "failed";
|
|
386
|
+
session.progress.state = "failed";
|
|
387
|
+
session.progress.updatedAt = Date.now();
|
|
388
|
+
if (!session.lastError) {
|
|
389
|
+
session.lastError = `ffmpeg exited with code ${code ?? -1}`;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
await this.waitUntilReady(session);
|
|
395
|
+
return session;
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (session.state === "failed") {
|
|
398
|
+
await this.disposeSession(session.id);
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
// Do not fail session creation on warmup timeout; playlist can appear later.
|
|
402
|
+
return session;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async waitUntilReady(session) {
|
|
407
|
+
const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
|
|
408
|
+
const deadline = Date.now() + this.startupWaitMs;
|
|
409
|
+
|
|
410
|
+
while (Date.now() < deadline) {
|
|
411
|
+
if (session.state === "failed") {
|
|
412
|
+
throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
await access(playlistPath);
|
|
416
|
+
const text = await readFile(playlistPath, "utf8");
|
|
417
|
+
if (text.includes("#EXTM3U")) {
|
|
418
|
+
session.state = "ready";
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
} catch (_error) {
|
|
422
|
+
// Playlist is not ready yet.
|
|
423
|
+
}
|
|
424
|
+
await delay(250);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
throw new Error("HLS playlist is still warming up.");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async getFileStream(sessionId, fileName) {
|
|
431
|
+
if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName)) {
|
|
432
|
+
return { kind: "not-found" };
|
|
433
|
+
}
|
|
434
|
+
const session = this.sessionsById.get(sessionId);
|
|
435
|
+
if (!session) {
|
|
436
|
+
return { kind: "not-found" };
|
|
437
|
+
}
|
|
438
|
+
if (session.state === "failed") {
|
|
439
|
+
return {
|
|
440
|
+
kind: "failed",
|
|
441
|
+
message: session.lastError || "ffmpeg failed for this transcode session."
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
session.lastAccessedAt = Date.now();
|
|
445
|
+
const filePath = path.join(session.dirPath, fileName);
|
|
446
|
+
try {
|
|
447
|
+
await access(filePath);
|
|
448
|
+
} catch (_error) {
|
|
449
|
+
return { kind: "warming-up" };
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
kind: "file",
|
|
453
|
+
stream: createReadStream(filePath),
|
|
454
|
+
contentType:
|
|
455
|
+
fileName === PLAYLIST_FILE_NAME
|
|
456
|
+
? "application/vnd.apple.mpegurl"
|
|
457
|
+
: "video/mp2t",
|
|
458
|
+
isPlaylist: fileName === PLAYLIST_FILE_NAME
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async cleanupExpired() {
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
const idsToDispose = [];
|
|
465
|
+
for (const [sessionId, session] of this.sessionsById.entries()) {
|
|
466
|
+
if (now - session.lastAccessedAt > this.sessionTtlMs) {
|
|
467
|
+
idsToDispose.push(sessionId);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
for (const sessionId of idsToDispose) {
|
|
471
|
+
await this.disposeSession(sessionId);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
getSessionProgress(sessionId) {
|
|
476
|
+
if (!isSafeSessionId(sessionId)) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const session = this.sessionsById.get(sessionId);
|
|
480
|
+
if (!session) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
session.lastAccessedAt = Date.now();
|
|
484
|
+
const warmupTotalSeconds = this.startupWaitMs / 1000;
|
|
485
|
+
const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
|
|
486
|
+
const isWarmupPhase = session.state === "starting" || session.progress.state === "starting";
|
|
487
|
+
const warmupPercent = isWarmupPhase
|
|
488
|
+
? Math.max(0, Math.min(100, (warmupElapsedSeconds / warmupTotalSeconds) * 100))
|
|
489
|
+
: null;
|
|
490
|
+
const warmupRemainingSeconds = isWarmupPhase
|
|
491
|
+
? Math.max(0, warmupTotalSeconds - warmupElapsedSeconds)
|
|
492
|
+
: null;
|
|
493
|
+
return {
|
|
494
|
+
sessionId: session.id,
|
|
495
|
+
state: session.progress.state,
|
|
496
|
+
processedSeconds: session.progress.processedSeconds,
|
|
497
|
+
totalSeconds: session.progress.totalSeconds,
|
|
498
|
+
percent: session.progress.percent,
|
|
499
|
+
remainingSeconds: session.progress.remainingSeconds,
|
|
500
|
+
warmupPercent,
|
|
501
|
+
warmupRemainingSeconds,
|
|
502
|
+
speed: session.progress.speed,
|
|
503
|
+
updatedAt: session.progress.updatedAt,
|
|
504
|
+
error: session.state === "failed" ? session.lastError : ""
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
|
|
509
|
+
if (!isSafeSessionId(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
const session = this.sessionsById.get(sessionId);
|
|
513
|
+
if (!session) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
if (!(session.consumers instanceof Set)) {
|
|
517
|
+
session.consumers = new Set();
|
|
518
|
+
}
|
|
519
|
+
session.consumers.delete(consumerId);
|
|
520
|
+
session.lastAccessedAt = Date.now();
|
|
521
|
+
const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
|
|
522
|
+
console.log(
|
|
523
|
+
`[proxy-client] consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
|
|
524
|
+
`remaining=${session.consumers.size}`
|
|
525
|
+
);
|
|
526
|
+
if (session.consumers.size > 0) {
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
await this.disposeSession(sessionId);
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async disposeSession(sessionId) {
|
|
534
|
+
const session = this.sessionsById.get(sessionId);
|
|
535
|
+
if (!session) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
session.state = "disposed";
|
|
539
|
+
this.sessionsById.delete(sessionId);
|
|
540
|
+
this.sessionIdBySource.delete(session.sourceMapKey);
|
|
541
|
+
|
|
542
|
+
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
543
|
+
session.ffmpeg.kill("SIGTERM");
|
|
544
|
+
await waitForChildExit(session.ffmpeg);
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
await rm(session.dirPath, { recursive: true, force: true });
|
|
548
|
+
} catch (error) {
|
|
549
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
550
|
+
console.warn(`[proxy-client] failed to cleanup HLS temp dir: ${message}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async disposeAll() {
|
|
555
|
+
clearInterval(this.cleanupTimer);
|
|
556
|
+
const activeIds = Array.from(this.sessionsById.keys());
|
|
557
|
+
for (const sessionId of activeIds) {
|
|
558
|
+
await this.disposeSession(sessionId);
|
|
559
|
+
}
|
|
560
|
+
const rootDir = path.join(os.tmpdir(), "torrent-tv-hls");
|
|
561
|
+
try {
|
|
562
|
+
const dirs = await readdir(rootDir);
|
|
563
|
+
if (dirs.length === 0) {
|
|
564
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
565
|
+
}
|
|
566
|
+
} catch (_error) {
|
|
567
|
+
// Best effort cleanup.
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
|
|
4
|
+
|
|
5
|
+
function parseStreamCodecs(ffmpegOutput) {
|
|
6
|
+
const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
|
|
7
|
+
const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
|
|
8
|
+
return {
|
|
9
|
+
audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
|
|
10
|
+
videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : ""
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const args = ["-hide_banner", "-loglevel", "info"];
|
|
17
|
+
if (typeof userAgent === "string" && userAgent.trim().length > 0) {
|
|
18
|
+
args.push("-user_agent", userAgent.trim());
|
|
19
|
+
}
|
|
20
|
+
args.push("-i", inputUrl, "-map", "0:a:0", "-t", "0.1", "-f", "null", "-");
|
|
21
|
+
|
|
22
|
+
const ffmpeg = spawn(ffmpegBin, args, {
|
|
23
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
24
|
+
windowsHide: true
|
|
25
|
+
});
|
|
26
|
+
let stderr = "";
|
|
27
|
+
let settled = false;
|
|
28
|
+
|
|
29
|
+
const finish = (codecs) => {
|
|
30
|
+
if (settled) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
settled = true;
|
|
34
|
+
resolve(codecs);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const timeoutId = setTimeout(() => {
|
|
38
|
+
if (!ffmpeg.killed) {
|
|
39
|
+
ffmpeg.kill("SIGTERM");
|
|
40
|
+
}
|
|
41
|
+
finish(parseStreamCodecs(stderr));
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
|
|
44
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
45
|
+
stderr += String(chunk);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
ffmpeg.on("error", () => {
|
|
49
|
+
clearTimeout(timeoutId);
|
|
50
|
+
finish({ audioCodec: "", videoCodec: "" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
ffmpeg.on("exit", () => {
|
|
54
|
+
clearTimeout(timeoutId);
|
|
55
|
+
finish(parseStreamCodecs(stderr));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
|
|
61
|
+
const directUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
62
|
+
directUrl.searchParams.set("sourceKey", sourceKey);
|
|
63
|
+
directUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
64
|
+
return directUrl.toString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createPlaybackPlanner({
|
|
68
|
+
ffmpegBin,
|
|
69
|
+
transcodeAudioEnabled,
|
|
70
|
+
localBaseUrl,
|
|
71
|
+
sourceRegistry,
|
|
72
|
+
torrentPool
|
|
73
|
+
}) {
|
|
74
|
+
const cache = new Map();
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async getPlan({ sourceKey, fileIndex, userAgent = "" }) {
|
|
78
|
+
const cacheKey = `${sourceKey}:${fileIndex}`;
|
|
79
|
+
const cached = cache.get(cacheKey);
|
|
80
|
+
if (cached) {
|
|
81
|
+
return cached;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
85
|
+
if (!sourceRecord) {
|
|
86
|
+
const error = new Error("Source key was not found.");
|
|
87
|
+
error.code = "SOURCE_NOT_FOUND";
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
92
|
+
const file = torrent.files[fileIndex];
|
|
93
|
+
if (!file) {
|
|
94
|
+
const error = new Error("File index was not found in torrent.");
|
|
95
|
+
error.code = "FILE_NOT_FOUND";
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const directUrl = buildDirectUrl(localBaseUrl, sourceKey, fileIndex);
|
|
100
|
+
if (!transcodeAudioEnabled) {
|
|
101
|
+
const plan = {
|
|
102
|
+
mode: "direct",
|
|
103
|
+
directUrl,
|
|
104
|
+
reason: "transcode-disabled",
|
|
105
|
+
audioCodec: "",
|
|
106
|
+
videoCodec: ""
|
|
107
|
+
};
|
|
108
|
+
cache.set(cacheKey, plan);
|
|
109
|
+
return plan;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { audioCodec, videoCodec } = await probeStreamCodecs({
|
|
113
|
+
ffmpegBin,
|
|
114
|
+
inputUrl: directUrl,
|
|
115
|
+
userAgent
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const requiresTranscode = !audioCodec || !DIRECT_AUDIO_CODECS.has(audioCodec);
|
|
119
|
+
const plan = {
|
|
120
|
+
mode: requiresTranscode ? "hls" : "direct",
|
|
121
|
+
directUrl,
|
|
122
|
+
reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
|
|
123
|
+
audioCodec,
|
|
124
|
+
videoCodec
|
|
125
|
+
};
|
|
126
|
+
cache.set(cacheKey, plan);
|
|
127
|
+
return plan;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|