@torrent-tv/proxy 2.30.0 → 2.30.2

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.30.2
2
+
3
+ - **Fix**: The cut-time shift of 2.28.0 is reverted — the field measured it and it moved the cuts OFF the source's keyframes rather than onto them. Of 75 pieces the picture produced afterwards, only **nine** began at a time the container's own table names, against **70 of 75** for the soundtrack, which the change never touched; the median distance from the playlist went from 0.04 s to 4.33 s. Before it, every piece began exactly on a named keyframe and it was the playlist that disagreed with them — which is the correction path's business, not the cut list's. The reasoning that produced the shift (that the muxer decides its cuts before the output is relabelled) was argued from ffmpeg's semantics rather than measured, and the measurement says otherwise.
4
+ - **Fix**: The steering line compared two different things. `steered onto N of M holders` summed the successes over every attempt of a wait while taking M from the last attempt alone, which is how the log came to read `steered onto 12 of 6 holders`. Both halves are now totals over the same attempts: `steered onto N of M asks (K peers held it)`.
5
+
6
+ ## 2.30.1
7
+
8
+ - **Fix**: A seek was undone a second after it was made. Measured 2026-08-17: the viewer jumped to 2083.4 s, both runs restarted at segment #373 — correctly — and then a request for #371, issued by the player BEFORE the jump and reissued a second later, dragged the encoder back to #370. The viewer sat at #374 waiting for it to return. Two things let that happen, and both are fixed. The behind-head repair refuses a request that is behind the position the VIEWER themselves reported: its existing guard only holds while a seek is still settling, which by then it was not. And a segment request may no longer move the recorded viewer position BACKWARDS past a reported seek — playback only ever moves forward from one, so nothing legitimate is lost, while a stale request can no longer rewrite the viewer's own statement, which is how the repair came to believe it. A reported seek is the viewer stating where they are; a request is evidence about where the player is reading, and evidence may refine a statement forward, never contradict it backwards. Pinned by `test/stale-request-after-seek.test.js`, whose control case shows the same traffic still repairing a genuinely misplaced run when the viewer has said nothing.
9
+
1
10
  ## 2.30.0
2
11
 
3
12
  - **New**: The speed a step must sustain, and the smallest buffer that hides an interruption, are now COMPUTED from the supply's own behaviour instead of being chosen by hand — printed first, used later. A step producing at `v` gains `v - 1` seconds of cushion per second and an interruption of `W` seconds costs `W`, so it survives its own supply only while `(v - 1) × T > W`, that is `v > 1 + W / T`, with `W` the worst recent wait for a piece and `T` the median interval between such waits. On the field torrent of 2026-08-17 that is **2.42x**, against the 1.5 assumed today and the 1.05 measured on the step that stalled; on the same file's copied stream it is 1.31 against 8x measured, which is why a copy never stalls. The buffer follows from the same readings: one whole segment — the one being played — plus the worst interruption that can arrive before it refills, whichever source it comes from, which was **7-9 s** where the browser waits for 25. Both figures are logged per file every half minute, so the next session says whether the arithmetic describes reality BEFORE anything is decided by it. The arithmetic is a pure module with the field session's own numbers as its tests (`services/supply-margin.js`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.30.0",
3
+ "version": "2.30.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -3567,30 +3567,22 @@ export class HlsSessionManager {
3567
3567
  const gridCutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
3568
3568
  ? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
3569
3569
  : null;
3570
- // On the COPY branch the muxer decides its cuts against the source's own
3571
- // timestamps, not against the labels we ask it to write. That branch keeps
3572
- // the source's timestamps (`-copyts`) and re-labels the output 0-based with
3573
- // `-output_ts_offset -sourceStartTime`; the cut list, being applied before
3574
- // that relabelling, must therefore be stated in the SOURCE's terms.
3570
+ // Cut times are stated on the grid, for both branches.
3575
3571
  //
