@torrent-tv/proxy 2.9.98 → 2.9.99

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2.9.99
2
+
3
+ - **New**: A source can be told to start before anyone asks to play it — `POST /api/sources/:sourceKey/warm`. Everything a cold torrent must do first takes seconds and none of it depends on which file is wanted: announce to the trackers, connect to peers, be unchoked by them. Given a file index it also fetches the two pieces at that file's edges, which is what the codec probe reads and what took **6.7 s of the 10.3 s** before playback in the session measured 2026-08-04. All of it used to begin only once a file had been chosen, because it was buried inside the playback plan. The route returns as soon as the work is under way and reports a refusal rather than an error — nothing is broken if a warm-up does not happen, since the ordinary path still does all of it.
4
+ - **Chore**: Two callers asking for the same file's edges at once now share one prefetch instead of opening a second pair of readers, each claiming a window and holding pieces. That happens by design on a single-video torrent, where the warm-up and the playback plan both want them.
5
+
1
6
  ## 2.9.98
2
7
 
3
8
  - **Fix**: The upload is no longer raised at moments when nobody wants a byte. A torrent with no reader was counted as starving whenever its download read low — which it always does while the encoder is held back for running ahead of the viewer. Measured: four cycles of 512 KB/s and back in three minutes, each reported as `earn unchoke … down=0KB/s`. Starvation now requires somebody to be waiting.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.98",
