@torrent-tv/proxy 2.76.6 → 2.78.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/bin/cli.js CHANGED
@@ -527,7 +527,20 @@ try {
527
527
  // Records the wire when a queue stays wedged — how the rare one-way
528
528
  // transmit death (roadmap item 10, 2026-08-24) gets its evidence.
529
529
  witness: packetWitness,
530
- usrsctpState: usrsctpStateReader
530
+ usrsctpState: usrsctpStateReader,
531
+ // Presence, from the one thing that knows it. Late-bound for the same
532
+ // reason as the transport snapshot above: the manager is built inside
533
+ // `startProxyServer`, with this handler already in hand.
534
+ onViewerPresent: (consumerId) => {
535
+ started?.hlsSessionManager?.viewers?.seen?.(consumerId);
536
+ },
537
+ onViewerGone: (consumerId, because) => {
538
+ void started?.hlsSessionManager?.viewerHasGone?.(consumerId, because)
539
+ ?.catch?.((error) => {
540
+ const message = error instanceof Error ? error.message : String(error);
541
+ logger.warn(`could not let go of viewer ${consumerId}: ${message}`);
542
+ });
543
+ }
531
544
  });
532
545
 
533
546
  webRtcManager = createWebRtcManager({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.76.6",
3
+ "version": "2.78.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": {
@@ -159,7 +159,7 @@ export function wedgeIsCertain({ queuedBytes, bytesPerSecond, flatForMs, longest
159
159
  * @param {DataChannel} channel
160
160
  * @returns {() => void} Stops the watch.
161
161
  */
162
- function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState }) {
162
+ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState, onConnectionGone }) {
163
163
  // Every channel of one connection reads the SAME transport counters — the
164
164
  // snapshot describes the peer connection, not the channel — so the heartbeat
165
165
  // belongs to the connection and is printed once for it. Printed per channel
@@ -291,6 +291,12 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
291
291
  // the live one.
292
292
  if (connection.channels.size === 0 && connections.get(sessionId) === connection) {
293
293
  connections.delete(sessionId);
294
+ // Every channel of this connection is down, so the person on it is
295
+ // gone. This is the SECOND way that becomes known, and it exists
296
+ // because the first does not always come: a peer connection can die
297
+ // without `onClosed`, which is why this watch was written in the first
298
+ // place.
299
+ onConnectionGone?.(sessionId, "the transport stopped answering");
294
300
  }
295
301
  };
296
302
  // Independent of the queue: the transport's own counters, sampled for as
@@ -544,8 +550,52 @@ export function createDataChannelHandler({
544
550
  // moment a wedge is declared, from either detector below. Optional: a host
545
551
  // without gdb simply never gets a reading, same as the witness without
546
552
  // tcpdump.
547
- usrsctpState
553
+ usrsctpState,
554
+ // Presence, both directions. A viewer is present because their connection
555
+ // is, and gone because it went — which is a fact about the PERSON and reaches
556
+ // every output they were watching at once. Before this, nothing anywhere
557
+ // released a viewer when their connection closed: the only exits were the
558
+ // browser's own `release` and a silence long enough to be called an absence.
559
+ onViewerPresent = () => {},
560
+ onViewerGone = () => {}
548
561
  }) {
562
+ /**
563
+ * Who is on each connection, by WebRTC session id.
564
+ *
565
+ * One connection carries one page, and a page holds one name per film it has
566
+ * open — so this is normally one id, and more than one only where a page has
567
+ * opened more than one.
568
+ *
569
+ * @type {Map<string, Set<string>>}
570
+ */
571
+ const viewersOnConnection = new Map();
572
+
573
+ /**
574
+ * Everyone on this connection has gone, because the connection has.
575
+ *
576
+ * Said once per connection: a second call after the set is emptied must not
577
+ * announce departures a second time, and the two detectors below — the close
578
+ * event and the transport's own counters — can both fire for one death.
579
+ *
580
+ * @param {string} sessionId
581
+ * @param {string} because
582
+ * @returns {void}
583
+ */
584
+ function connectionGone(sessionId, because) {
585
+ const viewers = viewersOnConnection.get(sessionId);
586
+ viewersOnConnection.delete(sessionId);
587
+ if (!viewers || viewers.size === 0) {
588
+ return;
589
+ }
590
+ for (const consumerId of viewers) {
591
+ onLog?.(`[dc] Session ${sessionId.slice(0, 8)}: viewer ${consumerId} left — ${because}`);
592
+ try {
593
+ onViewerGone(consumerId, because);
594
+ } catch {
595
+ // A viewer failing to be let go must not stop the others being let go.
596
+ }
597
+ }
598
+ }
549
599
  /**
550
600
  * Channels currently interested in one file's subtitle cues, keyed by
551
601
  * `sourceKey:fileIndex`. Populated the moment a browser asks for an
@@ -630,7 +680,11 @@ export function createDataChannelHandler({
630
680
  log: (message) => log(message),
631
681
  getTransportSnapshot,
632
682
  witness,
633
- usrsctpState
683
+ usrsctpState,
684
+ // The watcher is where a connection's death is noticed when no close event
685
+ // arrives, so it is the second door presence leaves by. Given rather than
686
+ // reached for: it is a module-level function and cannot see this closure.
687
+ onConnectionGone: connectionGone
634
688
  });
635
689
  // Numbered probes on every channel, and the browser's echo of what it saw.
636
690
  // The proxy's own counters cannot say whether bytes it handed to usrsctp were
@@ -804,6 +858,27 @@ export function createDataChannelHandler({
804
858
  return;
805
859
  }
806
860
 
861
+ // WHO is on this connection. Sent once when the page opens its control
862
+ // channel, and it is what lets a closed connection say that a PERSON has
863
+ // gone rather than that one output lost a request.
864
+ //
865
+ // The name is the page's, not this connection's: it is minted once per
866
+ // film opened in the tab and survives the reconnect ladder swapping the
867
+ // transport underneath a running player. A name minted here would make
868
+ // one person two the first time that happened, each with their own
869
+ // position.
870
+ if (message.type === "viewer" && typeof message.consumerId === "string" && message.consumerId) {
871
+ const known = viewersOnConnection.get(sessionId) ?? new Set();
872
+ const isNew = !known.has(message.consumerId);
873
+ known.add(message.consumerId);
874
+ viewersOnConnection.set(sessionId, known);
875
+ if (isNew) {
876
+ log(`[dc] Session ${tag}: viewer ${message.consumerId} is on this connection`);
877
+ }
878
+ onViewerPresent(message.consumerId);
879
+ return;
880
+ }
881
+
807
882
  // The far end's answer to the numbered probes, plus what it can see of
808
883
  // its own receiving. It travels browser to proxy, the direction that goes
809
884
  // on working through a freeze, so it arrives when nothing else does.
@@ -836,6 +911,11 @@ export function createDataChannelHandler({
836
911
  partials.clear();
837
912
  unsubscribeSubtitlesAll(channel);
838
913
  log(`[dc] Session ${tag}: channel closed`);
914
+ // The ordinary way a viewer leaves, and the only one that is immediate.
915
+ // Everything else — a silence long enough to be called an absence, the
916
+ // transport's own counters going quiet — is a backstop for this event
917
+ // failing to arrive.
918
+ connectionGone(sessionId, "the connection closed");
839
919
  });
840
920
 
841
921
  channel.onError((err) => {
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @file What one viewer needs, what all of them need together, and in what
3
+ * order the work should be taken.
4
+ *
5
+ * The shape, stated by the user 2026-09-05:
6
+ *
7
+ * > Usually you make a map for each viewer, then merge the maps, then decide
8
+ * > the best way of filling it given the encoders available, where they are now
9
+ * > and how many there are.
10
+ *
11
+ * Three questions, and this file answers the first two. The third — the filling
12
+ * — belongs to whoever holds the encoders, and it is handed the merged map
13
+ * instead of a list of windows.
14
+ *
15
+ * **ONE PRIORITISATION, TWO CONSUMERS.** Downloading and encoding keep
16
+ * different STATES — made / being made / free for one; downloaded / arriving,
17
+ * and which peers hold it at what speed, for the other — but they must agree
18
+ * about what matters first, or the swarm fetches what the encoder will not
19
+ * reach for another twenty minutes. That agreement is this map.
20
+ *
21
+ * **THE UNIT IS SECONDS OF FILM.** A map is a set of stretches with sizes and a
22
+ * length of its own, so it needs a unit, and seconds are the only one every
23
+ * term of the arithmetic is already stated in: encode speed is a ratio of
24
+ * seconds to seconds, the measured allowance below which an interruption
25
+ * reaches a viewer is seconds, the viewer's position is seconds, the film's
26
+ * length is seconds. Bytes cannot serve — how many a second costs is not known
27
+ * when a file is opened and is not constant across it, and a soundtrack in a
28
+ * file of its own has bytes of its own. Segment numbers cannot serve either:
29
+ * they exist only once a cut grid is read, and two outputs of one film number
30
+ * differently.
31
+ *
32
+ * **What this file must NOT know**, and the boundary is the point: nothing about
33
+ * containers, cut grids, pieces or bytes. Turning a stretch of seconds into the
34
+ * bytes of one track is the container's and the track's business, by whatever
35
+ * means suit that file — a Cues table, a sample table, or a walk when the file
36
+ * carries neither, which is the same answer they already give in order to play
37
+ * it at all. Getting those bytes is the downloader's business; making segments
38
+ * out of them is the encoder's.
39
+ */
40
+
41
+ /**
42
+ * How urgently a stretch of film is wanted. Higher is sooner.
43
+ *
44
+ * @typedef {object} DemandZone
45
+ * @property {number} from - First second of film, inclusive.
46
+ * @property {number} to - Last second of film, inclusive.
47
+ * @property {number} priority - Higher is more urgent. Only the ORDER between
48
+ * zones is meaningful; the numbers themselves are not a scale.
49
+ */
50
+
51
+ /** Where the viewer stands. Nothing outranks it. */
52
+ const AT_THE_VIEWER = 3;
53
+
54
+ /** In front of them, within reach while they watch what is already made. */
55
+ const IN_FRONT = 2;
56
+
57
+ /** The rest of the track: wanted, because the file is encoded whole. */
58
+ const THE_REST = 1;
59
+
60
+ /**
61
+ * One viewer's map.
62
+ *
63
+ * Three zones, and the two boundaries between them are measured rather than
64
+ * chosen:
65
+ *
66
+ * 1. **what must be ready before they set off, so that they never stop.** While
67
+ * they watch, film is consumed at one second per second and produced at
68
+ * `encodeSpeedX`. Above realtime the encoder gains on them, and all that is
69
+ * needed in front is the measured allowance for unevenness in the swarm and
70
+ * in production. Below realtime it LOSES `1 - speed` of a second for every
71
+ * second played, so over the film in front of them the shortfall is
72
+ * `remaining × (1 - speed)` — at 0.5x on twenty minutes ahead, ten minutes
73
+ * must exist before they start, or they meet a stall partway through;
74
+ * 2. **what the machine reaches while they watch zone 1** — in front of a
75
+ * moving viewer, so ahead of the rest, and nobody is waiting for it yet, so
76
+ * behind zone 1;
77
+ * 3. **the rest of the track.**
78
+ *
79
+ * @param {object} params
80
+ * @param {number} params.atSeconds - Where they are watching from.
81
+ * @param {number} params.durationSeconds - How long the film is.
82
+ * @param {number} params.allowanceSeconds - The measured depth below which an
83
+ * interruption reaches this viewer (`minimumBufferSeconds`).
84
+ * @param {number} params.encodeSpeedX - Measured encode speed against realtime
85
+ * for this track on this machine. Zero or less means nothing has measured it
86
+ * yet, and then zone 2 is left out rather than invented.
87
+ * @returns {DemandZone[]} Ascending, without gaps or overlaps, covering
88
+ * everything from where they are to the end of the film.
89
+ */
90
+ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, encodeSpeedX }) {
91
+ const from = Number.isFinite(atSeconds) && atSeconds > 0 ? atSeconds : 0;
92
+ const end = Number.isFinite(durationSeconds) ? durationSeconds : 0;
93
+ if (!(end > from)) {
94
+ return [];
95
+ }
96
+ const remaining = end - from;
97
+ const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 0;
98
+ const speed = Number.isFinite(encodeSpeedX) && encodeSpeedX > 0 ? encodeSpeedX : 0;
99
+ const shortfall = speed > 0 && speed < 1 ? remaining * (1 - speed) : 0;
100
+
101
+ /** @type {DemandZone[]} */
102
+ const zones = [];
103
+ const readyBy = Math.min(end, from + allowance + shortfall);
104
+ zones.push({ from, to: readyBy, priority: AT_THE_VIEWER });
105
+
106
+ if (readyBy < end && speed > 0) {
107
+ // While they watch what zone 1 holds, the encoder makes `speed` times that
108
+ // much. Beyond it nobody is waiting yet.
109
+ const reach = Math.min(end, readyBy + (readyBy - from) * speed);
110
+ if (reach > readyBy) {
111
+ zones.push({ from: readyBy, to: reach, priority: IN_FRONT });
112
+ }
113
+ }
114
+
115
+ const covered = zones[zones.length - 1].to;
116
+ if (covered < end) {
117
+ zones.push({ from: covered, to: end, priority: THE_REST });
118
+ }
119
+ return zones;
120
+ }
121
+
122
+ /**
123
+ * Every viewer's map as one.
124
+ *
125
+ * The highest priority per second wins: film two people want is as urgent as
126
+ * the more urgent of them, and making it once serves both. What comes back has
127
+ * no overlaps, so the filling can walk it without asking about any individual
128
+ * viewer — which is the rule this layer exists to keep, that which viewer asked
129
+ * never reaches the encoders.
130
+ *
131
+ * @param {DemandZone[][]} maps
132
+ * @returns {DemandZone[]} Ascending by position.
133
+ */
134
+ export function mergeMaps(maps) {
135
+ /** @type {DemandZone[]} */
136
+ const all = [];
137
+ for (const map of maps ?? []) {
138
+ for (const zone of map ?? []) {
139
+ if (Number.isFinite(zone?.from) && Number.isFinite(zone?.to) && zone.to > zone.from) {
140
+ all.push(zone);
141
+ }
142
+ }
143
+ }
144
+ if (all.length === 0) {
145
+ return [];
146
+ }
147
+ // Walked by BOUNDARIES rather than by second: a film is thousands of them and
148
+ // this is asked again on every change.
149
+ const points = [...new Set(all.flatMap((zone) => [zone.from, zone.to]))].sort(
150
+ (left, right) => left - right
151
+ );
152
+ /** @type {DemandZone[]} */
153
+ const merged = [];
154
+ for (let index = 0; index < points.length - 1; index += 1) {
155
+ const from = points[index];
156
+ const to = points[index + 1];
157
+ let priority = 0;
158
+ for (const zone of all) {
159
+ if (zone.from <= from && to <= zone.to && zone.priority > priority) {
160
+ priority = zone.priority;
161
+ }
162
+ }
163
+ if (priority <= 0) {
164
+ continue;
165
+ }
166
+ const previous = merged[merged.length - 1];
167
+ if (previous && previous.priority === priority && previous.to === from) {
168
+ previous.to = to;
169
+ continue;
170
+ }
171
+ merged.push({ from, to, priority });
172
+ }
173
+ return merged;
174
+ }
175
+
176
+ /**
177
+ * The merged map in the order the work is taken: most urgent first, and within
178
+ * one priority the earliest film first — that is where somebody is stopped.
179
+ *
180
+ * @param {DemandZone[]} merged
181
+ * @returns {DemandZone[]}
182
+ */
183
+ export function inWorkingOrder(merged) {
184
+ return [...(merged ?? [])].sort(
185
+ (left, right) => right.priority - left.priority || left.from - right.from
186
+ );
187
+ }