3576
- // Measured 2026-08-17, and this is the whole of the trouble: asked to cut
3577
- // at 808.808 s on the 0-based grid, ffmpeg cut at 806.806 s — exactly
3578
- // `sourceStartTime` (2.002 s) early, and 806.806 s is itself a keyframe the
3579
- // container's table names, which is why every "disagreement" landed on
3580
- // another real keyframe. The soundtrack, which is re-encoded and takes the
3581
- // other branch, cut where it was asked. The two then told the shared
3582
- // boundary table different things and corrected each other back and forth
3583
- // for the whole session (#202: 808.808 806.806 808.750 → …), so the
3584
- // playlist and the media drifted apart by a whole segment and the player
3585
- // refetched what it could not place.
3572
+ // 2.28.0 added `sourceStartTime` to them on the copy branch, reasoning that
3573
+ // the muxer decides its cuts before the output is relabelled. The field
3574
+ // measured it the next session and the reasoning was wrong: of 75 pieces
3575
+ // the picture produced, only NINE began at a time the container's own
3576
+ // keyframe table names (the soundtrack, untouched by the change, scored 70
3577
+ // of 75). Before it, every piece began exactly on a named keyframe and it
3578
+ // was the PLAYLIST that disagreed with them. So the shift moved the cuts
3579
+ // OFF the keyframes rather than onto them, and it is gone.
3586
3580
  //
3587
- // Nothing here is a guess about ffmpeg's semantics: the shift is the same
3588
- // one the seek already applies on this branch (`seekSeconds = startSeconds
3589
- // + sourceStartTime`), and the field measurement above is what says the
3590
- // cuts needed it too.
3591
- const cutTimes = gridCutTimes && onKeyframeGridFor(session) && sourceStartTime !== 0
3592
- ? gridCutTimes.map((time) => Number((time + sourceStartTime).toFixed(6)))
3593
- : gridCutTimes;
3581
+ // What remains true, and is what that measurement is really about: the
3582
+ // picture cuts where the source's keyframes are, and the playlist must be
3583
+ // built from those same times. That is the correction path's job, not the
3584
+ // cut list's.
3585
+ const cutTimes = gridCutTimes;
3594
3586
 
3595
3587
  // A second chance for a predecessor that survived the escalation above —
3596
3588
  // the first block is the one that does the work. Its exit is ignored
@@ -4263,6 +4255,26 @@ export class HlsSessionManager {
4263
4255
  if (session.seekSettleTimer != null) {
4264
4256
  return;
4265
4257
  }
4258
+ // And it outranks it AFTERWARDS too, which is what was missing. The guard
4259
+ // above only holds while the settle timer is armed — a second later it is
4260
+ // gone, and a request the browser issued BEFORE the seek is then treated as
4261
+ // fresh evidence. Field 2026-08-17: a seek to 2083.4 s put both runs at
4262
+ // #373, a request for #371 from before it arrived a second afterwards, and
4263
+ // this repair moved the encoder to #370 — three segments behind the viewer,
4264
+ // who waited for it to come back. A request BEHIND what the viewer
4265
+ // themselves reported cannot be describing where they are.
4266
+ const reportedSeconds = Number(session.viewerReportedSeconds);
4267
+ if (Number.isFinite(reportedSeconds)) {
4268
+ const reportedIndex = this.#segmentIndexForTime(session, reportedSeconds);
4269
+ if (index < reportedIndex) {
4270
+ this.#explainHold(
4271
+ session,
4272
+ session.segmentFormat.segmentFileName(index),
4273
+ `it is behind #${reportedIndex}, where the viewer said they are — answered, not obeyed`
4274
+ );
4275
+ return;
4276
+ }
4277
+ }
4266
4278
  // What separates a request the viewer is waiting for from the player
4267
4279
  // scanning the playlist is not TIME but what else it is asking for. On a
4268
4280
  // seek hls.js fires dozens of DIFFERENT indices within half a second (field
