@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.5.11",
3
+ "version": "2.6.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- bodyLimit: 10 * 1024 * 1024
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
- * @param {number} processedSeconds - Seconds of content encoded so far.
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 percent = Math.max(0, Math.min(100, (processed / safeTotal) * 100));
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=""] - Caller ID for reference counting.
346
- * @param {string} [options.fileName=""] - Display name for log output.
347
- * @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
348
- * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
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
- "-hide_banner",
426
- "-nostats",
427
- "-loglevel",
428
- "error",
429
- "-progress",
430
- "pipe:1",
431
- "-i",
432
- inputUrl.toString(),
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
- "vod",
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
- processedSeconds: 0,
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
- const requiresTranscode = !audioCodec || !DIRECT_AUDIO_CODECS.has(audioCodec);
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,
@@ -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
- if (state === "disconnected" || state === "failed" || state === "closed") {
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
  };