@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,43 @@
|
|
|
1
|
+
function buildRegistryUrl(serverUrl, pathname) {
|
|
2
|
+
return new URL(pathname, ensureBaseUrl(serverUrl));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function ensureBaseUrl(serverUrl) {
|
|
6
|
+
return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function registerClient({ serverUrl, id, name, baseUrl, token }) {
|
|
10
|
+
const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/register"), {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
id,
|
|
15
|
+
name,
|
|
16
|
+
baseUrl,
|
|
17
|
+
token
|
|
18
|
+
})
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
const bodyText = await response.text();
|
|
23
|
+
throw new Error(`Registration failed (${response.status}): ${bodyText}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return response.json();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function sendHeartbeat({ serverUrl, id, token }) {
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/heartbeat"), {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
id,
|
|
36
|
+
token
|
|
37
|
+
})
|
|
38
|
+
});
|
|
39
|
+
return response.status;
|
|
40
|
+
} catch (_error) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import WebTorrent from "webtorrent";
|
|
4
|
+
|
|
5
|
+
function decodeTorrentSource(sourceType, source) {
|
|
6
|
+
if (sourceType === "magnet") {
|
|
7
|
+
return source;
|
|
8
|
+
}
|
|
9
|
+
if (sourceType === "torrent") {
|
|
10
|
+
return Buffer.from(source, "base64");
|
|
11
|
+
}
|
|
12
|
+
throw new Error("Unsupported sourceType. Expected magnet or torrent.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class TorrentPool {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.client = new WebTorrent();
|
|
18
|
+
this.torrents = new Map();
|
|
19
|
+
this.fileUsageByTorrent = new WeakMap();
|
|
20
|
+
|
|
21
|
+
this.client.on("error", (error) => {
|
|
22
|
+
console.error(chalk.red(`[proxy-client] WebTorrent client error: ${error.message}`));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async getTorrent(sourceType, source) {
|
|
27
|
+
const key = `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
28
|
+
const existing = this.torrents.get(key);
|
|
29
|
+
if (existing) {
|
|
30
|
+
return existing;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const torrentId = decodeTorrentSource(sourceType, source);
|
|
34
|
+
const torrent = await new Promise((resolve, reject) => {
|
|
35
|
+
const onError = (error) => {
|
|
36
|
+
this.client.off("error", onError);
|
|
37
|
+
reject(error);
|
|
38
|
+
};
|
|
39
|
+
this.client.once("error", onError);
|
|
40
|
+
this.client.add(torrentId, (readyTorrent) => {
|
|
41
|
+
this.client.off("error", onError);
|
|
42
|
+
resolve(readyTorrent);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.torrents.set(key, torrent);
|
|
47
|
+
return torrent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setActiveFile(torrent, fileIndex) {
|
|
51
|
+
if (!torrent || !Array.isArray(torrent.files)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (let index = 0; index < torrent.files.length; index += 1) {
|
|
55
|
+
const file = torrent.files[index];
|
|
56
|
+
if (!file) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (index === fileIndex) {
|
|
60
|
+
if (typeof file.select === "function") {
|
|
61
|
+
file.select();
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (typeof file.deselect === "function") {
|
|
66
|
+
file.deselect();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
acquireFile(torrent, fileIndex) {
|
|
72
|
+
if (!torrent || !Array.isArray(torrent.files) || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
73
|
+
return () => undefined;
|
|
74
|
+
}
|
|
75
|
+
let usage = this.fileUsageByTorrent.get(torrent);
|
|
76
|
+
if (!usage) {
|
|
77
|
+
usage = new Map();
|
|
78
|
+
this.fileUsageByTorrent.set(torrent, usage);
|
|
79
|
+
}
|
|
80
|
+
usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
|
|
81
|
+
this.#syncSelections(torrent, usage);
|
|
82
|
+
|
|
83
|
+
let released = false;
|
|
84
|
+
return () => {
|
|
85
|
+
if (released) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
released = true;
|
|
89
|
+
const nextCount = (usage.get(fileIndex) ?? 0) - 1;
|
|
90
|
+
if (nextCount > 0) {
|
|
91
|
+
usage.set(fileIndex, nextCount);
|
|
92
|
+
} else {
|
|
93
|
+
usage.delete(fileIndex);
|
|
94
|
+
}
|
|
95
|
+
if (usage.size === 0) {
|
|
96
|
+
this.fileUsageByTorrent.delete(torrent);
|
|
97
|
+
}
|
|
98
|
+
this.#syncSelections(torrent, usage);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#syncSelections(torrent, usage) {
|
|
103
|
+
if (!torrent || !Array.isArray(torrent.files)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (let index = 0; index < torrent.files.length; index += 1) {
|
|
107
|
+
const file = torrent.files[index];
|
|
108
|
+
if (!file) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const shouldSelect = (usage.get(index) ?? 0) > 0;
|
|
112
|
+
if (shouldSelect) {
|
|
113
|
+
if (typeof file.select === "function") {
|
|
114
|
+
file.select();
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (typeof file.deselect === "function") {
|
|
119
|
+
file.deselect();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function createSourceRegistry(maxSources = 200) {
|
|
4
|
+
const sources = new Map();
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
upsert(sourceType, source) {
|
|
8
|
+
const sourceKey = crypto
|
|
9
|
+
.createHash("sha1")
|
|
10
|
+
.update(`${sourceType}:${source}`)
|
|
11
|
+
.digest("hex");
|
|
12
|
+
|
|
13
|
+
sources.set(sourceKey, {
|
|
14
|
+
sourceType,
|
|
15
|
+
source,
|
|
16
|
+
updatedAt: Date.now()
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (sources.size > maxSources) {
|
|
20
|
+
const oldest = Array.from(sources.entries()).sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
21
|
+
for (const [key] of oldest.slice(0, sources.size - maxSources)) {
|
|
22
|
+
sources.delete(key);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return sourceKey;
|
|
27
|
+
},
|
|
28
|
+
get(sourceKey) {
|
|
29
|
+
return sources.get(sourceKey) ?? null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function parseRange(rangeHeader, fileLength) {
|
|
2
|
+
if (!rangeHeader || !rangeHeader.startsWith("bytes=")) {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
const [startRaw, endRaw] = rangeHeader.slice("bytes=".length).split("-");
|
|
6
|
+
const start = startRaw.length > 0 ? Number(startRaw) : 0;
|
|
7
|
+
const end = endRaw && endRaw.length > 0 ? Number(endRaw) : fileLength - 1;
|
|
8
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return { start, end: Math.min(end, fileLength - 1) };
|
|
12
|
+
}
|