@torrent-tv/proxy 2.80.9 → 2.80.10

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.
@@ -19,8 +19,7 @@ import test from "node:test";
19
19
  import assert from "node:assert/strict";
20
20
  import { SourceFile } from "../services/source/SourceFile.js";
21
21
  import { Timeline } from "../services/output/Timeline.js";
22
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
23
- import os from "node:os";
22
+ import { rm, writeFile } from "node:fs/promises";
24
23
  import path from "node:path";
25
24
  import { HlsSessionManager } from "../services/hls-session-manager.js";
26
25
  import { ENCODE_RUN_STATE } from "../services/encode/encode-run-state.js";
@@ -32,6 +31,9 @@ const VIDEO_TIMESCALE = 90_000;
32
31
  const AUDIO_TIMESCALE = 48_000;
33
32
  const SEGMENT_START_SECONDS = 12.5;
34
33
  const SESSION_ID = "11111111-2222-3333-4444-555555555555";
34
+ // Its own address, so this file's directory in the shared store is nobody
35
+ // else's.
36
+ const OUTPUT_KEY = "serve-wiring:fmt=fmp4:grid=uniform:video-only:v=0/copy";
35
37
 
36
38
  /**
37
39
  * @param {string} type
@@ -120,39 +122,62 @@ function selfContainedPiece(offsetSeconds) {
120
122
  * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
121
123
  */
122
124
  async function managerWithReadySegment(overrides = {}) {
123
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
124
- const piece = selfContainedPiece(SEGMENT_START_SECONDS);
125
- // Two segments, because a piece is only finished once the next one exists.
126
- await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
127
- await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
128
-
129
125
  const manager = new HlsSessionManager({
130
126
  enabled: true,
131
127
  ffmpegBin: "ffmpeg",
132
128
  localBindHost: "127.0.0.1",
133
129
  localPort: 9090
134
130
  });
131
+ // ADDRESSED THE WAY PRODUCTION ADDRESSES IT. A session's segments live in the
132
+ // store's directory for its OUTPUT, and whether one is finished is asked of
133
+ // the store by that same address. This fixture used to make a directory of its
134
+ // own and leave the session with no output address at all, so it described a
135
+ // proxy that no longer exists and asked for a segment the store had never
136
+ // heard of.
137
+ manager.segmentStore.useFormat(OUTPUT_KEY, overrides.segmentFormat ?? fmp4Format);
138
+ const dirPath = manager.segmentStore.directoryFor(OUTPUT_KEY);
139
+ const piece = selfContainedPiece(SEGMENT_START_SECONDS);
140
+ // Two segments, because a piece is only finished once the next one exists.
141
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
142
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
143
+
135
144
  const session = {
136
145
  id: SESSION_ID,
146
+ outputKey: OUTPUT_KEY,
137
147
  dirPath,
138
148
  // Where this file is cut, held by the file. A fixture that stated it
139
149
  // on the session was describing what production no longer does.
150
+ // LONGER THAN WHAT IS ON DISK. Two segments exist; the film runs to five, so
151
+ // there is film left to make and an encoder on it is warranted. With the
152
+ // whole film already made, an encoder is not — the plan stops it, correctly,
153
+ // and a fixture that wants a live run has to give it something to do.
140
154
  timeline: new Timeline({
141
- boundaries: [0, SEGMENT_START_SECONDS, 25],
155
+ boundaries: [0, SEGMENT_START_SECONDS, 25, 37.5, 50, 62.5],
142
156
  cutGrid: "uniform"
143
157
  }),
144
158
  state: "ready",
145
- file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
159
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }).learn({ durationSeconds: 62.5 }),
146
160
  // An ordinary session reads its own file, and its sound is inside it. The
147
161
  // three differ only for a soundtrack shipped as a file of its own.
148
162
  get inputFile() { return this.file; },
149
163
  get audioFile() { return this.file; },
