@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,76 @@
|
|
|
1
|
+
import { parseRange } from "../../utils/parse-range.js";
|
|
2
|
+
|
|
3
|
+
function getSourceParams(query, sourceRegistry) {
|
|
4
|
+
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey : "";
|
|
5
|
+
const sourceTypeFromQuery = typeof query.sourceType === "string" ? query.sourceType : "";
|
|
6
|
+
const sourceFromQuery = typeof query.source === "string" ? query.source : "";
|
|
7
|
+
|
|
8
|
+
const sourceRecord = sourceKey ? sourceRegistry.get(sourceKey) : null;
|
|
9
|
+
const sourceType = sourceRecord?.sourceType ?? sourceTypeFromQuery;
|
|
10
|
+
const source = sourceRecord?.source ?? sourceFromQuery;
|
|
11
|
+
return { sourceType, source };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
15
|
+
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
16
|
+
const fileIndex = Number(fileIndexRaw);
|
|
17
|
+
const { sourceType, source } = getSourceParams(req.query, sourceRegistry);
|
|
18
|
+
|
|
19
|
+
if (!sourceType || !source || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
20
|
+
return reply
|
|
21
|
+
.code(400)
|
|
22
|
+
.send({ error: "sourceKey or sourceType+source with fileIndex are required." });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let torrent;
|
|
26
|
+
try {
|
|
27
|
+
torrent = await torrentPool.getTorrent(sourceType, source);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
return reply.code(500).send({ error: `Failed to load torrent source: ${message}` });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const file = torrent.files[fileIndex];
|
|
34
|
+
if (!file) {
|
|
35
|
+
return reply.code(404).send({ error: "File index was not found in torrent." });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
|
|
39
|
+
|
|
40
|
+
const range = parseRange(req.headers.range, file.length);
|
|
41
|
+
reply.header("Accept-Ranges", "bytes");
|
|
42
|
+
reply.header("Content-Type", "application/octet-stream");
|
|
43
|
+
reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`);
|
|
44
|
+
|
|
45
|
+
if (!range) {
|
|
46
|
+
reply.header("Content-Length", String(file.length));
|
|
47
|
+
const stream = file.createReadStream();
|
|
48
|
+
bindRelease(stream, reply, releaseFile);
|
|
49
|
+
return reply.send(stream);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const contentLength = range.end - range.start + 1;
|
|
53
|
+
reply.code(206);
|
|
54
|
+
reply.header("Content-Length", String(contentLength));
|
|
55
|
+
reply.header("Content-Range", `bytes ${range.start}-${range.end}/${file.length}`);
|
|
56
|
+
const stream = file.createReadStream({ start: range.start, end: range.end });
|
|
57
|
+
bindRelease(stream, reply, releaseFile);
|
|
58
|
+
return reply.send(stream);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bindRelease(stream, reply, release) {
|
|
62
|
+
let released = false;
|
|
63
|
+
const releaseOnce = () => {
|
|
64
|
+
if (released) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
released = true;
|
|
68
|
+
release();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
stream.on("close", releaseOnce);
|
|
72
|
+
stream.on("end", releaseOnce);
|
|
73
|
+
stream.on("error", releaseOnce);
|
|
74
|
+
reply.raw.once("close", releaseOnce);
|
|
75
|
+
reply.raw.once("finish", releaseOnce);
|
|
76
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
|
|
2
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
3
|
+
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
4
|
+
const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName, 15_000);
|
|
5
|
+
|
|
6
|
+
if (result.kind === "not-found") {
|
|
7
|
+
return reply.code(404).send({ error: "Transcode session file was not found." });
|
|
8
|
+
}
|
|
9
|
+
if (result.kind === "warming-up") {
|
|
10
|
+
reply.header("Retry-After", "2");
|
|
11
|
+
return reply.code(202).send({ status: "warming-up" });
|
|
12
|
+
}
|
|
13
|
+
if (result.kind === "failed") {
|
|
14
|
+
return reply.code(500).send({ error: result.message });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (result.isPlaylist) {
|
|
18
|
+
reply.header("Cache-Control", "no-store");
|
|
19
|
+
} else {
|
|
20
|
+
reply.header("Cache-Control", "public, max-age=60");
|
|
21
|
+
}
|
|
22
|
+
reply.header("Content-Type", result.contentType);
|
|
23
|
+
return reply.send(result.stream);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
|
|
27
|
+
const startedAt = Date.now();
|
|
28
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
29
|
+
const result = await hlsSessionManager.getFileStream(sessionId, fileName);
|
|
30
|
+
if (result.kind !== "warming-up") {
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
await delay(300);
|
|
34
|
+
}
|
|
35
|
+
return { kind: "warming-up" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function delay(ms) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
setTimeout(resolve, ms);
|
|
41
|
+
});
|
|
42
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import fastifyCors from "@fastify/cors";
|
|
3
|
+
import fastifyHelmet from "@fastify/helmet";
|
|
4
|
+
import fastifyStatic from "@fastify/static";
|
|
5
|
+
import getPort from "get-port";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { handleHealthGet } from "./routes/health/get.js";
|
|
9
|
+
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
10
|
+
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
11
|
+
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
12
|
+
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
13
|
+
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
14
|
+
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
15
|
+
import { handleStreamGet } from "./routes/stream/get.js";
|
|
16
|
+
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
17
|
+
import { createSourceRegistry } from "./store/source-registry.js";
|
|
18
|
+
import { TorrentPool } from "./services/torrent-pool.js";
|
|
19
|
+
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
20
|
+
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = path.dirname(__filename);
|
|
24
|
+
const publicRoot = path.resolve(__dirname, "./public");
|
|
25
|
+
|
|
26
|
+
function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
27
|
+
const ports = [];
|
|
28
|
+
for (let index = 0; index < maxAttempts; index += 1) {
|
|
29
|
+
ports.push(startPort + index);
|
|
30
|
+
}
|
|
31
|
+
return ports;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }) {
|
|
35
|
+
const app = Fastify({
|
|
36
|
+
bodyLimit: 10 * 1024 * 1024
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await app.register(fastifyHelmet, {
|
|
40
|
+
// Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
|
|
41
|
+
crossOriginResourcePolicy: {
|
|
42
|
+
policy: "cross-origin"
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
await app.register(fastifyCors, {
|
|
46
|
+
origin: true,
|
|
47
|
+
methods: ["GET", "POST", "OPTIONS"],
|
|
48
|
+
allowedHeaders: ["Content-Type", "Range"]
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const sourceRegistry = createSourceRegistry(200);
|
|
52
|
+
const torrentPool = new TorrentPool();
|
|
53
|
+
const selectedPort = await getPort({
|
|
54
|
+
port: buildPortCandidates(port)
|
|
55
|
+
});
|
|
56
|
+
const hlsSessionManager = new HlsSessionManager({
|
|
57
|
+
enabled: transcodeAudio,
|
|
58
|
+
ffmpegBin,
|
|
59
|
+
localBindHost: host,
|
|
60
|
+
localPort: selectedPort
|
|
61
|
+
});
|
|
62
|
+
const playbackPlanner = createPlaybackPlanner({
|
|
63
|
+
ffmpegBin,
|
|
64
|
+
transcodeAudioEnabled: transcodeAudio,
|
|
65
|
+
localBaseUrl: hlsSessionManager.localBaseUrl,
|
|
66
|
+
sourceRegistry,
|
|
67
|
+
torrentPool
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
app.get("/health", async (req, reply) => handleHealthGet(req, reply));
|
|
71
|
+
app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply));
|
|
72
|
+
app.post("/api/sources", async (req, reply) =>
|
|
73
|
+
handleApiSourcesPost(req, reply, { sourceRegistry })
|
|
74
|
+
);
|
|
75
|
+
app.post("/api/playback-plan", async (req, reply) =>
|
|
76
|
+
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
77
|
+
);
|
|
78
|
+
app.get("/stream", async (req, reply) =>
|
|
79
|
+
handleStreamGet(req, reply, { sourceRegistry, torrentPool })
|
|
80
|
+
);
|
|
81
|
+
app.post("/api/transcode-sessions", async (req, reply) =>
|
|
82
|
+
handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager })
|
|
83
|
+
);
|
|
84
|
+
app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
|
|
85
|
+
handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
|
|
86
|
+
);
|
|
87
|
+
app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
|
|
88
|
+
handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
|
|
89
|
+
);
|
|
90
|
+
app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
|
|
91
|
+
handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
|
|
92
|
+
);
|
|
93
|
+
await app.register(fastifyStatic, {
|
|
94
|
+
root: publicRoot,
|
|
95
|
+
prefix: "/",
|
|
96
|
+
serveDotFiles: true
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
app.addHook("onClose", async () => {
|
|
100
|
+
await hlsSessionManager.disposeAll();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await app.listen({ host, port: selectedPort });
|
|
104
|
+
return {
|
|
105
|
+
app,
|
|
106
|
+
port: selectedPort
|
|
107
|
+
};
|
|
108
|
+
}
|