@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.100.10",
3
+ "version": "2.100.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": {
@@ -36,7 +36,15 @@ export class Renditions {
36
36
  #host;
37
37
 
38
38
  /**
39
- * @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`, `segmentClosed`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
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 one `PeerConnection` per browser session, keyed by `sessionId`.
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 nodeDataChannel.IceUdpMuxListener(udpPort);
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 existing = peers.get(sessionId);
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
- const pc = new nodeDataChannel.PeerConnection(`proxy-${sessionId.slice(0, 8)}`, pcConfig);
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
- sendSignal(sessionId, { type: "candidate", candidate, mid });
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
- sendSignal(sessionId, { type: "candidate", candidate: p.candidate, mid });
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
- sendSignal(sessionId, { type, sdp });
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
- closeSession(sessionId);
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
- log(`[webrtc] Session ${sessionId.slice(0, 8)}: data channel "${channel.getLabel()}" opened`);
344
- onDataChannel(sessionId, channel);
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(sessionId, pc);
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
- closeSession(sessionId);
405
+ closePeer(peerId);
385
406
  }
386
407
  return;
387
408
  }
388
409
 
389
410
  if (signal.type === "candidate") {
390
- const pc = peers.get(sessionId);
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 closeSession(sessionId) {
413
- const pc = peers.get(sessionId);
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
- peers.delete(sessionId);
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
- closeSession(sessionId);
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
  *