@torrent-tv/proxy 2.101.11 → 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.11",
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": {
@@ -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
  //
@@ -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
  }