@torrent-tv/proxy 2.89.3 → 2.89.4

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.
Files changed (49) hide show
  1. package/AGENTS.md +338 -336
  2. package/CHANGELOG.md +12 -0
  3. package/docs/encode-architecture.md +55 -19
  4. package/docs/playback-start-forecast.md +13 -5
  5. package/docs/storage-architecture.md +6 -0
  6. package/package.json +1 -1
  7. package/routes/stream/get.js +38 -2
  8. package/server.js +748 -745
  9. package/services/encode/EncodeOrchestrator.js +24 -49
  10. package/services/encode/EncodePlan.js +5 -3
  11. package/services/encode/EncodeRun.js +80 -17
  12. package/services/encode/EncodeRuns.js +1339 -1239
  13. package/services/encode/Renditions.js +2 -1
  14. package/services/encode/RunClock.js +87 -0
  15. package/services/encode/RunProgress.js +1 -6
  16. package/services/encode/encoder-readings.js +26 -28
  17. package/services/encode/piece-completeness.js +46 -0
  18. package/services/encode/quality/EncodeCost.js +1043 -1167
  19. package/services/encode/quality/OutputQualityState.js +1 -5
  20. package/services/encode/quality/QualityController.js +213 -390
  21. package/services/encode/quality/link-budget.js +5 -17
  22. package/services/encode/run-inputs.js +13 -2
  23. package/services/server/SegmentServing.js +3 -1
  24. package/services/server/ViewerRequests.js +2 -7
  25. package/services/server/wire-outputs.js +12 -7
  26. package/services/storage/segment-store/SegmentStore.js +46 -29
  27. package/services/torrent/supply-margin.js +7 -5
  28. package/test/auto-quality-step.test.js +75 -29
  29. package/test/decode-cost.test.js +1 -1
  30. package/test/encode-cost.test.js +31 -39
  31. package/test/encode-orchestrator.test.js +15 -8
  32. package/test/encode-plan-viewers.test.js +34 -27
  33. package/test/encode-run.test.js +34 -6
  34. package/test/encoder-readings.test.js +48 -26
  35. package/test/helpers/encode-run.js +113 -9
  36. package/test/link-budget.test.js +4 -5
  37. package/test/manager-surface.test.js +1 -2
  38. package/test/orchestrator-wired.test.js +5 -4
  39. package/test/output-speed.test.js +0 -1
  40. package/test/piece-completeness.test.js +42 -0
  41. package/test/quality-offer.test.js +1 -3
  42. package/test/quality-variants.test.js +1 -1
  43. package/test/run-progress.test.js +2 -3
  44. package/test/same-height-switch.test.js +1 -2
  45. package/test/segment-store.test.js +15 -15
  46. package/test/stream-route.test.js +28 -0
  47. package/test/supply-margin.test.js +13 -0
  48. package/test/two-viewers-one-picture.test.js +1 -1
  49. package/test/viewer-outputs.test.js +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 2.89.4
