@torrent-tv/proxy 2.5.11 → 2.6.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/CHANGELOG.md +25 -0
- package/README.md +38 -4
- package/bin/cli.js +5 -0
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +42 -0
- package/routes/api/transcode-sessions/post.js +6 -1
- package/server.js +7 -1
- package/services/hls-session-manager.js +56 -23
- package/services/playback-planner.js +12 -1
- package/services/torrent-pool.js +107 -0
- package/services/webrtc-manager.js +3 -1
- package/utils/logger.js +14 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
## 2.5.15
|
|
2
|
+
|
|
3
|
+
- **New**: `GET /api/sources/:sourceKey/stats?fileIndex=N` — returns live torrent stats: connected peer count, download/upload speed, per-file download progress and size. Used by the browser to show meaningful feedback while waiting for file metadata.
|
|
4
|
+
- **New**: `TorrentPool.getFileStats()` — reads `torrent.numPeers`, `torrent.downloadSpeed`, `file.progress`, `file.downloaded`, `file.length` from the WebTorrent instance.
|
|
5
|
+
|
|
6
|
+
## 2.5.14
|
|
7
|
+
|
|
8
|
+
- **New**: `TorrentPool.prefetchFileEdges()` — opens WebTorrent read streams for the first 256 KB and last 2 MB of a file before ffprobe runs. This prioritises the torrent pieces that contain file headers (FTYP box) and the MOOV atom (typically at end of non-faststart MP4), ensuring codec and duration detection succeeds even for freshly-added torrents. Timeout is 5 minutes; failure is non-blocking.
|
|
9
|
+
- **New**: Seek-to-position HLS transcode — `createOrGetSession` now accepts `startPositionSeconds`. ffmpeg is started with `-ss <pos>` (fast keyframe seek before `-i`) and `-output_ts_offset <pos>` so that output PTS matches the original timeline, keeping `video.currentTime` correct after a seek restart. Session cache key includes the rounded start position (10 s buckets) so nearby seeks share a session.
|
|
10
|
+
- **New**: `POST /api/transcode-sessions` accepts `startPositionSeconds` in the request body.
|
|
11
|
+
- **Chore**: `computeProgressMetrics` updated to compute percentage relative to the remaining duration from the seek point rather than the full file.
|
|
12
|
+
|
|
13
|
+
## 2.5.13
|
|
14
|
+
|
|
15
|
+
- **Fix**: HLS playlist type changed from `vod` to `event`. With `vod`, ffmpeg only wrote `#EXT-X-ENDLIST` after transcoding the entire file, blocking playback start for large files indefinitely.
|
|
16
|
+
- **Fix**: `waitForHlsPlaylist` in the browser now unblocks as soon as `#EXTINF:` appears (first segment ready) instead of waiting for `#EXT-X-ENDLIST`. Latency to first frame drops from minutes to seconds.
|
|
17
|
+
- **Fix**: Codec detection in `PlaybackPlanner` — when ffprobe returns an empty audio codec (MOOV atom not yet downloaded), the plan now defaults to `direct` mode instead of forcing HLS transcode. The browser's range-request mechanism fetches the MOOV atom on demand.
|
|
18
|
+
|
|
19
|
+
## 2.5.12
|
|
20
|
+
|
|
21
|
+
- **New**: Timestamps (`HH:MM:SS.mmm`) added to all log lines.
|
|
22
|
+
- **New**: Proxy version logged at startup (`Starting @torrent-tv/proxy vX.Y.Z`).
|
|
23
|
+
- **Fix**: WebRTC session torn down immediately after connect — `disconnected` ICE state is transient and no longer triggers `closeSession()`. Only `failed` and `closed` are terminal. This fixed data channels opening and closing within milliseconds.
|
|
24
|
+
- **Fix**: Fastify `bodyLimit` raised from 10 MB to 256 MB — large `.torrent` files encoded as base64 JSON exceeded the previous limit.
|
|
25
|
+
|
|
1
26
|
## 2.5.7
|
|
2
27
|
|
|
3
28
|
- **Fix**: WebRTC connection failure behind symmetric NAT — all ICE candidates (private and public) are now sent to the browser immediately. The browser attempts all paths in parallel; the local LAN path succeeds when browser and proxy are on the same network. Chrome's Private Network Access dialog appears once on first connect.
|
package/README.md
CHANGED
|
@@ -122,9 +122,17 @@ The browser uses these metrics together with tunnel RTT to score proxies:
|
|
|
122
122
|
score = memFree × 0.4 + (1 - clamp(cpuLoad, 0, 1)) × 0.4 − (rttMs / 2000) × 0.2
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
### PlaybackPlanner
|
|
126
|
+
|
|
127
|
+
Determines whether a file should be streamed directly or transcoded to HLS, and caches the result per `(sourceKey, fileIndex)`.
|
|
128
|
+
|
|
129
|
+
Before running ffprobe it calls `TorrentPool.prefetchFileEdges()` which opens WebTorrent read streams for the first 256 KB and last 2 MB of the file. This forces WebTorrent to prioritise those torrent pieces, ensuring the MOOV atom (usually at the end of non-faststart MP4 files) is available before codec detection runs. The timeout is 5 minutes; if it elapses the plan still proceeds with whatever data is available and defaults to `direct` mode when the codec is unknown.
|
|
130
|
+
|
|
125
131
|
### HlsSessionManager & ffmpeg
|
|
126
132
|
|
|
127
|
-
Creates and manages ffmpeg-based HLS transcode sessions. Sessions are keyed by `sourceKey:fileIndex:mode` and shared across consumers.
|
|
133
|
+
Creates and manages ffmpeg-based HLS transcode sessions. Sessions are keyed by `sourceKey:fileIndex:mode:startPosition` and shared across consumers.
|
|
134
|
+
|
|
135
|
+
**Seek-to-position:** `createOrGetSession` accepts `startPositionSeconds` which allows restarting a transcode from an arbitrary point. ffmpeg is invoked with `-ss <N>` (fast keyframe seek before `-i`) and `-output_ts_offset <N>` (shifts output PTS) so that `video.currentTime` in the browser reflects the original timeline position rather than resetting to zero. Start positions are rounded to 10-second buckets for session reuse.
|
|
128
136
|
|
|
129
137
|
```mermaid
|
|
130
138
|
sequenceDiagram
|
|
@@ -211,6 +219,27 @@ GET /stream?sourceKey=<key>&fileIndex=0
|
|
|
211
219
|
|
|
212
220
|
Supports HTTP Range requests.
|
|
213
221
|
|
|
222
|
+
### Source stats
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
GET /api/sources/:sourceKey/stats?fileIndex=0
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Returns live torrent statistics for polling during the metadata wait phase:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"numPeers": 4,
|
|
233
|
+
"downloadSpeed": 1258291,
|
|
234
|
+
"uploadSpeed": 0,
|
|
235
|
+
"fileProgress": 0.008,
|
|
236
|
+
"fileDownloaded": 10485760,
|
|
237
|
+
"fileLength": 1299000000
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`fileProgress` is 0–1 and reflects only the pieces that have been downloaded so far (initially the prefetched head + tail). `downloadSpeed` and `uploadSpeed` are in bytes/s.
|
|
242
|
+
|
|
214
243
|
### Create HLS transcode session
|
|
215
244
|
|
|
216
245
|
```bash
|
|
@@ -222,10 +251,13 @@ Content-Type: application/json
|
|
|
222
251
|
"fileIndex": 0,
|
|
223
252
|
"transcodeVideo": false,
|
|
224
253
|
"consumerId": "uuid",
|
|
225
|
-
"fileName": "Episode01.mkv"
|
|
254
|
+
"fileName": "Episode01.mkv",
|
|
255
|
+
"startPositionSeconds": 300
|
|
226
256
|
}
|
|
227
257
|
```
|
|
228
258
|
|
|
259
|
+
`startPositionSeconds` is optional (default `0`). When set, ffmpeg fast-seeks to the specified position and shifts output timestamps accordingly, enabling seek-to-position playback.
|
|
260
|
+
|
|
229
261
|
Response: `{ "sessionId": "…", "playlistPath": "/transcode/<id>/index.m3u8" }`
|
|
230
262
|
|
|
231
263
|
### Poll transcode progress
|
|
@@ -234,7 +266,7 @@ Response: `{ "sessionId": "…", "playlistPath": "/transcode/<id>/index.m3u8" }`
|
|
|
234
266
|
GET /api/transcode-sessions/:sessionId/progress
|
|
235
267
|
```
|
|
236
268
|
|
|
237
|
-
Returns: `percent`, `processedSeconds`, `totalSeconds`, `remainingSeconds`, `speed`, `warmupPercent`, `warmupRemainingSeconds`.
|
|
269
|
+
Returns: `percent`, `processedSeconds`, `startPositionSeconds`, `totalSeconds`, `remainingSeconds`, `speed`, `warmupPercent`, `warmupRemainingSeconds`.
|
|
238
270
|
|
|
239
271
|
### Release consumer
|
|
240
272
|
|
|
@@ -325,9 +357,11 @@ sequenceDiagram
|
|
|
325
357
|
## Notes
|
|
326
358
|
|
|
327
359
|
- HLS session temp files are in the OS temp directory and cleaned up automatically.
|
|
328
|
-
- Transcode sessions are cached by `sourceKey:fileIndex:mode` and shared across consumers.
|
|
360
|
+
- Transcode sessions are cached by `sourceKey:fileIndex:mode:startPosition` and shared across consumers.
|
|
329
361
|
- The source registry is in-memory and bounded (old entries evicted).
|
|
330
362
|
- The proxy reconnects to the server automatically on tunnel disconnect.
|
|
363
|
+
- `prefetchFileEdges` downloads only the file's head and tail (≈ 2.3 MB total), not the full file, before codec detection runs.
|
|
364
|
+
- When `startPositionSeconds > 0`, ffmpeg uses fast seek (`-ss` before `-i`) — it starts from the nearest keyframe at or before the requested position. The first few frames may be slightly before the exact seek time.
|
|
331
365
|
|
|
332
366
|
## License
|
|
333
367
|
|
package/bin/cli.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Command } from "commander";
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
14
15
|
import ffmpegStatic from "ffmpeg-static";
|
|
15
16
|
import { startProxyServer } from "../server.js";
|
|
16
17
|
import { registerClient } from "../services/registry-api.js";
|
|
@@ -20,6 +21,9 @@ import { createDataChannelHandler } from "../services/data-channel-handler.js";
|
|
|
20
21
|
import { collectHealthMetrics } from "../services/health-collector.js";
|
|
21
22
|
import { logger } from "../utils/logger.js";
|
|
22
23
|
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { version: PROXY_VERSION } = require("../package.json");
|
|
26
|
+
|
|
23
27
|
const program = new Command();
|
|
24
28
|
|
|
25
29
|
const HELP_EXAMPLES = `
|
|
@@ -205,6 +209,7 @@ try {
|
|
|
205
209
|
actualPort = started.port;
|
|
206
210
|
const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
|
|
207
211
|
|
|
212
|
+
logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
|
|
208
213
|
logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
|
|
209
214
|
logger.info(`Advertised direct URL: ${directBaseUrl}`);
|
|
210
215
|
if (transcodeAudio) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return download statistics for a registered torrent source.
|
|
3
|
+
*
|
|
4
|
+
* Provides peer count, transfer speeds, and per-file download progress so
|
|
5
|
+
* that the browser client can display meaningful feedback while the proxy is
|
|
6
|
+
* pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/sources/:sourceKey/stats?fileIndex=N
|
|
9
|
+
*
|
|
10
|
+
* @param {import("fastify").FastifyRequest} req
|
|
11
|
+
* @param {import("fastify").FastifyReply} reply
|
|
12
|
+
* @param {{
|
|
13
|
+
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
14
|
+
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
15
|
+
* }} deps
|
|
16
|
+
* @returns {Promise<void>}
|
|
17
|
+
*/
|
|
18
|
+
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
19
|
+
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
20
|
+
if (!sourceKey) {
|
|
21
|
+
return reply.code(400).send({ error: "sourceKey is required." });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
25
|
+
if (!sourceRecord) {
|
|
26
|
+
return reply.code(404).send({ error: "Source key was not found." });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let torrent;
|
|
30
|
+
try {
|
|
31
|
+
// getTorrent resolves immediately when the torrent is already loaded.
|
|
32
|
+
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
39
|
+
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
40
|
+
|
|
41
|
+
return reply.send(torrentPool.getFileStats(torrent, fileIndex));
|
|
42
|
+
}
|
|
@@ -33,6 +33,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
33
33
|
const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
|
|
34
34
|
const targetWidth = Number(payload.targetWidth);
|
|
35
35
|
const targetHeight = Number(payload.targetHeight);
|
|
36
|
+
const startPositionSeconds = Number(payload.startPositionSeconds);
|
|
36
37
|
|
|
37
38
|
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
38
39
|
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
@@ -47,7 +48,11 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
|
|
|
47
48
|
consumerId,
|
|
48
49
|
fileName,
|
|
49
50
|
targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
|
|
50
|
-
targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0
|
|
51
|
+
targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
|
|
52
|
+
startPositionSeconds:
|
|
53
|
+
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
54
|
+
? startPositionSeconds
|
|
55
|
+
: 0
|
|
51
56
|
});
|
|
52
57
|
return reply.send({
|
|
53
58
|
sessionId: session.id,
|
package/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import { handleHealthGet } from "./routes/health/get.js";
|
|
17
17
|
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
18
18
|
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
19
|
+
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
19
20
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
20
21
|
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
21
22
|
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
@@ -62,7 +63,9 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
62
63
|
*/
|
|
63
64
|
export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }) {
|
|
64
65
|
const app = Fastify({
|
|
65
|
-
|
|
66
|
+
// No practical body-size limit — the proxy server is localhost-only and
|
|
67
|
+
// receives torrent source payloads that may be arbitrarily large.
|
|
68
|
+
bodyLimit: 256 * 1024 * 1024 // 256 MB
|
|
66
69
|
});
|
|
67
70
|
|
|
68
71
|
await app.register(fastifyHelmet, {
|
|
@@ -107,6 +110,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
|
|
|
107
110
|
app.post("/api/sources", async (req, reply) =>
|
|
108
111
|
handleApiSourcesPost(req, reply, { sourceRegistry })
|
|
109
112
|
);
|
|
113
|
+
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
114
|
+
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
|
|
115
|
+
);
|
|
110
116
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
111
117
|
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
112
118
|
);
|
|
@@ -188,17 +188,28 @@ function formatSeconds(seconds) {
|
|
|
188
188
|
/**
|
|
189
189
|
* Compute derived progress metrics from raw ffmpeg output values.
|
|
190
190
|
*
|
|
191
|
-
*
|
|
191
|
+
* When `startPositionSeconds` is provided (seek-restart case), progress is
|
|
192
|
+
* computed relative to the remaining duration after the seek point so the
|
|
193
|
+
* percent value reflects transcoding of the requested segment, not the whole
|
|
194
|
+
* file.
|
|
195
|
+
*
|
|
196
|
+
* @param {number} processedSeconds - Output timestamp of last encoded frame.
|
|
192
197
|
* @param {number | null} totalSeconds - Total duration, or `null` if unknown.
|
|
198
|
+
* @param {number} [startPositionSeconds=0] - Seek offset used for this session.
|
|
193
199
|
* @returns {{ totalSeconds: number | null, percent: number | null, remainingSeconds: number | null, processedSeconds: number }}
|
|
194
200
|
*/
|
|
195
|
-
function computeProgressMetrics(processedSeconds, totalSeconds) {
|
|
201
|
+
function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSeconds = 0) {
|
|
196
202
|
const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
|
|
203
|
+
const startOffset = Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
204
|
+
? startPositionSeconds
|
|
205
|
+
: 0;
|
|
197
206
|
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
|
198
207
|
return { totalSeconds: null, percent: null, remainingSeconds: null, processedSeconds: processed };
|
|
199
208
|
}
|
|
200
209
|
const safeTotal = totalSeconds;
|
|
201
|
-
const
|
|
210
|
+
const segmentDuration = Math.max(1, safeTotal - startOffset);
|
|
211
|
+
const segmentProcessed = Math.max(0, processed - startOffset);
|
|
212
|
+
const percent = Math.max(0, Math.min(100, (segmentProcessed / segmentDuration) * 100));
|
|
202
213
|
const remainingSeconds = Math.max(0, safeTotal - processed);
|
|
203
214
|
return {
|
|
204
215
|
totalSeconds: safeTotal,
|
|
@@ -342,10 +353,11 @@ export class HlsSessionManager {
|
|
|
342
353
|
* @param {number} options.fileIndex - Zero-based file index in the torrent.
|
|
343
354
|
* @param {boolean} [options.transcodeVideo=false]
|
|
344
355
|
* @param {boolean} [options.transcodeAudio=false]
|
|
345
|
-
* @param {string} [options.consumerId=""]
|
|
346
|
-
* @param {string} [options.fileName=""]
|
|
347
|
-
* @param {number} [options.targetWidth=0]
|
|
348
|
-
* @param {number} [options.targetHeight=0]
|
|
356
|
+
* @param {string} [options.consumerId=""] - Caller ID for reference counting.
|
|
357
|
+
* @param {string} [options.fileName=""] - Display name for log output.
|
|
358
|
+
* @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
|
|
359
|
+
* @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
|
|
360
|
+
* @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
|
|
349
361
|
* @returns {Promise<HlsSession>}
|
|
350
362
|
*/
|
|
351
363
|
async createOrGetSession({
|
|
@@ -356,7 +368,8 @@ export class HlsSessionManager {
|
|
|
356
368
|
consumerId = "",
|
|
357
369
|
fileName = "",
|
|
358
370
|
targetWidth = 0,
|
|
359
|
-
targetHeight = 0
|
|
371
|
+
targetHeight = 0,
|
|
372
|
+
startPositionSeconds = 0
|
|
360
373
|
}) {
|
|
361
374
|
if (!this.enabled) {
|
|
362
375
|
const error = new Error("Audio transcoding is disabled on this proxy.");
|
|
@@ -366,13 +379,20 @@ export class HlsSessionManager {
|
|
|
366
379
|
|
|
367
380
|
const normalizedTargetWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0;
|
|
368
381
|
const normalizedTargetHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0;
|
|
382
|
+
// Round seek position to the nearest 10 s so that two consumers seeking
|
|
383
|
+
// to similar positions can share the same ffmpeg session.
|
|
384
|
+
const normalizedStartPosition =
|
|
385
|
+
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
386
|
+
? Math.round(startPositionSeconds / 10) * 10
|
|
387
|
+
: 0;
|
|
369
388
|
const sourceMapKey = [
|
|
370
389
|
sourceKey,
|
|
371
390
|
String(fileIndex),
|
|
372
391
|
transcodeVideo ? "video" : "audio",
|
|
373
392
|
transcodeAudio ? "a1" : "a0",
|
|
374
393
|
String(normalizedTargetWidth),
|
|
375
|
-
String(normalizedTargetHeight)
|
|
394
|
+
String(normalizedTargetHeight),
|
|
395
|
+
String(normalizedStartPosition)
|
|
376
396
|
].join(":");
|
|
377
397
|
const existingId = this.sessionIdBySource.get(sourceMapKey);
|
|
378
398
|
if (existingId) {
|
|
@@ -421,15 +441,23 @@ export class HlsSessionManager {
|
|
|
421
441
|
? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
|
|
422
442
|
: ["-c:a", "copy"];
|
|
423
443
|
|
|
424
|
-
const args = [
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
"-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
444
|
+
const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
|
|
445
|
+
|
|
446
|
+
// Fast keyframe-level seek before -i. This is efficient because ffmpeg
|
|
447
|
+
// skips decoding all frames before the target position.
|
|
448
|
+
if (normalizedStartPosition > 0) {
|
|
449
|
+
args.push("-ss", String(normalizedStartPosition));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
args.push("-i", inputUrl.toString());
|
|
453
|
+
|
|
454
|
+
// Shift output timestamps to match the original timeline so that
|
|
455
|
+
// video.currentTime reflects the seeked position, not a reset-to-zero.
|
|
456
|
+
if (normalizedStartPosition > 0) {
|
|
457
|
+
args.push("-output_ts_offset", String(normalizedStartPosition));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
args.push(
|
|
433
461
|
"-map",
|
|
434
462
|
"0:v:0?",
|
|
435
463
|
"-map",
|
|
@@ -443,13 +471,13 @@ export class HlsSessionManager {
|
|
|
443
471
|
"-hls_list_size",
|
|
444
472
|
"0",
|
|
445
473
|
"-hls_playlist_type",
|
|
446
|
-
"
|
|
474
|
+
"event",
|
|
447
475
|
"-hls_flags",
|
|
448
476
|
"independent_segments+temp_file",
|
|
449
477
|
"-hls_segment_filename",
|
|
450
478
|
"segment-%05d.ts",
|
|
451
479
|
PLAYLIST_FILE_NAME
|
|
452
|
-
|
|
480
|
+
);
|
|
453
481
|
|
|
454
482
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
455
483
|
cwd: sessionDir,
|
|
@@ -469,10 +497,13 @@ export class HlsSessionManager {
|
|
|
469
497
|
consumers: new Set(consumerId ? [consumerId] : []),
|
|
470
498
|
progress: {
|
|
471
499
|
state: "starting",
|
|
472
|
-
|
|
500
|
+
// With -output_ts_offset the first out_time will be ≈ normalizedStartPosition,
|
|
501
|
+
// so initialise processedSeconds to that value for consistent percent math.
|
|
502
|
+
processedSeconds: normalizedStartPosition,
|
|
503
|
+
startPositionSeconds: normalizedStartPosition,
|
|
473
504
|
totalSeconds: Number.isFinite(durationSeconds) ? durationSeconds : null,
|
|
474
505
|
percent: null,
|
|
475
|
-
remainingSeconds: Number.isFinite(durationSeconds) ? durationSeconds : null,
|
|
506
|
+
remainingSeconds: Number.isFinite(durationSeconds) ? durationSeconds - normalizedStartPosition : null,
|
|
476
507
|
speed: "",
|
|
477
508
|
updatedAt: Date.now(),
|
|
478
509
|
lastLoggedAt: 0
|
|
@@ -512,7 +543,8 @@ export class HlsSessionManager {
|
|
|
512
543
|
}
|
|
513
544
|
const metrics = computeProgressMetrics(
|
|
514
545
|
session.progress.processedSeconds,
|
|
515
|
-
session.progress.totalSeconds
|
|
546
|
+
session.progress.totalSeconds,
|
|
547
|
+
session.progress.startPositionSeconds
|
|
516
548
|
);
|
|
517
549
|
session.progress.percent = metrics.percent;
|
|
518
550
|
session.progress.remainingSeconds = metrics.remainingSeconds;
|
|
@@ -708,6 +740,7 @@ export class HlsSessionManager {
|
|
|
708
740
|
sessionId: session.id,
|
|
709
741
|
state: session.progress.state,
|
|
710
742
|
processedSeconds: session.progress.processedSeconds,
|
|
743
|
+
startPositionSeconds: session.progress.startPositionSeconds ?? 0,
|
|
711
744
|
totalSeconds: session.progress.totalSeconds,
|
|
712
745
|
percent: session.progress.percent,
|
|
713
746
|
remainingSeconds: session.progress.remainingSeconds,
|
|
@@ -180,13 +180,24 @@ export function createPlaybackPlanner({
|
|
|
180
180
|
return plan;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Pre-fetch file edges (head + tail) before probing so that WebTorrent
|
|
184
|
+
// has the MOOV atom (or MKV EBML headers) ready for ffprobe.
|
|
185
|
+
// Without this, ffprobe times out on fresh torrents whose MOOV sits at
|
|
186
|
+
// the end of the file and hasn't been downloaded yet.
|
|
187
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
188
|
+
|
|
183
189
|
const { audioCodec, videoCodec } = await probeStreamCodecs({
|
|
184
190
|
ffmpegBin,
|
|
185
191
|
inputUrl: directUrl,
|
|
186
192
|
userAgent
|
|
187
193
|
});
|
|
188
194
|
|
|
189
|
-
|
|
195
|
+
// Only transcode when the codec is known AND not natively supported.
|
|
196
|
+
// When ffprobe cannot detect the codec (e.g. the torrent has just started
|
|
197
|
+
// downloading and the MOOV atom at the end of the MP4 is not yet available),
|
|
198
|
+
// fall back to "direct" so the browser can attempt native playback. The
|
|
199
|
+
// browser-side loading pipeline already has its own transcode fallback.
|
|
200
|
+
const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
|
|
190
201
|
const plan = {
|
|
191
202
|
mode: requiresTranscode ? "hls" : "direct",
|
|
192
203
|
directUrl,
|
package/services/torrent-pool.js
CHANGED
|
@@ -161,6 +161,113 @@ export class TorrentPool {
|
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Return download statistics for a torrent and optionally a specific file.
|
|
166
|
+
*
|
|
167
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
168
|
+
* @param {number | null} [fileIndex] - Zero-based file index, or null for torrent-level only.
|
|
169
|
+
* @returns {{
|
|
170
|
+
* numPeers: number,
|
|
171
|
+
* downloadSpeed: number,
|
|
172
|
+
* uploadSpeed: number,
|
|
173
|
+
* fileProgress: number | null,
|
|
174
|
+
* fileDownloaded: number | null,
|
|
175
|
+
* fileLength: number | null
|
|
176
|
+
* }}
|
|
177
|
+
*/
|
|
178
|
+
getFileStats(torrent, fileIndex = null) {
|
|
179
|
+
const numPeers = typeof torrent?.numPeers === "number" ? torrent.numPeers : 0;
|
|
180
|
+
const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
|
|
181
|
+
const uploadSpeed = typeof torrent?.uploadSpeed === "number" ? torrent.uploadSpeed : 0;
|
|
182
|
+
|
|
183
|
+
const base = { numPeers, downloadSpeed, uploadSpeed };
|
|
184
|
+
|
|
185
|
+
if (fileIndex === null || !Number.isInteger(fileIndex) || !Array.isArray(torrent?.files)) {
|
|
186
|
+
return { ...base, fileProgress: null, fileDownloaded: null, fileLength: null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const file = torrent.files[fileIndex];
|
|
190
|
+
if (!file) {
|
|
191
|
+
return { ...base, fileProgress: null, fileDownloaded: null, fileLength: null };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
fileProgress: typeof file.progress === "number" ? file.progress : 0,
|
|
197
|
+
fileDownloaded: typeof file.downloaded === "number" ? file.downloaded : 0,
|
|
198
|
+
fileLength: typeof file.length === "number" ? file.length : 0
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Pre-fetch the leading and trailing bytes of a torrent file so that
|
|
204
|
+
* WebTorrent prioritises the pieces that contain file headers and footers.
|
|
205
|
+
*
|
|
206
|
+
* For MP4 files the MOOV atom is often placed at the very end of the file
|
|
207
|
+
* (non-faststart encoding). Fetching the tail ensures that ffprobe can
|
|
208
|
+
* identify codecs and duration even for freshly-added torrents without
|
|
209
|
+
* waiting for the rest of the content to download.
|
|
210
|
+
*
|
|
211
|
+
* Resolves once both regions have been fully downloaded, or when the
|
|
212
|
+
* timeout elapses — whichever comes first. Never rejects.
|
|
213
|
+
*
|
|
214
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
215
|
+
* @param {number} fileIndex - Zero-based index into `torrent.files`.
|
|
216
|
+
* @param {object} [options]
|
|
217
|
+
* @param {number} [options.headBytes=262144] - Leading bytes to fetch (default 256 KB).
|
|
218
|
+
* @param {number} [options.tailBytes=2097152] - Trailing bytes to fetch (default 2 MB).
|
|
219
|
+
* @param {number} [options.timeoutMs=300000] - Maximum wait time in milliseconds (default 5 min).
|
|
220
|
+
* @returns {Promise<void>}
|
|
221
|
+
*/
|
|
222
|
+
async prefetchFileEdges(
|
|
223
|
+
torrent,
|
|
224
|
+
fileIndex,
|
|
225
|
+
{ headBytes = 256 * 1024, tailBytes = 2 * 1024 * 1024, timeoutMs = 300_000 } = {}
|
|
226
|
+
) {
|
|
227
|
+
if (!torrent || !Array.isArray(torrent.files)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const file = torrent.files[fileIndex];
|
|
231
|
+
if (!file || typeof file.createReadStream !== "function") {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const fileSize = file.length;
|
|
235
|
+
if (!Number.isFinite(fileSize) || fileSize <= 0) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const safeHeadEnd = Math.min(headBytes, fileSize) - 1;
|
|
240
|
+
const safeTailStart = Math.max(0, fileSize - tailBytes);
|
|
241
|
+
|
|
242
|
+
/** Drain a readable stream, resolving on end/error/close. */
|
|
243
|
+
const drainStream = (stream) =>
|
|
244
|
+
new Promise((resolve) => {
|
|
245
|
+
stream.on("data", () => undefined);
|
|
246
|
+
stream.once("end", resolve);
|
|
247
|
+
stream.once("error", resolve);
|
|
248
|
+
stream.once("close", resolve);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const tasks = [
|
|
253
|
+
// Head: FTYP/MOOV (faststart MP4), EBML header (MKV), etc.
|
|
254
|
+
drainStream(file.createReadStream({ start: 0, end: safeHeadEnd }))
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
// Tail: MOOV atom for non-faststart MP4. Skip when it overlaps the head.
|
|
258
|
+
if (safeTailStart > safeHeadEnd + 1) {
|
|
259
|
+
tasks.push(drainStream(file.createReadStream({ start: safeTailStart, end: fileSize - 1 })));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await Promise.race([
|
|
263
|
+
Promise.all(tasks),
|
|
264
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
|
265
|
+
]);
|
|
266
|
+
} catch (_error) {
|
|
267
|
+
// Best-effort — a prefetch failure must never prevent playback.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
164
271
|
/**
|
|
165
272
|
* Update WebTorrent piece selection to match the current usage map.
|
|
166
273
|
* Files with at least one consumer are selected; all others are deselected.
|
|
@@ -147,7 +147,9 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
|
|
|
147
147
|
|
|
148
148
|
pc.onStateChange((state) => {
|
|
149
149
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: state → ${state}`);
|
|
150
|
-
|
|
150
|
+
// "disconnected" is a transient state — ICE may recover on its own.
|
|
151
|
+
// Only tear down on terminal states: "failed" and "closed".
|
|
152
|
+
if (state === "failed" || state === "closed") {
|
|
151
153
|
closeSession(sessionId);
|
|
152
154
|
}
|
|
153
155
|
});
|
package/utils/logger.js
CHANGED
|
@@ -9,6 +9,16 @@ import chalk from "chalk";
|
|
|
9
9
|
|
|
10
10
|
const PREFIX = "[proxy-client]";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Return the current time as a compact ISO-8601 string, e.g. `12:34:56.789`.
|
|
14
|
+
* Uses only the time portion to keep log lines short.
|
|
15
|
+
*
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function ts() {
|
|
19
|
+
return new Date().toISOString().slice(11, 23); // "HH:MM:SS.mmm"
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/**
|
|
13
23
|
* @typedef {Object} ProxyLogger
|
|
14
24
|
* @property {(message: string) => void} info - Informational message (cyan).
|
|
@@ -23,8 +33,8 @@ const PREFIX = "[proxy-client]";
|
|
|
23
33
|
* @type {ProxyLogger}
|
|
24
34
|
*/
|
|
25
35
|
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}`)),
|
|
36
|
+
info: (message) => console.log(chalk.cyan(`${PREFIX} [${ts()}] ${message}`)),
|
|
37
|
+
success: (message) => console.log(chalk.green(`${PREFIX} [${ts()}] ${message}`)),
|
|
38
|
+
warn: (message) => console.warn(chalk.yellow(`${PREFIX} [${ts()}] ${message}`)),
|
|
39
|
+
error: (message) => console.error(chalk.red(`${PREFIX} [${ts()}] ${message}`)),
|
|
30
40
|
};
|