@torrent-tv/proxy 2.83.6 → 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