@torrent-tv/proxy 2.82.0 → 2.83.1
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 +44 -0
- package/CLAUDE.md +11 -0
- package/docs/disk-architecture.md +161 -0
- package/docs/encode-architecture.md +36 -7
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +11 -2
- package/routes/stream/get.js +74 -3
- package/services/delivery-probe.js +248 -43
- package/services/disk/keep.js +48 -0
- package/services/disk/returns.js +103 -0
- package/services/download/SwarmSelection.js +5 -5
- package/services/download/registry.js +20 -0
- package/services/encode/EncodeRun.js +1 -0
- package/services/encode/Encoder.js +15 -0
- package/services/encode/QsvEncoder.js +5 -0
- package/services/encode/SegmentStore.js +14 -0
- package/services/encode/VaapiEncoder.js +5 -0
- package/services/encode/encode-exit.js +17 -0
- package/services/encode/start-stop-cost.js +6 -2
- package/services/files/CompletedFiles.js +276 -0
- package/services/files/piece-from-whole-file.js +118 -0
- package/services/hls-session-manager.js +17 -1
- package/services/hwaccel.js +4 -0
- package/services/output/cut-grid.js +13 -3
- package/services/piece-store/piece-disk-store.js +143 -8
- package/services/piece-store/piece-lru.js +17 -0
- package/services/piece-store/shared-piece-store.js +1803 -1549
- package/services/torrent-pool.js +246 -26
- package/services/torrent-worker/client.js +21 -0
- package/services/torrent-worker/protocol.js +9 -1
- package/services/torrent-worker/worker.js +183 -2
- package/test/completed-files.test.js +115 -0
- package/test/cuts-follow-published-grid.test.js +35 -0
- package/test/delivery-probe.test.js +114 -1
- package/test/encode-exit.test.js +18 -0
- package/test/keeping-period.test.js +83 -0
- package/test/piece-disk-store.test.js +114 -0
- package/test/piece-from-whole-file.test.js +129 -0
- package/test/piece-store-eviction.test.js +28 -15
- package/test/piece-store-never-refuses.test.js +153 -0
- package/test/piece-store-reservations.test.js +16 -3
- package/test/probe-wedge-certainty.test.js +3 -3
- package/test/shared-piece-store.test.js +27 -13
- package/test/stream-route.test.js +41 -0
- package/test/swarm-follows-readers.test.js +126 -0
- package/test/swarm-reach.test.js +5 -0
- package/test/upload-hurry.test.js +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
## 2.83.1
|
|
2
|
+
|
|
3
|
+
- **Fix**: THE PIECE STORE COULD END A TORRENT, AND ON 2026-09-11 IT DID. Switching to the second episode of a five-file torrent reached a store that had shrunk to three blocks of 4 MB while the machine offered 4.3 GB; a claim for a block failed after five seconds, the failure travelled out through the torrent client's own write callback, and the client destroyed the torrent. For the rest of the process every read answered `File 1 not found in torrent:d4022ff4...`, `/stats` said `peers=0 connected of 1186 known`, the header of the new episode never downloaded, and the viewer waited on a plan that could not be built until the addon was restarted. Memory is no longer a reason to refuse anything: an arriving piece that cannot have a block is written straight to disk, which is where it was going anyway.
|
|
4
|
+
- **Fix**: A READ FOR A PEER TOOK A WHOLE PIECE FOR 16 KB. The torrent client's own `get` revived the entire piece into memory — 4 MB to answer a request for sixteen — and competed for the same blocks playback needs. It reads the asked-for range straight from the spilled file now, taking no block at all, so an upload can neither wait for memory nor be refused it. In the field session that path produced 63 416 reads of which 78.4 % missed memory and 49 696 whole pieces revived, to serve an upload capped at 512 KB/s.
|
|
5
|
+
- **Fix**: The branch that waits for the disk could not fire when it was needed. It asked whether the blocks IN USE had reached the ceiling, while what reaches the ceiling is the blocks in use plus the reservations — so a store held at its ceiling by reservations fell through to eviction, which has nothing to evict when nothing is resident. Two writes were on their way to disk at that moment and either would have returned a block.
|
|
6
|
+
- **Fix**: A store between readers keeps room for one window instead of falling to the minimum. The allowance is re-derived once a minute and a claim gives up after five seconds, so a reader arriving into that minute met whatever the store had shrunk to. The floor is the widest window this store has actually been asked for — measured, not chosen — and a store nobody has ever read keeps the minimum, since its arriving pieces have the disk.
|
|
7
|
+
- **Fix**: A torrent that dies without being asked to is forgotten, so the next request builds it again from the same magnet against the same data on disk. The record used to stay and answer, which is why re-adding the source from the browser returned the corpse.
|
|
8
|
+
- **New**: The store says what refused and with what numbers — resident, pinned, declared ranges, blocks in use against the ceiling, blocks on their way to disk, reservations outstanding. The old text named one state ("every resident piece is pinned") and the field case was a different one: nothing resident and nothing pinned. Its line also carries `to-disk-for-want-of-memory=` and `nothing-moved-for=`, which is the figure a refusal is measured against and which nothing printed.
|
|
9
|
+
- **Fix**: A STORE NEVER READ WHAT A PREVIOUS LIFE OF ITS TORRENT LEFT IN ITS OWN DIRECTORY. A torrent torn down and added again got a store whose index started empty, answered "not on disk" for every piece it in fact had, and downloaded the film a second time while the first copy sat beside it. Found by asking what "the data stays on disk" was worth after the fix above; it was worth nothing. The directory is read once when the store is made, and the torrent hashes what it uses anyway, so a file that does not match is refused and fetched again.
|
|
10
|
+
- **Fix**: NO SEGMENT IS LEFT SHORTER THAN A SEGMENT AT THE END OF THE FILM. Keyframes are thinned so no cut lands within a segment length of the one before it, and the end of the file was guarded by fifty milliseconds instead — a number from nowhere. Field 2026-09-11: a keyframe 160 ms before the end of a 54-minute film passed it and left segment #541 lasting 0.16 s. The sound has no data in such a tail, so its run made 541 segments where the picture made 542 and was marked short; the repair that followed was handed a start later than its own end, wrote 190 bytes that are not a fragment (`trun track id unknown, no tfhd was found`, reproduced on the addon host), and the viewer was held 23 s at the last minute of the film for a 404.
|
|
11
|
+
- **Fix**: A run that produced nothing has not finished. Making no segment and being unable to read the directory were the same `null`, and the reading written for the second — "cannot be contradicted, so it stands" — was applied to the first, so the empty repair run above was recorded as having reached the end of what it was given and nothing asked for that segment again for forty-six minutes.
|
|
12
|
+
- **Fix**: A torrent short of nothing anybody asked for is given the idle upload floor. Upload is bought with reciprocity and reciprocity is only worth buying while somebody is still missing bytes; on 2026-09-11 the proxy offered 512 KB/s of a fully downloaded file to 596 peers for forty-eight minutes, reading a 4 MB piece off the disk for every 16 KB it sent.
|
|
13
|
+
- **New**: `[stats]` says what we GAVE — `up=` and `gave=` — and how many of the connected peers have delivered anything. Without them "those reads are the swarm being served" was an inference from two numbers agreeing, and the connection count that grew 249 → 596 over one viewing said nothing about whether it helped.
|
|
14
|
+
|
|
15
|
+
- **Fix**: A PROXY THAT NEEDS NOTHING FROM A SWARM IS NO LONGER IN THAT SWARM. WebTorrent enforces `maxConns` only on peers it dials (`_drain`), while `_addIncomingPeer` checks that the torrent is neither destroyed nor paused and registers the peer — so a proxy with its port mapped is reachable and connections arrive and are never turned away. Field 2026-09-11: 249 connected at the start of one viewing, 596 at the end, 15 862 more queued, on a file complete for three quarters of an hour, each of them served by reading a 4 MB piece off the disk for every 16 KB sent. This is NOT a limit on connections: while anybody is reading, every one is worth keeping, since the one that has delivered nothing yet may deliver next. It fires when nothing is stated about a torrent at all — no window from any reader, no file held — and then uses the library's own word for the state, `paused`, which is the path it already checks. The pause does not close what is already open, so the peers are let go by hand; the data stays where it is, and the next reader rejoins the swarm rather than fetching it again.
|
|
16
|
+
- **Fix**: A QUIET SECOND WAS ENOUGH TO TAKE A THREE-MINUTE PACKET CAPTURE. The certainty gate asks whether the peer's highest-seen probe number has been frozen for longer than this connection has ever legitimately taken to report an advance — and in a connection's first minutes it has shown no healthy gap at all, so the floor under that was a single probe interval, 500 ms. Field 2026-09-11: captures triggered at `wedged 1s` and `wedged 2s` on a connection with `rtt=5ms`, every queue at 0 B and the viewer watching; 65 MB of captures left on the addon's disk. The floor is now the longest gap between two advances that health itself produces — one probe interval plus one report interval plus the crossing plus the peer's own loop delay, all four measured — which removes the one-second class. The two-second class is not removed and cannot be from anything measured today; what removes it is the learned figure, once a session has run healthily for a while.
|
|
17
|
+
- **Fix**: WHAT IS BEHIND IS NOW MEASURED IN TIME, WHICH IS THE UNIT IT WAS ALWAYS IN. The same probe goes down every channel including the one carrying the film, and SCTP schedules per ASSOCIATION — so a probe waits behind queued video exactly as a segment does, and counting outstanding probes measures the queue rather than the association. Every probe already carried the time it was sent, and `allowedGap` already computed the queue's own drain time before dividing it into probes and throwing it away. So the reading is the age of the newest probe the peer has reported seeing, against the time that channel's queue is allowed to take: a burst grows both together and stays flowing, while a stopped association grows only the age. The count stays in the line because it is what a reader recognises — `proxy=5591(gap 1 of 2 340ms of 1200ms)` — and decides nothing.
|
|
18
|
+
- **New**: THE TWO CLOCKS ARE RECONCILED, so the forward direction is measured instead of inferred. The page now reports when each channel's newest probe arrived and when its report left, on its own clock; with the time this proxy stamped into that probe and the time the report arrives, the round trip is `(t4 - t1) - (t3 - t2)` and the difference between the clocks is `((t2 - t1) + (t3 - t4)) / 2`. That arithmetic assumes the two directions are equally quick, which here they are not — film one way and almost nothing the other — so the estimate is kept from the report whose round trip was smallest, the one that queued the least. What the verdict then reads is the probe's own one-way time against what its queue may take, with neither the peer's reporting cadence nor its loop delay in either side of the comparison, because neither is in the measurement any more.
|
|
19
|
+
- **New**: A FILE DOWNLOADED WHOLE IS A FILE. Once every piece of a file is here the torrent has nothing left to do for it, so it is written out once and read from then on with an ordinary file read: no piece store, no memory ceiling, no eviction, no revival from a spill, and nothing that can refuse the read for want of memory. The stream route finds it by the source key alone — `torrent:<infohash>` is the identity — and therefore never asks the torrent thread, which would otherwise add a removed torrent back. Written under a name nothing serves and renamed when it is closed, so a process killed halfway leaves nothing that looks complete; taken up again at startup, where a file whose size does not match what the torrent says is removed rather than trusted. Not written while anybody is reading that file: writing a film out is a read of the whole of it and a write of the whole of it, and the file will still be complete when the viewer leaves.
|
|
20
|
+
- **New**: A PIECE CAN BE READ OUT OF THE FILE IT WAS ASSEMBLED INTO, and that is what makes keeping whole files worth anything. Without it a whole file is a second copy of bytes the piece store also holds and neither copy can be dropped; with it the spilled copy is a duplicate and goes — one episode on the field host was 1417 MB of segments beside 1424 MB of spilled pieces — and a torrent can be destroyed and added again without fetching a byte, since what it verifies it reads from the files. A piece belongs to one file or straddles two, and a piece any part of which is not held whole is refused rather than half read: half a piece is worse than none, because the layer above would hash it and mark it bad. The store is told how to read it and whether it can be had, as two plain functions — where those bytes are is not a piece store's business, and the project's own import rule says so.
|
|
21
|
+
- **New**: A TORRENT DOWNLOADED WHOLE IS REMOVED, and its files stay. Every file complete, every one of them written out, and nobody reading: the torrent has no job left — there is nothing to fetch, and this proxy does not seed what nobody is watching. Asked for in these words on 2026-09-11.
|
|
22
|
+
- **Chore**: NOT yet seen in the field. What the next session must show: a file switch inside a torrent that does not end the torrent, no `File 1 not found` after one, the last segment of a film served without a wait, and — if connections are being closed — whether the count is back up by the next reading, which would mean they reconnect and it costs more than it saves.
|
|
23
|
+
|
|
24
|
+
## 2.83.0
|
|
25
|
+
|
|
26
|
+
- **Fix**: THE SPILLED PIECES HAD ONE RULE AND IT NEVER BOUND. The ceiling 2.81.0 gave them is a share of free space, which on the addon host is 52 292 MB against a measured growth of 14 400 MB in one viewing — so nothing was ever removed until the torrent itself went. They have the pair the segments have now: a piece behind every read head has been read and will not be read again unless somebody seeks back, and a seek back re-downloads it, which is the bargain this tier already makes whenever it drops a piece for room. Under pressure the order is the same as the segments’ — behind the earliest reader first, furthest behind first of all — instead of by which piece happened to be touched longest ago, which says nothing about what anybody is about to read.
|
|
27
|
+
- **Fix**: THREE PERIODS FOR ONE UNKNOWN, AND THEY CONTRADICTED EACH OTHER. A torrent and its downloaded bytes went at fifteen minutes, a session lived to thirty, the produced segments to six hours — so between the first two there was a session with no source: a viewer returning at the twentieth minute got a session that could not make a single new segment until the torrent was added again. All three stand for the same unmeasured thing, whether the viewer comes back, so they are ONE number in one place (`services/disk/keep.js`, one hour) and the session now dies first, which is the right order and needs no rule of its own.
|
|
28
|
+
- **New**: The thing those periods stand for is being measured, and the proxy is the only place it can be. A session opened on an output whose segments are still on disk IS a return, and its age is exactly what the store recorded. `returns: 14 session(s) opened on material still held, 3 on material gone; median 12min after the last read, longest 51min — kept for 60min` — said beside the disk figures. The median and the longest are the two the period has to sit between, and a week of ordinary use replaces the hour with what viewers actually do.
|
|
29
|
+
- **Fix**: The benchmark of a device-backed encoder could not have run. VAAPI and QSV take their device in the arguments before the input, and what is there for a session sets up hardware DECODING — which a benchmark fed raw frames must not have. `Encoder.benchmarkInputArgs` states the device and nothing else; without it `h264_vaapi` and `h264_qsv` fail to open and the reading would have said "this host cannot encode" about a host that encodes perfectly well. Found by reviewing 2.82.0 rather than by running it.
|
|
30
|
+
- **Chore**: `docs/disk-architecture.md` — the three things that write to one disk, the two rules that remove material, the order the viewers give it, and what is still not solved. 1009 checks pass, biome clean; the new eviction order was checked by breaking it on purpose.
|
|
31
|
+
- **Fix**: A QUIET SECOND WAS ENOUGH TO TAKE A THREE-MINUTE PACKET CAPTURE. The certainty gate asks whether the peer's highest-seen probe number has been frozen for longer than this connection has ever legitimately taken to report an advance — and in a connection's first minutes it has shown no healthy gap at all, so the floor under that was a single probe interval, 500 ms. Field 2026-09-11: captures triggered at `wedged 1s` and `wedged 2s` on a connection with `rtt=5ms`, every queue at 0 B and the viewer watching; 65 MB of captures left on the addon's disk. The floor is now the longest gap between two advances that health itself produces — one probe interval plus one report interval plus the crossing plus the peer's own loop delay, all four measured — which removes the one-second class. The two-second class is not removed and cannot be from anything measured today; what removes it is the learned figure, once a session has run healthily for a while.
|
|
32
|
+
- **Fix**: WHAT IS BEHIND IS NOW MEASURED IN TIME, WHICH IS THE UNIT IT WAS ALWAYS IN. The same probe goes down every channel including the one carrying the film, and SCTP schedules per ASSOCIATION — so a probe waits behind queued video exactly as a segment does, and counting outstanding probes measures the queue rather than the association. Every probe already carried the time it was sent, and `allowedGap` already computed the queue's own drain time before dividing it into probes and throwing it away. So the reading is the age of the newest probe the peer has reported seeing, against the time that channel's queue is allowed to take: a burst grows both together and stays flowing, while a stopped association grows only the age. The count stays in the line because it is what a reader recognises — `proxy=5591(gap 1 of 2 340ms of 1200ms)` — and decides nothing.
|
|
33
|
+
- **New**: THE TWO CLOCKS ARE RECONCILED, so the forward direction is measured instead of inferred. The page now reports when each channel's newest probe arrived and when its report left, on its own clock; with the time this proxy stamped into that probe and the time the report arrives, the round trip is `(t4 - t1) - (t3 - t2)` and the difference between the clocks is `((t2 - t1) + (t3 - t4)) / 2`. That arithmetic assumes the two directions are equally quick, which here they are not — film one way and almost nothing the other — so the estimate is kept from the report whose round trip was smallest, the one that queued the least. What the verdict then reads is the probe's own one-way time against what its queue may take, with neither the peer's reporting cadence nor its loop delay in either side of the comparison, because neither is in the measurement any more.
|
|
34
|
+
- **New**: A FILE DOWNLOADED WHOLE IS A FILE. Once every piece of a file is here the torrent has nothing left to do for it, so it is written out once and read from then on with an ordinary file read: no piece store, no memory ceiling, no eviction, no revival from a spill, and nothing that can refuse the read for want of memory. The stream route finds it by the source key alone — `torrent:<infohash>` is the identity — and therefore never asks the torrent thread, which would otherwise add a removed torrent back. Written under a name nothing serves and renamed when it is closed, so a process killed halfway leaves nothing that looks complete; taken up again at startup, where a file whose size does not match what the torrent says is removed rather than trusted. Not written while anybody is reading that file: writing a film out is a read of the whole of it and a write of the whole of it, and the file will still be complete when the viewer leaves.
|
|
35
|
+
- **New**: A PIECE CAN BE READ OUT OF THE FILE IT WAS ASSEMBLED INTO, and that is what makes keeping whole files worth anything. Without it a whole file is a second copy of bytes the piece store also holds and neither copy can be dropped; with it the spilled copy is a duplicate and goes — one episode on the field host was 1417 MB of segments beside 1424 MB of spilled pieces — and a torrent can be destroyed and added again without fetching a byte, since what it verifies it reads from the files. A piece belongs to one file or straddles two, and a piece any part of which is not held whole is refused rather than half read: half a piece is worse than none, because the layer above would hash it and mark it bad. The store is told how to read it and whether it can be had, as two plain functions — where those bytes are is not a piece store's business, and the project's own import rule says so.
|
|
36
|
+
- **New**: A TORRENT DOWNLOADED WHOLE IS REMOVED, and its files stay. Every file complete, every one of them written out, and nobody reading: the torrent has no job left — there is nothing to fetch, and this proxy does not seed what nobody is watching. Asked for in these words on 2026-09-11.
|
|
37
|
+
- **Chore**: NOT yet seen in the field. What the next session must show: a `returns:` line once material has been reopened, `disk:` with both claimants, and the spill falling behind a viewer rather than only growing.
|
|
38
|
+
|
|
1
39
|
## 2.82.0
|
|
2
40
|
|
|
3
41
|
- **Fix**: THE ENCODER PING-PONGED BECAUSE NOTHING MEASURED WHAT A START COSTS. The plan compares when the wanted pieces appear under each arrangement, and for that it needs how long a fresh encoder takes to produce anything and how long killing one takes. Both were learned only from runs that had ENDED, so at a cold open both were zero — and zero does not read as “not measured”, it reads as “free”. Keeping a warming encoder then cost one piece and moving it cost `0 + 0 + one piece`: the same figure to the millisecond, so the tie fell to position and any advantage however small won. Field 2026-09-08: start at #68, a second later kill and start at #69, half a second later kill and start at #68 again, each dying having produced nothing. Over two days 153 runs were stopped that way and 68 of them made no segment at all.
|
|
@@ -25,6 +63,12 @@
|
|
|
25
63
|
- **Fix**: A clean exit leaves nothing of ours. The root was removed only when it happened to be empty — from the first commit of this repository, never a decision — so a directory adopted at startup, owned by no session, survived the exit and was adopted again at the next start. That loop is what made an orphan permanent, and it is why 5.0 GB of segments from sessions that had ended hours before were on the addon host on 2026-09-10. Now everything the store owns goes, which also gives the startup sweep its meaning back: what is found then is from a kill.
|
|
26
64
|
- **Fix**: Every test spilled into the process working directory. They passed `spillDirectory` and the store reads `options.path`, so each wrote over the others' files.
|
|
27
65
|
- **Chore**: `allowance.js` holds the rule memory and disk now share, instead of one copy per resource. 991 checks pass, biome clean; the ceiling and the double spawn were each checked by breaking them on purpose — 6 of 9 and 1 of 13 go red.
|
|
66
|
+
- **Fix**: A QUIET SECOND WAS ENOUGH TO TAKE A THREE-MINUTE PACKET CAPTURE. The certainty gate asks whether the peer's highest-seen probe number has been frozen for longer than this connection has ever legitimately taken to report an advance — and in a connection's first minutes it has shown no healthy gap at all, so the floor under that was a single probe interval, 500 ms. Field 2026-09-11: captures triggered at `wedged 1s` and `wedged 2s` on a connection with `rtt=5ms`, every queue at 0 B and the viewer watching; 65 MB of captures left on the addon's disk. The floor is now the longest gap between two advances that health itself produces — one probe interval plus one report interval plus the crossing plus the peer's own loop delay, all four measured — which removes the one-second class. The two-second class is not removed and cannot be from anything measured today; what removes it is the learned figure, once a session has run healthily for a while.
|
|
67
|
+
- **Fix**: WHAT IS BEHIND IS NOW MEASURED IN TIME, WHICH IS THE UNIT IT WAS ALWAYS IN. The same probe goes down every channel including the one carrying the film, and SCTP schedules per ASSOCIATION — so a probe waits behind queued video exactly as a segment does, and counting outstanding probes measures the queue rather than the association. Every probe already carried the time it was sent, and `allowedGap` already computed the queue's own drain time before dividing it into probes and throwing it away. So the reading is the age of the newest probe the peer has reported seeing, against the time that channel's queue is allowed to take: a burst grows both together and stays flowing, while a stopped association grows only the age. The count stays in the line because it is what a reader recognises — `proxy=5591(gap 1 of 2 340ms of 1200ms)` — and decides nothing.
|
|
68
|
+
- **New**: THE TWO CLOCKS ARE RECONCILED, so the forward direction is measured instead of inferred. The page now reports when each channel's newest probe arrived and when its report left, on its own clock; with the time this proxy stamped into that probe and the time the report arrives, the round trip is `(t4 - t1) - (t3 - t2)` and the difference between the clocks is `((t2 - t1) + (t3 - t4)) / 2`. That arithmetic assumes the two directions are equally quick, which here they are not — film one way and almost nothing the other — so the estimate is kept from the report whose round trip was smallest, the one that queued the least. What the verdict then reads is the probe's own one-way time against what its queue may take, with neither the peer's reporting cadence nor its loop delay in either side of the comparison, because neither is in the measurement any more.
|
|
69
|
+
- **New**: A FILE DOWNLOADED WHOLE IS A FILE. Once every piece of a file is here the torrent has nothing left to do for it, so it is written out once and read from then on with an ordinary file read: no piece store, no memory ceiling, no eviction, no revival from a spill, and nothing that can refuse the read for want of memory. The stream route finds it by the source key alone — `torrent:<infohash>` is the identity — and therefore never asks the torrent thread, which would otherwise add a removed torrent back. Written under a name nothing serves and renamed when it is closed, so a process killed halfway leaves nothing that looks complete; taken up again at startup, where a file whose size does not match what the torrent says is removed rather than trusted. Not written while anybody is reading that file: writing a film out is a read of the whole of it and a write of the whole of it, and the file will still be complete when the viewer leaves.
|
|
70
|
+
- **New**: A PIECE CAN BE READ OUT OF THE FILE IT WAS ASSEMBLED INTO, and that is what makes keeping whole files worth anything. Without it a whole file is a second copy of bytes the piece store also holds and neither copy can be dropped; with it the spilled copy is a duplicate and goes — one episode on the field host was 1417 MB of segments beside 1424 MB of spilled pieces — and a torrent can be destroyed and added again without fetching a byte, since what it verifies it reads from the files. A piece belongs to one file or straddles two, and a piece any part of which is not held whole is refused rather than half read: half a piece is worse than none, because the layer above would hash it and mark it bad. The store is told how to read it and whether it can be had, as two plain functions — where those bytes are is not a piece store's business, and the project's own import rule says so.
|
|
71
|
+
- **New**: A TORRENT DOWNLOADED WHOLE IS REMOVED, and its files stay. Every file complete, every one of them written out, and nobody reading: the torrent has no job left — there is nothing to fetch, and this proxy does not seed what nobody is watching. Asked for in these words on 2026-09-11.
|
|
28
72
|
- **Chore**: NOT yet seen in the field. What the next session must show: one ffmpeg per run in `run-state` (207 IDLE spawns and no STARTING spawns), no `could not publish … ENOENT`, a `disk:` line naming free space and each claimant's share, and `/tmp/torrent-tv-hls` empty after a clean stop.
|
|
29
73
|
|
|
30
74
|
## 2.80.19
|
package/CLAUDE.md
CHANGED
|
@@ -72,6 +72,17 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
72
72
|
wanted (`demand/`) against what the swarm is told (`download/`), why urgency
|
|
73
73
|
is not a number given to the library, and why the speculative levels are
|
|
74
74
|
withdrawn rather than lowered.
|
|
75
|
+
- `disk/` — one owner of the disk, read by everything that takes any of it.
|
|
76
|
+
`DiskSpace` reads the free space once and divides it between claimants by
|
|
77
|
+
the rule memory already uses; `wire.js` says who the claimants are (the
|
|
78
|
+
produced segments, and the pieces the memory store spills, which live on the
|
|
79
|
+
torrent thread and are told their share over its channel); `free.js` reads
|
|
80
|
+
the nearest directory that exists, because the segments' own is made at the
|
|
81
|
+
first session and removed at a clean stop.
|
|
82
|
+
- `docs/disk-architecture.md` — the three things that write to one disk, the
|
|
83
|
+
two rules that remove material (nobody needs it, or there is no room), the
|
|
84
|
+
order the viewers give that removal, why a spilled piece is a file of its
|
|
85
|
+
own, and what is still not solved.
|
|
75
86
|
- `docs/encode-architecture.md` — who decides where encoders go, and the
|
|
76
87
|
answer is one authority: `EncodePlan`, from what is made, what is being
|
|
77
88
|
made, what is wanted and what the host can hold. Why no viewer reaches it,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Disk architecture — one owner, and what goes first when it is short
|
|
2
|
+
|
|
3
|
+
Three things on this proxy write to the same disk. Until 2026-09-10 each read
|
|
4
|
+
the free space as though it were the only claimant, so there were three
|
|
5
|
+
ceilings, each standing for the whole disk.
|
|
6
|
+
|
|
7
|
+
| what | where | what bounded it before |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| segments an encoder produced | `services/encode/SegmentStore.js` | a quarter of what was free, plus a 2 GB floor — both numbers chosen by hand |
|
|
10
|
+
| pieces the memory store spilled | `services/piece-store/piece-disk-store.js` | **nothing at all** |
|
|
11
|
+
| diagnostics kept on purpose | `/data` — core dumps, heap snapshots, packet captures | a count of files, never a size |
|
|
12
|
+
|
|
13
|
+
The middle row is what it cost: one fifty-minute viewing wrote **14 400 MB** to
|
|
14
|
+
the spill file (field 2026-08-31) while the store held 312-424 MB, and free
|
|
15
|
+
space on the host fell by every megabyte of it. The bottom row is 2.9 GB of one
|
|
16
|
+
core dump on the addon host, inside a budget of "two dumps".
|
|
17
|
+
|
|
18
|
+
## One owner
|
|
19
|
+
|
|
20
|
+
`services/disk/DiskSpace.js` reads the free space once and divides it. The rule
|
|
21
|
+
is the one memory already uses, on the other reading:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
allowance = free now + what we already hold - what everything that is not us has been seen to need
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Nothing in it is a fraction chosen by hand. The reserve is measured: between two
|
|
28
|
+
readings, how much free space went away beyond what our own consumers took.
|
|
29
|
+
|
|
30
|
+
`services/disk/wire.js` says who the claimants are. The segments are told their
|
|
31
|
+
share by a call; the spilled pieces live on the torrent thread, so the share
|
|
32
|
+
travels the channel that already carries everything else and the reply says what
|
|
33
|
+
they hold — one exchange, both directions.
|
|
34
|
+
|
|
35
|
+
The reading is said every pass, in the series beside the memory one:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
disk: 104584MB free; segments 0MB of 52292MB, spilled pieces 0MB of 52292MB
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Two rules, answering two questions
|
|
42
|
+
|
|
43
|
+
They are kept apart because they were briefly proposed as one, and that was
|
|
44
|
+
wrong.
|
|
45
|
+
|
|
46
|
+
**Time.** Material nobody needs should not sit on the owner's disk merely
|
|
47
|
+
because there is room for it. An output nobody has read for long enough goes
|
|
48
|
+
whole, whatever the free space is.
|
|
49
|
+
|
|
50
|
+
**Space.** Material everyone needs must still go when there is no room.
|
|
51
|
+
|
|
52
|
+
A ceiling alone would keep 5 GB on a household disk for six hours because the
|
|
53
|
+
disk is large. An idle rule alone would keep everything until its clock ran out,
|
|
54
|
+
however tight the disk had become.
|
|
55
|
+
|
|
56
|
+
## What goes first, and it is the viewers who decide
|
|
57
|
+
|
|
58
|
+
For the segments, the order is the priority map's own read from the other end:
|
|
59
|
+
|
|
60
|
+
1. outputs nobody is watching at all;
|
|
61
|
+
2. what lies behind the earliest viewer — furthest behind first;
|
|
62
|
+
3. what lies ahead of the furthest viewer — furthest ahead first.
|
|
63
|
+
|
|
64
|
+
A segment a viewer is standing on is never a victim. It used to be whole outputs
|
|
65
|
+
by when their directory was last read, which says nothing about what anybody is
|
|
66
|
+
about to watch.
|
|
67
|
+
|
|
68
|
+
## A piece is a file
|
|
69
|
+
|
|
70
|
+
`DiskTier` wrote every spilled piece into ONE sparse file at
|
|
71
|
+
`index * chunkLength` and answered `forget(index)` by dropping the number from a
|
|
72
|
+
set. The bytes stayed: a sparse file's blocks come back only by hole punching,
|
|
73
|
+
which Node exposes no binding for, so nothing this process could do returned a
|
|
74
|
+
single block before the whole file was removed.
|
|
75
|
+
|
|
76
|
+
`PieceDiskStore` gives each piece its own file. Removing one returns exactly its
|
|
77
|
+
blocks, needs no binding this runtime lacks, and makes the unit of eviction the
|
|
78
|
+
same as the unit of storage — so the order pieces leave in is the order we
|
|
79
|
+
choose. Over its allowance the least recently used goes, and a piece being read
|
|
80
|
+
is never the victim. A piece thrown away is answered as absent, so the torrent
|
|
81
|
+
fetches it again, which is the same bargain the memory tier makes when it
|
|
82
|
+
spills.
|
|
83
|
+
|
|
84
|
+
The read this replaced was chosen for its cost — 22.08 ms via `readFile` against
|
|
85
|
+
**7.63 ms** into a buffer we already hold, measured on the field host — and that
|
|
86
|
+
is unchanged: it is still one `read` into the caller's buffer. What it adds is
|
|
87
|
+
an `open` per read, tens of microseconds against those milliseconds.
|
|
88
|
+
|
|
89
|
+
## A clean exit leaves nothing of ours
|
|
90
|
+
|
|
91
|
+
The segments' root was removed only when it happened to be empty — from the
|
|
92
|
+
first commit of this repository, never a decision. What it protected against is
|
|
93
|
+
a second proxy sharing the root; what it did was leave every directory alone,
|
|
94
|
+
including this process's own orphans. A directory adopted at startup, owned by
|
|
95
|
+
no session, therefore survived the exit and was adopted again at the next start.
|
|
96
|
+
|
|
97
|
+
That loop is what made an orphan permanent, and it is why 5.0 GB of segments
|
|
98
|
+
from sessions that had ended hours before were on the addon host on 2026-09-10.
|
|
99
|
+
`SegmentStore.dropAll` now removes everything this process owns, root included,
|
|
100
|
+
which also gives the startup sweep its meaning back: whatever is found then is
|
|
101
|
+
from a kill.
|
|
102
|
+
|
|
103
|
+
Both kinds have both rules. For the spilled pieces the time rule is the same
|
|
104
|
+
statement one layer down: a piece behind every read head has been read and will
|
|
105
|
+
not be read again unless somebody seeks back, and a seek back re-downloads it —
|
|
106
|
+
the bargain this tier already makes whenever it drops a piece for room.
|
|
107
|
+
`PieceLru.readHeads` says where the readers stand, `PieceDiskStore.forgetBehind`
|
|
108
|
+
acts on it, and the eviction order under pressure is the same: behind the
|
|
109
|
+
earliest reader first, furthest behind first of all. With no reader at all
|
|
110
|
+
nothing is removed — a store between reads is not a store nobody wants, and the
|
|
111
|
+
torrent going idle is what empties it whole.
|
|
112
|
+
|
|
113
|
+
## How long material nobody is using is kept
|
|
114
|
+
|
|
115
|
+
One number, in one place — `services/disk/keep.js`, one hour — because
|
|
116
|
+
everything it governs stands for the same unmeasured thing: whether the viewer
|
|
117
|
+
comes back.
|
|
118
|
+
|
|
119
|
+
It was three, and they contradicted each other:
|
|
120
|
+
|
|
121
|
+
| what | was | now |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| a torrent and its downloaded bytes | 15 minutes | one hour |
|
|
124
|
+
| a session | 30 minutes | unchanged — a session is a record, not material |
|
|
125
|
+
| the segments an encoder produced | 6 hours | one hour |
|
|
126
|
+
|
|
127
|
+
The torrent went at fifteen minutes while the session it feeds lived to thirty,
|
|
128
|
+
so between them there was a session with no source: a viewer returning at the
|
|
129
|
+
twentieth minute got a session that could not make a single new segment. With
|
|
130
|
+
one hour for both kinds of material the session dies first, which is the right
|
|
131
|
+
order and needs no rule of its own to enforce.
|
|
132
|
+
|
|
133
|
+
Nothing derives the hour. Everything else in the decision is measured — a piece
|
|
134
|
+
comes back from the swarm in ~1430 ms, a segment in its own encode time, and the
|
|
135
|
+
disk has an owner that prices holding it — and only the return is unknown.
|
|
136
|
+
|
|
137
|
+
**And the return is measurable here.** A session opened on an output whose
|
|
138
|
+
segments are still on disk IS a return, and its age is exactly what the store
|
|
139
|
+
recorded. `services/disk/returns.js` keeps them and says so beside the disk
|
|
140
|
+
figures:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
returns: 14 session(s) opened on material still held, 3 on material gone;
|
|
144
|
+
median 12min after the last read, longest 51min — kept for 60min
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The median and the longest are the two the period has to sit between: shorter
|
|
148
|
+
than the median throws away material half the returns wanted, longer than the
|
|
149
|
+
longest keeps material no return has ever reached. A week of ordinary use and
|
|
150
|
+
the hour is replaced by what viewers actually do — and then the two kinds can
|
|
151
|
+
have different numbers, since their costs of coming back differ.
|
|
152
|
+
|
|
153
|
+
## What is NOT solved
|
|
154
|
+
|
|
155
|
+
**The measurement that would replace the hour has not been taken yet.** The
|
|
156
|
+
counter is in place and says nothing until a week of ordinary use has produced a
|
|
157
|
+
distribution.
|
|
158
|
+
|
|
159
|
+
**The diagnostics are not a claimant yet.** They are bounded by a count of files
|
|
160
|
+
and never by a size, and they cannot simply be thrown away when space is short: a
|
|
161
|
+
dump is the only evidence of the death it records. That needs a rule of its own.
|
|
@@ -252,13 +252,42 @@ a fresh encoder = spawn overhead + the piece at the rate in force
|
|
|
252
252
|
a moved one = the kill, and then the same
|
|
253
253
|
```
|
|
254
254
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
255
|
+
**Both figures are measured before any viewer exists**, and until 2026-09-10
|
|
256
|
+
neither was. They were learned only from runs that had ENDED, so at a cold open
|
|
257
|
+
both read zero — and zero does not mean "unknown" here, it means "free".
|
|
258
|
+
|
|
259
|
+
That is not a small bias, it is the whole comparison. Subtract keeping from
|
|
260
|
+
moving and what is left is exactly the killing plus the time the run has already
|
|
261
|
+
lived:
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
keep a warming run = firstByteWait - elapsed
|
|
265
|
+
move it = kill + spawn overhead + one piece
|
|
266
|
+
= kill + firstByteWait (spawn overhead = firstByteWait - one piece)
|
|
267
|
+
move - keep = kill + elapsed
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`elapsed` is the warm-up a move throws away — a process started, an input
|
|
271
|
+
opened, the first bytes fetched, a decoder filled — and it is the term that
|
|
272
|
+
makes moving cost something. Set `firstByteWait` to zero and it cancels out of
|
|
273
|
+
both sides along with the kill: keeping and moving then cost the same figure to
|
|
274
|
+
the millisecond, the tie falls to position, and any advantage however small wins.
|
|
275
|
+
|
|
276
|
+
Field 2026-09-08, the first fifteen seconds of a session: start at #68, a second
|
|
277
|
+
later kill and start at #69, half a second later kill and start at #68 again,
|
|
278
|
+
each dying having produced nothing. Over two days 153 runs were stopped by the
|
|
279
|
+
plan and 68 of them made no segment at all, median life 9.6 s.
|
|
280
|
+
|
|
281
|
+
`services/encode/start-stop-cost.js` measures both from **one ffmpeg run at
|
|
282
|
+
startup**: spawn to the first piece the encoder itself announces closed, then
|
|
283
|
+
SIGTERM to exit. 0.68 s and 0.02 s on the developer's desktop. `RunCosts` starts
|
|
284
|
+
from those and replaces them with readings from real runs as they arrive.
|
|
285
|
+
|
|
286
|
+
There was an `Infinity` here once for the cost of a move, on the reasoning that
|
|
287
|
+
an unmeasured price must not license an irreversible act. It was removed as an
|
|
288
|
+
exception in a model that needs none — correctly, but what replaced it was a
|
|
289
|
+
floor of zero, which licenses the act rather than forbidding it. A measurement
|
|
290
|
+
is what a model like this needs, not an exception and not a floor.
|
|
262
291
|
|
|
263
292
|
And a run killed before producing anything is a measurement too — a lower bound
|
|
264
293
|
on the first output, and the only reading a thrash can supply, since every run in
|
package/package.json
CHANGED
|
@@ -113,10 +113,19 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
113
113
|
// The infohash is on EVERY line, not only on the ones that look empty: it is
|
|
114
114
|
// the one identifier the pool's own lines, the worker's and this one share,
|
|
115
115
|
// and a line that carries it can be lined up with them without a guess.
|
|
116
|
+
// WHAT WE GAVE, which no line has ever carried. Without it "the reads are the
|
|
117
|
+
// swarm being served" is an inference from two numbers agreeing, and on
|
|
118
|
+
// 2026-09-11 it was the whole explanation of a store that read 63 416 times
|
|
119
|
+
// with nothing encoding: 596 peers, a complete file, and an upload nobody
|
|
120
|
+
// needed.
|
|
121
|
+
const uploaded = Number.isFinite(torrent.uploaded) ? Math.round(torrent.uploaded / 1048576) : "?";
|
|
122
|
+
const upKbps = Number.isFinite(torrent.uploadSpeed) ? Math.round(torrent.uploadSpeed / 1024) : "?";
|
|
116
123
|
logger.info(
|
|
117
124
|
`[stats] ${sourceKey.slice(0, 8)} ${String(torrent.infoHash).slice(0, 8)} ` +
|
|
118
|
-
`peers=${stats.connectedPeers ?? stats.numPeers} connected of ${known} known
|
|
119
|
-
|
|
125
|
+
`peers=${stats.connectedPeers ?? stats.numPeers} connected of ${known} known ` +
|
|
126
|
+
`(${stats.deliveringPeers ?? "?"} delivering, ${queued} queued, ${offered})` +
|
|
127
|
+
`${firstPeer} down=${downKbps}KB/s up=${upKbps}KB/s gave=${uploaded}MB ` +
|
|
128
|
+
`file=${filePct} header=${header}${detail}`
|
|
120
129
|
);
|
|
121
130
|
|
|
122
131
|
return reply.send(stats);
|
package/routes/stream/get.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* and HTTP 200 for full-file requests.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createReadStream } from "node:fs";
|
|
9
10
|
import { parseRange } from "../../utils/parse-range.js";
|
|
10
11
|
import { logger } from "../../utils/logger.js";
|
|
11
12
|
|
|
@@ -15,7 +16,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
15
16
|
*
|
|
16
17
|
* @param {import("fastify").FastifyRequest["query"]} query
|
|
17
18
|
* @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
|
|
18
|
-
* @returns {{ sourceType: string, source: string }}
|
|
19
|
+
* @returns {{ sourceType: string, source: string, sourceKey: string }}
|
|
19
20
|
*/
|
|
20
21
|
function getSourceParams(query, sourceRegistry) {
|
|
21
22
|
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey : "";
|
|
@@ -25,7 +26,7 @@ function getSourceParams(query, sourceRegistry) {
|
|
|
25
26
|
const sourceRecord = sourceKey ? sourceRegistry.get(sourceKey) : null;
|
|
26
27
|
const sourceType = sourceRecord?.sourceType ?? sourceTypeFromQuery;
|
|
27
28
|
const source = sourceRecord?.source ?? sourceFromQuery;
|
|
28
|
-
return { sourceType, source };
|
|
29
|
+
return { sourceType, source, sourceKey };
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -78,10 +79,68 @@ async function waitForTorrent(torrentPool, sourceType, source) {
|
|
|
78
79
|
* @param {{ sourceRegistry: ReturnType<import("../../store/source-registry.js").createSourceRegistry>, torrentPool: import("../../services/torrent-pool.js").TorrentPool }} deps
|
|
79
80
|
* @returns {Promise<void>}
|
|
80
81
|
*/
|
|
82
|
+
/**
|
|
83
|
+
* The whole file for this source and index, if this proxy has one.
|
|
84
|
+
*
|
|
85
|
+
* The source key IS the identity: `torrent:<infohash>`, the same for a magnet
|
|
86
|
+
* and for a `.torrent` file of the same content. Nothing the torrent would have
|
|
87
|
+
* told us is needed to find the file, which is the point — asking the torrent
|
|
88
|
+
* would add it back.
|
|
89
|
+
*
|
|
90
|
+
* @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }> }} torrentPool
|
|
91
|
+
* @param {string} sourceKey
|
|
92
|
+
* @param {number} fileIndex
|
|
93
|
+
* @returns {{ path: string, length: number, name: string } | null}
|
|
94
|
+
*/
|
|
95
|
+
function wholeFileFor(torrentPool, sourceKey, fileIndex) {
|
|
96
|
+
const held = torrentPool?.wholeFiles;
|
|
97
|
+
if (!(held instanceof Map) || held.size === 0 || !sourceKey.startsWith("torrent:")) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const infoHash = sourceKey.slice("torrent:".length).toLowerCase();
|
|
101
|
+
return held.get(`${infoHash}/${fileIndex}`) ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Serve a whole file off the disk, with ranges.
|
|
106
|
+
*
|
|
107
|
+
* @param {import("fastify").FastifyRequest} req
|
|
108
|
+
* @param {import("fastify").FastifyReply} reply
|
|
109
|
+
* @param {{ path: string, length: number, name: string }} file
|
|
110
|
+
* @returns {Promise<void> | void}
|
|
111
|
+
*/
|
|
112
|
+
function serveWholeFile(req, reply, file) {
|
|
113
|
+
const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
|
|
114
|
+
if (req.method === "HEAD") {
|
|
115
|
+
reply.hijack();
|
|
116
|
+
reply.raw.writeHead(200, {
|
|
117
|
+
"Accept-Ranges": "bytes",
|
|
118
|
+
"Content-Type": "application/octet-stream",
|
|
119
|
+
"Content-Length": String(file.length),
|
|
120
|
+
"Content-Disposition": disposition
|
|
121
|
+
});
|
|
122
|
+
reply.raw.end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
reply.header("Accept-Ranges", "bytes");
|
|
126
|
+
reply.header("Content-Type", "application/octet-stream");
|
|
127
|
+
reply.header("Content-Disposition", disposition);
|
|
128
|
+
const range = parseRange(req.headers.range, file.length);
|
|
129
|
+
if (!range) {
|
|
130
|
+
reply.header("Content-Length", String(file.length));
|
|
131
|
+
return reply.send(createReadStream(file.path));
|
|
132
|
+
}
|
|
133
|
+
const { start, end } = range;
|
|
134
|
+
reply.code(206);
|
|
135
|
+
reply.header("Content-Length", String(end - start + 1));
|
|
136
|
+
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
137
|
+
return reply.send(createReadStream(file.path, { start, end }));
|
|
138
|
+
}
|
|
139
|
+
|
|
81
140
|
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null }) {
|
|
82
141
|
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
83
142
|
const fileIndex = Number(fileIndexRaw);
|
|
84
|
-
const { sourceType, source } = getSourceParams(req.query, sourceRegistry);
|
|
143
|
+
const { sourceType, source, sourceKey } = getSourceParams(req.query, sourceRegistry);
|
|
85
144
|
|
|
86
145
|
if (!sourceType || !source || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
87
146
|
return reply
|
|
@@ -89,6 +148,18 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
89
148
|
.send({ error: "sourceKey or sourceType+source with fileIndex are required." });
|
|
90
149
|
}
|
|
91
150
|
|
|
151
|
+
// A FILE DOWNLOADED WHOLE IS A FILE, and reading it needs no torrent: no
|
|
152
|
+
// piece store, no memory ceiling, no eviction, no revival from a spill, and
|
|
153
|
+
// nothing that can refuse the read for want of memory. The check comes before
|
|
154
|
+
// the torrent is asked for on purpose — asking would add it back.
|
|
155
|
+
//
|
|
156
|
+
// The infohash is in the source key (`torrent:<infohash>`), so this needs
|
|
157
|
+
// nothing the torrent would have told us.
|
|
158
|
+
const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
|
|
159
|
+
if (whole) {
|
|
160
|
+
return serveWholeFile(req, reply, whole);
|
|
161
|
+
}
|
|
162
|
+
|
|
92
163
|
let torrent;
|
|
93
164
|
try {
|
|
94
165
|
torrent = await waitForTorrent(torrentPool, sourceType, source);
|