@torrent-tv/proxy 2.0.1 → 2.2.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/bin/cli.js +89 -14
- package/package.json +1 -1
- package/routes/api/playback-plan/post.js +19 -0
- package/routes/api/sources/post.js +18 -0
- package/routes/api/transcode-sessions/post.js +18 -0
- package/routes/api/transcode-sessions/progress/get.js +10 -0
- package/routes/api/transcode-sessions/release/post.js +19 -0
- package/routes/health/get.js +9 -0
- package/routes/healthz/get.js +9 -0
- package/routes/stream/get.js +35 -0
- package/routes/transcode/session-file/get.js +29 -0
- package/server.js +29 -0
- package/services/hls-session-manager.js +207 -7
- package/services/playback-planner.js +71 -0
- package/services/registry-api.js +62 -12
- package/services/torrent-pool.js +72 -2
- package/services/tunnel-client.js +220 -0
- package/store/source-registry.js +41 -0
- package/utils/logger.js +30 -0
- package/utils/parse-range.js +13 -0
package/services/torrent-pool.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WebTorrent client pool.
|
|
3
|
+
*
|
|
4
|
+
* Manages a shared WebTorrent client instance and a map of active torrents
|
|
5
|
+
* keyed by a hash of their source. Tracks file-level usage so that only
|
|
6
|
+
* the pieces needed by active streams are selected for download.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import crypto from "node:crypto";
|
|
2
|
-
import chalk from "chalk";
|
|
3
10
|
import WebTorrent from "webtorrent";
|
|
11
|
+
import { logger } from "../utils/logger.js";
|
|
4
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Decode a raw torrent source value into the format expected by WebTorrent.
|
|
15
|
+
*
|
|
16
|
+
* @param {"magnet" | "torrent"} sourceType - How the source is encoded.
|
|
17
|
+
* @param {string} source - Magnet URI or base64-encoded .torrent file.
|
|
18
|
+
* @returns {string | Buffer}
|
|
19
|
+
*/
|
|
5
20
|
function decodeTorrentSource(sourceType, source) {
|
|
6
21
|
if (sourceType === "magnet") {
|
|
7
22
|
return source;
|
|
@@ -12,17 +27,47 @@ function decodeTorrentSource(sourceType, source) {
|
|
|
12
27
|
throw new Error("Unsupported sourceType. Expected magnet or torrent.");
|
|
13
28
|
}
|
|
14
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Shared WebTorrent pool.
|
|
32
|
+
*
|
|
33
|
+
* Torrents are loaded on demand and cached indefinitely (the pool has no
|
|
34
|
+
* eviction policy — callers are responsible for keeping the set small).
|
|
35
|
+
* File-level piece selection is tracked through a reference-count map so
|
|
36
|
+
* that only files with at least one active stream cause downloading.
|
|
37
|
+
*/
|
|
15
38
|
export class TorrentPool {
|
|
16
39
|
constructor() {
|
|
40
|
+
/** @type {import("webtorrent").WebTorrent} */
|
|
17
41
|
this.client = new WebTorrent();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Active torrents keyed by `"${sourceType}:${sha1(source)}"`.
|
|
45
|
+
*
|
|
46
|
+
* @type {Map<string, import("webtorrent").Torrent>}
|
|
47
|
+
*/
|
|
18
48
|
this.torrents = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-torrent file usage reference counts.
|
|
52
|
+
* Maps torrent object → (fileIndex → refCount).
|
|
53
|
+
*
|
|
54
|
+
* @type {WeakMap<import("webtorrent").Torrent, Map<number, number>>}
|
|
55
|
+
*/
|
|
19
56
|
this.fileUsageByTorrent = new WeakMap();
|
|
20
57
|
|
|
21
58
|
this.client.on("error", (error) => {
|
|
22
|
-
|
|
59
|
+
logger.error(`WebTorrent client error: ${error.message}`);
|
|
23
60
|
});
|
|
24
61
|
}
|
|
25
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Return the torrent for the given source, loading it if necessary.
|
|
65
|
+
* Resolves once the torrent metadata is ready.
|
|
66
|
+
*
|
|
67
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
68
|
+
* @param {string} source - Magnet URI or base64-encoded .torrent bytes.
|
|
69
|
+
* @returns {Promise<import("webtorrent").Torrent>}
|
|
70
|
+
*/
|
|
26
71
|
async getTorrent(sourceType, source) {
|
|
27
72
|
const key = `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
28
73
|
const existing = this.torrents.get(key);
|
|
@@ -47,6 +92,14 @@ export class TorrentPool {
|
|
|
47
92
|
return torrent;
|
|
48
93
|
}
|
|
49
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Mark a single file as active, deselecting all others.
|
|
97
|
+
* Prefer {@link acquireFile} when the active set may contain multiple files.
|
|
98
|
+
*
|
|
99
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
100
|
+
* @param {number} fileIndex - Zero-based index into `torrent.files`.
|
|
101
|
+
* @returns {void}
|
|
102
|
+
*/
|
|
50
103
|
setActiveFile(torrent, fileIndex) {
|
|
51
104
|
if (!torrent || !Array.isArray(torrent.files)) {
|
|
52
105
|
return;
|
|
@@ -68,6 +121,15 @@ export class TorrentPool {
|
|
|
68
121
|
}
|
|
69
122
|
}
|
|
70
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Increment the reference count for a file, selecting it for download.
|
|
126
|
+
* Returns a release function that decrements the count; when it reaches
|
|
127
|
+
* zero the file is automatically deselected.
|
|
128
|
+
*
|
|
129
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
130
|
+
* @param {number} fileIndex - Zero-based index into `torrent.files`.
|
|
131
|
+
* @returns {() => void} Release function — call it once when done streaming.
|
|
132
|
+
*/
|
|
71
133
|
acquireFile(torrent, fileIndex) {
|
|
72
134
|
if (!torrent || !Array.isArray(torrent.files) || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
73
135
|
return () => undefined;
|
|
@@ -99,6 +161,14 @@ export class TorrentPool {
|
|
|
99
161
|
};
|
|
100
162
|
}
|
|
101
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Update WebTorrent piece selection to match the current usage map.
|
|
166
|
+
* Files with at least one consumer are selected; all others are deselected.
|
|
167
|
+
*
|
|
168
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
169
|
+
* @param {Map<number, number>} usage - fileIndex → refCount.
|
|
170
|
+
* @returns {void}
|
|
171
|
+
*/
|
|
102
172
|
#syncSelections(torrent, usage) {
|
|
103
173
|
if (!torrent || !Array.isArray(torrent.files)) {
|
|
104
174
|
return;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Outbound WebSocket tunnel from the proxy to the registry server.
|
|
3
|
+
*
|
|
4
|
+
* The proxy establishes one persistent connection on startup.
|
|
5
|
+
* The server sends relay requests through it; the proxy fetches them
|
|
6
|
+
* locally (against 127.0.0.1) and streams responses back chunk-by-chunk.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} TunnelClientOptions
|
|
11
|
+
* @property {string} serverUrl - Base URL of the registry server (http/https).
|
|
12
|
+
* @property {string} proxyId - Stable ID used to identify this proxy on the server.
|
|
13
|
+
* @property {string} token - Auth token sent as a header during the WS handshake.
|
|
14
|
+
* @property {number} proxyPort - Local port the proxy HTTP server is listening on.
|
|
15
|
+
* @property {(message: string) => void} [onLog] - Optional log callback.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} TunnelRelayRequest
|
|
20
|
+
* @property {string} requestId - Unique ID assigned by the server for this relay round-trip.
|
|
21
|
+
* @property {string} method - HTTP method to use when calling the local proxy.
|
|
22
|
+
* @property {string} path - Request path (e.g. "/health").
|
|
23
|
+
* @property {string} query - Query string without the leading "?".
|
|
24
|
+
* @property {Record<string, string>} headers - Headers forwarded from the browser.
|
|
25
|
+
* @property {string | null} body - Serialised JSON body, or null for GET.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const RECONNECT_DELAY_MS = 5_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create and manage the outbound WebSocket tunnel to the registry server.
|
|
32
|
+
*
|
|
33
|
+
* @param {TunnelClientOptions} options
|
|
34
|
+
* @returns {{ connect: () => void, disconnect: () => void }}
|
|
35
|
+
*/
|
|
36
|
+
export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog }) {
|
|
37
|
+
const wsUrl = serverUrl.replace(/^http/, "ws").replace(/\/+$/, "") + "/ws/proxy-tunnel";
|
|
38
|
+
|
|
39
|
+
/** @type {WebSocket | null} */
|
|
40
|
+
let socket = null;
|
|
41
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
42
|
+
let reconnectTimer = null;
|
|
43
|
+
let stopped = false;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Emit a log message via the provided callback.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} message
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
function log(message) {
|
|
52
|
+
if (typeof onLog === "function") {
|
|
53
|
+
onLog(message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Open a new WebSocket connection to the server.
|
|
59
|
+
* Automatically reconnects on close unless {@link disconnect} was called.
|
|
60
|
+
*
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
63
|
+
function connect() {
|
|
64
|
+
if (stopped) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
log(`Connecting tunnel to ${wsUrl}`);
|
|
68
|
+
|
|
69
|
+
socket = new WebSocket(wsUrl, {
|
|
70
|
+
headers: {
|
|
71
|
+
"x-proxy-id": proxyId,
|
|
72
|
+
"x-proxy-token": token
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
socket.addEventListener("open", () => {
|
|
77
|
+
log("Tunnel connected.");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
socket.addEventListener("message", (event) => {
|
|
81
|
+
let message;
|
|
82
|
+
try {
|
|
83
|
+
message = JSON.parse(event.data);
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (message.type === "request") {
|
|
88
|
+
void handleRelayRequest(message).catch((error) => {
|
|
89
|
+
log(`Tunnel relay error: ${error?.message ?? error}`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
socket.addEventListener("close", (event) => {
|
|
95
|
+
log(`Tunnel disconnected (code=${event.code}). Reconnecting in ${RECONNECT_DELAY_MS}ms...`);
|
|
96
|
+
socket = null;
|
|
97
|
+
if (!stopped) {
|
|
98
|
+
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
socket.addEventListener("error", (event) => {
|
|
103
|
+
log(`Tunnel WebSocket error: ${event.message ?? "unknown"}`);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute a relay request sent by the server: fetch the resource
|
|
109
|
+
* from the local proxy and stream the response back chunk-by-chunk.
|
|
110
|
+
*
|
|
111
|
+
* @param {TunnelRelayRequest} relayRequest
|
|
112
|
+
* @returns {Promise<void>}
|
|
113
|
+
*/
|
|
114
|
+
async function handleRelayRequest(relayRequest) {
|
|
115
|
+
const { requestId, method, path, query, headers: forwardedHeaders, body } = relayRequest;
|
|
116
|
+
const targetUrl = `http://127.0.0.1:${proxyPort}${path}` + (query ? `?${query}` : "");
|
|
117
|
+
const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
|
|
118
|
+
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetch(targetUrl, {
|
|
122
|
+
method,
|
|
123
|
+
headers: requestHeaders,
|
|
124
|
+
body: body != null ? body : undefined,
|
|
125
|
+
redirect: "manual"
|
|
126
|
+
});
|
|
127
|
+
} catch (fetchError) {
|
|
128
|
+
sendError(requestId, fetchError?.message ?? String(fetchError));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const responseHeaders = {};
|
|
133
|
+
for (const [headerName, headerValue] of response.headers.entries()) {
|
|
134
|
+
responseHeaders[headerName] = headerValue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
send({
|
|
138
|
+
type: "response-start",
|
|
139
|
+
requestId,
|
|
140
|
+
status: response.status,
|
|
141
|
+
headers: responseHeaders
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!response.body) {
|
|
145
|
+
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const reader = response.body.getReader();
|
|
151
|
+
while (true) {
|
|
152
|
+
const { done, value } = await reader.read();
|
|
153
|
+
if (done) {
|
|
154
|
+
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
send({
|
|
158
|
+
type: "response-chunk",
|
|
159
|
+
requestId,
|
|
160
|
+
data: Buffer.from(value).toString("base64"),
|
|
161
|
+
done: false
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Send a JSON message through the WebSocket if it is open.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} message
|
|
173
|
+
* @returns {void}
|
|
174
|
+
*/
|
|
175
|
+
function send(message) {
|
|
176
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
177
|
+
socket.send(JSON.stringify(message));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Send a `response-error` message back to the server for the given request.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} requestId
|
|
185
|
+
* @param {string} errorMessage
|
|
186
|
+
* @returns {void}
|
|
187
|
+
*/
|
|
188
|
+
function sendError(requestId, errorMessage) {
|
|
189
|
+
send({ type: "response-error", requestId, error: errorMessage });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
/**
|
|
194
|
+
* Start the tunnel, connecting immediately and reconnecting on drop.
|
|
195
|
+
*
|
|
196
|
+
* @returns {void}
|
|
197
|
+
*/
|
|
198
|
+
connect() {
|
|
199
|
+
stopped = false;
|
|
200
|
+
connect();
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Stop the tunnel and close the current connection without reconnecting.
|
|
205
|
+
*
|
|
206
|
+
* @returns {void}
|
|
207
|
+
*/
|
|
208
|
+
disconnect() {
|
|
209
|
+
stopped = true;
|
|
210
|
+
if (reconnectTimer != null) {
|
|
211
|
+
clearTimeout(reconnectTimer);
|
|
212
|
+
reconnectTimer = null;
|
|
213
|
+
}
|
|
214
|
+
if (socket) {
|
|
215
|
+
socket.close(1000, "shutdown");
|
|
216
|
+
socket = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
package/store/source-registry.js
CHANGED
|
@@ -1,9 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file In-memory registry of torrent sources.
|
|
3
|
+
*
|
|
4
|
+
* Sources are stored under a SHA-1 key derived from their type and content.
|
|
5
|
+
* The registry is bounded: when `maxSources` is exceeded, the oldest entries
|
|
6
|
+
* are evicted to keep memory usage predictable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import crypto from "node:crypto";
|
|
2
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} SourceRecord
|
|
13
|
+
* @property {"magnet" | "torrent"} sourceType - Encoding of the `source` field.
|
|
14
|
+
* @property {string} source - Magnet URI or base64-encoded .torrent bytes.
|
|
15
|
+
* @property {number} updatedAt - Unix ms timestamp of the last upsert.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a bounded in-memory source registry.
|
|
20
|
+
*
|
|
21
|
+
* @param {number} [maxSources=200] - Maximum number of entries to retain.
|
|
22
|
+
* @returns {{
|
|
23
|
+
* upsert: (sourceType: string, source: string) => string,
|
|
24
|
+
* get: (sourceKey: string) => SourceRecord | null
|
|
25
|
+
* }}
|
|
26
|
+
*/
|
|
3
27
|
export function createSourceRegistry(maxSources = 200) {
|
|
28
|
+
/** @type {Map<string, SourceRecord>} */
|
|
4
29
|
const sources = new Map();
|
|
5
30
|
|
|
6
31
|
return {
|
|
32
|
+
/**
|
|
33
|
+
* Insert or refresh a source entry and return its key.
|
|
34
|
+
* Evicts the oldest entries if the map exceeds `maxSources`.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} sourceType
|
|
37
|
+
* @param {string} source
|
|
38
|
+
* @returns {string} Stable SHA-1 hex key for this source.
|
|
39
|
+
*/
|
|
7
40
|
upsert(sourceType, source) {
|
|
8
41
|
const sourceKey = crypto
|
|
9
42
|
.createHash("sha1")
|
|
@@ -25,6 +58,14 @@ export function createSourceRegistry(maxSources = 200) {
|
|
|
25
58
|
|
|
26
59
|
return sourceKey;
|
|
27
60
|
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Look up a source by its key.
|
|
64
|
+
* Returns `null` if the key is not registered.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} sourceKey
|
|
67
|
+
* @returns {SourceRecord | null}
|
|
68
|
+
*/
|
|
28
69
|
get(sourceKey) {
|
|
29
70
|
return sources.get(sourceKey) ?? null;
|
|
30
71
|
}
|
package/utils/logger.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Centralised console logger for the proxy process.
|
|
3
|
+
*
|
|
4
|
+
* All messages are prefixed with `[proxy-client]` and coloured with chalk
|
|
5
|
+
* for consistent, readable terminal output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
|
|
10
|
+
const PREFIX = "[proxy-client]";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} ProxyLogger
|
|
14
|
+
* @property {(message: string) => void} info - Informational message (cyan).
|
|
15
|
+
* @property {(message: string) => void} success - Positive outcome (green).
|
|
16
|
+
* @property {(message: string) => void} warn - Non-fatal warning (yellow).
|
|
17
|
+
* @property {(message: string) => void} error - Error condition (red).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shared logger instance used throughout the proxy process.
|
|
22
|
+
*
|
|
23
|
+
* @type {ProxyLogger}
|
|
24
|
+
*/
|
|
25
|
+
export const logger = {
|
|
26
|
+
info: (message) => console.log(chalk.cyan(`${PREFIX} ${message}`)),
|
|
27
|
+
success: (message) => console.log(chalk.green(`${PREFIX} ${message}`)),
|
|
28
|
+
warn: (message) => console.warn(chalk.yellow(`${PREFIX} ${message}`)),
|
|
29
|
+
error: (message) => console.error(chalk.red(`${PREFIX} ${message}`)),
|
|
30
|
+
};
|
package/utils/parse-range.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file HTTP Range header parser.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse an HTTP `Range` header value into a byte range clamped to the file size.
|
|
7
|
+
* Only the `bytes=<start>-<end>` form is supported.
|
|
8
|
+
*
|
|
9
|
+
* @param {string | undefined} rangeHeader - Value of the `Range` request header.
|
|
10
|
+
* @param {number} fileLength - Total file size in bytes.
|
|
11
|
+
* @returns {{ start: number, end: number } | null} Byte range, or `null` if the
|
|
12
|
+
* header is absent, malformed, or uses an unsupported unit.
|
|
13
|
+
*/
|
|
1
14
|
export function parseRange(rangeHeader, fileLength) {
|
|
2
15
|
if (!rangeHeader || !rangeHeader.startsWith("bytes=")) {
|
|
3
16
|
return null;
|