@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,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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
7513
|
-
*
|
|
7514
|
-
*
|
|
7515
|
-
*
|
|
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
|
-
*
|
|
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
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
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
|
|