@torrent-tv/proxy 2.65.0 → 2.66.1

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,12 @@
1
+ ## 2.64.10
2
+
3
+ - **Fix**: When a re-encode cannot keep up and nothing LOWER is on offer, the budget asks for the COPY instead of leaving the viewer where they are. "Cheaper" in that rule meant "fewer pixels", and it had no way to express that a copied rung costs no encoder at all, whatever its size — so it looked down, found every rung below refused, and gave up. Field 2026-08-31 and it cost the whole film: a 444x240 ultrafast encode ran at 0.43-0.94x for fifty minutes while the source's own 1038p sat on offer beside it, copied and free; the line `nothing lower is on offer; leaving the picture alone` printed fifty times and the picture stood still 161 times for 940 seconds. On a copied source the way out is up, and it is both the fastest thing the host can serve and the best picture it has (`research/session-2026-08-31-seeks-and-the-copy.md`).
4
+
5
+ ## 2.66.0
6
+
7
+ - **New**: A soundtrack that ships beside the picture is fetched WHOLE once the swarm has capacity to spare, so a later switch to it does not wait. It is a twentieth of the picture — 30 MB against 566 MB on the field torrent — and having it on disk is the difference between an instant switch and one that pays for its own first pieces. The moment it starts is not a guess about the swarm: it is when the encoder is already as far ahead of the viewer as `--lookahead` lets it get, which is the cushion figure the session already measures and prints. Once per file, and only for a soundtrack in a file of its own.
8
+ - **Chore**: That fetch is a bounded READ of the file's length, deliberately not `file.select()`. Selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against — a claim covering everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film waited 93 s while the swarm fetched 2.47 GB in file order. The comment there records it; this goes through the same path the edge warm-up uses.
9
+
1
10
  ## 2.65.0
2
11
 
3
12
  - **New**: A soundtrack that ships as its own file beside the picture is offered like any other. Releases commonly put a dub in `Rus Sound/<name>.mka` and subtitles in `Sub/[team]/<name>.ass`; until now the video played with its original sound and nothing said the rest of the release existed. The pairing is by name — equal base names, or a shared release hash — and a torrent holding exactly one picture takes every sidecar beside it, since there is nothing else they could belong to. A torrent with several episodes never relaxes that: a wrong pairing would put the sound of one episode over the picture of another, and nothing downstream could notice.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.65.0",
3
+ "version": "2.66.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -192,7 +192,30 @@ export async function startProxyServer({
192
192
  getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
193
193
  // The file's audio tracks, for the master playlist's rendition group. Already
194
194
  // probed for the browser's audio menu; read from there rather than probed again.
195
- getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params)
195
+ getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
196
+ // Pull one whole file onto the disk. Used for a soundtrack that ships beside
197
+ // the picture, once the encoder is as far ahead of the viewer as it is
198
+ // allowed to get — the one moment the swarm's capacity is demonstrably
199
+ // spare. A bounded read of the file's whole length, NOT `file.select()`:
200
+ // selecting a file alongside the readers' own windows is what made a seek
201
+ // wait 93 s while the swarm fetched 2.47 GB in file order (see
202
+ // `#syncSelections` in `torrent-pool.js`).
203
+ fetchWholeFile: async ({ sourceKey, fileIndex }) => {
204
+ const record = sourceRegistry.get(sourceKey);
205
+ if (!record) {
206
+ return;
207
+ }
208
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
209
+ const file = torrent?.files?.[fileIndex];
210
+ if (!file || !Number.isFinite(file.length) || file.length <= 0) {
211
+ return;
212
+ }
213
+ await torrentPool.prefetchFileEdges(torrent, fileIndex, {
214
+ headBytes: file.length,
215
+ tailBytes: 0,
216
+ timeoutMs: 600_000
217
+ });
218
+ }
196
219
  });
