@torrent-tv/proxy 2.14.0 → 2.14.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,7 @@
1
+ ## 2.14.1
2
+
3
+ - **Fix**: Changing the audio track no longer costs twenty seconds of silence. The player asks the NEW rendition for its segment #0 before anything else — measured 2026-08-15, a track changed at 159 s with the rendition correctly placed at #26 — and the repair that exists for a run placed WRONGLY took that literally: it killed the run and restarted the encoder at the beginning of the film, so the segment the viewer was waiting for arrived 20.5 s later. A rendition is never repaired by moving it, because its run is placed where the viewer is and the request behind it is the player probing; and such a request is now answered at once rather than held, since holding it spends the player's patience on a fragment that can never be produced. The refusal stands down while a seek of the rendition's own is settling: a viewer going BACKWARDS is reported to the base and forwarded to the rendition, but its run only moves when the settle fires, so until then the requests for the new position are behind the old one — and those are exactly the ones the viewer is waiting for.
4
+
1
5
  ## 2.14.0
2
6
 
3
7
  - **Fix**: A rung the source is served at by COPY is never withdrawn from the offer, and the offer is one answer for the whole file rather than one per rung. Which heights this host can serve is a property of the FILE, but a rung is a session of its own and knows only its own encode — so, asked while the viewer watched 240p, the 240p session priced the 1080p rung as a re-encode, because ITS video is re-encoded, and refused it on a machine that had been serving that exact height by copy a minute earlier. Field 2026-08-15: `proxy now offers 360p 240p` four seconds after the switch, and the viewer could not go back to the quality that worked. A copied rung costs no encoder at all, so no measurement of the host can be a reason to drop it — it is precisely where a viewer on a rung the machine cannot hold returns to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.14.0",
3
+ "version": "2.14.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": {
@@ -3570,6 +3570,17 @@ export class HlsSessionManager {
3570
3570
  if (head - index > BEHIND_HEAD_REPAIR_MAX_SEGMENTS) {
3571
3571
  return;
3572
3572
  }
3573
+ // An audio rendition is never repaired by moving it. Its run is placed
3574
+ // where the VIEWER is, from the session they are watching, so a request
3575
+ // behind that is not a run in the wrong place — it is the player probing.
3576
+ // Measured 2026-08-15: changing track at 159 s, hls.js asked for the new
3577
+ // rendition's segment #0, the repair obligingly took the encoder to the
3578
+ // start of the film, and the audio the viewer was waiting for arrived
3579
+ // 20.5 s later instead of at once. Left where it is, the right segment is
3580
+ // already being produced.
3581
+ if (session.audioOnly === true) {
3582
+ return;
3583
+ }
3573
3584
  // Nothing is encoding: a rung the viewer has switched away from is left
3574
3585
  // exactly so, and its held requests must not bring its encoder back.
3575
3586
  if (session.ffmpeg == null || hasChildExited(session.ffmpeg)) {
@@ -5863,6 +5874,34 @@ export class HlsSessionManager {
5863
5874
  // at this position (server-side seeking). The caller long-polls.
5864
5875
  if (!isPlaylist) {
5865
5876
  const requestedIndex = session.segmentFormat.segmentIndexFromName(fileName);
5877
+ // An audio rendition asked for something behind its run says so at once
5878
+ // instead of holding. Its run is placed where the viewer is and is not
5879
+ // moved from there, so the request can never be answered — and holding it
5880
+ // for the full window costs the player its own patience on the fragment
5881
+ // it needs NEXT. Measured 2026-08-15: on a track change at 159 s hls.js
5882
+ // asked for segment #0, and a held request plus a repaired encoder cost
5883
+ // 20.5 s of silence. Refused promptly, the player moves to the segment
5884
+ // that is genuinely being produced.
5885
+ if (
5886
+ session.audioOnly === true &&
5887
+ Number.isFinite(requestedIndex) &&
5888
+ requestedIndex < (session.encodeStartIndex ?? 0) &&
5889
+ session.ffmpeg != null &&
5890
+ // Not while a seek of its own is settling. A viewer seeking BACKWARDS
5891
+ // is reported to the base and forwarded here, but the run only moves
5892
+ // when the settle fires — until then `encodeStartIndex` still names the
5893
+ // old position, and every request for the new one is "behind" it. Those
5894
+ // are exactly the requests the viewer is waiting for, so they are held,
5895
+ // as they were before this refusal existed.
5896
+ session.seekTarget == null &&
5897
+ session.seekSettleTimer == null
5898
+ ) {
5899
+ logger.info(
5900
+ `transcode ${session.id} audio segment #${requestedIndex} is behind this rendition's run ` +
5901
+ `(#${session.encodeStartIndex}); it is not made and the run stays where the viewer is`
5902
+ );
5903
+ return { kind: "not-found" };
5904
+ }
5866
5905
  this.#ensureEncodingFor(
5867
5906
  session,
5868
5907
  requestedIndex,
@@ -637,3 +637,47 @@ test("a rung served by copy stays offered while a re-encoded rung is on screen",
637
637
  "one answer for the family: a rung asked while watching another must not disagree with the base"
638
638
  );
639
639
  });
640
+
641
+ test("an audio rendition refuses a segment behind its run instead of chasing it", async (t) => {
642
+ const { manager, dirPath } = await managerWithBase();
643
+ t.after(async () => {
644
+ await manager.disposeAll();
645
+ await rm(dirPath, { recursive: true, force: true });
646
+ });
647
+ // The field case of 2026-08-15: the viewer changes track at 159 s, the
648
+ // rendition is placed there (#26), and hls.js asks for its segment #0.
649
+ const RENDITION_ID = "99999999-8888-7777-6666-555555555555";
650
+ const rendition = fakeSession({ id: RENDITION_ID, encodeHeight: 0, dirPath });
651
+ rendition.audioOnly = true;
652
+ rendition.audioTrackIndex = 1;
653
+ rendition.encodeStartIndex = 26;
654
+ rendition.usesExplicitCuts = true;
655
+ rendition.ffmpeg = fakeEncoder();
656
+ rendition.variantBases = new Set([BASE_ID]);
657
+ manager.sessionsById.set(RENDITION_ID, rendition);
658
+
659
+ const answer = await manager.getFileStream(RENDITION_ID, "segment-00000.mp4");
660
+ // The distinction that matters: "not-found" is an answer, "warming-up" is the
661
+ // long poll — and holding this one is what cost 20.5 s of silence, because it
662
+ // can never be produced.
663
+ assert.notEqual(answer.kind, "warming-up", "not held: this request is unanswerable, not early");
664
+ assert.equal(
665
+ answer.kind,
666
+ "not-found",
667
+ "answered at once: the run stays where the viewer is, so this can never be produced"
668
+ );
669
+ assert.equal(rendition.encodeStartIndex, 26, "and the encoder was not moved to the start of the film");
670
+ assert.equal(rendition.seekTarget ?? null, null, "no seek was armed for it either");
671
+
672
+ // A seek of its own is the case where "behind the run" is temporary and real:
673
+ // the viewer went backwards, the base forwarded it here, and the run moves
674
+ // when the settle fires. Until then the request is held, not refused — it is
675
+ // the one the viewer is waiting for.
676
+ rendition.seekTarget = 4;
677
+ rendition.seekSettleTimer = setTimeout(() => {}, 60_000);
678
+ t.after(() => clearTimeout(rendition.seekSettleTimer));
679
+
680
+ const duringSeek = await manager.getFileStream(RENDITION_ID, "segment-00005.mp4");
681
+
682
+ assert.equal(duringSeek.kind, "warming-up", "held: the run is about to move there");
683
+ });