@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/CHANGELOG.md +1558 -1538
- package/bin/cli.js +14 -1
- package/package.json +1 -1
- package/services/data-channel-handler.js +83 -3
- package/services/encode/DemandMap.js +187 -0
- package/services/encode/EncodePlan.js +283 -272
- package/services/encode/SegmentDemand.js +0 -0
- package/services/hls-session-manager.js +476 -179
- package/services/orchestrators/EncodeOrchestrator.js +13 -3
- package/services/viewer/Viewer.js +214 -145
- package/services/viewer/Viewers.js +213 -124
- package/test/behind-head-repair.test.js +2 -2
- package/test/demand-map.test.js +102 -0
- package/test/encode-plan.test.js +44 -2
- package/test/failed-start-breaker.test.js +151 -0
- package/test/orchestrator-wired.test.js +190 -164
- package/test/quality-variants.test.js +2 -2
- package/test/run-intervals.test.js +67 -303
- package/test/seek-target-not-superseded.test.js +2 -2
- package/test/sidecar-naming.test.js +120 -120
- package/test/stale-request-after-seek.test.js +2 -2
- package/test/two-viewers-one-picture.test.js +2 -2
- package/test/viewer-outputs.test.js +7 -7
- package/test/viewer-presence.test.js +119 -0
- package/test/viewer.test.js +27 -10
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
|
@@ -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
|
+
}
|