@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.10",
3
+ "version": "2.101.12",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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 } | null}
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
- const short = leading.urgent === true ? this.#pauseRule.shortfall({ leader: leading.run, need }) : null;
744
- this.#noteShortfall(short ? { address: leading.address, ...short } : null);
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). Null where it is not
776
- * short, or nothing measured it.
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 } | null}
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
- const watched = this.#host.viewers.get(consumerId)?.outputs;
407
- if (!watched || watched.size === 0) {
408
- this.#host.viewers.hasGone(consumerId);
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
- * A viewer is forgotten once nothing has been heard from them for longer than
908
- * any silence a watching viewer can produce. The figure is the proxy's own
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.of(session, consumerId).seen();
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
- const known = consumerId ? this.#host.viewers.get(consumerId) : null;
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
- request.signal?.throwIfAborted();
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
- this.placeViewer(candidate, this.#host.viewers.of(candidate, consumerId), startPositionSeconds);
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
- request.signal?.throwIfAborted();
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
- this.placeViewer(output, this.#host.viewers.of(output, consumerId), positionSeconds);
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.of(named, consumerId);
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.of(output, consumerId).audio = { trackIndex: choice.trackIndex, transcode: choice.transcode === true };
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.of(output, consumerId).activeVariantId = stepId;
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.of(output, consumerId) : on(viewers, output, consumerId);
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.of(output, consumerId) : on(viewers, output, consumerId);
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
- * This viewer is watching this output. Asking for any of its files is watching
203
- * it: nothing is made for an output nobody is on, so the init a player needs
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.of(output, consumerId);
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.of(output, consumerId).moveTo(seconds);
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 {{ of: (session: object, consumerId: string) => import("./Viewer.js").Viewer }} params.viewers
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 no such session is live, which is the page
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.of(session, consumerId).report(report, now);
43
+ const viewer = viewers.join(session, consumerId);
44
+ if (!viewer) return false;
45
+ viewer.report(report, now);
45
46
  return true;
46
47
  }