197
220
  const playbackPlanner = createPlaybackPlanner({
198
221
  ffmpegBin,
@@ -1626,6 +1626,7 @@ export class HlsSessionManager {
1626
1626
  tonemapSupported = false,
1627
1627
  getCachedMediaInfo = null,
1628
1628
  getCachedAudioTracks = null,
1629
+ fetchWholeFile = null,
1629
1630
  segmentFormatId = undefined,
1630
1631
  stateDir = "",
1631
1632
  getTorrentTotals}) {
@@ -1645,6 +1646,12 @@ export class HlsSessionManager {
1645
1646
  // The file's audio tracks, for the master playlist's rendition group. Same
1646
1647
  // inventory the browser's audio menu is built from.
1647
1648
  this.getCachedAudioTracks = typeof getCachedAudioTracks === "function" ? getCachedAudioTracks : null;
1649
+ // Fetch one whole file of a source, as a bounded read rather than a
1650
+ // selection. Used to pull a soundtrack that ships beside the picture onto
1651
+ // the disk while the swarm has capacity to spare — see
1652
+ // `#fetchSpareSoundtracks`. Optional: a proxy wired without it simply reads
1653
+ // such a soundtrack when it is played.
1654
+ this.fetchWholeFile = typeof fetchWholeFile === "function" ? fetchWholeFile : null;
1648
1655
  // Optional async accessor for a source's live download stats, used by the
1649
1656
  // realtime budget to tell a CPU limit from a download-starved input:
1650
1657
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
@@ -3588,6 +3595,82 @@ export class HlsSessionManager {
3588
3595
  `${viewers} viewer(s) holding up to ` +
3589
3596
  `${deepestBuffer === null ? "?" : deepestBuffer.toFixed(1)}s`
3590
3597
  );
3598
+ this.#fetchSpareSoundtracks(session, aheadOfPicture);
3599
+ }
3600
+
3601
+ /**
3602
+ * Fetch the soundtracks that ship beside this picture, whole, while the swarm
3603
+ * has capacity to spare.
3604
+ *
3605
+ * WHY IT WAITS FOR THE CUSHION. A soundtrack nobody has chosen is worth having
3606
+ * on disk — it is a twentieth of the picture (30 MB against 566 MB on the
3607
+ * field torrent) and having it makes every later switch instant instead of
3608
+ * paying for its first pieces. But fetching it takes swarm capacity from the
3609
+ * picture, and there is exactly one moment when that capacity is demonstrably
3610
+ * spare: when the encoder is already as far ahead of the viewer as it is
3611
+ * allowed to get. That is not a guess about the swarm — it is the measurement
3612
+ * the line above just printed.
3613
+ *
3614
+ * WHY IT IS A READ AND NOT A SELECTION. `file.select()` claims every piece of
3615
+ * a file at once, and `#syncSelections` in `torrent-pool.js` records what that
3616
+ * cost when it was done alongside the readers' own windows: a claim covering
3617
+ * everything always outranked the window, and a seek to 89.1% of a 4.7 GB film
3618
+ * waited 93 s while the swarm fetched 2.47 GB in file order. So this goes
3619
+ * through the same bounded read the edge warm-up uses, which claims a moving
3620
+ * window like any other reader and gives it back when it ends.
3621
+ *
3622
+ * Once per file, and only for a soundtrack in a file of its own — the
3623
+ * picture's own tracks are already in the bytes being played.
3624
+ *
3625
+ * @param {HlsSession} session
3626
+ * @param {number} aheadOfPicture - Seconds of film ready ahead of the viewer.
3627
+ * @returns {void}
3628
+ */
3629
+ #fetchSpareSoundtracks(session, aheadOfPicture) {
3630
+ if (typeof this.fetchWholeFile !== "function") {
3631
+ return;
3632
+ }
3633
+ // The encoder is held at this distance and no further, so reaching it is the
3634
+ // signal that nothing more is being asked of the swarm on the picture's
3635
+ // behalf.
3636
+ if (!(aheadOfPicture >= this.lookaheadSeconds)) {
3637
+ return;
3638
+ }
3639
+ const inventory = this.getCachedAudioTracks?.({
3640
+ sourceKey: session.sourceKey,
3641
+ fileIndex: session.fileIndex
3642
+ }) ?? [];
3643
+ if (!(this.spareSoundtracksFetched instanceof Set)) {
3644
+ this.spareSoundtracksFetched = new Set();
3645
+ }
3646
+ const wanted = new Set(
3647
+ inventory
3648
+ .filter((entry) => entry?.kind === "sidecar" && Number.isInteger(entry.fileIndex))
3649
+ .map((entry) => entry.fileIndex)
3650
+ );
3651
+ for (const fileIndex of wanted) {
3652
+ const key = `${session.sourceKey}:${fileIndex}`;
3653
+ if (this.spareSoundtracksFetched.has(key)) {
3654
+ continue;
3655
+ }
3656
+ this.spareSoundtracksFetched.add(key);
3657
+ logger.info(
3658
+ `transcode ${session.id.slice(0, 8)} the picture is ${Math.round(aheadOfPicture)}s ahead of ` +
3659
+ `the viewer, so file ${fileIndex} — a soundtrack beside it — is fetched whole now; ` +
3660
+ "a switch to it will not wait for the swarm"
3661
+ );
3662
+ // Not awaited: nothing depends on it finishing, and a failure costs only
3663
+ // that the switch pays for its own pieces, as it did before this existed.
3664
+ Promise.resolve(this.fetchWholeFile({ sourceKey: session.sourceKey, fileIndex })).catch(
3665
+ (error) => {
3666
+ logger.info(
3667
+ `transcode: fetching soundtrack file ${fileIndex} whole failed ` +
3668
+ `(${error instanceof Error ? error.message : String(error)}) — ` +
3669
+ "it will be read when it is played"
3670
+ );
3671
+ }
3672
+ );
3673
+ }
3591
3674
  }
3592
3675
 
3593
3676
  /**
@@ -4357,6 +4440,27 @@ export class HlsSessionManager {
4357
4440
  const next = this.#splicableHeights(base)
4358
4441
  .find((height) => height < current && offered.includes(height));
4359
4442
  if (next === undefined) {
4443
+ // Nothing lower — but "lower" is not the same question as "cheaper", and
4444
+ // on a source that is COPIED the answer is above, not below. A copied
4445
+ // rung costs no encoder at all, whatever its size, so when a re-encode
4446
+ // cannot keep up it is both the fastest thing this host can serve AND the
4447
+ // best picture it has.
4448
+ //
4449
+ // Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
4450
+ // 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
4451
+ // own 1038p sat on offer beside it, copied and free. This line printed
4452
+ // fifty times — "nothing lower is on offer; leaving the picture alone" —
4453
+ // and the picture stood still 161 times for 940 seconds. The rescue was
4454
+ // on the screen the whole time and the rule could only look down.
4455
+ const copied = this.#copiedHeightOf(base);
4456
+ if (copied > 0 && copied !== current && offered.includes(copied)) {
4457
+ logger.info(
4458
+ `[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
4459
+ `but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
4460
+ `Asking for it instead of leaving the viewer on an encode that cannot keep up`
4461
+ );
4462
+ return this.#askQualityHeight(base, copied, reasonText);
4463
+ }
4360
4464
  logger.info(
4361
4465
  `[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
4362
4466
  `for "${session.fileName}"; leaving the picture alone`
@@ -4366,6 +4470,25 @@ export class HlsSessionManager {
4366
4470
  return this.#askQualityHeight(base, next, reasonText);
4367
4471
  }
4368
4472
 
4473
+ /**
4474
+ * The height this family serves by COPY, or zero when every rung is encoded.
4475
+ *
4476
+ * The one rung whose cost does not depend on the machine: the source's own
4477
+ * height, on a base whose video is not re-encoded. `offeredHeights` never
4478
+ * withdraws it for that reason, so it is always available as somewhere to
4479
+ * return to — which is exactly what {@link HlsSessionManager##askLowerHeight}
4480
+ * had no way to say.
4481
+ *
4482
+ * @param {HlsSession} base
4483
+ * @returns {number}
4484
+ */
4485
+ #copiedHeightOf(base) {
4486
+ if (!base || base.transcodeVideo === true) {
4487
+ return 0;
4488
+ }
4489
+ return Math.round(Number(base.sourceHeight) || 0);
4490
+ }
4491
+
4369
4492
  /**
4370
4493
  * Record a request to the viewer's player to move to another variant.
4371
4494
  *