@torrent-tv/proxy 2.30.0 → 2.30.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.30.1
2
+
3
+ - **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.
4
+
1
5
  ## 2.30.0
2
6
 
3
7
  - **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.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": {
@@ -4263,6 +4263,26 @@ export class HlsSessionManager {
4263
4263
  if (session.seekSettleTimer != null) {
4264
4264
  return;
4265
4265
  }
4266
+ // And it outranks it AFTERWARDS too, which is what was missing. The guard
4267
+ // above only holds while the settle timer is armed — a second later it is
4268
+ // gone, and a request the browser issued BEFORE the seek is then treated as
4269
+ // fresh evidence. Field 2026-08-17: a seek to 2083.4 s put both runs at
4270
+ // #373, a request for #371 from before it arrived a second afterwards, and
4271
+ // this repair moved the encoder to #370 — three segments behind the viewer,
4272
+ // who waited for it to come back. A request BEHIND what the viewer
4273
+ // themselves reported cannot be describing where they are.
4274
+ const reportedSeconds = Number(session.viewerReportedSeconds);
4275
+ if (Number.isFinite(reportedSeconds)) {
4276
+ const reportedIndex = this.#segmentIndexForTime(session, reportedSeconds);
4277
+ if (index < reportedIndex) {
4278
+ this.#explainHold(
4279
+ session,
4280
+ session.segmentFormat.segmentFileName(index),
4281
+ `it is behind #${reportedIndex}, where the viewer said they are — answered, not obeyed`
4282
+ );
4283
+ return;
4284
+ }
4285
+ }
4266
4286
  // What separates a request the viewer is waiting for from the player
4267
4287
  // scanning the playlist is not TIME but what else it is asking for. On a
4268
4288
  // seek hls.js fires dozens of DIFFERENT indices within half a second (field
@@ -4370,6 +4390,14 @@ export class HlsSessionManager {
4370
4390
  // The browser holds one session id for the whole file and knows nothing of
4371
4391
  // variants, so a seek it reports means the stream on screen.
4372
4392
  named.viewerPositionSeconds = positionSeconds;
4393
+ // What the viewer SAID, kept apart from what requests imply. A request is
4394
+ // evidence about where the player is reading; a reported seek is the viewer
4395
+ // stating where they are, and after one, requests already in flight
4396
+ // describe a place that no longer exists. Field 2026-08-17: a seek to
4397
+ // 2083.4 s restarted both runs at #373, a request for #371 issued before it
4398
+ // arrived a second later, and the encoder was dragged back to #370 — three
4399
+ // segments behind the viewer, who then waited for it to return.
4400
+ named.viewerReportedSeconds = positionSeconds;
4373
4401
  named.lastAccessedAt = Date.now();
4374
4402
  // The audio the viewer is listening to moves with them. It is a separate
4375
4403
  // encoder on a separate session that the browser cannot name, and nothing
@@ -4415,6 +4443,7 @@ export class HlsSessionManager {
4415
4443
  return false;
4416
4444
  }
4417
4445
  session.viewerPositionSeconds = positionSeconds;
4446
+ session.viewerReportedSeconds = positionSeconds;
4418
4447
  session.lastAccessedAt = Date.now();
4419
4448
  // Every segment request being held right now was made for the position the
4420
4449
  // viewer has just left. Release them: hls.js keeps ONE fragment load
@@ -7021,7 +7050,16 @@ export class HlsSessionManager {
7021
7050
  // read when a quality change has to place the next variant's first
7022
7051
  // encode run. The freshest evidence wins: a seek overwrites this, and
7023
7052
  // the first request after the seek overwrites it back.
7024
- session.viewerPositionSeconds = this.#segmentStartTime(session, requested);
7053
+ // A request refines this only FORWARD of what the viewer reported.
7054
+ // Playback always moves forward from a seek, so nothing legitimate is
7055
+ // lost — while a stale request from before the seek can no longer
7056
+ // rewrite the viewer's own statement, which is what let the repair
7057
+ // below drag the encoder backwards.
7058
+ const requestedStart = this.#segmentStartTime(session, requested);
7059
+ const reported = Number(session.viewerReportedSeconds);
7060
+ if (!Number.isFinite(reported) || requestedStart >= reported) {
7061
+ session.viewerPositionSeconds = requestedStart;
7062
+ }
7025
7063
  // A viewer who has caught up must not wait out the monitor's interval —
7026
7064
  // but only if they HAVE caught up, which is why this re-evaluates the
7027
7065
  // same condition instead of resuming outright.
@@ -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
+ });