@torrent-tv/proxy 2.59.3 → 2.60.0

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,9 @@
1
+ ## 2.60.0
2
+
3
+ - **Fix**: A separately published audio track starts where the EARLIEST viewer's picture is. A copied picture is one session shared by everyone watching it — the session key carries the consumer id only where the video is re-encoded — and the start was worked out as the read head less the buffer the browser reported. The read head is the furthest request of ANY viewer while the buffer belonged to whichever of them reported last, so with two viewers the two halves of that subtraction belonged to different people and the error was as large as the buffer is deep. The viewer now states where they are, in their own link report, and the track begins at the earliest of them. A browser that states nothing falls back to the old subtraction with the deepest buffer reported, which errs early — the cheap direction.
4
+ - **New**: Link reports are kept per viewer, keyed by consumer id, instead of one field per session overwritten by whoever reported last. The budget reads the worst of them: the slowest link and the emptiest buffer, which need not belong to the same person, because the question it asks is whether anybody is failing to keep up. A step UP has to be carried by all of them, so `#linkCouldCarry` reads the slowest link too. The reason line says how many viewers the figures were taken over.
5
+ - **Fix**: A report from a viewer who has left stops counting. Nothing releases a consumer when a data channel closes (roadmap item 55), so their last reading would otherwise go on deciding for the viewers still here; entries older than the report freshness window are dropped when the next report arrives.
6
+
1
7
  ## 2.59.3
2
8
 
