@torrent-tv/proxy 2.90.0 → 2.92.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.
Files changed (146) hide show
  1. package/bin/cli.js +5 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/transport/tunnel-client.js +111 -20
  132. package/services/viewer/DownloadMaps.js +168 -0
  133. package/services/viewer/NativeSourceMap.js +33 -0
  134. package/services/viewer/PriorityMap.js +49 -40
  135. package/services/viewer/PriorityOrchestrator.js +42 -13
  136. package/services/viewer/SourcePreparation.js +278 -0
  137. package/services/viewer/Viewer.js +11 -0
  138. package/services/viewer/Viewers.js +109 -2
  139. package/services/viewer/playback-readiness.js +50 -63
  140. package/services/viewer/segment-demands.js +37 -0
  141. package/services/viewer/urgent-output-ready.js +26 -0
  142. package/services/encode/run-inputs.js +0 -53
  143. package/services/media/keyframe-probe.js +0 -157
  144. package/services/media/media-info-probe.js +0 -93
  145. package/services/torrent/worker/background-fill.js +0 -213
  146. package/services/torrent/worker/resume-warm.js +0 -107
@@ -16,6 +16,7 @@ import { rmSync } from "node:fs";
16
16
  import WebTorrent from "webtorrent";
17
17
  import { logger } from "../../utils/logger.js";
18
18
  import { pieceStoreOf } from "./piece-store-of.js";
19
+ import { demandProgress } from "./demand-progress.js";
19
20
  import { Urgency, urgencyName } from "./demand/index.js";
20
21
  import { demandFor, forgetTorrent, reconcileAll, hasUnmetDemand } from "./download/registry.js";
21
22
  import { withdrawClaim } from "./download/withdraw-claim.js";
@@ -27,13 +28,10 @@ import { noteTorrentDestroyed } from "./destroyed-torrents.js";
27
28
  const MAP_CLAIMANT = "priority-map";
28
29
 
29
30
  /** Files of a selected torrent requested for background completion. */
30
- const torrentFillEnabled = new WeakSet();
31
31
 
32
32
  /** Claimant prefix for conditional whole-torrent fill demand. */
33
- const TORRENT_FILL_CLAIMANT = "torrent-fill:";
34
33
 
35
34
  /** How the two ends of a file name themselves. */
36
- const EDGES_CLAIMANT = "file-edges";
37
35
 
38
36
  // The DHT's entry points. Two of the three the library ships answer nothing —
39
37
  // measured 2026-08-21 from the addon host: `router.bittorrent.com` and
@@ -81,21 +79,6 @@ const WEBTORRENT_STORE_ROOT = path.join(os.tmpdir(), "webtorrent");
81
79
  // had gone.
82
80
  const TORRENT_IDLE_TTL_MS = IDLE_KEEP_MS;
83
81
 
84
- // Bytes ahead of a read position to mark CRITICAL on each range request. In
85
- // WebTorrent, `critical` does NOT reorder the sequential piece scan — it enables
86
- // HOTSWAP (re-request a block from a faster peer when a slower one already
87
- // reserved it), so this is the near read-ahead cushion where stealing from slow
88
- // peers pays off. The actual "download the seek target first" effect comes from
89
- // deselecting the gap BEHIND the playhead (see prioritizeByteRange). Kept a
90
- // moving window (reset each call) so criticality never accumulates over the
91
- // whole file across seeks, which would make hotswap thrash.
92
- const PRIORITY_WINDOW_BYTES = 16 * 1024 * 1024;
93
-
94
- // The file's header/index region the codec probe needs (phase 1). Must match
95
- // the ranges prefetchFileEdges fetches: leading bytes + trailing bytes.
96
- const HEADER_HEAD_BYTES = 256 * 1024;
97
- const HEADER_TAIL_BYTES = 2 * 1024 * 1024;
98
-
99
82
  // Adaptive upload. Seeding to the BitTorrent swarm does not help our viewer (we
100
83
  // deliver over our own WebRTC/HTTPS channel) — it is pure uplink cost and the
101
84
  // riskiest legal act (active distribution). So the default is minimal: a
@@ -814,81 +797,6 @@ function withdrawPriorityMap(torrent, fileIndex, keepBelow = 0) {
814
797
  return withdrawn;
815
798
  }
816
799
 
