@torrent-tv/proxy 2.55.1 → 2.55.3

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,9 @@
1
+ ## 2.55.3
2
+
3
+ - **Fix**: A track's subtitle cues are now walked ahead of being asked for, instead of only when a browser first requests them. `cuesHeldFor` only ever read clusters on demand, inside the `/api/subtitles` request itself — cheap once caught up, but the FIRST call for a track had to walk the whole backlog of already-downloaded-but-unparsed clusters serially, with no `pending`/streaming pattern the way the ffmpeg fallback has one. On a film well into playback that backlog is not small (`Minions.and.Monsters.1080p.mkv` indexes over a thousand cluster positions per track), so a viewer who turned subtitles on after watching for a while waited on that catch-up instead of seeing cues appear at once — the opposite of the rule this file states its own reason for existing ("the region the viewer is watching is downloaded before they reach it, so its cues are ready before they are needed"): true of the DATA, not of when it got READ. A new periodic pass in the torrent worker (`warmSubtitleCues`, every 3 s, one per actively-read file) walks new clusters as they arrive, reusing `cuesHeldFor`'s own memoized state — a file nobody has opened costs nothing, and a file being watched is caught up by the time a track is switched on.
4
+
5
+ - **Fix**: The decode pipe's sanity log carried a fixed editorial line — "a reading where these are far apart is worth a second look" — printed on every single reading regardless of whether the two figures actually were, which was noise the first time it ran in the field. The comparison is also now taken over the same window the speed itself is (bytes are snapshotted alongside each progress sample), rather than over the whole run from process start, which read systematically low for no reason but that mismatch. The line only says "worth a second look" when the two figures are actually more than 1.5x apart.
6
+
1
7
  ## 2.55.1
2
8
 
3
9
  - **Fix**: A source is now keyed by its own infohash, not by a hash of the request bytes. A magnet URI and a `.torrent` file for the same content are different bytes, so the old key (`sha1` of the source) named the same film as two unrelated sources — measured 2026-08-19, `a518ff46…` and `7ab2fb5d…` for one infohash `11f09299…` — sharing neither the swarm, nor a cache, nor any work already downloaded, and surfacing as `WebTorrent client error: Cannot add duplicate torrent`. The new key (`services/torrent-source-key.js`) reads the infohash straight out of the magnet's `btih` or the `.torrent`'s own `info` dictionary via `parse-torrent`, synchronously, with no network round trip — so both forms of the same torrent now share one entry from the first request, on both the pool's own map (`torrent-pool.js`) and the worker-thread boundary (`torrent-worker/pool-adapter.js`), instead of relying on WebTorrent's own duplicate-add error to reconcile them after the fact.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.55.1",
3
+ "version": "2.55.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1342,17 +1342,23 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
1342
1342
  return;
1343
1343
  }
1344
1344
  const speed = producedSec / windowSec;
1345
- // Diagnostic only — logged, not acted on. The required rate is what
1346
- // THIS reading implies was needed to keep the decoder fed; the achieved
1347
- // rate is what this process actually pushed over its whole run (a wider
1348
- // span than `windowSec`, since the feed starts before the first kept
1349
- // sample a looser figure is enough for a sanity comparison).
1350
- const elapsedSec = (Date.now() - startedAt) / 1000;
1351
- const achievedMBps = elapsedSec > 0 ? bytesWritten / elapsedSec / 1e6 : 0;
1345
+ // Diagnostic only — logged, not acted on. Both figures are taken over the
1346
+ // SAME window the speed itself is: bytesWritten is snapshotted alongside
1347
+ // every progress sample, so this compares like against like rather than
1348
+ // the achieved rate over the whole run (which starts before the first
1349
+ // kept sample and reads systematically low against the window's own
1350
+ // rate for no reason but that mismatch — measured while building this).
1351
+ const windowBytes = last.bytesWritten - first.bytesWritten;
1352
+ const achievedMBps = windowSec > 0 ? windowBytes / windowSec / 1e6 : 0;
1352
1353
  const requiredMBps = ((stream.megabitsPerSecond * 1e6) / 8) * speed / 1e6;
