@torrent-tv/proxy 2.83.5 → 2.83.7

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/server.js CHANGED
@@ -354,7 +354,8 @@ export async function startProxyServer({
354
354
  sourceRegistry,
355
355
  torrentPool,
356
356
  ffmpegBin,
357
- localBaseUrl: hlsSessionManager.localBaseUrl
357
+ localBaseUrl: hlsSessionManager.localBaseUrl,
358
+ viewers: hlsSessionManager.viewers
358
359
  })
359
360
  );
360
361
  app.get("/stream", async (req, reply) =>
@@ -45,7 +45,6 @@
45
45
 
46
46
  /** @import { DataChannel } from 'node-datachannel' */
47
47
 
48
- import { deriveSourceKey } from "./torrent-source-key.js";
49
48
  import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
50
49
 
51
50
  /**
@@ -582,11 +581,79 @@ export function encodeFrame(idBytes, bytes, done) {
582
581
  return frame;
583
582
  }
584
583
 
584
+ /**
585
+ * Reshapes a body into messages of `sizeBytes` each, handing every finished one
586
+ * to `sendOne`. A size of zero passes each body read through untouched, which
587
+ * is what the field does today — 10 657 210 bytes over 163 messages, so 65 382
588
+ * each, measured 2026-09-12.
589
+ *
590
+ * Only the SIZE changes: the same bytes reach the far end in the same order,
591
+ * over the same channel. That is what makes a comparison between two sizes a
592
+ * comparison of one thing, and it is why the wedge's dependence on the size can
593
+ * be measured at all — the wedge of 2026-09-12 arrived in the middle of one
594
+ * message, with 63 % of a segment on the wire and then nothing.
595
+ *
596
+ * @param {(bytes: Uint8Array) => void} sendOne - Given each finished message.
597
+ * @param {number} sizeBytes - 0 to leave reads as they are.
598
+ * @returns {{ push: (bytes: Uint8Array) => number, flush: () => number }}
599
+ * Both answer how many messages they sent, so a caller counting messages
600
+ * counts messages rather than body reads.
601
+ */
602
+ export function bodySender(sendOne, sizeBytes) {
603
+ if (!(sizeBytes > 0)) {
604
+ return {
605
+ push(bytes) {
606
+ sendOne(bytes);
607
+ return 1;
608
+ },
609
+ flush() {
610
+ return 0;
611
+ }
612
+ };
613
+ }
614
+ /** @type {Uint8Array[]} */
615
+ let held = [];
616
+ let heldBytes = 0;
617
+ const release = () => {
618
+ const message = held.length === 1 ? held[0] : Buffer.concat(held);
619
+ held = [];
620
+ heldBytes = 0;
621
+ sendOne(message);
622
+ };
623
+ return {
624
+ push(bytes) {
625
+ let sent = 0;
626
+ let offset = 0;
627
+ while (offset < bytes.length) {
628
+ const take = Math.min(sizeBytes - heldBytes, bytes.length - offset);
629
+ held.push(bytes.subarray(offset, offset + take));
630
+ heldBytes += take;
631
+ offset += take;
632
+ if (heldBytes === sizeBytes) {
633
+ release();
634
+ sent += 1;
635
+ }
636
+ }
637
+ return sent;
638
+ },
639
+ flush() {
640
+ if (heldBytes === 0) return 0;
641
+ release();
642
+ return 1;
643
+ }
644
+ };
645
+ }
646
+
585
647
  export function createDataChannelHandler({
586
648
  proxyPort,
587
649
  onLog,
588
650
  getTransportSnapshot,
589
- sourceRegistry,
651
+ // Who wants pushed subtitle cues for a file, BY NAME. A plain function
652
+ // returning opaque ids: this layer never learns what a viewer is, and the
653
+ // subscription it used to hold against a channel — and had to sniff
654
+ // `/api/subtitles` to build — now belongs to the person and outlives the
655
+ // channel, a reconnect and a deliberate rotation alike.
656
+ viewersWantingCues = () => [],
590
657
  witness,
591
658
  // Reads usrsctp's own association state (services/usrsctp-state.js) the
592
659
  // moment a wedge is declared, from either detector below. Optional: a host
@@ -599,7 +666,14 @@ export function createDataChannelHandler({
599
666
  // released a viewer when their connection closed: the only exits were the
600
667
  // browser's own `release` and a silence long enough to be called an absence.
601
668
  onViewerPresent = () => {},
602
- onViewerGone = () => {}
669
+ onViewerGone = () => {},
670
+ // The size of one data-channel message carrying body bytes. Zero keeps
671
+ // whatever the body read hands over, which in the field is 65 382 bytes —
672
+ // 10 657 210 over 163 chunks, measured 2026-09-12. It is settable because
673
+ // that day's wedge arrived in the MIDDLE of one message (63 % of a segment
674
+ // on the wire, then nothing), and whether the fault depends on the size is
675
+ // the one branch no capture has been able to close.
676
+ sendChunkBytes = 0
603
677
  }) {
604
678
  /**
605
679
  * Who is on each connection, by WebRTC session id.
@@ -639,40 +713,30 @@ export function createDataChannelHandler({
639
713
  }
640
714
  }
641
715
  /**
642
- * Channels currently interested in one file's subtitle cues, keyed by
643
- * `sourceKey:fileIndex`. Populated the moment a browser asks for an
644
- * embedded track — there is no separate subscribe message on the wire, the
645
- * existing `/api/subtitles` request already says which file a viewer opened
646
- * subtitles for. Pruned on channel close and, defensively, on a failed send.
716
+ * The channel each viewer is reachable on right now, by the name the far end
717
+ * presented when it opened.
647
718
  *
648
- * @type {Map<string, Set<DataChannel>>}
719
+ * The transport knows a NAME and nothing else about a viewer: what it means,
720
+ * who holds it and what they are watching are somebody else's facts. Keeping
721
+ * only "this name is reachable here" is what lets a subscription made once
722
+ * outlive the channel it was made on — a reconnect, and a rotation done on
723
+ * purpose, both just rewrite this entry.
724
+ *
725
+ * @type {Map<string, DataChannel>}
649
726
  */
650
- const subtitleSubscribers = new Map();
727
+ const channelOfViewer = new Map();
651
728
 
652
729
  /**
653
- * @param {string} sourceKey
654
- * @param {number} fileIndex
730
+ * Forget every name that was reachable on this channel.
731
+ *
655
732
  * @param {DataChannel} channel
656
733
  * @returns {void}
657
734
  */
658
- function subscribeSubtitles(sourceKey, fileIndex, channel) {
659
- const key = `${sourceKey}:${fileIndex}`;
660
- let set = subtitleSubscribers.get(key);
661
- if (!set) {
662
- set = new Set();
663
- subtitleSubscribers.set(key, set);
664
- }
665
- const isNew = !set.has(channel);
666
- set.add(channel);
667
- if (isNew) {
668
- log(`[dc] subtitle push: channel subscribed to ${key} (${set.size} channel(s) now)`);
669
- }
670
- }
671
-
672
- /** @param {DataChannel} channel */
673
- function unsubscribeSubtitlesAll(channel) {
674
- for (const set of subtitleSubscribers.values()) {
675
- set.delete(channel);
735
+ function forgetChannel(channel) {
736
+ for (const [consumerId, on] of channelOfViewer) {
737
+ if (on === channel) {
738
+ channelOfViewer.delete(consumerId);
739
+ }
676
740
  }
677
741
  }
678
742
 
@@ -688,30 +752,37 @@ export function createDataChannelHandler({
688
752
  * @returns {void}
689
753
  */
690
754
  function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, language, detectedLanguage, cursor }) {
691
- const set = subtitleSubscribers.get(`${sourceKey}:${fileIndex}`);
692
- if (!set || set.size === 0) {
755
+ const names = viewersWantingCues(sourceKey, fileIndex);
756
+ if (names.length === 0) {
693
757
  log(
694
758
  `[dc] subtitle push: ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
695
- "found no subscribed channel"
759
+ "found nobody who wants them"
696
760
  );
697
761
  return;
698
762
  }
699
- const message = { type: "subtitle-cues", fileIndex, trackIndex, cues, language, detectedLanguage, cursor };
700
- const total = set.size;
763
+ const message = JSON.stringify({
764
+ type: "subtitle-cues", fileIndex, trackIndex, cues, language, detectedLanguage, cursor
765
+ });
701
766
  let sent = 0;
702
- for (const channel of set) {
767
+ for (const consumerId of names) {
768
+ const channel = channelOfViewer.get(consumerId);
769
+ if (!channel) {
770
+ // Subscribed but not reachable this instant — between connections, or
771
+ // rotating. Nothing is dropped: they are still subscribed, and the walk
772
+ // keeps a per-viewer cursor, so what they missed comes on the next ask.
773
+ continue;
774
+ }
703
775
  try {
704
- channel.sendMessage(JSON.stringify(message));
776
+ channel.sendMessage(message);
705
777
  sent += 1;
706
778
  } catch {
707
- // Closed between the subscription and this send; onClosed will not
708
- // fire for a channel that is already gone, so drop it here too.
709
- set.delete(channel);
779
+ // Closed between the lookup and the send. The subscription belongs to
780
+ // the person and stays; only this delivery is lost.
710
781
  }
711
782
  }
712
783
  log(
713
784
  `[dc] subtitle push: sent ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
714
- `to ${sent}/${total} channel(s)`
785
+ `to ${sent}/${names.length} viewer(s)`
715
786
  );
716
787
  }
717
788
 
@@ -914,6 +985,7 @@ export function createDataChannelHandler({
914
985
  const isNew = !known.has(message.consumerId);
915
986
  known.add(message.consumerId);
916
987
  viewersOnConnection.set(sessionId, known);
988
+ channelOfViewer.set(message.consumerId, channel);
917
989
  if (isNew) {
918
990
  log(`[dc] Session ${tag}: viewer ${message.consumerId} is on this connection`);
919
991
  }
@@ -952,7 +1024,7 @@ export function createDataChannelHandler({
952
1024
  clearTimeout(entry.timer);
953
1025
  }
954
1026
  partials.clear();
955
- unsubscribeSubtitlesAll(channel);
1027
+ forgetChannel(channel);
956
1028
  log(`[dc] Session ${tag}: channel closed`);
957
1029
  // The ordinary way a viewer leaves, and the only one that is immediate.
958
1030
  // Everything else — a silence long enough to be called an absence, the
@@ -988,45 +1060,6 @@ export function createDataChannelHandler({
988
1060
  return;
989
1061
  }
990
1062
 
991
- // Piggy-backs on the browser's own request for an EMBEDDED track — no
992
- // separate subscribe message. `trackIndex` is what tells the two request
993
- // shapes apart: an external subtitle FILE (no trackIndex) names a
994
- // different file's own index in `fileIndex` — the subtitle file's, not the
995
- // video's — and subscribing under that would just be a key nothing ever
996
- // publishes to (an external file is one whole-file read, not something
997
- // this walks incrementally). `fileIndex` alone would also scope this to
998
- // the wrong grain for the real case — a torrent can carry several playable
999
- // files — so the pair is what a push is ever addressed to.
1000
- //
1001
- // The browser's `sourceKey` is a REGISTRY key — a hash of the raw request
1002
- // bytes, one per (magnet-or-.torrent, this API session). The torrent pool
1003
- // publishes under its OWN key — the content's infohash, deliberately the
1004
- // SAME for a magnet and a `.torrent` naming the same film, so the two
1005
- // share one swarm (item 10). The two are different strings for the same
1006
- // torrent whenever a source was added by its `.torrent` file (a `.torrent`
1007
- // and a magnet are different request bytes, same infohash) — subscribing
1008
- // under the registry key found no publisher for that reason, not because
1009
- // nothing was ever read: field case 2026-08-22, cues were found and
1010
- // logged, every push answered "found no subscribed channel". Resolved to
1011
- // the pool's key here, the one place both are in hand.
1012
- if (path === "/api/subtitles" && typeof query === "string") {
1013
- const params = new URLSearchParams(query);
1014
- const registrySourceKey = params.get("sourceKey");
1015
- const fileIndex = Number(params.get("fileIndex"));
1016
- const hasTrackIndex = params.get("trackIndex") !== null && params.get("trackIndex") !== "";
1017
- if (registrySourceKey && Number.isInteger(fileIndex) && hasTrackIndex) {
1018
- const record = sourceRegistry?.get(registrySourceKey);
1019
- if (record) {
1020
- try {
1021
- const poolSourceKey = await deriveSourceKey(record.sourceType, record.source);
1022
- subscribeSubtitles(poolSourceKey, fileIndex, channel);
1023
- } catch (error) {
1024
- log(`[dc] subtitle push: could not resolve ${registrySourceKey.slice(0, 8)} to a pool key: ` +
1025
- `${error instanceof Error ? error.message : error}`);
1026
- }
1027
- }
1028
- }
1029
- }
1030
1063
 
1031
1064
  const queryInfo = query ? `?${query}` : "";
1032
1065
  const bodyInfo =
@@ -1092,12 +1125,14 @@ export function createDataChannelHandler({
1092
1125
  let readMs = 0;
1093
1126
  let sendMs2 = 0;
1094
1127
  let drainMs = 0;
1128
+ const body = bodySender((bytes) => sendChunk(channel, requestId, bytes, false), sendChunkBytes);
1095
1129
  resetEventLoopDelay();
1096
1130
  while (true) {
1097
1131
  const readStartedAt = performance.now();
1098
1132
  const { done, value } = await reader.read();
1099
1133
  readMs += performance.now() - readStartedAt;
1100
1134
  if (done) {
1135
+ chunks += body.flush();
1101
1136
  sendChunk(channel, requestId, null, true);
1102
1137
  const elapsedMs = Date.now() - sendStartedAt;
1103
1138
  let bufferedNow = 0;
@@ -1107,6 +1142,9 @@ export function createDataChannelHandler({
1107
1142
  log(
1108
1143
  `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
1109
1144
  `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
1145
+ // The size of one message, so a reading can be attributed to the
1146
+ // size it was taken at rather than to the run it came from.
1147
+ `msgBytes=${sendChunkBytes > 0 ? sendChunkBytes : "asread"} ` +
1110
1148
  `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
1111
1149
  // Where the time went: reading the body from the local route,
1112
1150
  // handing chunks to the channel, or waiting for its queue. Plus
@@ -1120,14 +1158,13 @@ export function createDataChannelHandler({
1120
1158
  break;
1121
1159
  }
1122
1160
  if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
1123
- chunks += 1;
1124
1161
  totalBytes += value.length;
1125
1162
  try {
1126
1163
  const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
1127
1164
  if (b > maxBuffered) maxBuffered = b;
1128
1165
  } catch { /* ignore */ }
1129
1166
  const sendStepAt = performance.now();
1130
- sendChunk(channel, requestId, value, false);
1167
+ chunks += body.push(value);
1131
1168
  sendMs2 += performance.now() - sendStepAt;
1132
1169
  // Backpressure: do not keep queuing chunks once the channel's outgoing
1133
1170
  // buffer is large — wait for it to drain. Prevents the SCTP send buffer
@@ -63,7 +63,8 @@ export class EncodeOrchestrator {
63
63
 
64
64
  /**
65
65
  * How many times in a row an output's run has died because its input was not
66
- * there. Cleared by any other ending, which is the input answering.
66
+ * there. Cleared only by a run that PRODUCED something, which is the only
67
+ * proof that the input can be read — see `#noteInputAvailability`.
67
68
  *
68
69
  * @type {Map<string, number>}
69
70
  */
@@ -307,6 +308,14 @@ export class EncodeOrchestrator {
307
308
  */
308
309
  notePriorityMap(address, zones) {
309
310
  this.demand.state(address, zones);
311
+ // NOBODY IS COMING HERE, so what was remembered about this output's input
312
+ // is about nobody. Kept, those three entries would stay for the life of the
313
+ // process, and the wait they describe would greet whoever opens this output
314
+ // next — a viewer arriving is new information, and one attempt for them is
315
+ // right whatever the last one met.
316
+ if (!Array.isArray(zones) || zones.length === 0) {
317
+ this.#forgetInputState(address);
318
+ }
310
319
  }
311
320
 
312
321
  /**
@@ -408,8 +417,7 @@ export class EncodeOrchestrator {
408
417
  // AND ONLY WHILE NOTHING IS PRODUCING THERE. A run still alive on this
409
418
  // output is proof the input can be read, whatever a run beside it met, so
410
419
  // the wait must not silence an output that is working.
411
- const quietMs = this.#quietFor(address);
412
- if (quietMs > 0 && this.runsOn(address).every((run) => !run.isAlive)) {
420
+ if (this.#isQuiet(address) && this.runsOn(address).every((run) => !run.isAlive)) {
413
421
  return;
414
422
  }
415
423
  // ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
@@ -812,23 +820,45 @@ export class EncodeOrchestrator {
812
820
  }
813
821
 
814
822
  /**
815
- * How long this output still has to wait before anything is placed on it, in
816
- * milliseconds; zero when it may be planned now.
823
+ * Whether this output is still waiting before anything may be placed on it.
824
+ *
825
+ * A boolean rather than the milliseconds left: the figure had one reader and
826
+ * that reader only asked whether it was above zero, so it was a number
827
+ * computed and thrown away. How long the wait is was said when it began.
817
828
  *
818
829
  * @param {string} address
819
- * @returns {number}
830
+ * @returns {boolean}
820
831
  */
821
- #quietFor(address) {
832
+ #isQuiet(address) {
822
833
  const until = this.#quietUntil.get(address);
823
834
  if (!Number.isFinite(until)) {
824
- return 0;
835
+ return false;
825
836
  }
826
- const left = until - this.now();
827
- if (left <= 0) {
837
+ if (until - this.now() <= 0) {
828
838
  this.#quietUntil.delete(address);
829
- return 0;
839
+ return false;
840
+ }
841
+ return true;
842
+ }
843
+
844
+ /**
845
+ * Forget everything remembered about an output nobody is producing for.
846
+ *
847
+ * Three maps are keyed by address, and an output that goes away leaves an
848
+ * entry in each for the life of the process. Small, and exactly the shape of
849
+ * accumulation this layer was built to remove from the session.
850
+ *
851
+ * @param {string} address
852
+ * @returns {void}
853
+ */
854
+ #forgetInputState(address) {
855
+ this.#inputLostAttempts.delete(address);
856
+ this.#quietUntil.delete(address);
857
+ const timer = this.#quietTimers.get(address);
858
+ if (timer) {
859
+ clearTimeout(timer);
860
+ this.#quietTimers.delete(address);
830
861
  }
831
- return left;
832
862
  }
833
863
 
834
864
  /**
@@ -1067,6 +1067,9 @@ export class TorrentPool {
1067
1067
  */
1068
1068
  #wholeSources = new Set();
1069
1069
 
1070
+ /** How many claims this pool has withdrawn, over its whole life. */
1071
+ #claimsWithdrawn = 0;
1072
+
1070
1073
  /**
1071
1074
  * Say that every file of a source is held whole.
1072
1075
  *
@@ -1165,9 +1168,6 @@ export class TorrentPool {
1165
1168
  }
1166
1169
  }
1167
1170
 
1168
- /** How many claims this pool has withdrawn, over its whole life. */
1169
- #claimsWithdrawn = 0;
1170
-
1171
1171
  get claimsWithdrawn() {
1172
1172
  return this.#claimsWithdrawn;
1173
1173
  }
@@ -17,7 +17,7 @@
17
17
  import { Worker } from "node:worker_threads";
18
18
  import { Readable } from "node:stream";
19
19
  import { fileURLToPath } from "node:url";
20
- import { logger } from "../../utils/logger.js";
20
+ import { logger, writeAlreadyDecided } from "../../utils/logger.js";
21
21
  import { createCaller, createReceiveStream } from "./channel.js";
22
22
  import { Command, Event } from "./protocol.js";
23
23
 
@@ -218,7 +218,10 @@ export class TorrentWorkerClient {
218
218
  this.#fragmentReaders.delete(message.id);
219
219
  break;
220
220
  case Event.LOG:
221
- logger.info(`torrent-worker: ${message.message}`);
221
+ // Written as it is: the worker holds the same repeat rule and has
222
+ // already applied it, and deciding again here would be one decision
223
+ // taken twice on two different histories.
224
+ writeAlreadyDecided(message.level ?? "info", `torrent-worker: ${message.message}`);
222
225
  break;
223
226
  case Event.FILE_COMPLETE:
224
227
  // Recorded here so the stream route can answer from the file without
@@ -56,8 +56,12 @@ import { forwardLogsTo, logger } from "../../utils/logger.js";
56
56
  * copy of the logger that had no file, and every one of their lines was lost:
57
57
  * measured over a whole 49 938-line file, not one of them was in it.
58
58
  */
59
- forwardLogsTo((_level, message) => {
60
- parentPort.postMessage({ type: Event.LOG, message });
59
+ forwardLogsTo((level, message) => {
60
+ // THE LEVEL TRAVELS TOO. It was dropped here, so every line this thread wrote
61
+ // — a warning about a spill that failed, an error about a torrent that went
62
+ // away — arrived on the other side as information and was coloured and
63
+ // recorded as such.
64
+ parentPort.postMessage({ type: Event.LOG, level, message });
61
65
  });
62
66
 
63
67
  // Imported dynamically, and that is load-bearing: static imports are RESOLVED
@@ -658,6 +662,7 @@ startMemoryReport({
658
662
  const STORE_REPORT_INTERVAL_MS = 60_000;
659
663
 
660
664
  /** Last reported reserve, so an unchanged one stays silent. */
665
+ let lastClaimsWithdrawn = 0;
661
666
  let lastReserveBytes = 0;
662
667
 
663
668
  /** Last reported figures per store, so unchanged ones stay silent. */
@@ -695,6 +700,20 @@ setInterval(() => {
695
700
  );
696
701
  }
697
702
  }
703
+ // WHAT THE ANNOUNCEMENTS ACTUALLY DID. The stores count every piece they
704
+ // stopped being able to produce; this counts the ones where the library did
705
+ // still hold a claim and it was taken back. The GAP between the two is the
706
+ // reading: announcements far above withdrawals mean the stores are mostly
707
+ // dropping pieces that were never completed, and a withdrawal count stuck at
708
+ // zero while announcements climb means the mechanism is not reaching the
709
+ // torrent at all.
710
+ if (pool.claimsWithdrawn !== lastClaimsWithdrawn) {
711
+ lastClaimsWithdrawn = pool.claimsWithdrawn;
712
+ log(
713
+ `torrent-pool: ${pool.claimsWithdrawn} piece claim(s) withdrawn — pieces this proxy ` +
714
+ "had dropped and has now told the swarm it needs again"
715
+ );
716
+ }
698
717
  const reserveNow = machineReserveBytes();
699
718
  if (reserveNow !== reserveBefore || reserveNow !== lastReserveBytes) {
700
719
  lastReserveBytes = reserveNow;