817
- /**
818
- * WHERE A FILE SAYS WHAT IT IS: the piece at each of its ends.
819
- *
820
- * A container keeps its directory at one end or the other — `ftyp` and an EBML
821
- * header at the front, and for an MP4 that was not written for streaming the
822
- * `moov` at the very back. Nothing can be read of such a file until those have
823
- * arrived, and they stay wanted for as long as it is open: a player asks for
824
- * the file's shape again at every seek.
825
- *
826
- * **The size is not chosen, and it cannot be.** One byte is claimed at each
827
- * end, and the piece is what the swarm delivers — so one byte at each end IS
828
- * one piece at each end, with nothing rounded up by a number anybody picked.
829
- * The 256 KB and 2 MB the prefetch reads are its own affair; where a directory
830
- * is bigger than the piece it starts in, the read that needs it says so itself,
831
- * at the level of a reader that is stopped.
832
- *
833
- * **Stated by the FILE, not by the read that happens to want it.** A read
834
- * withdraws what it stated the moment it finishes, so until now the two ends of
835
- * an open film were held by nothing at all once the codec probe was done — and
836
- * they are exactly the pieces a seek needs and eviction is free to take.
837
- *
838
- * A function of a torrent rather than of the pool, like `leaveSwarm` beside it,
839
- * so both can be exercised without building one.
840
- *
841
- * @param {object} torrent
842
- * @param {number} fileIndex
843
- * @param {number} urgency - NEAR while somebody is waiting for them, TAIL to
844
- * keep them afterwards.
845
- * @returns {boolean} Whether anything was stated.
846
- */
847
- export function stateFileEdges(torrent, fileIndex, urgency, { lower = false } = {}) {
848
- const file = Array.isArray(torrent?.files) ? torrent.files[fileIndex] : null;
849
- const length = Number(file?.length);
850
- if (!file || !(length > 0)) {
851
- return false;
852
- }
853
- const { register } = demandFor(torrent);
854
- const ends = [
855
- { claimant: `${EDGES_CLAIMANT}:${fileIndex}:head`, byteStart: 0, byteEnd: 0 },
856
- { claimant: `${EDGES_CLAIMANT}:${fileIndex}:tail`, byteStart: length - 1, byteEnd: length - 1 }
857
- ];
858
- for (const end of ends) {
859
- // RAISED, NEVER LOWERED, unless the caller is the one putting them back
860
- // down. Two things ask for the same ends — a warm-up that nobody is waiting
861
- // for and a playback plan that somebody is — and the warm-up's sidecars are
862
- // fired off without being awaited, so it can arrive second. Taking the
863
- // plan's urgency away there would leave a person watching a loading screen
864
- // behind a film somebody else is watching.
865
- const stated = register.windows().find((window) => String(window.claimant) === end.claimant);
866
- const level = !lower && stated ? Math.min(stated.urgency, urgency) : urgency;
867
- register.state({ ...end, fileIndex, urgency: level });
868
- }
869
- return true;
870
- }
871
-
872
- /**
873
- * Give up the ends of a file nobody has any use for.
874
- *
875
- * @param {object} torrent
876
- * @param {number} fileIndex
877
- * @returns {number} How many were withdrawn.
878
- */
879
- export function withdrawFileEdges(torrent, fileIndex) {
880
- const { register } = demandFor(torrent);
881
- let withdrawn = 0;
882
- for (const end of ["head", "tail"]) {
883
- const claimant = `${EDGES_CLAIMANT}:${fileIndex}:${end}`;
884
- if (register.windows().some((window) => String(window.claimant) === claimant)) {
885
- register.withdraw(claimant);
886
- withdrawn += 1;
887
- }
888
- }
889
- return withdrawn;
890
- }
891
-
892
800
  /**
893
801
  * IS ANYTHING WANTED OF THIS TORRENT — the one question asked about a torrent's
894
802
  * life, and it is answered by what has been stated, never by counting who is
@@ -966,17 +874,6 @@ export class TorrentPool {
966
874
  */
967
875
  #lastAccess = new Map();
968
876
 
969
- /**
970
- * Last byte offset each active reader is streaming from, keyed by torrent then
971
- * fileIndex. Set by prioritizeByteRange on every /stream range request; read by
972
- * getFileStats to report how much of the window ahead of the read head is still
973
- * to download — the "amount left to resume" shown while buffering.
974
- *
975
- * @type {Map<import("webtorrent").Torrent, Map<number, number>>}
976
- */
977
- #readPositionByTorrent = new Map();
978
-
979
-
980
877
  /** When each torrent's download first fell below the stall threshold. */
981
878
  #stallSince = new Map();
982
879
  /** When each torrent's stall was last reported, so it is not repeated hotly. */