1354
+ // "Far apart" is stated, not left to the reader to eyeball: outside a
1355
+ // factor of 1.5 either way is bigger than the write-timing slop this
1356
+ // comparison carries on a healthy reading.
1357
+ const farApart = requiredMBps > 0 && (achievedMBps / requiredMBps < 1 / 1.5 || achievedMBps / requiredMBps > 1.5);
1353
1358
  log.info(
1354
1359
  `hwaccel: decode pipe fed ${achievedMBps.toFixed(1)} MB/s, ${requiredMBps.toFixed(1)} MB/s ` +
1355
- `needed for ${speed.toFixed(2)}x — a reading where these are far apart is worth a second look`
1360
+ `needed for ${speed.toFixed(2)}x` +
1361
+ (farApart ? " — far enough apart to be worth a second look" : "")
1356
1362
  );
1357
1363
  resolve({
1358
1364
  speed,
@@ -1399,7 +1405,7 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
1399
1405
  if (line.startsWith("out_time_ms=")) {
1400
1406
  const microseconds = Number(line.slice("out_time_ms=".length));
1401
1407
  if (Number.isFinite(microseconds)) {
1402
- samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6 });
1408
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6, bytesWritten });
1403
1409
  }
1404
1410
  }
1405
1411
  newline = stdout.indexOf("\n");
@@ -316,6 +316,29 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
316
316
 
317
317
  }
318
318
 
319
+ /**
320
+ * Walk whatever clusters have newly arrived, for every text track a file
321
+ * carries — so the browser's first request for a track finds its cues already
322
+ * caught up instead of paying for the whole backlog inside that request.
323
+ *
324
+ * `cuesHeldFor` already skips positions it has walked before (`state.walked`),
325
+ * so calling this on a timer is cheap once a file is caught up: the only cost
326
+ * is deciding there is nothing new to read. It is `getSubtitleCues` run ahead
327
+ * of being asked, on the same state that call itself would build — nothing is
328
+ * duplicated, and a browser that never asks costs nothing beyond this.
329
+ *
330
+ * @param {object} torrent
331
+ * @param {number} fileIndex
332
+ * @param {string} sourceKey
333
+ * @returns {Promise<void>}
334
+ */
335
+ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
336
+ const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
337
+ for (const track of plan?.tracks ?? []) {
338
+ await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
339
+ }
340
+ }
341
+
319
342
  /**
320
343
  * The text subtitle tracks of a file, for the menu the viewer sees.
321
344
  *
@@ -27,7 +27,7 @@ import { parentPort, workerData } from "node:worker_threads";
27
27
  import { createSendStream } from "./channel.js";
28
28
  import { createFileClaims } from "./file-claims.js";
29
29
  import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
- import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf } from "./subtitle-cues.js";
30
+ import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
31
31
  import { Command, Event } from "./protocol.js";
32
32
 
33
33
  // Imported dynamically, and that is load-bearing: static imports are RESOLVED
@@ -507,4 +507,26 @@ setInterval(() => {
507
507
  }
508
508
  }, STORE_REPORT_INTERVAL_MS).unref();
509
509
 
510
+ /**
511
+ * How often an actively-read file's subtitle cues are walked ahead of being
512
+ * asked for. Cheap once caught up (`warmSubtitleCues` skips clusters it has
513
+ * already read), so this can run often; every read.js pass is one it does not
514
+ * have to do.
515
+ */
516
+ const SUBTITLE_WARMUP_INTERVAL_MS = 3_000;
517
+
518
+ setInterval(() => {
519
+ for (const [sourceKey, torrent] of pool.torrents) {
520
+ const usage = pool.fileUsageByTorrent.get(torrent);
521
+ if (!usage) {
522
+ continue;
523
+ }
524
+ for (const fileIndex of usage.keys()) {
525
+ warmSubtitleCues(torrent, fileIndex, sourceKey).catch((error) => {
526
+ log(`subtitle warmup ${sourceKey}:${fileIndex} failed: ${error instanceof Error ? error.message : error}`);
527
+ });
528
+ }
529
+ }
530
+ }, SUBTITLE_WARMUP_INTERVAL_MS).unref();
531
+
510
532
  log("torrent worker started");