164
+ // WHAT THIS HOST ENCODES AT. Every real host measures it before a viewer
165
+ // exists, and the plan compares arrivals — which cannot be computed without
166
+ // it. A fixture that leaves it out describes a machine that has never
167
+ // measured itself: every arrangement is then equally hopeless, they all tie,
168
+ // and the plan takes the encoder away for changing nothing.
169
+ lastAloneSpeed: 2,
150
170
  startedAt: Date.now(),
151
171
  createEntryMs: Date.now(),
152
172
  lastAccessedAt: Date.now(),
153
173
  runs: new Set(),
154
174
  lastError: "",
155
175
  consumers: new Set(),
176
+ // Who is watching this output, and how long it is. Without the first the
177
+ // output has no viewers to build a priority map from, so it states no zones
178
+ // at all — which reads as nobody watching and stops every encoder on it.
179
+ viewers: new Map(),
180
+ segmentCount: 5,
156
181
  segmentFormat: overrides.segmentFormat ?? fmp4Format,
157
182
  useSyntheticPlaylist: true,
158
183
  playlistText: "#EXTM3U\n",
@@ -161,9 +186,20 @@ async function managerWithReadySegment(overrides = {}) {
161
186
  waitEpoch: 0
162
187
  };
163
188
  manager.sessionsById.set(SESSION_ID, session);
189
+ // SOMEBODY IS WATCHING IT. A segment is requested by a viewer, so a fixture
190
+ // that asks for one without stating a viewer describes a state production
191
+ // never reaches — and an output nobody is watching has every encoder on it
192
+ // stopped, which is right and is what left the run here reading STOPPED.
193
+ // NO PLAN RUNS HERE. This file is about the path that answers a request for a
194
+ // segment. Deciding how many encoders there should be and where is a separate
195
+ // question with its own checks, and letting it run inside these would spawn
196
+ // real ffmpeg processes and replace the very run each test is watching — the
197
+ // test would then be about the plan, and about it at its least stable.
198
+ manager.planEncodersNow = () => {};
199
+ manager.planEncodersSoon = () => {};
164
200
  // The run in force. The fixture's segments live directly in the session
165
201
  // directory, which is exactly what one run's directory is here.
166
- startRunOn(session, { from: 0, usesExplicitCuts: true });
202
+ startRunOn(session, { from: 0, usesExplicitCuts: true, speedX: 2 });
167
203
  return { manager, session, dirPath };
168
204
  }
169
205
 
@@ -245,7 +281,7 @@ test("a fault while preparing an existing segment is named, not turned into a wa
245
281
  });
246
282
 