3
+ "version": "2.9.99",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -0,0 +1,83 @@
1
+ import { logger } from "../../../../utils/logger.js";
2
+
3
+ /**
4
+ * Start fetching a source before anyone asks to play it.
5
+ *
6
+ * POST /api/sources/:sourceKey/warm { fileIndex?: number }
7
+ *
8
+ * Everything a torrent must do before the first byte of video can be served
9
+ * takes seconds and none of it depends on the viewer: announce to the
10
+ * trackers, connect to peers, be unchoked by them, and fetch the two pieces at
11
+ * the file's edges that the codec probe reads. Measured 2026-08-04 on a cold
12
+ * 7.4 GB torrent: 6.7 s of the 10.3 s before playback was those two pieces
13
+ * arriving, with the swarm ramping from nothing.
14
+ *
15
+ * That work used to begin only when a file had been chosen, because it was
16
+ * buried inside the playback plan. It can begin as soon as the viewer has
17
+ * picked a TORRENT — while they are still reading the list of episodes — and
18
+ * then most or all of it has happened by the time they choose.
19
+ *
20
+ * Returns as soon as the work is under way. The caller is not waiting for a
21
+ * result; it is only saying "you may start". Failures are logged and answered
22
+ * as `started: false` rather than as an error, because nothing is broken if a
23
+ * warm-up does not happen — the ordinary path still does all of it.
24
+ *
25
+ * @param {import("fastify").FastifyRequest} req
26
+ * @param {import("fastify").FastifyReply} reply
27
+ * @param {{
28
+ * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
29
+ * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
30
+ * }} deps
31
+ * @returns {Promise<void>}
32
+ */
33
+ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool }) {
34
+ const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
35
+ if (!sourceKey) {
36
+ return reply.code(400).send({ error: "sourceKey is required." });
37
+ }
38
+
39
+ const sourceRecord = sourceRegistry.get(sourceKey);
40
+ if (!sourceRecord) {
41
+ return reply.code(404).send({ error: "Source key was not found." });
42
+ }
43
+
44
+ const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
45
+ const requestedIndex = Number(body.fileIndex);
46
+ const fileIndex = Number.isInteger(requestedIndex) && requestedIndex >= 0 ? requestedIndex : null;
47
+
48
+ // Adding the torrent is what announces to the trackers and starts connecting
49
+ // to peers, and it is also what a magnet needs in order to fetch its
50
+ // metadata. It is awaited because everything else needs the torrent object,
51
+ // and because until it resolves there is nothing to report.
52
+ let torrent;
53
+ try {
54
+ torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
55
+ } catch (error) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: could not add the torrent: ${message}`);
58
+ return reply.send({ started: false, swarm: false, edges: false });
59
+ }
60
+
61
+ // The edges are only worth fetching once it is known WHICH file will be
62
+ // played: on a season pack, warming twenty episodes' worth would spend the
63
+ // pool owner's bandwidth on nineteen files nobody opened. The caller passes
64
+ // an index when the torrent holds a single video, and again later if it
65
+ // wants to.
66
+ let edges = false;
67
+ if (fileIndex !== null && torrent.files?.[fileIndex]) {
68
+ edges = true;
69
+ // Deliberately not awaited: this is the multi-second part, and the point of
70
+ // the whole route is that the viewer goes on choosing while it happens.
71
+ Promise.resolve(torrentPool.prefetchFileEdges(torrent, fileIndex)).catch((error) => {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: file edges failed: ${message}`);
74
+ });
75
+ }
76
+
77
+ logger.info(
78
+ `warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
79
+ (edges ? `, fetching the edges of file ${fileIndex}` : ", file not chosen yet")
80
+ );
81
+
82
+ return reply.send({ started: true, swarm: true, edges });
83
+ }
package/server.js CHANGED
@@ -19,6 +19,7 @@ import { handleHealthzGet } from "./routes/healthz/get.js";
19
19
  import { handleApiSourcesPost } from "./routes/api/sources/post.js";
20
20
  import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
21
21
  import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
22
+ import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
22
23
  import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
23
24
  import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
24
25
  import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
@@ -176,6 +177,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
176
177
  app.get("/api/sources/:sourceKey/files", async (req, reply) =>
177
178
  handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
178
179
  );
180
+ app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
181
+ handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool })
182
+ );
179
183
  app.post("/api/playback-plan", async (req, reply) =>
180
184
  handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
181
185
  );
@@ -383,6 +383,14 @@ export class TorrentPool {
383
383
  */
384
384
  #readPositionByTorrent = new Map();
385
385
 
386
+ /**
387
+ * Edge prefetches currently running, keyed by infoHash and file index, so two
388
+ * callers asking at the same time share one.
389
+ *
390
+ * @type {Map<string, Promise<void>>}
391
+ */
392
+ #edgePrefetches = new Map();
393
+
386
394
  /** Global disk cap in bytes (0 = disabled). */
387
395
  #maxDiskBytes = 0;
388
396
 
@@ -1034,6 +1042,37 @@ export class TorrentPool {
1034
1042
  fileIndex,
1035
1043
  { headBytes = 256 * 1024, tailBytes = 2 * 1024 * 1024, timeoutMs = 300_000 } = {}
1036
1044
  ) {
1045
+ if (!torrent || !Array.isArray(torrent.files)) {
1046
+ return;
1047
+ }
1048
+ // Two callers can ask for the same edges at once: the warm-up that starts
1049
+ // when a torrent is picked, and the playback plan a moment later. Reading
1050
+ // the same two pieces twice costs nothing in bandwidth — the torrent
1051
+ // fetches each piece once — but it does open a second pair of readers, each
1052
+ // claiming a window and holding pieces. One is enough.
1053
+ const inFlightKey = `${torrent.infoHash}:${fileIndex}`;
1054
+ const running = this.#edgePrefetches.get(inFlightKey);
1055
+ if (running) {
1056
+ return running;
1057
+ }
1058
+ const prefetch = this.#prefetchFileEdgesOnce(torrent, fileIndex, { headBytes, tailBytes, timeoutMs });
1059
+ this.#edgePrefetches.set(inFlightKey, prefetch);
1060
+ try {
1061
+ return await prefetch;
1062
+ } finally {
1063
+ this.#edgePrefetches.delete(inFlightKey);
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * The body of {@link prefetchFileEdges}, without the de-duplication.
1069
+ *
1070
+ * @param {import("webtorrent").Torrent} torrent
1071
+ * @param {number} fileIndex
1072
+ * @param {{ headBytes: number, tailBytes: number, timeoutMs: number }} options
1073
+ * @returns {Promise<void>}
1074
+ */
1075
+ async #prefetchFileEdgesOnce(torrent, fileIndex, { headBytes, tailBytes, timeoutMs }) {
1037
1076
  if (!torrent || !Array.isArray(torrent.files)) {
1038
1077
  return;
1039
1078
  }
@@ -219,7 +219,8 @@ test("criticality marks the window being waited for, not the whole range", async
219
219
  try {
220
220
  const iterator = readFragments({
221
221
  torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
222
- cancellation: { isCancelled: () => false }
222
+ cancellation: { isCancelled: () => false },
223
+ windowBytes: WINDOW_PIECES * PIECE
223
224
  });
224
225
  const pending = iterator.next();
225
226
  await new Promise((resolve) => setImmediate(resolve));