@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
package/.dockerignore
ADDED
package/Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
FROM node:22-alpine
|
|
3
|
+
|
|
4
|
+
ENV NODE_ENV=production
|
|
5
|
+
ENV PORT=9090
|
|
6
|
+
ENV HOST=0.0.0.0
|
|
7
|
+
|
|
8
|
+
WORKDIR /app
|
|
9
|
+
|
|
10
|
+
# ffmpeg is needed for optional HLS audio transcode mode.
|
|
11
|
+
RUN apk add --no-cache ffmpeg
|
|
12
|
+
|
|
13
|
+
# Create an unprivileged runtime user.
|
|
14
|
+
RUN addgroup -S app && adduser -S -G app app
|
|
15
|
+
|
|
16
|
+
# Install only production dependencies first for better layer caching.
|
|
17
|
+
COPY package.json package-lock.json ./
|
|
18
|
+
RUN npm ci --omit=dev && npm cache clean --force
|
|
19
|
+
|
|
20
|
+
# Copy application sources.
|
|
21
|
+
COPY --chown=app:app . .
|
|
22
|
+
|
|
23
|
+
USER app
|
|
24
|
+
|
|
25
|
+
EXPOSE 9090
|
|
26
|
+
|
|
27
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
28
|
+
CMD node -e "fetch(`http://127.0.0.1:${process.env.PORT || 9090}/healthz`).then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
29
|
+
|
|
30
|
+
CMD ["sh", "-ec", "if [ -z \"${SERVER_URL:-}\" ]; then echo 'SERVER_URL is required' >&2; exit 1; fi; exec node ./bin/cli.js --server-url \"$SERVER_URL\" --host \"$HOST\" --port \"$PORT\" ${PROXY_EXTRA_ARGS:-}"]
|
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Copyright (C) 2026 Anton Nemtsev
|
|
2
|
+
|
|
3
|
+
This program is free software: you can redistribute it and/or modify
|
|
4
|
+
it under the terms of the GNU General Public License as published by
|
|
5
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
6
|
+
(at your option) any later version.
|
|
7
|
+
|
|
8
|
+
This program is distributed in the hope that it will be useful,
|
|
9
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
10
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
11
|
+
GNU General Public License for more details.
|
|
12
|
+
|
|
13
|
+
You should have received a copy of the GNU General Public License
|
|
14
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
SPDX-License-Identifier: GPL-3.0-or-later
|
package/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Torrent Proxy Client
|
|
2
|
+
|
|
3
|
+
`@torrent-tv/proxy` is a lightweight Node.js service that turns torrent content into HTTP endpoints that are easy to consume from web players and backend services.
|
|
4
|
+
|
|
5
|
+
It is designed for setups where a central registry/UI needs a simple direct media URL, while the actual torrent fetching happens on a separate edge/client machine.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
- Browsers and many media players cannot consume torrents directly.
|
|
10
|
+
- This service exposes torrent files through regular HTTP (`/stream`) with range support.
|
|
11
|
+
- It can optionally create HLS sessions with AAC audio when direct playback is not suitable.
|
|
12
|
+
- It can also create HLS sessions with video transcoding when browser-side decode still fails.
|
|
13
|
+
- It registers itself in an external registry service and sends heartbeats, so other services can discover and use it.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
- Runs a Fastify server with health and media endpoints.
|
|
18
|
+
- Accepts torrent sources (`magnet` or base64 `.torrent`) and returns a stable `sourceKey`.
|
|
19
|
+
- Streams a selected file from a torrent by `fileIndex`.
|
|
20
|
+
- Builds a playback plan (`direct` vs `hls`) based on detected audio codec and returns both audio/video codecs.
|
|
21
|
+
- Starts ffmpeg-based HLS transcoding sessions (`audio-only` or `video+audio`) and serves generated playlist/segments.
|
|
22
|
+
- Tracks multi-client consumers per transcode session and stops ffmpeg when the last consumer releases.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Node.js 18+ (ESM and built-in `fetch` are required).
|
|
27
|
+
- npm.
|
|
28
|
+
- ffmpeg is required only when audio transcoding is enabled.
|
|
29
|
+
- By default, the package uses `ffmpeg-static`.
|
|
30
|
+
- You can override binary path with `--ffmpeg-bin`.
|
|
31
|
+
- You can disable transcoding with `--no-transcode-audio`.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Run
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm start -- --server-url http://localhost:3000
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Minimal required argument:
|
|
46
|
+
|
|
47
|
+
- `--server-url <url>`: base URL of your registry server.
|
|
48
|
+
|
|
49
|
+
Useful optional arguments:
|
|
50
|
+
|
|
51
|
+
- `--host <host>`: bind host (default `127.0.0.1`).
|
|
52
|
+
- `--port <port>`: preferred local port (default `9090`; first free port in range is selected).
|
|
53
|
+
- `--public-base-url <url>`: externally reachable base URL advertised to registry.
|
|
54
|
+
- `--id <id>`: stable proxy client id.
|
|
55
|
+
- `--name <name>`: display name for registry.
|
|
56
|
+
- `--token <token>`: token sent to register/heartbeat endpoints.
|
|
57
|
+
- `--ffmpeg-bin <path>`: custom ffmpeg binary path.
|
|
58
|
+
- `--no-transcode-audio`: disable HLS audio transcoding.
|
|
59
|
+
- `--help`: print all options with descriptions and examples, then exit.
|
|
60
|
+
|
|
61
|
+
## HTTP API
|
|
62
|
+
|
|
63
|
+
Base URL examples below use `http://127.0.0.1:9090`.
|
|
64
|
+
|
|
65
|
+
### Health
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl http://127.0.0.1:9090/health
|
|
69
|
+
curl http://127.0.0.1:9090/healthz
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 1) Register a source
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
curl -X POST http://127.0.0.1:9090/api/sources \
|
|
76
|
+
-H "Content-Type: application/json" \
|
|
77
|
+
-d '{
|
|
78
|
+
"sourceType": "magnet",
|
|
79
|
+
"source": "magnet:?xt=urn:btih:..."
|
|
80
|
+
}'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Response:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{ "sourceKey": "..." }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Supported `sourceType` values:
|
|
90
|
+
|
|
91
|
+
- `magnet`: magnet URI string.
|
|
92
|
+
- `torrent`: base64-encoded raw `.torrent` file bytes.
|
|
93
|
+
|
|
94
|
+
### 2) Build playback plan
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
curl -X POST http://127.0.0.1:9090/api/playback-plan \
|
|
98
|
+
-H "Content-Type: application/json" \
|
|
99
|
+
-d '{
|
|
100
|
+
"sourceKey": "<sourceKey>",
|
|
101
|
+
"fileIndex": 0,
|
|
102
|
+
"userAgent": "Mozilla/5.0"
|
|
103
|
+
}'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Typical response:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"mode": "direct",
|
|
111
|
+
"directUrl": "http://127.0.0.1:9090/stream?sourceKey=...&fileIndex=0",
|
|
112
|
+
"reason": "audio-codec-supported",
|
|
113
|
+
"audioCodec": "aac",
|
|
114
|
+
"videoCodec": "h264"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`mode` can be:
|
|
119
|
+
|
|
120
|
+
- `direct`: play `directUrl` directly.
|
|
121
|
+
- `hls`: create an HLS session, then use playlist URL.
|
|
122
|
+
|
|
123
|
+
### 3) Direct stream endpoint
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
curl -v "http://127.0.0.1:9090/stream?sourceKey=<sourceKey>&fileIndex=0"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Or without pre-registering source:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
curl -v "http://127.0.0.1:9090/stream?sourceType=magnet&source=magnet:?xt=...&fileIndex=0"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The endpoint supports HTTP Range requests.
|
|
136
|
+
|
|
137
|
+
### 4) Create HLS transcode session (optional)
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
curl -X POST http://127.0.0.1:9090/api/transcode-sessions \
|
|
141
|
+
-H "Content-Type: application/json" \
|
|
142
|
+
-d '{
|
|
143
|
+
"sourceKey": "<sourceKey>",
|
|
144
|
+
"fileIndex": 0,
|
|
145
|
+
"transcodeVideo": false,
|
|
146
|
+
"consumerId": "browser-session-uuid",
|
|
147
|
+
"fileName": "Episode01.mkv"
|
|
148
|
+
}'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Set `"transcodeVideo": true` to force video transcoding (for browser decode fallback cases).
|
|
152
|
+
|
|
153
|
+
Response:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"sessionId": "...",
|
|
158
|
+
"playlistPath": "/transcode/<sessionId>/index.m3u8"
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Open playlist as:
|
|
163
|
+
|
|
164
|
+
`http://127.0.0.1:9090/transcode/<sessionId>/index.m3u8`
|
|
165
|
+
|
|
166
|
+
### 5) Poll transcode progress
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
curl "http://127.0.0.1:9090/api/transcode-sessions/<sessionId>/progress"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Response includes transcode and warmup metrics:
|
|
173
|
+
- `percent`, `processedSeconds`, `totalSeconds`, `remainingSeconds`, `speed`
|
|
174
|
+
- `warmupPercent`, `warmupRemainingSeconds`
|
|
175
|
+
|
|
176
|
+
### 6) Release transcode consumer
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
curl -X POST http://127.0.0.1:9090/api/transcode-sessions/<sessionId>/release \
|
|
180
|
+
-H "Content-Type: application/json" \
|
|
181
|
+
-d '{
|
|
182
|
+
"consumerId": "browser-session-uuid",
|
|
183
|
+
"reason": "pagehide"
|
|
184
|
+
}'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
When the last consumer is released, proxy disposes the session and stops ffmpeg.
|
|
188
|
+
|
|
189
|
+
## End-to-end flow
|
|
190
|
+
|
|
191
|
+
1. Start proxy client with `--server-url`.
|
|
192
|
+
2. Register torrent source via `/api/sources` and get `sourceKey`.
|
|
193
|
+
3. Request `/api/playback-plan`.
|
|
194
|
+
4. If plan is `direct`, use `directUrl`.
|
|
195
|
+
5. If plan is `hls`, create session and play generated playlist.
|
|
196
|
+
6. Poll `/progress` for UI updates, then release session on client stop/close.
|
|
197
|
+
|
|
198
|
+
## Transcode Session Lifecycle
|
|
199
|
+
|
|
200
|
+
```mermaid
|
|
201
|
+
sequenceDiagram
|
|
202
|
+
participant C as Client
|
|
203
|
+
participant P as Proxy
|
|
204
|
+
participant F as FFmpeg
|
|
205
|
+
|
|
206
|
+
C->>P: POST /api/transcode-sessions (consumerId, mode, fileName)
|
|
207
|
+
P->>F: start/reuse transcode session
|
|
208
|
+
C->>P: GET /transcode/:sessionId/index.m3u8
|
|
209
|
+
C->>P: GET /api/transcode-sessions/:sessionId/progress
|
|
210
|
+
C->>P: POST /api/transcode-sessions/:sessionId/release (reason)
|
|
211
|
+
P->>P: remove consumer
|
|
212
|
+
alt no consumers left
|
|
213
|
+
P->>F: stop process and cleanup
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Notes
|
|
218
|
+
|
|
219
|
+
- HLS session files are stored in OS temp directory and cleaned up automatically.
|
|
220
|
+
- Transcode sessions are cached by `sourceKey:fileIndex:mode`.
|
|
221
|
+
- ffmpeg is bundled via `ffmpeg-static` for out-of-the-box availability.
|
|
222
|
+
- Source registry is in-memory and bounded (old entries are evicted).
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
This project is distributed under GPL-3.0-or-later (see `LICENSE`).
|
|
227
|
+
|
|
228
|
+
Third-party dependencies keep their own licenses. In particular, bundled ffmpeg binaries
|
|
229
|
+
provided by `ffmpeg-static` are GPL-compatible.
|
|
230
|
+
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import ffmpegStatic from "ffmpeg-static";
|
|
7
|
+
import { startProxyServer } from "../server.js";
|
|
8
|
+
import { registerClient, sendHeartbeat } from "../services/registry-api.js";
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
const HELP_EXAMPLES = `
|
|
13
|
+
Examples:
|
|
14
|
+
torrent-tv-proxy --server-url http://localhost:8080
|
|
15
|
+
torrent-tv-proxy --server-url http://localhost:8080 --host 0.0.0.0 --port 9090
|
|
16
|
+
torrent-tv-proxy --server-url http://localhost:8080 --public-base-url https://proxy.example.com
|
|
17
|
+
torrent-tv-proxy --server-url http://localhost:8080 --ffmpeg-bin /usr/local/bin/ffmpeg
|
|
18
|
+
torrent-tv-proxy --server-url http://localhost:8080 --no-transcode-audio
|
|
19
|
+
|
|
20
|
+
Notes:
|
|
21
|
+
- --help prints this message and exits with code 0.
|
|
22
|
+
- Video transcode is available automatically for per-session fallback when requested by client API.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
if (process.argv.includes("help")) {
|
|
26
|
+
process.argv = [process.argv[0], process.argv[1], "--help"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.name("torrent-proxy-client")
|
|
31
|
+
.description("Expose torrent files over HTTP stream endpoints for browser playback.")
|
|
32
|
+
.requiredOption("--server-url <url>", "Registry server base URL")
|
|
33
|
+
.option("--public-base-url <url>", "Direct URL advertised to browser clients")
|
|
34
|
+
.option("--host <host>", "Local bind host", "127.0.0.1")
|
|
35
|
+
.option("--port <port>", "Local HTTP port", "9090")
|
|
36
|
+
.option("--id <id>", "Stable client id")
|
|
37
|
+
.option("--name <name>", "Display name")
|
|
38
|
+
.option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
|
|
39
|
+
.option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
|
|
40
|
+
.option("--token <token>", "Registration token", "")
|
|
41
|
+
.addHelpText("after", HELP_EXAMPLES);
|
|
42
|
+
|
|
43
|
+
program.parse(process.argv);
|
|
44
|
+
const options = program.opts();
|
|
45
|
+
|
|
46
|
+
const localPort = Number(options.port);
|
|
47
|
+
if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
|
|
48
|
+
console.error(chalk.red("[proxy-client] Invalid --port value."));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
|
|
53
|
+
const bindHost = String(options.host);
|
|
54
|
+
const explicitBaseUrl = options.publicBaseUrl
|
|
55
|
+
? String(options.publicBaseUrl).replace(/\/+$/, "")
|
|
56
|
+
: "";
|
|
57
|
+
const clientId = options.id ? String(options.id) : crypto.randomUUID();
|
|
58
|
+
const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
|
|
59
|
+
const token = String(options.token ?? "");
|
|
60
|
+
const transcodeAudio = options.transcodeAudio !== false;
|
|
61
|
+
const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
|
|
62
|
+
const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
|
|
63
|
+
|
|
64
|
+
function assertFfmpegAvailability() {
|
|
65
|
+
const probe = spawnSync(ffmpegBin, ["-version"], {
|
|
66
|
+
stdio: "ignore",
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
timeout: 5000
|
|
69
|
+
});
|
|
70
|
+
if (probe.error) {
|
|
71
|
+
const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
|
|
72
|
+
throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
|
|
73
|
+
}
|
|
74
|
+
if (typeof probe.status === "number" && probe.status !== 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let registrationInProgress = false;
|
|
82
|
+
let heartbeatTimer = null;
|
|
83
|
+
let app = null;
|
|
84
|
+
let actualPort = localPort;
|
|
85
|
+
let shutdownInProgress = false;
|
|
86
|
+
|
|
87
|
+
async function registerClientSafe() {
|
|
88
|
+
if (registrationInProgress) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
registrationInProgress = true;
|
|
92
|
+
try {
|
|
93
|
+
const result = await registerClient({
|
|
94
|
+
serverUrl,
|
|
95
|
+
id: clientId,
|
|
96
|
+
name: clientName,
|
|
97
|
+
baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
|
|
98
|
+
token
|
|
99
|
+
});
|
|
100
|
+
console.log(chalk.green(`[proxy-client] Registered: ${JSON.stringify(result.client)}`));
|
|
101
|
+
} finally {
|
|
102
|
+
registrationInProgress = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function shutdown(signal) {
|
|
107
|
+
if (shutdownInProgress) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
shutdownInProgress = true;
|
|
111
|
+
if (heartbeatTimer) {
|
|
112
|
+
clearInterval(heartbeatTimer);
|
|
113
|
+
heartbeatTimer = null;
|
|
114
|
+
}
|
|
115
|
+
console.log(chalk.yellow(`[proxy-client] Received ${signal}, shutting down...`));
|
|
116
|
+
try {
|
|
117
|
+
if (app) {
|
|
118
|
+
await app.close();
|
|
119
|
+
}
|
|
120
|
+
process.exit(0);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
console.error(chalk.red(`[proxy-client] Shutdown failed: ${message}`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
if (transcodeAudio) {
|
|
130
|
+
assertFfmpegAvailability();
|
|
131
|
+
}
|
|
132
|
+
const started = await startProxyServer({
|
|
133
|
+
host: bindHost,
|
|
134
|
+
port: localPort,
|
|
135
|
+
transcodeAudio,
|
|
136
|
+
ffmpegBin
|
|
137
|
+
});
|
|
138
|
+
app = started.app;
|
|
139
|
+
actualPort = started.port;
|
|
140
|
+
const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
|
|
141
|
+
|
|
142
|
+
console.log(chalk.cyan(`[proxy-client] Local stream endpoint: http://${bindHost}:${actualPort}/stream`));
|
|
143
|
+
console.log(chalk.cyan(`[proxy-client] Advertised direct URL: ${directBaseUrl}`));
|
|
144
|
+
if (transcodeAudio) {
|
|
145
|
+
console.log(
|
|
146
|
+
chalk.cyan(`[proxy-client] Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await registerClientSafe();
|
|
151
|
+
|
|
152
|
+
heartbeatTimer = setInterval(async () => {
|
|
153
|
+
const status = await sendHeartbeat({
|
|
154
|
+
serverUrl,
|
|
155
|
+
id: clientId,
|
|
156
|
+
token
|
|
157
|
+
});
|
|
158
|
+
if (status === 404) {
|
|
159
|
+
console.log(chalk.yellow("[proxy-client] Heartbeat returned 404, re-registering..."));
|
|
160
|
+
try {
|
|
161
|
+
await registerClientSafe();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
console.error(chalk.red(`[proxy-client] Re-register failed: ${message}`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}, 20_000);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
console.error(chalk.red(`[proxy-client] ${message}`));
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
process.on("SIGINT", () => {
|
|
175
|
+
void shutdown("SIGINT");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
process.on("SIGTERM", () => {
|
|
179
|
+
void shutdown("SIGTERM");
|
|
180
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@torrent-tv/proxy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
|
+
"license": "GPL-3.0-or-later",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"torrent-tv-proxy": "./bin/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"patch": "npm version patch && npm publish && git push --follow-tags",
|
|
15
|
+
"minor": "npm version minor && npm publish && git push --follow-tags",
|
|
16
|
+
"major": "npm version major && npm publish && git push --follow-tags",
|
|
17
|
+
"start": "node ./bin/cli.js",
|
|
18
|
+
"dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@fastify/cors": "^11.2.0",
|
|
22
|
+
"@fastify/helmet": "^13.0.2",
|
|
23
|
+
"@fastify/static": "^9.1.3",
|
|
24
|
+
"chalk": "^5.4.1",
|
|
25
|
+
"commander": "^12.1.0",
|
|
26
|
+
"fastify": "^5.8.5",
|
|
27
|
+
"ffmpeg-static": "^5.3.0",
|
|
28
|
+
"get-port": "^7.1.0",
|
|
29
|
+
"webtorrent": "^2.8.4"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function getPayload(body) {
|
|
2
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
3
|
+
return body;
|
|
4
|
+
}
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner }) {
|
|
9
|
+
const payload = getPayload(req.body);
|
|
10
|
+
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
11
|
+
const fileIndex = Number(payload.fileIndex);
|
|
12
|
+
const userAgent = typeof payload.userAgent === "string" ? payload.userAgent : "";
|
|
13
|
+
|
|
14
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
15
|
+
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const plan = await playbackPlanner.getPlan({ sourceKey, fileIndex, userAgent });
|
|
20
|
+
return reply.send(plan);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
23
|
+
return reply.code(404).send({ error: error.message });
|
|
24
|
+
}
|
|
25
|
+
if (error instanceof Error && error.code === "FILE_NOT_FOUND") {
|
|
26
|
+
return reply.code(404).send({ error: error.message });
|
|
27
|
+
}
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return reply.code(500).send({ error: `Failed to prepare playback plan: ${message}` });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function getPayload(body) {
|
|
2
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
3
|
+
return body;
|
|
4
|
+
}
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function handleApiSourcesPost(req, reply, { sourceRegistry }) {
|
|
9
|
+
const payload = getPayload(req.body);
|
|
10
|
+
const sourceType = typeof payload.sourceType === "string" ? payload.sourceType : "";
|
|
11
|
+
const source = typeof payload.source === "string" ? payload.source : "";
|
|
12
|
+
if (!sourceType || !source) {
|
|
13
|
+
return reply.code(400).send({ error: "sourceType and source are required." });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sourceKey = sourceRegistry.upsert(sourceType, source);
|
|
17
|
+
return reply.send({ sourceKey });
|
|
18
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function getPayload(body) {
|
|
2
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
3
|
+
return body;
|
|
4
|
+
}
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager }) {
|
|
9
|
+
const payload = getPayload(req.body);
|
|
10
|
+
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
11
|
+
const fileIndex = Number(payload.fileIndex);
|
|
12
|
+
const transcodeVideo = payload.transcodeVideo === true;
|
|
13
|
+
const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
|
|
14
|
+
const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
|
|
15
|
+
|
|
16
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
17
|
+
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const session = await hlsSessionManager.createOrGetSession({
|
|
22
|
+
sourceKey,
|
|
23
|
+
fileIndex,
|
|
24
|
+
transcodeVideo,
|
|
25
|
+
consumerId,
|
|
26
|
+
fileName
|
|
27
|
+
});
|
|
28
|
+
return reply.send({
|
|
29
|
+
sessionId: session.id,
|
|
30
|
+
playlistPath: `/transcode/${session.id}/index.m3u8`
|
|
31
|
+
});
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
|
|
34
|
+
return reply.code(409).send({ error: error.message });
|
|
35
|
+
}
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export async function handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager }) {
|
|
2
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
3
|
+
if (!sessionId) {
|
|
4
|
+
return reply.code(400).send({ error: "sessionId is required." });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const progress = hlsSessionManager.getSessionProgress(sessionId);
|
|
8
|
+
if (!progress) {
|
|
9
|
+
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return reply.send(progress);
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function getPayload(body) {
|
|
2
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
3
|
+
return body;
|
|
4
|
+
}
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager }) {
|
|
9
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
10
|
+
const payload = getPayload(req.body);
|
|
11
|
+
const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
|
|
12
|
+
const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
|
|
13
|
+
if (!sessionId || !consumerId) {
|
|
14
|
+
return reply.code(400).send({ error: "sessionId and consumerId are required." });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const released = await hlsSessionManager.releaseSessionConsumer(sessionId, consumerId, reason);
|
|
18
|
+
if (!released) {
|
|
19
|
+
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
20
|
+
}
|
|
21
|
+
return reply.send({ ok: true });
|
|
22
|
+
}
|