@@ -988,7 +885,6 @@ export class TorrentPool {
988
885
  *
989
886
  * @type {Map<string, Promise<void>>}
990
887
  */
991
- #edgePrefetches = new Map();
992
888
 
993
889
  /** Memory budget per torrent for resident pieces; undefined = store default. */
994
890
  #memoryBytes;
@@ -1312,77 +1208,6 @@ export class TorrentPool {
1312
1208
  return { ranges, missing };
1313
1209
  }
1314
1210
 
1315
- /**
1316
- * State what is worth fetching once nothing urgent is missing: the rest of
1317
- * each file being read, from the furthest window in THAT file to its end.
1318
- *
1319
- * Per file, and that is a fix rather than a detail. It used to take the
1320
- * furthest window across ALL files and the last piece across ALL files and
1321
- * claim everything between: with two viewers on two episodes of one release —
1322
- * the ordinary case for a season pack — that claimed every episode lying
1323
- * between them, none of which anybody had asked for.
1324
- *
1325
- * Whether it is stated at all is not decided here. It is a level of urgency
1326
- * like any other, and the swarm layer withholds it while anything urgent, on
1327
- * ANY torrent, is still missing.
1328
- *
1329
- * @param {import("webtorrent").Torrent} torrent
1330
- * @returns {void}
1331
- */
1332
- #stateBackgroundFill(torrent) {
1333
- const { register } = demandFor(torrent);
1334
- const pieceLength = Number(torrent.pieceLength);
1335
- if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
1336
- return;
1337
- }
1338
- if (torrentFillEnabled.has(torrent)) {
1339
- for (const [fileIndex, file] of (torrent.files ?? []).entries()) {
1340
- const claimant = `${TORRENT_FILL_CLAIMANT}${fileIndex}`;
1341
- const length = Number(file?.length);
1342
- if (!(length > 0) || file.done === true) {
1343
- register.withdraw(claimant);
1344
- continue;
1345
- }
1346
- register.state({
1347
- claimant,
1348
- fileIndex,
1349
- byteStart: 0,
1350
- byteEnd: length - 1,
1351
- urgency: Urgency.TAIL
1352
- });
1353
- }
1354
- }
1355
- // The files anything is stated for — which is the same list the reader
1356
- // counts used to give and is one fact rather than two.
1357
- for (const fileIndex of register.files()) {
1358
- const file = torrent.files?.[fileIndex] ?? null;
1359
- const claimant = `background-fill:${fileIndex}`;
1360
- if (!file) {
1361
- register.withdraw(claimant);
1362
- continue;
1363
- }
1364
- const wanted = register
1365
- .windows()
1366
- .filter((window) => window.fileIndex === fileIndex && window.claimant !== claimant);
1367
- const furthest = wanted.length === 0
1368
- ? -1
1369
- : Math.max(...wanted.map((window) => window.byteEnd));
1370
- const byteStart = furthest + 1;
1371
- const byteEnd = Number(file.length) - 1;
1372
- if (wanted.length === 0 || byteStart > byteEnd) {
1373
- register.withdraw(claimant);
1374
- continue;
1375
- }
1376
- register.state({
1377
- claimant,
1378
- fileIndex,
1379
- byteStart,
1380
- byteEnd,
1381
- urgency: Urgency.TAIL
1382
- });
1383
- }
1384
- }
1385
-
1386
1211
  /**
1387
1212
  * Take the priority map for one file and state what to fetch from it.
1388
1213
  *
@@ -1412,7 +1237,6 @@ export class TorrentPool {
1412
1237
  withdrawPriorityMap(torrent, fileIndex);
1413
1238
  // The ends of the file go with it. They are kept for as long as the file
1414
1239
  // is open, and this is what says it is not.
1415
- withdrawFileEdges(torrent, fileIndex);
1416
1240
  // And this may have been the last thing anybody wanted of this torrent.
1417
1241
  // Optional because these two are functions of a torrent and are exercised
1418
1242
  // as such; called on the pool, the pool also acts on what they said.
@@ -1422,14 +1246,15 @@ export class TorrentPool {
1422
1246
  const file = Array.isArray(torrent?.files) ? torrent.files[fileIndex] : null;
1423
1247
  const length = Number(file?.length);
1424
1248
  const duration = Number(durationSeconds);
1425
- if (!file || !(length > 0) || !(duration > 0) || !Array.isArray(zones)) {
1249
+ if (!file || !(length > 0) || !Array.isArray(zones)) {
1426
1250
  return;
1427
1251
  }
1428
1252
  const { register } = demandFor(torrent);
1429
1253
  // Highest number first, so the stretch a viewer reaches soonest is stated
1430
1254
  // first and the rest fall in behind it.
1431
1255
  const ordered = [...zones]
1432
- .filter((zone) => Number.isFinite(zone?.from) && Number.isFinite(zone?.to) && zone.to > zone.from)
1256
+ .filter((zone) => Number.isSafeInteger(zone?.byteStart) && Number.isSafeInteger(zone?.byteEnd) &&
1257
+ zone.byteStart >= 0 && zone.byteEnd >= zone.byteStart && zone.byteStart < length)
1433
1258
  .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
1434
1259
  /**
1435
1260
  * Which level of urgency one zone is stated at, from the map's own number
@@ -1463,23 +1288,20 @@ export class TorrentPool {
1463
1288
  // The scale is that layer's, and the numbers inside a band mean nothing
1464
1289
  // but their order.
1465
1290
  const priority = zone.priority ?? 0;
1466
- if (isBehindEverybody(priority)) {
1291
+ if (zone.behind === true || (zone.behind === undefined && isBehindEverybody(priority))) {
1467
1292
  return Urgency.BEHIND;
1468
1293
  }
1469
- if (isNobodyComingNow(priority)) {
1294
+ if (zone.deferred === true || (zone.deferred === undefined && isNobodyComingNow(priority))) {
1470
1295
  // In front of somebody who has stopped the picture, and of nobody who is
1471
1296
  // watching. Wanted, and wanted after everyone who is on their way.
1472
1297
  return Urgency.TAIL;
1473
1298
  }
1474
- return isAtAWatchingViewer(priority) ? Urgency.NEAR : Urgency.AHEAD;
1299
+ return (zone.urgent === true || (zone.urgent === undefined && isAtAWatchingViewer(priority))) ? Urgency.NEAR : Urgency.AHEAD;
1475
1300
  };
1476
1301
  ordered.forEach((zone, index) => {
1477
- // A second of film sits at that fraction of the file. Constant bitrate is
1478
- // an approximation, and it is the only one available without an index of
1479
- // the container — near enough to say which stretch matters more, which is
1480
- // all this decides.
1481
- const byteStart = Math.max(0, Math.floor((zone.from / duration) * length));
1482
- const byteEnd = Math.min(length - 1, Math.ceil((zone.to / duration) * length) - 1);
1302
+ // Only ranges declared by the container index or metadata read are wanted.
1303
+ const byteStart = zone.byteStart;
1304
+ const byteEnd = Math.min(length - 1, zone.byteEnd);
1483
1305
  if (byteEnd < byteStart) {
1484
1306
  return;
1485
1307
  }
@@ -1488,7 +1310,12 @@ export class TorrentPool {
1488
1310
  fileIndex,
1489
1311
  byteStart,
1490
1312
  byteEnd,
1491
- urgency: levelOf(zone)
1313
+ urgency: levelOf(zone),
1314
+ priority: Number.isFinite(zone.priority) ? Math.max(1, Math.min(100, zone.priority)) : 1,
1315
+ order: Number.isSafeInteger(zone.order) && zone.order >= 0 ? zone.order : 0,
1316
+ deadlineAt: Number.isFinite(zone.deadlineAt) ? zone.deadlineAt :
1317
+ Number.isFinite(zone.withinSeconds) ? Date.now() + Math.max(0, zone.withinSeconds) * 1000 : Number.POSITIVE_INFINITY,
1318
+ requestId: zone.requestId ?? `${torrent.infoHash}:${fileIndex}:map`
1492
1319
  });
1493
1320
  });
1494
1321
  // Bands the map no longer has: a viewer moved on, and what they had wanted
@@ -1507,10 +1334,10 @@ export class TorrentPool {
1507
1334
  const byLevel = new Map();
1508
1335
  for (const zone of ordered) {
1509
1336
  const level = levelOf(zone);
1510
- const seconds = Math.max(0, zone.to - zone.from);
1337
+ const byteCount = Math.max(0, Math.min(length - 1, zone.byteEnd) - zone.byteStart + 1);
1511
1338
  const held = byLevel.get(level) ?? { zones: 0, megabytes: 0 };
1512
1339
  held.zones += 1;
1513
- held.megabytes += (seconds / duration) * length / 1048576;
1340
+ held.megabytes += byteCount / 1048576;
1514
1341
  byLevel.set(level, held);
1515
1342
  }
1516
1343
  const shape = [...byLevel.entries()]
@@ -1530,23 +1357,6 @@ export class TorrentPool {
1530
1357
  }
1531
1358
  }
1532
1359
 
1533
- /**
1534
- * Enable conditional whole-torrent download for a selected source.
1535
- *
1536
- * @param {import("webtorrent").Torrent} torrent
1537
- * @returns {boolean}
1538
- */
1539
- fillTorrentAsCapacityAllows(torrent) {
1540
- if (!torrent || !Array.isArray(torrent.files) || torrent.destroyed) {
1541
- return false;
1542
- }
1543
- torrentFillEnabled.add(torrent);
1544
- this.#stateBackgroundFill(torrent);
1545
- this.followTheDemand(torrent);
1546
- reconcileAll();
1547
- return true;
1548
- }
1549
-
1550
1360
  #reportStalledDownloads() {