2
+
3
+ - **Fix**: An encoder's speed is read over its own working time. The stream route says when a run's input starts and stops waiting for bytes, each input read carries the number of the run it feeds, and a run's clock (`RunClock`) takes out that waiting and the time it was stopped. Field 2026-10-01: a copy waited 32.93 s and 43.91 s for two pieces and read 0.21x, which the start forecast then carried over the whole remaining film while it charged the same download separately. The forecast's processing rate and the learned price of copying, decoding and soundtracks now measure the machine.
4
+ - **Fix**: Every quality step is judged on the viewer's report, with every term measured. Down: on its trend their buffer runs dry before another output could have the piece they need, and either their link carries less than the stream or this machine makes the picture slower than realtime over its own working time; a shortfall of the swarm is not answered with a smaller picture, which reads the same input. Up: the machine makes the picture at realtime or faster, their buffer is not draining and holds the time another output takes to be ready, and their link carries the next height. Gone: the 0.95x threshold and fifteen-second window of slowness, the sixty-second window before a step back up, the 80 % share of a measured link (`LINK_SAFETY`), and the download-bound test built on `fileProgress >= 0.999` and a download rate against the file's byte rate. While a viewer waits for playback to start nothing steps down for the machine: their buffer is not draining.
5
+ - **Fix**: An encoder's price and the forecast's processing rate are learned each time it closes a piece, on every host. The five-second timer only samples the host's load and the torrents' download rates; hardware hosts used to learn nothing, because the timer stopped before its learning part there.
6
+ - **Fix**: The stream an output sends is measured over every finished piece, each over its own length on the cut table, from the sizes the segment store already holds. It used to be the last five pieces but the newest, timed as if every piece were one length — wrong for a copy, which is cut at the source's keyframes — and read from the disk on every report.
7
+ - **Fix**: A request to move a viewer's quality stands for as long as the conditions it was asked under hold, not for a chosen 45 s. Each judgement of the viewer's report asks for what it needs again; a request it does not repeat — their buffer stopped draining, the room for a step up went, the smaller picture is no longer smaller — is let go and said once.
8
+ - **Fix**: The speed a file's supply asks for is no longer cut down at a lost share of 0.99. The share is strictly below one wherever a figure is given — at least one gap of delivery lies inside the span — so the speed is finite; a supply that delivered for a thousandth of the time now asks a thousand times realtime instead of a hundred, which no re-encode meets and which leaves the copy.
9
+ - **Fix**: An encoder's speed has one owner, the run. It measures itself between two closed pieces over its own working time and announces each reading once; the plan, the quality budget, the playback forecast and the learned price all read that one figure. The plan and the orchestrator used ffmpeg's cumulative `speed=` instead, which divides by every second the encoder was stopped or its input starved, so a slow swarm read as a slow machine and the plan bought encoders that read the same input. Gone with it: the orchestrator's per-output copy of the fastest speed (which nothing filled any more), the quality state's copy of the last reading, and the `speed` field of a progress report, which no page reads.
10
+ - **Chore**: Whether a closed piece holds the whole of its stretch is decided by the encoding (`encode/piece-completeness.js`), which knows the cut table: `EncodeRuns` judges a closed file before it is published and a stored piece left by an earlier process when the forecast reads it. The segment store keeps the bytes and the media intervals read from them, and takes a piece off the disk only at the encoding's word. Behaviour is unchanged. The store used to decide it, which made it read the cut table, a fact of the encoding.
11
+ - **Chore**: The three-second shortest window a speed was read over is gone: a speed is the difference of two samples taken at the encoder's own progress reports. So is the check that kept a starved reading out of the learned price, which judged starvation by two chosen numbers (`fileProgress >= 0.999` and a download rate against the file's byte rate); the waiting is now subtracted instead of guessed at.
12
+
1
13
  ## 2.89.3
2
14
 
3
15
  - **Chore**: The same release as 2.89.2, which the registry accepted as a staged version and never published (`409 Cannot publish over previously staged version` with no staged version listed).
@@ -295,20 +295,31 @@ The page has no quality control: the quality is always automatic. What moves a
295
295
  viewer is decided here, per viewer, and each part of it is a measurement.
296
296
 
297
297
  1. **Judged on the viewer's report, not on a timer.** `QualityController.noteViewerReported`
298
- runs from the net-report route. The chosen fifteen-second window of a slow
299
- link, the ten-second buffer threshold and the thirty-second wait after every
300
- action are gone.
301
- 2. **Down when the buffer would run dry first.** Their link does not carry the
302
- stream they are given AND, on the buffer's trend, it ends sooner than another
303
- output could close the piece they need, counting the time until their next
304
- report (`quality/drain-threat.js`). The trend is a least-squares fit over the
305
- shortest run of their reports spanning one segment
298
+ runs from the net-report route, for the step down, the picture they see and
299
+ the step up alike. The chosen fifteen-second window of a slow link or a slow
300
+ machine, the 0.95x threshold of slowness, the sixty-second window before a
301
+ step back up, the ten-second buffer threshold, the thirty-second wait after
302
+ every action and the 80 % share of the link are gone. The five-second timer
303
+ only samples the host's load and the torrents' download rates; an encoder's
304
+ price is learned when it closes a piece.
305
+ 2. **Down when the buffer would run dry first, for a reason a smaller output
306
+ removes.** On the buffer's trend it ends sooner than another output could
307
+ close the piece they need, counting the time until their next report
308
+ (`quality/drain-threat.js`), AND either their link carries less than the
309
+ stream they are given, or this machine makes the picture slower than
310
+ realtime over its run's own working time (`encode/RunClock.js`: the time its
311
+ input waited for the swarm and the time it was stopped are taken out). A
312
+ threat neither explains — the swarm is short — is not answered with a
313
+ smaller picture, which reads the same input. The trend is a least-squares fit
314
+ over the shortest run of their reports spanning one segment
306
315
  (`viewer/buffer-trend.js`), because the buffer rises a segment at a time and
307
- two readings catch only the rise or the fall. The time to readiness is this
308
- host's measured first-segment time. The levers: a lower limit of the same
309
- height, then a lower height asked of the player as URGENT — their page
310
- switches as soon as the rung is ready, without waiting for a cushion. Where
311
- neither can be prepared they stay on what they are given, with no message.
316
+ two readings catch only the rise or the fall. The time to readiness is the
317
+ observed preparation time of another output of this mode, else this host's
318
+ measured first-segment time. The levers: for the link, a lower limit of the
319
+ same height, then a lower height; for the machine, a lower height. A lower
320
+ height is asked of the player as URGENT — their page switches as soon as the
321
+ rung is ready, without waiting for a cushion. Where nothing can be prepared
322
+ they stay on what they are given, with no message.
312
323
  3. **The picture the viewer sees bounds a re-encode.** The page sends the