247
283
  test("a run's FIRST segment is served once the encoder has passed it, without waiting for a next one", async (t) => {
248
- const { manager, session, dirPath } = await managerWithReadySegment();
284
+ const { manager, dirPath } = await managerWithReadySegment();
249
285
  t.after(async () => {
250
286
  await manager.disposeAll();
251
287
  await rm(dirPath, { recursive: true, force: true });
@@ -254,7 +290,12 @@ test("a run's FIRST segment is served once the encoder has passed it, without wa
254
290
  // successor and nothing is producing one. Waiting for that successor is what
255
291
  // held #317 for 46 s and then answered 404 to a browser that had given up.
256
292
  await rm(path.join(dirPath, "segment-00001.mp4"));
257
- session.progress = { processedSeconds: SEGMENT_START_SECONDS + 10 };
293
+ // WHAT PROVES IT INSTEAD: the encoder named it. ffmpeg writes the name of each
294
+ // piece on a channel of its own as it closes it, so a run's own first piece is
295
+ // proven the moment it is finished and the absence of a next one says nothing.
296
+ // This used to be inferred from the encoder's reported position instead, which
297
+ // is a different question — where it has read to, not what it has closed.
298
+ manager.segmentStore.markClosed(OUTPUT_KEY, 0);
258
299
 
259
300
  const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
260
301
 
@@ -301,12 +342,26 @@ test("serving a run's own segment moves the run out of STARTING", async (t) => {
301
342
  // let go first, because this test is about ONE run and what serving does to
302
343
  // it.
303
344
  session.runs.clear();
304
- const run = startRunOn(session, { from: 0, producing: false, usesExplicitCuts: true });
345
+ // ITS OWN PIECE, AND FILM STILL TO MAKE BEHIND IT. A run standing on material
346
+ // that already exists is moved forward — correctly — so a fixture that wants a
347
+ // live run has to put it where the work is. #1 is what it is making; #2..#4
348
+ // are unmade, so it is wanted; and the encoder has named #1 on its ready
349
+ // channel, which is what makes the piece servable at all.
350
+ const run = startRunOn(session, { from: 1, producing: false, usesExplicitCuts: true, speedX: 2 });
351
+ manager.segmentStore.markClosed(OUTPUT_KEY, 1);
305
352
  assert.equal(run.state, ENCODE_RUN_STATE.STARTING);
306
-
307
- await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
308
-
309
- assert.equal(run.state, ENCODE_RUN_STATE.PRODUCING);
353
+ assert.deepEqual(run.produced, [], "it has made nothing yet");
354
+
355
+ await manager.getFileStream(SESSION_ID, "segment-00001.mp4", { requestSeq: 1 });
356
+
357
+ // ASKED OF WHAT THE SERVE WROTE, not of the state that happens to follow it.
358
+ // The plan owns a run's life and may end it in the same turn for reasons of
359
+ // its own — the machine's budget, a viewer leaving, nothing left to make —
360
+ // and a check that reads the state afterwards is really asking what the plan
361
+ // decided. What the SERVE does is tell the run the piece is finished, and that
362
+ // is what moves it out of starting.
363
+ assert.deepEqual(run.produced, [1], "the serve told the run its piece is finished");
364
+ assert.equal(run.head, 2, "so the run stands one past it");
310
365
  });
311
366
 
312
367
  test("a segment behind a run's own start does not claim that run has produced", async (t) => {
@@ -321,9 +376,10 @@ test("a segment behind a run's own start does not claim that run has produced",
321
376
  // the number alone, and a run believed to be producing is one the look-ahead
322
377
  // may suspend and the seek path may wave through as "already covered".
323
378
  session.runs.clear();
324
- const run = startRunOn(session, { from: 5, producing: false, usesExplicitCuts: true });
379
+ const run = startRunOn(session, { from: 5, producing: false, usesExplicitCuts: true, speedX: 2 });
325
380
 
326
381
  await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
327
382
 
328
- assert.equal(run.state, ENCODE_RUN_STATE.STARTING);
383
+ assert.deepEqual(run.produced, [], "#0 is somebody else's work and says nothing about this run");
384
+ assert.equal(run.head, 5, "which is still where it began");
329
385
  });
@@ -1,19 +1,21 @@
1
1
  /**
2
- * @file A request issued before a seek must not steer the encoder.
2
+ * @file A segment request steers nothing.
3
3
  *
4
4
  * Field 2026-08-17: the viewer seeked to 2083.4 s, both runs restarted at
5
5
  * segment #373, and a request for #371 — issued before the seek and reissued by
6
6
  * the player a second later — moved the encoder to #370. The viewer was at
7
7
  * #374 and waited for the encoder to come back to them.
8
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.
9
+ * That was answered first by a guard: a request behind what the viewer had
10
+ * REPORTED was refused, while the same traffic still moved the encoder when
11
+ * nothing had been reported. The guard is gone with the thing it guarded. A
12
+ * request for a file is not a statement about where anybody is, and nothing
13
+ * about it belongs in the decision of where encoders go — that is read from the
14
+ * priority map, which is built from where the viewers are and from nothing else.
12
15
  *
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.
16
+ * So the rule pinned here is now the whole rule, and it has no exception: a
17
+ * request moves neither the encoder nor the viewer, whether or not the viewer
18
+ * has said anything.
17
19
  */
18
20
 
19
21
  import assert from "node:assert/strict";
@@ -40,79 +42,48 @@ const SESSION_ID = "22222222-3333-4444-5555-666666666666";
40
42
  * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
41
43
  */
42
44
  async function sessionWithRunAt373() {
43
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "stale-seek-"));
45
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "stale-request-"));
44
46
  const manager = new HlsSessionManager({
45
47
  enabled: true,
46
48
  ffmpegBin: "ffmpeg",
47
49
  localBindHost: "127.0.0.1",
48
50
  localPort: 9090
49
51
  });
50
- const boundaries = [];
51
- for (let index = 0; index <= 600; index += 1) {
52
- boundaries.push(index * SEGMENT_SECONDS);
53
- }
54
52
  const session = {
55
53
  id: SESSION_ID,
56
54
  dirPath,
57
- // Where this file is cut, held by the file. A fixture that stated it
58
- // on the session was describing what production no longer does.
59
55
  timeline: new Timeline({
60
- boundaries: boundaries,
56
+ boundaries: Array.from({ length: 901 }, (_, index) => index * SEGMENT_SECONDS),
61
57
  cutGrid: "uniform"
62
58
  }),
63
59
  state: "ready",
64
60
  file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
65
- // An ordinary session reads its own file, and its sound is inside it. The
66
- // three differ only for a soundtrack shipped as a file of its own.
67
61
  get inputFile() { return this.file; },
68
62
  get audioFile() { return this.file; },
63
+ segmentFormat: fmp4Format,
64
+ segmentCount: 900,
65
+ startedAt: Date.now(),
69
66
  createEntryMs: Date.now(),
70
67
  lastAccessedAt: Date.now(),
68
+ runs: new Set(),
71
69
  consumers: new Set(),
72
- segmentFormat: fmp4Format,
70
+ viewers: new Map(),
71
+ progress: { processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS },
73
72
  useSyntheticPlaylist: true,
74
73
  playlistText: "#EXTM3U\n",
75
- segmentCount: boundaries.length - 1,
76
- runs: new Set(),
77
- behindHeadAsks: new Map(),
78
- firstWantedAt: new Map(),
79
- holdExplainedAt: new Map(),
80
- seekSettleTimer: null,
81
- seekTarget: null,
82
- failedStartAt: -1,
83
- failedStartCount: 0,
84
- waitEpoch: 0,
85
- firstSegmentLogged: true,
86
- progress: { processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS, speed: "1.0x", startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
74
+ lastError: "",
75
+ firstSegmentLogged: false,
76
+ waitEpoch: 0
87
77
  };
88
78
  manager.sessionsById.set(SESSION_ID, session);
89
- // A live run: the repair refuses outright when nothing is encoding.
90
- startRunOn(session, { from: RUN_STARTS_AT, usesExplicitCuts: true });
79
+ startRunOn(session, { from: RUN_STARTS_AT, usesExplicitCuts: true, speedX: 2 });
91
80
  return { manager, session, dirPath };
92
81
  }
93
82
 
94
83
  /**
95
- * The traffic that dragged the encoder back: the same index asked for twice,
96
- * first wanted long enough ago to pass the repair's patience guard.
97
- *
98
- * @param {object} session
99
- */
100
- function askedTwiceLongEnough(session) {
101
- session.firstWantedAt.set(BEHIND_INDEX, Date.now() - 5_000);
102
- session.behindHeadAsks.set(BEHIND_INDEX, { count: 3, at: Date.now() });
103
- }
104
-
105
- /**
106
- * Put the session down without going through disposal.
107
- *
108
- * Disposal signals the encoder and waits for it to die, which a stub cannot do
109
- * — and none of that is what these tests are about. Clearing the map and any
110
- * armed timer leaves nothing running.
111
- *
112
84
  * @param {HlsSessionManager} manager
113
85
  * @param {object} session
114
86
  * @param {string} dirPath
115
- * @returns {Promise<void>}
116
87
  */
117
88
  async function tidy(manager, session, dirPath) {
118
89
  if (session.seekSettleTimer) {
@@ -131,45 +102,48 @@ test("a request behind where the viewer said they are does not move the encoder"
131
102
  });
132
103
 
133
104
  // The viewer stated their position: 2083.4 s, which is segment #520 here.
134
- manager.requestSeek(SESSION_ID, 2083.4);
135
- askedTwiceLongEnough(session);
105
+ manager.requestSeek(SESSION_ID, 2083.4, "viewer-1");
136
106
 
137
- const answer = await manager.getFileStream(
107
+ await manager.getFileStream(
138
108
  SESSION_ID,
139
109
  fmp4Format.segmentFileName(BEHIND_INDEX),
140
110
  { requestSeq: 1 }
141
111
  );
142
112
 
143
- assert.equal(answer.kind, "warming-up", "the request is answered, not obeyed");
144
- // The viewer's own seek legitimately armed a move to #519. What must NOT
145
- // happen is the stale request replacing that with #370 — which is exactly
146
- // what the field log shows: `seek settle → restart at segment #370`.
147
- assert.notEqual(
148
- session.seekTarget,
149
- BEHIND_INDEX - 1,
113
+ // The field log's line was `seek settle → restart at segment #370`. Nothing
114
+ // aims an encoder from here at all now, so there is no destination for a
115
+ // stale request to become.
116
+ assert.equal(
117
+ session.seekTarget ?? null,
118
+ null,
150
119
  "a request behind the viewer must not become the encoder's destination"
151
120
  );
152
- assert.equal(session.seekTarget, 519, "the viewer's own seek is what the encoder is going to");
121
+ assert.equal(
122
+ session.seekSettleTimer ?? null,
123
+ null,
124
+ "and it must not arm a restart"
125
+ );
153
126
  });
154
127
 
155
- test("the same traffic DOES move the encoder when the viewer has said nothing", async (t) => {
128
+ test("the same traffic moves nothing when the viewer has said nothing either", async (t) => {
156
129
  const { manager, session, dirPath } = await sessionWithRunAt373();
157
130
  t.after(async () => {
158
131
  await tidy(manager, session, dirPath);
159
132
  });
160
133
 
161
- // No reported seek: a run placed wrongly is exactly what the repair is for,
162
- // and this is the case it must keep serving.
163
- askedTwiceLongEnough(session);
164
-
165
- await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
166
-
167
- assert.equal(
168
- session.seekTarget,
169
- BEHIND_INDEX - 1,
170
- "with nothing said by the viewer, a request stuck behind the head still repairs the run"
134
+ // THE CONTROL, INVERTED. It used to be that without a reported seek this very
135
+ // traffic DID move the encoder the guard applied only to a viewer who had
136
+ // spoken. That exception is the thing that was removed: a request is evidence
137
+ // about what a player is reading, never about where a person is, and an
138
+ // encoder placed from it is placed from a number the player picked.
139
+ await manager.getFileStream(
140
+ SESSION_ID,
141
+ fmp4Format.segmentFileName(BEHIND_INDEX),
142
+ { requestSeq: 1 }
171
143
  );
172
- assert.notEqual(session.seekSettleTimer, null);
144
+
145
+ assert.equal(session.seekTarget ?? null, null);
146
+ assert.equal(session.seekSettleTimer ?? null, null);
173
147
  });
174
148
 
175
149
  test("a request cannot move the viewer's position backwards", async (t) => {
@@ -178,12 +152,12 @@ test("a request cannot move the viewer's position backwards", async (t) => {
178
152
  await tidy(manager, session, dirPath);
179
153
  });
180
154
 
181
- manager.requestSeek(SESSION_ID, 2083.4);
155
+ manager.requestSeek(SESSION_ID, 2083.4, "viewer-1");
182
156
  await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
183
157
 
184
158
  assert.equal(
185
- session.furthestViewerSeconds,
159
+ manager.viewers.get("viewer-1")?.positionSeconds(),
186
160
  2083.4,
187
- "a stale request must not rewrite what the viewer reported that is how the repair came to believe it"
161
+ "a stale request must not rewrite what the viewer said about themselves"
188
162
  );
189
163
  });