@torrent-tv/proxy 2.101.10 → 2.101.12
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/package.json +1 -1
- package/services/encode/EncodeOrchestrator.js +75 -8
- package/services/server/OutputLifecycle.js +3 -8
- package/services/server/SegmentServing.js +3 -4
- package/services/server/ViewerRequests.js +20 -6
- package/services/server/wire-outputs.js +3 -1
- package/services/viewer/Viewers.js +10 -0
- package/services/viewer/choices.js +10 -9
- package/services/viewer/report-intake.js +5 -4
package/package.json
CHANGED
|
@@ -156,10 +156,19 @@ export class EncodeOrchestrator {
|
|
|
156
156
|
* Whether the run a viewer needs first is short of the processor, from the
|
|
157
157
|
* last time who has the processor was decided (`processorShortfall`).
|
|
158
158
|
*
|
|
159
|
-
* @type {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number
|
|
159
|
+
* @type {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number,
|
|
160
|
+
* waitedFor?: number } | null}
|
|
160
161
|
*/
|
|
161
162
|
#shortfall = null;
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The piece each waiting viewer's start is held by the production of, as
|
|
166
|
+
* the start forecast last said it (`noteViewerWaiting`), by viewer.
|
|
167
|
+
*
|
|
168
|
+
* @type {Map<string, { address: string, index: number }>}
|
|
169
|
+
*/
|
|
170
|
+
#viewersWaiting = new Map();
|
|
171
|
+
|
|
163
172
|
/**
|
|
164
173
|
* What starting and stopping an encoder was measured to cost on this host,
|
|
165
174
|
* before any viewer existed. Told once; real runs replace it as they end.
|
|
@@ -455,6 +464,7 @@ export class EncodeOrchestrator {
|
|
|
455
464
|
this.#lastError.delete(address);
|
|
456
465
|
this.#lastProgress.delete(address);
|
|
457
466
|
this.#failedStarts.delete(address);
|
|
467
|
+
this.#forgetWaitsOn(address);
|
|
458
468
|
}
|
|
459
469
|
|
|
460
470
|
/** @param {string} address @returns {{ at: number, count: number, blocked: boolean, limit: number }} */
|
|
@@ -610,6 +620,37 @@ export class EncodeOrchestrator {
|
|
|
610
620
|
// right whatever the last one met.
|
|
611
621
|
if (!Array.isArray(zones) || zones.length === 0) {
|
|
612
622
|
this.#forgetInputState(address);
|
|
623
|
+
this.#forgetWaitsOn(address);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* What a viewer's start waits for, as the start forecast says each time it
|
|
629
|
+
* is asked: the production of piece `index` of `address`, or nothing an
|
|
630
|
+
* encoder makes (null). The map's deadlines can be met while the forecast
|
|
631
|
+
* still holds the viewer for the stock it wants after the piece they stand
|
|
632
|
+
* in: on Home Assistant (r87, torrent-tv/meta#180) the download was let go
|
|
633
|
+
* 5.3 s before a seek's viewer started, while the forecast held them for
|
|
634
|
+
* the production of the next piece. Who has the processor is decided again
|
|
635
|
+
* when it changes.
|
|
636
|
+
*
|
|
637
|
+
* @param {string} viewer
|
|
638
|
+
* @param {{ address: string, index: number } | null} wait
|
|
639
|
+
*/
|
|
640
|
+
noteViewerWaiting(viewer, wait) {
|
|
641
|
+
const said = this.#viewersWaiting.get(viewer) ?? null;
|
|
642
|
+
const next = typeof wait?.address === "string" && Number.isInteger(wait.index)
|
|
643
|
+
? { address: wait.address, index: wait.index } : null;
|
|
644
|
+
if (said?.address === next?.address && said?.index === next?.index) return;
|
|
645
|
+
if (next) this.#viewersWaiting.set(viewer, next);
|
|
646
|
+
else this.#viewersWaiting.delete(viewer);
|
|
647
|
+
this.shareTheMachine();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** @param {string} address */
|
|
651
|
+
#forgetWaitsOn(address) {
|
|
652
|
+
for (const [viewer, wait] of this.#viewersWaiting) {
|
|
653
|
+
if (wait.address === address) this.#viewersWaiting.delete(viewer);
|
|
613
654
|
}
|
|
614
655
|
}
|
|
615
656
|
|
|
@@ -739,9 +780,14 @@ export class EncodeOrchestrator {
|
|
|
739
780
|
const now = this.now();
|
|
740
781
|
const running = this.#runningNow();
|
|
741
782
|
const need = this.#needOf(leading);
|
|
742
|
-
// Only a run in a band a viewer is in can be short for a viewer
|
|
743
|
-
|
|
744
|
-
|
|
783
|
+
// Only a run in a band a viewer is in can be short for a viewer, and a run
|
|
784
|
+
// making a piece the start forecast holds a viewer for needs all it can
|
|
785
|
+
// make, whatever the map's deadlines say.
|
|
786
|
+
const waited = this.#runAViewerWaitsFor();
|
|
787
|
+
const judged = waited ?? (leading.urgent === true ? leading : null);
|
|
788
|
+
const short = judged
|
|
789
|
+
? this.#pauseRule.shortfall({ leader: judged.run, need: waited ? Number.POSITIVE_INFINITY : need }) : null;
|
|
790
|
+
this.#noteShortfall(short ? { address: judged.address, ...short, ...(waited ? { waitedFor: waited.index } : {}) } : null);
|
|
745
791
|
const free = this.#processorReading("freeCores");
|
|
746
792
|
const others = this.#processorReading("otherCores");
|
|
747
793
|
for (const [address, runs] of this.#runs) {
|
|
@@ -768,20 +814,41 @@ export class EncodeOrchestrator {
|
|
|
768
814
|
this.#placeHeldBack(leading);
|
|
769
815
|
}
|
|
770
816
|
|
|
817
|
+
/**
|
|
818
|
+
* The live run making a piece the start forecast holds a viewer for, the
|
|
819
|
+
* most urgent if there are several; null where none is.
|
|
820
|
+
*
|
|
821
|
+
* @returns {{ address: string, index: number, run: object, band: { urgent: boolean, priority: number } } | null}
|
|
822
|
+
*/
|
|
823
|
+
#runAViewerWaitsFor() {
|
|
824
|
+
let found = null;
|
|
825
|
+
for (const { address, index } of this.#viewersWaiting.values()) {
|
|
826
|
+
for (const run of this.#runs.get(address) ?? []) {
|
|
827
|
+
if (!run.isAlive || run.isStopping || run.isSuspended === true || index < run.head || index > run.to) continue;
|
|
828
|
+
const band = this.#bandAt(address, run.head);
|
|
829
|
+
if (!found || compareUrgency(band, found.band) < 0) found = { address, index, run, band };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return found;
|
|
833
|
+
}
|
|
834
|
+
|
|
771
835
|
/**
|
|
772
836
|
* Whether the run a viewer needs first is short of the processor, and by
|
|
773
837
|
* what: its output, the speed it made over its seconds kept (median and
|
|
774
838
|
* scatter) and the speed its deadlines need, infinite where a viewer waits
|
|
775
|
-
* for it (`PauseRule.shortfall`, torrent-tv/meta#180).
|
|
776
|
-
*
|
|
839
|
+
* for it (`PauseRule.shortfall`, torrent-tv/meta#180). The run the start
|
|
840
|
+
* forecast holds a viewer for is judged before the map's leader, and names
|
|
841
|
+
* the piece it is waited for by (`waitedFor`). Null where it is not short,
|
|
842
|
+
* or nothing measured it.
|
|
777
843
|
*
|
|
778
|
-
* @returns {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number
|
|
844
|
+
* @returns {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number,
|
|
845
|
+
* waitedFor?: number } | null}
|
|
779
846
|
*/
|
|
780
847
|
processorShortfall() {
|
|
781
848
|
return this.#shortfall;
|
|
782
849
|
}
|
|
783
850
|
|
|
784
|
-
/** @param {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number } | null} short */
|
|
851
|
+
/** @param {{ address: string, speed: { middle: number, scatter: number }, need: number, seconds: number, waitedFor?: number } | null} short */
|
|
785
852
|
#noteShortfall(short) {
|
|
786
853
|
this.#shortfall = short;
|
|
787
854
|
this.shortfallNoted();
|
|
@@ -403,17 +403,12 @@ export class OutputLifecycle {
|
|
|
403
403
|
if (typeof consumerId !== "string" || consumerId.length === 0) {
|
|
404
404
|
return 0;
|
|
405
405
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return 0;
|
|
410
|
-
}
|
|
411
|
-
// Copied before anything is released: releasing walks the same set.
|
|
412
|
-
const outputs = [...watched];
|
|
406
|
+
// Presence ends synchronously, before disposal waits for a child process.
|
|
407
|
+
// Late reports and preparations must not rejoin this viewer meanwhile.
|
|
408
|
+
const outputs = this.#host.viewers.hasGone(consumerId);
|
|
413
409
|
for (const outputId of outputs) {
|
|
414
410
|
await this.releaseSessionConsumer(outputId, consumerId, because);
|
|
415
411
|
}
|
|
416
|
-
this.#host.viewers.hasGone(consumerId);
|
|
417
412
|
return outputs.length;
|
|
418
413
|
}
|
|
419
414
|
|
|
@@ -904,9 +904,8 @@ export class SegmentServing {
|
|
|
904
904
|
* backwards several times a second — measured 2026-09-13, 77 encoder starts
|
|
905
905
|
* and 141 stops in six minutes while both viewers sat frozen.
|
|
906
906
|
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
* look-ahead, not a chosen interval.
|
|
907
|
+
* Presence is established by arrival or an explicit opening and ends with
|
|
908
|
+
* the connection. A queued request cannot restore a viewer who has left.
|
|
910
909
|
*
|
|
911
910
|
* @param {HlsSession} session
|
|
912
911
|
* @param {string} consumerId
|
|
@@ -917,7 +916,7 @@ export class SegmentServing {
|
|
|
917
916
|
if (!consumerId) {
|
|
918
917
|
return;
|
|
919
918
|
}
|
|
920
|
-
this.#host.viewers.
|
|
919
|
+
this.#host.viewers.join(session, consumerId)?.seen();
|
|
921
920
|
}
|
|
922
921
|
|
|
923
922
|
/**
|
|
@@ -121,7 +121,13 @@ export class ViewerRequests {
|
|
|
121
121
|
// What this viewer's link last measured, where the request does not say:
|
|
122
122
|
// a viewer who comes back to open another film has a link this proxy has
|
|
123
123
|
// already measured, and the output they are given is judged by it.
|
|
124
|
-
|
|
124
|
+
let known = consumerId ? this.#host.viewers.get(consumerId) : null;
|
|
125
|
+
const requireViewing = () => {
|
|
126
|
+
request.signal?.throwIfAborted();
|
|
127
|
+
if (known && this.#host.viewers.get(consumerId) !== known) {
|
|
128
|
+
throw new DOMException("The viewer left while the output was opening.", "AbortError");
|
|
129
|
+
}
|
|
130
|
+
};
|
|
125
131
|
const viewerLinkMbps = request.viewerLinkMbps ?? known?.linkReading()?.linkMbps ?? null;
|
|
126
132
|
const startPositionSeconds = request.startPositionSeconds ?? 0;
|
|
127
133
|
// Whether they were already on the output this request lands on, read at
|
|
@@ -136,13 +142,14 @@ export class ViewerRequests {
|
|
|
136
142
|
// state nothing has every encoder on it stopped.
|
|
137
143
|
const claim = consumerId
|
|
138
144
|
? (candidate) => {
|
|
139
|
-
|
|
145
|
+
requireViewing();
|
|
140
146
|
alreadyOn = this.#host.viewers.forOutput(candidate).has(consumerId);
|
|
141
147
|
const answer = alreadyOn
|
|
142
148
|
? { admitted: true, reason: "already watching it", speedX: null }
|
|
143
149
|
: this.#host.admitsWatching(candidate);
|
|
144
150
|
if (answer.admitted) {
|
|
145
|
-
|
|
151
|
+
known = this.#host.viewers.of(candidate, consumerId);
|
|
152
|
+
this.placeViewer(candidate, known, startPositionSeconds);
|
|
146
153
|
}
|
|
147
154
|
return answer;
|
|
148
155
|
}
|
|
@@ -152,7 +159,7 @@ export class ViewerRequests {
|
|
|
152
159
|
viewerLinkMbps,
|
|
153
160
|
claim
|
|
154
161
|
});
|
|
155
|
-
|
|
162
|
+
requireViewing();
|
|
156
163
|
if (consumerId) {
|
|
157
164
|
const joined = existed && !alreadyOn;
|
|
158
165
|
// What THIS viewer wants of the sound, which an output they are joining
|
|
@@ -257,7 +264,8 @@ export class ViewerRequests {
|
|
|
257
264
|
if (!output || !consumerId) {
|
|
258
265
|
return;
|
|
259
266
|
}
|
|
260
|
-
|
|
267
|
+
const viewer = this.#host.viewers.join(output, consumerId);
|
|
268
|
+
if (viewer) this.placeViewer(output, viewer, positionSeconds);
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
placeViewer(session, viewer, positionSeconds) {
|
|
@@ -320,7 +328,8 @@ export class ViewerRequests {
|
|
|
320
328
|
// from where the viewers are, and both orchestrators read the map.
|
|
321
329
|
// A seek that names nobody moves nobody: the route refuses it before it
|
|
322
330
|
// gets here.
|
|
323
|
-
const viewer = this.#host.viewers.
|
|
331
|
+
const viewer = this.#host.viewers.join(named, consumerId);
|
|
332
|
+
if (!viewer) return false;
|
|
324
333
|
viewer.moveTo(positionSeconds);
|
|
325
334
|
// WHICH VIEWING THEIR REQUESTS NOW BELONG TO, as the page states it.
|
|
326
335
|
//
|
|
@@ -790,6 +799,11 @@ export class ViewerRequests {
|
|
|
790
799
|
})}`);
|
|
791
800
|
}
|
|
792
801
|
this.#sayHolding(session, consumerId, measurement, forecast, input, now);
|
|
802
|
+
// The encoder this viewer waits for, told to whoever shares the processor
|
|
803
|
+
// (torrent-tv/meta#180): the forecast knows it before the map's deadlines do.
|
|
804
|
+
const holding = forecast.holdingBy;
|
|
805
|
+
this.#host.noteViewerWaiting?.(consumerId || session.id,
|
|
806
|
+
holding?.kind === "production" ? { address: holding.track, index: holding.segment } : null);
|
|
793
807
|
return forecast;
|
|
794
808
|
}
|
|
795
809
|
|
|
@@ -631,6 +631,7 @@ export function wireOutputs({
|
|
|
631
631
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
632
632
|
planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
|
|
633
633
|
waitUntilReady: (...args) => parts.serving.waitUntilReady(...args),
|
|
634
|
+
noteViewerWaiting: (viewer, wait) => parts.encodeOrchestrator?.noteViewerWaiting(viewer, wait),
|
|
634
635
|
get encodeRuns() { return parts.encodeRuns; },
|
|
635
636
|
// When this viewer's playback can start and run to the end: the viewer
|
|
636
637
|
// component's forecast, which models the viewer's browser.
|
|
@@ -781,7 +782,8 @@ export function wireOutputs({
|
|
|
781
782
|
const swarm = parts.hostLoad.swarmRate();
|
|
782
783
|
const made = `the encoder a viewer needs first made ${short.speed.middle.toFixed(2)}x ` +
|
|
783
784
|
`(scatter ${short.speed.scatter.toFixed(2)}) over ${short.seconds}s` +
|
|
784
|
-
(Number.isFinite(short.need) ? `, needing ${short.need.toFixed(2)}x` : " with a viewer waiting")
|
|
785
|
+
(Number.isFinite(short.need) ? `, needing ${short.need.toFixed(2)}x` : " with a viewer waiting") +
|
|
786
|
+
(Number.isInteger(short.waitedFor) ? ` for its #${short.waitedFor} by the start forecast` : "");
|
|
785
787
|
if (!Number.isFinite(needs) || needs <= 0) {
|
|
786
788
|
because = `${made}; its film's bytes per second are unknown`;
|
|
787
789
|
} else if (swarm === null) {
|
|
@@ -179,6 +179,16 @@ export class Viewers {
|
|
|
179
179
|
return this.#byId.get(consumerId) ?? null;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Add an output to an existing viewing. A request or preparation completing
|
|
184
|
+
* after departure cannot establish presence again; only arrival or a new
|
|
185
|
+
* explicit opening may do that through `present` or `of`.
|
|
186
|
+
*/
|
|
187
|
+
join(output, consumerId, now = Date.now()) {
|
|
188
|
+
const viewer = this.get(consumerId);
|
|
189
|
+
return viewer?.isPresent() ? this.of(output, consumerId, now) : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
182
192
|
sourceKeys() {
|
|
183
193
|
return new Set([...this.#byId.values()].filter(viewer => viewer.isPresent() && viewer.source)
|
|
184
194
|
.map(viewer => viewer.source.sourceKey));
|
|
@@ -96,7 +96,8 @@ export function audioChoiceOf(viewers, output, consumerId) {
|
|
|
96
96
|
* @returns {void}
|
|
97
97
|
*/
|
|
98
98
|
export function chooseAudioTrack(viewers, output, consumerId, choice) {
|
|
99
|
-
viewers.
|
|
99
|
+
const viewer = viewers.join(output, consumerId);
|
|
100
|
+
if (viewer) viewer.audio = { trackIndex: choice.trackIndex, transcode: choice.transcode === true };
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
/**
|
|
@@ -122,7 +123,8 @@ export function stepOnScreenOf(viewers, output, consumerId) {
|
|
|
122
123
|
* @returns {void}
|
|
123
124
|
*/
|
|
124
125
|
export function noteStepOnScreen(viewers, output, consumerId, stepId) {
|
|
125
|
-
viewers.
|
|
126
|
+
const viewer = viewers.join(output, consumerId);
|
|
127
|
+
if (viewer) viewer.activeVariantId = stepId;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
/**
|
|
@@ -148,7 +150,7 @@ export function noteStepBeingWarmed(viewers, output, consumerId, stepId) {
|
|
|
148
150
|
// Clearing does not make a watcher. Warming one FOR somebody is them
|
|
149
151
|
// watching; saying nothing is being warmed for a person who watches nothing
|
|
150
152
|
// would put them on this output for the sake of a null.
|
|
151
|
-
const viewer = stepId ? viewers.
|
|
153
|
+
const viewer = stepId ? viewers.join(output, consumerId) : on(viewers, output, consumerId);
|
|
152
154
|
if (viewer) {
|
|
153
155
|
// When the warming of THIS step began: kept across a repeat of the same
|
|
154
156
|
// step, started afresh for another one, gone with it.
|
|
@@ -192,16 +194,15 @@ export function audioBeingWarmedOf(viewers, output, consumerId) {
|
|
|
192
194
|
* @returns {void}
|
|
193
195
|
*/
|
|
194
196
|
export function noteAudioBeingWarmed(viewers, output, consumerId, renditionId) {
|
|
195
|
-
const viewer = renditionId ? viewers.
|
|
197
|
+
const viewer = renditionId ? viewers.join(output, consumerId) : on(viewers, output, consumerId);
|
|
196
198
|
if (viewer) {
|
|
197
199
|
viewer.warmingAudioId = renditionId;
|
|
198
200
|
}
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* before it can ask for a segment would never be made.
|
|
204
|
+
* A present viewer is watching this output. Asking for its init or a segment
|
|
205
|
+
* adds the output to their viewing; a late request cannot establish presence.
|
|
205
206
|
*
|
|
206
207
|
* @param {object} viewers
|
|
207
208
|
* @param {object} output
|
|
@@ -209,7 +210,7 @@ export function noteAudioBeingWarmed(viewers, output, consumerId, renditionId) {
|
|
|
209
210
|
* @returns {void}
|
|
210
211
|
*/
|
|
211
212
|
export function watches(viewers, output, consumerId) {
|
|
212
|
-
viewers.
|
|
213
|
+
viewers.join(output, consumerId);
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
/**
|
|
@@ -222,7 +223,7 @@ export function watches(viewers, output, consumerId) {
|
|
|
222
223
|
* @returns {void}
|
|
223
224
|
*/
|
|
224
225
|
export function placeOn(viewers, output, consumerId, seconds) {
|
|
225
|
-
viewers.
|
|
226
|
+
viewers.join(output, consumerId)?.moveTo(seconds);
|
|
226
227
|
}
|
|
227
228
|
|
|
228
229
|
/**
|
|
@@ -23,13 +23,12 @@ import { activeOutputFor } from "./active-output.js";
|
|
|
23
23
|
*
|
|
24
24
|
* @param {object} params
|
|
25
25
|
* @param {{ get: (id: string) => object | undefined }} params.outputs - Every live output, by id.
|
|
26
|
-
* @param {{
|
|
26
|
+
* @param {{ join: (session: object, consumerId: string) => import("./Viewer.js").Viewer | null }} params.viewers
|
|
27
27
|
* @param {string} params.sessionId - The session the page addresses, which is
|
|
28
28
|
* always the picture's: the browser is not told which rung it is on.
|
|
29
29
|
* @param {object} params.report - What they said about themselves.
|
|
30
30
|
* @param {number} [params.now]
|
|
31
|
-
* @returns {boolean} False when
|
|
32
|
-
* reporting into one that has been disposed.
|
|
31
|
+
* @returns {boolean} False when the session or its viewer has gone.
|
|
33
32
|
*/
|
|
34
33
|
export function recordViewerReport({ outputs, viewers, sessionId, report, now = Date.now() }) {
|
|
35
34
|
const named = outputs.get(sessionId);
|
|
@@ -41,6 +40,8 @@ export function recordViewerReport({ outputs, viewers, sessionId, report, now =
|
|
|
41
40
|
// The stream on screen is the reporter's own: with two viewers on two rungs,
|
|
42
41
|
// one report says nothing about the other's encoder.
|
|
43
42
|
const session = activeOutputFor({ base: named, consumerId, outputs, viewers });
|
|
44
|
-
viewers.
|
|
43
|
+
const viewer = viewers.join(session, consumerId);
|
|
44
|
+
if (!viewer) return false;
|
|
45
|
+
viewer.report(report, now);
|
|
45
46
|
return true;
|
|
46
47
|
}
|