@torrent-tv/proxy 2.100.10 → 2.100.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/bin/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import os from "node:os";
|
|
|
18
18
|
import { spawnSync } from "node:child_process";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import ffmpegStatic from "ffmpeg-static";
|
|
21
|
+
import nodeDataChannel from "node-datachannel";
|
|
21
22
|
import { startProxyServer } from "../server.js";
|
|
22
23
|
import { registerClient } from "../services/transport/registry-api.js";
|
|
23
24
|
import { createTunnelClient } from "../services/transport/tunnel-client.js";
|
|
@@ -689,6 +690,7 @@ try {
|
|
|
689
690
|
});
|
|
690
691
|
|
|
691
692
|
webRtcManager = createWebRtcManager({
|
|
693
|
+
rtc: nodeDataChannel,
|
|
692
694
|
// Single UDP port (UPnP-mapped above) shared by all sessions via a
|
|
693
695
|
// persistent ICE UDP mux listener, so the WebRTC path is reachable from the
|
|
694
696
|
// internet on one fixed port.
|
package/package.json
CHANGED
|
@@ -36,7 +36,15 @@ export class Renditions {
|
|
|
36
36
|
#host;
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
39
|
+
* The segment each viewer's move was last said to wait for on the output
|
|
40
|
+
* they are leaving, so that a wait is said once per segment.
|
|
41
|
+
*
|
|
42
|
+
* @type {Map<string, number>}
|
|
43
|
+
*/
|
|
44
|
+
#moveWaitsSaid = new Map();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `switchingFrom`, `segmentClosed`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
|
|
40
48
|
*/
|
|
41
49
|
constructor(host) {
|
|
42
50
|
this.#host = host;
|
|
@@ -836,6 +844,14 @@ export class Renditions {
|
|
|
836
844
|
for (const consumerId of this.#host.switchingOnto(output)) {
|
|
837
845
|
this.#reconsiderSameHeightSwitch(base, consumerId, index);
|
|
838
846
|
}
|
|
847
|
+
// A segment closed on the output a viewer is LEAVING may be the one their
|
|
848
|
+
// move waits for (`#givenAndOpenOn`). It is behind the segment the move
|
|
849
|
+
// begins at by construction, so it is asked about with no index.
|
|
850
|
+
for (const consumerId of this.#host.switchingFrom?.(output) ?? []) {
|
|
851
|
+
if (this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs }) === output) {
|
|
852
|
+
this.#reconsiderSameHeightSwitch(base, consumerId, null);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
839
855
|
}
|
|
840
856
|
}
|
|
841
857
|
|
|
@@ -894,6 +910,28 @@ export class Renditions {
|
|
|
894
910
|
}
|
|
895
911
|
}
|
|
896
912
|
|
|
913
|
+
/**
|
|
914
|
+
* The segment just before the one a move would begin at, when it was given
|
|
915
|
+
* to this viewer from the output on their screen and is not closed there:
|
|
916
|
+
* the segment their held request waits for. Null otherwise.
|
|
917
|
+
*
|
|
918
|
+
* @param {HlsSession | null} current - The output on their screen.
|
|
919
|
+
* @param {string} consumerId
|
|
920
|
+
* @param {number[]} askedHeights
|
|
921
|
+
* @param {number} next - The segment the move would begin at.
|
|
922
|
+
* @returns {number | null}
|
|
923
|
+
*/
|
|
924
|
+
#givenAndOpenOn(current, consumerId, askedHeights, next) {
|
|
925
|
+
const segment = next - 1;
|
|
926
|
+
if (!current?.outputKey || segment < 0) {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
const generation = this.#host.generationOfRequest(consumerId, Number.NaN);
|
|
930
|
+
const given = askedHeights.some((height) =>
|
|
931
|
+
this.#host.givenOutputOf(consumerId, generation, height, segment) === current.outputKey);
|
|
932
|
+
return given && !this.#host.segmentClosed(current.outputKey, segment) ? segment : null;
|
|
933
|
+
}
|
|
934
|
+
|
|
897
935
|
/**
|
|
898
936
|
* The segment this viewer will ask for next at these heights.
|
|
899
937
|
*
|
|
@@ -967,6 +1005,24 @@ export class Renditions {
|
|
|
967
1005
|
if (!this.#host.segmentClosed(prepared.outputKey ?? "", next)) {
|
|
968
1006
|
return;
|
|
969
1007
|
}
|
|
1008
|
+
// THE SEGMENT ALREADY GIVEN FROM THE OUTPUT ON SCREEN. A request is held by
|
|
1009
|
+
// the output it was given from, and leaving that output stops its encoder:
|
|
1010
|
+
// a segment given and not yet closed there would then be made by nobody.
|
|
1011
|
+
// On Home Assistant a viewer waiting for #2176 was moved from #2177 and
|
|
1012
|
+
// the picture stood for good (r71, torrent-tv/meta#178). The move waits for
|
|
1013
|
+
// that segment to be closed; an output whose encoding has failed for good
|
|
1014
|
+
// closes nothing, and its held request is the serving path's to answer.
|
|
1015
|
+
const open = this.#givenAndOpenOn(current, consumerId, move.askedHeights, next);
|
|
1016
|
+
if (open !== null && !this.#host.encodeRuns.hasFailed(current)) {
|
|
1017
|
+
if (this.#moveWaitsSaid.get(consumerId) !== open) {
|
|
1018
|
+
this.#moveWaitsSaid.set(consumerId, open);
|
|
1019
|
+
this.#host.logger.info(
|
|
1020
|
+
`transcode ${base.id} ${consumerId}'s move to ${move.outputKey} waits for #${open} on ` +
|
|
1021
|
+
`${current.outputKey}, given and not yet closed there`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
970
1026
|
// THE CUSHION, for a move that is not urgent (roadmap item 98): a move up
|
|
971
1027
|
// is made once the viewer holds the cushion this file needs, and their
|
|
972
1028
|
// next report asks again until they do. A move DOWN is made at once — it
|
|
@@ -1026,6 +1082,7 @@ export class Renditions {
|
|
|
1026
1082
|
* @returns {void}
|
|
1027
1083
|
*/
|
|
1028
1084
|
#completeSameHeightSwitch(base, consumerId, move, prepared, current, next) {
|
|
1085
|
+
this.#moveWaitsSaid.delete(consumerId);
|
|
1029
1086
|
for (const height of move.askedHeights) {
|
|
1030
1087
|
this.#choose(consumerId, height, prepared, base);
|
|
1031
1088
|
}
|
|
@@ -1063,6 +1120,7 @@ export class Renditions {
|
|
|
1063
1120
|
* @returns {void}
|
|
1064
1121
|
*/
|
|
1065
1122
|
#cancelSameHeightSwitch(base, consumerId, reason) {
|
|
1123
|
+
this.#moveWaitsSaid.delete(consumerId);
|
|
1066
1124
|
const move = this.#host.sameHeightSwitchOf(consumerId);
|
|
1067
1125
|
if (!move) {
|
|
1068
1126
|
return;
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
stepBeingWarmedOf,
|
|
61
61
|
stepBeingWarmedSinceOf,
|
|
62
62
|
stepOnScreenOf,
|
|
63
|
+
switchingFrom,
|
|
63
64
|
switchingOnto,
|
|
64
65
|
watches
|
|
65
66
|
} from "../viewer/choices.js";
|
|
@@ -450,6 +451,7 @@ export function wireOutputs({
|
|
|
450
451
|
sameHeightSwitchOf: (consumerId) => sameHeightSwitchOf(parts.viewers, consumerId),
|
|
451
452
|
noteSameHeightSwitch: (consumerId, value) => noteSameHeightSwitch(parts.viewers, consumerId, value),
|
|
452
453
|
switchingOnto: (output) => switchingOnto(parts.viewers, output),
|
|
454
|
+
switchingFrom: (output) => switchingFrom(parts.viewers, output),
|
|
453
455
|
// What a viewer holds, and the cushion this file needs before a move that
|
|
454
456
|
// is not urgent is made (roadmap item 98).
|
|
455
457
|
bufferedSecondsOf: (consumerId) => bufferedSecondsOf(parts.viewers, consumerId),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file WebRTC connection manager for the proxy (Node.js side).
|
|
3
3
|
*
|
|
4
|
-
* Maintains
|
|
4
|
+
* Maintains a media connection and an optional independent control connection
|
|
5
|
+
* per browser session. Control signals carry a generation's `connectionId`.
|
|
5
6
|
* Receives SDP offers and ICE candidates through the signalling tunnel
|
|
6
7
|
* and responds with SDP answers and its own candidates through the same channel.
|
|
7
8
|
*
|
|
@@ -9,8 +10,6 @@
|
|
|
9
10
|
* via the `onDataChannel` callback — this module only owns the signalling phase.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import nodeDataChannel from "node-datachannel";
|
|
13
|
-
|
|
14
13
|
/** @import { WebRtcSignal } from './tunnel-client.js' */
|
|
15
14
|
|
|
16
15
|
// Two STUN servers from different operators, both with IPv6 (AAAA) records, so
|
|
@@ -148,6 +147,8 @@ function candidateAddrKind(candidate) {
|
|
|
148
147
|
* Configuration for the WebRTC manager.
|
|
149
148
|
*
|
|
150
149
|
* @typedef {Object} WebRtcManagerOptions
|
|
150
|
+
* @property {typeof import("node-datachannel").default} rtc
|
|
151
|
+
* The peer connection implementation, supplied by the composition root.
|
|
151
152
|
* @property {(sessionId: string, signal: WebRtcSignal) => void} sendSignal
|
|
152
153
|
* Send a WebRTC signal (answer or ICE candidate) back to the browser via
|
|
153
154
|
* the tunnel. Typically wired to `tunnelClient.sendSignal`.
|
|
@@ -196,9 +197,10 @@ function candidateAddrKind(candidate) {
|
|
|
196
197
|
* @param {WebRtcManagerOptions} options
|
|
197
198
|
* @returns {WebRtcManager}
|
|
198
199
|
*/
|
|
199
|
-
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo }) {
|
|
200
|
+
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo, rtc }) {
|
|
200
201
|
/** @type {Map<string, import("node-datachannel").PeerConnection>} */
|
|
201
202
|
const peers = new Map();
|
|
203
|
+
const controlPeers = new Map();
|
|
202
204
|
|
|
203
205
|
/**
|
|
204
206
|
* @param {string} message
|
|
@@ -222,7 +224,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
222
224
|
let udpMuxListener = null;
|
|
223
225
|
if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
|
|
224
226
|
try {
|
|
225
|
-
udpMuxListener = new
|
|
227
|
+
udpMuxListener = new rtc.IceUdpMuxListener(udpPort);
|
|
226
228
|
// STUN that doesn't match an existing session (our PeerConnections are
|
|
227
229
|
// created from the SDP offer before the browser's STUN arrives, so normal
|
|
228
230
|
// traffic is "handled"). Stray/unhandled requests are simply dropped.
|
|
@@ -249,13 +251,23 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
249
251
|
* @param {string} sessionId
|
|
250
252
|
* @returns {import("node-datachannel").PeerConnection}
|
|
251
253
|
*/
|
|
252
|
-
function getOrCreatePeer(sessionId) {
|
|
253
|
-
const
|
|
254
|
+
function getOrCreatePeer(sessionId, connectionId = null) {
|
|
255
|
+
const peerId = connectionId === null ? sessionId : `${sessionId}/${connectionId}`;
|
|
256
|
+
const existing = peers.get(peerId);
|
|
254
257
|
if (existing) {
|
|
255
258
|
return existing;
|
|
256
259
|
}
|
|
257
260
|
|
|
258
|
-
|
|
261
|
+
if (connectionId !== null) {
|
|
262
|
+
const previous = controlPeers.get(sessionId);
|
|
263
|
+
if (previous) closePeer(previous);
|
|
264
|
+
}
|
|
265
|
+
const pc = new rtc.PeerConnection(`proxy-${peerId}`, pcConfig);
|
|
266
|
+
if (connectionId !== null) controlPeers.set(sessionId, peerId);
|
|
267
|
+
const signalBrowser = (signal) => sendSignal(sessionId, {
|
|
268
|
+
...signal,
|
|
269
|
+
...(connectionId === null ? {} : { connectionId })
|
|
270
|
+
});
|
|
259
271
|
|
|
260
272
|
// Forward all ICE candidates to the browser through the tunnel.
|
|
261
273
|
//
|
|
@@ -272,7 +284,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
272
284
|
log(
|
|
273
285
|
`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${kind} candidate: ${candidate.replace(/^a=/, "")}`
|
|
274
286
|
);
|
|
275
|
-
|
|
287
|
+
signalBrowser({ type: "candidate", candidate, mid });
|
|
276
288
|
|
|
277
289
|
// Symmetric-NAT port prediction: offer extra srflx candidates at the
|
|
278
290
|
// predicted external ports so the browser probes them too. No-op unless
|
|
@@ -287,7 +299,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
287
299
|
`[webrtc] Session ${sessionId.slice(0, 8)}: symmetric NAT (delta=${nat.portDelta}) — injecting ${predicted.length} predicted srflx candidates: ${predicted.map((p) => p.port).join(",")}`
|
|
288
300
|
);
|
|
289
301
|
for (const p of predicted) {
|
|
290
|
-
|
|
302
|
+
signalBrowser({ type: "candidate", candidate: p.candidate, mid });
|
|
291
303
|
}
|
|
292
304
|
}
|
|
293
305
|
}
|
|
@@ -304,7 +316,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
304
316
|
// Forward our SDP answer to the browser through the tunnel.
|
|
305
317
|
pc.onLocalDescription((sdp, type) => {
|
|
306
318
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${type}`);
|
|
307
|
-
|
|
319
|
+
signalBrowser({ type, sdp, ...(connectionId === null ? { separateControl: true } : {}) });
|
|
308
320
|
});
|
|
309
321
|
|
|
310
322
|
pc.onStateChange((state) => {
|
|
@@ -328,7 +340,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
328
340
|
// "disconnected" is a transient state — ICE may recover on its own.
|
|
329
341
|
// Only tear down on terminal states: "failed" and "closed".
|
|
330
342
|
if (state === "failed" || state === "closed") {
|
|
331
|
-
|
|
343
|
+
if (peers.get(peerId) === pc) closePeer(peerId);
|
|
332
344
|
}
|
|
333
345
|
});
|
|
334
346
|
|
|
@@ -340,11 +352,15 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
340
352
|
|
|
341
353
|
// Browser creates the data channel — we receive it here.
|
|
342
354
|
pc.onDataChannel((channel) => {
|
|
343
|
-
|
|
344
|
-
|
|
355
|
+
if (peers.get(peerId) !== pc || (connectionId !== null && channel.getLabel() !== "proxy-control")) {
|
|
356
|
+
channel.close();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: ${connectionId === null ? "media" : `control ${connectionId}`} channel "${channel.getLabel()}" opened`);
|
|
360
|
+
onDataChannel(peerId, channel);
|
|
345
361
|
});
|
|
346
362
|
|
|
347
|
-
peers.set(
|
|
363
|
+
peers.set(peerId, pc);
|
|
348
364
|
return pc;
|
|
349
365
|
}
|
|
350
366
|
|
|
@@ -364,6 +380,11 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
364
380
|
if (!signal || typeof signal.type !== "string") {
|
|
365
381
|
return;
|
|
366
382
|
}
|
|
383
|
+
const connectionId = signal.connectionId ?? null;
|
|
384
|
+
if (connectionId !== null && (typeof connectionId !== "string" || !/^[a-zA-Z0-9-]{1,64}$/.test(connectionId))) return;
|
|
385
|
+
// A second association is subordinate to an existing media connection.
|
|
386
|
+
if (connectionId !== null && !peers.has(sessionId)) return;
|
|
387
|
+
const peerId = connectionId === null ? sessionId : `${sessionId}/${connectionId}`;
|
|
367
388
|
|
|
368
389
|
if (signal.type === "offer") {
|
|
369
390
|
if (typeof signal.sdp !== "string") {
|
|
@@ -375,19 +396,19 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
375
396
|
// single bad session must never crash the whole proxy — that would drop
|
|
376
397
|
// every other viewer and the tunnel. Contain it: fail this session only.
|
|
377
398
|
try {
|
|
378
|
-
const pc = getOrCreatePeer(sessionId);
|
|
399
|
+
const pc = getOrCreatePeer(sessionId, connectionId);
|
|
379
400
|
pc.setRemoteDescription(signal.sdp, "offer");
|
|
380
401
|
// `onLocalDescription` fires automatically with the SDP answer.
|
|
381
402
|
} catch (error) {
|
|
382
403
|
const message = error instanceof Error ? error.message : String(error);
|
|
383
404
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to handle offer: ${message}`);
|
|
384
|
-
|
|
405
|
+
closePeer(peerId);
|
|
385
406
|
}
|
|
386
407
|
return;
|
|
387
408
|
}
|
|
388
409
|
|
|
389
410
|
if (signal.type === "candidate") {
|
|
390
|
-
const pc = peers.get(
|
|
411
|
+
const pc = peers.get(peerId);
|
|
391
412
|
if (!pc) {
|
|
392
413
|
return;
|
|
393
414
|
}
|
|
@@ -409,15 +430,23 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
409
430
|
* @param {string} sessionId
|
|
410
431
|
* @returns {void}
|
|
411
432
|
*/
|
|
412
|
-
function
|
|
413
|
-
const pc = peers.get(
|
|
433
|
+
function closePeer(peerId) {
|
|
434
|
+
const pc = peers.get(peerId);
|
|
414
435
|
if (pc) {
|
|
436
|
+
peers.delete(peerId);
|
|
437
|
+
const sessionId = peerId.split("/")[0];
|
|
438
|
+
if (controlPeers.get(sessionId) === peerId) controlPeers.delete(sessionId);
|
|
415
439
|
try { pc.close(); } catch { /* ignore */ }
|
|
416
|
-
|
|
417
|
-
log(`[webrtc] Session ${sessionId.slice(0, 8)}: closed`);
|
|
440
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: peer ${peerId} closed`);
|
|
418
441
|
}
|
|
419
442
|
}
|
|
420
443
|
|
|
444
|
+
function closeSession(sessionId) {
|
|
445
|
+
const control = controlPeers.get(sessionId);
|
|
446
|
+
if (control) closePeer(control);
|
|
447
|
+
closePeer(sessionId);
|
|
448
|
+
}
|
|
449
|
+
|
|
421
450
|
/**
|
|
422
451
|
* Close all peer connections and stop the shared UDP mux listener.
|
|
423
452
|
*
|
|
@@ -425,7 +454,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
|
|
|
425
454
|
*/
|
|
426
455
|
function dispose() {
|
|
427
456
|
for (const sessionId of [...peers.keys()]) {
|
|
428
|
-
|
|
457
|
+
closePeer(sessionId);
|
|
429
458
|
}
|
|
430
459
|
if (udpMuxListener) {
|
|
431
460
|
try { udpMuxListener.stop(); } catch { /* ignore */ }
|
|
@@ -565,6 +565,25 @@ export function switchingOnto(viewers, output) {
|
|
|
565
565
|
return found;
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
/**
|
|
569
|
+
* The present viewers on this output for whom a move onto ANOTHER output is
|
|
570
|
+
* being prepared: the ones a segment closed here may let go.
|
|
571
|
+
*
|
|
572
|
+
* @param {object} viewers
|
|
573
|
+
* @param {object} output
|
|
574
|
+
* @returns {string[]}
|
|
575
|
+
*/
|
|
576
|
+
export function switchingFrom(viewers, output) {
|
|
577
|
+
const found = [];
|
|
578
|
+
for (const [consumerId, viewer] of viewers.forOutput(output)) {
|
|
579
|
+
const move = viewer.sameHeightSwitch;
|
|
580
|
+
if (viewer.isPresent() && move && move.outputKey !== output.outputKey) {
|
|
581
|
+
found.push(consumerId);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return found;
|
|
585
|
+
}
|
|
586
|
+
|
|
568
587
|
/**
|
|
569
588
|
* Every output a present viewer is being prepared onto, as ids.
|
|
570
589
|
*
|