@torrent-tv/proxy 2.9.97 → 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,14 @@
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
+
6
+ ## 2.9.98
7
+
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.
9
+ - **Fix**: A session start no longer looks like a burst of seeks. The codec probe and the keyframe index read through the same route as the encoder and visit the first bytes and the last ones, which from byte offsets alone is indistinguishable from a viewer dragging the slider — two spurious "the viewer moved" per start, and more on every encoder restart. The encoder's input URL now says that it is the read that follows the viewer, and only that read counts.
10
+ - **Chore**: The per-run ffmpeg log line abbreviates the list of cut times to its count and its two ends. There is one cut per segment — 830 on a two-hour film, about 7 KB of log per run — and the list is only ever consulted for whether cutting was explicit, where it starts and how far it reaches.
11
+
1
12
  ## 2.9.97
2
13
 
3
14
  - **Fix**: The generous upload of 2.9.96 did not actually reach the moment it was written for. Only torrents with a registered reader were shown to the upload policy, and the first thing done with a new torrent — fetching the file's head and tail for the codec probe — reads through `createReadStream` without registering one. So for the whole of that wait, 8.36 s of the 11.46 s before playback in the measured session, the torrent looked unused and the upload stayed at the near-silent idle floor, during the exact seconds peers decide whether to serve us. A torrent in a hurry now counts whether or not anything is reading it. The selection is a named function of its own so it can be tested without a live swarm — the fault was in which torrents were considered, not in what was decided about them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.97",
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
+ }
@@ -144,8 +144,14 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool
144
144
  // byte offset) downloads first instead of waiting behind the sequential
145
145
  // backlog — this is what caused ~15-18 s stalls when seeking into an
146
146
  // undownloaded region.
147
+ // Only the encoder's own input read tracks where the viewer is. Everything
148
+ // else that comes through here — the codec probe, the keyframe index, a
149
+ // subtitle fetch — reads the file's edges, and treating those as a viewer
150
+ // position made every session start and every encoder restart look like a
151
+ // burst of seeks (measured: two spurious "the viewer moved" per start).
147
152
  torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0, undefined, {
148
- wholeFileRead: range === null
153
+ wholeFileRead: range === null,
154
+ isPlaybackRead: req.query.reader === "playback"
149
155
  });
150
156
 
151
157
  const start = range ? range.start : 0;
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
  );
@@ -672,6 +672,33 @@ function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, key
672
672
  * @returns {number[] | null} Times relative to the run start, or null when the
673
673
  * boundaries cannot serve (missing, or the index is outside them).
674
674
  */
