@torrent-tv/proxy 2.84.0 → 2.85.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,22 @@
1
+ ## 2.85.1
2
+
3
+ - **Chore**: NOTHING IS ADDED TO THE SESSION MANAGER, and 2.85.0 added a delegation and an import to it. Both are gone, and what they were there for went with them: taking in a viewer's report is the VIEWER layer's own operation now (`services/viewer/report-intake.js`), the route calls it directly with the live sessions and the registry of viewers, and the manager has no method for it at all. Which output a viewer has on screen moved out with it (`services/viewer/active-output.js`) — it is a question about a person, not about an encoder, and eight call sites now ask it outward.
4
+ - **Chore**: HOW LONG A LINK READING DESCRIBES A LINK is answered by the reading, once (`Viewer.linkReading`). It was a constant in the session manager consulted in four places, and three of those were conditions over a figure that had already passed that very filter — unreachable by construction. Nothing nulls a stale reading any more either: expiry is answered when it is read, so the write that existed only to make a later read correct is gone, and a viewer who has simply stopped reporting is untouched.
5
+ - **Chore**: The session manager is 103 lines shorter for it, and gained one import and eight calls outward.
6
+
7
+ ## 2.85.0
8
+
9
+ - **Fix**: A VIEWER WHO HAS SAID NOTHING IS NOT ASSUMED TO BE WATCHING. `playing` started true on a viewer this proxy had never heard from, and the position is carried forward by the clock at that rate — so a page whose tab was hidden for 145 seconds, while it deliberately held start-up until it was shown, was placed **145.979 s** into a film it had not begun. Field 2026-09-14: that is the exact `-ss` the soundtrack's encoder was given, the browser was asking for segment #0, the map ranked #0 last of a hundred (`p1:#0..#13`), and the request was held 60 145 ms and answered 503. The moment the viewer left and the invented clock stopped, the plan asked for exactly the segment they had been asking for all along. A statement about somebody else's machine is theirs to make: until they make it the rate is zero.
10
+ - **Fix**: THE POSITION IS BOUNDED BY WHAT THE VIEWER SAID THEY HELD — `position(now) = stated + rate × min(now − statedAt, held)`. Nobody can play past the end of what they have; a viewer who said "thirty seconds" and has said nothing for two minutes has played at most thirty of them. The bound binds only where the clock has outrun the cushion (a report lost, or a viewer starving) and there it under-states, which is the direction that costs an encoder rather than a viewer. In the field session above the cushion was 0.0 s throughout, so this alone would have held that viewer at zero whatever the rate said.
11
+ - **Fix**: THREE STATES, NOT TWO. A picture that is not advancing was stopped either by the viewer — who then consumes nothing and can wait — or by us having delivered nothing, and that viewer is the most urgent there is. One boolean held two of the three and read the second as the first, so a viewer frozen for want of a segment counted as somebody who had chosen to stop and nothing of theirs fell due. `waiting` is stated beside `playing`, a viewer arrives waiting, and the priority map's upper band is for those watching OR waiting. A page one release behind states only `playing`, and its two states are told apart by the quantity it does state: stopped while holding film is a pause, stopped holding nothing is starvation.
12
+ - **Fix**: A LINK MEASUREMENT IS NO LONGER THE TICKET FOR A VIEWER'S OWN REPORT. The same condition stood in three places — the page, this route, and the session manager — and a page measures its link from completed transfers, so a page that has not been delivered a segment has no figure at all. Field 2026-09-14: one transfer completed in the whole session, the median needs two, and every report the page tried to send — the pauses, the starvation, the tab going hidden — was dropped. The link figure is one field now, kept only when it is a measurement.
13
+ - **Fix**: The second presence detector threw instead of working. `makeSendQueueWatcher` called `connectionGone`, a function of the factory that is not in its scope, so the watch that exists because `onClosed` does not always come raised a `ReferenceError` inside its own timer and left the viewer registered for the life of the process.
14
+ - **New**: `GET /api/link-probe?bytes=N` — bytes a browser can time its link with before any film has been chosen. Bounded at 2 MiB, which is 336 Mbit/s inside the browser's minimum measurable transfer and twenty-eight times the top rung this product offers. Not `delivery-sink`, which is the load stand's and answers any size asked for.
15
+ - **New**: A refusal says WHY, in the viewer's terms and not only in this proxy's log: the 503 for a segment that is not ready carries a `reason`, and where the priority map is working further along the film it says so. The reason existed sixty seconds before the failure of 2026-09-14 and travelled nowhere.
16
+ - **New**: `peerWaiting=` on the delivery-probe line — how many requests the far end says it is still waiting for. The browser has sent it twice a second since the probe echo was built and nothing read it. Evidence, not a verdict: the receiver declares its own transport lost, because it knows first — on 2026-09-06 it said so sixteen minutes before this end did.
17
+ - **Chore**: Taking in a viewer's report leaves the session manager for `services/viewer/report-intake.js`, and `Viewer.deadlineAt` is deleted — nothing consumed it, and when a second of film falls due is the priority map's answer. The manager is 18 lines shorter.
18
+ - **New**: `test/viewer-states.test.js` — twelve checks over the three states, the bound, the cold open of 2026-09-14 with its own 146 seconds, and a report carrying no link measurement. `test/link-probe.test.js` covers this end of the probe; how big an ask has to be is checked where that function lives, in the page's own repository.
19
+
1
20
  ## 2.84.0