313
324
  frame it would show without enlarging, in physical pixels; it is kept on
314
325
  `Viewer.visiblePicture`. The bound is the smallest rung of the source's
@@ -319,12 +330,21 @@ viewer is decided here, per viewer, and each part of it is a measurement.
319
330
  4. **A smaller picture moves the viewer only onto a READY rung**
320
331
  (`Renditions.heightReadyFor`: the piece they ask for next closed on it);
321
332
  with none ready, the next judgement of their link or of the machine applies
322
- the bound. **A larger picture** asks one rung up when the machine and their
323
- link have room.
324
- 5. **A step up is let go when its conditions go back**: a draining buffer, a
325
- link that no longer carries the stream, or no room for the output. The page
326
- drops the move it was preparing when the proxy stops asking for it.
327
- 6. **The page switches a variant when the rung is ready**, and for a request
333
+ the bound.
334
+ 5. **Up one rung when there is room, every term measured**: the picture on
335
+ their screen is made at least at realtime over its run's own working time (a
336
+ copy is not limited by an encoder), their buffer is not draining and holds
337
+ at least the time another output takes to be ready, their link carries the
338
+ next height, and the picture they see is not already served. A rung this
339
+ host has been measured failing at is not offered, so the step back up cannot
340
+ return to it; no window has to pass.
341
+ 6. **A request stands for as long as its conditions hold**: each judgement
342
+ of the viewer's report asks for what it needs again, and a request it does
343
+ not repeat is let go — a draining buffer for a step up, a buffer that no
344
+ longer drains for a step down, no room for the output, a picture that is no
345
+ longer smaller. No chosen time ends a request. The page drops the move it
346
+ was preparing when the proxy stops asking for it.
347
+ 7. **The page switches a variant when the rung is ready**, and for a request
328
348
  that is not urgent only once it holds `minimumBufferSeconds`.
329
349
 
330
350
  ## A place on the machine
@@ -620,6 +640,22 @@ different figures, none of which reproduced the answer the plan had given.
620
640
  A zero in `firstByte`, `kill` or `refetch` is a measurement nobody has taken, not
621
641
  a free operation. It is printed so that reading it as free is a choice.
622
642
 
643
+ **`speed` has one owner: the run.** `EncodeRun.speedX` is the film a run made
644
+ between two closed pieces over its own working time (`encode/RunClock.js`), so
645
+ the time its input waited for the swarm and the time it was stopped are not in
646
+ it. The run announces each reading once (`onSpeedMeasured`); `EncodeCost` learns
647
+ the price of the output from it, the quality budget and the playback forecast
648
+ read the newest reading of the output's runs, and the plan reads the runs
649
+ themselves. Before a run has closed two pieces, and after a restart, the plan
650
+ uses `startingSpeedFor`: what this output was last measured doing alone, or
651
+ what the startup measurements predict. Nothing keeps a copy across restarts,
652
+ and ffmpeg's cumulative `speed=` is read by nothing: it divides by every second
653
+ the encoder was stopped or starved.
654
+
655
+ What the swarm delivers is not in this figure, by construction. It is the
656
+ separate supply term `refetch`, and while that is unmeasured (printed as zero)
657
+ the plan's arrivals assume the input keeps up.
658
+
623
659
  It was missing, and its absence cost three wrong diagnoses of one field session.
624
660
  The line printed the windows, the budget and where the live runs stood; the
625
661
  intervals the actions carried were the one thing it did not print. What that
@@ -14,7 +14,11 @@ Each output's remaining processing work subtracts progress only within its
14
14
  actual live run intervals. Initial video rates use existing codec/size/encoder
15
15
  calibration. Initial audio rates use short packaged per-codec measurements and
16
16
  sample work (encoding) or encoded bytes (copying). Actual output progress