@@ -4370,6 +4382,14 @@ export class HlsSessionManager {
4370
4382
  // The browser holds one session id for the whole file and knows nothing of
4371
4383
  // variants, so a seek it reports means the stream on screen.
4372
4384
  named.viewerPositionSeconds = positionSeconds;
4385
+ // What the viewer SAID, kept apart from what requests imply. A request is
4386
+ // evidence about where the player is reading; a reported seek is the viewer
4387
+ // stating where they are, and after one, requests already in flight
4388
+ // describe a place that no longer exists. Field 2026-08-17: a seek to
4389
+ // 2083.4 s restarted both runs at #373, a request for #371 issued before it
4390
+ // arrived a second later, and the encoder was dragged back to #370 — three
4391
+ // segments behind the viewer, who then waited for it to return.
4392
+ named.viewerReportedSeconds = positionSeconds;
4373
4393
  named.lastAccessedAt = Date.now();
4374
4394
  // The audio the viewer is listening to moves with them. It is a separate
4375
4395
  // encoder on a separate session that the browser cannot name, and nothing
@@ -4415,6 +4435,7 @@ export class HlsSessionManager {
4415
4435
  return false;
4416
4436
  }
4417
4437
  session.viewerPositionSeconds = positionSeconds;
4438
+ session.viewerReportedSeconds = positionSeconds;
4418
4439
  session.lastAccessedAt = Date.now();
4419
4440
  // Every segment request being held right now was made for the position the
4420
4441
  // viewer has just left. Release them: hls.js keeps ONE fragment load
@@ -7021,7 +7042,16 @@ export class HlsSessionManager {
7021
7042
  // read when a quality change has to place the next variant's first
7022
7043
  // encode run. The freshest evidence wins: a seek overwrites this, and
7023
7044
  // the first request after the seek overwrites it back.
7024
- session.viewerPositionSeconds = this.#segmentStartTime(session, requested);
7045
+ // A request refines this only FORWARD of what the viewer reported.
7046
+ // Playback always moves forward from a seek, so nothing legitimate is
7047
+ // lost — while a stale request from before the seek can no longer
7048
+ // rewrite the viewer's own statement, which is what let the repair
7049
+ // below drag the encoder backwards.
7050
+ const requestedStart = this.#segmentStartTime(session, requested);
7051
+ const reported = Number(session.viewerReportedSeconds);
7052
+ if (!Number.isFinite(reported) || requestedStart >= reported) {
7053
+ session.viewerPositionSeconds = requestedStart;
7054
+ }
7025
7055
  // A viewer who has caught up must not wait out the monitor's interval —
7026
7056
  // but only if they HAVE caught up, which is why this re-evaluates the
7027
7057
  // same condition instead of resuming outright.
@@ -87,7 +87,7 @@ function speedOf(wire) {
87
87
  * @param {object} torrent
88
88
  * @param {number} pieceIndex
89
89
  * @param {number} [limit] - How many wires to push it onto.
90
- * @returns {{ asked: number, considered: number, fastestBytesPerSecond: number }}
90
+ * @returns {{ asked: number, attempted: number, considered: number, fastestBytesPerSecond: number }}
91
91
  * `asked` counts requests the library actually placed: it refuses when a
92
92
  * wire's pipeline is full or when nothing can be reserved even with hotswap,
93
93
  * and that refusal is information — a piece nobody can be asked for is
@@ -95,7 +95,7 @@ function speedOf(wire) {
95
95
  */
96
96
  export function askFastestWiresFor(torrent, pieceIndex, limit = 3) {
97
97
  if (!canPlaceRequests(torrent) || !Number.isInteger(pieceIndex) || pieceIndex < 0) {
98
- return { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
98
+ return { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
99
99
  }
100
100
  const candidates = wiresForPiece(torrent, pieceIndex);
101
101
  let asked = 0;
@@ -116,6 +116,13 @@ export function askFastestWiresFor(torrent, pieceIndex, limit = 3) {
116
116
  return {
117
117
  asked,
118
118
  considered: candidates.length,
119
+ // How many of the asks the library placed, against how many it was asked
120
+ // for. The caller sums these over the whole wait, and summing `asked`
121
+ // against a `considered` taken from the LAST attempt is how the field log
122
+ // came to read "steered onto 12 of 6 holders" — a ratio of two different
123
+ // things. Both halves are returned per attempt so the caller can add each
124
+ // to its own total.
125
+ attempted: Math.min(candidates.length, Math.max(1, limit)),
119
126
  fastestBytesPerSecond: candidates.length > 0 ? speedOf(candidates[0]) : 0
120
127
  };
121
128
  }
@@ -517,12 +517,15 @@ export async function* readFragments({
517
517
  // holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
518
518
  // of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
519
519
  // minutes, on pieces five peers already had.
520
- let pushed = { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
520
+ let pushed = { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
521
521
  const pushToFastest = () => {
522
522
  try {
523
523
  const result = askFastestWiresFor(torrent, pieceIndex);
524
524
  pushed = {
525
525
  asked: pushed.asked + result.asked,
526
+ // Summed like the successes, so the line compares two totals over
527
+ // the same attempts instead of a total against a snapshot.
528
+ attempted: (pushed.attempted ?? 0) + result.attempted,
526
529
  considered: result.considered,
527
530
  fastestBytesPerSecond: result.fastestBytesPerSecond
528
531
  };
@@ -592,7 +595,7 @@ export async function* readFragments({
592
595
  // What WE did about it, so the next session says whether steering
593
596
  // the piece onto faster holders shortens the tail — by number
594
597
  // rather than by impression.
595
- `; steered onto ${pushed.asked} of ${pushed.considered} holders` +
598
+ `; steered onto ${pushed.asked} of ${pushed.attempted} asks (${pushed.considered} peers held it)` +
596
599
  (pushed.fastestBytesPerSecond > 0
597
600
  ? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
598
601
  : "")
@@ -97,7 +97,7 @@ test("a build without the request entry is reported, not silently skipped", () =
97
97
  assert.equal(canPlaceRequests({ wires: [] }), false);
98
98
  assert.equal(canPlaceRequests({ wires: [], _request: () => true }), true);
99
99
  const result = askFastestWiresFor({ wires: [wire({ speed: 1 })] }, 1);
100
- assert.deepEqual(result, { asked: 0, considered: 0, fastestBytesPerSecond: 0 });
100
+ assert.deepEqual(result, { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 });
101
101
  });
102
102
 
103
103
  test("a wire that cannot say how fast it is ranks last rather than throwing", () => {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @file A request issued before a seek must not steer the encoder.
3
+ *
4
+ * Field 2026-08-17: the viewer seeked to 2083.4 s, both runs restarted at
5
+ * segment #373, and a request for #371 — issued before the seek and reissued by
6
+ * the player a second later — moved the encoder to #370. The viewer was at
7
+ * #374 and waited for the encoder to come back to them.
8
+ *
9
+ * The rule pinned here: a reported seek is the viewer STATING where they are; a
10
+ * segment request is evidence about where the player is reading. Evidence may
11
+ * refine a statement forward, never contradict it backwards.
12
+ *
13
+ * Both cases go through `getFileStream`, the way production reaches the repair,
14
+ * and the second is the control: without a reported seek the very same traffic
15
+ * DOES move the encoder, which is what makes the first case a measurement of
16
+ * the guard rather than of the weather.
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import test from "node:test";
21
+ import { mkdtemp, rm } from "node:fs/promises";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
26
+ import { ENCODE_RUN_STATE } from "../services/encode-run-state.js";
27
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
28
+
29
+ const SEGMENT_SECONDS = 4;
30
+ const RUN_STARTS_AT = 373;
31
+ const BEHIND_INDEX = 371;
32
+ const SESSION_ID = "22222222-3333-4444-5555-666666666666";
33
+
34
+ /**
35
+ * A live session whose run begins at #373 and whose directory is empty, so any
36
+ * segment request is a request for something not yet produced.
37
+ *
38
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
39
+ */
40
+ async function sessionWithRunAt373() {
41
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "stale-seek-"));
42
+ const manager = new HlsSessionManager({
43
+ enabled: true,
44
+ ffmpegBin: "ffmpeg",
45
+ localBindHost: "127.0.0.1",
46
+ localPort: 9090
47
+ });
48
+ const boundaries = [];
49
+ for (let index = 0; index <= 600; index += 1) {
50
+ boundaries.push(index * SEGMENT_SECONDS);
51
+ }
52
+ const session = {
53
+ id: SESSION_ID,
54
+ dirPath,
55
+ state: "ready",
56
+ runState: ENCODE_RUN_STATE.PRODUCING,
57
+ fileName: "film.mkv",
58
+ createEntryMs: Date.now(),
59
+ lastAccessedAt: Date.now(),
60
+ consumers: new Set(),
61
+ segmentFormat: fmp4Format,
62
+ usesExplicitCuts: true,
63
+ useSyntheticPlaylist: true,
64
+ playlistText: "#EXTM3U\n",
65
+ segmentBoundaries: boundaries,
66
+ segmentCount: boundaries.length - 1,
67
+ encodeStartIndex: RUN_STARTS_AT,
68
+ // A live process: the repair refuses outright when nothing is encoding.
69
+ ffmpeg: { pid: 1234, killed: false, exitCode: null, signalCode: null, kill() { this.killed = true; } },
70
+ encodeRunGeneration: 0,
71
+ runSerial: 1,
72
+ behindHeadAsks: new Map(),
73
+ firstWantedAt: new Map(),
74
+ holdExplainedAt: new Map(),
75
+ seekSettleTimer: null,
76
+ seekTarget: null,
77
+ seekFailureTarget: -1,
78
+ seekFailureCount: 0,
79
+ waitEpoch: 0,
80
+ firstSegmentLogged: true,
81
+ progress: { processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS, speed: "1.0x", startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
82
+ };
83
+ manager.sessionsById.set(SESSION_ID, session);
84
+ return { manager, session, dirPath };
85
+ }
86
+
87
+ /**
88
+ * The traffic that dragged the encoder back: the same index asked for twice,
89
+ * first wanted long enough ago to pass the repair's patience guard.
90
+ *
91
+ * @param {object} session
92
+ */
93
+ function askedTwiceLongEnough(session) {
94
+ session.firstWantedAt.set(BEHIND_INDEX, Date.now() - 5_000);
95
+ session.behindHeadAsks.set(BEHIND_INDEX, { count: 3, at: Date.now() });
96
+ }
97
+
98
+ /**
99
+ * Put the session down without going through disposal.
100
+ *
101
+ * Disposal signals the encoder and waits for it to die, which a stub cannot do
102
+ * — and none of that is what these tests are about. Clearing the map and any
103
+ * armed timer leaves nothing running.
104
+ *
105
+ * @param {HlsSessionManager} manager
106
+ * @param {object} session
107
+ * @param {string} dirPath
108
+ * @returns {Promise<void>}
109
+ */
110
+ async function tidy(manager, session, dirPath) {
111
+ if (session.seekSettleTimer) {
112
+ clearTimeout(session.seekSettleTimer);
113
+ session.seekSettleTimer = null;
114
+ }
115
+ manager.sessionsById.clear();
116
+ manager.stop?.();
117
+ await rm(dirPath, { recursive: true, force: true });
118
+ }
119
+
120
+ test("a request behind where the viewer said they are does not move the encoder", async (t) => {
121
+ const { manager, session, dirPath } = await sessionWithRunAt373();
122
+ t.after(async () => {
123
+ await tidy(manager, session, dirPath);
124
+ });
125
+
126
+ // The viewer stated their position: 2083.4 s, which is segment #520 here.
127
+ manager.requestSeek(SESSION_ID, 2083.4);
128
+ session.encodeStartIndex = RUN_STARTS_AT;
129
+ askedTwiceLongEnough(session);
130
+
131
+ const answer = await manager.getFileStream(
132
+ SESSION_ID,
133
+ fmp4Format.segmentFileName(BEHIND_INDEX),
134
+ { requestSeq: 1 }
135
+ );
136
+
137
+ assert.equal(answer.kind, "warming-up", "the request is answered, not obeyed");
138
+ // The viewer's own seek legitimately armed a move to #519. What must NOT
139
+ // happen is the stale request replacing that with #370 — which is exactly
140
+ // what the field log shows: `seek settle → restart at segment #370`.
141
+ assert.notEqual(
142
+ session.seekTarget,
143
+ BEHIND_INDEX - 1,
144
+ "a request behind the viewer must not become the encoder's destination"
145
+ );
146
+ assert.equal(session.seekTarget, 519, "the viewer's own seek is what the encoder is going to");
147
+ });
148
+
149
+ test("the same traffic DOES move the encoder when the viewer has said nothing", async (t) => {
150
+ const { manager, session, dirPath } = await sessionWithRunAt373();
151
+ t.after(async () => {
152
+ await tidy(manager, session, dirPath);
153
+ });
154
+
155
+ // No reported seek: a run placed wrongly is exactly what the repair is for,
156
+ // and this is the case it must keep serving.
157
+ askedTwiceLongEnough(session);
158
+
159
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
160
+
161
+ assert.equal(
162
+ session.seekTarget,
163
+ BEHIND_INDEX - 1,
164
+ "with nothing said by the viewer, a request stuck behind the head still repairs the run"
165
+ );
166
+ assert.notEqual(session.seekSettleTimer, null);
167
+ });
168
+
169
+ test("a request cannot move the viewer's position backwards", async (t) => {
170
+ const { manager, session, dirPath } = await sessionWithRunAt373();
171
+ t.after(async () => {
172
+ await tidy(manager, session, dirPath);
173
+ });
174
+
175
+ manager.requestSeek(SESSION_ID, 2083.4);
176
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
177
+
178
+ assert.equal(
179
+ session.viewerPositionSeconds,
180
+ 2083.4,
181
+ "a stale request must not rewrite what the viewer reported — that is how the repair came to believe it"
182
+ );
183
+ });