2
21
 
3
22
  - **Fix**: WHERE A VIEWER IS HAS ONE WRITER AND IS A FUNCTION OF TIME. It had two: a seek wrote `position.seeked`, a segment request overwrote `position.seconds`, and the reading preferred whichever was set — so the two took turns and the priority map jumped backwards and forwards several times a second. Field 2026-09-13, one output over one second: `p100:#9..#10`, `p100:#18..#20`, `p100:#16..#18`, `p100:#18..#20`, with the second viewer stopped dead at 1673.6 s throughout. Encoder placement follows the map, as it must, so the machine spent six minutes on **77 starts and 141 stops against one normal end**, produced almost nothing, and both browsers sat frozen with empty buffers while the swarm delivered 3 MB/s from 129 peers. A segment request now says only that the viewer is still here; where they are is what they state, and between statements it is carried forward by the clock at the rate the film moves — `position(now) = stated + (playing ? 1 : 0) × (now − statedAt)`. Continuous and monotone by construction, so nothing derived from it can dither.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.84.0",
3
+ "version": "2.85.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": {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file `GET /api/link-probe?bytes=N` — bytes to measure the link with.
3
+ *
4
+ * WHY THIS EXISTS. A viewer's link is measured from completed transfers, and
5
+ * the only transfers that counted were segments of the film. So the figure came
6
+ * into being after playback had begun — after the quality offer had been made,
7
+ * and after the start-up decisions that want it. Worse, the page's report of
8
+ * its OWN facts was skipped when there was no figure, so at a cold open — the
9
+ * moment where where-the-viewer-is matters most — the page said nothing at all
10
+ * (2026-09-14).
11
+ *
12
+ * There is nothing to wait for: a proxy can send bytes the moment its channel
13
+ * is up, which is when the page opens rather than when a film is chosen.
14
+ *
15
+ * NOT `delivery-sink`, which is the load stand's and answers any size asked
16
+ * for. This is part of the product, always on, and bounded — the browser's own
17
+ * sizing stops at 2 MiB and this refuses more, so a caller cannot turn it into
18
+ * a way of drawing a proxy owner's uplink.
19
+ *
20
+ * The body is zeros. What is being measured is the wire, and zeros cross it at
21
+ * the same speed as anything else; producing them costs one allocation.
22
+ */
23
+
24
+ /**
25
+ * The most this route will send for one ask.
26
+ *
27
+ * The figure is only ever used to decide what a link can carry, and the most
28
+ * anything this product offers is a 1080p rung at around 12 Mbit/s. Two
29
+ * mebibytes inside the browser's minimum measurable transfer (50 ms) is
30
+ * 336 Mbit/s, twenty-eight times that rung: a link too fast to time at this
31
+ * size is beyond anything a decision here distinguishes.
32
+ */
33
+ export const MAX_LINK_PROBE_BYTES = 2 * 1024 * 1024;
34
+
35
+ /**
36
+ * @param {import("fastify").FastifyRequest} req
37
+ * @param {import("fastify").FastifyReply} reply
38
+ * @returns {Promise<void>}
39
+ */
40
+ export async function handleApiLinkProbeGet(req, reply) {
41
+ const asked = Number(/** @type {{ bytes?: string }} */ (req.query)?.bytes);
42
+ if (!Number.isFinite(asked) || asked <= 0) {
43
+ return reply.code(400).send({ error: "bytes (>0) is required." });
44
+ }
45
+ const bytes = Math.min(Math.floor(asked), MAX_LINK_PROBE_BYTES);
46
+ return reply
47
+ .code(200)
48
+ .header("Content-Type", "application/octet-stream")
49
+ .header("Cache-Control", "no-store")
50
+ .send(Buffer.alloc(bytes));
51
+ }
@@ -1,64 +1,94 @@
1
- /**
2
- * Accept a viewer link report for a transcode session (adaptive bitrate).
3
- * The browser measures its own data-channel throughput per segment fetch and
4
- * posts a rolling median + its buffered seconds; the session manager's budget
5
- * loop uses the latest report as the link-deficit downshift trigger.
6
- *
7
- * POST /api/transcode-sessions/:sessionId/net-report
8
- * Body: { linkMbps: number, bufferedAheadSec: number,
9
- * consumerId?: string, positionSeconds?: number }
10
- *
11
- * `consumerId` and `positionSeconds` say WHO is reporting and WHERE they are.
12
- * A copied picture is one session shared by every viewer of it, so without them
13
- * the proxy could only act on whichever viewer reported last. Both are
14
- * optional: a browser that sends neither is treated exactly as before.
15
- *
16
- * Best-effort telemetry: invalid body → 400, unknown session → 404, ok → 204.
17
- *
18
- * @param {import("fastify").FastifyRequest} req
19
- * @param {import("fastify").FastifyReply} reply
20
- * @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
21
- * @returns {Promise<void>}
22
- */
23
- export async function handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager }) {
24
- const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
25
- const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
26
- const linkMbps = Number(body.linkMbps);
27
- const bufferedAheadSec = Number(body.bufferedAheadSec);
28
- if (!sessionId || !Number.isFinite(linkMbps) || linkMbps <= 0 || !Number.isFinite(bufferedAheadSec) || bufferedAheadSec < 0) {
29
- return reply.code(400).send({ error: "linkMbps (>0) and bufferedAheadSec (>=0) are required." });
30
- }
31
-
32
- // Neither is required, and neither can make a report invalid: they are what
33
- // the proxy uses to tell the viewers of one session apart, and a report
34
- // without them is still a truthful reading of somebody's link.
35
- const consumerId = typeof body.consumerId === "string" ? body.consumerId.trim() : "";
36
- // Whether the picture is moving. Absent from a page that does not say, and
37
- // then the viewer counts as playing, which is what every page meant before it
38
- // could say otherwise.
39
- const playing = typeof body.playing === "boolean" ? body.playing : undefined;
40
- // Whether the page is on screen, and whether the picture was pulled out of
41
- // it. A hidden tab has its timers throttled, so it asks for nothing and looks
42
- // exactly like a viewer holding a full cushion; picture-in-picture is the case
43
- // that makes the distinction necessary, because there the tab is hidden and
44
- // the viewer is watching. Absent from a page that does not say, and then the
45
- // viewer is on screen, as every page meant before it could say otherwise.
46
- const onScreen = typeof body.onScreen === "boolean" ? body.onScreen : undefined;
47
- const inPictureInPicture =
48
- typeof body.inPictureInPicture === "boolean" ? body.inPictureInPicture : undefined;
49
- const positionSeconds = Number(body.positionSeconds);
50
- const recorded = hlsSessionManager.recordNetReport(sessionId, {
51
- linkMbps,
52
- bufferedAheadSec,
53
- consumerId,
54
- playing,
55
- onScreen,
56
- inPictureInPicture,
57
- positionSeconds:
58
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
59
- });
60
- if (!recorded) {
61
- return reply.code(404).send({ error: "Transcode session was not found." });
62
- }
63
- return reply.code(204).send();
64
- }
1
+ import { recordViewerReport } from "../../../../services/viewer/report-intake.js";
2
+
3
+ /**
4
+ * Accept a viewer link report for a transcode session (adaptive bitrate).
5
+ * The browser measures its own data-channel throughput per segment fetch and
6
+ * posts a rolling median + its buffered seconds; the session manager's budget
7
+ * loop uses the latest report as the link-deficit downshift trigger.
8
+ *
9
+ * POST /api/transcode-sessions/:sessionId/net-report
10
+ * Body: { linkMbps: number, bufferedAheadSec: number,
11
+ * consumerId?: string, positionSeconds?: number }
12
+ *
13
+ * `consumerId` and `positionSeconds` say WHO is reporting and WHERE they are.
14
+ * A copied picture is one session shared by every viewer of it, so without them
15
+ * the proxy could only act on whichever viewer reported last. Both are
16
+ * optional: a browser that sends neither is treated exactly as before.
17
+ *
18
+ * Best-effort telemetry: invalid body → 400, unknown session → 404, ok → 204.
19
+ *
20
+ * @param {import("fastify").FastifyRequest} req
21
+ * @param {import("fastify").FastifyReply} reply
22
+ * @param {{ sessions: { get: (id: string) => object | undefined }, viewers: object }} deps -
23
+ * The live sessions and the registry of viewers. The VIEWER layer takes the
24
+ * statement from here; this route's whole job is turning a request into that
25
+ * one call and its answer into a status code.
26
+ * @returns {Promise<void>}
27
+ */
28
+ export async function handleApiTranscodeSessionNetReportPost(req, reply, { sessions, viewers }) {
29
+ const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
30
+ const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
31
+ // WHAT THIS REPORT IS. A statement by one viewer about itself: where the
32
+ // picture is, how much film it holds, whether it is moving, whether it is
33
+ // blocked on us, whether the page is on screen — and, when there has been
34
+ // anything to measure, how fast the link carried it.
35
+ //
36
+ // THE LINK FIGURE IS NOT REQUIRED, and requiring it is the fault of
37
+ // 2026-09-14. A page measures its link from completed transfers, so a page
38
+ // that has not yet been delivered a segment has no figure; at a cold open
39
+ // that is every page. Rejected here with 400, the page's own account of
40
+ // itself never arrived, this proxy filled the silence by assuming the film
41
+ // was running, and a viewer who had not seen a frame was placed 146 seconds
42
+ // into it — the soundtrack's encoder went there, and the segment the browser
43
+ // was actually asking for was ranked last of a hundred and answered 503.
44
+ const linkMbps = Number(body.linkMbps);
45
+ const bufferedAheadSec = Number(body.bufferedAheadSec);
46
+ if (!sessionId || !Number.isFinite(bufferedAheadSec) || bufferedAheadSec < 0) {
47
+ return reply.code(400).send({ error: "bufferedAheadSec (>=0) is required." });
48
+ }
49
+
50
+ // Neither is required, and neither can make a report invalid: they are what
51
+ // the proxy uses to tell the viewers of one session apart, and a report
52
+ // without them is still a truthful reading of somebody's link.
53
+ const consumerId = typeof body.consumerId === "string" ? body.consumerId.trim() : "";
54
+ // Whether the picture is moving. Absent from a page that does not say, and
55
+ // then nothing is assumed: a statement about somebody else's machine is
56
+ // theirs to make, and assuming it walked a viewer 146 seconds into a film
57
+ // they had not begun (2026-09-14).
58
+ const playing = typeof body.playing === "boolean" ? body.playing : undefined;
59
+ // Whether this viewer is BLOCKED on material we owe them, which is a
60
+ // different state from having stopped the picture: the first is the most
61
+ // urgent viewer there is, the second consumes nothing and can wait. One
62
+ // boolean could not hold three states and read the first as the second.
63
+ const waiting = typeof body.waiting === "boolean" ? body.waiting : undefined;
64
+ // Whether the page is on screen, and whether the picture was pulled out of
65
+ // it. A hidden tab has its timers throttled, so it asks for nothing and looks
66
+ // exactly like a viewer holding a full cushion; picture-in-picture is the case
67
+ // that makes the distinction necessary, because there the tab is hidden and
68
+ // the viewer is watching. Absent from a page that does not say, and then the
69
+ // viewer is on screen, as every page meant before it could say otherwise.
70
+ const onScreen = typeof body.onScreen === "boolean" ? body.onScreen : undefined;
71
+ const inPictureInPicture =
72
+ typeof body.inPictureInPicture === "boolean" ? body.inPictureInPicture : undefined;
73
+ const positionSeconds = Number(body.positionSeconds);
74
+ const recorded = recordViewerReport({
75
+ sessions,
76
+ viewers,
77
+ sessionId,
78
+ report: {
79
+ linkMbps: Number.isFinite(linkMbps) && linkMbps > 0 ? linkMbps : undefined,
80
+ bufferedAheadSec,
81
+ consumerId,
82
+ playing,
83
+ waiting,
84
+ onScreen,
85
+ inPictureInPicture,
86
+ positionSeconds:
87
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
88
+ }
89
+ });
90
+ if (!recorded) {
91
+ return reply.code(404).send({ error: "Transcode session was not found." });
92
+ }
93
+ return reply.code(204).send();
94
+ }