1551
1361
  const now = Date.now();
1552
1362
  for (const torrent of this.torrents.values()) {
@@ -1600,12 +1410,6 @@ export class TorrentPool {
1600
1410
  if (!isWanted(torrent) || torrent?.done === true) {
1601
1411
  continue;
1602
1412
  }
1603
- this.#stateBackgroundFill(torrent);
1604
- // And act on what that just said. The fill withdraws itself once nothing
1605
- // else wants the file, so a torrent everybody has left can be held by the
1606
- // fill's own claim until this pass — and without this line the moment it
1607
- // goes would be noticed by nobody, leaving the torrent in its swarm with
1608
- // no idle clock running.
1609
1413
  this.followTheDemand(torrent);
1610
1414
  }
1611
1415
  this.#reportStalledDownloads();
@@ -1660,6 +1464,16 @@ export class TorrentPool {
1660
1464
  const addedAt = Date.now();
1661
1465
  this.#swarmTimingByTorrent.set(torrent, { addedAt, firstPeerAt: null });
1662
1466
 
1467
+ torrent.on("download-requested", ({ piece, deadlineAt, requestId, peer, queued }) => {
1468
+ logger.info(`download request=${requestId} piece=${piece} deadline=${deadlineAt} peer=${peer} outstanding=${queued}`);
1469
+ });
1470
+ torrent.on("download-request-cancelled", ({ piece, offset, length, reason, requestId }) => {
1471
+ logger.info(`download request=${requestId ?? `${torrent.infoHash ?? "unknown"}:${piece}:${offset}`} cancel bytes=${length} reason=${reason}`);
1472
+ });
1473
+ torrent.on("download-request-cancel-failed", ({ piece, offset, length, error }) => {
1474
+ logger.warn(`download request=${torrent.infoHash ?? "unknown"}:${piece}:${offset} cancel bytes=${length} failed: ${error}`);
1475
+ });
1476
+
1663
1477
  // The first connected peer, said once, because the wait for it can BE the
1664
1478
  // whole cold start and nothing else measures it.
1665
1479
  torrent.on("wire", () => {
@@ -2125,7 +1939,6 @@ export class TorrentPool {
2125
1939
  #forgetBookkeeping(torrent) {
2126
1940
  this.#cancelIdleRemoval(torrent);
2127
1941
  this.#lastAccess.delete(torrent);
2128
- this.#readPositionByTorrent.delete(torrent);
2129
1942
  this.#stallSince.delete(torrent);
2130
1943
  this.#stallReportedAt.delete(torrent);
2131
1944
  }
@@ -2166,16 +1979,6 @@ export class TorrentPool {
2166
1979
  *
2167
1980
  * @param {import("webtorrent").Torrent} torrent
2168
1981
  * @param {number | null} [fileIndex] - Zero-based file index, or null for torrent-level only.
2169
- * @param {{ resumeAnchorByteStart?: number | null }} [options] - `resumeAnchorByteStart`
2170
- * pins the resume window to a FIXED byte offset instead of the live (moving)
2171
- * read position. Without it, the window is anchored to wherever the file is
2172
- * CURRENTLY being read from — which slides forward as playback/encoding
2173
- * advances, so "bytes still needed" can jump up mid-poll even though nothing
2174
- * regressed (the window just moved past already-downloaded pieces into
2175
- * fresh ones). The caller should capture the returned `resumeAnchorByteStart`
2176
- * on the FIRST poll of a buffering episode and pass it back on subsequent
2177
- * polls of that SAME episode, so "bytes needed" counts down monotonically
2178
- * against a fixed target instead of chasing a moving one.
2179
1982
  * @returns {{
2180
1983
  * numPeers: number,
2181
1984
  * downloadSpeed: number,
@@ -2193,7 +1996,7 @@ export class TorrentPool {
2193
1996
  * secondsWaitingForFirstPeer: number | null
2194
1997
  * }}
2195
1998
  */
2196
- getFileStats(torrent, fileIndex = null, options = {}) {
1999
+ getFileStats(torrent, fileIndex = null) {
2197
2000
  const numPeers = typeof torrent?.numPeers === "number" ? torrent.numPeers : 0;
2198
2001
  const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
2199
2002
  const uploadSpeed = typeof torrent?.uploadSpeed === "number" ? torrent.uploadSpeed : 0;
@@ -2237,19 +2040,10 @@ export class TorrentPool {
2237
2040
  return { ...base, fileProgress: null, fileDownloaded: null, fileLength: null };
2238
2041
  }
2239
2042
 
2240
- const header = this.#getHeaderRangeProgress(torrent, file);
2241
-
2242
- // Bytes still to download in the window ahead of the anchor point — "how
2243
- // much left to resume". Null until a read position is known. The anchor is
2244
- // the caller-supplied FROZEN offset when given (see JSDoc above), otherwise
2245
- // the live (moving) read position tracked from /stream range requests.
2246
- const readPositions = this.#readPositionByTorrent.get(torrent);
2247
- const liveReadByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
2248
- const requestedAnchor = options?.resumeAnchorByteStart;
2249
- const readByteStart = Number.isFinite(requestedAnchor) ? requestedAnchor : liveReadByteStart;
2250
- const resume = typeof readByteStart === "number"
2251
- ? this.#getResumeWindowProgress(torrent, file, readByteStart)
2252
- : null;
2043
+ // Current map demand is the only source of preparation progress.
2044
+ const resume = demandProgress({ windows: demandFor(torrent).register.windows()
2045
+ .filter(window => window.fileIndex === fileIndex && window.urgency <= Urgency.NEAR),
2046
+ file, pieceLength: torrent.pieceLength, locationOf: pieceStoreOf(torrent)?.locationOf?.bind(pieceStoreOf(torrent)) });
2253
2047
 
2254
2048
  // Null-safe downloaded/progress (webtorrent's own getters throw on a
2255
2049
  // deselected null piece — see fileDownloadedBytes).
@@ -2273,373 +2067,18 @@ export class TorrentPool {
2273
2067
  fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
2274
2068
  fileDownloaded,
2275
2069
  fileLength,
2070
+ fileOffset: file.offset,
2276
2071
  residence,
2277
- // Resume window (ahead of the anchor): bytes needed vs downloaded, plus
2278
- // the anchor itself so the caller can pin it for the rest of one episode.
2072
+ fileAvailable: fileLength > 0 && residence.length > 0 && residence.every(range => range.location !== "missing"),
2073
+ // Compatibility fields report the same current urgent map demand.
2279
2074
  resumeNeededBytes: resume ? resume.totalBytes : null,
2280
2075
  resumeDownloadedBytes: resume ? resume.downloadedBytes : null,
2281
- resumeAnchorByteStart: typeof readByteStart === "number" ? readByteStart : null,
2282
- // Phase-1 progress: how much of the header/index region (the bytes the
2283
- // codec probe needs before transcoding can start) is downloaded. Counted
2284
- // by whole pieces from the torrent bitfield, so it advances coarsely
2285
- // (piece granularity). Null when the bitfield/piece info is unavailable.
2286
- headerBytes: header ? header.totalBytes : null,
2287
- headerDownloadedBytes: header ? header.downloadedBytes : null
2076
+ resumeAnchorByteStart: null,
2077
+ headerBytes: resume ? resume.totalBytes : null,
2078
+ headerDownloadedBytes: resume ? resume.downloadedBytes : null
2288
2079
  };
2289
2080
  }
2290
2081
 
2291
- /**
2292
- * Count, by whole torrent pieces, how many bytes of a file's header/index
2293
- * region (leading {@link HEADER_HEAD_BYTES} + trailing {@link HEADER_TAIL_BYTES})
2294
- * are downloaded. Used to show progress toward the codec-probe phase.
2295
- *
2296
- * @param {import("webtorrent").Torrent} torrent
2297
- * @param {import("webtorrent").TorrentFile} file
2298
- * @returns {{ totalBytes: number, downloadedBytes: number } | null}
2299
- */
2300
- #getHeaderRangeProgress(torrent, file) {
2301
- const pieceLength = Number(torrent?.pieceLength);
2302
- const bitfield = torrent?.bitfield;
2303
- const fileLength = Number(file?.length);
2304
- if (
2305
- !Number.isFinite(pieceLength) || pieceLength <= 0 ||
2306
- !bitfield || typeof bitfield.get !== "function" ||
2307
- !Number.isFinite(fileLength) || fileLength <= 0
2308
- ) {
2309
- return null;
2310
- }
2311
- const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
2312
- const headEnd = Math.min(HEADER_HEAD_BYTES, fileLength) - 1;
2313
- const ranges = [[0, headEnd]];
2314
- const tailStart = Math.max(headEnd + 1, fileLength - HEADER_TAIL_BYTES);
2315
- if (tailStart <= fileLength - 1) {
2316
- ranges.push([tailStart, fileLength - 1]);
2317
- }
2318
- const pieces = new Set();
2319
- for (const [start, end] of ranges) {
2320
- const first = Math.floor((fileOffset + start) / pieceLength);
2321
- const last = Math.floor((fileOffset + end) / pieceLength);
2322
- for (let piece = first; piece <= last; piece += 1) {
2323
- pieces.add(piece);
2324
- }
2325
- }
2326
- let totalBytes = 0;
2327
- let downloadedBytes = 0;
2328
- for (const piece of pieces) {
2329
- totalBytes += pieceLength;
2330
- if (bitfield.get(piece)) {
2331
- downloadedBytes += pieceLength;
2332
- }
2333
- }
2334
- return { totalBytes, downloadedBytes };
2335
- }
2336
-
2337
- /**
2338
- * Count, by whole torrent pieces, how many bytes of the window AHEAD of the
2339
- * current read position are downloaded — i.e. how much is still to download
2340
- * before playback can continue from that point. Mirrors
2341
- * {@link #getHeaderRangeProgress}, for the moving read head.
2342
- *
2343
- * @param {import("webtorrent").Torrent} torrent
2344
- * @param {import("webtorrent").TorrentFile} file
2345
- * @param {number} readByteStart - Byte offset within the file the reader is at.
2346
- * @returns {{ totalBytes: number, downloadedBytes: number } | null}
2347
- */
2348
- #getResumeWindowProgress(torrent, file, readByteStart) {
2349
- const pieceLength = Number(torrent?.pieceLength);
2350
- const bitfield = torrent?.bitfield;
2351
- const fileLength = Number(file?.length);
2352
- if (
2353
- !Number.isFinite(pieceLength) || pieceLength <= 0 ||
2354
- !bitfield || typeof bitfield.get !== "function" ||
2355
- !Number.isFinite(fileLength) || fileLength <= 0
2356
- ) {
2357
- return null;
2358
- }
2359
- const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
2360
- const windowStart = Math.max(0, Math.min(Number(readByteStart) || 0, fileLength - 1));
2361
- const windowEnd = Math.min(fileLength - 1, windowStart + PRIORITY_WINDOW_BYTES - 1);
2362
- const firstPiece = Math.floor((fileOffset + windowStart) / pieceLength);
2363
- const lastPiece = Math.floor((fileOffset + windowEnd) / pieceLength);
2364
- // Byte-accurate: count the PARTIAL progress of in-progress pieces (not whole
2365
- // pieces), so "amount left" moves smoothly instead of jumping by a whole
2366
- // piece (8 MB here) at a time.
2367
- let totalBytes = 0;
2368
- let downloadedBytes = 0;
2369
- for (let piece = firstPiece; piece <= lastPiece; piece += 1) {
2370
- totalBytes += pieceLength;
2371
- downloadedBytes += pieceDownloadedBytes(torrent, piece);
2372
- }
2373
- return { totalBytes, downloadedBytes };
2374
- }
2375
-
2376
- /**
2377
- * Pre-fetch the leading and trailing bytes of a torrent file so that
2378
- * WebTorrent prioritises the pieces that contain file headers and footers.
2379
- *
2380
- * For MP4 files the MOOV atom is often placed at the very end of the file
2381
- * (non-faststart encoding). Fetching the tail ensures that ffprobe can
2382
- * identify codecs and duration even for freshly-added torrents without
2383
- * waiting for the rest of the content to download.
2384
- *
2385
- * Resolves once both regions have been fully downloaded, or when the
2386
- * timeout elapses — whichever comes first. Never rejects.
2387
- *
2388
- * @param {import("webtorrent").Torrent} torrent
2389
- * @param {number} fileIndex - Zero-based index into `torrent.files`.
2390
- * @param {object} [options]
2391
- * @param {number} [options.headBytes=262144] - Leading bytes to fetch (default 256 KB).
2392
- * @param {number} [options.tailBytes=2097152] - Trailing bytes to fetch (default 2 MB).
2393
- * @param {number} [options.timeoutMs=300000] - Maximum wait time in milliseconds (default 5 min).
2394
- * @param {boolean} [options.awaited=false] - Whether somebody is waiting for
2395
- * these bytes right now. The playback plan is: it cannot answer until the
2396
- * file has said what is in it, and a person is watching a loading screen
2397
- * meanwhile. The warm-up is NOT, by its whole purpose — it happens while
2398
- * the viewer is still choosing, so it must not outrank another film that
2399
- * somebody is watching on this proxy this minute.
2400
- * @returns {Promise<void>}
2401
- */
2402
- async prefetchFileEdges(
2403
- torrent,
2404
- fileIndex,
2405
- { headBytes = 256 * 1024, tailBytes = 2 * 1024 * 1024, timeoutMs = 300_000, awaited = false } = {}
2406
- ) {
2407
- if (!torrent || !Array.isArray(torrent.files)) {
2408
- return;
2409
- }
2410
- // THE ENDS OF THIS FILE ARE WANTED, and they go on being wanted after this
2411
- // read is over: the file's own directory lives there, and a seek asks for
2412
- // it again. Stated before the read rather than by it, and kept afterwards
2413
- // at the level of something nobody is waiting for.
2414
- stateFileEdges(torrent, fileIndex, awaited ? Urgency.NEAR : Urgency.TAIL);
2415
- // The first thing ever stated about a torrent being opened, and therefore
2416
- // what keeps it in its swarm through the seconds when nothing else can say
2417
- // anything about it.
2418
- this.followTheDemand(torrent);
2419
- // Two callers can ask for the same edges at once: the warm-up that starts
2420
- // when a torrent is picked, and the playback plan a moment later. Reading
2421
- // the same two pieces twice costs nothing in bandwidth — the torrent
2422
- // fetches each piece once — but it does open a second pair of readers, each
2423
- // claiming a window and holding pieces. One is enough.
2424
- const inFlightKey = `${torrent.infoHash}:${fileIndex}`;
2425
- const running = this.#edgePrefetches.get(inFlightKey);
2426
- if (running) {
2427
- return running;
2428
- }
2429
- const prefetch = this.#prefetchFileEdgesOnce(torrent, fileIndex, { headBytes, tailBytes, timeoutMs });
2430
- this.#edgePrefetches.set(inFlightKey, prefetch);
2431
- try {
2432
- return await prefetch;
2433
- } finally {
2434
- this.#edgePrefetches.delete(inFlightKey);
2435
- // Nobody is waiting for them now. Said with `lower`, because this is the
2436
- // one caller entitled to put them back down: the read it belongs to is
2437
- // over, and there is no other — a file's edges are fetched once at a time.
2438
- stateFileEdges(torrent, fileIndex, Urgency.TAIL, { lower: true });
2439
- }
2440
- }
2441
-
2442
- /**
2443
- * Fetch a bounded region in the MIDDLE of a file.
2444
- *
2445
- * The warm-up fetches a file's two edges because the codec probe reads them.
2446
- * A viewer resuming a film needs neither: they need the region under their own
2447
- * position, and until now nothing asked for it before the encoder did. Field
2448
- * 2026-09-03 — a retry after a crash reached the encoder 53 s after the button
2449
- * was pressed, and only THEN was the piece under the viewer's position first
2450
- * requested; it took another 46 s, and the browser gave up 0.4 s before it
2451
- * landed.
2452
- *
2453
- * Read as an ordinary bounded read, never as a selection: claiming a whole
2454
- * region alongside the readers' own windows is the mistake `#syncSelections`
2455
- * was written against.
2456
- *
2457
- * @param {import("webtorrent").Torrent} torrent
2458
- * @param {number} fileIndex
2459
- * @param {number} startByte
2460
- * @param {number} bytes
2461
- * @param {{ timeoutMs?: number }} [options]
2462
- * @returns {Promise<void>}
2463
- */
2464
- async prefetchFileRegion(torrent, fileIndex, startByte, bytes, { timeoutMs = 300_000 } = {}) {
2465
- const file = torrent?.files?.[fileIndex];
2466
- if (!file || typeof file.createReadStream !== "function") {
2467
- return;
2468
- }
2469
- const fileSize = file.length;
2470
- if (!Number.isFinite(fileSize) || fileSize <= 0 || !(bytes > 0)) {
2471
- return;
2472
- }
2473
- const start = Math.max(0, Math.min(Math.floor(startByte), fileSize - 1));
2474
- const end = Math.min(fileSize - 1, start + Math.floor(bytes) - 1);
2475
- if (end <= start) {
2476
- return;
2477
- }
2478
- const drained = new Promise((resolve) => {
2479
- const stream = file.createReadStream({ start, end });
2480
- stream.on("data", () => undefined);
2481
- stream.once("end", resolve);
2482
- stream.once("error", resolve);
2483
- stream.once("close", resolve);
2484
- });
2485
- await Promise.race([drained, new Promise((resolve) => setTimeout(resolve, timeoutMs))]);
2486
- }
2487
-
2488
- /**
2489
- * The body of {@link prefetchFileEdges}, without the de-duplication.
2490
- *
2491
- * @param {import("webtorrent").Torrent} torrent
2492
- * @param {number} fileIndex
2493
- * @param {{ headBytes: number, tailBytes: number, timeoutMs: number }} options
2494
- * @returns {Promise<void>}
2495
- */
2496
- async #prefetchFileEdgesOnce(torrent, fileIndex, { headBytes, tailBytes, timeoutMs }) {
2497
- if (!torrent || !Array.isArray(torrent.files)) {
2498
- return;
2499
- }
2500
- const file = torrent.files[fileIndex];
2501
- if (!file || typeof file.createReadStream !== "function") {
2502
- return;
2503
- }
2504
- const fileSize = file.length;
2505
- if (!Number.isFinite(fileSize) || fileSize <= 0) {
2506
- return;
2507
- }
2508
-
2509
- const safeHeadEnd = Math.min(headBytes, fileSize) - 1;
2510
- const safeTailStart = Math.max(0, fileSize - tailBytes);
2511
-
2512
- /** Drain a readable stream, resolving on end/error/close. */
2513
- const drainStream = (stream) =>
2514
- new Promise((resolve) => {
2515
- stream.on("data", () => undefined);
2516
- stream.once("end", resolve);
2517
- stream.once("error", resolve);
2518
- stream.once("close", resolve);
2519
- });
2520
-
2521
- try {
2522
- const tasks = [
2523
- // Head: FTYP/MOOV (faststart MP4), EBML header (MKV), etc.
2524
- drainStream(file.createReadStream({ start: 0, end: safeHeadEnd }))
2525
- ];
2526
-
2527
- // Tail: MOOV atom for non-faststart MP4. Skip when it overlaps the head.
2528
- if (safeTailStart > safeHeadEnd + 1) {
2529
- tasks.push(drainStream(file.createReadStream({ start: safeTailStart, end: fileSize - 1 })));
2530
- }
2531
-
2532
- await Promise.race([
2533
- Promise.all(tasks),
2534
- new Promise((resolve) => setTimeout(resolve, timeoutMs))
2535
- ]);
2536
- } catch (_error) {
2537
- // Best-effort — a prefetch failure must never prevent playback.
2538
- }
2539
- }
2540
-
2541
-
2542
-
2543
- /**
2544
- * Record where a file is being read from.
2545
- *
2546
- * This used to also decide what the torrent should download, and that was the
2547
- * mistake: it was one of THREE places claiming pieces for the same file — the
2548
- * whole-file selection, a window around the read head, and the reader itself
2549
- * — and they overwrote each other on every request. Every need is now stated
2550
- * in one register (`services/demand/`) and one class turns the register into
2551
- * requests to the swarm (`services/torrent/download/SwarmSelection.js`); several
2552
- * readers on one file therefore produce the union of their windows instead of
2553
- * the last caller's opinion.
2554
- *
2555
- * What is left here is bookkeeping the readers cannot do: `getFileStats`
2556
- * reports how much of the window ahead of the read head is still missing, so
2557
- * the viewer can be shown how long a resume will take, and a jump in the read
2558
- * position is logged because a seek that never reaches the torrent is
2559
- * invisible otherwise.
2560
- *
2561
- * @param {import("webtorrent").Torrent} torrent
2562
- * @param {number} fileIndex
2563
- * @param {number} byteStart - Start offset within the file.
2564
- * @param {number} [windowBytes] - Unused; kept so callers need not change.
2565
- * @param {{ wholeFileRead?: boolean, isPlaybackRead?: boolean }} [options] -
2566
- * `wholeFileRead` marks a request that carried no byte range, i.e. one that
2567
- * merely opens the file at 0 rather than asking to read from there.
2568
- * `isPlaybackRead` marks the encoder's input read, the only one that
2569
- * follows the viewer. See the guards below.
2570
- * @returns {void}
2571
- */
2572
- prioritizeByteRange(
2573
- torrent,
2574
- fileIndex,
2575
- byteStart,
2576
- windowBytes = PRIORITY_WINDOW_BYTES,
2577
- options = {}
2578
- ) {
2579
- if (!torrent || !Array.isArray(torrent.files)) {
2580
- return;
2581
- }
2582
- const file = torrent.files[fileIndex];
2583
- if (!file) {
2584
- return;
2585
- }
2586
- const fileLength = Number(file.length);
2587
- if (!Number.isFinite(fileLength) || fileLength <= 0) {
2588
- return;
2589
- }
2590
-
2591
- const safeStart = Math.max(0, Number(byteStart) || 0);
2592
-
2593
- // Remember where this file is being read from, so getFileStats can report the
2594
- // download progress of the window ahead of the read head (resume amount).
2595
- let readPositions = this.#readPositionByTorrent.get(torrent);
2596
- if (!readPositions) {
2597
- readPositions = new Map();
2598
- this.#readPositionByTorrent.set(torrent, readPositions);
2599
- }
2600
- const previousStart = readPositions.get(fileIndex);
2601
-
2602
- // A request with no byte range says nothing about where the viewer is. ffmpeg
2603
- // opens its input with a plain GET and abandons it the moment it seeks, and
2604
- // the keyframe index and the codec probe do the same — four such reads around
2605
- // every encoder restart, each one arriving here as "position 0". Acting on
2606
- // them undoes the seek that just happened: the whole file is re-selected from
2607
- // piece 0, the picker skips the pieces already on disk and walks the swarm
2608
- // forward from the first hole. Measured on a 4.7 GB film: a seek to 89.1%
2609
- // downloaded 2.47 GB over 93 s before the segment could be served. So a
2610
- // whole-file read only sets the position when nothing else has.
2611
- if (options.wholeFileRead && previousStart !== undefined) {
2612
- return;
2613
- }
2614
-
2615
- readPositions.set(fileIndex, safeStart);
2616
-
2617
- // Log jumps only. Sequential reading calls this on every range request and
2618
- // would drown the log; a jump is a seek, and a seek that never reaches the
2619
- // torrent is exactly the failure this line exists to make visible — after a
2620
- // seek the encoder waits on pieces nobody has been told to fetch.
2621
- const isJump =
2622
- previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
2623
- if (isJump) {
2624
- // A jump in the read that follows the viewer is a seek. Whatever the
2625
- // swarm was giving us was for somewhere else, and the pieces at the new
2626
- // position have to be earned from peers that are choking us — the same
2627
- // standing start as a fresh torrent. A jump in any OTHER read is the
2628
- // codec probe or the keyframe index visiting the ends of the file, and
2629
- // nobody is waiting on those the way a viewer waits on a seek.
2630
- if (options.isPlaybackRead) {
2631
- this.#markHurry(torrent, "the viewer moved");
2632
- }
2633
- const percent = ((safeStart / fileLength) * 100).toFixed(1);
2634
- logger.info(
2635
- `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
2636
- `${(safeStart / 1024 / 1024).toFixed(0)}MB (${percent}% of file ${fileIndex})` +
2637
- (previousStart === undefined ? " (first)" : ` (was ${(previousStart / 1024 / 1024).toFixed(0)}MB)`)
2638
- );
2639
- }
2640
-
2641
- }
2642
-
2643
2082
  /**
2644
2083
  * Destroy every torrent together with its on-disk store, then tear down the
2645
2084
  * WebTorrent client. Called from the proxy's graceful-shutdown `onClose`