17
- replaces these initial rates. Startup is charged once, separately from processing
17
+ replaces these initial rates. That rate is the run's processing speed: film
18
+ made over the run's own working time, with the time its input waited for the
19
+ swarm and the time it was stopped taken out (`RunClock`, fed by the stream
20
+ route, which marks each wait of a run's input read). The download is charged
21
+ once, by the source service, and not a second time inside the processing rate. Startup is charged once, separately from processing
18
22
  throughput, and disappears once actual run progress establishes processing.
19
23
 
20
24
  Input arrival and processing overlap. With arrival time D(m), processing rate
@@ -84,7 +88,11 @@ Seconds appear again only for rates, schedules and the answer.
84
88
  A closed non-final fragment is reusable only if the end of its last frame, on
85
89
  every track, reaches its next declared cut less `SEGMENT_CUT_TIME_DELTA_SECONDS`,
86
90
  the delta the muxer is configured with. That is a fact of production, so no
87
- browser rule enters it. Interrupted fragments are excluded on publication and
88
- when reading an existing cache, so the production schedule treats their media
89
- as unfinished work. The final fragment uses its actual media end rather than
90
- the container's approximate duration.
91
+ browser rule enters it, and the encoding decides it
92
+ (`encode/piece-completeness.js`): `EncodeRuns` judges a closed file before it is
93
+ published, and judges a stored piece left by an earlier process when the
94
+ forecast asks for it, taking a short one off the disk. The segment store keeps
95
+ the bytes and the media intervals read from them and judges nothing. Interrupted
96
+ fragments are therefore never served or counted, so the production schedule
97
+ treats their media as unfinished work. The final fragment uses its actual media
98
+ end rather than the container's approximate duration.
@@ -167,6 +167,12 @@ finished pieces inside the ENCODING layer was the encoder owning its own disk.
167
167
  It did not move into the room's own directory either: it is a CLAIMANT, and a
168
168
  claimant inside the owner of the resource is the same fault one floor down.
169
169
 
170
+ The store keeps a produced piece's bytes and the media intervals read from them;
171
+ it does not judge whether a piece is whole. That is a fact of production, read
172
+ against the output's cut table, and the encoding decides it
173
+ (`encode/piece-completeness.js`): before a piece is published, and for a stored
174
+ piece left by an earlier process, which it asks the store to `remove`.
175
+
170
176
  **Four directories, deliberately.** They share one property — bytes on a medium
171
177
  with a limit — and that is what makes them one layer. What they are is three
172
178
  different things: a torrent piece is born when it is downloaded and addressed by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.89.3",
3
+ "version": "2.89.4",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -10,6 +10,37 @@ import { open } from "node:fs/promises";
10
10
  import { parseRange } from "../../utils/parse-range.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
 
13
+ /**
14
+ * The same fragments, with every wait for the next one marked: `mark(true)`
15
+ * before it is asked for and `mark(false)` when it has come. The time between
16
+ * is time the encoder reading this response waits for its input.
17
+ *
18
+ * @template T
19
+ * @param {AsyncIterable<T>} source
20
+ * @param {(waiting: boolean) => void} mark
21
+ * @returns {AsyncGenerator<T>}
22
+ */
23
+ async function* markingWaits(source, mark) {
24
+ const iterator = source[Symbol.asyncIterator]();
25
+ try {
26
+ while (true) {
27
+ mark(true);
28
+ let step;
29
+ try {
30
+ step = await iterator.next();
31
+ } finally {
32
+ mark(false);
33
+ }
34
+ if (step.done) {
35
+ return;
36
+ }
37
+ yield step.value;
38
+ }
39
+ } finally {
40
+ await iterator.return?.();
41
+ }
42
+ }
43
+
13
44
  /**
14
45
  * Resolve source parameters from the query string.
15
46
  * Prefers a registered `sourceKey`; falls back to inline `sourceType`+`source`.
@@ -138,7 +169,7 @@ function serveWholeFile(req, reply, file, handle) {
138
169
  return reply.send(handle.createReadStream({ start, end }));
139
170
  }
140
171
 
141
- export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null }) {
172
+ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null }) {
142
173
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
143
174
  const fileIndex = Number(fileIndexRaw);
144
175
  const { sourceType, source, sourceKey } = getSourceParams(req.query, sourceRegistry);
@@ -265,6 +296,11 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
265
296
  // file — and two sessions can read one file, so the file cannot stand in for
266
297
  // the session.
267
298
  const sessionId = typeof req.query.session === "string" ? req.query.session : "";
299
+ // Which run of that output, so the time its input waits is that run's.
300
+ const runToken = Number(req.query.run);
301
+ const markWaits = noteInputWaiting && Number.isInteger(runToken)
302
+ ? (waiting) => noteInputWaiting(runToken, waiting)
303
+ : null;
268
304
 
269
305
  const fragments = typeof file.createFragmentReader === "function"
270
306
  ? file.createFragmentReader({ start, end, windowBytes })
@@ -286,7 +322,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
286
322
 
287
323
  let sent = 0;
288
324
  try {
289
- for await (const fragment of fragments) {
325
+ for await (const fragment of markWaits ? markingWaits(fragments, markWaits) : fragments) {
290
326
  if (reply.raw.writableEnded || reply.raw.destroyed) {
291
327
  fragment.release();
292
328
  break;