675
+ /**
676
+ * The ffmpeg command as one readable line.
677
+ *
678
+ * Everything is shown as passed except the list of cut times, which is one
679
+ * value per segment — 830 of them on a two-hour film, about 7 KB of log for a
680
+ * single run, repeated on every restart. The count and the two ends say
681
+ * everything the list is ever consulted for: whether cutting was explicit at
682
+ * all, how far it reaches, and where it starts.
683
+ *
684
+ * @param {string[]} args
685
+ * @returns {string}
686
+ */
687
+ export function describeFfmpegArgs(args) {
688
+ const parts = [];
689
+ for (let index = 0; index < args.length; index += 1) {
690
+ const value = args[index];
691
+ if (value === "-segment_times" && typeof args[index + 1] === "string") {
692
+ const times = args[index + 1].split(",");
693
+ parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
694
+ index += 1;
695
+ continue;
696
+ }
697
+ parts.push(value);
698
+ }
699
+ return parts.join(" ");
700
+ }
701
+
675
702
  export function segmentCutTimesFrom(boundaries, startIndex) {
676
703
  if (!Array.isArray(boundaries) || boundaries.length < 2) {
677
704
  return null;
@@ -999,6 +1026,12 @@ export class HlsSessionManager {
999
1026
  const readWindowBytes = await this.#readWindowBytesFor(sourceKey, fileIndex, durationSeconds);
1000
1027
  if (readWindowBytes > 0) {
1001
1028
  inputUrl.searchParams.set("windowBytes", String(readWindowBytes));
1029
+ // This read, and only this read, follows the viewer. The codec probe and
1030
+ // the keyframe index also go through `/stream`, and they jump between the
1031
+ // first bytes and the last ones — which is indistinguishable, from byte
1032
+ // offsets alone, from someone dragging the slider. Saying so here is
1033
+ // cheaper and more truthful than guessing from the offsets.
1034
+ inputUrl.searchParams.set("reader", "playback");
1002
1035
  }
1003
1036
  if (!hasDuration) {
1004
1037
  logger.warn(
@@ -2132,7 +2165,7 @@ export class HlsSessionManager {
2132
2165
  // were verified to handle a copied AC-3 track on this very host, so the
2133
2166
  // arguments that run actually received are the missing evidence. One line
2134
2167
  // per run, and a run happens at most every few seconds.
2135
- logger.info(`transcode ${session.id} ffmpeg ${args.join(" ")}`);
2168
+ logger.info(`transcode ${session.id} ffmpeg ${describeFfmpegArgs(args)}`);
2136
2169
 
2137
2170
  const ffmpeg = spawn(this.ffmpegBin, args, {
2138
2171
  cwd: session.dirPath,
@@ -136,7 +136,11 @@ export function torrentsForUploadPolicy(torrents, usageByTorrent, now) {
136
136
  const chosen = [];
137
137
  for (const torrent of torrents) {
138
138
  const usage = usageByTorrent?.get?.(torrent);
139
- if ((usage && usage.size > 0) || (torrent?.hurryUntil ?? 0) > now) {
139
+ const hasReader = Boolean(usage && usage.size > 0);
140
+ if (hasReader || (torrent?.hurryUntil ?? 0) > now) {
141
+ // Recorded so the policy can tell "nothing is arriving and somebody is
142
+ // waiting" from "nothing is arriving because nobody asked".
143
+ torrent.hasActiveReader = hasReader;
140
144
  chosen.push(torrent);
141
145
  }
142
146
  }
@@ -176,7 +180,13 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
176
180
  // iterate the piece array and throw on webtorrent 3.x when a piece is null
177
181
  // (deselected / mid-verify), which would crash this timer every cycle.
178
182
  const notDone = torrent?.done !== true;
179
- const starving = notDone && downloadSpeed < starvingSpeed;
183
+ // A torrent nobody is reading is not starving, however still its download
184
+ // looks. The encoder is held back once it is far enough ahead of the
185
+ // viewer, and while it is held nothing is requested — measured
186
+ // 2026-08-04: four cycles of 512 -> 50 KB/s in three minutes, each
187
+ // reported as `earn unchoke ... down=0KB/s`, all of them raising the
188
+ // upload at moments when no byte was wanted by anyone.
189
+ const starving = notDone && torrent?.hasActiveReader !== false && downloadSpeed < starvingSpeed;
180
190
  if (starving && chokedInterested >= chokedThreshold) {
181
191
  const name = typeof torrent?.name === "string" ? torrent.name : "?";
182
192
  return {
@@ -373,6 +383,14 @@ export class TorrentPool {
373
383
  */
374
384
  #readPositionByTorrent = new Map();
375
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
+
376
394
  /** Global disk cap in bytes (0 = disabled). */
377
395
  #maxDiskBytes = 0;
378
396
 
@@ -1024,6 +1042,37 @@ export class TorrentPool {
1024
1042
  fileIndex,
1025
1043
  { headBytes = 256 * 1024, tailBytes = 2 * 1024 * 1024, timeoutMs = 300_000 } = {}
1026
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 }) {
1027
1076
  if (!torrent || !Array.isArray(torrent.files)) {
1028
1077
  return;
1029
1078
  }
@@ -1125,9 +1174,11 @@ export class TorrentPool {
1125
1174
  * @param {number} fileIndex
1126
1175
  * @param {number} byteStart - Start offset within the file.
1127
1176
  * @param {number} [windowBytes] - Unused; kept so callers need not change.
1128
- * @param {{ wholeFileRead?: boolean }} [options] - `wholeFileRead` marks a
1129
- * request that carried no byte range, i.e. one that merely opens the file at
1130
- * 0 rather than asking to read from there. See the guard below.
1177
+ * @param {{ wholeFileRead?: boolean, isPlaybackRead?: boolean }} [options] -
1178
+ * `wholeFileRead` marks a request that carried no byte range, i.e. one that
1179
+ * merely opens the file at 0 rather than asking to read from there.
1180
+ * `isPlaybackRead` marks the encoder's input read, the only one that
1181
+ * follows the viewer. See the guards below.
1131
1182
  * @returns {void}
1132
1183
  */
1133
1184
  prioritizeByteRange(
@@ -1182,10 +1233,15 @@ export class TorrentPool {
1182
1233
  const isJump =
1183
1234
  previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
1184
1235
  if (isJump) {
1185
- // A jump is a seek. Whatever the swarm was giving us was for somewhere
1186
- // else, and the pieces at the new position have to be earned from peers
1187
- // that are choking us — the same standing start as a fresh torrent.
1188
- this.#markHurry(torrent, "the viewer moved");
1236
+ // A jump in the read that follows the viewer is a seek. Whatever the
1237
+ // swarm was giving us was for somewhere else, and the pieces at the new
1238
+ // position have to be earned from peers that are choking us — the same
1239
+ // standing start as a fresh torrent. A jump in any OTHER read is the
1240
+ // codec probe or the keyframe index visiting the ends of the file, and
1241
+ // nobody is waiting on those the way a viewer waits on a seek.
1242
+ if (options.isPlaybackRead) {
1243
+ this.#markHurry(torrent, "the viewer moved");
1244
+ }
1189
1245
  const percent = ((safeStart / fileLength) * 100).toFixed(1);
1190
1246
  logger.info(
1191
1247
  `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
@@ -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));
@@ -94,3 +94,28 @@ test("a torrent with no reader still reaches the policy while it is in a hurry",
94
94
  "a torrent being read still counts, hurry or not"
95
95
  );
96
96
  });
97
+
98
+ test("a torrent nobody is reading is not treated as starving", () => {
99
+ // The encoder is suspended once it is far enough ahead, and while it is
100
+ // suspended nothing is requested — so the download reads zero without anyone
101
+ // waiting. Measured before this guard: four cycles of 512 -> 50 KB/s in three
102
+ // minutes, every one of them reported as `earn unchoke ... down=0KB/s`.
103
+ const idleButChoked = {
104
+ name: "film.mkv",
105
+ hasActiveReader: false,
106
+ wires: [
107
+ { amInterested: true, peerChoking: true },
108
+ { amInterested: true, peerChoking: true }
109
+ ],
110
+ downloadSpeed: 0,
111
+ done: false
112
+ };
113
+ assert.equal(decideUploadLimit([idleButChoked], { now: NOW }).bytesPerSec, 50 * 1024);
114
+
115
+ const waiting = { ...idleButChoked, hasActiveReader: true };
116
+ assert.equal(
117
+ decideUploadLimit([waiting], { now: NOW }).bytesPerSec,
118
+ 512 * 1024,
119
+ "a reader that IS waiting must still earn unchoke slots"
120
+ );
121
+ });