@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
package/bin/cli.js CHANGED
@@ -442,10 +442,11 @@ try {
442
442
  // in and at the same instant as them.
443
443
  readExtra: () => {
444
444
  const fragments = fragmentBufferCollection();
445
- return fragments.seen === 0
445
+ const pieces = fragments.seen === 0
446
446
  ? ""
447
447
  : `piece buffers handed here ${fragments.seen} seen, ${fragments.collected} collected, ` +
448
448
  `${fragments.seen - fragments.collected} still alive`;
449
+ return [pieces, `binary packet indexes ${started.mediaMemory.packetIndexBytes()} bytes`].filter(Boolean).join("; ");
449
450
  },
450
451
  // Once a minute cannot see what kills this process. Both out-of-memory
451
452
  // kills of 2026-09-02 happened inside a single gap of the old cadence: the
@@ -559,6 +560,8 @@ try {
559
560
  proxyId: clientId,
560
561
  token,
561
562
  proxyPort: actualPort,
563
+ name: clientName,
564
+ baseUrl: directBaseUrl,
562
565
  onSignal(sessionId, signal) {
563
566
  webRtcManager?.handleSignal(sessionId, signal);
564
567
  },
@@ -639,7 +642,7 @@ try {
639
642
  // reason as the transport snapshot above: the manager is built inside
640
643
  // `startProxyServer`, with this handler already in hand.
641
644
  onViewerPresent: (consumerId) => {
642
- started?.outputParts?.viewers?.seen?.(consumerId);
645
+ started?.outputParts?.viewers?.present?.(consumerId);
643
646
  },
644
647
  onViewerGone: (consumerId, because) => {
645
648
  void started?.outputParts?.lifecycle?.viewerHasGone?.(consumerId, because)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.90.0",
3
+ "version": "2.92.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -29,13 +29,14 @@
29
29
  "scripts": {
30
30
  "start": "node ./bin/cli.js",
31
31
  "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
32
- "test": "npm run lint && node --test",
32
+ "test": "npm run lint && node --require ./test/no-torrent/support/refuse-torrent.cjs --test \"test/no-torrent/**/*.test.js\"",
33
33
  "graph": "node scripts/render-run-graph.js",
34
34
  "lint": "biome lint ."
35
35
  },
36
36
  "dependencies": {
37
37
  "@fastify/cors": "^11.2.0",
38
38
  "@fastify/helmet": "^13.0.2",
39
+ "@ffprobe-installer/ffprobe": "^2.1.2",
39
40
  "@silentbot1/nat-api": "^0.4.9",
40
41
  "chalk": "^5.4.1",
41
42
  "commander": "^12.1.0",
@@ -46,6 +47,7 @@
46
47
  "node-datachannel": "^0.32.0",
47
48
  "parse-torrent": "^11.0.23",
48
49
  "uint8-util": "2.2.6",
50
+ "undici": "^7.30.0",
49
51
  "webtorrent": "2.8.5",
50
52
  "werift": "^0.24.2",
51
53
  "ws": "^8.18.2"
@@ -53,6 +55,20 @@
53
55
  "devDependencies": {
54
56
  "@biomejs/biome": "^2.5.7"
55
57
  },
58
+ "allowScripts": {
59
+ "@ffprobe-installer/darwin-arm64@5.0.1": true,
60
+ "@ffprobe-installer/darwin-x64@5.1.0": true,
61
+ "@ffprobe-installer/linux-arm@5.2.0": true,
62
+ "@ffprobe-installer/linux-arm64@5.2.0": true,
63
+ "@ffprobe-installer/linux-ia32@5.2.0": true,
64
+ "@ffprobe-installer/linux-x64@5.2.0": true,
65
+ "@torrent-tv/utp-native@2.5.3-ttv.9": true,
66
+ "bufferutil@4.1.0": true,
67
+ "ffmpeg-static@5.3.0": true,
68
+ "node-datachannel@0.32.3": true,
69
+ "utf-8-validate@6.0.6": true,
70
+ "ip-set": false
71
+ },
56
72
  "overrides": {
57
73
  "utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.9"
58
74
  }
@@ -24,23 +24,37 @@ function getPayload(body) {
24
24
  return {};
25
25
  }
26
26
 
27
- export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
27
+ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers }) {
28
28
  const payload = getPayload(req.body);
29
29
  const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
30
30
  const fileIndex = Number(payload.fileIndex);
31
31
  const userAgent = typeof payload.userAgent === "string" ? payload.userAgent : "";
32
32
 
33
- if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
33
+ if (!sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
34
34
  return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
35
35
  }
36
+ const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
37
+ const selection = {};
38
+ if (typeof payload.positionSeconds === "number" && Number.isFinite(payload.positionSeconds) && payload.positionSeconds >= 0) selection.positionSeconds = payload.positionSeconds;
39
+ if (typeof payload.wantsToPlay === "boolean") selection.wantsToPlay = payload.wantsToPlay;
40
+ if (consumerId) viewers?.selectsFile(consumerId, sourceKey, fileIndex, Date.now(), selection);
36
41
 
37
42
  // Interface delegates to PlaybackController (orchestrator + domain). Keeps route thin.
38
43
  const { PlaybackController } = await import("../../../services/server/controllers/PlaybackController.js");
39
44
  const controller = new PlaybackController({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner });
45
+ const cancellation = new AbortController();
46
+ const aborted = () => cancellation.abort();
47
+ const closed = () => { if (!reply.raw?.writableEnded) aborted(); };
48
+ req.raw?.once?.("aborted", aborted);
49
+ reply.raw?.once?.("close", closed);
50
+ if (req.raw?.aborted) aborted();
40
51
  try {
41
- const plan = await controller.getPlan({ sourceKey, fileIndex, userAgent, maxWaitMs: 8_000 });
52
+ const params = { sourceKey, fileIndex, userAgent };
53
+ const plan = payload.waitForReady === true
54
+ ? await controller.getReadyPlan(params, { signal: cancellation.signal }) : await controller.getPlan(params);
42
55
  return reply.send(plan);
43
56
  } catch (error) {
57
+ if (cancellation.signal.aborted) return;
44
58
  if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
45
59
  return reply.code(404).send({ error: error.message });
46
60
  }
@@ -48,6 +62,9 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, s
48
62
  return reply.code(404).send({ error: error.message });
49
63
  }
50
64
  const message = error instanceof Error ? error.message : String(error);
51
- return reply.code(500).send({ error: `Failed to prepare playback plan: ${message}` });
65
+ return reply.code(500).send({ error: `Failed to prepare playback plan: ${message}`, code: error?.code, canRetry: error?.canRetry !== false });
66
+ } finally {
67
+ req.raw?.removeListener?.("aborted", aborted);
68
+ reply.raw?.removeListener?.("close", closed);
52
69
  }
53
70
  }
@@ -1,4 +1,5 @@
1
1
  import { contentsOf } from "../../../../services/torrent/Contents.js";
2
+ import { logger } from "../../../../utils/logger.js";
2
3
 
3
4
  /**
4
5
  * List the files of a registered source (torrent file OR magnet).
@@ -7,13 +8,8 @@ import { contentsOf } from "../../../../services/torrent/Contents.js";
7
8
  *
8
9
  * The browser parses `.torrent` files locally, but a magnet URI carries no
9
10
  * file list — the metadata comes from the swarm and can take a while to
10
- * arrive on a cold magnet. Rather than block the request until it does (a
11
- * single long request racing the transport timeout, which surfaced as a
12
- * premature "no peers" error while the metadata was in fact still arriving),
13
- * this waits only a short per-request budget: if the metadata is not ready it
14
- * returns `{ pending: true }` while the fetch continues in the background. The
15
- * caller polls again until the file list comes back — mirroring the cold-
16
- * torrent playback-plan poll.
11
+ * arrive on a cold magnet. Metadata readiness, failure or caller cancellation
12
+ * ends the request; elapsed time does not change the answer about a torrent.
17
13
  *
18
14
  * @param {import("fastify").FastifyRequest} req
19
15
  * @param {import("fastify").FastifyReply} reply
@@ -24,10 +20,9 @@ import { contentsOf } from "../../../../services/torrent/Contents.js";
24
20
  * @returns {Promise<void>}
25
21
  */
26
22
 
27
- /** Sentinel resolved when the per-request wait elapses before metadata. */
28
- const PENDING = Symbol("pending");
23
+ const CANCELLED = Symbol("cancelled");
29
24
 
30
- export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
25
+ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers }) {
31
26
  const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
32
27
  if (!sourceKey) {
33
28
  return reply.code(400).send({ error: "sourceKey is required." });
@@ -37,20 +32,13 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
37
32
  return reply.code(404).send({ error: "Source key was not found." });
38
33
  }
39
34
 
40
- // How long to wait within THIS request before returning `pending`. Well
41
- // under the transport's request timeout so a single poll never races it.
42
- const rawWait = Number(req.query?.maxWaitMs);
43
- const maxWaitMs = Number.isFinite(rawWait) && rawWait > 0 ? Math.min(rawWait, 20_000) : 8_000;
44
-
45
- // getTorrent dedupes concurrent/repeated calls via the pool's in-flight map,
46
- // so polling keeps joining the same background fetch (metadata keeps
47
- // downloading between polls). Race it against the wait budget; if the wait
48
- // wins, the fetch is left running for the next poll to observe.
49
- let timer;
50
- const waitPromise = new Promise((resolve) => {
51
- timer = setTimeout(() => resolve(PENDING), maxWaitMs);
52
- timer.unref?.();
53
- });
35
+ let cancelled;
36
+ const waitPromise = new Promise(resolve => { cancelled = () => resolve(CANCELLED); });
37
+ reply.raw?.once?.("close", cancelled);
38
+ if (reply.raw?.destroyed) {
39
+ reply.raw?.removeListener?.("close", cancelled);
40
+ return;
41
+ }
54
42
  const torrentPromise = torrentPool
55
43
  .getTorrent(sourceRecord.sourceType, sourceRecord.source)
56
44
  // Swallow so a rejection that loses the race is not an unhandled rejection;
@@ -61,12 +49,10 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
61
49
  try {
62
50
  result = await Promise.race([torrentPromise, waitPromise]);
63
51
  } finally {
64
- clearTimeout(timer);
52
+ reply.raw?.removeListener?.("close", cancelled);
65
53
  }
66
54
 
67
- if (result === PENDING) {
68
- return reply.send({ pending: true });
69
- }
55
+ if (result === CANCELLED) return;
70
56
  if (result instanceof Error) {
71
57
  return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
72
58
  }
@@ -80,6 +66,12 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
80
66
  // only video present). It ships the paths already relative to the torrent
81
67
  // root, so there is no stripping rule on the other side either.
82
68
  const contents = contentsOf(torrent);
69
+ for (const file of contents.files()) {
70
+ if (file.excludedReason) logger.info(`contents request=${req.id ?? "unstated"} source=${sourceKey} ` +
71
+ `file=${file.fileIndex} name=${JSON.stringify(file.relativePath)} bytes=${file.length} excluded=${file.excludedReason}`);
72
+ }
73
+ const consumerId = typeof req.query?.consumerId === "string" ? req.query.consumerId.trim() : "";
74
+ if (consumerId) viewers?.visibleFiles(consumerId, sourceKey, contents.items.map(item => item.fileIndex));
83
75
  return reply.send({
84
76
  name: torrent.name ?? "",
85
77
  infoHash: torrent.infoHash ?? "",
@@ -23,7 +23,7 @@ function getPayload(body) {
23
23
  return {};
24
24
  }
25
25
 
26
- export async function handleApiSourcesPost(req, reply, { sourceRegistry }) {
26
+ export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers }) {
27
27
  const payload = getPayload(req.body);
28
28
  const sourceType = typeof payload.sourceType === "string" ? payload.sourceType : "";
29
29
  const source = typeof payload.source === "string" ? payload.source : "";
@@ -32,5 +32,7 @@ export async function handleApiSourcesPost(req, reply, { sourceRegistry }) {
32
32
  }
33
33
 
34
34
  const sourceKey = await sourceRegistry.upsert(sourceType, source);
35
- return reply.send({ sourceKey });
35
+ const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
36
+ if (consumerId) viewers?.selectsSource(consumerId, sourceKey);
37
+ return reply.send({ sourceKey, playbackMapVersion: 1 });
36
38
  }
@@ -30,32 +30,25 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
30
30
 
31
31
  let torrent;
32
32
  try {
33
- // getTorrent resolves immediately when the torrent is already loaded.
34
- torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
33
+ torrent = torrentPool.knownTorrent(sourceKey);
35
34
  } catch (error) {
36
35
  const message = error instanceof Error ? error.message : String(error);
37
36
  logger.warn(`stats: getTorrent failed for ${sourceKey.slice(0, 8)}: ${message} stack=${error instanceof Error ? error.stack?.split("\n")[1]?.trim() ?? "" : ""}`);
38
37
  return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
39
38
  }
40
39
  if (!torrent) {
41
- logger.warn(`stats: torrent missing for ${sourceKey.slice(0, 8)} (getTorrent returned null/undefined)`);
42
- return reply.code(500).send({ error: "Torrent instance not found" });
40
+ return reply.code(202).send({ pending: true });
43
41
  }
44
42
 
45
43
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
46
44
  const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
47
45
 
48
- // Optional: pin the resume window to a FIXED byte offset for the duration of
49
- // one buffering episode (see getFileStats JSDoc) instead of the live, moving
50
- // read position — otherwise "bytes needed" can jump up mid-poll as the window
51
- // slides forward with playback/encoding progress.
52
- const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
53
- const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
54
46
 
55
47
  // Awaited: with the torrent on its own thread this is a round trip, not a
56
48
  // local lookup. Without the await the reply was the pending promise itself,
57
49
  // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
58
- const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
50
+ const stats = await torrentPool.getFileStats(torrent, fileIndex);
51
+ if (stats?.pending || !stats) return reply.code(202).send({ pending: true });
59
52
 
60
53
  // Diagnostic: surface the real swarm state per poll so a cold-start download
61
54
  // stall (0 peers / header not advancing → playback-plan blocks on the codec
@@ -0,0 +1,16 @@
1
+ /** Report the position and intent of a viewer playing the source directly. */
2
+ export async function handleApiSourceViewerPost(req, reply, { sourceRegistry, viewers }) {
3
+ const { sourceKey, fileIndex: indexText } = req.params;
4
+ const fileIndex = Number(indexText);
5
+ const body = req.body;
6
+ if (!Number.isSafeInteger(fileIndex) || fileIndex < 0 || !body || typeof body !== "object" ||
7
+ typeof body.consumerId !== "string" || !body.consumerId) {
8
+ return reply.code(400).send({ error: "A file index and consumerId are required.", canRetry: false });
9
+ }
10
+ if (!sourceRegistry.get(sourceKey)) return reply.code(404).send({ error: "Source key was not found.", canRetry: false });
11
+ if (!viewers.reportSource(body.consumerId, sourceKey, fileIndex, body)) {
12
+ return reply.code(409).send({ error: "The source selection is no longer current.",
13
+ code: "REQUEST_OBSOLETE", canRetry: false });
14
+ }
15
+ return reply.send({ received: true });
16
+ }
@@ -1,217 +1,8 @@
1
- import { logger } from "../../../../utils/logger.js";
2
- import { TEXT_SUBTITLE_SIDECAR_EXTENSIONS } from "../../../../services/torrent/files.js";
3
- import { contentsOf } from "../../../../services/torrent/Contents.js";
4
-
5
- const metadataWarmups = new Map();
6
- const METADATA_RETRY_MS = 2500;
7
-
8
- function startMetadataWarmup({ sourceKey, items, preferredFileIndex, userAgent, playbackPlanner, torrentPool, torrent }) {
9
- if (!playbackPlanner || !Array.isArray(items) || items.length === 0) {
10
- return false;
11
- }
12
- let state = metadataWarmups.get(sourceKey);
13
- if (!state) {
14
- state = { pending: new Set(), preferredFileIndex: null, running: false, items: [] };
15
- metadataWarmups.set(sourceKey, state);
16
- }
17
- state.items = items;
18
- for (const item of items) {
19
- if (Number.isInteger(item?.fileIndex)) {
20
- state.pending.add(item.fileIndex);
21
- }
22
- }
23
- if (Number.isInteger(preferredFileIndex) && state.pending.has(preferredFileIndex)) {
24
- state.preferredFileIndex = preferredFileIndex;
25
- }
26
- if (state.running) {
27
- return true;
28
- }
29
- state.running = true;
30
- void (async () => {
31
- try {
32
- while (state.pending.size > 0) {
33
- const fileIndex = state.preferredFileIndex !== null && state.pending.has(state.preferredFileIndex)
34
- ? state.preferredFileIndex
35
- : state.pending.values().next().value;
36
- state.preferredFileIndex = null;
37
- if (!Number.isInteger(fileIndex)) {
38
- break;
39
- }
40
- const item = state.items.find((entry) => entry.fileIndex === fileIndex);
41
- if (!item) {
42
- state.pending.delete(fileIndex);
43
- continue;
44
- }
45
- const sidecars = contentsOf(torrent).sidecarsOf(fileIndex);
46
- const sidecarFiles = [...sidecars.audio, ...sidecars.subtitles];
47
- await Promise.all(sidecarFiles.map((file) =>
48
- torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
49
- tailBytes: 0,
50
- headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
51
- ? Math.max(1, file.length)
52
- : undefined,
53
- timeoutMs: 10_000
54
- }).catch((error) => {
55
- logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
56
- })
57
- ));
58
- const plan = await playbackPlanner.getPlan({
59
- sourceKey,
60
- fileIndex,
61
- userAgent,
62
- maxWaitMs: 0,
63
- background: true
64
- }).catch((error) => {
65
- logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: media metadata not ready: ${error?.message ?? error}`);
66
- return null;
67
- });
68
- if (plan && !plan.pending) {
69
- state.pending.delete(fileIndex);
70
- } else if (torrent.files?.[fileIndex]?.done === true) {
71
- state.pending.delete(fileIndex);
72
- }
73
- if (state.pending.size > 0) {
74
- await new Promise((resolve) => setTimeout(resolve, METADATA_RETRY_MS));
75
- }
76
- }
77
- } catch (error) {
78
- logger.warn(`warm ${sourceKey.slice(0, 8)}: metadata queue stopped: ${error?.message ?? error}`);
79
- } finally {
80
- state.running = false;
81
- if (state.pending.size === 0) {
82
- metadataWarmups.delete(sourceKey);
83
- }
84
- }
85
- })();
86
- return true;
87
- }
88
-
89
- /**
90
- * Start fetching a source before anyone asks to play it.
91
- *
92
- * POST /api/sources/:sourceKey/warm { fileIndex?: number, positionSeconds?: number, userAgent?: string }
93
- *
94
- * Everything a torrent must do before the first byte of video can be served
95
- * takes seconds and none of it depends on the viewer: announce to the
96
- * trackers, connect to peers, be unchoked by them, and fetch the two pieces at
97
- * the file's edges that the codec probe reads. Measured 2026-08-04 on a cold
98
- * 7.4 GB torrent: 6.7 s of the 10.3 s before playback was those two pieces
99
- * arriving, with the swarm ramping from nothing.
100
- *
101
- * That work used to begin only when a file had been chosen, because it was
102
- * buried inside the playback plan. It can begin as soon as the viewer has
103
- * picked a TORRENT — while they are still reading the list of episodes — and
104
- * then most or all of it has happened by the time they choose.
105
- *
106
- * Returns as soon as the work is under way. The caller is not waiting for a
107
- * result; it is only saying "you may start". Failures are logged and answered
108
- * as `started: false` rather than as an error, because nothing is broken if a
109
- * warm-up does not happen — the ordinary path still does all of it.
110
- *
111
- * @param {import("fastify").FastifyRequest} req
112
- * @param {import("fastify").FastifyReply} reply
113
- * @param {{
114
- * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
115
- * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
116
- * playbackPlanner?: ReturnType<import("../../../../services/media/playback-planner.js").createPlaybackPlanner>,
117
- * durationOf?: ({ sourceKey: string, fileIndex: number }) => Promise<number | null>
118
- * }} deps
119
- * @returns {Promise<void>}
120
- */
121
- export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool, playbackPlanner = null, durationOf = null }) {
1
+ /** Compatibility acknowledgement: source preparation is owned by present viewers. */
2
+ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, viewers }) {
122
3
  const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
123
- if (!sourceKey) {
124
- return reply.code(400).send({ error: "sourceKey is required." });
125
- }
126
-
127
- const sourceRecord = sourceRegistry.get(sourceKey);
128
- if (!sourceRecord) {
129
- return reply.code(404).send({ error: "Source key was not found." });
130
- }
131
-
132
- const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
133
- const requestedIndex = Number(body.fileIndex);
134
- const fileIndex = Number.isInteger(requestedIndex) && requestedIndex >= 0 ? requestedIndex : null;
135
- // Where the viewer is about to resume, if they are resuming. The edges below
136
- // are what the codec probe reads; this is what the VIEWER will read, and until
137
- // now nothing asked for it before the encoder did.
138
- const requestedPosition = Number(body.positionSeconds);
139
- const positionSeconds = Number.isFinite(requestedPosition) && requestedPosition > 0
140
- ? requestedPosition
141
- : 0;
142
- const userAgent = typeof body.userAgent === "string" ? body.userAgent : "";
143
-
144
- // Adding the torrent is what announces to the trackers and starts connecting
145
- // to peers, and it is also what a magnet needs in order to fetch its
146
- // metadata. It is awaited because everything else needs the torrent object,
147
- // and because until it resolves there is nothing to report.
148
- let torrent;
149
- try {
150
- torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
151
- } catch (error) {
152
- const message = error instanceof Error ? error.message : String(error);
153
- logger.warn(`warm ${sourceKey.slice(0, 8)}: could not add the torrent: ${message}`);
154
- return reply.send({ started: false, swarm: false, edges: false });
155
- }
156
-
157
- const contents = contentsOf(torrent);
158
- const named = fileIndex !== null && torrent.files?.[fileIndex] ? fileIndex : null;
159
- if (named !== null) {
160
- const selectedSidecars = contents.sidecarsOf(named);
161
- for (const file of [...selectedSidecars.audio, ...selectedSidecars.subtitles]) {
162
- void torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
163
- tailBytes: 0,
164
- headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
165
- ? Math.max(1, file.length)
166
- : undefined,
167
- timeoutMs: 10_000,
168
- awaited: true
169
- }).catch((error) => {
170
- logger.info(`warm ${sourceKey.slice(0, 8)}:${named}: selected sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
171
- });
172
- }
173
- }
174
- const orderedItems = named === null
175
- ? contents.items
176
- : [...contents.items.filter((item) => item.fileIndex === named), ...contents.items.filter((item) => item.fileIndex !== named)];
177
- const metadataStarted = startMetadataWarmup({
178
- sourceKey,
179
- items: orderedItems,
180
- preferredFileIndex: named,
181
- userAgent,
182
- playbackPlanner,
183
- torrentPool,
184
- torrent
185
- });
186
- const sidecarCount = new Set(orderedItems.flatMap((item) => {
187
- const matched = contents.sidecarsOf(item.fileIndex);
188
- return [...matched.audio, ...matched.subtitles].map((file) => file.fileIndex);
189
- })).size;
190
- const fillStarted = typeof torrentPool.fillTorrent === "function"
191
- ? await torrentPool.fillTorrent(torrent).catch((error) => {
192
- logger.warn(`warm ${sourceKey.slice(0, 8)}: whole-torrent fill could not start: ${error?.message ?? error}`);
193
- return false;
194
- })
195
- : false;
196
- if (named !== null && positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
197
- void Promise.resolve(
198
- (typeof durationOf === "function"
199
- ? durationOf({ sourceKey, fileIndex: named })
200
- : Promise.resolve(null)
201
- ).then((durationSeconds) =>
202
- torrentPool.warmResumePosition(torrent, named, positionSeconds, durationSeconds)
203
- )
204
- ).catch((error) => {
205
- logger.warn(`warm ${sourceKey.slice(0, 8)}: the viewer's position failed: ${error?.message ?? error}`);
206
- });
207
- }
208
-
209
- logger.info(
210
- `warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
211
- `, metadata for ${contents.items.length} video file(s) ${metadataStarted ? "queued" : "unavailable"}` +
212
- `, ${sidecarCount} related audio/subtitle file(s)` +
213
- (fillStarted ? ", whole-torrent fill enabled at TAIL urgency" : ", whole-torrent fill unavailable")
214
- );
215
-
216
- return reply.send({ started: true, swarm: true, edges: metadataStarted, sidecars: sidecarCount, fill: fillStarted });
4
+ if (!sourceKey) return reply.code(400).send({ error: "sourceKey is required." });
5
+ if (!sourceRegistry.get(sourceKey)) return reply.code(404).send({ error: "Source key was not found." });
6
+ const started = (viewers?.forSource(sourceKey).length ?? 0) > 0;
7
+ return reply.send({ started, swarm: started, edges: false, fill: false });
217
8
  }