3
9
  - **Fix**: two quality rungs that this machine encodes at the same size now share one encoder instead of starting one each. A variant was remembered under the height the browser ASKED for, while what it encodes is settled afterwards by the clamp that starts a manual pick at the top of the ladder this host can sustain — so on a weak machine a request for 360p and one for 540p both became a 426x240 encode, were filed under keys 360 and 540, and neither ever found the 240p session already making that exact picture. Field 2026-08-28: three ffmpeg processes on a CM4 producing one identical picture, every rung above 240p then measured at 0.04x of realtime and 240p itself at 0.30-0.72x, the viewer watching a slideshow that ended in a spinner, and the process dying eight minutes later after resident memory grew 121→810 MB. The comparison is made on the height PRODUCED, which is the only figure that cannot be wrong: predicting the clamp instead would be a second copy of the budget arithmetic, and the two would drift — the offer prices a rung from the startup measurement while the clamp prices it from what this file has since been seen to cost. A duplicate is let go the moment its size is known, and the height it was asked for then names the session that serves it, so no later request starts anything. A COPY is never adopted for a re-encoded rung: it costs no encoder and is the one thing a stranded viewer can always return to (`research/session-pileup-variant-key-2026-08-28.md`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.59.3",
3
+ "version": "2.60.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,31 +1,48 @@
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
- *
10
- * Best-effort telemetry: invalid body → 400, unknown session → 404, ok → 204.
11
- *
12
- * @param {import("fastify").FastifyRequest} req
13
- * @param {import("fastify").FastifyReply} reply
14
- * @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
15
- * @returns {Promise<void>}
16
- */
17
- export async function handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager }) {
18
- const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
19
- const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
20
- const linkMbps = Number(body.linkMbps);
21
- const bufferedAheadSec = Number(body.bufferedAheadSec);
22
- if (!sessionId || !Number.isFinite(linkMbps) || linkMbps <= 0 || !Number.isFinite(bufferedAheadSec) || bufferedAheadSec < 0) {
23
- return reply.code(400).send({ error: "linkMbps (>0) and bufferedAheadSec (>=0) are required." });
24
- }
25
-
26
- const recorded = hlsSessionManager.recordNetReport(sessionId, { linkMbps, bufferedAheadSec });
27
- if (!recorded) {
28
- return reply.code(404).send({ error: "Transcode session was not found." });
29
- }
30
- return reply.code(204).send();
31
- }
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
+ const positionSeconds = Number(body.positionSeconds);
37
+ const recorded = hlsSessionManager.recordNetReport(sessionId, {
38
+ linkMbps,
39
+ bufferedAheadSec,
40
+ consumerId,
41
+ positionSeconds:
42
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
43
+ });
44
+ if (!recorded) {
45
+ return reply.code(404).send({ error: "Transcode session was not found." });
46
+ }
47
+ return reply.code(204).send();
48
+ }
@@ -2176,9 +2176,18 @@ export class HlsSessionManager {
2176
2176
  // nothing else: they do not appear in the SPS, so one init segment goes
2177
2177
  // on describing every fragment — which the picture's SIZE cannot do.
2178
2178
  rateCapKbps: null,
2179
- // Latest viewer link report ({ linkMbps, bufferedAheadSec, at }) and the
2179
+ // The latest link report of EACH viewer, keyed by consumer id
2180
+ // ({ linkMbps, bufferedAheadSec, positionSeconds, at }), and the
2180
2181
  // link-deficit slow window (mirrors budgetSlowSince for the CPU path).
2181
- netReport: null,
2182
+ //
2183
+ // One per viewer rather than one per session, because a copied picture is
2184
+ // shared: the session key carries the consumer id only where the video is
2185
+ // re-encoded. A single field was whichever viewer reported last, so the
2186
+ // budget could act on one viewer's link while the other was the one
2187
+ // running dry, and the audio rendition's start subtracted one viewer's
2188
+ // buffer from another viewer's read head.
2189
+ /** @type {Map<string, { linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number }>} */
2190
+ netReports: new Map(),
2182
2191
  linkSlowSince: 0,
2183
2192
  sourceWidth,
2184
2193
  sourceHeight,
@@ -2841,11 +2850,15 @@ export class HlsSessionManager {
2841
2850
  * Record the latest viewer link report for a session (adaptive bitrate).
2842
2851
  * Returns false for an unknown/disposed session.
2843
2852
  *
2853
+ * Kept per viewer. A browser that does not say who it is lands under one
2854
+ * shared key, which is exactly the old behaviour for that browser and no
2855
+ * worse: with one viewer the two are the same thing.
2856
+ *
2844
2857
  * @param {string} sessionId
2845
- * @param {{ linkMbps: number, bufferedAheadSec: number }} report
2858
+ * @param {{ linkMbps: number, bufferedAheadSec: number, consumerId?: string, positionSeconds?: number }} report
2846
2859
  * @returns {boolean}
2847
2860
  */
2848
- recordNetReport(sessionId, { linkMbps, bufferedAheadSec }) {
2861
+ recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds }) {
2849
2862
  const named = this.sessionsById.get(sessionId);
2850
2863
  if (!named || named.state === "disposed") {
2851
2864
  return false;
@@ -2857,10 +2870,66 @@ export class HlsSessionManager {
2857
2870
  }
2858
2871
  // The link carries the stream on screen, so the report belongs to the
2859
2872
  // variant producing it — that is the encoder whose bitrate it can bound.
2860
- this.#activeVariant(named).netReport = { linkMbps, bufferedAheadSec, at: Date.now() };
2873
+ const session = this.#activeVariant(named);
2874
+ const now = Date.now();
2875
+ session.netReports.set(typeof consumerId === "string" && consumerId.length > 0 ? consumerId : "", {
2876
+ linkMbps,
2877
+ bufferedAheadSec,
2878
+ // Where the picture is, said by the viewer rather than worked out from
2879
+ // their buffer. Null from a browser that does not send it.
2880
+ positionSeconds:
2881
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
2882
+ at: now
2883
+ });
2884
+ // A viewer who left stops reporting, and their last reading must not go on
2885
+ // deciding for the ones still here. Nothing else removes it: a closed data
2886
+ // channel does not release consumers today (roadmap item 55).
2887
+ for (const [key, report] of session.netReports) {
2888
+ if (now - report.at > LINK_REPORT_FRESH_MS) {
2889
+ session.netReports.delete(key);
2890
+ }
2891
+ }
2861
2892
  return true;
2862
2893
  }
2863
2894
 
2895
+ /**
2896
+ * The worst of what the viewers of this session report, as one reading.
2897
+ *
2898
+ * The budget asks one question — is anybody failing to keep up — so both
2899
+ * terms are the worst case: the slowest link and the emptiest buffer, which
2900
+ * may belong to different people. That is deliberate. Taking the last report
2901
+ * instead meant a session with two viewers acted on whichever of them
2902
+ * happened to report most recently.
2903
+ *
2904
+ * @param {HlsSession} session
2905
+ * @param {number} now
2906
+ * @returns {{ linkMbps: number, bufferedAheadSec: number, at: number, viewers: number } | null}
2907
+ * Null when nothing fresh measures the link, which is the silence every
2908
+ * caller already treats as "no opinion".
2909
+ */
2910
+ #worstNetReport(session, now) {
2911
+ let worst = null;
2912
+ for (const report of session.netReports.values()) {
2913
+ if (now - report.at > LINK_REPORT_FRESH_MS) {
2914
+ continue;
2915
+ }
2916
+ if (worst === null) {
2917
+ worst = {
2918
+ linkMbps: report.linkMbps,
2919
+ bufferedAheadSec: report.bufferedAheadSec,
2920
+ at: report.at,
2921
+ viewers: 1
2922
+ };
2923
+ continue;
2924
+ }
2925
+ worst.linkMbps = Math.min(worst.linkMbps, report.linkMbps);
2926
+ worst.bufferedAheadSec = Math.min(worst.bufferedAheadSec, report.bufferedAheadSec);
2927
+ worst.at = Math.max(worst.at, report.at);
2928
+ worst.viewers += 1;
2929
+ }
2930
+ return worst;
2931
+ }
2932
+
2864
2933
  /**
2865
2934
  * Answer the player's report that a delivered fragment sits far from the edge
2866
2935
  * of its buffer, with the one fact only this side holds: which boundary the
@@ -2981,7 +3050,7 @@ export class HlsSessionManager {
2981
3050
  * @returns {Promise<boolean>}
2982
3051
  */
2983
3052
  async #checkLinkBudget(session, now) {
2984
- const report = session.netReport;
3053
+ const report = this.#worstNetReport(session, now);
2985
3054
  if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
2986
3055
  session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
2987
3056
  return false;
@@ -3006,9 +3075,12 @@ export class HlsSessionManager {
3006
3075
  return false; // not sustained yet
3007
3076
  }
3008
3077
  session.linkSlowSince = 0;
3078
+ // How many viewers the two figures were taken over, because with more than
3079
+ // one they are the worst of each and need not belong to the same person.
3009
3080
  const reasonText =
3010
3081
  `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` +
3011
- `buffer=${report.bufferedAheadSec.toFixed(1)}s`;
3082
+ `buffer=${report.bufferedAheadSec.toFixed(1)}s` +
3083
+ (report.viewers > 1 ? ` (worst of ${report.viewers} viewers)` : "");
3012
3084
  // Which lever this branch HAS, which is not the same on both paths.
3013
3085
  //
3014
3086
  // A re-encoded picture can simply be told to make fewer bits at the size it
@@ -4111,7 +4183,7 @@ export class HlsSessionManager {
4111
4183
  if (session.linkSlowSince !== 0 || session.budgetSlowSince !== 0) {
4112
4184
  return false;
4113
4185
  }
4114
- const report = session.netReport;
4186
+ const report = this.#worstNetReport(session, now);
4115
4187
  if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4116
4188
  // Nothing fresh measures the link, so it has no opinion either way — the
4117
4189
  // same silence that stops #checkLinkBudget from acting.
@@ -4137,7 +4209,9 @@ export class HlsSessionManager {
4137
4209
  * @returns {boolean}
4138
4210
  */
4139
4211
  #linkCouldCarry(session, wantedMbps, now) {
4140
- const report = session.netReport;
4212
+ // The SLOWEST link among the viewers: a step up has to be carried by all of
4213
+ // them, not by whichever reported last.
4214
+ const report = this.#worstNetReport(session, now);
4141
4215
  if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4142
4216
  return true;
4143
4217
  }
@@ -7509,17 +7583,25 @@ export class HlsSessionManager {
7509
7583
  * Where to start a separately published audio track, in seconds.
7510
7584
  *
7511
7585
  * The player, on changing track, discards the audio it holds and refills from
7512
- * the PICTURE onwards — so that is where the encoder has to begin. What this
7513
- * class knows directly is the read head, which runs ahead of the picture by
7514
- * the player's own buffer; the browser reports that buffer with every link
7515
- * report, so the picture is the one subtraction below.
7586
+ * the PICTURE onwards — so that is where the encoder has to begin, and with
7587
+ * more than one viewer that means the EARLIEST picture: a run starting at the
7588
+ * leader's position has nothing to give the one behind them.
7589
+ *
7590
+ * The viewer states where they are, in their own link report. It used to be
7591
+ * worked out instead, as the read head less the buffer they reported, and
7592
+ * that subtraction is only sound with one viewer: the read head is the
7593
+ * furthest request of ANY of them while the buffer belongs to whoever
7594
+ * reported last, so with two viewers the two halves belong to different
7595
+ * people and the error is as large as the buffer is deep.
7516
7596
  *
7517
7597
  * One segment of margin, because the report is up to ten seconds old and the
7518
7598
  * picture has moved on since — a run that begins a little early costs a
7519
7599
  * segment of audio nobody plays, while one that begins a little late is
7520
7600
  * behind the viewer and can only be fixed by restarting it.
7521
7601
  *
7522
- * With no fresh report, the whole look-ahead is subtracted instead: it is the
7602
+ * A browser that reports no position falls back to the old subtraction, with
7603
+ * the DEEPEST buffer reported, which errs early — the cheap direction. With
7604
+ * no fresh report at all the whole look-ahead is subtracted: it is the
7523
7605
  * furthest the two can be apart, so it cannot leave the run ahead of them.
7524
7606
  *
7525
7607
  * @param {HlsSession} base
@@ -7528,11 +7610,33 @@ export class HlsSessionManager {
7528
7610
  #audioStartSecondsFor(base) {
7529
7611
  const watching = this.#activeVariant(base);
7530
7612
  const readHead = this.#viewerPositionOf(watching);
7531
- const report = watching.netReport;
7532
- const reportAge = Number.isFinite(report?.at) ? Date.now() - report.at : Number.POSITIVE_INFINITY;
7533
- const buffered = reportAge <= NET_REPORT_FRESH_MS && Number.isFinite(report?.bufferedAheadSec)
7534
- ? report.bufferedAheadSec
7535
- : LOOKAHEAD_PAUSE_SECONDS;
7613
+ const now = Date.now();
7614
+ let earliestStated = null;
7615
+ let deepestBuffer = null;
7616
+ for (const report of watching.netReports.values()) {
7617
+ if (now - report.at > NET_REPORT_FRESH_MS) {
7618
+ continue;
7619
+ }
7620
+ if (Number.isFinite(report.positionSeconds)) {
7621
+ earliestStated =
7622
+ earliestStated === null
7623
+ ? report.positionSeconds
7624
+ : Math.min(earliestStated, report.positionSeconds);
7625
+ }
7626
+ if (Number.isFinite(report.bufferedAheadSec)) {
7627
+ deepestBuffer =
7628
+ deepestBuffer === null
7629
+ ? report.bufferedAheadSec
7630
+ : Math.max(deepestBuffer, report.bufferedAheadSec);
7631
+ }
7632
+ }
7633
+ if (earliestStated !== null) {
7634
+ // Never ahead of the read head: a position claiming to be past what has
7635
+ // been asked for is a report that arrived out of order, and acting on it
7636
+ // would start the run where no request can ever reach it.
7637
+ return Math.max(0, Math.min(earliestStated, readHead) - this.segmentDurationSec);
7638
+ }
7639
+ const buffered = deepestBuffer === null ? LOOKAHEAD_PAUSE_SECONDS : deepestBuffer;
7536
7640
  return Math.max(0, readHead - buffered - this.segmentDurationSec);
7537
7641
  }
7538
7642