@torrent-tv/proxy 2.80.0 → 2.80.2

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,21 @@
1
+ ## 2.81.0
2
+
3
+ - **Fix**: Three encoders were started on one track within 200 ms, each into the road another was already writing, and the film came apart. Field 2026-09-05: fifteen readers on a piece store that holds sixteen pieces, half of all evictions taking a piece a reader had declared it wanted, two thirds of reads coming back from disk — and then `/stream` handing out bytes that were not the file's, twenty-two source-parse errors from ffmpeg at scattered offsets, a segment the player could not append, and an empty picture for six minutes while the proxy went on encoding. The placement rule was the cause: an encoder was put at the first unmade piece of the most urgent band, which is always the piece the working encoder is about to write.
4
+ - **Fix**: One encoder's work now ends where the next one's begins. A free stretch may run to the end of the track, and an encoder given all of it walks into every stretch behind it — two LIVE encoders then write the same file names at the same time, and the file belongs to neither. That is the thing being prevented, and only that: driving through material that is already made is a legitimate outcome of the comparison, taken when encoding it again costs less than the death of this encoder plus the start of another. Bounded at the next start, two live stretches cannot overlap however the bands move. Bounding them at a band's own end was tried and is worse: a band edge travels with the viewer, so every step forward leaves a sliver just past the previous encoder and buys an encoder for it.
5
+ - **Fix**: The plan is given one merged map rather than one window per viewer per band. Two viewers a few seconds apart state stretches that overlap, and one encoder was placed on each — an encoder per viewer for film they both want, which is the opposite of what sharing an output is for.
6
+ - **New**: The priority map is a reading of how soon a viewer will be somewhere, and nothing else (`services/priority/PriorityMap.js`). A viewer moving forward reaches the second `x` after `x - p` seconds of film; that distance is what the number is derived from, and it is why two viewers can be compared at all — the nearer one wins the second they both want. The bands widen as they go: the first is the measured allowance, each next is twice the last, so a fifty-minute film is described in ten bands, fine where the difference decides something and coarse where it does not. Three levels could not do this: they collapsed a continuous quantity, and two viewers in different places both got the top one.
7
+ - **New**: The machine's whole budget is used, and the film is divided between the encoders it affords. One viewer never meant one encoder — that was a reading of one host's budget at 1080p, not a rule. The division serves two things in order: the viewer must not stop, so the first encoders stand at the points where the one before them stops keeping ahead — `(q - p) x s / (1 - s)` of film, which is why the stretches grow; and then the film should be finished as soon as possible, so whatever is left is shared EQUALLY between the encoders that remain, because equal shares finish together and any other division finishes when its longest share does. At or above realtime the first requirement is met by one encoder for the whole film and every other goes to the second, which is the common case on a copied picture.
8
+
9
+ - **Fix**: The downloading reads the priority map instead of a forecast each read made for itself. A read used to declare a rolling window — the piece it wanted and two bands beyond it — and fifteen reads on one file were then fifteen windows on a piece store that holds sixteen pieces. Half of all evictions took a piece a reader had declared, two thirds of reads came back from disk, and what `/stream` handed out stopped being the file's bytes: twenty-two source-parse errors, a segment the player refused, an empty picture for six minutes (field 2026-09-05). A read now states only the piece it is STOPPED on, which is not a forecast but the fact that somebody is waiting there; what should be downloaded ahead of a viewer is stated once for the whole file by the side that knows where the viewers are.
10
+ - **New**: The map is published to the torrent thread as byte ranges and stated there like any other need (`torrent-pool.applyPriorityMap`), so the swarm is asked for what the map wants and for nothing else. Republished only when it changes: a viewer sitting still would otherwise rebuild the download set several times a second.
11
+ - **Fix**: Which piece leaves memory is decided by the map's number, not by how long ago the piece was touched. A reader walking a film touches each piece once, so the piece the decoder wants in two seconds looks exactly as stale as one fetched forty minutes ago — and with the encoder running ahead of the viewer, the second kind is what fills the store. Recency now separates only pieces the map wants equally, and a piece no zone covers goes first, which is the ordinary case and costs one step.
12
+ - **Fix**: Admission compares the same quantity in the same order. Whether an arriving piece displaces a resident one is the same question as which resident one goes, and it was answered from a different quantity — distance to a read head — so the store could admit a piece by one rule and evict it again by the other in the same second. The map's level first, the distance only between pieces the map wants equally.
13
+ - **New**: A zone reaches the swarm at the level it MEANS, read from the map's own scale rather than from where it sits in a list. The map has as many bands as the film needs — ten on a fifty-minute film — against five levels in the register: the top of the scale is where a viewer stands, the bottom is what nobody is approaching, one above the bottom is the far tail, everything between is the lead being built. Never the level that means "a reader is stopped on these bytes", which only a read can say. Read from the scale's ends and not from this file's own highest and lowest, because the two speculative levels are withheld across every torrent at once: a paused viewer's film has to compare as wanted-last against a film somebody is watching, and judged against itself alone it would be the most urgent thing there is.
14
+ - **New**: A wait is attributed to the level the MAP puts that piece at, and the reader's log line says where the map wanted it. It used to be attributed to the reader's own band, which was the forecast that has now gone: a wait belongs to a level, and the level says whether a zone is asked for too late.
15
+
16
+ - **Fix**: How fast this machine encodes is out of the priority map. It had sized the map's zones, which made the map unusable by the downloading — a band whose width comes from encode speed says nothing about bytes — and it is not a fact about what a viewer needs. What one encoder can hold is the encoding orchestrator's own arithmetic, from its own measured speed.
17
+ - **Fix**: A viewer who has stopped the picture is going nowhere, so nothing in the film is nearer to them than anything else: their whole film drops to the band nobody is approaching, and the machine's work goes to whoever is watching. It needs no rule of its own — it falls out of the number being a distance.
18
+
1
19
  ## 2.80.0
2
20
 
3
21
  - **Fix**: The reading of usrsctp's own state is OFF unless `--usrsctp-state` is given, and its deadline is counted by a separate process. On 2026-09-05 a delivery probe declared a wedge that lasted half a second and cleared itself; the reading it triggered attached gdb to this process, which stops every one of its eighty threads for as long as it is attached. The log ended mid-second, `/healthz` stopped answering, and the viewer waited a minute and was told the proxy had sent no video. The fifteen-second guard could not fire — it was a timer inside the process gdb had stopped — and killing gdb left the main thread deadlocked for good, so only restarting the addon recovered it. A means of diagnosis may not stop the product: this one attaches to a live process and is triggered by a verdict with a known history of false positives, so it is switched on deliberately while somebody is watching, and `timeout` counts its seconds from outside.
@@ -21,1561 +39,1561 @@
21
39
  - **Chore**: The priority map is a layer of its own (`services/priority/`), below both orchestrators and depending on nothing. It knows only priorities: what is downloaded is the download orchestrator's own knowledge, what is encoded is the encoding orchestrator's, and neither is visible from the map.
22
40
  - **Chore**: A check on the shape rather than the behaviour (`test/one-authority.test.js`): one place stops an encoder for scheduling reasons, one place builds one, a start stops nothing, a seek starts nothing, and the stretch computed by the plan is the one handed over. Every rule in it is one that was broken on 2026-09-05.
23
41
 
24
- ## 2.78.0
25
-
26
- - **Fix**: One authority over the encoders, where there were three. A viewer watching an episode on 2026-09-05 had their picture stop for 125 seconds while the proxy spawned and killed an encoder every 350-700ms, producing nothing. Two decisions were contradicting each other on every pass: the plan commanded a start inside the viewer's window, at #46; `planRunInterval` in the session manager moved that start to #78, because it counted every live run as claiming up to its head plus the look-ahead; and the plan then saw a run at #78 against a window of [27, 57], found no overlap and killed it as "nothing it was given is wanted". Neither coverage nor demand changed between two such passes, so the same start was commanded again, for as long as anybody watched. `planRunInterval` is deleted: where a run starts is the plan's decision and nothing moves it, and how far it may work is read off the one coverage map the plan itself uses.
27
- - **Fix**: A run is never stopped for standing outside a viewer's window. While a file is being encoded it is encoded whole; a viewer decides the ORDER the work is taken in and, through the machine's budget, how many processes take it — never whether a run may go on living. The stop this replaced was the other half of the contradiction above, and with it went the last two uses of "does this run overlap a window" in the file.
28
- - **New**: What a viewer needs is a map, and the maps of several viewers merge into one (`services/encode/DemandMap.js`). Three zones per viewer, in seconds of film: what must be ready before they set off so that they never stop, what the machine reaches while they watch that, and the rest of the track. Merging takes the highest priority per second, so film two people want is as urgent as the more urgent of them and is made once. Which viewer asked never reaches the encoders.
29
- - **Fix**: The size of that first zone is measured rather than chosen. Below realtime an encoder loses `1 - speed` of a second of film for every second played, so over the film in front of a viewer the shortfall is `remaining × (1 - speed)`, and that much has to exist before they start or they meet a stall partway through — at 0.25x on 400 seconds of film, 300 seconds. Above realtime only the allowance this file's own recent interruptions have shown to be necessary is needed. The 120 seconds that used to size this window were `LOOKAHEAD_PAUSE_SECONDS`, the threshold of a suspended encoder, one chosen number answering seven unrelated questions.
30
- - **Fix**: The look-ahead limiter is gone, with both of its chosen numbers. It suspended a run 120 seconds in front of the viewer and woke it at 60 — deliberately pushing a run past the window the plan was asking about, which is what the plan then killed it for. What remains of it is a reading: how much film is ready in front of the earliest viewer, which commands nothing.
31
- - **Fix**: A zone's priority reaches the register instead of being dropped on the way. `want()` took only the two ends, so the order the map states was computed and then discarded. It is a property of the window now, and the claimant's name stays the person's, so a viewer who leaves takes all three of their zones with them.
32
- - **Fix**: The measured encode speed is read from the field that exists. `progress.speedX` appears nowhere in this codebase, so the speed was always zero and the middle zone was never built at all; the reading the budget already trusts is `recentSpeed.speed`.
33
-
34
- ## 2.77.0
35
-
36
- - **Fix**: A viewer who has just arrived is watching. Presence and position were one field — the segment they last asked for — so a viewer who had asked for nothing counted as absent, and an output all of whose viewers count as absent has every encoder on it stopped. Field 2026-09-05: a soundtrack's encoder was stopped 1.25 s after it started, with nothing produced, so its `init.mp4` was never made; the picture could not be played without it, the browser waited sixty seconds and was told the proxy had sent no video. The viewer could not have rescued themselves — the segment request that would have marked them present needs the `init.mp4` the stopped encoder was going to make. They are now two facts: position is set the moment they arrive, from the position their own request names, and presence is a fact of their connection.
37
- - **New**: The page says who is watching, once, over the control channel it opens. A connection closing is then a PERSON leaving, and every output they were watching — the picture, the quality step, the soundtrack — is told at once, instead of nothing being told at all. Two doors, because the first does not always come: the channel's own close event, and the watch that already exists beside it for a peer connection that dies without one. Silence is never a departure: a viewer on pause, a viewer whose tab is hidden and whose timers the browser has throttled, and a viewer holding two minutes of buffer are all silent and all still watching. The name belongs to the page, not to the connection, so the reconnect ladder swapping the transport under a running player does not make one person two.
38
- - **New**: What encoders should exist is re-decided when its inputs change, not every five seconds. A viewer arriving, moving or leaving, and a run ending, each say so; the decision is taken once at the end of that turn however many of them happened. The five seconds were also the period of the restart loop seen in the field on 2026-09-05 — one ffmpeg spawned per tick for sixteen minutes — and a just-created output waited up to five seconds before anything noticed it had a viewer at all.
39
- - **New**: A viewer asking for any file of a soundtrack or a quality step is that viewer watching it. Only a segment request counted before, and a segment cannot be asked for until the init has been served, which cannot be made for an output nobody is watching.
40
- - **Fix**: What happens after an encode run ends had never once happened. The handler removed the run from its session and then asked whether the run was still that session's — a question that always answers "no" after the removal — so everything below that line was unreachable: the fallback from a failed hardware encoder to software, the retry when the torrent data goes away, the limit on retrying a position that keeps failing, and the error line naming the ffmpeg command. Verified over both of the field host's log files: zero occurrences of that error line and zero of `fast failure at segment`, across every session that proxy has ever run. The identity is now read before the removal.
41
- - **Fix**: A start that cannot succeed is no longer commanded for ever. The limit on retrying a position counted only past segment #0 — it was written for seek restarts, and a seek is never to the beginning — which left the one position a first start happens at with no count at all. With the two defects above together and the decision now taken the moment its inputs change, ffmpeg failing to spawn produced fifty passes of the plan in the time a probe took to notice; on the five-second timer the same thing had read as a restart every five seconds for sixteen minutes in the field. The count now runs at every position, the plan consults it before commanding a start, a different position keeps its own budget, and a run that does real work resets it.
42
- - **Chore**: A viewer's position is called `position`. It was `head`, which is also what a running encoder's position is called, and the two are different quantities on different objects. An output created on behalf of a picture keeps its made-up name out of the viewer registry, where — now that a viewer is present from the moment they are known — it would have kept its output producing for ever.
43
-
44
- ## 2.76.6
45
-
46
- - **New**: The proxy says which films it is already downloading, in the same health poll the browser makes before it picks one. A viewer sent to the proxy that has their film costs it the encode and nothing else — the torrent is there, its pieces are there, and where the output is identical they share that too — while the same viewer sent anywhere else starts the download from nothing. Answered from the torrent thread, which is the only truthful record: the main thread keeps stand-ins that are cleared on shutdown alone, so they name films this proxy let go of hours ago. A proxy that cannot answer is scored on its machine as before.
47
- - **Chore**: Two doc comments in the torrent pool's interface described methods that had been moved or renamed out from under them, leaving the methods below without one. Each is back over its own.
48
-
49
- ## 2.76.5
50
-
51
- - **Chore**: What encoding costs this machine is a class of its own (`services/quality/EncodeCost.js`), the third part taken out of the session manager. One subject — seconds of work per second of video — asked about a picture being re-encoded, a soundtrack, a copy, everything running beside the rung being judged, and turned into the list of heights the machine can hold. It holds what an encoder taught it, since it is the only thing that reads those figures, and it is given what it cannot work out for itself: which sessions belong to one file, the host's own readings, how many encoders are running, and what the file costs merely by being fetched. The host is asked at the moment of the question rather than copied when the object is made — the share of the machine that is free is re-read every few seconds, and a copy would price every later rung against a machine that has gone. The manager keeps the three methods that LEARN those costs; moving them is the next step.
52
- - **Fix**: A rung measured running below realtime is withdrawn even on a host whose startup benchmark is missing. That measurement is the strongest evidence there is about a rung — it ran, and it could not keep up — and it was being discarded along with the prediction it does not depend on. Found by a check written while the arithmetic was being moved.
53
- - **Chore**: What a session's encoding is doing, as one state, is answered where the run states are defined (`encode/encode-run-state.js`) instead of by three functions in the manager. The boundary of the new directory is a lint rule, like every layer before it, and the rule was checked to fire rather than assumed to.
54
-
55
- ## 2.76.4
56
-
57
- - **Fix**: An encoder was started and killed every five seconds, each one producing 0-2 segments, for as long as anybody watched. Measured on the addon host: a run given no end carries a `to` below its `from` — which is how "no end" is written everywhere here — and two places read that as a number instead. The plan's test for "is anybody waiting for what this run was given" said no, so it was stopped as unwanted; and the stretch it claimed in the coverage map collapsed to a single segment, so the plan saw the rest of the film as free and started another encoder one number along. The two together are the loop. The rule is stated once now (`endOfRun`) and read from that one place, including where a run's own ending is judged.
58
- - **Fix**: A run with no end holds the look-ahead in front of it, not the rest of the film. It is what actually bounds one — a run is suspended once it is that far in front of the segment its viewer asked for and produces nothing until somebody asks — and claiming further would leave a viewer who opens the same film further in with no encoder at all, waiting for that run to encode its way there. The rule was already applied where a session plans its own interval and not on the path the plan uses.
59
-
60
- ## 2.76.3
61
-
62
- - **Fix**: The proxy stopped answering anything — playback, health, its own log — a few seconds after a viewer opened a film, and burned a whole processor doing it. Measured on the addon host with the stack read out of the live process: the look-ahead timer asked the plan where a new encoder could start, and the walk that answers that walked one segment number at a time towards nine quadrillion, scanning every claim at each step. It did that because the map had no length, and the map had no length because the field naming it moved onto the timeline in 2.76.0 while three readers were left on the old name, where every session answers `undefined`. Those three are the whole defect: with them wrong, no run was ever given an end either, so the feature that lets two encoders share one output was inert as well.
63
- - **Fix**: That walk can no longer do this whatever the length says. With no length known there is nothing to walk towards, and the answer — where the free stretch ends — is read off what the map already holds: the segments made and the stretches claimed, both finite however long the film is. A run then gets no end, which is what "the length is unknown" honestly means.
64
- - **Chore**: Three test fixtures stated the moved field on the session, so the checks went on passing over code that could not work. They state the timeline now, which is where the product reads it.
65
-
66
- ## 2.76.2
67
-
68
- - **Fix**: How long a session waits for a film's keyframe table is bounded, and the bound is one the read already had rather than a new number. That table decides which branch a picture takes — with it the picture is passed through untouched, without it the whole picture is re-encoded — and nothing limited the wait, while the file comes off a torrent and the bytes the table lives in may still be arriving. Measured on the addon host over seventeen files from four containers, pieces from 0.25 to 16 MB (`research/keyframe-table-read-2026-09-04.md`): every table that arrived did so within 24.8 s and most within half a second, while two files answered nothing for 120.9 s and 120.5 s — which is exactly TWO of the sixty-second bound the read already has, one for the wait on the file's edges and one for the read, in series. A session now waits for one of them. The read is not cancelled: it goes on, is remembered on the file, and the next session of that file gets the copy.
69
- - **Fix**: A table that has not arrived is not written onto the file as an absence. It would make a passing shortage of bytes look like a property of the bytes, and every later session of the file would then re-encode a picture that can be copied.
70
- - **Chore**: The two lines about that read say which quantity each is. They differ by up to sixty seconds — one is the swarm delivering the file's edges plus the parse, the other only the parse — and reading them as one figure produced a wrong conclusion the same day.
71
- - **Chore**: What a playlist says is written where a playlist belongs (`services/output/playlists.js`), the second part taken out of the session manager: the media playlist, the master with its quality steps and soundtrack group, and the lookup from a time to a segment. All three are statements about a timeline and about nothing else — not about a session, a viewer, an encoder or a disk — and they were private details of an eleven-thousand-line class, along with the HLS attribute quoting, the language tags and the bitrate a variant declares.
72
-
73
- ## 2.76.1
74
-
75
- - **New**: How long it takes to read a file's keyframe table is recorded, once per file, with the container that answered and how many times it found. That read decides which branch a picture takes — with the table it is copied, without it the whole picture is re-encoded — and nothing measured it. The one figure printed until now, `keyframes=` on the session-create line, is what the SESSION waited for, which is the remainder of a read the playback plan had already started, and reads zero whenever the plan finished first.
76
- - **Chore**: The comment promising this read a short timeout and "never more than ~6 s to session start" is gone. Nothing in that path has ever had a timeout: the fall back to re-encoding fires when the table is ABSENT, which is a statement about the file, not about how long a read ran. The file comes off a torrent, so the bytes the table lives in may still be arriving and the read waits for them, without a bound.
77
-
78
- ## 2.76.0
79
-
80
- - **Fix**: A viewer who changed quality twice lost the soundtrack they had chosen. The three link fields between sessions were replaced by a relation indexed both ways — an output holds its viewers, a viewer holds its outputs — and leaving an output was written as "delete the viewer from the one they came off". The one they came off can be the picture itself, which happens whenever somebody goes down a step, back to the picture's own height and down again: the picture then forgot them entirely, and with them their language, their position and their link. What followed was not a lost setting but silence — the soundtrack they were listening to was the one nobody was listening to any more, so its encoder was stopped as unwanted. A viewer is never dropped from the picture: it is the only id the browser holds, and everything a viewer has chosen is recorded there. Reproduced through the public path before the fix and pinned by a check that fails without it.
81
- - **Fix**: A viewer who leaves is subtracted from every output they were watching, and an output nobody is left watching is let go at once. Removing the closing chain left nothing in its place: nobody outside the session manager knows the id of a quality step or of a separately published soundtrack — the browser holds one id for the whole film — so when the picture ended, each of those went on holding a consumer, a claim on the torrent, a directory and a live encoder until the idle timer noticed half an hour later. The rule is now the viewers and not the picture, which is the difference that matters: an output with somebody still watching stays, whoever made it, and a soundtrack dies of having no listeners rather than of its picture ending.
82
- - **Fix**: Whatever removes a viewer from an output releases what their watching claimed of production. The only place a claim is ever released is the plan's pass over an output's viewers, so a viewer deleted from that map by any other route — a switch away from a step, a track change, a head that went stale, a session being disposed — left a claim nothing could reach, and the plan went on planning encoders for somebody who had gone. Every removal goes through one method now, and that method releases the claim.
83
- - **Fix**: One viewer is one object. It was made per SESSION, so the same person watching a picture, a quality step and a soundtrack was three objects, each with its own copy of what that person had chosen and where they were. The field saying which outputs a person is watching was worse than duplicated: being per session, each copy could only ever hold the id of the session that owned it, so it carried nothing, the one place that read it learned nothing from it, and three deletions written against it matched nothing. `services/viewer/Viewers.js` keys a viewer by the consumer id the browser sends — minted once per film opened in the page, so one id is one person watching one film — and writes both directions of the relation in one place. A viewer that cannot name itself still belongs to the session that met it, since two anonymous viewers are not one person.
84
- - **Chore**: Which outputs of one file exist right now is a class of its own (`services/output/LiveOutputs.js`), the first part taken out of the session manager. Eight questions with one subject and no state — the picture a step belongs to, the steps, the soundtracks, the height a session is named by, the height its encoder actually produces, where the file can be spliced, whether it publishes a master at all — answered by walking the live sessions and looking at what each one IS. They were private methods of an eleven-thousand-line class, which meant the quality budget and the serving path could only reach them by being inside it. The ladder of heights went with them (`output/ladder.js`), because a list of heights derived from a source height is a fact about an output and about nothing else. Two orphaned doc comments were found by the move: each described a function that had been moved or renamed out from under it, and each stood above a different function that has its own.
85
- - **Fix**: How many encoders may work on one output is bounded by measurement alone. It was bounded by a constant of four as well, and that constant could bind first: past the concurrency actually measured on a host, the penalty reading is HELD rather than extrapolated — which is the right answer to "what does this cost" and the wrong one to "may I start another", because it prices a fifth encoder at what a second was measured to cost. A fast host therefore kept dividing until the constant stopped it. The ladder now stops where the readings stop, and the constant is gone.
86
- - **Fix**: A quality step is a session made as one, not "another session of this file that carries a picture". One file can hold two pictures at once — a browser that understands rendition groups and one that needs the sound muxed in produce two — and treating one as a step of the other would let a switch away from a step stop the encoder of somebody else's picture. What every session of one file DOES share is the machine they run on, which is what the family is asked for, so that is what the family now means: every live session of the file, with the steps and the soundtracks named among them by what they are.
87
- - **Chore**: Every kind of encoder is a class of its own (`services/encode/`), and what a kind knows is written where the kind is. Five object literals inside `hwaccel.js` stated the arguments and nothing else; the arithmetic they are built from — output frame rate, the bitrate ladder and its caps, the frame box, the keyframe arguments — moved to `encode/args.js`, so a kind no longer drags detection and benchmarking behind it. The move was proved rather than reviewed: old and new built arguments over 2160 combinations of size, segment length, frame rate, preset, tone mapping, cut list and bitrate cap, and all 10800 came out identical element for element.
88
- - **New**: A kind states its own ladder of speed settings, and whether that ladder has ever been measured. Only libx264's has. `h264_vaapi`'s setting is `-quality`, higher is faster, with a range belonging to the driver — read from ffmpeg on the addon host rather than from memory — and nothing passes it. `h264_qsv` and `h264_nvenc` are declared and unverified, no host here carrying either encoder to ask, while NVENC is given a hardcoded `p4`: one rung in the middle of a ladder nobody has measured. `h264_v4l2m2m` has no such setting at all and says why. On a pool machine with working hardware encoding this is the only way to change speed, so its absence is now visible in the code instead of looking like a decision.
89
- - **New**: What has been made of an output and what is being made of it (`encode/CoverageMap.js`); what viewers want of it (`encode/SegmentDemand.js`); how many encoders there should be and where each belongs (`encode/EncodePlan.js`); one running encoder with the stretch it was given (`encode/EncodeRun.js`); and the layer tying them together (`orchestrators/EncodeOrchestrator.js`). A run is given a start AND an end, which no run has ever had — neither `-to` nor `-t` appears anywhere in the arguments this proxy builds — so two encoders can work on one output without writing over each other. A run that meets material somebody else has already made is moved forward to the first gap rather than killed, and is left to drive through instead when the covered stretch divided by that run's own measured speed comes out cheaper than a restart. Not yet wired to the session manager: nothing calls it yet.
90
- - **New**: Every start and every end of an encoder is recorded with its cause, and endings are counted by cause over the life of the process. Exactly one ending is normal — a run reached the end of the stretch it was given and exited by itself — and our own kill is not one of them, because hiding it among the normal endings is what would make the count useless.
91
- - **New**: One store of produced segments for the whole proxy, addressed by what the segments ARE rather than by which session's encoder wrote them (`encode/SegmentStore.js`). A segment used to live under the random id of its session, so two viewers of one film could not see each other's work even when their output was identical byte for byte. Two sessions of one output now write into one directory and each serves what the other has made; the run number belongs to the output too, or they would both claim `run-1`. The start position is deliberately not part of the address: segment 42 covers the same span whoever began where. A directory is removed when the last session on that output goes, not when the first one does.
92
- - **New**: What a killed process left on the disk is taken back at startup instead of being abandoned. The kernel kills this proxy often enough for that to be an ordinary state — twice in one viewing on 2026-09-02 — and when it does, no exit handler runs and nothing is cleared up: memory is reclaimed but the files are not, `/tmp` on the addon host being the overlay filesystem rather than a memory one (measured 2026-09-04). Each directory now says which output it holds, so a new process can tell what it is looking at; segments whose closure is proven are kept, because a copied segment's bytes depend only on the source and re-encoding them costs the one thing the machine is short of; the single piece per output that was being written when the process died is removed. The sweep says what it found before it decides anything — for an encoder killed with the process, that line is the only record that it ended at all.
93
- - **New**: A run is given a stretch — from a number and TO one — and every run of an output writes into one flat directory. Runs used to be kept apart by a directory each (`run-1`, `run-2`, …), because two of them writing one segment name at the same time produce a file belonging to neither; that was also the only reason a restart ever had to wait for its predecessor to die. The stretch removes the collision by construction: a run starts at the first number nobody has and nobody is making, and stops before the next number somebody does, so no two runs can want the same file. A run asked to start inside material that already exists is moved forward to the first gap instead, and where there is no gap ahead no encoder is started at all — which is what three ffmpeg processes making one identical picture cost on a CM4 in August. The `run-*` reading survives only to serve what an older version left on the disk.
94
- - **New**: Which argument states a run's end is a property of the output branch, and it is measured rather than reasoned (`research/encoder-layer-2026-09-04.md` §11): `-t` is a duration on the output's own clock and `-to` a point on the input's, so the re-encode branch takes the duration and the copy branch — which runs with `-copyts`, where the input's clock is the source's — takes the absolute time. Swapping them is not a near miss: on the copy branch `-t` produced one segment where five were wanted, because the time it names is already past when the run starts.
95
- - **Fix**: What an output is encoded AS is decided once, for the output (`services/output/Output.js`). The box in pixels, the frame rate, the speed setting and the tone map were decided per SESSION, by the realtime budget, from what the machine could hold at that moment — so two sessions of one output made minutes apart could be given different pictures while claiming the same identity, and everything downstream assumes they cannot be: a segment of one is supposed to stand in for a segment of the other, and the master names one `RESOLUTION` for both. What the budget learns afterwards still moves the rate cap, which stays with the run for the reason it always did — rate control appears in neither the SPS nor the PPS, so it can move under a player that has cached the init, and the size cannot.
96
- - **New**: Where a file is cut is held once, by the file, instead of once per session (`services/output/Timeline.js`). Every quality step of one film must be cut at exactly the same times and every session of it must publish the same playlist — and that agreement was arranged by COPYING a table into each new session at creation. Copying is a thing somebody has to remember to do, and it drifted twice in the field: 0.6-2.9 s between two sessions of one film on 2026-08-17, and segments arriving a uniform 2.002 s before the times the playlist named for them on 2026-08-20, four times what a player bridges. Sessions hold the table itself now, so a correction found by one — a produced segment showing where the cut really is — is written once and seen by all, including sessions created afterwards. The two tables stay two things: what a run cuts at moves as the file is learned, what the player was told never does.
97
- - **Fix**: An encoder handed to the plan from outside is released when it stops. A run the plan builds reports its own ending; a session whose encoder stopped makes no such promise, so its claim on a stretch would have sat in the coverage map for the life of the process, telling the plan that numbers nobody is making are being made — and nothing would ever have been started there again. Found reviewing the wiring, before it ran anywhere. Counted under an ending of its own, because "nobody watched it end" has to be distinguishable from the endings that are meant to happen.
98
- - **New**: The plan decides the encoders, and it is asked of the sessions that exist. Three rules written into the session manager become one: where a run belongs, when it has been overtaken, and how many the machine affords. Every session on an output is handed to it as the run it is (`encode/SessionRun.js`), what each viewer is waiting for reaches it as a span with no name on it, and what the store already holds reaches it as coverage — so a second encoder is never started over numbers somebody is already making, and one making material nobody asked for is stopped. The bound on concurrent runs is measured rather than chosen: at 854x480 on the addon host one run makes 7.12x and two make 4.20x and 4.16x, so a second fits; at 1920x1080 one makes 1.96x and two make 0.99x, so it does not. Where what a second job costs has NOT been measured on this machine, the answer is one — an unmeasured penalty of 1 says nothing has been measured, not that a second encoder is free.
99
- - **Fix**: A viewer jumping backwards no longer takes the picture away from one watching ahead. Two viewers who opened a film together share a session, a session holds one encoder, and repositioning it is repositioning theirs — so a jump of an hour stopped the other viewer's segments being made. The seek is not refused; it is given a run of its own at that position, which costs nothing extra now that segments are addressed by the output rather than by the session: both runs write into one directory and either viewer is served whatever either run has made. Only where it would actually cost somebody — a jump BACKWARDS, past a live viewer's own head, on a run that would have to restart. Alone, or forward, or into what the run already covers, nothing changes.
100
- - **Chore**: A viewer is an object (`services/viewer/Viewer.js`), not six maps hung on the session and keyed by consumer id. What they are listening to and whether their browser needs it re-encoded, the quality step on their screen, the step and the track being warmed for them, where they are, what their link carries: six places to remember to update and six to remember to forget. The forgetting was already wrong — releasing a consumer emptied none of them, so a viewer who had left went on counting as wanting their soundtrack until their head expired, up to two minutes of an encoder nobody was listening to, and their entries stayed for the life of the session. One object, one deletion.
101
- - **Fix**: A run with no end no longer claims the whole film. Bounding runs by their stretches introduced it: the first viewer's run, started on an empty output, was taken to own every segment to the last, so a second viewer opening the same film further on found every number claimed and got no encoder at all — they would have waited for the first run to encode its way there. A run's claim reaches as far as it will actually get, which is its head plus the look-ahead, because past that point it is suspended and produces nothing until somebody asks. And a run that walks into a stretch another run was expressly given stops there, since its own end was set from the gaps of the moment it began and a viewer who arrived afterwards was not in that picture.
102
- - **Fix**: What produced segments cost is no longer decided by anybody's departure. Removing them when the last session on an output ended was still the session deciding, and a session ending says nothing about whether the material will be wanted: the viewer who closed the tab may come back, and a viewer who never had a session here may open the same film a minute later and find the work done. What decides now is when the output was last READ, and how much room the disk has — a share of what is FREE at that moment, re-read on every sweep for the same reason the piece store re-derives its memory allowance, since a Home Assistant install often runs from a 32 GB card carrying everything else in the house. Over the allowance, what was read longest ago goes first.
103
- - **Chore**: Dead code removed, found with `knip`, now configured for this package. Four folders carried an index file that nothing imported at all — `container/`, `controllers/`, `download/`, `orchestrators/` — and three more re-exported names nobody took. Gone with them: `containerAudioTracksOf` and `forgetContainerTracks`, `anythingUrgentIsMissing` and `liveDemand`, the `OperationTimer` class and the import only it used, and two re-exports of figures nothing reads. What is left of the report is names used inside their own file and exported as well, which is not dead code.
104
- - **Fix**: A session belongs to the tracks its output actually carries, and its key holds those tracks' parameters and nothing else. Measured 2026-09-03: two viewers of one COPIED picture got two sessions with descriptions identical word for word and output identical byte for byte — the same `segment-00000.mp4` of 4141899 bytes — four encoder processes for one film, the look-ahead paid twice at ~228 MB each, and the piece store at its ceiling with twelve readers where one viewer gives three. The key forked on the soundtrack number, carried by a session whose output is `video-only` and contains no audio at all. Three fields are now conditional: the soundtrack and whether it is re-encoded appear only where the output carries sound, and the target box only where the picture is re-encoded — a copy is the source's own size whatever box was asked for. The soundtrack is also named precisely: the file the track lives in and the `0:a:N` inside it, not the flat number the browser sends, which spans the picture's own tracks and the files beside it and so means different things for different pictures of one torrent. Reuse between viewers is not a feature built on top of this: it is what a correctly built key already means.
105
- - **New**: The identity of an output is a thing with a name (`services/output/OutputSpec.js`) instead of a `join(":")` inside a 300-line function with no test and nothing printing it. Twelve checks state what may and may not tell two outputs apart.
106
- - **New**: The key is printed — at creation, and when a second viewer joins an existing session, which said nothing at all before. A fork was undiagnosable: both create requests of 2026-09-03 were 265 bytes and nothing anywhere recorded what the two had been told apart by.
107
- - **Fix**: Whether the sound travels separately is decided BEFORE the key and never derived again. It used to be settled after the session had already been filed, which was survivable only while the key carried the audio parameters unconditionally — with them conditional, a key saying "no sound in this output" over an output that muxes it would have let two viewers who chose different languages share one encode, and one of them would have heard the other's.
108
- - **Fix**: A picture whose sound is published separately no longer opens a second input on the soundtrack's file, nor holds that file against the disk sweep for the session's life. It maps no audio at all (`-an`).
109
- - **Fix**: Everything about the sound that used to be a field of the session is a fact about a VIEWER, because two viewers of one picture can want different things of it. Which soundtrack each is listening to, and whether their browser needs it re-encoded: a segment request from one used to read as everybody moving to that track, so with two viewers they would have switched each other's encoder off in turn, once per segment, for the whole film. A rendition is filed under the track AND how it is produced — a browser that can decode it as it stands gets a copy, one that cannot gets AAC — so the first viewer's codec support no longer decides for the second. The master marks the ASKING viewer's own track as the default one. A track is stopped when NO live viewer is listening to it, and "live" is the same head-expiry the encoder's steering already uses, so a tab that closed without releasing the session cannot hold an encoder for ever.
110
- - **Fix**: The quality step each viewer has on screen is theirs, and the consumer id is out of the session key. A step is a session of its own, so with one answer per session a step taken by one viewer stopped the other's stream and forwarded that viewer's next seek to a rung they never chose — which is exactly why the key carried the viewer at all. The step being left is stopped only when nobody is left on it; a step anybody is watching is never withdrawn from the offer; a link report and a progress poll are answered from the reporter's own step; a seek moves the seeking viewer's own soundtrack and nobody else's.
111
- - **New**: Where a file's keyframes are is a fact of the FILE. It is a property of immutable bytes, like the duration and the track list, and it is read once — by the same container that already answers those two, over the torrent instead of over this proxy's own HTTP. The wait belongs to the file as well: two sessions created in the same moment join one read instead of making two, which is what two viewers opening one film do (measured 13 ms apart). The answer decides whether a picture can be copied at all, so it has to be one answer, and it now is.
112
- - **Chore**: A container reads its own keyframe table once. `Container.readKeyframeIndex` memoizes and `parseKeyframeIndex` is what each format implements — the same split `readMediaInfo` already had. A read that threw is not remembered as an answer: the bytes it needed may simply not have arrived yet.
113
- - **Chore**: The session's shared position field is called what it is. `viewerPositionSeconds` was one number for a thing a session has as many of as it has viewers; it is `furthestViewerSeconds` now — the figure an encode run is placed by, because what lies behind the furthest viewer has already been made — and every reading that is about ONE viewer takes their own head instead, including the refusal line, which used to explain a refusal by somebody else's position. A head now remembers whether its viewer STATED that position or merely asked for a segment there, which is the distinction a cold open's soundtrack placement turns on.
114
- - **Fix**: One rule decides which file beside the picture belongs to it, and the proxy is where it lives. It was implemented twice — here by comparing base names, in the browser by accepting any name that BEGINS with the video's — and the two answers were compared nowhere. Measured over the 115 real torrents in `Dropbox/trn`: of 1249 video files the two pairings agreed on 1239 and differed on 10, every one of the ten a `<video base>.<language>.ass` name, which is the shape Plex, Jellyfin, Kodi, Bazarr and OpenSubtitles all produce. The difference reached the viewer, because this side warms what IT paired while the browser offered what IT paired: a subtitle track offered but never warmed waits for its first piece off the swarm, 27.7 s in the field measurement of 2026-08-31. The name grammar — the union of what those five players implement, and until now the browser's alone — moved here with its checks, and the pairing asks it: a name pairs when it continues the video's at a token boundary and the remainder reads as languages, flags and title text. The boundary is what makes that safe, and a plain "begins with" is not the same rule — without it `Film.20.rus.srt` pairs with `Film.2.mkv`, measured, and the grammar then reports the leftover `0` as the track's title, which is the tell. Re-measured after the change: 1249 of 1249 agree. The plan now carries the paired subtitle files and the images beside the picture, each with what its own path says, so the browser reads no name at all.
115
- - **New**: A still image shipped beside a video is recognised and paired with it — a contact sheet of frames, a cover, a poster. Two things a viewer can see follow from it: a pack of a hundred videos is unusable as a list of release names and many such packs ship one sheet per video, so the sheets can be the picker; and a film with a cover beside it has something to show while its first frame is being made. Paired more strictly than sound and subtitles: the one-video relaxation does not reach images, since a torrent's stray screenshot is not this film's cover, and the sheet's own naming shape is read — `Movie.mp4.jpg` beside `Movie.mp4`, verified on a pack of 106 videos with 106 sheets, which no base-name rule would match.
116
- - **Chore**: Which files of a torrent belong together is the torrent's own statement, and it lives in `services/torrent/` now. A container knows only itself and a track only its container; "the file next to this one" is a notion that exists only where there is a list of files.
117
- - **Chore**: Which layer may import which is a lint rule now, not a thing to remember. Nine folders were already right — `output/`, `demand/` and `viewer/` import nothing at all, `container/` only `tracks/` — and nothing held them there: the config checked five things and none of them was about imports. Each folder now states what it may not reach for, with the reason in the message, so an accidental edge fails in the editor rather than being found by a reviewer. Two files moved to make the rule need no exceptions: the run's state table into `encode/`, where its only two readers are, and language detection into `tracks/`, since what it detects is a subtitle track's own language. A layer that wants a logger takes one as a parameter, which is what `EncodeOrchestrator` already did.
118
- - **Chore**: The full argument list for one encoder run is stated where the arguments live (`services/encode/run-command.js`), not as a 377-line method of the session manager reading fifteen of its fields. A run is a fact about WHAT is being produced and WHERE it begins, and about nothing else — so stated over the material and the stretch alone it can be built by whoever needs one, which is what lets an output have more than a single encoder. Six pure functions moved with it, each the answer to a question about a run rather than about a session: how a number of seconds is written for ffmpeg, which timeline the output is labelled on, where the player was told a segment begins, where the cuts are rebased to, which keyframe a seek snaps to and how much later than it to ask. The three things inside that method which were not command building — resuming a suspended predecessor, killing a superseded process, warning about a stale init — moved to the one place a run is started. Proved rather than reviewed: the manager as it stood before and the working tree built arguments over 4096 combinations of the twelve axes that change a branch — copy or re-encode of picture and sound, audio-only, a soundtrack published separately, either cut grid, a second input, a keyframe table or none, either start, an overridden position, an end or none, a non-zero container start, a rate cap — and all 20480 comparisons came out identical. The comparison was then shown to have teeth: one character changed in the seek-landing constant produced 768 disagreements.
119
-
120
-
121
- ## 2.75.0
122
-
123
- - **New**: The picture's facts are read from BOTH readings that state them, which audio and subtitles have done since their flags were first read from the file and video never did. Every figure the encode is planned from — the size, the frame rate, whether it is HDR, how many bits a sample carries — came from ffmpeg's `-i` banner alone, while the `VideoTrack` the container declares was read and then used for nothing but a line in the log. Which reading answers is decided per field by what each one IS, and the rule is the one `readMediaInfo` already states: the coded size and the frame rate are the BANNER's, because what the encoder receives is what the decoder produced and the ladder and the scale filter must be sized to that; the bit depth and the HDR signalling are the CONTAINER's where it states them, because they are not properties of the decoded frames at all but the file saying how its samples are to be read, and ffmpeg prints them only as a side effect of naming a pixel format; the display size is the container's alone, the banner having no such field. Where the two disagree on something both state, the disagreement is now reported — it is a fact about the file and nothing could see it before. HDR is deliberately not compared: both sides give a boolean, and a boolean cannot say "I did not look", so a container with no Colour element and one stating SDR are the same `false`. That was found by the test, not by reading.
124
- - **Chore**: One reconciliation of ffmpeg's banner with a container's own table, instead of two. The rule is the same for every media kind — align by position, check each pair on language or title, and drop the container reading WHOLE if one pair agrees on neither, because a wrong flag is worse than a missing one — and it was written twice, down to a `pairingHolds` that was byte-for-byte the same function under two names. `Container.alignWithBanner` is the alignment; `mergeSubtitleFlags` and `mergeAudioFlags` are the two flag lists, both on the container, because what they correct is what the container declares.
125
- - **Chore**: Keyframe reading moved into the containers, and the third place that decided what a file is went with it. `container-index/matroska.js`, `mp4.js` and `avi.js` were each a specification speaking about its own container — the Cues table, `stss`/`stts`/`ctts` and the edit list, `idx1` and its keyframe flag — reachable only through that container's `readKeyframeIndex`. `container-index/index.js` sniffed the header a third time to pick between them; that is `ContainerFactory.readKeyframeIndex` now, over the factory's own sniff. `services/container-index/` is gone; the generic EBML element walker moved beside its only user.
126
- - **Fix**: Folding the MP4 readers together put two `findMoov` implementations side by side, and they were not the same: one guarded against a box declaring size 0 — legal for the last box of a file, and an infinite loop for anything stepping over it — and the other did not. The guarded one is what survives, which is a defect removed rather than a tidy-up.
127
- - **Chore**: The codec-name table is `AudioTrack`'s. Which name ffmpeg gives `A_AAC`, `ac-3` or a bare `.dts` is a property of the codec, and it decides whether the browser can play a soundtrack at all — the same shape as the markup table that moved to `TextSubtitleTrack` in 2.74.0.
128
-
129
- ## 2.74.1
130
-
131
- - **Chore**: The subtitle path chose a container twice, from two different kinds of evidence, and the two had to agree. Once by file extension, to decide which class to build; once by whether a track carried a sample list, to decide how to read its cues. Nothing enforced the agreement — a `.mkv` whose track somehow carried samples would have called a method Matroska does not have. There is one choice now and it is made from the BYTES: `ContainerFactory` sniffs the header, because that is what the muxer wrote while a name is what somebody typed, and only where the head is not downloaded — the cue walk asks the swarm for nothing — does the name answer instead. `Container.readHeldCues(plan, track, progress)` is then the one question: Matroska walks the clusters its Cues table names and fills every track from one walk, an MP4 reads the samples its own table states, and the caller chooses between them nowhere.
132
- - **Chore**: A subtitle plan has one shape whichever container answers it. `Mp4Container` used to hand back its own raw table for the caller to reshape, inside the branch that has now gone; it returns the common shape itself, and says in its own words why `declared` is empty for an MP4 — the format has no element meaning "show this subtitle track by default", so nothing is shown unasked.
133
- - **Chore**: `ContainerFactory.create` passes its parameters through whole, so a caller with a torrent's two readers gets a container that has both instead of one built with the fetching reader only.
134
-
135
- ## 2.74.0
136
-
137
- - **Chore**: Everything about subtitles is now in the classes that own it, and six loose modules are gone. `matroska-subtitles.js`, `matroska-blocks.js` and `mp4-subtitles.js` were a container's own specification written outside the container: the track table, the Cues, the blocks inside a cluster, the sample table. They are `MatroskaContainer` and `Mp4Container` now. `subtitle-markup.js` and the cue half of `subtitle-convert.js` were a codec's own rules written outside the track — which markup a codec carries, how to take it off, how to write the cues out as WebVTT — and are `TextSubtitleTrack`. Reading a subtitle FILE is `SubtitleFileContainer`, and reconciling ffmpeg's banner with a container's own table (`subtitle-defaults.js`) is `Container`. Folding them in exposed the duplication that having two homes had allowed: one Matroska helper and three MP4 box helpers existed twice over, with different parameter names and the same behaviour, and twenty-two element-id constants were declared in both places — checked pair by pair for disagreement before the second copy was dropped, and there was none.
138
- - **Chore**: The cluster walk moved with them, and the design question that had held it back is answered. Two readers of one file want opposite policies — the track table fetches what is missing, the cue walk reads only what is already downloaded so that turning subtitles on pulls nothing — and one container held one reader. A container now takes both, plus a predicate saying whether a range can be read without fetching, so one instance per file serves both and the per-file cache is kept. The container is NOT given the torrent: those three functions are the whole of what the caller knows and the container does not, and reducing it to them is what let the reading move to where the format is specified. `torrent-worker/subtitle-cues.js` supplies them and keeps what is its own — the found-order cursor, the per-file state, one walk at a time.
139
- - **Chore**: The walk had no check of its own, which is why this began with writing one. `test/subtitle-cue-walk.test.js` builds a Matroska file with two text tracks and two clusters carrying real blocks, and states exactly what comes back: each cue's text and times, the found-order cursor, that an undownloaded cluster is left alone, that one walk fills every track, and that a torrent which cannot say what it holds is refused rather than answered emptily. It passed on the unmodified code first and is unchanged by the move — which is the only reason the move can be called safe. It also caught an error in its own fixture: Matroska writes eight fields before an ASS cue's text and the fixture had seven.
140
-
141
- - **Fix**: The verdict that declares a wedged connection now counts the far end's own event-loop delay, which the browser has been reporting all along and nothing read. Field 2026-09-03, session `03f211b8`: the viewer paused at 15:24:21 with 121.5 s buffered, the tab went hidden at 15:24:35, and its loop fell behind — `loopLag` 681 → 1881 → 4297 → 5957 ms. At 15:26:05 the probes read `gap 12 of 11`, printed `association-stopped` and then `reverse-direction-gone`, kept four ring files (61 MB) and took a 180 s capture; seven seconds later the same connection read `flowing`. Nothing had stopped — a browser whose timers are frozen cannot answer a probe. The delay is a wait the answer has to fit inside, so it is a term in the allowance and in the bound on a stale echo, alongside the queue's drain time, the round trip and the peer's own echo cadence. It is measured on the peer and arrives on the direction that survives a freeze, so it is known BEFORE the silence — unlike the cadence, which can only grow after a late echo has already landed, and so was always too late for the first one. The line prints `peerLoopLag=` and `peerTab=` beside the verdict, because an allowance nobody can check is not arithmetic. Four false verdicts on 2026-09-03 alone, each costing kept evidence and a capture that the evidence for a real wedge has to fit beside.
142
- - **New**: A forced subtitle track in an MP4 is read as forced. Apple's QuickTime File Format defines two bits of the subtitle sample entry's `displayFlags`: `0x40000000` "Some samples are forced" — at least one cue carries a forced (`frcd`) atom — and `0x80000000` "All samples are forced", which the handler treats as forced whatever the cues carry, and which requires the first to be set with it (`0xC0000000`). Either bit alone now marks the track forced: a file that sets only the second is saying what a well-formed one says twice. The Matroska side has had this since 2.57.1; MP4 had `isForced: false` written in.
143
- - **New**: One statement of what a session has produced (`services/produced-index.js`). Three places asked and each answered for itself — the look-ahead counted numbers, the serving path looked for a file, the header derivation listed names — and on 2026-09-03 two of them held opposite beliefs about one file for ten minutes, which is what stopped playback for ever in 2.72.0. The definition is now in one place and they all ask it. It is also what those three cost: every one of them walked every run directory on the thread that carries the data channel, 1350 files for a 90-minute film, on every segment request. A directory's modification time changes when an entry is added or removed, so the index asks THAT of each run and re-reads only what moved — a quiet request costs one `stat` per run instead of a listing plus a `stat` per file, and the class counts its own listings so the claim can be checked rather than believed. Newest run still wins, and that rule is applied when the question is asked rather than when a directory is read, so re-reading an older run cannot overwrite a newer one's answer. Eight checks in `test/produced-index.test.js`, each built on real directories and real files.
144
-
145
- ## 2.73.1
146
-
147
- - **Fix**: The catch-up pull for embedded subtitles is answered by the thread that owns the torrent, so a viewer no longer loses the beginning of an episode. Field 2026-09-03 on `[HorribleSubs] Drifters - 04 [1080p].mkv`: the file had been downloaded in an earlier sitting, so the worker's cluster walk found cues 1.5 s after it was opened and pushed four batches — cursor 1 to 31, everything up to 81.7 s — before the browser had subscribed. The browser then did the pull that exists for exactly that case and got a seven-byte `WEBVTT` with `x-subtitle-covered-clusters: 0` against 283 indexed; it held 330 cues spanning 81.7-1310.2 s, which is precisely the first push it saw. The parsing was never at fault — the same file put through this proxy's own reader produces clean English from 5.4 s onward — the pull was simply running on the MAIN thread, where the torrent is a stand-in with no `bitfield` and no `pieceLength`, so every range read as "not downloaded" and nothing was walked. It goes to the worker now, over the command that already existed and had no caller. That also settles a second thing: the found-order cursor the browser follows comes from ONE register, so a cursor from a pull and a cursor from a push are comparable, which two separate walks could not guarantee.
148
- - **Fix**: A walk asked of a torrent that cannot say which pieces it holds says so in the log instead of returning an empty document. Emptiness is the right answer for a file with no cues yet, and that is how the defect above stayed invisible for a whole session.
149
-
150
- ## 2.73.0
151
-
152
- - **New**: `Container.readMediaInfo()` — what a file declares about ITSELF: its format, its duration, and where its own timeline begins. Matroska reads Duration and TimestampScale from `Info` and takes the start from the first Cluster's timestamp (RFC 9559 states no start-time element, so that IS the start); MP4 reads `mvhd` and the empty edit of `elst` (ISO/IEC 14496-12 §8.6.6); AVI multiplies microseconds per frame by the frame count. `null` means the container does not declare the field, which is a final answer rather than "ask someone else".
153
- - **Fix**: Session creation no longer spends eight seconds learning where a soundtrack shipped as its own file begins. It spawned an ffmpeg against this proxy's own `/stream` and waited out the probe's whole 8 s budget — measured in the field on 2026-09-03 at 8078 ms, 8112 ms and 8121 ms, three cold starts out of three. The container layer had read the same header of the same file **in 8 ms, in the same second**: two readers of one header, a thousandfold apart. Worse, the eight seconds bought nothing — the probe's early exit is gated on a parsed DURATION, and a partially downloaded file prints `Duration: N/A` with the start time on that very line, so the answer was in hand from the first second. The value now comes from the container reading, and creating a session does not wait for it at all: what is known is used, the reading runs behind, and the encode run takes the freshest value at spawn — the same shape `session.keyframeTimes` already had. Where the file has not said, the two timelines are taken to AGREE rather than assumed to differ, because asserting an offset nobody has read is inventing a number.
154
- - **Chore**: Checked against ffmpeg by hand rather than assumed. A Matroska file offset by 0.130435 s: ffmpeg reported `Duration: 00:00:02.13, start: 0.130000`, this reader `durationSeconds 2.131, startTimeSeconds 0.13`. The same content as MP4: `start: 0.000000` against 0. Six checks in `test/container-media-info.test.js` build their fixtures byte by byte instead of running an encoder, so the suite measures the parsing and not the machine.
155
- - **New**: `/progress` carries `inputBytes` — the bytes the swarm has delivered to THIS session's own input read. It is the only sign of life a session can give before its first frame exists, because `processedSeconds` cannot move until the decoder has one. Field 2026-09-03: a cold retry blocked 46.3 s on a single piece with `processedSeconds` frozen at the start position throughout, and the browser declared the proxy dead 0.4 s before the piece landed. Counted per SESSION and never per torrent — in that same episode the torrent received 55.9 MB while the picture's own reads received 4.5 MB of it, so a torrent-wide figure would have called a starved session healthy.
156
- - **New**: `POST /api/sources/:key/warm` accepts `positionSeconds` and fetches the region under it. The warm-up fetched a file's two EDGES, because that is what the codec probe reads; the region the viewer will actually resume at was asked for by nobody until the encoder opened its input — 53 s after the Retry button in the field case, and it then took another 46 s. Where that region lies is worked out from the file's own length and duration, which is the first thing `readMediaInfo` is used for beyond its own purpose. Started after the edges and never awaited by them: the edges gate the playback plan.
157
- - **Fix**: A blocked reader's tail is duplicated on every attempt, not only on the attempts where no ordinary request could be placed. A read ends when its LAST block arrives, and that block is reserved to one wire whether or not other blocks are still free to ask for. Field 2026-09-03: 46.3 s on one piece, ordinary requests placed on 54 of 87 attempts, a tail of 3 blocks of 512 held by wires at 51-99 KB/s to the very end, and duplication — which ran only on the attempts that placed nothing — managed 5 blocks in the whole wait. Bounded by the tail's own length: 16 blocks (256 KB) against a 4-16 MB piece, above which the piece is still arriving normally and a second copy would spend the shared link on bytes already on their way.
158
- - **Fix**: One wire refusing a duplicate no longer ends the pass. Pipelines are per wire; stopping at the first refusal rested on the stated reasoning that the remaining wires were "no emptier", which is an assumption about other peers' queues that nothing here measures.
159
- - **Chore**: `docs/container-architecture.md` states the boundary outright — a fact the container declares is read from the container, a fact only the media has is measured from the media — with the three jobs that stay ffmpeg's and the measurements behind each. It also records why byte access does not belong on a track, and names the one thing that is genuinely split (a subtitle track carries byte positions while the reading of them lives in the worker) together with the obstacle to closing it: the track table fetches what is missing while the cue walk deliberately reads only what is downloaded, and one container instance holds one reader.
160
-
161
- ## 2.72.2
162
-
163
- - **Fix**: A claim for room in the piece store could never end. Two waits inside one function shared a single field for when the wait began — the wait for the disk, and the wait for a piece that may be evicted — and each of them zeroed that field on giving up, which restarted the other's clock. Measured 2026-09-03: the claim cycled for ever, five seconds per side, `grewWaitingForDisk` and `waitedForPins` each climbing once every five seconds while `blockedByPins` stayed at 0, so the refusal the store is supposed to end with was unreachable. A claim's patience is now held by the claim itself and not by the store, which is also correct with several claimants: one caller giving up used to reset the wait of every other. A claim that cannot be met now fails after 5 s — measured 5025 ms — with `Every resident piece is pinned and nothing moved`.
164
- - **Chore**: The whole suite can finish again. `test/piece-store-reservations.test.js` did not fail on the defect above, it HUNG, so `node --test` never completed at all and no release since 2.72.0 had been covered by a full run. The three checks that drive a held disk write now carry a 30 s bound, so a return of this defect is a failure in seconds rather than a stoppage.
165
- - **Chore**: Two further checks in that file were failing and nobody could see it, because the file stopped before reaching them. Measured on the unmodified code, both fail there identically, so neither is from this release. One asked whether the claim was waiting by reading `waitedForPins` alone, while the store was demonstrably waiting for the disk and counting `waitedForDisk`; it now accepts either. The other gave the store three blocks and then required a piece to be admitted while a block was in flight, which is a full store by the store's own accounting — it has four now, so what the check measures is what it was written for: a completing spill must not put the stale copy back on disk.
166
-
167
- ## 2.72.1
168
-
169
- - **Fix**: Subtitles on an embedded ASS track were shown to the viewer as the whole dialogue row — `21,0,Default,,0000,0000,0000,,I am the powerful Demon King of the Sixth Heaven.` — because one function both unwrapped the container's framing and stripped the codec's markup, and decided which framing it held by counting commas. Matroska writes eight fields before the text and takes the two timing fields out into the block's own timestamp (`matroska.org/technical/subtitles.html`); a row in a `.ass` FILE has nine and states its own column order in `[Events]`. The function expected the file's shape, found nine fields where it wanted more than nine, and returned the row untouched. The test that covered this passed because its fixture carried the file's two timestamps — a shape that never occurs on that path.
170
- - **New**: `Container.cueTextOf` — each container answers for its own framing, because how a cue's bytes are wrapped is stated by the container's specification and not by the subtitle format's. `MatroskaContainer` carries the eight-field rule with the quotation it comes from, `Mp4Container` the length-prefixed sample, and `AviContainer` throws rather than guessing at somebody else's framing. The unwrapping now happens where the cue is READ, which is the only place the container is known.
171
- - **New**: `SubtitleFileContainer` — a file of subtitles is a container of its own. It reads the `Format:` line of `[Events]` for ASS, which is where a FILE states its field order, and SubRip's positional form; `.vtt` is passed through unparsed, since taking it apart to write it back would drop its styles, regions and cue identifiers for nothing.
172
- - **New**: `services/tracks/subtitle-markup.js` — the other axis: `{\pos(…)}`, `\N`, `\n`, `\h`, the same wherever ASS is stored. One table for every name a codec has — Matroska CodecID, MP4 sample entry, file extension — because the answer for all of them is the same. `\h` reached the viewer as a backslash and an h on embedded tracks until now; the file path had always handled it.
173
- - **Chore**: `finalizeCues` and the WebVTT writing move to `subtitle-convert.js` and exist once. The writer was duplicated between the controller and the file conversion, so a pushed cue and a pulled one were formatted by two different pieces of code.
174
- - **Chore**: `test/subtitle-cue-framing.test.js`, 17 checks across both axes; three of them fail on the old code, including the field case itself and "one line of dialogue, two framings, one result". Measurements and the spec quotations: `research/subtitle-cue-framing-2026-09-03.md`.
175
-
176
- - **Fix**: A file's NAME was being read as proof that a segment exists. The `segment` muxer creates its output the moment it OPENS it, so a run killed for a seek leaves a file of zero bytes behind whose name is indistinguishable from a finished piece's — measured 2026-09-03: ffmpeg exited 19 ms after SIGTERM leaving `segment-00025.mp4` empty, and that empty file closed the only hole in the numbering. The two sides of the session then deadlocked on it without either being able to see the other's reason: the look-ahead read `420s ahead of the viewer` and kept the encoder stopped because the segment was on disk, while the serving path refused the very same file for carrying no track. Both sides now ask `usableSegmentIndices`, where a number counts only when some run holds a NON-EMPTY copy of it — which is exactly the condition under which the serving path can answer.
177
- - **Fix**: The piece a run had open when it ended is removed once that run's process is gone, and only when it is unusable — a run stopped between two cuts leaves a finished file, and deleting good output would mean encoding it a second time. The current run's own unfinished piece is waited for, never deleted.
178
- - **Fix**: A segment is served from the newest copy that carries every track, across every run the session has had, instead of from whichever run wrote the name last. Where nothing can judge the copies — before the session has an init segment there is nothing to compare a piece against — the newest copy with bytes in it stands, and a piece that no run holds servably is still handed back so the readiness path can say WHY rather than answering "not produced".
179
- - **Fix**: Two more places asked a file's name where they meant its contents. That a segment is finished is proved by the NEXT one existing, and the proof was taken from whichever run happened to hold that number — a run that has ended closed everything it wrote and needs no such proof, so the evidence now has to come from the same run. And the session's header is derived from the first piece that has bytes in it rather than the first name, since an empty file skips a number whose header is sitting in the run before it.
180
- - **Chore**: Segment sizes are asked of the filesystem once per file. A piece that has bytes never loses them and a run rewriting a number writes into its own directory, so without that memory every request walked every segment of every run — 1350 of them for a 90-minute film — on the thread that also carries the data channel.
181
- - **Chore**: `test/produced-copy-choice.test.js`, 9 checks: an empty file is not counted by the look-ahead, a killed run leaves nothing behind, a run that finished its last piece keeps it, a last piece short of a track goes even though it has bytes, and the sizes are read once.
182
-
183
- ## 2.72.0
184
-
185
- - **Fix**: The proxy was killed by the machine's out-of-memory killer at 4.37 GB, twenty minutes after 2.71.0 went out, and the cause was 2.71.0's own budget. A piece being written out to disk leaves the store's count of what it holds the moment the eviction begins, while its memory stays held until the write — which reads from that very block — has finished. The allowance counted resident pieces, so a block held by a pending write was counted nowhere: every admission turned one resident block into one held by the disk and took a fresh block for the arrival, and memory in use rose by one block per admission for as long as the disk was behind. It was behind by a factor of two: 233 evictions started against about 119 writes completed in the same minute. The store reported 203 blocks held with THREE pieces resident against 68 MB allowed. The allowance now bounds blocks in use — resident, reserved, and held by writes that have not finished — so a full store waits for the disk instead of evicting another piece, using the wait and the wake that were already there.
186
- - **Fix**: And the reason there were so many evictions: 2.71.0 made the allowance equal to what the readers ask for, exactly. `6 reader(s) want 23 piece(s) of 23 the store may hold` — no free place ever exists, so every arriving piece must evict a wanted one. The allowance now includes room for what arrives while one write is finishing, measured from the store's own median write duration and its own arrival rate, and zero until both have been seen rather than invented in advance.
187
- - **Chore**: `test/piece-store-slow-disk.test.js` drives a disk that answers only when the test says so, which is the field condition — writes slower than arrivals — and it fails without the fix. The check that shipped with the first attempt at this did not: it passed with the defect in place, which is worth recording, because a test that cannot fail proves nothing.
188
-
189
- - **Fix**: A proxy reported how much memory it had free with `os.freemem()`, and on Linux that counts only the pages free at this instant — the kernel keeps that number low on purpose and fills the rest with cache, which it hands back the moment anything asks. A host with 4 GB of cache and 200 MB genuinely free called itself nearly full while it had 4.2 GB to give. That figure weighs 0.4 of every proxy's score, so every Linux proxy in the pool understated itself, each by a different amount according to how much cache it happened to hold. It reads `MemAvailable` now — the same fix the piece store's budget got on 2026-08-27, which had stayed in this file until today.
190
- - **New**: A proxy can answer whether it could sustain a file it is only told ABOUT. The expensive half of that question is finding out what the file IS — add the torrent, wait for metadata, fetch the header, run ffmpeg — and it has already been paid by whichever proxy probed it. Its answer is a handful of numbers; every other proxy answers by arithmetic against its own startup benchmarks in milliseconds, without adding the torrent or fetching a byte. Asked over the tunnel as `can-serve-request`.
191
- - **New**: The refusal added in 2.71.1 now carries that description, so a viewer whose proxy cannot keep up is moved to one that can instead of being shown an error. A viewer is given a proxy BEFORE the file is known, by a score that reads processor load, free memory and round-trip time — none of which can answer a question about a particular source — and this is where that ordering is repaired, after the fact and only when it went wrong.
192
-
193
- ## 2.71.1
194
-
195
- - **Fix**: A reader stated the same thing twice — `protectRange` to the piece store for memory, and a window to the torrent for download — and the two were separate lists that could drift. There is one statement now: `SwarmSelection.reconcile` derives both views from the register, so the swarm and the store are told what to do from the same words. Only the urgent levels reach memory: it holds what will be READ soon, and protecting the speculative tail would push out a piece the decoder is about to want.
196
- - **New**: A file this machine cannot sustain at ANY height is refused rather than served badly. Both offered lists empty means not even copying the picture — which costs no encoder at all — can keep up, so a session made there produces a slideshow and takes the swarm and the processor from whoever is already watching. Field 2026-08-28: five sessions on one file put every rung at 0.04x of realtime. The plan now carries `cannotServe` with the reason, which is a different thing from a spinner that never ends.
197
- - **Chore**: `SwarmSelection` takes its store lookup as a parameter, so the memory projection is driven by a test without constructing a real piece store.
198
-
199
- ## 2.71.0
200
-
201
- - **Fix**: The torrent thread's log lines never reached the log file. A worker thread loads its own instance of every module, so the logger's file handle — set once, on the main thread — was null there for the life of the process. Measured over a whole 49 938-line file: zero lines from the piece reader and zero from the torrent pool, against 52 and 36 of them in the container's output, which every release destroys. That is why the comparison of the two claim strategies could never be read: it was being printed into a place we wipe ourselves. The worker now sends its lines to the main thread, which is the only writer — two threads appending to one file would race on the rotation and could interleave mid-line.
202
- - **New**: `services/demand/` — what anybody wants, stated once and in BYTES. `Window` (claimant, file, byte range, urgency), `Urgency`, `DemandRegister` (live windows by claimant), and `pieces.js`, the one place bytes become piece numbers. Nothing in it knows about WebTorrent, the piece store or pieces. The rounding it removes is where a real failure lived: with 16 MiB pieces a 64 MB allowance is four places while two readers asking for 96 MB each want six, and both figures had already been floored before anything could notice.
203
- - **New**: `services/download/SwarmSelection.js` is the only thing in the proxy that calls `select`, `deselect` or `critical`. There were four callers before and their comments record them fighting: a whole-file read undid a seek that had just happened and the swarm walked forward from the first hole — on a 4.7 GB film, 2.47 GB over 93 s before the segment could be served.
204
- - **Fix**: Urgency is no longer a number handed to the library, because the library does not keep it. Measured against the vendored 2.8.5: selections are sorted by priority only when one is inserted, and `shufflePriority` then moves the selection just served to the back of the whole non-zero group — so distinct numbers give an order once and a round robin thereafter. Five levels are kept here instead, and a level is stated only while every level above it is satisfied. The library is given the one distinction it honours: non-zero for what is wanted now, zero for the speculative tail.
205
- - **Fix**: The speculative levels are withdrawn rather than lowered. A peer that cannot help with anything urgent falls through the selection list to whatever is below it, and with a permanently low priority would spend the shared link on pieces nobody is waiting for — about a second of its own throughput at a time. A withdrawn window is not in the download set at all. The condition is global across torrents: two films on one proxy share the link.
206
- - **Fix**: Nothing is fetched until somebody states a need — the torrent is added with `deselect: true`. WebTorrent's own default is to select the whole torrent, and this proxy used to undo that afterwards by deselecting the files nobody had opened, so on a season pack every episode was fetched for as long as the viewer took to choose one.
207
- - **Fix**: The background fill is stated per FILE, from the furthest window in that file to that file's end. It used to take the furthest window across all files and the last piece across all files and claim everything between: with two viewers on two episodes of one release, that claimed every episode lying between them.
208
- - **Chore**: The two claim strategies are one. Each read was assigned at random to a single band or four, and the waits sorted by which; the split halved the sample and the ten-wait threshold was never reached in either arm — nine reads against three on 2026-08-28, and forty waits against one on 2026-08-29, the only day both arms printed. Waits are recorded by the LEVEL the reader was stopped in now, which says whether a band is too narrow rather than whether banding is the wrong idea. `TORRENT_TV_READ_MODE` is gone.
209
- - **Chore**: `setActiveFile` deleted — no caller anywhere in the repository. `#reassertReaderWindows`, `#updateBackgroundFill`, `#tailAfterWindows` and `#syncSelections` deleted with the mechanisms they patched over; `claimWindow`, `releaseWindow`, `markCritical` and `clearCritical` deleted from the reader.
210
- - **New**: `askFastestWiresFor` counts the requests refused because every block of the piece was already reserved and displacement did not happen. WebTorrent's displacement thresholds are constants, not settings — the asker must be above 16 KB/s, the holder below 48 KB/s and twice as slow — so a holder at 50 KB/s is never displaced however long the piece has been waited for. That counter is what would justify replacing the rule; a zero says the thresholds are not what we are short of.
211
- - **Chore**: `docs/download-architecture.md`, in the shape of `docs/container-architecture.md`: the two axes, what is not a third, the layers as a diagram, and what was deleted with the reason. 19 checks in `test/demand-register.test.js` and `test/swarm-selection.test.js`.
212
-
213
- ## 2.70.0
214
-
215
- - **New**: The piece store keeps a pool of memory blocks instead of allocating one per piece. A block is one piece's worth of memory; it is taken from the free list, and put back there when its piece is written out. Field 2026-09-02: 7575 allocations of 4 MiB in 44 minutes, each released only when the collector reached it, which is why the process held 1.86 GB while the store's own accounting said 352 MB. A block goes back for re-use only AFTER the spill write has finished, because that write reads out of it; a block whose piece is re-put while a reader holds it is given up rather than recycled, and a counter says if one ever is not.
216
- - **Fix**: The store's allowance could only ever fall. `#capacity` was computed once in the constructor and used as an upper bound on every revision, so a torrent opened while the machine was full kept a small allowance for its whole life however much memory was freed afterwards. The field is gone; the opening figure is now only where the store starts.
217
- - **Fix**: `AVAILABLE_MEMORY_SHARE` (a quarter), `MIN_BUDGET_BYTES` (64 MB) and `MEMORY_BUDGET_CEILING_BYTES` (512 MB) are removed. All three trace to one observation of one host on 2026-08-03 and none was derived. The budget is now the smaller of what the readers have declared — the union of their windows, since picture and sound overlap — and what the machine allows, which is `MemAvailable` plus what the stores already hold, less what other processes have recently been seen to need. That last figure starts at zero and grows only on evidence: the fall in available memory between two readings, beyond what the stores themselves took.
218
- - **Fix**: A piece no reader has declared, arriving at a store with no room, goes straight to disk instead of pushing out a piece a reader is about to read. It costs the same one write it would have cost when the next arrival evicted it. Before the first read nothing is declared and nothing is refused memory on a guess.
219
- - **Fix**: Evicting a piece the disk already holds costs no second write. `#revive` reads a piece back and leaves the copy on disk, and only `put` removes it, so a piece revived and not re-put is identical to what is already written. There were 7575 revivals in that one session (roadmap item 66: 14.4 GB written in a single viewing).
220
- - **Fix**: A store whose readers have GONE asks for nothing, where before it kept asking for what it held. Its torrent sits until the pool's idle timer removes it, and that timer needs a refcount of zero and can be a quarter of an hour away. A store that has never had a reader is a different case and keeps its opening share.
221
- - **Fix**: A store is never cut below one reader's whole window, whatever the machine's share says. Obeying a smaller share would leave it unable to finish the read it is serving: every resident piece pinned, zero bytes returned, ffmpeg taking that for the end of the file — which killed every encoder on that file on 2026-08-15. The line says when the share was smaller than the window.
222
- - **Chore**: What other processes need is a window of the last sixty observations, not a high-water. A single spike would otherwise squeeze the stores for the life of the process, which is the same mistake an all-time maximum makes of the block re-use gap.
223
- - **New**: A spare block is given up once it has sat unused longer than the store's own working rhythm — the longest wait, over recent work, between a block falling free and being wanted again. Measured, not chosen: while a film is being watched a block is taken again within milliseconds, because one is taken for every piece that arrives.
224
- - **New**: The store line says how many blocks of memory exist and how many are spare, how long a block waits before it is wanted again, how many were given back, how many pieces were admitted that were in nobody's window, and how many evictions needed no write. `committed` now means what the process holds — the blocks — rather than the pieces in them.
225
- - **Chore**: Thirteen checks across `test/piece-lru.test.js`, `test/piece-store-eviction.test.js` and `test/memory-budget.test.js`, including that a re-used block never carries the previous piece's bytes into the next one.
226
-
227
- ## 2.69.2
228
-
229
- - **New**: The piece store says WHY it spills, which no reading has ever answered. A session on 2026-09-02 did 6565 spills and 7575 revivals in 44 minutes with only 53.6 % of reads served from memory, and nothing recorded whether that was an eviction order fighting the read order or a working set that simply does not fit. Three figures now settle it, on one line per store: what the live readers between them are asking to keep against what the store may hold; how many evictions had to take a piece a reader had declared it wants; and how long a revived piece had been on disk before it was wanted back.
230
- - **Chore**: The demand is the UNION of the readers' windows, not their sum. Two readers of one file — picture and sound — overlap by construction, and summing them would report the store as short when it is not. A union wider than the capacity cannot be held however the eviction is ordered, which is the difference between a policy to fix and arithmetic to accept.
231
- - **Chore**: `PieceLru.evictionChoice()` returns the victim with the two facts about it — whether protection had to yield, and how many pieces the victim lies from the nearest declared window, zero inside one and -1 when nothing is declared. `evictionCandidate()` is kept and delegates, so nothing else moved.
232
- - **Chore**: A revived piece's age is kept as a bounded window of the last 200, because the figure wanted is a median rather than a history. The line also says how many of those came back within five seconds — a piece wanted again that soon should not have left. Seven checks in `test/piece-lru.test.js` and `test/piece-store-eviction.test.js`.
233
-
234
- ## 2.69.1
235
-
236
- - **Fix**: The change trigger on the torrent worker's memory line watched the one figure that does not move. A thread's watched quantity was `heapTotal`, and through the session of 2026-09-02 that stood at 31-173 MB while the same isolate's `arrayBuffers` swung between 130 and 950 MB — so nothing ever earned a line and every reading of the quantity that grew came out on the quiet minute. Each of `heapTotal`, `external` and `arrayBuffers` is now compared against its own last written value and any one of them moving writes the line. Nothing is summed: `arrayBuffers` is documented as part of `external` and reads larger than it here, and this code has no business resolving that.
237
- - **Fix**: The process line was read once a minute, which cannot see what kills this process. Both out-of-memory kills of 2026-09-02 fell inside a single gap of that cadence — the last line before the first said `rss=602MB` and the kernel recorded `anon-rss:1722836kB` 34 seconds later; before the second it said `rss=1171MB` and the kernel recorded `anon-rss:1905024kB` nine seconds later. The figure is read every second now and written when `rss` has moved by 25 MB or when a quiet minute is up, so a rise of a gigabyte is a curve instead of one number and then a death.
238
- - **Chore**: A quiet second costs one call to `process.memoryUsage()`. Every /proc read — `meminfo`, `smaps_rollup`, `smaps`, the free-space call — moved behind the decision to write, where only `smaps` already was. The `high rss … watch for OOM` warning moved with them, or at one reading a second it would have printed a line a second for as long as the process stayed large.
239
- - **Chore**: The piece-buffer counters are on the memory line itself rather than on a timer of their own. Both sides read them at the same instant now — the worker's own buffers beside its `arrayBuffers`, and the ones handed to the main thread beside the process's `rss`. On separate timers they were up to a minute apart: 950 MB of `arrayBuffers` at 14:10:40 could only be checked against readings half a minute either side saying 14 and 130 buffers were alive, and the arithmetic did not close. `watchedFigures` is exported and pinned, with two checks in `test/memory-budget.test.js`.
240
-
241
- ## 2.69.0
242
-
243
- - **Fix**: The language detector no longer states an answer the text cannot support. Measured 2026-09-02 over Wikipedia extracts in seventeen languages, 120 random windows per language per length: franc's answer for Russian walks between Bulgarian, Serbian and Russian until about 650 characters, while Korean is settled by 40 and English by 130 — the figure is the language's own, because Russian competes with three neighbours for the same trigrams and Korean competes with nothing. The detector now asks franc for a candidate, refuses when the text is shorter than THAT candidate's measured figure, and refuses again when the answer does not survive losing either half of the text. The margin to the runner-up was tried first and rejected on measurement: it is 0.026 where the answer is wrong and 0.015 where it is right, so it separates nothing. `research/franc-boundary-2026-09-02.md`.
244
- - **New**: The reading is re-taken as the film downloads. `warmSubtitleCues` re-runs detection over every cue held so far on each push and sends it as `detectedLanguage`; the browser moves the track's label onto it. This is what makes the refusal above cost the viewer nothing — a track opens as Unknown and becomes Russian a minute later, instead of opening as Bulgarian and staying wrong for the session. About 6 ms per push, measured; franc costs ~2 ms whatever the size.
245
- - **Chore**: The stability test is worth its own line because it needs no table: across 38 400 trials franc alone was wrong 2197 times, and requiring the two halves to agree with the whole left 255 of those standing while keeping 81.8 % of the right answers.
246
- - **Chore**: `test/worker-channel.test.js` waits for the chunk it is about instead of sleeping 50 ms and hoping. It has been on the flaky list since roadmap item 53, and this change is what pushed it over: the torrent worker gained one more module to load, and the chosen interval stopped being enough. A test that samples the clock does not test.
247
-
248
- ## 2.68.1
249
-
250
- - **Fix**: A Russian subtitle file was reported to the viewer as English, and the proxy was the one saying so. Field 2026-09-01: `Sub/[Stan WarHammer & Nesitach]/[HorribleSubs] Drifters - 03 [1080p].ass` came back with `X-Subtitle-Language: en` and a body whose first cue is `Ты видишь их?`. The cause is what the detector was FED, not the detector. An ASS file is markup by half — measured on that file, 5040 Latin characters of Aegisub headers, style and font names, `Format:`/`Dialogue:` field prefixes and `{\…}` override groups against 5983 Cyrillic characters of dialogue — and `franc` scores letter trigrams over the whole string it is handed: `franc(the file) = eng`, `franc(the dialogue) = rus`. The markup-free WebVTT was already built two lines above and the detection was given the raw file anyway.
251
- - **Fix**: The same mistake at the two other places a language is read, both found by looking rather than reported. An embedded ASS track was detected on its RAW cue text — a cluster-held cue is the dialogue row without its `Dialogue:` header, so it carries nine comma-separated fields and the override groups, which are Latin on a Russian track; it now reads the cues through `finalizeCues`, the same conversion that produces what is served. And the ffmpeg extraction path detected on the first 4096 BYTES of its WebVTT output, where a byte cut lands mid-character on any non-Latin track and most of those bytes are timestamps; it now reads the whole document's cue text.
252
- - **Chore**: `language-detect.js` gains `cueTextOfVtt` — the words of a WebVTT document, with the header, `NOTE`/`STYLE`/`REGION` blocks, cue identifiers, timing lines, inline tags and character references removed — and `detectLanguageFromVtt` over it. One rule does most of it: a block with no timing line is not a cue. Six checks in `test/subtitle-language.test.js`.
253
- - **Chore**: The embedded path now detects on every cue held so far rather than on the `fresh` subset being sent, because a re-subscription after a reconnect asks for only what that page missed and a handful of lines is not a sample of a language. This matters more than it sounds: measured 2026-09-01, `franc`'s answer among the Cyrillic languages is unstable on a small sample — one Russian text grown line by line answered `bul` at 129 characters, `srp` at 158 and 241, `bul` again at 292, then `rus` at every length from 337 to 881. Every change here gives the detector more text rather than less; the instability itself is not fixed and is recorded as its own roadmap item.
254
-
255
- ## 2.68.0
256
-
257
- - **Fix**: The background fetch of a soundtrack now stands aside after a stall, not only during one. Pausing only while a reader is blocked is not enough on a swarm with no surplus: between stalls the fetch still takes bandwidth the picture needs, and the stalls themselves are the proof there was none to spare. Field 2026-08-31 — the swarm delivered 200-600 KB/s against the 399 KB/s the film eats, one piece waited 101 s after a seek, and the picture stood still 145.6 s before the player gave up. A chunk is fetched only when the stall count has not moved since the previous one, re-baselined after each quiet pause so it can resume; on a starving swarm it stops altogether, which is the right answer.
258
- - **Chore**: `piece-reader.js` counts the stalls a torrent's readers have had, so work of lower importance can ask "did the viewer stall while I was busy?" — a stricter and more useful question than "is the viewer stalled right now".
259
-
260
- ## 2.67.0
261
-
262
- - **New**: A soundtrack or subtitle file shipped beside the picture is fetched WHOLE as soon as the viewer opens the episode, using only the room their own reading leaves. The ordering is the design: what plays now comes first — the picture at the playhead, the soundtrack being heard, the subtitles being shown — the other tracks next, and reading the film far ahead last. The middle tier stays below the first by a condition that is measured rather than chosen: it fetches only while NO reader on the torrent is inside a wait, re-asked before every piece, because a torrent that was healthy a moment ago is not evidence about the next second. A soundtrack is about a twentieth of the picture, and having it on disk is what turns a later switch into a local read: in the field on 2026-08-31 the first piece of one took 27.7 s to arrive, which is longer than a switch is willing to wait.
263
- - **Chore**: That fetch is a bounded READ walking the file a piece at a time, deliberately not `file.select()` — selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against, and it cost a seek 93 s. The cushion-triggered fetch added in 2.66.0 now goes through the same guarded walk instead of a whole-file read of its own, so two triggers cannot put two readers on one soundtrack — only one of which would have stood aside for the picture.
264
- - **Fix**: The speed a quality step had to reach to survive the swarm was computed as thousands, and every step was refused against it. Field 2026-08-31: `a step must run at 4422.00x (worst wait 13.26s, one every 0.00s, 2 measured)`. Two faults in one calculation, both now removed. Several readers walk one file — the picture and each audio rendition — so a piece that has not arrived blocks all of them and their waits end within milliseconds of each other; counted as separate interruptions they gave an interval of 0.00 s. Waits are merged into the stretches during which the supply was actually not delivering, however many readers noticed. And the interval is now measured from the end of one stall to the START of the next, which is when the encoder is running and building cushion — the previous end-to-end spacing counted a stall's own duration as part of the recovery from it, and so set the bar too low, in the same direction as the hand-chosen 1.5 it had replaced.
265
- - **Chore**: The `supply` line says how many STALLS a figure rests on and how many waits they came from, because `2 measured` reading as two interruptions 3 ms apart is what made the 4422x line look better evidenced than it was.
266
-
267
-
268
- ## 2.66.1
269
-
270
- - **Fix**: When a re-encode cannot keep up and nothing LOWER is on offer, the budget asks for the COPY instead of leaving the viewer where they are. "Cheaper" in that rule meant "fewer pixels", and it had no way to express that a copied rung costs no encoder at all, whatever its size — so it looked down, found every rung below refused, and gave up. Field 2026-08-31 and it cost the whole film: a 444x240 ultrafast encode ran at 0.43-0.94x for fifty minutes while the source's own 1038p sat on offer beside it, copied and free; the line `nothing lower is on offer; leaving the picture alone` printed fifty times and the picture stood still 161 times for 940 seconds. On a copied source the way out is up, and it is both the fastest thing the host can serve and the best picture it has (`research/session-2026-08-31-seeks-and-the-copy.md`).
271
-
272
- ## 2.66.0
273
-
274
- - **New**: A soundtrack that ships beside the picture is fetched WHOLE once the swarm has capacity to spare, so a later switch to it does not wait. It is a twentieth of the picture — 30 MB against 566 MB on the field torrent — and having it on disk is the difference between an instant switch and one that pays for its own first pieces. The moment it starts is not a guess about the swarm: it is when the encoder is already as far ahead of the viewer as `--lookahead` lets it get, which is the cushion figure the session already measures and prints. Once per file, and only for a soundtrack in a file of its own.
275
- - **Chore**: That fetch is a bounded READ of the file's length, deliberately not `file.select()`. Selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against — a claim covering everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film waited 93 s while the swarm fetched 2.47 GB in file order. The comment there records it; this goes through the same path the edge warm-up uses.
276
-
277
- ## 2.65.0
278
-
279
- - **New**: A soundtrack that ships as its own file beside the picture is offered like any other. Releases commonly put a dub in `Rus Sound/<name>.mka` and subtitles in `Sub/[team]/<name>.ass`; until now the video played with its original sound and nothing said the rest of the release existed. The pairing is by name — equal base names, or a shared release hash — and a torrent holding exactly one picture takes every sidecar beside it, since there is nothing else they could belong to. A torrent with several episodes never relaxes that: a wrong pairing would put the sound of one episode over the picture of another, and nothing downstream could notice.
280
- - **New**: No new container class was needed for it, and that is the point. A `.mka` IS Matroska: `ContainerFactory` sniffs it, `MatroskaContainer` reads its `TrackEntry` list, and out comes an `AudioTrack` with its language, title, channel count and flags — the same reader that serves the picture. "External" is not a kind of track; it is the answer to WHERE a track's bytes are, which is torrent knowledge and now lives in `services/sidecar-files.js` (which file goes with which) and `services/audio-inventory.js` (one numbered list), both pure and both application-layer. `tracks/ExternalSubtitleFile.js` asserted the opposite — it did not extend `ContainerTrack`, duplicated four of its fields and was imported by nothing — and is deleted rather than extended.
281
- - **New**: The audio menu is built from the container's own track table where one can be read, with ffmpeg's `-i` banner as the fallback. `FlagOriginal` (0x55AE), `FlagCommentary` (0x55AF), `FlagVisualImpaired` (0x55AC), `FlagEnabled` (0xB9) and `LanguageBCP47` appear nowhere in that banner, so a director's commentary and the film itself were indistinguishable in the menu — roadmap item 58, points 1-3. The two readings are lined up by position and the pairing is CHECKED, exactly as `subtitle-defaults.js` checks its own: a differing count, or one pair agreeing on neither language nor title, drops the container reading whole rather than attributing a flag to the wrong track. `0:a:N` is what the encoder is handed, so a wrong flag is worse than a missing one.
282
- - **New**: One flat number spans the picture's own tracks and the files beside it, and one place resolves it. The browser's menu, the `audioTrackIndex` of a session request and the `a/<n>/` address of a rendition all mean the same number; `#resolveAudioSource` turns it into a file index and a `0:a:N` inside that file. Embedded tracks keep the numbers they have always had, so a session created against a plan cached by an older build still means what it meant.
283
- - **New**: A sidecar soundtrack chosen by a browser that understands rendition groups costs no second input at all — the rendition reads that file as its only input, and the player joins picture and sound, which is what a rendition group is for. A browser without them gets the two muxed by ffmpeg from two inputs, with the seek applied to each and `-itsoffset` stating the soundtrack's timestamps on the picture's timeline. Both files begin at their own container start time and those need not be equal; the difference is measured from the file rather than assumed to be zero, which is what keeps the sound from sitting at a fixed offset for the whole film.
284
- - **Fix**: A session now holds a claim on every file it reads. A rendition reading a sidecar claimed only the picture, and the disk sweep deletes what nobody holds — the failure that deleted a film being watched on 2026-08-06, one file over.
285
- - **Fix**: `declaredTracks` no longer answers from the picture's probe alone. A release that ships its dub separately often ships the picture with no sound at all, so the probe said there was no audio while the output plainly carried some — leaving the header check expecting one track where two arrive, and telling the browser its sound had been lost.
286
- - **New**: `Command.CONTAINER_TRACKS` reads any file's own track table on the thread that owns the torrent, fetching its head first — a sidecar has usually had nothing downloaded when it is first asked about. The picture's tracks come back the same way, so there is one command rather than two.
287
- - **New**: The warm-up that runs when a viewer picks a file now warms what lies beside it, for the same reason it warms the picture: none of it depends on the viewer, and every second of it spent now is a second they do not spend waiting later. How much of each follows from what the file IS, not from a size anyone chose — a text subtitle file is smaller than one piece of the torrent, so its edges and the whole of it are the same pieces and all of it is fetched; a soundtrack is tens of megabytes and only its header is needed to name it, so it gets head and tail like the picture. Without this the first thing to ask for a dub's header was the playback plan, on the path to the first frame.
288
- - **Fix**: The plan waits at most 3 s for a sidecar's header instead of up to two minutes. Reading one goes through the worker, which prefetches the file's edges (60 s) and then reads them (60 s), and neither was bounded from the plan's side — the route's own `maxWaitMs` covers the codec probe loop and nothing after it. A sidecar is a file of the torrent nobody has asked for yet, and on this torrent 256 KB of header means one whole 8 MiB piece. What the timeout costs is small and deliberate: the track is still offered, still numbered and still playable, only without the language and flags its header would have given — and the language the viewer sees comes from the folder the release put it in, which is in the file list and costs no bytes at all.
289
- - **Fix**: The media-info probe no longer spends its whole 8 s timeout on a file with no picture. It bails early when it has both a duration and a video stream line; a soundtrack file never prints the second, so it waited out the timeout every time — on the path where a viewer is changing track and the browser refuses a switch that is not ready in time. `Duration:` carries the start time being asked for, and it is the last thing such a file has to say about itself.
290
- - **Fix**: A soundtrack session sizes its read window from the file it READS. Sized from the picture, whose byte rate is twenty times higher over the same duration, the window bought twenty times the seconds it was meant to represent and the piece store held it.
291
-
292
- ## 2.64.9
293
-
294
- - **New**: The memory line says the SHAPE of the anonymous memory, not only its size — `mappings=[heap 40MB, 47 anon >=2MB = 388MB (largest 64MB), 812 anon <2MB = 231MB, files 31MB]`, read per mapping from `/proc/self/smaps`. The 700 MB that grew in the field on 2026-08-31 is none of the JavaScript heaps, none of the piece store and none of ffmpeg, and the three shapes it could have are three different diagnoses: one growing `[heap]` is the allocator keeping what we free, with no `malloc_trim` on musl to ask for it back; many large anonymous mappings are piece buffers, and whether their count tracks what the store says it holds decides whether anything is retaining them; many medium ones are the allocator's own arenas. Mappings with no resident pages are left out — those are V8's address-space reservations, tens of gigabytes of them, and counting them would drown the reading.
295
- - **New**: Both threads count the piece buffers they have let go of against what the collector has actually taken back. Shared memory belongs to neither isolate and lives until BOTH have released it, so one side answers only half the question: the worker reports its own (`piece buffers: N let go, M collected`) and the main thread reports the handles it was passed. A gap that widens means a reference of ours outlives the piece, and then a heap snapshot can name the holder; a gap that does not widen means whatever grows is below us. This is the reading that separates the only two explanations left for roadmap item 2, and neither could be told from the other before.
296
-
297
- ## 2.64.8
298
-
299
- - **New**: A backward restart says what it costs. Nothing already written is lost — every run keeps its own directory and the session serves the union of all of them — so the price of moving the encoder back is not the files; it is work about to be done twice, because the new run walks forward through segments the old one had already finished and ffmpeg cannot know they exist, and it is the viewer in front, who has nothing produced ahead of them until the run gets back to where it already was. Neither had ever been counted. The line now says how far back it went, how many of the segments it will walk through are already on disk, and the running totals for the session. This is the reading roadmap item 64 needs before a session is allowed more than one concurrent run: if a viewer is behind the head twice a week, that design does not earn its complexity.
300
-
301
- ## 2.64.7
302
-
303
- - **Fix**: A file opened at a position starts its SOUND at that position. The audio rendition's start is worked out from the picture's read head less the buffer the viewer reports holding — sound, because a read head is the furthest request of any viewer and the picture sits behind it by however deep that buffer is. At a cold open there is no report yet, and the fallback subtracted the WHOLE 120 s look-ahead from a buffer that does not exist: field 2026-08-31, a page opened at 588 s started its sound at 460 s, 131 seconds of film nobody would hear, and the segment the viewer needed took 38.8 s to appear against the picture's 8.4 s — the audio encoder healthy at 2.3-3.1x throughout, simply given a running start it did not need. The reading now says WHICH of its three sources answered (`viewerPositionSource`): a seek and a served segment are request edges and keep the subtraction, while the opening position is not an edge — nothing has been asked for since the session was made, and a browser that has just opened holds nothing by construction (`research/cold-open-audio-start-2026-08-31.md`).
304
- - **Fix**: The first encode run is positioned from the position the viewer ASKED for, not from the figure rounded to ten seconds. That rounding exists to answer one question — whether two viewers share a session — and `Math.round` can move a position FORWARD: 588 s became 590 s, which falls in segment #85 while the viewer at 588 s is inside #84. The player asked for a segment behind the run, the run was restarted onto it, and the 4.5 s it had already produced were thrown away.
305
-
306
- ## 2.64.6
307
-
308
- - **New**: The torrent worker reads its own memory once a SECOND, and writes a line only when something moved. A minute cannot see what kills it: three times — 2026-08-30 14:00 and 23:19, 2026-08-31 13:27 — the worker's own line read `heap=28-36MB`, and by the sample after next the thread had been terminated for reaching its heap ceiling, with the whole rise fitting inside a single sixty-second gap. The reading and the line are now separate cadences: taken every second, written when the heap has moved by 25 MB or when a quiet minute is up, so a healthy session costs the same one line a minute it costs today and a runaway is a curve rather than a step.
309
- - **New**: A heap snapshot of the WORKER's own isolate, on every new high-water above 400 MB, into the state directory, three kept. Every snapshot ever written by this proxy has been of the MAIN isolate — `v8.writeHeapSnapshot` snapshots the thread that calls it, and the main thread's heap is 26 MB while the one that dies climbs to 2240 MB. So the isolate that has died three times has never once been looked at, and the question "what is holding this memory" has had no way to be answered. Deliberately NOT taken as the ceiling is approached: a snapshot is about the size of the heap it is of, so asking for one at 1.9 GB on a machine with 600 MB left is a way to cause the kill being studied — and at one reading a second no 400 MB step is ever missed. `stateDir` now travels to the worker for this, because a worker cannot change the process's working directory and had no way to choose where a file lands.
310
- - **New**: The memory line says the heap ceiling beside the heap — `heap=1800MB/1904MB of 2240MB allowed`. That ceiling is what the runtime terminates a thread for reaching, it is inherited from the main isolate by a worker created without `resourceLimits`, and without it the log said "30MB" with no way to tell how far that was from the end.
311
-
312
- - **Fix**: Room for a piece in the store is an owned reservation, released in a `finally`, instead of a number one function increments and another decrements. Every consequence of the old pairing was a defect, and all of them are gone with it: a failure between `#claimSlot` and `#registerPiece` — a disk read that throws — lost a slot for the life of the process, because there was no `try/finally` anywhere on that path; `put`'s error path tried to correct that by guessing, and its own comments said so ("Heuristic:", "We conservatively decrement"), so it could take back a reservation belonging to a different claim in flight and let the store admit past its allowance until the next minute's revision evicted the excess. `stats()` now reports `outstanding`, and the periodic `piece-store` line prints it when it is not zero: a reservation that never comes back is invisible until the store cannot admit anything, and by then the reason is long gone.
313
- - **Fix**: A claim waits on PROGRESS, not on activity. The five-second "every resident piece is pinned" error was skipped entirely while a spill was in flight or any reservation was held, so ONE lost reservation from the defect above made that error permanently unreachable and a read retried every 50 ms for the rest of the process's life — never completing, never failing, and never saying anything. The clock now restarts when something actually moves (a piece admitted, a spill finished, a pin released) and the error is raised when nothing has moved for five seconds, whatever is nominally in flight.
314
- - **Fix**: The wait between attempts allocates once instead of once per pending spill. It attached a fresh pair of handlers to EVERY spill in flight on EVERY attempt and left an uncancelled timer behind each time, so a claim that could not be satisfied allocated in proportion to attempts times spills. One idempotent handler now, its timer cleared when it is woken, and a settling spill wakes the store itself — which is where that belonged, and which `#claimSlotOnce`'s own spills were not doing at all.
315
- - **Fix**: Closing or destroying the store wakes whoever is waiting for room, and a claim checks for a closed store on every attempt. Callers inside `#claimSlot` were simply left asleep for ever.
316
- - **Fix**: A piece handed back to memory while its own spill is still being written no longer reappears on disk. `DiskTier.write` records the index when it COMPLETES, and `put` called `forget` before that, so the completing write put the stale copy back and a later read returned bytes from before the rewrite. `put` now waits for that piece's spill to finish before forgetting it.
317
- - **Fix**: A spill that fails while the allowance is being lowered is counted (`spill-failures` in the store's line) instead of thrown into nothing. Nobody awaits those spills, so the rethrow was an unhandled rejection, and an unhandled rejection in the torrent worker ends the thread — a second way to lose the torrent client on top of the one being investigated.
318
- - **Fix**: Two concurrent revivals of the same piece no longer each allocate and register a buffer for it, leaving whoever holds the first reading memory the store has stopped tracking. The second finds the piece already back and uses it.
319
- - **Fix**: `PieceLru` follows the store's live allowance. It was built with the capacity the store was created with and never revised, so `isFull()` answered against a number that had stopped being the limit — dormant only because nothing calls it today.
320
- - **Chore**: The store accepts an injected disk tier so a test can hold a write open or make one fail on purpose. Four of the defects above live in what happens when the disk does not answer at once, and none of them was reachable from outside before (`test/piece-store-reservations.test.js`).
321
-
322
- All eight were found by reading the file after the torrent worker was killed by its own JS heap limit on 2026-08-31 (`research/worker-heap-oom-2026-08-31.md`, §5). None of them is proven to be that growth; they are what the reading found, and each is a defect in its own right.
323
-
324
- ## 2.64.5
325
-
326
- - **Fix**: A rung measured at 0.007x is no longer kept just because it is on screen. The `playingHeight` exemption is now checked after the `measured < 1` withdrawal, so a 4K HEVC transcode at 0.007x on a CM4 (field 2026-08-31, 0.1x at 23:45 and 0.007x at 06:57, 0.04s buffered) is withdrawn and the offer can become empty instead of stalling the viewer with no way to downgrade. `ownHeight` is kept only for a copied source (`!transcodeVideo`), not for a re-encode already running at 0.18x.
327
-
328
- ## 2.64.4
329
-
330
- - **Fix**: Piece store holds one `SharedArrayBuffer` per resident piece instead of a single growable pool that only ever grew. Evicting a piece deletes its buffer and the memory is reclaimable by GC — `committed` is now `resident * chunkLength`, not a high-water. On the field host a 6-piece 8 MiB overflow kept 48 MB committed; now it keeps 32 MB (roadmap 2).
331
- - **Fix**: `reviseGrowthCeiling` eagerly evicts excess pieces when the allowance is lowered, so a machine that filled up releases memory immediately instead of holding it until the next `put`. Previously lowering a ceiling stopped growth but never freed what was already committed.
332
- - **New**: `reviseGrowthCeiling` returns `evicted` and the periodic `piece-store` line reports `evictedOnRevise`; the worker logs `evicted N piece(s) to meet it` or `pinned, cannot shrink yet`.
333
- - **Fix**: Cross-thread fragment path carries the piece's own buffer (`piece-reader` → `worker` `FRAGMENT` → `client`) instead of an offset into a single `sharedBuffer`. Removes dead code `sharedBuffer`/`_legacySharedBuffer`/`poolBySource`/`poolByRead`.
334
-
335
- ## 2.64.0
336
-
337
- - **Fix**: A reader sizes its window from the memory the store may hold NOW, not from the allowance it was created with. 2.63.0 made the allowance follow the machine but left the `capacity` getter answering the original reservation, and that getter is what `ceilingPieces` reads — so a reader would have gone on claiming pieces against an allowance the machine had already withdrawn.
338
- - **New**: Container and track domain — `Container` (RFC 9559 / ISO 14496-12) with `MatroskaContainer` / `Mp4Container` / `AviContainer` via `ContainerFactory` (16-byte sniff), and `ContainerTrack` → `VideoTrack` / `AudioTrack` (`FlagOriginal`/`FlagCommentary`/`FlagVisualImpaired`) / `SubtitleTrack` → `TextSubtitleTrack` (`S_TEXT/UTF8`, `tx3g`, `wvtt`) / `ImageSubtitleTrack` (`PGS`, `VobSub`, `subp`) + `ExternalSubtitleFile`. Every class constructed from its spec section: `FlagForced` only on subtitles (RFC 9559 §5.1.4.1 0x55AA), `FlagEnabled`/`FlagDefault` + `LanguageBCP47` MUST on base, `track_enabled`/`alternate_group`/`elng` on MP4, `AVIIF_KEYFRAME` on AVI. `LanguageBCP47` overrides `Language` per MUST, disabled tracks kept for `declaredIndex` alignment with ffmpeg `0:s:N` (roadmap 62).
339
- - **New**: Application layer — `ContainerOrchestrator` (per-file cache, `getTracks`/`getKeyframeIndex`) and `SubtitleOrchestrator` (wraps `torrent-worker/subtitle-cues.js` cluster walk behind track abstraction).
340
- - **New**: Interface layer — `PlaybackController` / `SubtitleController`; `routes/api/playback-plan/post.js` and `routes/api/subtitles/get.js` delegate to controllers instead of calling services directly.
341
- - **Chore**: `proxy/docs/container-architecture.md` with mermaid class/sequence diagrams and flags matrix; `proxy/CLAUDE.md` layout updated; `services/container-index/` marked as internal detail used by `container/*`.
342
-
343
- ## 2.63.0
344
-
345
- - **New**: The piece store says what it has TAKEN, not only what it holds. `committed` and `on-disk` stand beside `resident` in its line. The two are different quantities and the difference is the growth that had no explanation: the pool only ever grows — `SharedArrayBuffer` has no shrink — so a piece spilled to disk returns its slot to the free list and its memory to nobody. On 2026-08-28 the store reported "144MB" while the process held 893 MB.
346
- - **New**: The torrent worker reports its OWN isolate's memory. The piece pool is a `SharedArrayBuffer` allocated there, so the main thread's `arrayBuffers` cannot see it however carefully it is read — which is why the main line said `no torrent stores` in the same second the worker logged a live one. `rss` and the kernel's rollup stay on the main line, because they belong to the process and are read once.
347
- - **New**: The process line carries `anon` from `/proc/self/smaps_rollup` and the free space where pieces spill. What neither isolate claims is now visible, and both limits are measured rather than declared — a budget for memory alone is half a budget when the overflow goes to a card the host boots from.
348
- - **Fix**: A store's allowance follows the machine instead of being settled at birth. It was `MemAvailable` divided between the stores at the moment one was created, so a store opened on an idle machine kept an idle machine's allowance for life and went on growing while everything else competed for what was left. It is re-derived every minute, within the reservation the store was created with (`maxByteLength` was fixed from that and `grow()` cannot pass it). Lowering it frees nothing already committed — the pool cannot shrink — but it stops the growth, sends those pieces to disk, and says so in the log when a store is already past its new allowance.
349
- - **Fix**: The delivery probe no longer calls a healthy connection stopped. Its allowance had one load-dependent term, our own send queue, and that queue is empty by construction — it drains the moment libdatachannel accepts the bytes, whether or not usrsctp then puts them on the wire. So a browser filling its cushion showed an empty queue, a small allowance and a large gap, which reads exactly like a stopped association: four `association-stopped` in the first two minutes of a healthy session on 2026-08-28, and deepening the cushion from 30 s to 120 s had made that burst four times longer. The browser reports its own transport-level received total with every echo, so the question is now answered by a counter rather than a threshold: while that total is advancing, bytes are crossing and no verdict of a stopped association stands. A browser that does not report it is judged exactly as before.
350
-
351
- ## 2.62.0
352
-
353
- - **New**: Each session says what its cushion actually is, every half minute: how many seconds of film are ready ahead of the EARLIEST viewer's picture, roughly how many megabytes that is off the swarm, and how deep a buffer the browsers say they are holding. The three were never printed together and could not be reconstructed afterwards from anything that was — so whether deepening the browser's buffer (2.61.0) protected anybody, and what it cost the person hosting the proxy, had no answer. Every term is measured: the produced range from the segments on disk, the picture from the viewers' own reports, the byte rate from the file's length over its duration. The read window sits on top of the megabytes figure, so it is a floor.
354
-
355
- ## 2.61.0
356
-
357
- - **New**: The proxy states how far ahead of the viewer it lets an encoder run — `lookaheadSeconds` on the session-create response. The browser sizes its forward buffer from it, so the two sides agree by construction instead of each carrying a figure of its own: the browser held 30 s, with a ceiling of 60, while this proxy keeps 120 s produced and suspends the encoder there. Three quarters of the protection against every interruption was left on the disk.
358
- - **Fix**: A held request is judged stale against the encoder's own look-ahead, measured on the session's own cut grid, instead of against `MAX_LOOKAHEAD_SEGMENTS`. Those eight segments bound a request ahead of the ENCODE HEAD and say nothing about how far ahead of the VIEWER a request may legitimately sit; the two happened to match a browser holding 30 s, and a browser holding the whole cushion would have had three quarters of its requests refused. The grid matters because a copied picture is cut at the source's keyframes, so its segments are not four seconds long.
359
- - **Fix**: Each viewer of a session has their own head — the segment they last asked for, or the position they seeked to. The encoder is still steered by the FURTHEST of them, since what lies behind the leader has already been produced and is served from disk; what is no longer shared is the question "is THIS request still wanted". One session serves everyone watching a copied picture, so a seek by the viewer in front used to release every request being held for the viewer behind them — the freeze of 2026-08-18, one viewer at a time. A head unheard from for longer than the whole cushion plus a segment is forgotten: a viewer who is playing asks again within that, so a longer silence means paused or gone, and neither should hold the encoder ahead of them.
360
- - **New**: `/seek` accepts `consumerId`, and a segment request carries `?consumer=`. Without them everything falls back to the one shared position, which is what a single viewer means anyway.
361
-
362
- ## 2.60.0
363
-
364
- - **Fix**: A separately published audio track starts where the EARLIEST viewer's picture is. A copied picture is one session shared by everyone watching it — the session key carries the consumer id only where the video is re-encoded — and the start was worked out as the read head less the buffer the browser reported. The read head is the furthest request of ANY viewer while the buffer belonged to whichever of them reported last, so with two viewers the two halves of that subtraction belonged to different people and the error was as large as the buffer is deep. The viewer now states where they are, in their own link report, and the track begins at the earliest of them. A browser that states nothing falls back to the old subtraction with the deepest buffer reported, which errs early — the cheap direction.
365
- - **New**: Link reports are kept per viewer, keyed by consumer id, instead of one field per session overwritten by whoever reported last. The budget reads the worst of them: the slowest link and the emptiest buffer, which need not belong to the same person, because the question it asks is whether anybody is failing to keep up. A step UP has to be carried by all of them, so `#linkCouldCarry` reads the slowest link too. The reason line says how many viewers the figures were taken over.
366
- - **Fix**: A report from a viewer who has left stops counting. Nothing releases a consumer when a data channel closes (roadmap item 55), so their last reading would otherwise go on deciding for the viewers still here; entries older than the report freshness window are dropped when the next report arrives.
367
-
368
- ## 2.59.3
369
-
370
- - **Fix**: two quality rungs that this machine encodes at the same size now share one encoder instead of starting one each. A variant was remembered under the height the browser ASKED for, while what it encodes is settled afterwards by the clamp that starts a manual pick at the top of the ladder this host can sustain — so on a weak machine a request for 360p and one for 540p both became a 426x240 encode, were filed under keys 360 and 540, and neither ever found the 240p session already making that exact picture. Field 2026-08-28: three ffmpeg processes on a CM4 producing one identical picture, every rung above 240p then measured at 0.04x of realtime and 240p itself at 0.30-0.72x, the viewer watching a slideshow that ended in a spinner, and the process dying eight minutes later after resident memory grew 121→810 MB. The comparison is made on the height PRODUCED, which is the only figure that cannot be wrong: predicting the clamp instead would be a second copy of the budget arithmetic, and the two would drift — the offer prices a rung from the startup measurement while the clamp prices it from what this file has since been seen to cost. A duplicate is let go the moment its size is known, and the height it was asked for then names the session that serves it, so no later request starts anything. A COPY is never adopted for a re-encoded rung: it costs no encoder and is the one thing a stranded viewer can always return to (`research/session-pileup-variant-key-2026-08-28.md`).
371
- - **Fix**: disposing a variant clears every height that named it, not only the one it was created under. With one session now serving several requested heights, the old cleanup left the other keys pointing at a disposed session — recovered on the next request, but only after it had been found dead.
372
-
373
- ## 2.59.2
374
-
375
- - **New**: a failed encode run says what it asked the source for and what the source holds, beside the command it ran. Every `-map` this proxy builds carries the `?` suffix, so ffmpeg drops a mapping for an absent stream in silence; drop them all and the output has no streams, which it reports as `Output file does not contain any stream` and exit 255. Three sessions died that way on 2026-08-26 and the log held only the code — indistinguishable from any other refusal. The stream counts are read from the probe's own banner, which is the one place the source says what it carries, and kept for the failure to quote. Beside them the cause is unmistakable and it says which side is wrong: an audio index past the end of the list is ours, no streams at all is the source's. The argument list was already logged at spawn; it is now repeated at the failure, so it need not be found among the runs that succeeded around it (roadmap item 58, `research/session-pileup-variant-key-2026-08-28.md`).
376
-
377
- ## 2.59.1
378
-
379
- - **Fix**: the usrsctp state script no longer crashes when no association exists yet. Found while verifying 2.59.0 by hand against the live process with no viewer connected: `system_base_info`'s association hash table is a null pointer until usrsctp allocates its first association, and the walk dereferenced it unconditionally — `Cannot access memory at address 0x0` instead of the graceful "no association found" the script already prints for the equivalent case where the table exists but is empty. Guarded the walk behind `$hash != 0`.
380
-
381
- ## 2.59.0
382
-
383
- - **New**: a wedge (roadmap item 11) is now declared, and evidence gathered for it automatically, even when the small-message shape means nothing ever queues. The only trigger that existed (`wedgeIsCertain`) requires a nonzero channel queue — confirmed 2026-08-28 by reading the code, not assuming it from the note: `bufferedAmount()` reads 0 the instant our bytes reach usrsctp, whatever usrsctp does with them next, so a wedge carrying only probes and control messages never set it and the last real episode's ring was never saved automatically. The delivery probe already computes a queue-independent verdict (`association-stopped`); it is now wired to the same evidence-gathering, gated on its OWN certainty rule rather than the raw verdict — a connection can sit behind by a bounded, non-growing amount for minutes (measured the same day, a backgrounded tab: gap held at 6-7 probes for 95+ seconds while `seen` kept climbing right along with `sent`) without anything being wrong. What a true wedge shows instead, checked against a session already known to be one: `seen` frozen at one value for over a minute while `sent` climbs unbounded. `probeWedgeIsCertain` asks whether the counter has stopped moving for longer than this connection's own history says a healthy gap ever takes — the same shape `wedgeIsCertain` already uses, applied to the probe's own counter.
384
- - **New**: `usrsctp`'s live association state (peer receive window, pending data, retransmission timeout, congestion window) can now be read on either wedge declaration, automatically — `node_datachannel.node` ships unstripped, so the read is a gdb attach against the running process, no rebuild. The walk and its healthy baseline are `research/session-2026-08-27-28-freeze-onset-and-sessions.md`, section 1; the script that performs it ships in the package (`assets/diagnostics/sctpstate.gdb`) instead of surviving only as long as someone remembers to copy it back onto a host after a container is recreated. Nine episodes of this item have lacked exactly this reading.
385
-
386
- ## 2.58.3
387
-
388
- - **New**: the proxy says what it is holding, once a minute — resident memory, heap, external and array buffers, the torrent stores in BYTES, and what the machine has left. It was killed on 2026-08-28 by the kernel's own out-of-memory killer at 2.4 GB resident (`exit code 137`, no core dump, `Out of memory: Killed process ... anon-rss: 2422628kB`) and the log had never recorded a single figure about memory. There was one final reading, taken by the kernel, and no series leading to it.
389
- - **Fix**: the torrent stores share ONE budget instead of each taking its own. It was per torrent, so two torrents meant two of it, and nothing anywhere asked what the process as a whole was holding. On the film the proxy died under, one store had taken the full 504 MB.
390
- - **Fix**: that budget is a share of `MemAvailable` rather than of `os.freemem()`. On Linux the second counts only the pages free at that instant, while the kernel deliberately keeps that number low by filling the rest with reclaimable cache — so the share it produced had little to do with what an allocation could actually obtain. The kernel publishes the estimate; we read it.
391
- - **New**: the piece-store line reports megabytes beside its piece count. The count alone says nothing without the piece size, and the piece size differs per torrent: on that film, "63" meant 504 MB.
392
-
393
- ## 2.58.2
394
-
395
- - **Fix**: `utp-native` moves to 2.5.3-ttv.8, which removes the whole crash family rather than another instance of it. Nine deaths in a fortnight had one shape — libuv holding a pointer into memory that had gone — because the structs carrying `uv_udp_t`, `uv_timer_t` and `uv_udp_send_t` were allocated by JavaScript as `Buffer.alloc(sizeof(...))`, putting them under the garbage collector while libuv's rule is that they must live until the close or completion callback has run. Every earlier fix reconciled the two owners with a rule and the next release found another way through. The module now allocates and frees that memory itself, at the point libuv has provably finished; JavaScript holds only a token, and freeing points the token at nothing so a late call does nothing instead of faulting. On the environment's own teardown nothing is freed at all — a deliberate leak while the process ends beats touching napi as it goes. Checked on the target: 77 tests pass, and 60 create/serve/destroy cycles leave memory flat.
396
-
397
- ## 2.58.1
398
-
399
- - **Fix**: `utp-native` moves to 2.5.3-ttv.7, which removes a defect ttv.6 itself introduced. That release registered an environment cleanup hook per uTP context and never removed it on the ordinary close path, so once a context finished closing and its buffer was collected the hook stayed registered against freed memory. At teardown it then ran there and called `uv_close` on handles that no longer existed, putting a dead handle into libuv's closing machinery; the fault surfaced later and elsewhere, when a healthy handle was unlinked and its neighbour in the loop's handle queue turned out to be that dead one. Read from `core.WorkerThread.81.1787857798` down to the faulting instruction — `QUEUE_REMOVE`'s second store with an unmapped operand — and by walking the loop's handle queue until a node could not be read (`research/worker-crash-cleanup-hook-2026-08-27.md`).
400
-
401
- ## 2.58.0
402
-
403
- - **Fix**: `utp-native` moves to 2.5.3-ttv.6, which removes the fault the ninth core dump named. Its stack carries no uTP frame at all — `uv_timer_stop` ← `uv_close` ← `PerIsolatePlatformData::Shutdown` ← `NodePlatform::UnregisterIsolate` ← `Worker::Run` — the torrent worker thread ending and node walking its loop into memory that had gone. `uv_udp_t` and `uv_timer_t` are fields of a struct that lives inside a JavaScript Buffer, and they are registered on the loop, so libuv holds pointers into collector-owned memory for as long as they stay registered; the thread ending takes that memory with the heap. On a main thread nobody notices because the process is ending anyway, and this proxy runs the torrent client in a worker. Three changes: a cleanup hook takes the handles off the loop before the environment goes; a plain int says whether the environment can still be called into and every callback reads it first; a strong reference holds the buffer until both handles have closed. 77 checks pass on the target.
404
- - **New**: the torrent worker says why it is ending, from inside itself, before anything is torn down — `beforeExit` with what still held the loop open, `exit` with the code, and uncaught errors and rejections. The parent already had a line for an unexpected exit and it never printed once: the fault happens during the thread's own teardown, before the parent's `exit` event is delivered, so the reading that would name the cause was being eaten by the failure it was meant to explain.
405
- - **Fix**: the delivery probe's allowance takes in the far end's own answering cadence, measured on the same connection. Without it the allowance collapsed to a single probe whenever the queue was empty, while a hidden tab echoes about once a second because the browser throttles its timers — 67 `association-stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection carrying 3.4 MB/s with every queue at zero (2026-08-27).
406
-
407
- ## 2.57.1
408
-
409
- - **New**: The flags a Matroska file states about its own subtitle tracks are read, instead of being guessed from the words a releaser typed into a track's name. `FlagForced` (`0x55AA`): "Set to 1 if the track is eligible for automatic selection by the player if it matches the user's language preference, even if the user's preferences would not normally enable subtitles with the selected audio track" — a track of signs and foreign speech, not of dialogue. `FlagHearingImpaired` (`0x55AB`): "suitable for users with hearing impairments". Both travel with the playback plan, so the browser can say which is which; until now "fors" and "SDH" in a menu were whatever text someone happened to write.
410
- - **Fix**: A track the file marks unusable is no longer offered. `FlagEnabled` (`0xB9`, default 1): "Set to 1 if the track is usable." It is left out of the extraction list but KEPT in the declared one, because ffmpeg keeps it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as `EBML_NONE`, reading the element and storing nothing, so the stream is created and numbered like any other. Dropping it would have shifted every track after it off `0:s:N`, which is the numbering defect 2.56.0 exists to remove. An element written with zero length carries its default, and a value outside the declared 0-1 range is not a statement either — only an explicit zero takes a track away.
411
- - **Fix**: `LanguageBCP47` (`0x22B59D`) is read and takes precedence, which the specification states as a MUST: "If this element is used, then any Language elements used in the same TrackEntry MUST be ignored." A file writing `pt-BR` beside `por` was being shown as plain Portuguese. The tag replaces the code in the list this proxy owns and rides BESIDE it in the list that is lined up against ffmpeg's `-i` banner, which prints the three-letter form — reporting `pt-BR` there would break the pairing and cost the `FlagDefault` reading with it.
412
-
413
- ## 2.57.0
414
-
415
- - **New**: the packet witness records the seconds BEFORE a freeze, not only after it. A size-bounded ring (4 x 16 MB, wrapping) runs on the WebRTC UDP port for exactly as long as a data channel is open, and its files are copied aside the moment a wedge is declared. The two field captures that placed this defect (`research/delivery-freeze-sender-silent-2026-08-26.md`) both began 30 s after the queue was already stuck, so neither holds the onset.
416
- - **Fix**: the capture rotates by SIZE. `-G <seconds>` with `-W <count>` and a file name carrying no strftime field made every rotation write the same name, so the four files the witness believed it kept were one file overwritten four times — which is why both field captures hold 28 s instead of the intended 120.
417
- - **New**: the capture that follows a wedge runs for 180 s, three times usrsctp's 60 s retransmission ceiling. That length is what turns silence into a statement: a stalled sender whose peer advertises a zero window must probe once per timeout, and no probe appeared in either 28 s capture.
418
- - **Fix**: a wedge is declared when it is CERTAIN rather than after a chosen delay. Three facts that are not ambiguous together: the queue has not fallen, the accepted-byte counter has not moved for longer than either the queue's own drain time at the best rate this connection has been seen to move bytes at OR the longest pause this connection has shown while healthy, and the peer has sent something during the wedge. The middle term matters twice over — the LAST rate is usually the quietest (with the browser's buffer full the only traffic is the probe, a few hundred bytes a second, and dividing a queue by that gives hours), and an ordinary retransmission timeout stops the counter dead for as long as it lasts. On the field episode's own numbers the answer comes in seconds instead of thirty; on a thin link with a large queue it waits as long as the arithmetic says it should.
419
- - **New**: ring files left behind by a process that was KILLED are kept at the next startup instead of being deleted by the next viewer's first channel. Seven deaths in a fortnight (roadmap item 1) each threw away the last seconds of wire before them.
420
- - **Fix**: the ring is stopped and its files removed on shutdown, and it is released by the watch that owns it rather than by the channel's `onClosed`, which does not always come. Either gap left a tcpdump writing on an idle proxy for the life of the process.
421
- - **Fix**: captures are pruned after each one is written, not only at startup. One episode writes up to six files and the cooldown allows six episodes an hour.
422
- - **Fix**: the delivery probe's verdict is derived, not chosen. `MISSES_FOR_VERDICT = 4` was compared against a delay that is measurable: SCTP orders per stream but schedules per ASSOCIATION, so a probe waits behind whatever is queued ahead of it on any channel, the unordered one included. Measured 2026-08-26, the old rule printed `association-stopped` at gap 4-7 while 110-150 Mbps crossed that same association and 7.34 GB went through without a failure. The allowance is now that channel's queue divided by the connection's measured rate, plus one round trip; with no rate measured the line says `no-rate-yet` instead of naming a fault.
423
-
424
- ## 2.56.0
425
-
426
- - **Fix**: A subtitle track is asked for by ffmpeg's own number, `0:s:N`, which counts EVERY subtitle stream the file carries — and the container plan counted only the ones it can turn into text, dropping PGS and VobSub before numbering. On a release whose picture-based track comes first the two numberings ran a place apart, with two consequences, both silent: a pushed cue named a track the browser does not know and was dropped, and the browser's own request found no track at all and fell through to the ffmpeg extraction, which reads the whole film for a few kilobytes of text (752 s measured on one file, 2026-08-19). Every text track now carries `declaredIndex` — its position among all the file's subtitle tracks, counted by what a track IS and not by what this code can read — and both the push and `/api/subtitles?trackIndex=` use it. In an MP4 that count includes the `subp` and `clcp` handlers, which ffmpeg also calls subtitle streams.
427
- - **Fix**: One walk of a file at a time. The walk marks a cluster as read only after fetching and parsing it — two suspension points — while it is started both on every verified piece and on a 3 s timer, so on a fast download several passes read and parsed the same cluster and could push one line twice under different found-order numbers. Each of those reads is a WebTorrent file stream, which selects and deselects its pieces, so the repetition reached the piece picker as well. Walks are now serialized per file, the plan is read once even when two callers arrive together, a warmup triggered while the previous one is still walking is dropped rather than queued, and forgetting a file waits for its walk instead of leaving one running beside a fresh copy of the state.
428
- - **Fix**: A read of already-downloaded bytes that never ends is given up after 30 s, with a line saying so. It had no bound, and with walks now serialized one such read would have held that file's queue — the browser's own request for its subtitles included — for the rest of the session.
429
- - **New**: A subtitle push says what it is ABOUT: the film-time span its new cues cover, how many of the file's indexed clusters have been walked, and the found-order cursor. Read against the position being played — which the browser now logs beside it — that separates cues that arrived late from cues that arrived early for a stretch nobody is watching. Report 2026-08-26: embedded subtitles appear "after some time", and no line on either side could say whether the cues held covered the playhead (`research/subtitle-delay-2026-08-26.md`).
430
- - **New**: The push carries a `cursor`, so a browser that loses the subscription — which a reconnect does, since the subscription belongs to the channel — can ask for exactly what it missed instead of the whole track.
431
-
432
- ## 2.55.14
433
-
434
- - **Fix**: `utp-native` moves to 2.5.3-ttv.5, which carries ten defects found by reading the whole binding after the seventh crash of this family named its frame. Three callbacks read the connection pointer without checking it exists — and the socket carries none unless it was accepted or dialled, while its destructor announces itself regardless; the ttv.4 patch created one such path itself. The read callback copied at an accumulated offset without ever consulting the buffer length it maintains, so a peer sending more between two hand-offs wrote past the end — heap corruption does not fault where it happens, which is what six deaths inside libuv bookkeeping look like. A connection could be destroyed twice, deleting already-deleted napi references, and the socket pointer was never cleared. The callback macro checked none of the three napi results it then used. Plus IPv6, which the module never had at all, and the resolver that fed it addresses it could not use. Tests on the target: 77 checks, no failures — two of them were failing or hanging before.
435
- - **Chore**: `--sctp-debug` is removed. It set node-datachannel's log level to verbose and filtered for `usrsctp:` lines, which only exist in a binary built with `SCTP_DEBUG=ON` — a source rebuild of libdatachannel that is not done any more. A flag that cannot do anything in any image we ship is an untruthful interface; the delivery probes of 2.55.13 answer the question it was wanted for (a probe on an unordered, no-retransmit channel passes head-of-line blocking in another stream but not a closed receive window) without touching the native build.
436
-
437
- ## 2.55.13
438
-
439
- - **New**: numbered delivery probes (`services/delivery-probe.js`). Every channel of a connection carries a numbered probe twice a second; the far end echoes back the highest number it saw on each, and the gaps are read into a verdict — `flowing`, `stream-stuck` (a retransmission held up in one stream), `association-stopped` (the window shut or transmission halted), `reverse-direction-gone`. The proxy's own counters could never separate those: libdatachannel's `bytesSent` counts bytes accepted into usrsctp, not bytes put on the wire. Logged as `[dc-probe]`, with the numbers that produced each verdict beside it.
440
- - **New**: the far end's own account of itself is logged as `[dc-far]` — tab visibility, event-loop lag, the longest the channel's message handler ran, the transport's received bytes and per-channel message counts. A page that has stopped draining the channel and a sender that has stopped transmitting look identical from here; this is the difference.
441
- - **New**: `--delivery-sink` serves `GET /api/delivery-sink?bytes=N`, a torrent-free stream of a repeating pattern, so gigabytes can be pushed through a data channel on demand. Off without the flag (404), and it exists because the delivery freeze takes hundreds of megabytes and the real transport to appear at all.
442
-
443
- ## 2.55.12
444
-
445
- - **Fix**: `--sctp-debug` now passes the required callback to `initLogger` (`node-datachannel` 0.32.x needs two args). The 2.55.10 single-arg call always threw `Function expected` and left verbose logging off.
446
-
447
- ## 2.55.11
448
-
449
- - **Fix**: `initLogger('Verbose')` call for `--sctp-debug` used the wrong `this` binding and never enabled verbose logging (`Function expected`). Call it as a method on the imported module's default export so the SCTP debug lines appear.
450
-
451
- ## 2.55.10
452
-
453
- - **Change**: Add `--sctp-debug` (off by default). When enabled, the proxy calls `node-datachannel.initLogger('Verbose')` early so SCTP-level lines (`usrsctp: …`) become visible. Useful only with an image rebuilt with `SCTP_DEBUG=ON` (addon 0.48.0) — there they carry the SACK `a_rwnd` and gap information that separates the two remaining hypotheses for the delivery-side freeze of 2026-08-24/25.
454
-
455
- ## 2.55.9
456
-
457
- - **New**: A send queue that stays wedged for over 30 s now records the wire itself. Field session 2026-08-24 (`research/dead-channel-2026-08-24.md`): the proxy counted bytes as sent that never reached the viewer's SCTP stack, and every counter above the wire reported success for 88 minutes — the two candidate causes inside SCTP separate by one look at the packets (duplicate SACKs naming a missing TSN with no retransmission vs SACKs advertising `a_rwnd=0`), but occurrences are rare, so waiting to be asked meant waiting forever. When the stuck warning crosses 30 s, the proxy spawns a bounded tcpdump on the WebRTC UDP port filtered to that session's remote address: snaplen 128 B, ring of 4 × 30 s files beside the core dumps (`--state-dir`), killed after 120 s + grace, one capture at a time with a 10-minute cooldown, old captures pruned at startup the way core dumps are. Where no tcpdump exists it degrades to a single log line; the address travels into the filter only as a validated IPv4/IPv6 literal (zone suffixes stripped), spawned as an argv array without a shell.
458
-
459
- ## 2.55.8
460
-
461
- - **Fix**: The proxy died twice in one evening (2026-08-22, 15:50:12 and 16:12:21 UTC) with no stop order given, and both deaths are the same fault. The torrent worker thread ends itself when its event loop drains — every recurring interval there is unref'd, upload is disabled by default, and idle peer connections close about half a minute after the traffic stops — so when a viewer paused or left, the thread finished ~35 s later on its own and Node began tearing it down. That teardown touched memory already freed or overwritten (SIGSEGV inside `uv_timer_stop`, reached through `PerIsolatePlatformData::Shutdown`; two core dumps captured identical stacks), and a fault in any thread kills the whole process instantly — HTTP server, tunnel and data channels together, with no log line and no way to restart anything from inside. The HA supervisor restarted the container each time (~15 s), but the browser's reconnect ladder had already given up by then. The worker now keeps ONE interval accounted for (no `.unref()`): an empty tick every 5 s costs nothing, the event loop can never drain while the process lives, and the teardown path — with whatever structure is corrupted inside it — stays unreachable, regardless of which native module is guilty; the three earlier crashes of this family (2026-08-18..21) stay documented under roadmap item 1.
462
-
463
- ## 2.55.7
464
-
465
- - **Fix**: The subtitle push subscription is now recorded under the torrent pool's own key, not the browser's registry key — the two are different strings whenever a source was added by a `.torrent` file (a `.torrent` and a magnet for the same film are different request bytes, hashed into different registry keys, but the SAME infohash) and were silently different in every other case too: a registry key is `sha1(sourceType:source)`, one per API session; the pool's key is `torrent:<infohash>`, deliberately shared across a magnet and a `.torrent` for the same content (item 10). The diagnostic logging added in 2.55.6 caught it directly, field case 2026-08-22: cues were found and logged repeatedly, and every push answered `found no subscribed channel` — the subscription and the publish had never been able to agree on a key, for any torrent, since the push feature shipped in 2.55.5. `data-channel-handler.js` now resolves the browser's registry key through `sourceRegistry` to `(sourceType, source)` and runs it through the same `deriveSourceKey` the pool itself uses, at the one point both keys are in hand — the subscribe intercept, before the request is even forwarded.
466
-
467
- ## 2.55.6
468
-
469
- - **Chore**: Every step of the subtitle push chain now logs on success, not only on failure. Field report 2026-08-22, playing `Minions.and.Monsters.1080p.mkv`: a track was switched on over a minute after the seed fetch found nothing (`bytes=7`, an empty `WEBVTT` — expected, the torrent had barely started), and no cues appeared. The proxy log carried no evidence either way — `warmActiveFiles` posted `Event.SUBTITLE_CUES_READY` silently, `publishSubtitleCues` sent (or found no subscriber for) a push silently, and `subscribeSubtitles` registered a channel silently. Confirmed separately by reading WebTorrent's own source that `verified` fires on every live piece completion (`_markVerified` inside `store.put`'s callback in `torrent.js`, not only at startup), so the event source itself is real; what could not be told apart without these lines is subscription, discovery, and delivery. Logs now name each: `subtitle push: channel subscribed to …`, `… cue(s) found, posting to main thread`, `… sent N cue(s) … to M/T channel(s)` (or `found no subscribed channel`).
470
-
471
- ## 2.55.5
472
-
473
- - **New**: Subtitle cues are now PUSHED to the browser the moment they are read, over the WebRTC data channel — not fetched by the browser on a timer. Every declared track was already being warmed off the piece-`verified` event (2.55.4); what changed is that the result now travels to the browser unprompted instead of sitting on the proxy until the next poll asked for it. `data-channel-handler.js` remembers which channel last asked about a file's subtitles (piggy-backing on the browser's own first `/api/subtitles?trackIndex=` request — no separate subscribe message) and sends new cues there directly (`{ type: "subtitle-cues", fileIndex, trackIndex, cues, language }`), for every track the container declares, not only the one on screen. Rides the existing `proxy-control` data channel — the same one the request itself used, which is never the one carrying segment bytes, so a push cannot queue behind video. `finalizeCues` (end-time synthesis + ASS-dialogue stripping) is factored out of the HTTP route into `services/torrent-worker/subtitle-cues.js` so a pushed cue and a pulled one are built the same way. A browser's one-off seed fetch per track (for whatever is already read at the moment a file opens) and the external-subtitle-FILE path (`.srt`/`.ass` beside the video — a single whole-file read, no incremental delivery to begin with) are unchanged.
474
- - **Chore**: The push subscription only fires for an embedded-track request (`trackIndex` present) — an external subtitle file's request carries a different file's own index in `fileIndex` and was being registered under a key nothing ever publishes to. Harmless (the worker's plan for a non-container file is empty, so nothing was ever sent there), but pointless bookkeeping is still a bug waiting to be one.
475
-
476
- ## 2.55.4
477
-
478
- - **Fix**: A file's subtitle cues are now walked the moment a piece verifies, not on a 3 s poll. The poll (2.55.3) closed the worst of it but still left every new cluster waiting up to 3 s after its piece arrived, and "waiting" at all was the thing objected to — a cue's readiness must not depend on which of two independent timers happens to fire first. `torrent.on("verified", …)` is WebTorrent's own signal for exactly this instant, set in the same place the bitfield itself is (`_markVerified`), so the walk now runs off the same event that makes a piece a piece rather than off a schedule. The 3 s poll stays as a fallback — it only matters for a listener attached after some pieces already verified, or if a `verified` handler ever throws — so nothing that used to be caught can now be missed.
479
-
480
- - **Fix**: A track's subtitle cues are now walked ahead of being asked for, instead of only when a browser first requests them. `cuesHeldFor` only ever read clusters on demand, inside the `/api/subtitles` request itself — cheap once caught up, but the FIRST call for a track had to walk the whole backlog of already-downloaded-but-unparsed clusters serially, with no `pending`/streaming pattern the way the ffmpeg fallback has one. On a film well into playback that backlog is not small (`Minions.and.Monsters.1080p.mkv` indexes over a thousand cluster positions per track), so a viewer who turned subtitles on after watching for a while waited on that catch-up instead of seeing cues appear at once — the opposite of the rule this file states its own reason for existing ("the region the viewer is watching is downloaded before they reach it, so its cues are ready before they are needed"): true of the DATA, not of when it got READ. A new periodic pass in the torrent worker (`warmSubtitleCues`, every 3 s, one per actively-read file) walks new clusters as they arrive, reusing `cuesHeldFor`'s own memoized state — a file nobody has opened costs nothing, and a file being watched is caught up by the time a track is switched on.
481
-
482
- - **Fix**: The decode pipe's sanity log carried a fixed editorial line — "a reading where these are far apart is worth a second look" — printed on every single reading regardless of whether the two figures actually were, which was noise the first time it ran in the field. The comparison is also now taken over the same window the speed itself is (bytes are snapshotted alongside each progress sample), rather than over the whole run from process start, which read systematically low for no reason but that mismatch. The line only says "worth a second look" when the two figures are actually more than 1.5x apart.
483
-
484
- ## 2.55.1
485
-
486
- - **Fix**: A source is now keyed by its own infohash, not by a hash of the request bytes. A magnet URI and a `.torrent` file for the same content are different bytes, so the old key (`sha1` of the source) named the same film as two unrelated sources — measured 2026-08-19, `a518ff46…` and `7ab2fb5d…` for one infohash `11f09299…` — sharing neither the swarm, nor a cache, nor any work already downloaded, and surfacing as `WebTorrent client error: Cannot add duplicate torrent`. The new key (`services/torrent-source-key.js`) reads the infohash straight out of the magnet's `btih` or the `.torrent`'s own `info` dictionary via `parse-torrent`, synchronously, with no network round trip — so both forms of the same torrent now share one entry from the first request, on both the pool's own map (`torrent-pool.js`) and the worker-thread boundary (`torrent-worker/pool-adapter.js`), instead of relying on WebTorrent's own duplicate-add error to reconcile them after the fact.
487
- - **Chore**: The decode-cost reading now logs how many MB/s the pipe was actually fed against how many MB/s the measured speed implies were needed (item 4(d2)) — a divergence between the two is worth a second look before trusting the reading. A `write()`-return-value signal was tried first, to say outright whether the pipe or the decoder was the limit, and measured false on every reading taken while building it — including clips this host decodes at 15-80x with slack to spare — so it does not discriminate and was not shipped; only the byte count, which is real, is kept.
488
-
489
- ## 2.55.0
490
-
491
- - **Fix**: The decode calibration was measuring the loop rather than the decode. It looped each clip with `-stream_loop -1`, and a loop is not free: measured 2026-08-22, a restart costs **0.03 s on the 480p clip and 0.12 s on the 1080p one** — it scales with the picture, so it is the decoder tearing down and re-allocating its frame buffers rather than anything about reading the file. A five-second clip decoded at 55x restarts eleven times a second, and that cost dominated the reading: the same clips measured 53.7x looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse, the bias depends on BOTH the clip's own resolution and the host's speed — the two axes the fit exists to separate — so it did not cancel out, it tilted the fit. That is the fast-host failure recorded on 2026-08-20, where a desktop read 1080p at 9.35 Mbit/s as cheaper than 720p at 9.94, an ordering no decoder produces, and the H.264 fit refused to solve at all. A host that could not fit H.264 got no decode figure whatsoever, which is exactly the host most able to serve.
492
- - **New**: The clip is fed to the decoder as ONE Annex-B elementary stream, written to its stdin over and over. Parameter sets are inline in Annex-B and it can be joined by plain byte concatenation — that is what a broadcast is — so more bytes are simply more stream: nothing re-opens, nothing re-initialises, and there is no restart inside the measured window. The lift out of the container is a copy, not a re-encode, and it goes straight to a pipe: no temporary media is written at any point. Error against the continuous-pass truth is now −0.2 % and −5.5 %, with the readings spread 2-6 %, against −25 % and −33 % for the loop. Every reading on the developer's desktop moved, by up to 66 %, and they are monotonic in both axes for the first time.
493
- - **New**: The measured window is half a second instead of one. What used to make a long window necessary was the clip restarting inside it; with the stream continuous, the only thing left to average over is the timing jitter of two progress lines, which is milliseconds. Measured at half a second: −3.0 % and +0.6 %.
494
- - **Fix**: The contention penalty was wrong for the same reason. It compares a decode alone against the same decode beside an encoder, and both readings carried the loop, but not equally — the machine's speed differs between them. Decoding alone now reads 77.5x where it read about 53x.
495
- - **Chore**: A codec family's clips are lifted out of their containers in ONE ffmpeg run, and the startup is shorter than before rather than longer. The lift is a copy, so its cost is almost entirely the process: one per clip added 11 s here, and running them concurrently did not help — six at once took 4.75 s against 0.89 s for one, so the machine serialises them. One run with many inputs and many outputs costs one process. Measured end to end on the same desktop, alternating old and new: **35.3 s before, 31.6 s after**, with the readings corrected. The contention benchmark lifts its clip once and decodes the same bytes three times instead of lifting it again for each reading. Done before the measurements and never beside them: a remux running next to a decode is a second job on the machine, and this benchmark exists to find out what one job costs.
496
- - **Fix**: The lift has a time bound and is killed on it. It was the only ffmpeg run in this file without one, and it is awaited before the proxy's tunnel opens — so a remux that never exited was a startup that never finished, with nothing said. Its failures, and the decode's, now carry ffmpeg's own last line instead of "said nothing".
497
- - **Fix**: A codec family with no Annex-B mapping fails by name instead of being lifted with H.264's filter. This matters for what comes next: AV1 has no Annex-B form at all — its packaging is OBU — and MPEG-2 and VC-1 have no `*_mp4toannexb` filter, so all three of the families the roadmap plans need another route through here, and finding that out as "the clip failed" would send the reader after the clip.
498
- - **Chore**: `test/decode-measurement.test.js` states the orderings the readings must have — a bigger picture costs more than a smaller one at the same bitrate, a thicker stream more than a thin one at the same size, HEVC more than H.264 — rather than any number, since the numbers belong to whatever machine runs them. Those are the properties the loop inverted, and nothing was checking them.
499
-
500
- ## 2.54.0
501
-
502
- - **Fix**: The automatic quality step no longer changes the SIZE of the picture underneath a session the browser is already decoding. The fMP4 init segment is fetched once — a player reads `#EXT-X-MAP` and never asks again — and `avc1` keeps SPS and PPS in it rather than in the fragments, so every fragment produced after a size change was decoded against parameter sets describing a picture that was no longer being made. Measured 2026-08-21 across five viewing attempts: both re-encoded sessions of the five were destroyed by it. On `LXH-12.TS` the encoder left 1280x720 for 960x540 at 13:30:06 and the browser went on reporting `decode … size=1280x720` for the next three and a half minutes — 67 readings, not one of them 960x540 — while the viewer watched a band of macroblock garbage over a smeared field. On `c0930.com_chijyo0073.wmv` the same act at 13:35:36 made the element error on the first mismatched fragment, close the MediaSource, throw `bufferAppendError InvalidStateError` on both tracks and sit at `size=0x0 readyState=0` for four and a half minutes. Which of the two happens is the decoder's choice, not ours, and no layer reported an error either time. A change of resolution is a change of VARIANT, as the standard has it: every height is already published in the master with its own init, so the proxy now ASKS the browser to move — the same act the manual menu performs, which has never had this fault.
503
- - **Fix**: The step is decided on the rate the encoder is making NOW, read as the slope between two progress reports. ffmpeg's `-progress speed=` is cumulative — output time over wall time since the run began — so a run starved early carries that average for life. `LXH-12.TS` spent its first four minutes on a swarm giving ~100 KB/s and the budget correctly refused to act while it could see that, five times. The download recovered at 13:29:56; ten seconds later the cumulative figure still read 0.39x, the machine was now genuinely busy, the classifier answered CPU, and it stepped down a rung that the progress lines themselves show running at **1.30x** — 13 s of video in 10.02 s of wall clock. The same mistake was found and solved once already: the startup decode benchmark reads the slope between two reports for exactly this reason.
504
- - **New**: The step BACK UP exists. `budgetRungIndex` was written in exactly one place in this codebase's whole life, `nextIndex = session.budgetRungIndex + 1`, so there was no way up from anywhere. A session whose encoder has stayed ahead of realtime and whose viewer's link can carry the next rung's allowed peak, unbroken for four times the window a step down needs, is asked back up one rung at a time — never above the source. A bitrate cap is lifted before the picture is enlarged, because it is the cheaper of the two and the one the viewer notices first.
505
- - **New**: The quality step exists on the COPY path, which had no automatic behaviour at all — the budget loop left on `!session.transcodeVideo`, so a copied picture too thick for the viewer's link had nothing to answer with. A copy is not being encoded, so it has no rate to lower; the only way to send fewer bits is another rendering of the film, which is a re-encoded rung and therefore a change of variant. A copy is never stepped down for the PROCESSOR, because moving that viewer to a re-encoded rung costs the machine more, not less.
506
- - **New**: A picture that the viewer's measured link cannot carry is bounded by that measurement, at the size it is already being made. `-maxrate`, `-bufsize` and CRF do not appear in the SPS — x264 writes no HRD parameters unless asked — so one init segment goes on describing every fragment, and there was no separate lever for bitrate before this. The target is not chosen: it is the link the browser reported, less the share protocol overhead and measurement noise take out of it. The preset is deliberately NOT part of this step, and that is a correction to the plan rather than an omission: a preset change moves `profile_idc`, `num_ref_frames`, `entropy_coding_mode_flag` and `transform_8x8_mode_flag`, all of which live in the same init segment as the size, so moving it in place would reproduce the fault this release exists to remove. It rides with the variant change, where the init is that variant's own.
507
- - **New**: A run about to encode a picture the served init does not describe says so, once per distinct disagreement — the shape 2.48.0 uses for the TIME a run begins at. The size is read out of the init's own bytes rather than taken from our record of what the encoder was told, because those two disagreeing IS the fault. This whole class was silent: the encoder healthy, segments served in milliseconds, and nothing anywhere naming what the viewer was looking at.
508
- - **Chore**: The per-session resolution ladder state is gone — `budgetLadder`, `budgetRungIndex`, `budgetDownshifts` and the cap on how many steps a session might take. A step is a change of variant now, and a variant is a session with its own init, so there is no rung index to walk. The ladder still chooses the STARTING rung when a session is made, which is untouched.
509
- - **Fix**: A height this machine has been MEASURED failing at is withdrawn from the offer once the viewer has left it. The base session's own height was exempt from every refusal, which was harmless while a step rewrote the encode inside the base — that height then always WAS the rung on screen. With the step moving the viewer to another variant, the height they left went on being offered, and the way back up would have asked for the one rung the host had just been seen failing at: down, up, down, about every hundred seconds for the length of the film, each move costing a buffer flush and a cold encoder start. The rung on screen keeps its exemption, which is the one that matters; the copied source height cannot reach the refusal at all, since only re-encoding sessions have a reading to be withdrawn on.
510
- - **Fix**: Lifting a bitrate cap and enlarging the picture are decided by two different questions. Both used to be answered by one — "can the link carry the NEXT rung" — and a session already at the top offered height has no next rung, so the answer was an unconditional yes: the cap came off a link measured at a fifth of what the picture needs, and fifteen seconds later `#checkLinkBudget` put it back. Two ffmpeg restarts every minute and a half, on exactly the thin cellular viewer the cap exists for. Whether to lift a cap is now asked about the picture the cap is ON.
511
- - **Fix**: The bitrate cap has a floor, and a link report that is not a positive finite number is not a measurement. `linkMbps` reaches an encoder's `-maxrate` from the browser and was taken verbatim: a reading of 0.05 produced `-maxrate 40k` on a 720p encode, and because the cap only ever tightened, one bad reading pinned the session there for the rest of the film. The floor is what the SMALLEST picture this file is offered at is sized to carry — below that the link is not short of bitrate at this size, it is short of the size, and the answer is a smaller variant.
512
- - **Fix**: One condition decides whether a stream publishes a master playlist, so the builder and the budget cannot disagree about it. A copied stream whose keyframe index could not be read falls back to an even grid ffmpeg does not cut on; the builder refuses it, but the budget looked only at how many heights could in principle be spliced, and recorded requests against a player that has no variants — once per window, for the whole film. It now refuses the same streams, and says why once per session rather than once per window.
513
- - **Fix**: The line about a run leaving the served init behind is not written when nothing was told. `computeOutputDimensions` reads a zero target width as "no width constraint" while the encoder descriptors read it as their own default of 1280, so on a hardware host — where the budget returns nothing and the width stays zero — a 1920x816 scope source at the 720p variant would have been reported as a size disagreement that does not exist. A line whose whole purpose is to name an otherwise-silent fatal class must not cry wolf on letterboxed content.
514
- - **Chore**: One pass of the quality budget has a public name, `runQualityBudgetOnce`. A loop that decides what the viewer sees and can only be reached through `setInterval` is a loop nothing can check; it now has nine tests.
515
-
516
- ## 2.53.0
517
-
518
- - **Fix**: The torrent thread no longer dies of an answer that never came. Our build of `utp-native` moves to 2.5.3-ttv.4, which stops `on_utp_accept` reading a `napi_value` the callback never wrote: it asks JavaScript for the buffer that will hold the NEXT connection and handed the returned handle straight to `napi_get_buffer_info`, while the handle was an uninitialised local and the macro that fills it inspects exactly one failure — `napi_pending_exception` — and even for that one reports the exception and carries on. Every other status is discarded, and in none of those cases does napi write anything. Read from two core dumps on 2026-08-21, at 16:49 and 19:50, both on the thread that owns the uTP socket and both with the same top frames — `v8::Value::IsArrayBufferView` under `napi_get_buffer_info` under `on_utp_accept` — over an ordinary `SpinEventLoopInternal`, so it is NOT the shutdown race that 2.49.0 narrowed. Every live session on the proxy died with the process, five times in three days. The handle is now initialised and every status read; a buffer that never arrives clears `next_connection` instead of leaving it pointing at memory just handed to the connection being accepted, and an accept with no buffer is refused rather than written through null.
519
-
520
- ## 2.52.0
521
-
522
- - **Fix**: A file opened at a position puts the SOUND there too. Where the audio rendition starts is computed from where the viewer is, and that reading consulted only two things — a position seeked to, and the last segment the session had served — both written by events that have not happened yet at the moment a file is opened partway through. The answer was therefore zero. Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address bar at 52:07: the picture session was created at `start=3130s` and ran from segment #781, and half a second later the audio rendition was created at `start=0s` with no `-ss` at all and set about re-encoding the film from the beginning. The player asked both for #782; the picture had it, the sound reached 57.5 s of 3130 in the 45 s the request lasted and then answered 404 — which the viewer was shown as "the proxy accepted the request but sent no video". The position a session was OPENED at is now the third reading, and `resolveViewerPosition` is pure and tested. The same calculation prepares a track for a language change, so that case is covered by the same fix.
523
- - **Fix**: The line describing a swarm answers the question it is asked. It printed `peers=N` beside `wires=?`, which reads as two quantities of which one is unknown — while WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js`, identically in 2.8.5 and 3.0.21), so the first was the connection count and the second was a field that has never printed anything in any line it has ever written: the torrent lives on a worker thread and that property does not exist on the side doing the printing. What was missing was the other half of the question, and it is now there — how many peer addresses the client HOLDS, how many are queued to be tried, and what the tracker said the swarm has. Five offered and none connected is a connectivity fault; nobody offered is a supply fault; they need opposite investigations and one line now tells them apart. What the trackers said is kept PER TRACKER and reported as the best answer any of them gave: they answer separately, and a dead one replying `0` after a live one replied `500` would otherwise turn "several offered" into "nobody offered", inverting the very distinction being drawn.
524
- - **New**: The wait for the first connected peer is measured, said once when it ends, and carried in the stats while it is still going. Measured 2026-08-21 on `JUFD665.mp4`: the tracker answered `seeders=5` at 13:40:30 and the first wire arrived at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait, after which the file's 12 MiB of edges arrived at 6.8 MB/s and the plan finished in three seconds. The whole cold start was that one number, and it was neither counted nor shown. The watching is attached to what `add` returns rather than inside its ready callback, because for a magnet everything it watches happens before `ready`: peer discovery starts before the metadata arrives, so the trackers' answers land before any listener exists, and the peer that DELIVERED the metadata connected before `ready` fired — `wire` is emitted on connection and never replayed. It is also attached once per torrent: WebTorrent answers a duplicate add by handing back the torrent it already has, and re-attaching reset the timing of a live swarm, after which the next connection would print "first peer connected after 0.3s" about a torrent that had been connected for minutes.
525
- - **Fix**: One name for a torrent, and it is the infohash. The pool's `added`, `announce` and `warning` lines were labelled with the first eight characters of a sha1 of the SOURCE BYTES, the upload lines used the infohash, and the stats line used the registry's own key — three different hashes of one film, printed in the same second, none matching. The infohash is now on all of them, and on every stats line rather than only on the ones that look empty.
526
- - **Chore**: `askedFor=0` is called `fileIndex=0`. It is the index of the file being asked about, and it was printed under a name that reads as "nothing was asked for" — in a line whose subject is a download that is not happening.
527
-
528
- ## 2.51.0
529
-
530
- - **Fix**: A run is POSITIONED where the player was told the segment begins, on the same table its cuts are stated on. There are two boundary tables — the one the playlist text was written from, which never changes, and the live one, corrected as produced segments reveal where the file's cuts truly are. 2.45.0 moved the CUT LIST onto the published table and left the position on the live one, and that is one fault rather than two: `-segment_times` are measured from wherever the run really began, so any distance between the two carries into EVERY cut the run makes. The corrections run backwards, so each restart began a little earlier than the grid its cuts were stated on, and because the corrections accumulate, so did the distance. Measured 2026-08-21 on `JUFD665.mp4` — an MP4 whose index was read cleanly, 1765 keyframes, served by copy: after one seek restart a produced segment held the boundary **two** places before its own number (16.684 s, exactly 2.0000 segments), after the next restart **four** (33.5 s). The player's buffer then stops extending at all, because every fragment's content lands before the time its playlist entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far` warnings until hls.js gave up and jumped the viewer 16.8 s forward. Four of those jumps in one window is what the viewer reported as sticking on every seek.
531
- - **Fix**: The line about a segment that began away from its grid follows the numbers instead of the branch it is printed from. On the copy path it always read "the container's keyframe index disagrees with the file", including in the case this code's own neighbouring comment defines as a fault in this code — a segment holding another boundary **of the same list**. It now says which of the two it is, because they need opposite fixes and that sentence is what sent the reading of the session above after the file instead of after the arithmetic. The soundtrack's variant of the sentence stops claiming anything about a keyframe index: a soundtrack has no keyframes and is cut exactly where it was asked to be.
532
- - **New**: A run says where it was positioned — the time, the boundary it came from, that boundary's time in the PUBLISHED table and in the LIVE one, the distance between them, and the number it starts counting from. Four numbers whose disagreement was invisible everywhere else.
533
- - **New**: A correction to the live boundary table states the total drift it is part of. Corrections are applied one boundary at a time and each is small enough to look harmless; nobody was watching the sum, which is the quantity that actually moves the cuts.
534
- - **Fix**: The realignment that starts the sound where a copied picture truly begins is given that instant EXPLICITLY. It used to be smuggled through the live boundary table — the correction wrote the measured time there and the restarted run read its position from the same place — which stops working the moment a run positions itself on the table the player holds, as it now must. Smuggled, the restart would land exactly where it already was: picture and sound stay apart AND a healthy soundtrack run is discarded for nothing, which is the shape the field already showed on 2026-08-21 — eleven audio restarts in under four minutes, eight of them dying with `run had produced 0.0s`.
535
- - **Fix**: What a run was asked for is taken from the run rather than looked up again in a table. Two lines did the second — the one reporting where a run landed, and the one explaining a held segment — and once the position moved to the published grid they could disagree with the run by the distance between the two tables: a perfect landing reported as a drift, a real drift of the same size cancelled to zero, and a held segment printing a negative "produced" that sends the reader after the torrent when the encoder is the subject.
536
- - **New**: The player's own "this fragment is nowhere near my buffer" reading is answered instead of being left in its console. `POST /api/transcode-sessions/:id/fragment-far` takes the report and the proxy replies in the log with the one fact only this side holds: which boundary the segment of that number really begins at, and whether that is the one its number claims. The report says which stream it is about, and a report about the soundtrack is answered from the soundtrack's own session: picture and sound are produced by two sessions positioned by two runs, which is how they come apart, so answering one from the other's records would state something confident about the wrong stream. Diagnostic only — nothing is repositioned on the strength of a browser's reading, because a wrong answer there would restart an encoder a viewer is waiting on.
537
- - **Fix**: The DHT is given entry points that answer. Measured 2026-08-21 from the addon host: of the three bootstrap nodes the library ships, `router.bittorrent.com` and `router.utorrent.com` replied to nothing while a control datagram to a DNS server came back in 20 ms, and the third, `dht.transmissionbt.com`, is alive — it answered `find_node` with eight nodes — but on a host with global IPv6 its name resolves to an IPv6 address first while the DHT's socket is IPv4, so by name it was never reached. The list now carries a live node, and the names are resolved to IPv4 here rather than left to whichever family the host prefers. Measured on that host: 0 nodes after 21 s by name, 22 nodes in 5 s by address. The resolution is capped at two seconds per name, because it is awaited before the torrent client exists and a host whose resolver black-holes would otherwise hold that thread for c-ares' own four tries with nothing said. What it cost: on `JUFD665.mp4` the tracker answered `seeders=5` at 13:40:30 and the first peer connected at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait with an empty routing table beside them. A private torrent still ignores the DHT, by specification.
538
- - **New**: The size of the DHT's routing table is said a minute after start, and an empty one is said as a warning. A bootstrap list rots — that is exactly what had happened here — and nothing reported it for as long as it was wrong, so the next list to die would be found the same way this one was: by hand, after a viewer waited four minutes.
539
-
540
- ## 2.50.0
541
-
542
- - **Fix**: An AVI seek is asked for late enough to survive the container's own arithmetic. AVI names a keyframe by its frame NUMBER, and `services/container-index/avi.js` turned that into a time by multiplying by the frame duration the header declares — which lands 10-44 ms from the presentation time the demuxer computes, always under one frame (measured 2026-08-21 against the files themselves: 1196 index entries against 1196 real keyframes and 901 against 901, the frames exactly right and only their names off). A name sitting just BELOW its real keyframe seeks to before it and lands on the one before that, which is the same fault the landing offset already exists for. The reader now declares how far its times may be, and the request carries that on top. Matroska and MP4 declare nothing, because they state instants outright — nine files and 11 665 keyframes with not one disagreement.
543
- - **Fix**: A container with no keyframe index is re-encoded rather than copied against a grid nobody knows. MPEG-TS carries no index of any kind — measured the same day, 669 real keyframes and nothing to read them from without walking the file — and a copied picture can only be cut at the source's own keyframes, so declaring an even grid is a falsehood the player punishes: it walks the whole file to rebuild the timeline, or presents audio with no picture because a segment begins with nothing decodable, both field-observed 2026-08-02. Re-encoding PLACES keyframes on our own cuts, so the grid is right by construction whatever the container. A container whose index could not be read inside the budget lands here too, for the same reason. It costs an encoder, and the alternative was a broken playlist.
544
-
545
- ## 2.49.0
546
-
547
- - **Fix**: The torrent worker is allowed to END rather than being torn down under itself. A core dump read on 2026-08-21 named the fault the proxy has been dying of: `SIGSEGV` in `v8::Value::IsArrayBufferView` reached through `napi_get_buffer_info` from utp-native's `on_utp_accept`, called from its UDP read — all of it inside `node::Environment::CleanupHandles`, under `FreeEnvironment`, on `Worker::Run`. That is a teardown race, not a data fault, which is why neither patch our forked library already carries touched it: a datagram arriving while the environment is being freed walks into an isolate that no longer exists. `destroyAll` called `Worker.terminate()` immediately after destroying the client inside, and `terminate()` frees the environment with libuv's handle callbacks still queued. It now waits for the thread to exit by itself — once the client is destroyed nothing holds its loop open — with `terminate()` kept as a five-second fallback, because a shutdown that hangs is worse than one that is forced.
548
- - **New**: A worker thread that ends is noticed. Only `message` and `error` were listened for, so when the thread went away the proxy simply stopped and the log ended mid-sentence — five times in three days with not one line to say so, and no way to tell our own shutdown from the thread dying. An `exit` handler now says which of the two it was, and fails everything waiting rather than leaving it hanging.
549
- - **New**: Core dumps are capped at the newest two at startup. Each is the worker's whole address space — 4.18 GB on the field host — and four of them had nearly filled a 235 GB disk. The newest stay because they are the evidence for the fault still open. `dumpsToRemove` is pure and tested.
550
-
551
- ## 2.48.0
552
-
553
- - **Fix**: A copied picture now begins where it was asked to, so its cuts land on the times its playlist names. ffmpeg's own CLI moves an input seek back by `3*AV_TIME_BASE / 23` — **130.435 ms** — whenever the container does not declare `AVFMT_SEEK_TO_PTS` (Matroska does not) and a stream carries B-frames, which is sound in itself: such containers seek in decode order while the caller asks in presentation order. The consequence for a copy is that asking for a keyframe lands on the one BEFORE it, deterministically; and since `-segment_times` is measured from where the run really began while this code computed those offsets from the time it asked for, every cut of the run inherited one whole keyframe interval. Field 2026-08-20: 119 of 125 segments arriving a uniform 2.002 s early against the 0.5 s hls.js bridges, so every fragment was refused and re-fetched — on 2026-08-17 two of them 1908 times each. The request is now made that much later, bounded by half the distance to the next keyframe. Measured 2026-08-21 on Matroska with keyframes every 2 s: `-ss 10` produced a first segment starting at 8.000, `-ss 10.130435` one starting at 10.000; on MP4, where the heuristic does not fire, 10, 10.130435 and 10.2 all produced 10.000 — right in one case and harmless in the other. Not applied when the picture is re-encoded: a re-encode discards frames up to the requested time and already begins exactly there (`-ss 11` copied starts at 10.000, re-encoded at 11.000).
554
- - **New**: A run that did not begin where it was asked to says so. The first piece a run produces is the only statement of its real origin that exists, and nothing compared the two — which is why the fault above stayed silent through two releases that touched the same grid. Said once per run, and only past what a player bridges.
555
- - **Chore**: The tunnel-renewal test shuts its stand-in registry down deterministically. `WebSocketServer.close` waits for every connection to end and a renewal can leave one still closing, so a full suite run could hang for nine minutes on it.
556
-
557
- ## 2.47.0
558
-
559
- - **Fix**: The tunnel is replaced before anything upstream ends it, so a viewer no longer arrives to find no proxy. Something between the proxy and the server closes the socket after exactly **100 min 15 s** — measured across a day of logs 2026-08-20, three intervals of 100:15 wherever a restart did not reset the clock, `code=1006` each time, and with the 30 s keepalive running throughout, so it is a lifetime cap and not an idle timeout. Reconnecting afterwards takes five seconds during which this proxy does not exist as far as the registry is concerned. The connection is now replaced at ninety minutes and the replacement takes over FIRST: the new socket registers itself, the server atomically supersedes the old one, and only then does the old one close — so there is no instant with nothing registered. A socket that finds itself superseded says so rather than reporting the tunnel as down, and an abrupt close nobody asked for still reconnects as before. Pinned by a test against a real WebSocket server.
560
-
561
- ## 2.46.0
562
-
563
- - **New**: The cost of DECODING is measured per codec family, not once on H.264. A video that has to be re-encoded is by definition one the browser could not play — HEVC, 10-bit — so the one model the host had was fitted on the codec it is least often asked about, and those decode dearer per pixel on the same box. There are now sets for HEVC 8-bit and HEVC Main 10 beside the H.264 one (`assets/calibration/`, four clips each: two sizes at two bitrates, the smallest grid that keeps the axes independent and still leaves a spare), the source's own codec and bit depth choose the constants, and a family with no set of its own is priced as H.264 — said in the log rather than left to be inferred. Measured on a desktop 2026-08-20, the same 1080p picture at ~5.8 Mbit/s: 7.7x as 8-bit HEVC against 6.3x as 10-bit, which is why ten bits is its own family and not a multiplier. AV1 has no set yet; the release survey of 2026-07-10 found it rare where HEVC was 18 %.
564
- - **New**: The video's bit depth is read from the probe (`parseFfmpegBitDepth`), and it travels with the source's pixel and bit rates because it decides which measurement of this host applies.
565
- - **Chore**: When the H.264 clips do not fit, the line now says which families did. Measured on a fast desktop the same day, the H.264 readings stopped being ordered — 1080p at 9.35 Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is not a thing a decoder does — so a failure there is a measurement problem and not a missing file, and the two have to be tellable apart.
566
-
567
- ## 2.45.0
568
-
569
- - **Fix**: A run is cut where the PLAYER was told the cuts are. There are two boundary tables — the live one, corrected as produced segments reveal where the file's cuts truly are, and the one the playlist text was written from — and a player places every fragment by the text it holds, which never changes. The cut list handed to ffmpeg came from the live table, so every correction moved the run away from the timeline the player is reading: measured 2026-08-20 on `Minions.and.Monsters.1080p.mkv`, 119 of 125 produced segments arrived a uniform 2.002 s before the times their playlist named, against the 0.5 s hls.js bridges, and a fragment that does not land is fetched again — on 2026-08-17 two of them 1908 times each. A seek is resolved on the same table for the same reason: the time being resolved came out of that playlist. The corrections keep their purpose, which is to describe the FILE — a variant created later inherits the corrected table and publishes it, so its own playlist and its own cuts agree from the start — but they may no longer move the cuts of a session already being read.
570
- - **New**: `FlagDefault` is read from the container itself rather than from ffmpeg's description of it. Matroska's flag DEFAULTS TO 1 and ffmpeg has applied that default by the time it prints `(default)`, so a file whose muxer marked no subtitle track is indistinguishable in the banner from one that marked every track — and the difference is the whole question, since one means "show this" and the other means "the file has no opinion". The EBML reader already walks the Tracks element for subtitle extraction and now also records whether the element was WRITTEN. Lining the two readings up is the awkward part and it is checked rather than assumed: ffmpeg numbers `0:s:N` over every subtitle stream in container order, so position is the correspondence, but each pair must agree on language or on title — one that agrees on neither, or a differing count, and the container reading is not used at all, leaving the probe's own flags with nothing claimed for them. `mergeContainerSubtitleFlags` is pure and tested.
571
- - **Chore**: The line reporting a segment that began away from its grid said "the playlist says", while the figure it prints comes from the live table. Reading that log on 2026-08-20 cost a wrong diagnosis; it now says "the grid says".
572
-
573
- ## 2.44.0
574
-
575
- - **Fix**: The cues a browser is missing are found by the order they were READ, not by where they sit in the film. Cues come out of whichever clusters are downloaded, and a torrent does not arrive in film order, so the set of known cues grows in the MIDDLE as well as at the end — and the cursor shipped in 2.43.1 was a time. Measured 2026-08-20: a viewer at 272 s was answered with cues out to 1176 s, and from that moment every cue between the two was filtered away for the rest of the session, with 59 of 276 clusters read. The subtitles the viewer was about to need had become unreachable, while cues fifteen minutes ahead kept arriving. Each cue now carries the order it was found in, `?since=<n>` selects by that, and the answer states the next cursor in `X-Subtitle-Cursor`. `?after=<seconds>` still works, for a browser that has not been reloaded.
576
- - **Fix**: One walk over the container fills EVERY subtitle track, instead of one walk per track. A Matroska cluster carries the blocks of every track that has anything to say over its span, so the bytes that answer one track answer them all — but the set of clusters already read was kept per track, so the same bytes were fetched and parsed as many times as the film has subtitle tracks. On the field film that was five reads of everything, each costing 0.2-5.2 s, for cues that together weigh a few kilobytes. The union of the tracks' cluster lists is walked once and every track is filled from it, which is also why offering all of them costs no more than offering one.
577
-
578
- ## 2.43.2
579
-
580
- - **Fix**: The proxy no longer dies without a word in the middle of a film. It was a segmentation fault in the uTP native library — `on_uv_read` parsed a sender address that a FAILED read never produced, and libuv passes null there — so a read error dereferenced a null pointer on the thread that owns the torrent client. Three core dumps in two days, each about three and a half hours into an otherwise healthy run, each with the same top frame; the last one on a swarm of 63-75 peers delivering 13 MB/s, one segment after a successful 158 Mbit/s send. Fixed in our build (`@torrent-tv/utp-native@2.5.3-ttv.3`, which this now depends on) and absent from upstream master. Detail: `research/utp-native-null-addr-2026-08-20.md`.
581
- - **Fix**: A soundtrack no longer moves the grid the picture is cut on. The boundary table is the picture's cut list — it is built from the container's keyframe index, and a copied stream can be cut nowhere else — but a produced sound segment was writing its own start into it too, and the two readings are of different things: a soundtrack has no keyframes and is cut exactly where `-segment_times` asks, to within one audio frame, while the picture's cuts are the file's real keyframes. Measured 2026-08-20 on `Minions.and.Monsters.1080p.mkv`, boundary #521 was corrected 2086.084s → 2084.082s by the picture and 2084.082s → 2086.033s by the sound 1.6 s later — **1.951 s apart**, against the 0.25 s that stops a correction and the 0.5 s a player bridges. Each reading contradicted the table the other had just written, so it never converged and the correction repeated for as long as the film ran. Only a session carrying picture may correct the grid now.
582
- - **Chore**: The line that reports a produced segment starting away from the playlist says what it measures. On the picture that is the container's keyframe index being wrong, which is what it always claimed; on a soundtrack there is no keyframe involved at all, and what it measures is how far the grid has moved since that run was launched. The per-boundary warning and the periodic summary both name the two apart now (`keyframe-index` against `sound-vs-grid`), and the summary no longer ends a soundtrack's figures with a count of keyframes read.
583
- - **Chore**: The per-boundary warning is limited to once per segment per five seconds, like the playlist-disagreement line beside it. A run keeps cutting on the `-segment_times` list it was launched with, so once the picture has moved the grid under a soundtrack every one of that run's segments deviates — and the same segment is produced and served again and again while a player refuses it. A line each time buries the first one, which is the one somebody is reading the log for. The soundtrack summary also keeps the tolerance its count was made against; only the count of keyframes read is dropped, since a soundtrack has none.
584
- - **Note, so the next field session is not read as a regression**: this closes the oscillation, not the gap. A run already producing keeps cutting on the `-segment_times` list it was launched with, so after a correction its segments still begin at pre-correction times until it is restarted — only a member whose run BEGINS at the corrected boundary is moved. Making the published grid agree with where runs really begin is the separate piece of work the code has been carrying a note about since 2026-08-17.
585
-
586
- ## 2.43.1
587
-
588
- - **New**: A subtitle request can say where the browser's copy ends (`?after=<seconds>`) and gets back only the cues past it. A track read out of downloaded clusters grows as the film does, and the browser was being sent all of it every few seconds — 76 KB a time on the field file — for the few lines at its end. The language is still detected from every cue held rather than from the handful being sent, because three lines say much less about a language than a whole track does.
589
-
590
- ## 2.43.0
591
-
592
- - **New**: An MP4's text subtitles are read the same way, and more cheaply than Matroska's. Where a Matroska cue costs whatever cluster holds it — the picture around it included — an MP4 states every sample's own byte range in its sample table (ISO/IEC 14496-12 §8.6.1.2, §8.7.3-8.7.5), so a cue costs its own few dozen bytes and nothing else. The tables are read out of the `moov` the keyframe reader already fetches: `stts` for when each cue starts and how long it lasts, `stsz` for its length, `stsc` with `stco`/`co64` for where its bytes are. `tx3g` (3GPP timed text) and `wvtt` (WebVTT in MP4) are decoded; `stpp` (TTML) is XML and is deliberately left out rather than half-shown. An empty sample is the format's way of saying nothing is on screen and is not turned into a blank cue. Same rule as before: only samples whose bytes are already downloaded are read, so a cue never costs a request.
593
- - **Chore**: The MP4 reader has its own tests over a file built byte by byte — the sample table walked into times and offsets, the gap sample dropped, a `wvtt` payload decoded, and a file with no text track answering with nothing.
594
-
595
- ## 2.42.0
596
-
597
- - **New**: Embedded text subtitles are read out of the clusters the film is already downloading, and no longer extracted with ffmpeg. Measured 2026-08-19 on `Minions.and.Monsters.1080p.mkv`: the browser asked for a track, gave up at its own 60 s limit, and the proxy answered **752 seconds later** with 3040 bytes — because a subtitle stream is sparse and ffmpeg walks the whole container whatever range is asked of it. Measured twice more to be sure: `-ss 1200 -t 4` read to the end of the file and pulled the download from 2.7 % to 81 % of 6.5 GB, and `-copyts -ss 600 -to 604` took 154 s on a copy already 81 % local and still emitted the whole track. A subtitle block sits in the same cluster as the picture around it, so those clusters are in hand anyway: the cue points of the subtitle track name them, the blocks are read where every piece covering them is already downloaded, and nothing is requested from the swarm. **Cost: zero extra bytes**, and the cues for the part being watched are ready before the viewer reaches it — which is the rule this was held to, subtitles arriving like the picture or not at all. On the field file the plan reads in 3.8 s over the swarm and names all four tracks with their languages, and the cues come out with their real times (`118.41s → 125.71s «МАГИЯ ГОЛЛИВУДА»`). A file this cannot be read from falls back to the old extraction, unchanged. `S_TEXT/UTF8` needs no conversion; `S_TEXT/ASS` and `S_TEXT/SSA` have their dialogue fields stripped; image subtitles (PGS, VobSub) are deliberately not offered, since this path cannot show them.
598
- - **Chore**: The Matroska block reader is its own module with its own tests (`services/container-index/matroska-blocks.js`): cluster time plus the block's own offset, the duration out of the block group, other tracks skipped, negative offsets placed correctly, and lacing stepped over rather than read as text.
599
-
600
- ## 2.41.0
601
-
602
- - **Fix**: An embedded subtitle track is prepared in the background and kept, instead of being extracted afresh inside a request the browser cannot hold open. Extracting one makes ffmpeg read the WHOLE film, because subtitles are interleaved through it — measured 2026-08-19 on a release with three tracks: track 0 produced **3040 bytes over 752 seconds**, track 1 76 KB over 193 s, track 2 68 KB over 55 s, with the data channel idle throughout (`maxBuffered=0`, the time all in reading the body). The browser gives up at sixty seconds, and every retry started the same twelve-minute scan again, so the first track never arrived at all. The route now starts the work once per `(source, file, track)`, answers `202 { pending: true }` while it runs, and serves the kept result the moment it exists. The scan still takes what it takes; what changes is that it happens once and its result is not thrown away.
603
- - **New**: Every read says which way it claimed its pieces, whatever the outcome. The arm — `flat` or `bands`, chosen at random per read so the two accumulate side by side — was named only beside a WAIT, and across eight sessions on 2026-08-19 there were none: the swarm kept up, the log recorded nothing, and the comparison the arms exist for could not tell whether either had ever run. A read now reports its arm, what it delivered, how long it took and how much of that was spent waiting, at its end and under every outcome. "No wait" is the result worth counting, and it was the one being discarded.
604
-
605
- ## 2.40.2
606
-
607
- - **Fix**: The second place `utp-native` read a callback result that was never written. 2.40.1 got our patched build into the loading path at last, and the process went on dying — twice within an hour, 22:32 and 22:50 — with a stack naming `on_utp_accept` rather than the `on_utp_read` the patch had covered. The code there carried the comment "will never throw due to the event being NTed in js" and then read `next` unconditionally; throwing is not the only way a callback fails, and once the environment is closing or the function reference has gone, `napi_make_callback` returns without writing anything. `next` was then whatever the stack happened to hold, and V8 dereferenced it. Both places are now guarded the same way, and every other call in that file passes NULL for the result and cannot have the fault. `@torrent-tv/utp-native@2.5.3-ttv.2`.
608
-
609
- ## 2.40.1
610
-
611
- - **Fix**: The patched `utp-native` now replaces every copy in the tree, not just the top one. Installed at this package's own level, it left `webtorrent/node_modules/utp-native` untouched — and Node resolves from the requiring module outward, so WebTorrent went on loading the published build with the defect in it. The crash of 2026-08-19 21:03 names that exact path in frame 2, and every earlier one did too: the substitution shipped in 2.36.2 was never once in the loading path. `overrides` in this package's manifest now redirects the whole tree, npm applies it because a global install makes this package the root, and the addon image additionally deletes any nested copy and FAILS THE BUILD if a surviving `utp_native.node` belongs to another package. A silent fallback to the broken one is what made a fix that changed nothing look like a fix that worked.
612
-
613
- ## 2.40.0
614
-
615
- - **New**: What a reader wants is claimed in four bands of decreasing urgency instead of one, and which way it claims is decided per read so the two can be compared on real viewing. Until now there was one band at priority 1 with WebTorrent's own whole-file selection at 0 beneath it, so "what the viewer reaches in seconds" and "the rest of the film" were the same thing to the picker. The bands are: what the viewer reaches in seconds, anchored at the first piece not already held; the near lead; the far lead; and, only once the lead has covered everything to the end of the file, whatever was never downloaded BEHIND the position — which a backward seek needs and which must never compete with the picture being watched. Priorities are 4, 3, 2, 1 and none of them zero, because zero is where the library's own background fill sits, and they are distinct because the library deliberately shuffles selections of equal non-zero priority against each other.
616
- - **New**: The widths of the lead bands are derived from what has been measured about this file on this swarm, not chosen. The near band covers the worst interruption this reader has actually met (`worst wait × the rate the consumer is taking bytes at`); the far band covers what the swarm can put ahead of the viewer between interruptions (`(download rate - consumption) × the median interval between them`). A swarm with no surplus produces no far band, which is right: there is nothing to get ahead with. The consumption rate is measured by the reader as it hands bytes over, so nothing has to be passed in or assumed about who is reading. Until two interruptions have been seen there are no figures and both bands fall back to the reader's own window, and the log says which of the two it is.
617
- - **New**: Every wait line names the mode it happened under and the bands as they stood (`mode=bands p4:340-352 p3:353-370 p2:371-500`), and the periodic supply summary compares the two arms directly — `flat N waits median Xms worst Yms, bands M waits median Zms worst Wms` — appearing only once both have samples. `TORRENT_TV_READ_MODE=flat|bands` pins one arm for a deployment that wants no alternation.
618
- - **New**: A wait is also recorded against whether the blocked piece was steered onto another peer at all, and the summary states both — `steered N waits median Xms, unsteered M waits median Yms`. The steering itself has been logged per wait since 2.29.0; what could not be read from it was what it bought, which is a difference between waits and not a property of one.
619
-
620
- ## 2.39.1
621
-
622
- - **Fix**: The block duplication of 2.39.0 is removed, because measured against what a viewer actually feels it never paid. 2.39.0 was shipped on a measurement of the median wait for a piece; the quantity that matters is the seconds the picture stands still, and measured on that (2026-08-19, a reader paced at the film's own byte rate with an eight-second lead, arms alternated per position) it is neutral at best and costly at worst. On a well-seeded film every arm read 67 MB in 59 s and stopped for at most 2.6 s — nothing to improve. With the download capped just above the film's rate, which is what a home line IS whenever the swarm can fill it, duplication made the picture stop for **28.8 s against 12.8 s** at one position and left the other unchanged: under one shared budget a duplicate spends the very bytes it is trying to go around. The regime where it could pay — several slow peers, each with capacity of its own — could not be reproduced: the thin-swarm candidate turned out to have no live swarm at all (4 peers, 179.5 s of a 209 s run spent stopped). A lever with no measured gain and a measured cost does not stay on by default. Narrowing the read window to the blocked piece was tried in the same experiment and is not shipped for the same reason: it was never better and reached **44.8 s against 12.8 s**. What stays is the measurement that decided it (`research/tail-duplication-2026-08-19.md`) and the `tail …` line from 2.38.1, which is what will say whether a real thin swarm ever behaves differently.
623
-
624
- ## 2.39.0
625
-
626
- - **New**: When a reader is blocked and nothing can be steered, the blocks it is still waiting on are asked of a second wire as well. WebTorrent reserves each block for exactly one wire, so once `Piece.reserve()` answers -1 the read ends when the holder of the last block delivers it, however fast the rest of the swarm is. The library's own `_hotswap` does exactly the right thing — `piece.cancel(block)` frees the reservation while the first request stays in flight — but only for a wire under 48 KB/s and twice as slow as the asker, and measured on a real swarm the tails a reader waits on sit at 109-886 KB/s. Peers the library rightly calls good, because for bulk downloading they are; the gate is about throughput across a torrent and knows nothing about a reader blocked on one piece now. Speed is not even what is wrong with them: two blocks — 32 KB — on a wire measured at 109 KB/s is 0.3 s of transfer, and that read waited 4.6 s, because the blocks are queued behind that wire's other work. **Measured against itself, same film, same positions, arms alternated, six pairs across two pacing rates: the median wait for a piece fell in all six — 39 %, 48 %, 68 %, 6 %, 50 % and 28 %.** In the one pair where the swarm had spare capacity throughout, the wait fell 7226→5174 ms and the lead the reader kept ended at +2.0 MB instead of +0.3 MB. Cost: one duplicate per candidate wire per attempt, about 3 % extra traffic, and only while a reader is blocked with every block already spoken for. The tail measurement added in 2.38.1 reports what it placed: `duplicated 14 blocks`.
627
- - **Chore**: The first attempt at this was reverted the same day on a measurement that could not have shown it. That probe read 64 MB flat out, so its total time was bytes ÷ aggregate rate by construction — a quantity no reordering of requests can change. Reordering moves latency between pieces; it does not add throughput. The corrected probe consumes at a film's own byte rate and keeps a lead, which is the state a viewer is in, and there the effect is plain. Recorded in `research/blocked-piece-tail-2026-08-19.md` so the next such decision is not made on the wrong quantity.
628
-
629
- ## 2.38.1
630
-
631
- - **New**: When a blocked piece cannot be steered anywhere, the wait line says what is holding it. The steering added in 2.29.0 often places nothing — `steered onto 0 of 9 asks (8 peers held it)`, measured 2026-08-18 while eight peers had the piece — because every block is already reserved and WebTorrent will not hand out a second request for the same block (`Piece.reserve()` answers -1; the only mention of an endgame in the library is a commented-out line). Duplicating those blocks is the standard remedy and costs a block's traffic each time, so this measures the tail before anything is built on it: `tail 2/512 blocks missing, held by 1@12KB/s 1@900KB/s`, slowest wire first, and `held by nobody` when the piece has not been asked for at all. Sampled at the instant an attempt placed nothing rather than once at the start, so the numbers and the reason they are printed describe the same moment. If the missing blocks turn out to sit on one slow wire, duplication is aimed at the right thing; if they are spread across fast ones, the wait has another cause and that work should not be done.
632
-
633
- ## 2.38.0
634
-
635
- - **Fix**: An MP4's keyframe times are read as composition times, on the track the handler names. Two faults, both measured on real releases over the swarm (`research/mp4-composition-times-2026-08-19.md`). (1) The reader took sample times from `stts`, which is DECODE order, and used neither `ctts` nor `elst`: ISO/IEC 14496-12 says `CT(n) = DT(n) + CTTS(n)` (§8.6.1.3) and the edit list then shifts that (§8.6.6.3). Every LostFilm MP4 measured carries a composition offset AND an edit list cancelling it exactly, which is why decode times had been right on them; `Firefly.S01E03.720p.mp4` carries the same 2002-tick offset with NO edit list, and its times were **62.1 ms early on all 34 keyframes** compared against ffmpeg's own `pts_time` — a constant that closes to four decimals as offset (0.08342 s) minus the container start (0.02133 s). After the fix that file matches ffmpeg to the container start, which `computeSegmentBoundaries` already subtracts, and `Superman.720p` — where the terms cancel — is unchanged and exact to 0.0000 s. Version 1 offsets are read as SIGNED, which is what that version exists for; an empty edit (`media_time = -1`) is skipped rather than treated as a shift. (2) The video track was "the first one carrying sync samples", and the handler was never read. That worked only because all seven measured files put video first; the standard identifies a track by `hdlr`, and a file whose audio track carries sync samples, or one leading with a cover-art video track, would have been read from the wrong place — the same defect fixed in the Matroska reader the day before, arrived at from the other side.
636
-
637
- ## 2.37.1
638
-
639
- - **Fix**: The cost of a seek no longer counts against the quality offer. `requiredSpeed` — the speed a step must sustain to survive a swarm — is built from the reader's interruptions, and the wait on the first piece after a JUMP is not one of them: those pieces have not been asked for yet and the encoder is restarting, so it measures the move, not the supply. Measured 2026-08-18: `proxy now offers 720p` landed 131 ms after a seek, collapsing a five-rung menu to one while the player was already hunting for a fragment, and another session churned `640p` → `640p 540p` → `640p 240p`. The wait is still reported, saying plainly that it belongs to the jump and is not counted, so a gap in the history cannot be mistaken for a swarm that never made the reader wait.
640
-
641
- ## 2.37.0
642
-
643
- - **Fix**: The segment the viewer seeks TO is no longer refused as stale. A seek bumps the wait epoch so requests made for the position being LEFT stop being held, and the epoch alone cannot tell those apart from the request for the position just arrived at — hls.js asks for it within milliseconds of the seek, and it raced the bump. Measured 2026-08-18: a seek to 1061.0 s, `segment-00101` answered 503 twice within 80 ms, the player never asked for it again, and instead re-fetched `a/0/segment-00103` and `a/0/segment-00104` **737 and 736 times over 149 seconds** — about half a gigabyte of the same two segments — while the picture stood at `t=1061.0s readyState=1` until the session ended. A held request is now released only when its segment lies behind where the viewer now is, or so far ahead that the running encode will not reach it; anything between is what the viewer is waiting for and is held.
644
- - **New**: The log survives the container. `--log-file <path>` writes every line to a file as well as the console, appending across restarts and rotating at 32 MB with one previous turn kept. The console is the container's stdout, and the container is exactly what does not survive a crash: thirteen SIGSEGVs on 2026-08-18 each had the watchdog recreate it, taking every line before the crash away, and a deploy of ours destroyed the evidence for two field reports the same day. Opt-in and named by the caller, so nothing here assumes Home Assistant — the addon points it at `/data`.
645
- - **New**: A refusal says what it refused. `[hold] <segment> refused: the viewer is at <position>s and this is not the segment there`, and a request kept across a seek says so too. The old line said only "superseded", which is why the freeze above took a day to explain.
646
-
647
- ## 2.36.2
648
-
649
- - **Fix**: A live session no longer answers 404 to the master playlist it has just published. The browser is handed `master.m3u8` when the session is created, and which rungs are worth OFFERING is recomputed every five seconds — so on 2026-08-18 a five-rung offer became a one-rung offer **192 ms** after creation (the session's own encoder started, charging the contention penalty of 2.35.0, and the first supply reading raised the bar of 2.36.0 from 1.00x to 1.06x), `buildMasterPlaylist` returned null for having fewer than two rungs, and the master answered 404. hls.js treats that as fatal and unrecoverable, so nothing played at all. The master now lists what CAN be spliced onto this session's cut grid — a fact about the source, settled once — while the live judgement stays where it belongs, in `offeredHeights` and in every progress report, which is what the viewer's menu already follows. The variant routes honour the published set too, so a quality switch can no longer meet a 404 on a rung the master named.
650
- - **Fix**: Peer discovery no longer starves behind name resolution. Node resolves host names on the libuv thread pool, which holds four threads by default; a torrent announces to every tracker in its file at once, so four names resolve and the rest queue — and a tracker that no longer exists holds its thread for the resolver's full ten-second timeout while every announce behind it blows its own fifteen-second deadline. Measured inside the addon container: the ten trackers of one film took **7.58 s** to resolve as a burst and **27-42 ms** each with a larger pool. That film has 517 seeders on a tracker that answers in 50 ms, and it spent eleven minutes with **zero peers** while four other torrents in the same process were fine — they were the ones whose live trackers happened to fall in the first four. The pool is now stated before anything can create it (`services/thread-pool.js`, imported first by the entry point), and a deployment that states its own size is left alone.
651
-
652
- ## 2.36.1
653
-
654
- - **Fix**: The cut list of a copied picture is built from the picture's own keyframes, and no longer from every entry in the container's table. A Matroska CuePoint belongs to the track named inside it, and RFC 9559 leaves the muxer free to index whichever tracks it likes — both field files index their subtitles as well. Measured over the swarm on 2026-08-18, reading only the head and the table: `Minions.and.Monsters.1080p.mkv` has **2778 video entries, one every 2.002 s, and 4669 more across four subtitle tracks**; `Moana.2.2024.720p.BluRay … MegaPeer.mkv` has **1055 video entries and 5007 across five**. Read without the track, the extra times entered the cut list as though they were keyframes; ffmpeg can only cut a copied picture at a real keyframe at or after the time it is given, so each such cut landed at the next one instead — which is exactly the disagreement the field measured, and why it was always positive: 2.002 s on the first file (its own keyframe spacing), a median of 6.3 s and a worst case of 21 s on the second. The reader now takes the first video track's number from Tracks — already inside the head it fetches, with one short extra read only for a file that keeps Tracks elsewhere — and keeps the entries of that track. Nothing else about the two-read approach changes, and a session costs nothing more. With the fix the same two files read 2778 and 1055 times, all of them keyframes. When the filter leaves NOTHING — a table whose entries name a track number Tracks never declares — the unfiltered table is used rather than no table: that case is this reader failing to recognise the file, and answering with nothing would put an even grid on a copied picture, which is the failure it exists to prevent.
655
- - **Chore**: `scripts/read-container-index.mjs` reads the index of any `.torrent` over the swarm — two short ranged reads, in memory, no file written — so a claim about what a container says can be checked against a real film in seconds. Written after the measurement above was made by hand three times.
656
-
657
- ## 2.36.0
658
-
659
- - **New**: The torrent is charged for the megabytes it is measured to be moving, and the price it is charged at no longer contains work that is not the torrent's. Two faults, both visible in one field log from the addon host (2026-08-18): the same session reported **145.4 ms of CPU per MB over 8.7 MB and 23.1 ms per MB over 54 MB**, a sixfold disagreement that followed the size of the interval rather than anything about the torrent — because a process with nothing to do still runs its timers, its tunnel and its session sweeps, and that draw does not shrink when fewer megabytes move. A minimum-megabytes threshold stood against exactly this and did not hold, because a chosen number was standing in for a measured one. The draw is now measured directly, in the intervals where nothing encodes and not one byte moves, and subtracted before the rest is called the torrent's (`services/torrent-cost.js`). What the threshold was reaching for is arrived at from the readings instead: the draw's own readings disagree by a measured amount, that disagreement is worth `scatter × elapsed` seconds over an interval, and a remainder smaller than it measures the wobble in the subtraction rather than the torrent — so a small interval fails on the same arithmetic that lets a large one through, with no size chosen anywhere. The second fault: the price was then charged against the file's own byte rate — what the viewer consumes — so a fully downloaded file moving nothing still paid, and a file being fetched ahead of the viewer, which is how every session starts, paid too little. It is charged against the rate the torrent is measured to be moving, sampled every five seconds per watched torrent and divided among the files of it being read, so two episodes of one pack do not each pay for the whole download.
660
- - **New**: No separate penalty for downloading, because the measurement says none is needed. The readings of 2026-08-18 have decoding at 10.1-11.2x with nothing running and 6.45-6.71x while the torrent pulled 1.8 MB/s — an extra 0.050-0.066 seconds of work per second of content, which is **28-37 ms per megabyte moved**, against the 20-35 ms/MB the host measures for itself. The download's effect on other work is the processor time it consumes, and that is already priced; unlike a second encoder, which costs 2.71× and is not explained by any sum. So this closes roadmap item 6 with a subtraction rather than another multiplier.
661
- - **New**: A quality step is judged against the speed this file's own supply demands, not against a chosen margin. `1.5` (and `1.8` where decoding had no price) stood for "faster than realtime by enough", and what "enough" means is measured per file and per swarm by the reader that waits for the pieces: `1 + worst wait / median interval`, which the proxy has been printing since 2.30.0 without using. On the field torrent of 2026-08-17 that is 1.67, and on the 720p rip of the same evening 4.04-8.14 — a torrent on which no re-encoded step could have kept up, and which a fixed 1.5 admitted. Where the swarm has not been measured yet — fewer than two interruptions — the bar is realtime, which is the one thing that can be said without measuring it, and the offer is restated as soon as the reader has something to say. The refusal line names the figure it refused against and where it came from. One chosen number survives, and only where nothing can be measured: a host whose calibration produced no decode term at all is judged on an encode-only prediction that was several times optimistic in the field, and its bar stays at the 1.8 it has had since before decoding was priced, because lowering it to realtime would make the least-measured hosts the most permissive.
662
- - **Chore**: Two learning thresholds removed, both of which were chosen numbers standing in for measurements. A reading of an encoder's speed is no longer withheld for the first twenty seconds of a run: each sample now carries the serial of the run it was taken from and a pair whose serials differ is discarded, which is what the wait stood in for — a restart spends up to a second and a half making its directory and burying its predecessor, and a sample taken in that window carries the old run's position, so paired with the new one it reads a twenty-minute seek as twenty minutes produced in five seconds. The wait cost every reading a short run could have given, which is how a rung spent three minutes below realtime teaching nothing (2026-08-15); the serial costs none. And a new median is adopted when it has moved further than the readings behind it disagree with each other, instead of by more than five per cent (`services/learned-median.js`). What remains chosen is the length of the history a median is taken over, and it is now written down as such rather than given a measured-sounding reason.
663
-
664
- ## 2.35.0
665
-
666
- - **New**: A second job's cost is measured on the host instead of being added as though jobs were independent. Measured on the addon host 2026-08-18, decoding the same clip: **2.10-2.25x alone, 0.79-0.90x with one encoder beside it, 0.56-0.64x with two** — the same work costs 2.6× more for having company, and 3.7× for having two. Heat is not the cause: the hot idle machine (68 °C, a lower reported clock) was the fastest reading of all, which settles what roadmap item 6 was opened for. Four cores sharing one path to memory is the cause, and it contradicts the SHAPE of the budget rather than its constants — everything in the quality offer adds seconds of work per second of content, and these readings say two jobs that each fit alone do not fit together. So the penalty is now measured at startup the way everything else is (the cheapest clip decoded alone, then again while an encoder of it runs), and the offer multiplies a step's cost by it when anything else is encoding. Beyond the readings it holds the largest rather than extrapolating: two points say nothing about a fourth job, and a budget that guesses at a memory bottleneck will be wrong in whichever direction it guesses. With nothing measured, nothing is corrected. This is separate from the availability share of 2.33.0, which removes work nobody has been charged for; this is our own work colliding with itself.
667
-
668
- ## 2.34.0
669
-
670
- - **New**: The proxy tells the browser the smallest buffer at which no interruption reaches the viewer, measured on the file being watched. It is one whole segment — the one being played — plus the worst wait its own reader met before the buffer could refill, from that file's recent interruptions on that swarm. On the field torrent of 2026-08-17 that is 7-9 s, where the browser has been waiting for a hand-chosen 25: sixteen seconds of spinner that nothing had shown to be necessary. Null until the reader has seen two interruptions — one wait shows no interval, and an interval invented from one point is what this work exists to remove — and the browser keeps its own figure until then. The reader measures it, the session manager states it with its own segment length, and the progress reply carries it.
671
- - **Chore**: Removed `services/torrent-worker/supply-interruptions.js`, a second copy of the same arithmetic that was wired to nothing.
672
-
673
- ## 2.33.0
674
-
675
- - **New**: A quality step is judged on the machine it will actually run on. The encoder benchmark measures a QUIET host — one ffmpeg and nothing else — while the addon host was measured 99 % busy, and a step predicted at 1.83x ran at 1.01-1.12x (2026-08-17). The offer now multiplies each prediction by the share of the machine that is free, taken from the same `host-load` reading that is already printed every five seconds. What is subtracted is ONLY the work nobody has been charged for — the kernel, the container, whatever else the owner runs — because our own encoders are already priced by the concurrency arithmetic and the proxy's own work per megabyte moved. Charging those here as well is what shipped in 2.21.0 and emptied the quality menu down to a single copied height. On the field reading the correction is about 0.77, and the "not offering" line now says what the machine had to spare when it decided.
676
- - **New**: Each step reports what its prediction was worth. When a step runs with the machine to itself, the log states the speed it was predicted at, the speed it measured, and the ratio — so the error that REMAINS after the availability correction is a number in the field rather than an argument. It is written when it moves by more than a tenth, so a steady step says it once. On the field case that correction takes 1.83x to 1.41x against 1.01-1.12x measured: part of the gap, not all of it, and this line is how the rest gets found.
677
-
678
- ## 2.32.0
679
-
680
- - **New**: The decode cost is fitted from a clip set that can be checked, and a term the measurements do not determine is refused instead of published as a zero. The set that shipped until now was three clips for three unknowns — an EXACT system, with two of the clips at the same pixel rate — and such a system cannot fail visibly: it returns whatever satisfies its equations. On 2026-08-17 it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`, so a film's own bitrate never entered its price, and the prediction built on it was 1.8-2.2x optimistic against the same file measured while playing. The new set is six clips — three sizes × two bitrates, the axes varied INDEPENDENTLY — cut from the same Netflix Open Content "Meridian" footage (CC BY 4.0, `assets/calibration/NOTICE.md`), 7.7 MB against 8.8 MB before. Three spare measurements give the fit a residual, and with it two questions it could not ask before: whether a term's whole effect across the measured range exceeds the scatter, and whether the coefficient exceeds its own standard error. A term that fails either is dropped, the rest are fitted again, and the log names it — a zero now means "not measured" only when it says so. A NEGATIVE coefficient is dropped too rather than clamped to zero: more pixels cannot cost less work, so a negative fit is noise beating an effect, not a discovery about the host. Measured on the developer's machine, the new set determines all three terms (`0.000520 × Mpx/s + 0.002086 × Mbit/s + 0.0033 s/s`, typical disagreement 0.0012 s/s), and the bitrate term it recovers matches the difference between the two 1080p clips to 15 %. The arithmetic is a pure module with the degenerate case as a test (`services/decode-cost-fit.js`).
681
- - **Chore**: What the calibration costs at startup, measured rather than assumed: six clips take 14.8 s on the developer's Windows box, of which the decoding is about 50 ms per clip — an empty ffmpeg spawn there costs 774 ms, and opening each file most of the rest. Shortening the clips would therefore save nothing; the cost is spawning ffmpeg once per clip, and it is paid before any viewer exists.
682
-
683
- ## 2.31.0
684
-
685
- - **Chore**: The encoder run's two status strings are gone; both are now outputs of the state table shipped in 2.23.0. `session.progress.state` was maintained by hand at seven sites and `session.state` at nine, and neither could answer on its own — the warm-up test had to read both under an `||`, because one said "starting" from the first spawn until something overwrote it while the other said it again on its own schedule. What the browser is told is computed where it is sent (`wireState(runState)`), and `session.state` is reduced to the session's own lifetime: it exists, or it has been disposed. That deletes the line in the spawn path that read `state === "disposed" ? "disposed" : "starting"` — two lifetimes in one variable, which is what it was there to paper over. Verified before the change that nothing in the browser reads the wire string, so the value set is unchanged and unobserved either way; the four values it can take are the same four as before.
686
-
687
- ## 2.30.2
688
-
689
- - **Fix**: The cut-time shift of 2.28.0 is reverted — the field measured it and it moved the cuts OFF the source's keyframes rather than onto them. Of 75 pieces the picture produced afterwards, only **nine** began at a time the container's own table names, against **70 of 75** for the soundtrack, which the change never touched; the median distance from the playlist went from 0.04 s to 4.33 s. Before it, every piece began exactly on a named keyframe and it was the playlist that disagreed with them — which is the correction path's business, not the cut list's. The reasoning that produced the shift (that the muxer decides its cuts before the output is relabelled) was argued from ffmpeg's semantics rather than measured, and the measurement says otherwise.
690
- - **Fix**: The steering line compared two different things. `steered onto N of M holders` summed the successes over every attempt of a wait while taking M from the last attempt alone, which is how the log came to read `steered onto 12 of 6 holders`. Both halves are now totals over the same attempts: `steered onto N of M asks (K peers held it)`.
691
-
692
- ## 2.30.1
693
-
694
- - **Fix**: A seek was undone a second after it was made. Measured 2026-08-17: the viewer jumped to 2083.4 s, both runs restarted at segment #373 — correctly — and then a request for #371, issued by the player BEFORE the jump and reissued a second later, dragged the encoder back to #370. The viewer sat at #374 waiting for it to return. Two things let that happen, and both are fixed. The behind-head repair refuses a request that is behind the position the VIEWER themselves reported: its existing guard only holds while a seek is still settling, which by then it was not. And a segment request may no longer move the recorded viewer position BACKWARDS past a reported seek — playback only ever moves forward from one, so nothing legitimate is lost, while a stale request can no longer rewrite the viewer's own statement, which is how the repair came to believe it. A reported seek is the viewer stating where they are; a request is evidence about where the player is reading, and evidence may refine a statement forward, never contradict it backwards. Pinned by `test/stale-request-after-seek.test.js`, whose control case shows the same traffic still repairing a genuinely misplaced run when the viewer has said nothing.
695
-
696
- ## 2.30.0
697
-
698
- - **New**: The speed a step must sustain, and the smallest buffer that hides an interruption, are now COMPUTED from the supply's own behaviour instead of being chosen by hand — printed first, used later. A step producing at `v` gains `v - 1` seconds of cushion per second and an interruption of `W` seconds costs `W`, so it survives its own supply only while `(v - 1) × T > W`, that is `v > 1 + W / T`, with `W` the worst recent wait for a piece and `T` the median interval between such waits. On the field torrent of 2026-08-17 that is **2.42x**, against the 1.5 assumed today and the 1.05 measured on the step that stalled; on the same file's copied stream it is 1.31 against 8x measured, which is why a copy never stalls. The buffer follows from the same readings: one whole segment — the one being played — plus the worst interruption that can arrive before it refills, whichever source it comes from, which was **7-9 s** where the browser waits for 25. Both figures are logged per file every half minute, so the next session says whether the arithmetic describes reality BEFORE anything is decided by it. The arithmetic is a pure module with the field session's own numbers as its tests (`services/supply-margin.js`).
699
-
700
- ## 2.29.0
701
-
702
- - **New**: A piece a reader is blocked on is handed to the fastest peers that hold it. Measured 2026-08-17: the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s — a fivefold surplus — and the reader still blocked 47 times in two minutes, 1.0-4.5 s each, on pieces a median of five peers already had. A block belongs to exactly one wire, so the read ends when the SLOWEST holder delivers, and `critical()` only lets the library take a block from a slow wire when its own picker happens to visit an idle one. This asks for it deliberately: when the wait starts, and again on the sampling tick that already runs while it lasts, the piece is pushed onto the three fastest unchoked holders through the library's own request entry with hotswap enabled. Nothing is duplicated — the library moves a block to a wire at least twice as fast, which bounds how often it can move at all. A refusal is counted rather than ignored (a full pipeline, or nothing reservable even with hotswap, means the piece waits on the wire and not on the picker), and a build that offers no such entry says so instead of failing silently. The wait line now reports `steered onto N of M holders`, so the next session says by number whether the tail shortened.
703
-
704
- ## 2.28.0
705
-
706
- - **Fix**: The playlist and the media agree again, and the container's keyframe table was never at fault. On a file whose first timestamp is 2.002 s, the copied picture was asked to cut at 808.808 s on the 0-based grid and cut at 806.806 s — exactly the container's start time early, because that branch keeps the source's own timestamps and re-labels the output afterwards, so a cut list stated in 0-based terms is applied 2 s away from where it means. The soundtrack, re-encoded and on the other branch, cut where it was asked. The two then wrote different values into the shared boundary table and corrected each other for the whole session (#202: 808.808 → 806.806 → 808.750 → …), the playlist drifted a whole segment from the media, and the player refetched fragments it could not place. The cut list is now stated in the source's terms on that branch, which is the same shift the seek on it already applies.
707
- - **Chore**: Which timeline a session works on is answered by one exported predicate instead of two expressions that could disagree — and their disagreement is exactly what desynced picture from sound. Pinned by `test/cut-times-timeline.test.js`, with the field numbers in its header.
708
-
709
- ## 2.27.0
710
-
711
- - **Fix**: Picture and sound now begin a run at the same instant. They were asked for the same time and landed in different places: a copied picture may begin only at a real keyframe and may not begin before the time asked for — that content belongs to the previous segment — so it moves FORWARD to the next keyframe, by up to the keyframe spacing (0.58-2.96 s measured on the field file); a soundtrack has no keyframes and begins exactly where asked, to within one audio frame. So after every seek the two runs of one film began up to three seconds apart. The picture's true start is measured from the piece it produces, and that measurement now moves every other member of the family whose run begins at the same boundary. Restarted at the boundary rather than seeked to the time, deliberately: a seek decides by segment index, finds the run already begins there and answers "already within the running encode" — true about the index and false about the instant, which is why the first version of this fix moved nothing at all.
712
-
713
- ## 2.26.0
714
-
715
- - **New**: The keyframe-index measurement now answers the question it was raising. Each file's summary reports the distribution of how far produced segments fell from the playlist (median and worst, not one extreme), how many keyframes were read from the container, and — the discriminator — **how many of the disagreeing segments began at ANOTHER time the same table names**. That separates the two explanations that have been argued rather than measured: a table describing times the file does not have, against a table listing only some keyframes with our grid built over its gaps. Every deviation measured on 2026-08-17 was positive, 0.58-2.96 s, which is what a cut pushed forward to the next real keyframe looks like. The summary is also written every 25 distinct boundaries instead of only when a session is disposed, because a proxy restart — every addon update is one — takes its sessions with it and the summary was routinely never written.
716
-
717
- ## 2.25.1
718
-
719
- - **Fix**: Picture and sound are back in step. Two releases in a row moved a segment's stamp toward the playlist — 2.24.1 per session, 2.25.0 by one offset for the whole family — and both desynced playback in the field the same day. The reason is what the first segment of a run is: it is not CUT at all, it begins where ffmpeg's seek landed, and the picture must land on a keyframe while the sound needs none, so after every restart the two runs genuinely begin at different real times and the whole run carries that difference (measured: the sound's #292 began at 1587.892 s and #293 at 1592.692 s, one segment apart, the run shifted 2.5 s from the grid). Labelling each track with its own true time is what keeps them together in real time; a segment is stamped with its own start again, as it was for weeks before 2.24.1. What stays from those releases is the part that was right: one published timeline per family, and a warning when a piece lands further from the playlist than a player will bridge.
720
- - **Chore**: The run's state now answers the questions its process handle used to be asked. Ten sites that re-derived "is this run alive" from a child-process handle, and every read of "is it suspended", now read the state machine shipped in 2.23.0; the `encoderPaused` field is gone. The two places that ask about a NAMED process — the predecessor a restart is replacing, and a deliberate stop — still ask the OS, which remains the authority on whether a pid exists.
721
-
722
- ## 2.25.0
723
-
724
- - **Fix**: Picture and sound drifted apart after a seek, by exactly the amount the grid had been corrected. 2.24.1 made every segment stamp itself against the playlist its own session published — but each session froze that playlist at its own creation, and a soundtrack or a quality step is created later than the picture it accompanies, so it froze a table that had since been corrected. Two members of one family then stated the same moment differently, and the corrections measured on the field file are 0.6-2.9 s. A family now publishes ONE timeline: a session created inside a family takes its base's published table verbatim and writes its own playlist from it, while the live table goes on being corrected for cutting, which is what keeps a re-encoded step aligned with the copy it joins.
725
- - **New**: The read window grows into a lead instead of staying a fixed length. Every wait that cost time widens it by a piece; every piece already in hand gives one back, down to the size the caller sized from the file's own byte rate. The ceiling is this reader's share of the store's memory, so widening can never ask for more than the store can hold. Measured 2026-08-17, the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s while the reader still blocked 47 times in two minutes — a fivefold surplus that never became distance ahead of the head.
726
-
727
- ## 2.24.1
728
-
729
- - **Fix**: Seeking could leave a film dead. After a seek the encoder restarts at the segment before the target, and every segment it then produces states its own position, read out of the piece. On a file whose container index is wrong those positions disagree with the playlist the player is holding — measured 2026-08-17, a seek to 1590.4 s produced audio segments #292 and #293 carrying 1587.892 s and 1592.692 s against a playlist saying 1585.376 s and 1590.585 s. A fragment landing further from where the playlist put it than a player will bridge (hls.js bridges `maxBufferHole`, 0.5 s by default) is not recognised as buffered, so the browser asks for it again: those two segments were fetched **1908 times each over ten minutes**, every one served in 4 ms, with the picture frozen and nothing in either log saying why. A segment is now stamped where the playlist the player holds says it begins, whenever the piece's own figure is further away than that; within it the piece's own figure is kept, which is what keeps speech and subtitles together on a file whose index is slightly out. The boundary table goes on being corrected from produced segments — that is what lets a re-encoded step be cut like the copy it joins — but the correction no longer moves segments under a player holding the original playlist: the published table is frozen when the playlist text is written from it. Pinned by `test/published-timeline.test.js` with the field figures.
730
-
731
- ## 2.24.0
732
-
733
- - **New**: The budget's two remaining holes are closed, which is the rest of roadmap item 6. A soundtrack published on its own is a second encoder running for as long as the picture does, and it was charged at nothing; a picture being RE-ENCODED beside the step being judged — which is what every quality switch does, two encoders on purpose — was charged at nothing too. Both are priced now: the soundtrack from its own measured speed, the second picture from what it was last seen doing alone, falling back to the same model that judges every step. Nothing is charged for an encode nobody has measured and no model can price: a guess there would refuse steps on arithmetic no one performed.
734
- - **Fix**: A soundtrack could never have been priced as shipped. Four things each made it impossible: the reading path refused renditions a measurement outright, the only call that would have filed one sat behind a guard its caller had already made, a family never contained its renditions at all — so every sum over the family missed them — and the rule that a price may only be learned from an encoder alone on the machine excludes a rendition by construction, since it runs for exactly as long as the picture it accompanies. All four found by review before release. A soundtrack's share is now recovered by subtracting what the machine is already known to be spending, which is the same arithmetic that recovers a source's decoding from a running encoder, and only when every other running encode has a price — unpriced work must never land in the soundtrack's account, because an overpriced soundtrack refuses steps the host could hold.
735
- - **Fix**: A quality step being warmed was charged its own cost while being judged, so the step the viewer had just asked for was dropped from the offer by the act of warming it — and with every route guard reading that list, its next segment would answer 404 on a stream that was playing. On the field figures of 2026-08-15 that is 1.83x judged as 1.03x. A height is now judged against what the machine spends on everything EXCEPT it.
736
- - **Fix**: A speed measured while the torrent was what was short is no longer filed as an encode's price. It was recorded before the check that exists to reject it, so a run starved for twenty seconds priced itself at three seconds of work per second of video — more than the machine has — and every other step was refused on the download's account.
737
- - **Fix**: The "not offering" line is written when the ANSWER changes, not every time it is recomputed — this is asked on the path that serves every playlist, init and segment, so an unconditional line was about seven hundred identical lines an hour into a log buffer that holds five hundred.
738
- - **Fix**: A speed measured before a downshift no longer prices the encode that replaced it. It described a picture the session had stopped producing, and it kept the step it was measured on withdrawn from the offer although nothing was producing that step any more.
739
- - **Fix**: The offered list is recomputed when what an encoder was last seen doing changes. That figure both withdraws a step measured below realtime and prices every running picture, and it was missing from what identifies a cached answer — on a COPIED picture nothing else in the key ever moves, so the menu could stay pinned to what was computed before anything had been measured. It enters the key as the two decisions it feeds — below realtime or not, and the cost rounded — rather than as a raw speed, which moves on every reading and would defeat the cache on the hottest path in the proxy.
740
-
741
- ## 2.23.0
742
-
743
- - **New**: The encoder run is a transition table, and the table is the specification rather than a description of code written elsewhere. `services/encode-run-state.js` declares eight states, ten events named for what happened, two superstates and the answers each state gives — whether the input is being read, whether the process can be signalled, what a missing segment gets, what the browser is told, whether a restart is allowed. What it buys is not tidiness: five field failures in a row were empty cells — a pair of state and event nobody had considered — and a table makes an empty cell visible before a release. The edges that must NEVER exist are data too, each naming the release it cost: the 2.9.93 sawtooth where any segment request released a suspended encoder, and the 2.9.93 dead-run shortcut where the handle pointed at a corpse and every later seek was waved through as already covered.
744
- - **New**: Every transition a real run makes is logged as state, event and target (`run-state <id> STARTING --FIRST_SEGMENT--> PRODUCING`), and a pair the table does not declare is logged as a refusal instead of being obeyed. Nothing READS the state yet — the fields it will replace keep their current writes — because whether the model matches reality is a measurement to take in the field, not an assumption to build on. This release exists to take it.
745
- - **New**: The picture in `docs/encode-run-state.md` is rendered FROM the table (`npm run graph`), and a test regenerates it and compares, so a drawing that disagrees with the code cannot be committed.
746
- - **Fix**: The ffmpeg a seek kills is no longer handled as the session's own run dying — and this was found by writing the table down, before it shipped. The exit handler decides whether an exit is its own by comparing against `session.ffmpeg`, and during a restart that field still names the process being killed, because the replacement is spawned a few hundred lines later. So every seek and every quality switch ran the failure branch for its predecessor: a spurious `failed` for the moment between the kill and the spawn, which a segment request landing in that window is answered 500 for; a fast-failure tally against a target that never failed; and on any host with a hardware encoder, the runtime safety net firing on each seek — the proxy downgraded itself to libx264 permanently and started an extra run at the OLD index, which took the generation and made the real restart abort. A process is now marked superseded BEFORE it is signalled.
747
- - **Fix**: Losing the torrent's data no longer condemns a working hardware encoder. The hardware-failure fallback was asked of every non-zero exit, including a run that died because its input went away — which says nothing about the encoder. What an exit means is now classified in one place (`services/encode-exit.js`, tested by its four field cases) and the fallback is asked only of a genuine encoder failure.
748
-
749
- - **Fix**: What the torrent costs this machine can actually be measured now. The reading is taken when no encoder is RUNNING, and a SUSPENDED encoder was being counted as one — so on a host with two sessions parked by the look-ahead cap the moment never arrived: measured 2026-08-15, four minutes of `encoders=0 running +2 suspended` in which the price could have been taken and was not. A suspended encoder costs nothing, which is exactly why that moment is the right one.
750
-
751
- ## 2.22.0
752
-
753
- - **Fix**: What a rung is OFFERED on is the startup measurement again, not the figure learned from a live session. The startup one is taken on a quiet machine against known clips and does not move; the learned one moves with whatever else the box was doing that second, and three field sessions in a row show the price of that: decoding learned at 0.87x, then at 1.34-1.57x, against calibration's 2.6x — each reading refusing another rung until the offer held a single height and the quality menu vanished with it.
754
- - **New**: A live reading keeps the one thing it is authority on — itself. A rung that has actually been seen running below realtime, with the machine to itself, is withdrawn on that evidence whatever any prediction says. A rung nobody has run is judged by the startup measurement like any other, because a measurement of one rung is not a prediction about the rest.
755
-
756
- ## 2.21.1
757
-
758
- - **Fix**: A cost is learned only from an encoder that had the machine to itself. Beside another encoder a reading already contains that other work, and the budget then ADDS the same work again when it predicts — so the price of a file grew with every reading. Measured in the field 2026-08-15: copying, whose truth is 7.9x, was learned as 2.03x; decoding, whose calibration clips say 2.6x, as 0.87x. Every re-encoded rung was then refused (`not offering 720p=0.56x … 240p=0.66x`), the offer collapsed to the one copied height, and the viewer lost the quality menu entirely.
759
-
760
- ## 2.21.0
761
-
762
- - **Fix**: The two costs added in 2.18.0 and 2.19.0 were never measured in production — both features were inert. The torrent's cost read `torrentPool.client`, a field that belongs to the pool implementation that no longer runs on this thread (the WebTorrent client lives on the worker), so the byte totals were always zero and the guard that needs two megabytes of movement never passed. The copy's cost sat in a branch its only caller had already filtered out, so it never ran. The totals now come from the worker over its own protocol, and the caller admits a copying session.
763
- - **Fix**: A speed is read as the DIFFERENCE between two readings of an uninterrupted stretch, not from ffmpeg's cumulative figure. The cumulative one counts every second the look-ahead cap keeps the encoder stopped, and a copy spends most of its life stopped — it reaches the cap in about fifteen seconds and then waits a minute, so a copy running at 8x reports 1.6x and falling. Filed as the price of copying, that would have refused rungs on a measurement of a pause. The pair is dropped whenever the encoder is paused, resumed or restarted, so every surviving pair spans real work.
764
- - **Fix**: The torrent's cost is divided by the core count. `process.cpuUsage()` adds up every thread, while everything it is added to is wall seconds per second of video — undivided on the four-core addon host it overstated the torrent fourfold, which on the field's own rung is the difference between offering it and refusing it. Only DOWNLOADED bytes are counted, since a byte sent back to the swarm is neither hashed nor stored, and the file is priced by its own length rather than by the video stream's bitrate — the torrent moves the container, and two or three audio tracks are 10-25 % of it.
765
- - **Fix**: The offered list is recomputed when either new figure changes, and the FIRST offer — the one a viewer sees on opening a file — is priced with the torrent's cost too. Keyed only on the decode version, the cache could never change for a copied picture, which is precisely the case these costs exist for.
766
-
767
- ## 2.20.0
768
-
769
- - **Fix**: A file's read window is shared between the readers it has, instead of being granted whole to each. The window is stated in seconds of playback and the piece store's memory is one budget for the whole torrent, so a viewer with a picture and a separately published audio track asked for twice what the budget was written against, and a warm-up made it three times. On 2026-08-15 that ended as it had to: every resident piece held at once, a read that returned zero bytes, and every encoder on the file taking that for the end of it. This is the first step of the sliding window, not the whole of it — pieces still leave memory only by the store's own eviction.
770
-
771
- ## 2.19.0
772
-
773
- - **New**: What the torrent itself costs this machine is measured and charged. Downloading a file, verifying every piece of it and pushing segments down a data channel are work on the same box as the encoder, they scale with the file's own bitrate, and the budget counted none of it — measured on the addon host with every encoder suspended, the machine was still 20-29 % busy. The figure is taken only while NOTHING is encoding, which is the one moment it can be attributed without arithmetic, and it is expressed per megabyte moved so any file's rate can be priced from it. A viewer's file is then charged at its own byte rate when deciding what quality this host can offer.
774
- - **New**: The host-load line reports the proxy's own share of the machine beside the encoders'. The two answer different questions — whether ffmpeg is getting the cores, and how much of the box goes to everything around it — and only the first was visible.
775
-
776
- ## 2.18.0
777
-
778
- - **New**: Copying the picture is no longer priced at nothing. It demuxes, re-encodes the audio and writes segments, and it is what runs BESIDE every rung warmed for a quality change — the field measured it at 7.92-8.02x, about an eighth of a second of work per second of video. The figure is not a constant: a session that is copying reports its own speed, and the reciprocal of that IS the cost, learned per file as the decode cost already is (median of recent readings, only from a run past its own start, never from a suspended one).
779
- - **New**: A rung is judged against the machine it will actually have. The cost of what the family is already committed to is added to the rung's own before the check, so the arithmetic of 2026-08-15 comes out as it did in the field: 0.125 for the copy plus about 1.05 for the 240p rung is more than the one second of work per second the machine has. Unmeasured means zero, so a host that has observed nothing is exactly as permissive as before.
780
- - **Fix**: A copy reading taken while the torrent is short is discarded. A re-encode near realtime may be the host's limit; a copy near realtime is a copy waiting for data, since copying runs at eight times realtime — and filing that as the price of copying would refuse rungs on the download's account. An audio rendition is excluded from this learning too: it carries no picture, and its speed is the price of a soundtrack, not of a copy.
781
-
782
- ## 2.17.0
783
-
784
- - **New**: The encoder is benchmarked on real footage instead of a generated test pattern, and measured by ffmpeg's own progress rather than by the clock around the process. The pattern has flat areas and no grain and encodes **1.23x** cheaper than film on the same machine and preset — an error that always points at offering a rung the host cannot hold. Timing whole runs was the second error: process startup is ~0.4 s, which put `fast` and `ultrafast` within 1.24x of each other when they differ by three times. The clip is decoded once to raw frames in a temp file (feeding them through a pipe measured the pipe: the fastest presets want hundreds of megabytes a second), each preset is read from the slope between two progress reports, and the run is stopped as soon as a second of it has been covered.
785
- - **Fix**: A preset that ends before its window is covered is still measured, but never over a window of no width — two reports a millisecond apart would have called a host twenty times faster than it is, and one such reading is what every ladder decision is then taken from. A position ffmpeg reports as the smallest signed 64-bit integer (some builds print that instead of `N/A` before the first packet) is discarded, and a slope above a thousand times realtime is treated as a fault rather than as a fast machine.
786
- - **Fix**: Which rungs may be offered is decided from the CHEAPEST preset's throughput, not from the largest reading in the array. Measurements scatter on a busy machine — `faster` read below `fast` twice on 2026-08-15 — and taking the maximum let one noisy reading of an expensive preset raise the bar that decides what is offered. Choosing a preset still scans every entry rather than stopping at the first miss, because there the direction of that error costs picture quality, not playback.
787
- - **Fix**: A host with nothing measured says so in those words — `the quality ladder is UNFILTERED on this host` — because that is what an empty benchmark means, and the previous wording said only that presets were unmeasured. The benchmark also can no longer stop the proxy from starting: a missing or read-only temp directory, or a locked file after a kill, is a host left unmeasured, not a process that fails to listen.
788
- - **Fix**: The host-load line counts CPU per PROCESS across readings, and only for processes present in both. A seek kills ffmpeg and starts another whose counter begins at zero, so subtracting one total from another printed shares like `-598%`; and on a host without `/proc` the sum of no readings was reported as a confident `0%` beside honest `n/a`s.
789
-
790
- ## 2.16.0
791
-
792
- - **New**: While an encoder runs, one line every five seconds says what the MACHINE is doing: the share of it ffmpeg is getting, the share everything else is taking, the share spent waiting on a disk, the CPU's current clock and its temperature. The budget predicts a rung from benchmarks taken at startup on an idle box, and on 2026-08-15 it predicted 1.83x for a rung that then ran at 0.90-0.999x with nothing else encoding — and no log anywhere could say which of the candidate reasons it was. Now the reading exists: an encoder starved of cores, a machine that has dropped its clock or grown hot, and work around the encode that nobody counted all look different in this line. Linux-only and best effort — a host without `/proc` writes nothing and nothing else changes.
793
-
794
- ## 2.15.3
795
-
796
- - **Fix**: A magnet whose swarm never answered no longer poisons the film for good. It leaves a torrent with the right infohash and no file list, and WebTorrent then refuses the same film opened from a `.torrent` as a duplicate — so the answer to every later attempt came from the entry that knows nothing: `Proxy playback plan request failed (404): File index was not found in torrent`, reproduced in a browser 2026-08-15, and no reload could clear it because the useless entry outlives them all. A source that carries the metadata now replaces one that lacks it.
797
-
798
- ## 2.15.2
799
-
800
- - **Fix**: A segment request that can never be answered is answered as absent instead of being held for a minute. Changing audio track makes hls.js ask the new stream for segment #0 before anything else; the run was at #354, the repair reaches sixty segments back and no further, no seek was coming, and an encoder only moves forward — so the request was unanswerable from the moment it arrived, and holding it simply spent the player's own patience. Measured 2026-08-15: the track was made ready in 7.1 s at the viewer's position, and the viewer then watched a spinner for **63 s** — sixty of them the hold, the rest the player recovering after it failed. Deliberately narrower than the refusal 2.14.1 shipped and 2.14.2 withdrew: a request within the repair's reach, or one with a seek on its way, is still held, because for those the encoder is about to be moved there.
801
-
802
- ## 2.15.1
803
-
804
- - **Fix**: A magnet that never found its metadata no longer makes the same film unplayable from its own `.torrent`. One infohash is one torrent, so the second add is refused and the pool takes the one already there — which is right when it is ready and wrong when it is not: a magnet whose swarm has not answered has no file list, and everything bound to it is answered 404. Measured 2026-08-15 on the addon host: a magnet with no reachable trackers was added first, the film's own `.torrent` then joined that empty torrent instead of replacing it, `/stream` answered 404, the encoder died on its first read, and the film stayed unplayable until the proxy was restarted. A `.torrent` carries the file list, the piece hashes and the trackers outright, so when it meets a torrent with no metadata it now replaces it; two magnets still wait, because neither has anything the other lacks.
805
-
806
- ## 2.15.0
807
-
808
- - **New**: An audio track is prepared before the player is told to change to it — `GET /transcode/:id/a/:track/warm?position=<seconds>`, the same shape the quality rung has had since 2.12.0. Changing track makes the player discard the audio it holds, and it cannot show a frame until the new track covers the playhead: switching first and producing second therefore put the track's whole cold start on screen as a spinner over a stopped picture. Prepared first, the player finds the bytes already made. A track prepared for a change the viewer then did not make is stopped, as a warmed rung is.
809
- - **Fix**: The audio track a viewer leaves is stopped, and a seek reaches only the track being listened to. Each track is an ffmpeg process AND a reader holding pieces of the torrent, and one viewer who had changed track once had three readers on one file — picture, the track chosen and the track left. At a seek all three revived their windows at once, every resident piece was pinned, a read ended with zero bytes, ffmpeg read that as the end of the file, and every encoder died; the sessions answered 500 to everything after that until the viewer gave up.
810
- - **Fix**: A read waits for a piece to be released instead of failing outright. Every resident piece being read at once is not a permanent condition — a pin lasts one read of one piece — so the store now waits for one, and a released pin wakes whoever is waiting. Failing there ended a read with zero bytes, which is indistinguishable from the end of the file to the process reading it. A five-second deadline keeps a genuine deadlock visible, and the wait re-checks on a timer: waiting on events alone hung, because when everything is pinned and nothing else is in flight there is no event left to fire — it hung this store's own test for the ten minutes a run is allowed.
811
-
812
- ## 2.14.3
813
-
814
- - **Fix**: A separately published audio track begins where the PICTURE is, measured rather than guessed. The position this class keeps is the read head, and the viewer sits behind it by whatever the player has buffered — a figure the browser already reports with every link report, so the playhead is one subtraction away (less one segment of margin, since the report can be ten seconds old). 2.14.2 subtracted the whole look-ahead instead, which was safe but made the encoder produce up to two minutes of audio nobody would hear before reaching the part that was wanted. A report older than fifteen seconds is ignored — a viewer may have seeked since — and then the whole look-ahead is subtracted as before.
815
- - **Fix**: A request behind the encode run is acted on when the player ASKS AGAIN, not after three seconds of waiting. Repetition is the player saying it still needs that exact segment; a delay only says time has passed, and those three seconds were part of the twenty a track change cost. A scan is told apart by what else is being asked for — more than three distinct segments behind the run within two seconds is the player sweeping the playlist, and moving the encoder to one of them would be moving it to a number picked at random.
816
- - **Fix**: That scan count is taken over a two-second window rather than over the life of the run. Accumulated, it would have crossed the threshold on any long session and disabled the repair for good — silently, since nothing about a repair that never fires is logged.
817
-
818
- ## 2.14.2
819
-
820
- - **Fix**: A separately published audio track starts BEHIND the picture's read head, and a request behind its run is answered as before. Two mistakes compounded in 2.14.1 and left the viewer on a spinner that never ended. The position this class keeps is written by the segments a session serves — the READ head — while the viewer's picture sits behind it by everything they have buffered, so the track was started AHEAD of them: field 2026-08-15, the run placed at segment #16 while the player asked for #10. On top of that, 2.14.1 had begun answering such a request "not found" at once instead of holding it, which turned a condition the encoder used to correct in twenty seconds into a permanent refusal: hls.js retried #10 for a minute and a half, raised a fatal network error, recovered, and retried it again. The prompt refusal is withdrawn — it was written for a probe and met a real request — and the track now starts a whole look-ahead behind the read head, which is exactly how far apart the two can be. The price is audio the player already holds: at ten to twenty times realtime and 75 KB a piece, a second or two of work.
821
-
822
- ## 2.14.1
823
-
824
- - **Fix**: Changing the audio track no longer costs twenty seconds of silence. The player asks the NEW rendition for its segment #0 before anything else — measured 2026-08-15, a track changed at 159 s with the rendition correctly placed at #26 — and the repair that exists for a run placed WRONGLY took that literally: it killed the run and restarted the encoder at the beginning of the film, so the segment the viewer was waiting for arrived 20.5 s later. A rendition is never repaired by moving it, because its run is placed where the viewer is and the request behind it is the player probing; and such a request is now answered at once rather than held, since holding it spends the player's patience on a fragment that can never be produced. The refusal stands down while a seek of the rendition's own is settling: a viewer going BACKWARDS is reported to the base and forwarded to the rendition, but its run only moves when the settle fires, so until then the requests for the new position are behind the old one — and those are exactly the ones the viewer is waiting for.
825
-
826
- ## 2.14.0
827
-
828
- - **Fix**: A rung the source is served at by COPY is never withdrawn from the offer, and the offer is one answer for the whole file rather than one per rung. Which heights this host can serve is a property of the FILE, but a rung is a session of its own and knows only its own encode — so, asked while the viewer watched 240p, the 240p session priced the 1080p rung as a re-encode, because ITS video is re-encoded, and refused it on a machine that had been serving that exact height by copy a minute earlier. Field 2026-08-15: `proxy now offers 360p 240p` four seconds after the switch, and the viewer could not go back to the quality that worked. A copied rung costs no encoder at all, so no measurement of the host can be a reason to drop it — it is precisely where a viewer on a rung the machine cannot hold returns to.
829
- - **Fix**: What a file costs to decode is learned from every running encoder, ahead of the realtime budget's own conditions rather than inside them. Those conditions decide whether to step the quality DOWN, and they exclude most of what is worth measuring: a rung already at the foot of its ladder has nowhere to step, and a 240p variant is its whole ladder — which is exactly the rung the field ran at 0.95x for three minutes on 2026-08-15 while learning nothing from it. A reading is refused where it would describe something other than this machine on this source: a suspended encoder (ffmpeg's `speed=` is cumulative, so a look-ahead pause decays it while nothing is being encoded), a figure that has not moved since the last one (the loop runs every five seconds and would otherwise fill the window with one frozen sample), and a run whose input is what is short.
830
- - **New**: Audio is published once for the file, as its own rendition group in the master playlist, instead of being muxed into every quality rung. On a host that struggles to encode one stream, encoding the same AC-3 track again for each of six rungs is work spent on nothing — the tracks are identical. Each track becomes an `#EXT-X-MEDIA` entry served under `a/<track>/`, cut on the same grid as the picture so the two play together, and every `#EXT-X-STREAM-INF` names the group. Changing track is then the player fetching another rendition rather than this proxy rebuilding the session, which is a cold start with the screen empty.
831
- - **New**: A session carries audio, or a picture, or both, and says which. A rendition is one audio track with no picture (`-vn`); a stream whose audio is published separately carries the picture alone (`-an`); everything else is muxed as before. The three are different encodes of one file and share no session, directory or encoder.
832
- - **Fix**: Where the audio travels is settled once, when the session is made, and every variant and rendition of it inherits that answer. Derived per session instead, it disagreed with itself: a 540p rung of a copied 1080p source is offered no rungs of its own, so it would conclude "audio muxed" and carry a second copy of the track the player was already fetching from the rendition — and the same predicate could flip mid-session as the host learned what the source costs, giving a silent stream after the next restart.
833
- - **Fix**: An audio rendition is cut on the grid of the picture it accompanies, and labelled on the same timeline. Created with no video, it was falling into the video-COPY path — the source's keyframe times and `-copyts` — while the re-encoded picture beside it was on the even grid labelled from zero: the segments the player was told about and the ones ffmpeg made drifted further apart with every cut, and the two streams were offset by the container's start time.
834
- - **Fix**: A seek reaches the audio. The browser names one session and means the picture, so nothing repositioned the rendition, and a request far ahead of its run is not treated as a seek anywhere — after a forward jump the audio was held, refused, and left grinding forward from where it was, for as long as the jump.
835
- - **Fix**: A rendition starts where the viewer is, read the way a quality variant reads it. The base's own position field is written only by a seek or by a segment it served itself, so on a resume-from-position open it is still unset while the player asks for segment #537 — the audio began at zero and, with the seek gap above, never caught up.
836
- - **Fix**: Audio renditions are released with the session they belong to. Nothing outside this proxy knows their ids, so nothing else could ever release the consumer, the claim on the torrent, the directory and the encoder each of them holds.
837
- - **Fix**: A session declares the tracks it actually produces. With the picture and the audio in separate streams it still claimed both, so the check that waits for a complete init header could never be satisfied and warned on every one, and the browser was told a stream carries audio that is not in it.
838
- - **Fix**: Renditions are published only where there is a master playlist to publish them in, and only to a browser that asked for them. A stream served as a single media playlist has nowhere to carry an `#EXT-X-MEDIA` line, so taking its audio out would leave a picture and silence; a browser that does not know about renditions must be sent audio in its stream. Both conditions are checked in one place, and the ffmpeg arguments, the master and the rendition route all read it.
839
-
840
- ## 2.13.0
841
-
842
- - **New**: A quality rung is offered only where this host can produce it faster than it is watched, and the budget now knows what DECODING costs. It priced the encoder alone, which is half the work — a re-encode decodes the whole source first — so on the addon host the startup benchmark read 11.2 Mpx/s against the 2.45 Mpx/s a 240p rung needs, declared it clear by two and a half times, and the rung then ran at 0.388-0.947x: first segment 30 099 ms, later segments held 21 951 ms and 10 662 ms, while the 1080p it replaced was being COPIED at 7.8-8.9x. Choosing a lower quality was what broke playback. Three bundled clips (`assets/calibration/`, cut from Netflix Open Content "Meridian", CC-BY 4.0) are decoded once at startup and solve this host's cost as `a × Mpixel/s + b × Mbit/s + c`; a rung is then priced as `1/(1/decodeSpeed + 1/encodeSpeed)` and left out of the master playlist unless it clears realtime by the margin. Checked against a file the fit was not made from: 4.8 % error, where the encoder-only model was 209 % out on that rung. Real footage rather than a generated pattern, because `testsrc2` decodes 158 % away from a real film where these clips are 11 % away.
843
- - **New**: The playback plan carries the heights this host could serve the file at, for both branches — copied video and re-encoded — so the viewer's quality menu is right from the moment a file is opened rather than from the moment an encoder exists. Only the browser knows which branch it will take, so both are answered; a session that then runs replaces the estimate with what its encoder really does.
844
- - **Chore**: The decode measurement reads ffmpeg's own progress rather than the clock around the process. Starting ffmpeg costs about a second, and on a quick machine a five-second clip decodes in a tenth of that — so timing the process measured the process starting, and working around it by repeating passes and subtracting took 20-26 s and still produced a fit that had to be rejected. Progress lines arrive twice a second after startup, and the slope between two of them contains no part of it by construction: one run per clip, stopped as soon as a second of decoding has been observed. Measured on a desktop: 7.4 s and 7.9 s for the whole benchmark on two runs, agreeing to 5 %, where the differencing method gave anything between "no model" and a fivefold spread.
845
- - **New**: A session opened at a named height starts no higher than this host can hold. The height comes from a browser that was told what is on offer, but a stale tab or a repeated address can still name a rung that was refused, and starting there means the encode never catches up — the runtime downshift would step down eventually, and the viewer would watch it happen. The ladder beneath the request is kept, as before.
846
- - **New**: The session-create response carries `offeredHeights`, the heights this host will actually serve the file at. A stream without variants still changes quality by re-opening the session, and the browser was composing that list itself from the source height and a fixed ladder — a statement about the file where the question is about the host.
847
- - **New**: What a source really costs to decode is learned from the encoder running on it, and replaces the estimate made from the startup clips. A re-encode pays for both halves and ffmpeg reports the sum, so subtracting the encode half — priced for the preset and pixel rate actually in use — leaves this file's own decoding, on this machine, under whatever else it is doing. The clips are H.264 while a source that needs re-encoding usually is not, which is exactly where the model was optimistic: on the field case of 2026-08-14 the clips priced that film's 240p rung at 1.58x and admitted it, while one reading of the rung itself prices it at 0.95x and refuses it. The fastest reading is kept rather than the latest, since a slow moment can be a starved download rather than a slow host. It also prices a host whose clips were never fitted, which until now could refuse nothing.
848
- - **Fix**: The rung a viewer is WATCHING is never withdrawn from the offer. The list is recomputed as the host learns, and the reading that teaches it comes from the rung just switched to — so the rung that taught the lesson would be the first dropped, and every route guard reads that list: the next segment of a playing stream would 404 with its own encoder still running. It leaves the offer when the viewer leaves it.
849
- - **New**: The progress response carries the rungs still on offer, restated about once a second. The menu the viewer sees is corrected as the host learns what this source costs, so a rung beyond the machine disappears from it instead of being found by switching to it.
850
- - **Fix**: The decode cost is priced from the VIDEO stream's bitrate, not the container's. The calibration clips carry video alone and are decoded with `-an`, so the fitted term describes video bits, while the container figure adds every audio and subtitle track — a release with two or three AC-3/DTS tracks carries 1-2 Mbit/s of them, which inflated the predicted cost by 10-25 % and refused rungs on the strength of audio the benchmark never decoded. The term is also not the weak one it was recorded as: on the shipped clips an 11.7× bitrate change moves the cost 2.47×, and it is about two thirds of the predicted cost of a high-bitrate 1080p source.
851
- - **Fix**: What a file's offer is computed from is answered on every response instead of being frozen into the cached plan — the defect fixed once in 2.9.106, in the same object and three lines under the comment recording it. A plan is cached for the life of the process while what the host will serve is not, so every later open of a file handed back the first guess and undid what the encoder had learned.
852
- - **Fix**: A downshift chooses its preset with decoding priced, as the offer and the starting rung already did. Choosing it on the encoder alone treats decoding as free, which is what made the check and the encode disagree — and it mattered most here, on a host that has already failed to keep up and is spending one of its three downshifts.
853
- - **New**: `--state-dir` says where to keep what this host has measured about itself; without it the file stays beside the installed proxy, exactly where it has always been. Deployments differ in what survives: on the Home Assistant addon both the install directory and the working directory sit in the container's writable layer and are discarded when an update rebuilds it, so only a directory the host keeps — `/data` there — makes the measurements outlive an update. Which directory that is cannot be decided here without putting one deployment's assumptions into proxy code, so the deployment names it.
854
- - **Chore**: The margin is 1.5 where the prediction includes decoding, and stays 1.8 where it cannot. An encoder-only figure was several times too optimistic on the rung this exists for, so it is not fit to refuse anything: a host with no usable fit offers the whole ladder exactly as before, and its preset is still chosen against the old 1.8.
855
- - **Chore**: Where the three-point fit produces a negative term the bitrate term is dropped and the remaining two are fitted by least squares over all three points. A negative term does not describe a host; it says the difference it was solved from is inside the noise between runs, which is what a fast machine produces — measured on a desktop, the 720p clip took longer per second of video than the low-bitrate 1080p one, because process startup is a large share of a decode lasting a second. If even the pixel slope comes out non-positive there is no measurable dependence on the source, and the fit is refused rather than invented.
856
- - **Known limits**, both measured rather than assumed: the prediction describes an idle machine, so on the very host above the 240p rung predicts 1.58x and clears a margin of 1.5 while the field measured it at 0.388-0.947x under real load (copying 1080p, downloading, serving) — the margin is what has to carry that, and 1.5 does not. And the fit is made from H.264 clips, so it describes H.264 decoding: a source that must be re-encoded is by definition not H.264, and HEVC or AV1 decode dearer per pixel on the same box.
857
-
858
- ## 2.12.2
859
-
860
- - **Fix**: The transport heartbeat is written once per connection, with each channel's queue beside it. The counters it reports belong to the peer connection, not to a channel, so printing the line per channel produced two byte-for-byte identical readings — `sent=5153491` under both "proxy" and "proxy-control" on 2026-08-14 — which read as two independent measurements agreeing. The one figure that IS per channel, its queue depth, was the only real difference and was buried in a line that looked like a duplicate, leaving the second channel unobservable in the log.
861
- - **Fix**: A channel watch ends when the transport stops knowing about its session, not only when the channel reports itself closed. The close callback is the ordinary way it ends and it does not always arrive — a peer connection can die without one — leaving a timer sampling a session that no longer exists for the life of the process.
862
- - **Chore**: `host-timings.json` is no longer under version control, and no longer ships in the package. It is runtime state the proxy rewrites every session, so it arrived in every diff, would have carried one developer machine's medians into every published version, and would have conflicted on every release.
863
-
864
- ## 2.12.1
865
-
866
- - **Fix**: The grid a copied stream is cut on now describes the FILE, not the container's index. A copy can only be cut where a keyframe already is, and nothing cheaper than the index can say where that is before a byte is encoded — but an index can be wrong. Reproduced 2026-08-12 against one file, both ways: with an honest index every produced segment started exactly where declared; with the index moved 1.8 s, every segment started 1.8 s early and matched no boundary at all. The field showed the second shape, so the mechanism was never at fault and the data was. The truth arrives anyway, one segment at a time — a produced piece states where it really begins — and it is now written back into the grid, which the whole family shares. That is what lets a re-encoded rung be cut to match a copied one: it is forced onto times the copy really uses. A correction that would cross its neighbours is refused, since that is a reading from a run that began somewhere else.
867
- - **Fix**: A warm-up is no longer cancelled by the stream that is still playing. The cancellation stood before the check for whether the active rung had actually changed, and the rung on screen asks for its own segments every few seconds — so the rung being prepared was stopped 117 ms and 1.5 s after two warm-ups began (measured 2026-08-12), and the viewer then waited out the full thirty-second warm-up for a segment nobody was making, and waited again for the switch. One switch took 43.6 s.
868
- - **Fix**: Warming the height the base session itself serves repositions it. It was skipped because it "is the base", but the base is parked wherever the viewer left it with its encoder stopped: warming 400p found it still at `run from #0`, so the switch had nothing to fetch.
869
- - **Fix**: Repositioning inside this class names the session it means. `requestSeek` forwards to the rung on screen, which is right for the browser — it knows only the base id — and wrong for everything internal: warming a rung moved the rung already playing instead. Split into the public forwarding call and an internal literal one.
870
-
871
- ## 2.12.0
872
-
873
- - **Fix**: A rung warmed for a switch the viewer did not make is stopped. Only becoming active stopped the rung being left, so trying two rungs in a row left the first encoding for nobody — three encoders at once on a host sized for one, which is the opposite of what warming is for.
874
- - **Fix**: The warm-up closes the handle it opened. It answers without sending the bytes, and on formats whose segments are served straight off disk that left a file descriptor behind on every quality pick; enough of them and every read fails, segments included.
875
-
876
- - **New**: A quality rung is prepared before the player is told to switch to it — `GET /transcode/:id/v/:height/warm?position=<seconds>`. A rung is an encoder that does not exist until it is asked for, so switching first and waiting second put the whole of its cold start on screen as a spinner: measured 2026-08-11, the first segment of a 240p rung producing at 1.2x took 15 988 ms, and the viewer watched all of it. The rung on screen deliberately keeps its own encoder until the player actually moves, so the wait happens behind a picture that is still playing. Both encoders run for the length of the warm-up, which is what the switch costs to be invisible.
877
- - **Fix**: A viewer who names a resolution keeps the ladder beneath it. Forcing a rung disabled the realtime budget outright, so on 2026-08-11 a viewer picked 480p on a host that encodes it at 0.27-0.78x and the stream simply never caught up — nothing could step in, because the one thing that steps in had been switched off. The encode now STARTS at the size asked for and may still be stepped down under it. The rung's height is its name and does not move with a downshift, so the player goes on addressing it by the height it chose; what changes is the picture, and a smaller picture that plays beats a correct label that freezes.
878
- - **New**: When a produced segment starts somewhere other than the playlist says, the line now names which boundary it DOES fall on. The two possible faults need opposite fixes and the numbers alone do not separate them: matching boundary #N-1 means this proxy's own numbering is shifted, matching none means the container's index describes times the file does not have. Measured 2026-08-11 on a 1080p Matroska, three samples out by 3.5-4.6 s, all matching #N-1.
879
-
880
- ## 2.11.0
881
-
882
- - **Fix**: A quality change places the new rung where the PLAYER asked for it, not where the rung being left had read to. After a level switch hls.js discards what it had buffered ahead and fetches from the picture's own position, so its first request for the new rung IS that position; the read head is a whole buffer further on. Measured 2026-08-11 on a switch back up to 400p: a 240p rung encoding at 5-6x had read 56 s past the picture, the run was placed at 3084 s, the player needed 3028 s, and nothing it asked for was ever produced.
883
- - **Fix**: A segment request BELOW the running encode is repaired instead of being held for ever. The encoder only moves forward from where its run began, so such a request cannot be answered by anything that run does — every other far request is a claim the run may yet reach, this one is a hole. Same session: it was held for two minutes forty-one while the encoder produced 409 s of video nobody had asked for at 2.48x, and the viewer sat on a loading screen until they gave up. It now moves the encoder there, through the same settle a reported seek goes through, and never overrides a seek the viewer has actually stated.
884
- - **New**: A quality variant can now accompany a COPIED video. The obstacle was never the encoder but the cut points: a copy can only be cut where the source already has a keyframe, while a re-encode was always cut on an even grid, so a rung's segment covered a different span from the copy's and could not stand in its place. A session now carries which grid it is cut on as a fact of its own, separately from whether its video is copied, and a variant of a keyframe-cut session inherits that grid — the same times serve as the muxer's cut list and as the keyframes the encoder is told to force. A copied stream is offered variants only when its own grid is real: with no readable keyframe index it falls back to an even grid that ffmpeg does not cut on, and nothing can be aligned to a fiction.
885
- - **New**: What a container's keyframe index says about its own file is now counted and reported. The cut times of a copied video ARE that index, and an index can be wrong — measured 2026-08-06, one claimed a keyframe four seconds from where the real ones were. Every produced segment states where it truly begins and is already read whole in order to be stamped, so the comparison costs a subtraction and no scan: nothing is downloaded for it, and only boundaries somebody actually watched are counted. Each session ends with one line naming the container, how many boundaries were examined, how many disagreed and by how much — so silence can be told from nobody having watched, which the per-boundary warning alone could not do.
886
- - **Fix**: The repair above is bounded, and cannot become the request-steering this proxy removed in 2.9.100. A player that cannot get what it wants scans the playlist — field log 2026-08-02, probes at #178, #681, #725, #807, #74, #245, #387 within half a second — and moving the encoder to the lowest of those would put it at the start of the film with the viewer's own requests unreachable ahead of it. What separates the two: a run placed wrongly is out by at most the buffer the player was holding, fourteen segments in the measured case, while a scan probe is out by anything at all. So only a request within sixty segments behind the head is repaired, only while an encoder is actually running (a rung the viewer switched away from stays parked), and never over a seek the viewer has stated or a target the circuit breaker has already refused.
887
- - **Fix**: How long a segment has gone unanswered is measured against the run in force. The record was kept for the life of the session, so a timestamp left by an abandoned scan probe minutes earlier said a fresh request had already waited long enough — which would have fired the repair on the first poll, before the browser's own seek report could arrive. It is cleared with each run, which also stops the map growing all session.
888
- - **Fix**: A copied video whose keyframe index could not be read keeps its explicit cut list. Making the list conditional on the keyframe grid dropped that case onto the `hls` muxer, which takes no cut list and writes no self-contained pieces — so nothing could read where a segment truly begins and each was stamped with a time the file does not have. That is the 4.17 s drift between speech and subtitles, which had already cost one release.
889
- - **New**: A rung that does not cut where its grid says now says so. The check above runs on re-encoded variants too, where the meaning is different: the encoder was TOLD to put a keyframe there and did not, so a switch to that rung will not join cleanly. Hardware encoders honouring an explicit cut list is unverified — this is what will name it if one does not.
890
-
891
- ## 2.10.0
892
-
893
- - **New**: Quality can be changed without interrupting playback. A session that re-encodes its video now also publishes a master playlist — `GET /transcode/:id/master.m3u8` — listing every height the file can be served at, each as an ordinary HLS variant under `v/<height>/`. The player then does the switching itself: it fetches the other variant, appends it after what is already buffered, and changes the decoder's type if the codec parameters differ. Until now a change of resolution could only re-open the session, which is a cold start with the picture gone. Rewriting the media playlist underneath the player is not an alternative — ours is VOD and terminated with `#EXT-X-ENDLIST`, and hls.js re-reads only a live playlist, so anything written into it afterwards is never seen.
894
- - **New**: A variant IS a session — same source, same file, another encode — so nothing parallel was invented for it. It is created on the first request for it and not before, which is what keeps a weak host running one encoder: the player's own bitrate adaptation is off, so no variant is ever asked for unless the viewer picked it. Two viewers on the same rung of the same file share one encode, as sessions already do.
895
- - **New**: Only one encoder runs. A SEGMENT request for another variant is what says the viewer has moved — a playlist or an init segment is fetched to decide with, and the player fetches both for levels it may never use. On that signal the previous variant's encoder is stopped, every request still held on it is answered at once instead of running out its minute, and the new variant is pointed at where the viewer stands. It has to be told: since 2.9.100 a segment request steers the encoder nowhere, so a variant watched a minute ago is parked wherever it was left.
896
- - **New**: The session's OWN height is one of the variants, even when the realtime budget settled on something that is not a ladder rung. Leaving it out would mean the player, on loading the master, immediately asks for a height nobody is encoding — a second cold start in place of the run already serving segments. The create response names it (`variantHeight`) so the browser can pin the player to it.
897
- - **New**: Seek, progress, link reports and release are addressed to the session the browser was given and answered from the variant on screen. The browser holds one id for the whole file and never learns a variant's — which is what keeps the switch out of its state machine.
898
- - **New**: A variant's playlist is answered from the base session, and no encoder is started for it. Every variant of a file has the same media playlist — same duration, same boundaries, same init name — which is precisely what makes them interchangeable. The player fetches a level's playlist to decide with and may never switch to it, so building a session there would leave a second encoder running on a host with capacity for one. Only the init segment and the segments themselves belong to a variant.
899
- - **New**: A re-encoded session is no longer shared between viewers. A quality change acts on the session — it stops the encoder of the rung being left and repositions the one being joined — so shared, one viewer's change would kill the stream the other was watching, and that viewer's seek would then be forwarded to a variant they never asked for. The sharing given up was always narrow: two viewers had to open the same file at the same size within the same ten seconds, and a shared seek already dragged both of them. Restoring it needs the active variant tracked per consumer rather than per session.
900
- - **Fix**: Stopping an encoder clears everything armed to start it again. The input-retry timer fires seconds after a run dies of torrent starvation — routine here — and would have spawned a run for a rung nobody was watching; the seek-settle timer did the same on a quick second switch.
901
- - **Fix**: A variant made for a session that ended while it was being made is released at once. Making one takes seconds (a probe and a keyframe index) and the viewer can leave inside that window; registered onto a disposed session it would be reachable by nobody, since the browser never learns a variant's id, and would hold an encoder, a directory and a claim on the torrent until its own idle timer noticed half an hour later.
902
- - **Fix**: Where the viewer is is now taken from the segments they ask for, not only from a reported seek. Playback reports no position at all, so the recorded one was as old as the last scrub — and it is what places the next variant's first encode run. A variant started an hour behind the viewer produces segments nobody will ever request, and since a segment request steers nothing, the ones they DO request would never be made.
903
- - **Fix**: A variant's first run starts on the ten-second grid that session keys are bucketed to, floored rather than rounded. A position rounded up starts the run past the viewer, so the run just spawned is torn down and restarted before it has produced anything — about half of all switches paid that twice over.
904
- - **Fix**: A variant that cannot be prepared answers a retryable 503 rather than a bare 500. hls.js treats a 500 on a level playlist as fatal and ends the stream, over a probe or an input that the next attempt may well get past.
905
- - **New**: A variant's height is its NAME, settled once. The player fetches the master exactly once and addresses the variant by that name for the rest of the session, while the height a session encodes at is not stable — the realtime budget steps it down when the host cannot keep up. Deriving the name afresh would let a downshift silently rename the variant being watched, and the next segment request under the old name would build a SECOND session at the very height the host had just proved it could not manage. A downshift changes the picture inside the variant instead, as it always has.
906
- - **Chore**: What makes the splice possible is pinned by a test: on the re-encode path the cut times are a uniform grid with keyframes forced onto it, so segment N covers the same span at every height and the source's own keyframes cannot move the cuts. Variants stop being interchangeable the moment that stops holding.
907
-
908
- ## 2.9.141
909
-
910
- - **New**: A held segment says whether the encoder is actually moving. The line already reported that the run was alive and at the right index and stopped there, which left the two possible causes indistinguishable: an encoder waiting on torrent pieces looks exactly like one that is encoding and has not finished. It now reports how much media the run has produced since it started and at what speed, and says outright when the position has not moved at all — which means the input is what is being waited for. Measured 2026-08-11: segment #675 was held with the run started at #675 and the encoder alive, nothing in the log could say why, and the browser then abandoned the session and built another — which is where the "second session after a seek" came from.
911
-
912
- ## 2.9.140
913
-
914
- - **New**: What this host takes to produce a first segment is now derived from the startup benchmark, so a machine answers correctly on its very first run. Encoder detection already encodes `testsrc2` through the real HLS pipeline and records each preset's throughput in pixels per second; one segment is a known quantity of pixels, so the time follows by division. No coefficient is involved — it is a measurement of this machine taken minutes earlier, applied to a known amount of work. Until now there was no answer at all before the first session finished, and the browser filled the gap with an assumed rate of exactly one, which was wrong by a factor of four in both directions.
915
- - **New**: Recorded medians survive a restart, in `host-timings.json` beside the proxy. Previously every restart went back to knowing nothing and the first viewer after it saw a figure with no measurement behind it.
916
- - **New**: Both are logged together on every real measurement — `first-segment synthetic=Xms measured=Yms ratio=Z`. The intent is to stop carrying history: if the synthetic figure tracks the measured one, the file can go and every machine is right from its first second. A ratio that varies with content instead would say the synthetic figure needs the source's own character as an input, which the probe already has. Reasoning recorded in the meta roadmap.
917
-
918
- ## 2.9.139
919
-
920
- - **Fix**: The proxy actually states which tracks its output will carry. It has been declaring `{video: false, audio: false}` for every session since the declaration was written, because it reads the codecs off the planner's media-info cache and that cache has only ever stored dimensions, duration, fps, start time and an HDR flag. Reading a field that is not there yields `undefined`, and `Boolean(undefined)` is `false`, so the promise was empty and silently so. Measured 2026-08-11 in the field: `declared tracks video=false audio=false`. Two things depended on it and both were disarmed — the browser could not tell "this file has no video" from "the video was lost on the way", and the init-segment guard computed a requirement of zero tracks and therefore accepted any header at all, including the audio-only one that leaves a session playing sound with no picture. The codecs are now stored where they are read.
921
- - **Chore**: A test compares the fields the declaration READS with the fields the cache STORES. Nothing caught this: the writer and the reader each look correct in isolation, and no test had ever put the two shapes side by side.
922
-
923
- ## 2.9.138
924
-
925
- - **Fix**: The init segment is now required to declare every track before it is cached — the requirement was computed and then ignored. One pass worked out how many tracks a complete header must have; the next returned the FIRST header it found, whatever it declared. A piece written before the video was muxed therefore supplied an audio-only header, and that header is kept for the session's whole life, because the player fetches `#EXT-X-MAP` exactly once and never again. The browser then has no video source buffer however much video arrives afterwards. Measured 2026-08-11 on the field host: `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — an element perfectly content, playing sound, with no picture in it for as long as the session lasted. A header short of a track is now kept only as a fallback and used only if no complete one is found, which is also when the log says so.
926
-
927
- ## 2.9.137
928
-
929
- - **New**: A session states the track set its output will carry, and sends it to the browser. The proxy knows the set exactly — it chose it: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output. That statement is now used twice, which is the point of making it: the init segment is checked against it here, and the browser checks what it actually received against the same statement (server 0.8.161). A track lost between the encoder and the element was previously noticed only by its absence, minutes later, as a black picture with working sound.
930
-
931
- ## 2.9.136
932
-
933
- - **Fix**: What a complete init segment must describe is now taken from what the proxy DECLARES it will output, not from a count. 2.9.135 required two tracks, which is a guess — wrong for a film with no soundtrack, and meaningless for a source carrying several dubs, subtitles or a cover-art video stream. The output does not inherit the source's track list: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output at all. So the proxy knows the output's set exactly, because it chose it — the probe says which kinds exist, the mapping says how many are taken. Deriving the figure from produced pieces instead reads correctly only once a piece carrying every track exists, and the moment that matters is the one before that: an early piece written before the video was muxed sets the requirement to one and waves through precisely the header this exists to reject. Pieces remain as a floor, since a piece carrying more than declared is evidence, and evidence outranks a declaration.
934
-
935
- ## 2.9.135
936
-
937
- - **Fix**: A session no longer plays sound with no picture at all. The init segment — the header that tells the browser which tracks exist — is lifted out of the first self-contained piece and then cached for the WHOLE session, because the player fetches `#EXT-X-MAP` once and never again. A piece written before the video track had been muxed declares audio alone, and the browser then has no video source buffer for the rest of the session however much video arrives afterwards. Measured 2026-08-10 from the browser's own counters: sixty-five seconds of playing sound with `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element perfectly satisfied, with no picture in it. A header short of a track is now passed over and the next piece tried; if no piece carries the full set the richest one found is served and the shortfall is logged, so a source that genuinely lacks a stream still plays while the other possibility stays visible.
938
-
939
- ## 2.9.134
940
-
941
- - **Fix**: The line reporting how long a segment waited before anyone decided to restart for it now prints. A restart backs off a segment or two from what was asked for, so the request that prompted it is recorded under a higher index than the run starts at; looking it up by the start index alone found nothing, and the instrument added in 2.9.132 never said a word. It now takes the earliest request at or above the index the run begins from.
942
-
943
- ## 2.9.133
944
-
945
- - **Fix**: A restarted encoder no longer waits for its predecessor to die — every seek is about a second shorter. Runs shared one output directory, so two of them writing `segment-00042.mp4` at once would produce a file that is neither; the only defence was to kill the old run and block until it was gone. Measured 2.9.132 across four seeks: 712, 852, 882 and 1297 ms, against 11-15 ms of everything else a restart does. So that wait WAS the restart. Each run now writes into a directory of its own, which makes the collision impossible, so the new run starts at once and the old one is left to die in the background. Serving a segment searches the run directories newest-first, because a later run's answer supersedes an earlier one's — the older file may be the truncated output of a run that was killed mid-write, which is precisely what sharing a directory used to hide. Covered by a test that lays out two runs and insists the newer one wins.
946
- - **New**: How long a segment waited before anyone decided to restart for it. The restart costs about a second; a seek costs five to eight, so most of the wait happens before the decision is even taken, and nothing measured that gap.
947
-
948
- ## 2.9.132
949
-
950
- - **New**: A restarted encoder run says what its restart cost, and how much of that was waiting for the previous run to die. A seek costs 5-8 s in the field, and the reason on record — waiting for the previous ffmpeg to exit, measured once at 0.54-1.47 s — does not account for it. The remedy under consideration is a separate output directory per run, which removes the wait entirely but makes serving a segment a search across runs: the hottest path in the proxy, rebuilt on a guess about where the seconds go. So each stage states its own cost first. Two lines: how long SIGTERM took to be obeyed, and the total from the restart being asked for to the new run being announced.
951
-
952
- ## 2.9.131
953
-
954
- - **Fix**: The first segment of an encoder run is served once the encoder has passed it, instead of waiting for a successor nobody is producing. A segment counted as finished only when the NEXT one had been started — sound while a run moves forward through a file, and meaningless for the segment a run BEGINS at, because the run has only just arrived there. That is precisely the segment a resume or a seek depends on. Measured 2026-08-09 with the hold instrument: `#807 exists, but the next segment (#808) has not been started yet`, held while it lay complete on disk; in August the same shape held `#317` for 46 s and then answered 404 to a browser that had already given up, three releases in a row. A run whose reported output position is past a segment's end has necessarily closed that segment, so that is what decides it now. Covered by a test that builds exactly the resume shape — a run start with no successor on disk — and insists on the bytes.
955
-
956
- ## 2.9.130
957
-
958
- - **New**: A segment being held says WHY, at most once every five seconds per file. A hold was silent, and that silence has now cost three releases: a file that exists, a route answering "not yet", and nothing anywhere naming which of the several reasons applied. Measured 2026-08-09 — a run begun mid-file at segment #317 produced two minutes of video from #317 upwards at 10.5x, while #317 itself was held 46 s and then answered 404 once the browser had given up, with not one line about the cause. The line names the reason, the segment the run began at, where the viewer is, whether the encoder is alive, and which index was asked for. The case that matters most is called out on its own: the first segment of a run started mid-file is held by a rule that waits for the NEXT one to exist, and that is exactly the segment a resume depends on.
959
-
960
- ## 2.9.129
961
-
962
- - **New**: A held segment says why it is held. A hold was silent, and that silence has now cost three releases: a file that exists, a route answering "not yet", and nothing saying which of the several reasons applied. Measured 2026-08-09 — a run begun mid-file at segment #317 produced two minutes of video from #317 upwards at 10.5x while #317 itself was held for 46 116 ms and then answered 404, once the browser had already given up. At most once every five seconds per file it now names the reason (not on disk, or present but the next segment has not been started), together with the index the run began at, where the viewer is, and whether the encoder is alive. The readiness rule — a segment counts as finished once the NEXT one exists — is the suspect for a resume, because for the first segment of a mid-file run that rule decides whether playback begins at all.
963
-
964
- ## 2.9.128
965
-
966
- - **Fix**: A read that ends because the reader left is no longer reported as a failure. ffmpeg is terminated on every seek and whenever the look-ahead bound suspends it, and its connection closes with it, so `write ECANCELED` on the stream route is the ordinary end of a read — yet it was logged as a warning several times a minute through healthy playback. On 2026-08-09 it was read as the cause of broken audio, which it was not. The line now says whose end it was: a reader that disconnected is recorded at debug and says so, anything else stays a warning.
967
-
968
- ## 2.9.127
969
-
970
- - **New**: A read that hands the file over out of order now says so. A sequential read walks forwards, so each fragment either continues the piece before it or moves to the very next one; anything else means the bytes reaching the decoder are not the file's bytes in order. Measured 2026-08-09 on a 1080p file with an AC-3 track: the encoder ran at 7.7-9.3x, produced its first segment in 9.1 s, reached 00:02:19 of 02:29:58 — and the AC-3 decoder reported "new coupling strategy must be present in block 0", "exponent 26 is out-of-range" and "invalid coupling range" while the piece store showed no spills and 100% of reads served from memory. Video was being COPIED in the same run, so the viewer lost the picture and the sound together: one fault, not two. The bounds check added in 2.9.126 catches a fragment outside the shared pool and stayed silent throughout, so the bytes came from the pool legitimately and belonged somewhere else. This names which piece arrived where.
971
-
972
- ## 2.9.126
973
-
974
- - **Fix**: A read whose offset lies outside its piece pool ends short and says so, instead of taking the whole source down without a word. The pool is a growable `SharedArrayBuffer` shared with the torrent thread, and an offset only means anything against the buffer of the store that produced it; when the two disagreed, building the view threw `RangeError: Invalid typed array length: 8388608` — one piece — which the process-wide handler swallowed. Reads then stopped answering for good. Measured 2026-08-09: ffmpeg was fed cut-up frames and reported them as a broken AC-3 stream ("new coupling strategy must be present in block 0"), no segment could be closed because no audio frames were produced, and segment #305 was held for a minute eight times running while 76 seeders delivered 35 MB/s. The file was fine; the reads were not. The log line now carries the offset, the length and the pool's size, so a recurrence names its own cause instead of being reconstructed from a decoder's complaints.
975
-
976
- ## 2.9.125
977
-
978
- - **Fix**: Playback works again. 2.9.124 shipped two names it never declared — `readSelfContainedStartSeconds`, called in `segment-formats/fmp4.js` and imported nowhere, and `SEGMENT_START_DISAGREEMENT_SEC` on the line after it — so preparing any segment cut at keyframes threw a `ReferenceError`. That is every ordinary file. Measured 2026-08-08: the playlist and the init segment were served, twelve finished segments lay in the session directory, and the request for segment #0 was held for 45 281 ms and then answered 404 because the browser had given up and released the session. Peers, download rate and transport were all healthy throughout, so nothing in the logs pointed anywhere near the cause.
979
- - **Fix**: A fault while preparing a file that EXISTS is now reported instead of being passed off as "not produced yet". One `try` covered both the existence check and everything after it, and its `catch` meant only the first, so the exception above came out as "still warming up" — the request was held, the next poll threw the same exception, and so on until the viewer left. Nothing was logged at any point. The existence check now stands alone; a file that goes away between the check and the read still means "not ready", and anything else is logged with its stack and answered as a failure. The same split applies to the init segment.
980
- - **Chore**: `npm test` runs the linter before the tests. The linter added in 2.9.103 exists precisely to catch an undeclared name, and it names both of these — it simply was not run before releasing 2.9.124, because nothing ran it.
981
- - **Chore**: A test asks the session manager for a segment that exists and insists on getting the bytes back. Every unit test of the fMP4 path passed while playback was dead: they import the function straight from `mp4-boxes.js`, so the missing import in its CALLER was invisible. A second test pins that a fault in preparing an existing segment is answered as a failure, not as an endless wait.
982
-
983
- ## 2.9.124
984
-
985
- - **Fix**: Subtitles no longer drift away from the picture. A segment was stamped with the time the PLAYLIST assigned it, and the playlist is built from the container keyframe index — which can be wrong. Measured 2026-08-06 on a Matroska file whose index claimed a keyframe at 157.99 s where the real ones were 153.820 and 164.247: ffmpeg cut at 153.820, the stamp said 157.99, and the player was told that picture belonged 4.17 s later than it did. Subtitles, extracted straight from the source with no offset of any kind, kept the true times, so speech and text sat 4.17 s apart for the whole stretch. The stamp now comes from the piece itself — the muxer records its position as an empty edit at the head of the track edit list, read before the header is stripped — and falls back to the playlist only when the piece does not say. Identical to the old figure whenever the index is honest, so a well-formed file is unaffected. When the two disagree by more than a quarter of a second the log names both, so an index that lies is visible rather than merely felt. Verified against ffmpeg on pieces cut the same way (12/30/36 s read back exactly), and covered by tests including a 64-bit edit list and a non-default movie timescale.
986
-
987
- ## 2.9.123
988
-
989
- - **Fix**: The check added in 2.9.121 was deleting the file an encoder was writing into. A segment short of a track means one of two very different things — left behind by a run that was killed, or simply not finished yet — and treating them alike removed the file mid-write, after which ffmpeg went on writing to something nobody could open and the segment never appeared. Measured 2026-08-06: segment #225 was deleted 14 s into the run producing it and answered 404 thirty-three seconds later. The readiness rule could not prevent it, because it waves a segment through once the NEXT one exists and that next one had been left by an older run. Ownership decides it now: the current run writes from its start index upwards, so a file at or above that index while the run is alive is unfinished and is waited for, and only a file below it, or any file once no run is producing, is a leftover worth removing.
990
-
991
- ## 2.9.122
992
-
993
- - **Fix**: A session created with a start position begins encoding there, instead of at the top of the file. The position was honoured everywhere except the one place that mattered: it went into the session key and into the log line, and then the first run started at index 0 regardless. Measured 2026-08-06 on a Retry after the proxy had restarted — the session was created with `start=1580s`, the encoder began at #0, the player asked for #152, and 45 s later the browser gave up with "no data arrived from the proxy" while the transcode ran happily at 9.9x through the opening credits.
994
-
995
- ## 2.9.121
996
-
997
- - **Fix**: A segment that is short of a track is no longer served, which is what left a seek hanging with the proxy answering every request in 98 ms. A run that is terminated closes its current output file properly — trailing index and all — but the file holds only what had been muxed by then, and after a seek-restart that is routinely one track of two. Nothing about such a file looks unfinished: it exists, the next one exists, so the readiness rule called it done. Measured 2026-08-06 on a stuck session: segment #133 carried one `tfdt` where #131 and #134 carried two, and #132 was zero bytes. The fragments are already walked to stamp their timestamps, so counting the tracks in them costs nothing; a segment missing one is deleted and produced again.
998
- - **New**: The session-start line says where the browser asked the encoder to begin. A resume that reaches hls.js but not this call makes the player request a segment nobody was told to produce — measured the same day, the session began at #0 while the player asked for #127 and gave up 45.6 s later — and neither side said what it meant.
999
-
1000
- ## 2.9.120
1001
-
1002
- - **Fix**: The rest of the file is fetched while the viewer needs nothing, instead of the link sitting idle. The background fill was re-evaluated only when a reader window MOVED, so during exactly the state it exists for — the encoder held back by the look-ahead cap, the viewer comfortably ahead, the link free — nothing was fetched at all. It is now owned by the pool's own timer rather than by the reader, because a parked reader cannot act, and parked is the whole point. Priority 0 against the window's 1, and withdrawn the moment any reader window wants something, so it can never take capacity from the picture.
1003
- - **Fix**: The stall warning stays quiet when a download of zero is correct. It fired all through a healthy session on 2026-08-06 — 65.3% of the file present, the encoder 134-159 s ahead, its window complete — and a warning that goes off when everything is right teaches the reader to ignore it. It now checks whether any reader window is actually missing a piece before saying anything.
1004
- - **New**: The progress response carries what this host takes to create a session and to produce a first segment. Both are on the playback plan too, but the browser reads that once per file: measured 2026-08-06 across four seeks, a proxy that had just restarted answered null for both, so every later seek estimated the wait with one term of four — the figure reached zero after 3.5 s of an 11.8 s wait and read "starting now" for the remaining 8.4 s. This response is polled about every 1.5 s.
1005
-
1006
- ## 2.9.119
1007
-
1008
- - **Fix**: The progress report says the height the viewer is actually watching, not only the one an encoder is producing. It was zero whenever the video was copied — which is most sessions — so the quality menu read a bare "Auto" in exactly the case it was built to explain. Copying reports the source height, re-encoding reports the rung the proxy has settled on.
1009
-
1010
- ## 2.9.118
1011
-
1012
- - **New**: Suspending the encoder says what the decision was taken on — where the viewer is, how far the unbroken run of segments reaches from there, and how many segment files the session directory holds. Suspending stops the only thing that reads the input, so a wrong reading here stops the download as well: measured 2026-08-06, the log announced "135s ahead of the viewer" while three segments totalling 31 s lay on disk, and neither figure could be checked against the other because the line carried no evidence. The directory keeps the segments of every run a session has had, so which of them were counted is the whole question.
1013
-
1014
- ## 2.9.117
1015
-
1016
- - **Fix**: A seek backwards no longer kills playback. How far the encoder is ahead of the viewer was measured as the highest segment number lying in the session's directory, which equals the look-ahead only while a viewer moves forward through one run. Measured 2026-08-06: a seek forward left segments 662-665 on disk, the viewer seeked BACK to 646, and the limiter compared 6950 s of output against a viewer at 6700 s, called it "250s ahead" and suspended the new run **136 ms after it started**, before it had produced anything. With the encoder stopped nothing read the input, so no pieces were requested — `0 selection(s)` with 33 peers connected — and segment 646 was never made; the viewer sat on a spinner for four minutes while a stopped ffmpeg was held back for being too far ahead. The measure is now the unbroken run of segments starting where the viewer is, and a viewer whose own segment is missing is not "zero ahead" but waiting, which resumes the encoder instead of pausing it. Covered by tests, including the field case.
1017
- - **New**: Groundwork for switching quality without interrupting playback: the heights a source can be served at, the master playlist that offers them as HLS variants, and creation of a variant's encoder on first request. Not yet reachable — the routes come with the browser side. Reasoning in `research/seamless-switching-2026-08-06.md`.
1018
-
1019
- ## 2.9.116
1020
-
1021
- - **New**: A progress report says which height is being produced right now. Under automatic quality the proxy steps the resolution down when the host cannot encode in realtime or the viewer's link cannot carry the stream, and nothing said so — the menu read "Auto" whatever it had settled on. Zero when the video is copied, because then nothing is being chosen and the source's own height is what plays.
1022
-
1023
- ## 2.9.114
1024
-
1025
- - **Fix**: A seek leaked a pinned piece, and enough of them destroyed the torrent. A piece is pinned before its fragment is handed to the reader and released by whoever received it — but a consumer that ABANDONS the read never gets the chance, and a seek abandons it every time: the encoder is killed, the response is torn down, the loop is left between two fragments. Field 2026-08-06, one seek was enough: the store answered `Every resident piece is pinned; no slot can be freed`, and it answered it to the WebTorrent client, which closed the store and destroyed the torrent — after which every read failed with `File 0 not found`, the session went terminal, and the segment the viewer was waiting for returned an instant error. The pin of a fragment still in the consumer's hands is now dropped by the reader itself on every exit, abandonment included. Covered by a test that abandons a read mid-fragment and checks the store has nothing pinned.
1026
- - **Chore**: The look-ahead reports that ffmpeg's position and the segments on disk disagree on the EDGES of that state — once when they part company, once when they meet again, with how long it lasted. It was printed per call of a function that runs on every segment request, which after a seek meant three hundred times a minute; a rate limit would only have hidden that the line was in the wrong place, and it could not have said how long the disagreement went on.
1027
-
1028
- ## 2.9.113
1029
-
1030
- - **New**: The transport's own counters are written to the log for as long as a channel is open, not only when its send queue backs up. The queue was the wrong thing to watch: field 2026-08-06, a 9.26 MB segment was accepted by the transport with `maxBuffered=0 bufferedAtEnd=0` and reported as sent at 274 Mbit/s, and it never arrived — after which everything the proxy sent vanished the same way while requests kept arriving in the other direction. With nothing ever queued the existing watcher never woke, so the one question that decides the cause — did those bytes leave the machine — had no answer in the log. Every five seconds it now records bytes sent and received by the transport itself, the queue depth, the round-trip time, and the path in use. The browser records the matching figures on the same cadence (server 0.8.110), so a recurrence is settled by subtracting one line from the other rather than by reasoning.
1031
-
1032
- ## 2.9.112
1033
-
1034
- - **New**: A session whose data went away now waits for it to come back instead of dying. Losing the input is not the session failing — the torrent can be added again and the pieces downloaded again — but a run that died that way marked the session terminal, and every request for the playlist answered 500 from then on, although the swarm was right there and the data would have returned in seconds. Such a run is now retried at the position the viewer is waiting at, backing off from 2 s to at most 15 s so a source that is genuinely unavailable costs a process every few seconds rather than continuously, and the requests being held are simply held: nothing is broken and there is nothing for the viewer to retry. The circuit breaker stays for what it was built for — a target that truly cannot be encoded — and no longer condemns a session that merely lost its data. Which of the two happened is decided by the message, tested against the exact ones the field produced.
1035
-
1036
- ## 2.9.111
1037
-
1038
- - **Fix**: The film being watched could be deleted mid-seek. A torrent's data is protected only by a claim that READS take, and a seek leaves a gap with no read at all — the old encoder is dead, the new one has not started. The thirty-second disk sweep met that gap on 2026-08-06: with the cap exceeded it evicted "the idle torrent" that a viewer was in the middle of, deleted six gigabytes, and the new encoder found nothing to read. The session's own thirty minutes never governed the data underneath it, because the pool was never told a session existed. A session now holds its source for as long as it lives, and lets go when it is disposed.
1039
- - **Fix**: The encoder is no longer suspended on ffmpeg's word alone. How far it has run ahead was taken from the position ffmpeg reports for itself, and that is not evidence: field 2026-08-06, it claimed 6012 s processed at `speed=1.18e+03x` on a file one percent downloaded with exactly one segment on disk. The limiter believed it, suspended the encoder twelve seconds into the session, and segment #1 — which nobody was now producing — was held for 45.7 s until the viewer gave up and seeked. It is now measured by the segments that exist, which is what the viewer can actually be served, and a wide disagreement between the two is logged, since that is the only trace of whatever made ffmpeg report a position it had not reached.
1040
-
1041
- ## 2.9.110
1042
-
1043
- - **Fix**: A torrent the pool had destroyed was still being handed to readers, which killed every later session for that source. The torrent thread remembers each source as a promise and only ever forgot one when the ADD failed — but the pool destroys a torrent that has gone unread for a quarter of an hour, and under disk pressure, clearing its own map and knowing nothing about this one. The promise then resolved to a corpse: a destroyed torrent keeps its object and loses its files. Nothing noticed, because by then everything else answers from cache — measured 2026-08-06 on two sessions in a row, the plan came back in 23 ms and the session was created in 2 ms, so no step waited for metadata, and ffmpeg's first read died 130 ms in with `File 0 not found in torrent:…`; every request for the playlist then answered 500 until the viewer gave up. Both sessions were from a phone on a cellular link, which is what made it look like a connectivity problem — ICE had in fact connected in 0.84 s over reflexive addresses and both data channels were open. A handle that cannot be read from is now replaced rather than returned: the source is added again, using the recipe the thread now keeps for exactly this. Covered by tests.
1044
-
1045
- ## 2.9.109
1046
-
1047
- - **Chore**: A session now outlives a vanished browser by thirty minutes instead of ten. The number means something different since server 0.8.103: a browser that holds a session re-asserts it every 30 s, so an open tab never consumes this at all — not while paused, not across a three-hour film. What is left is the case where the browser has genuinely gone, and keeping the session means such a viewer returns to a warm encoder rather than a cold start. While nobody is there the encoder is suspended and burns no CPU; the cost is disk for the produced segments, already bounded by the pool's 10 GB cap with eviction. Thirty minutes covers a meal, a phone call or a lift ride.
1048
-
1049
- ## 2.9.108
1050
-
1051
- - **New**: A send queue that stops draining now says WHY, instead of leaving the cause to be guessed at. Field 2026-08-06: a channel stayed open, kept accepting requests and delivered nothing for eleven minutes — the queue grew from 214 049 to 239 731 bytes in fourteen seconds and never fell, while the route reported answering in 15 ms and the channel reported itself open. `bufferedAmount` alone cannot distinguish the possible causes; it only proves the bytes are still ours. Each channel is now sampled every second, and once its queue has failed to fall for five seconds the transport itself is asked: bytes sent, bytes received, round-trip time, connection and ICE state, and the candidate pair in use — then again every second, so the trend of each counter is in the log rather than one snapshot. The reading is decided in advance and written beside the code: bytes-sent rising with the queue means packets leave and nothing acknowledges them (the return path is broken); bytes-sent flat with the queue rising means SCTP is not transmitting at all (the peer's receive window is shut, or congestion control has collapsed); bytes-received still rising in either case proves the peer is alive and the failure is one-directional.
1052
-
1053
- ## 2.9.107
1054
-
1055
- - **Fix**: 2.9.106 could not produce a playback plan at all — `Failed to prepare playback plan: firstSegmentMs is not defined`. Moving the two host timings to be read when a plan is ANSWERED removed the two variables but left the object literal still naming them, on the path that builds a fresh plan. My own linter reports it in four seconds and I did not run it, which is the second time an undeclared name has reached a release; `npm publish` now runs it, so this class of error cannot leave the machine again. The test added with 2.9.106 did not catch it because it exercises the cached path only — the fresh-plan path needs a real probe.
1056
-
1057
- ## 2.9.106
1058
-
1059
- - **Fix**: The two figures the browser needs to say how long until playback now reach it for the file that needs them most. Both are medians of sessions already finished on this host, and the plan read them at the moment it was BUILT and then cached the result — so the very first file opened after a restart got `null` for both and kept answering `null` for the life of the process, however many sessions ran afterwards. Measured 2026-08-05: a fresh proxy answered `null`, then created the session in 6 ms and produced the first segment in 21 479 ms. They are now read when the plan is ANSWERED, so a cached plan reports what the host currently knows. Covered by tests.
1060
- - **New**: When the stats route has nothing to report it says which thing is missing — the torrent handle, the file index, or neither. A source answered `peers=0 file=n/a header=n/a` for minutes on 2026-08-05 while that very torrent was announcing to trackers with hundreds of seeders, and the line could not tell those cases apart. That line is what the viewer's loading screen shows, so it has to be answerable from the log.
1061
-
1062
- ## 2.9.105
1063
-
1064
- - **Fix**: A reader's claim on pieces is put back when WebTorrent drops it, which is what stopped a download dead for eleven minutes. A reader declares the window it needs as a selection and withdraws it when it ends; that claim turns out not to be durable — the library deletes a selection the moment every piece in it is present (`remove fully downloaded selection`). While the reader keeps moving this is invisible, because the next window is claimed at once. It is fatal when the reader STOPS: the encoder gets held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a window that is fully downloaded, the selection disappears, and no code of ours can notice because the reader is parked inside a write. Measured 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at 22:45:05 and stayed there for eleven minutes with 150 peer connections open and the new diagnostic reading `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in flight`; when the encoder was let go there was nothing ahead of it. Live reader windows are now re-asserted from the pool's own timer, using the set the piece store already keeps, and only where something is actually missing — re-claiming a satisfied window would only be deleted again on the next pass.
1065
-
1066
- ## 2.9.104
1067
-
1068
- - **Fix**: An encoder run that stops because its input ran dry is no longer reported as a finished file. ffmpeg exits 0 both when it reaches the end of the source and when the source simply stops delivering, and over HTTP it cannot tell the two apart — so when a torrent's download died mid-session (field 2026-08-05), a run that had produced 188 segments of 624 logged `encode-run complete`, the player consumed what was already on disk and then froze for 60 s on the first segment nobody was making. The claim is now checked against the playlist that was published: a run that stopped short is a failure, which the session can restart, rather than a completed file.
1069
- - **New**: A download that stalls says so, and says which of the two possible reasons it is. The same session spent five minutes at **1 KB/s** with 186 peer connections open and trackers reporting ~300 seeders, on a torrent that was not finished, and produced no log line at all — the collapse had to be reconstructed afterwards from three unrelated counters. A torrent with an active reader that drops below 32 KB/s for ten seconds now reports how many pieces are selected and still missing, how many are marked critical, how many peers hold what we want, how many are choking us, how many are being asked and how many blocks are in flight. That separates "the swarm was never told what we need" from "it was told and will not deliver", which the previous evidence could not.
1070
-
1071
- ## 2.9.103
1072
-
1073
- - **Fix**: Playback worked in neither 2.9.101 nor 2.9.102. Both cold-start estimates keep a window of recent samples, and the constant naming that window was used twice and declared nowhere. The session-create one runs on every new session, so `POST /api/transcode-sessions` answered 500 to every viewer and the browser then reported the first segment missing. Field session 2026-08-05: the plan succeeded in 5858 ms, the session request failed 47 ms later, the data channel closed 16 ms after that.
1074
- - **Fix**: The fallback read path threw the same way. `createReadStream` passed a `windowBytes` its own signature never accepted — a reference to nothing, which in a module is an error, not an undefined. It is the path taken for a source with no shared piece pool, so it had never run on a host where it would have been noticed.
1075
- - **Fix**: A failed session no longer leaves its directory behind. It was created before the probe and the keyframe index, both of which can fail, and nothing tracks or sweeps a directory whose session was never registered.
1076
- - **New**: The transcode-session route says why it failed, on the proxy's own log and with the stack. It caught, answered 500 and stayed silent, so the log carried only the data-channel layer's bare `→ 500`: the cause of the defect above had to be recovered by replaying the request against the live proxy.
1077
- - **Chore**: The proxy has a linter. It had none, and the rule for an undeclared name catches this whole class outright — it found the second occurrence above on its first run. Biome, `npm run lint`, limited to the correctness rules that describe real faults rather than style.
1078
-
1079
- ## 2.9.102
1080
-
1081
- - **New**: The playback plan also reports what this host takes to CREATE a session — median of the last eight, 116-843 ms depending on whether the keyframe index is already in hand. It is the second term of the browser's end-to-end estimate, which is being rebuilt as a sum over the stages that have not happened yet rather than a choice between figures that each describe only one of them (`research/playback-eta-2026-08-05.md`).
1082
-
1083
- ## 2.9.101
1084
-
1085
- - **New**: The playback plan reports what this host takes to produce a session's first segment — the median of its last eight, measured from session-create to a servable segment (782-1518 ms on the field host). The browser needs it for the gap between "the file is downloaded" and "a segment exists", where until now it assumed the pipeline merely keeps up with realtime and therefore showed 15 s where 3.8 s were left. It is per-host, so a weak box and a fast one each answer for themselves.
1086
-
1087
- ## 2.9.100
1088
-
1089
- - **New**: A long wait for a piece now says who was working on it. The open question about a seek is that a single 8 MiB piece takes 3.0-4.6 s while the swarm as a whole moves 4-6 MB/s, so only about 2 MB/s reaches the piece being waited for — and whether that is because few peers hold it, few are being asked, or each is slow could not be told apart from outside. The line now carries the rate achieved on that piece and, sampled at its peak while waiting, how many connected peers had it, how many were asked, and how many blocks were in flight.
1090
- - **New**: The keyframe index is read alongside the codec probe instead of after it. Both wait for the same tail of the file; measured 2026-08-04, a probe of 722-1206 ms was followed by an index read of 311-430 ms, all of it before the first segment could be produced. Started together the second is free. Fire and forget, sharing the cache a session would fill itself.
1091
- - **Chore**: Every encoder run is numbered in the log. A burst of seeks starts several runs within a second and every line about them carries the session id, which is the same for all of them — so the command that failed could not be told from the ones that succeeded around it. That is the state the unexplained `Cannot write moov atom before AC3 packets` was found in.
1092
-
1093
- ## 2.9.99
1094
-
1095
- - **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.
1096
- - **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.
1097
-
1098
- ## 2.9.98
1099
-
1100
- - **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.
1101
- - **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.
1102
- - **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.
1103
-
1104
- ## 2.9.97
1105
-
1106
- - **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.
1107
-
1108
- ## 2.9.96
1109
-
1110
- - **New**: The proxy uploads generously at the two moments a viewer is provably waiting — when a torrent is added, and when the viewer seeks — for 25 s, which is two of BitTorrent's unchoke cycles. Peers serve those who serve them: each re-ranks its takers about every 10 s and opens a few slots to whoever uploaded most, plus one at random, so uploading a token 8-50 KB/s means being picked at random, one slot per cycle. Measured on a session where 96 peers were already connected within 2 s: 64 KB/s after 2 s, 1.6 MB/s after 4 s, 4.8 MB/s after 8 s — and the 16 MB the codec probe needs took **8.36 s of the 11.46 s** before playback could start. The existing reciprocity boost could not help, because it waits for the download to be all but dead (below 200 KB/s) with peers visibly choking us, and a ramp is neither: in that same session it first moved the limit 13.3 s after the torrent was added and reached the generous rate at 43.7 s, both after the wait they were meant to shorten. Seeding policy is otherwise unchanged — near-silence when nothing is being watched, a token upload while reading.
1111
- - **New**: Every encode run logs the exact ffmpeg command line. A failure is otherwise reported with ffmpeg's message and nothing about what it was asked to do, and the two are not always deducible from each other: a run died with `Cannot write moov atom before AC3 packets` although both muxing paths were then verified to handle a copied AC-3 track on that very host, so the arguments that run actually received are the missing evidence.
1112
-
1113
- ## 2.9.95
1114
-
1115
- - **New**: The rest of the file is downloaded in the background — but only while that cannot cost the viewer anything. The tail enters the download set at the lowest priority ONLY when every piece of the reader's near window is already on hand, and leaves it the moment one is missing, the window slides onto undownloaded content, or a seek moves it. Relying on priority ordering alone would be weaker: it decides which selection a wire is offered first, not what that wire already has outstanding, so a seek would still queue behind whatever was in flight. What it buys is a file that ends up downloaded while it is watched, making every later seek into it instant.
1116
- - **Fix**: `/stream` answers when the torrent is not ready instead of holding the connection open in silence. Reproduced 2026-08-04 with a magnet whose metadata never arrived: a ranged GET and a HEAD both returned nothing at all for the full 30 s the client was willing to wait — no status, no headers, and nothing in the log — because the route awaited `getTorrent` with no bound and adding a magnet takes as long as its metadata does. The wait is now capped at 10 s and answers a retryable 503; the add itself continues, so the next attempt is likely to find it ready.
1117
- - **New**: The read-ahead window is sized in seconds of playback instead of bytes. A flat 32 MB is about half a minute of a 1080p film and roughly four seconds of a disc remux, and the torrent thread cannot tell the difference — it knows only bytes. The transcode session, which knows both the duration and the file size, now works out the file's own byte rate, asks for 30 seconds of it (bounded to 16-96 MB against an odd rate) and puts the figure on the ffmpeg input URL. Without it the reader keeps its previous default.
1118
- - **Fix**: Everything a reader needs next is marked urgent, not just the piece it is standing on. `critical` is what enables hotswap — a block reserved by a slow peer is re-requested from a faster one — and with pieces of 4 MB the old rule (`min(1 MB / pieceLength, 2)`) marked exactly one. Measured 2026-08-04: the first segment after a seek took 7.2 s while its four pieces arrived one after another at ~2.2 MB/s, with single-piece waits of 1.3 s and 2.8 s. This is not the earlier behaviour returning — that marked the whole requested range, which for ffmpeg's input is every piece to the end of the file.
1119
- - **Fix**: The piece a viewer is about to watch is no longer as evictable as one fetched forty minutes ahead. Each reader declares its window to the store and the eviction order takes something else while it can; measured in a session where the encoder ran ahead, the hit rate fell from 100% to 45.7% with 221 pieces read back from disk. It is a preference, not a hold: when everything resident is declared, protection yields, because at its smallest budget the store guarantees only two resident pieces and an absolute hold would deadlock it. Pins are unchanged — a piece being read now can never be taken.
1120
- - **New**: A seek into content already downloaded but spilled to disk brings the whole window back at once. A spilled piece used to be revived only when the reader reached it, one disk round trip at a time, in step with decoding. The disk is local, so the window can be restored while the reader is still on its first piece.
1121
- - **New**: The playback plan reports where its time goes — waiting for the torrent, waiting for the file's head and tail, and the codec probe itself, with the number of probe attempts. Everything from the transcode-session request onwards was already broken down by `cold-start`, but the plan runs before that and was one opaque wait: a field session spent 5.7 s in it with the torrent already in the store and the probe cached, and nothing said which part was slow.
1122
-
1123
- ## 2.9.93
1124
-
1125
- - **Fix**: A seek could kill playback outright. Restarting at a position that lands exactly on a keyframe leaves a floating-point residue — `seekSeconds - snappedKeyframe` came out as `3.3333333249174757e-7` — and `String()` renders anything below 1e-6 in exponential notation, which ffmpeg's duration parser rejects: `Invalid duration for option ss`. The run died on startup, and from then on every segment request answered 500. Time arguments are now formatted in fixed notation, and a residue under a millisecond is dropped rather than passed on, because it is not a real offset.
1126
- - **Fix**: A session could never recover from a dead encoder. The "already covered by the running encode, not restarting" shortcut did not check that the run was alive, so once one had died `session.ffmpeg` still pointed at the corpse and every later seek was waved through as already covered. One ffmpeg failure therefore became a session that answered 500 for as long as the viewer kept trying.
1127
- - **Fix**: The look-ahead bound held the encoder back but did not keep it there. Any segment request released it, including a request for something produced ten minutes earlier, so it sawtoothed between suspended and running and drifted from 155 s to 922 s ahead of the viewer over three minutes. A request now re-evaluates the same condition the monitor uses instead of resuming outright.
1128
-
1129
- ## 2.9.92
1130
-
1131
- - **Fix**: A seek acts on what the viewer asked for, instead of waiting out guards built for a signal that no longer exists. Three delays sat in front of every seek, all of them there because a far segment REQUEST used to steer the encoder and the player's playlist scan produced dozens of them. Requests stopped steering anything when the position became explicit, so what arrives now is only ever a position the viewer stated. The settle window drops from 1.2 s to 300 ms (the browser already collapses a drag into one report at 300 ms — this was a second debounce on an already-debounced signal, and it cost 1.2 s of every measured seek). The floor between restarts drops from 4 s to 500 ms, now a guard against a client spamming the endpoint rather than a policy about noise. And a run in progress is no longer protected for up to 30 s while it reaches its first segment: finishing a segment for a position the viewer has left is work nobody wants, and the hold could delay a genuine second seek by the whole grace. Measured cost of the old behaviour, 2026-08-04: two seeks 1.3 s apart produced two restarts 4.4 s apart, the first encoding 119.5 s of content before the second killed it.
1132
- - **Chore**: Removed `ENCODER_STALL_MS`, declared with a paragraph describing a watchdog that was never wired to anything.
1133
-
1134
- ## 2.9.91
1135
-
1136
- - **Fix**: The encoder no longer runs away from the viewer. Nothing bounded how far ahead it produced: measured 2026-08-04, three minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51 source at 12.8x while the viewer was still at the start, and the torrent had pulled 80% of 4.7 GB to feed it — the pool owner's bandwidth and disk spent on a viewer who may watch two minutes, the pieces being read evicted from memory by pieces forty minutes ahead, and the swarm busy with anything but the segment being waited for. An encoder more than two minutes of content ahead of the last segment its viewer asked for is now **suspended**, and released once the viewer is within a minute of it — or at once when a segment is requested. Suspended rather than killed on purpose: restarting costs about nine seconds on this hardware, so a viewer reaching the end of the produced range would stall every time, while suspending keeps the process, its input and its position. POSIX only; where `SIGSTOP` does not exist the attempt fails once, is logged, and that session keeps the old behaviour. Every path that terminates an encoder now releases it first — a suspended process does not act on `SIGTERM` until it is continued, which would have hung the wait a seek performs before starting its replacement.
1137
- - **New**: A reader reports what it waited for. When a read blocks a second or more on a piece, the log names the piece, its position in the read, and the offset the read started at. The first segment after a seek-restart costs 9.2-9.4 s and there was no way to tell whether that is the swarm, the piece picker or ffmpeg; now there is.
1138
-
1139
- ## 2.9.90
1140
-
1141
- - **New**: The output container is chosen per session, by the viewer, instead of once per proxy. `POST /api/transcode-sessions` accepts `segmentFormat`; `--segment-format` remains the default for a client that expresses no preference, and an unrecognised value falls back to it rather than to the library default. The browser is the only party that knows what its media stack will accept for the tracks it asked to be copied: a copied MP3 track cannot be appended from fMP4 at all (`audio/mp4; codecs="mp4a.69"` is refused by MediaSource) but works from MPEG-TS, which hls.js demuxes itself and hands to a plain `audio/mpeg` buffer — the same file, the same browser, silent loop one way and normal playback the other. Sessions are keyed by container too, so two viewers wanting different ones do not share an encoder. Nothing branches on the format outside `services/segment-formats/`; the manager now reads it off the session.
1142
-
1143
- ## 2.9.89
1144
-
1145
- - **Fix**: What the torrent downloads is now decided by the readers, and by nobody else. Three places were claiming pieces for the same file and overwriting each other on every request: `acquireFile` selected the whole file, `prioritizeByteRange` selected from the read position to the end, and the reader selected its entire requested range. The reader's claim was the worst of the three — ffmpeg opens its input as `bytes <position>-<EOF>`, so the first read of a session claimed the **whole file** and marked **every piece critical**, and nothing ever gave it back, because that read is abandoned a second later when ffmpeg seeks. No later prioritisation could outrank a permanent whole-file claim, which is why 2.9.88 changed nothing measurable. Each read now holds a moving window ahead of its own head, as a **stream selection** — the kind WebTorrent counts rather than merges, so several parallel readers (the codec probe's head and tail, subtitles, one input per viewer) produce the union of their windows — and releases it on completion, cancellation and abandonment. `critical` marks only the piece being waited for and at most two more, which is the rule WebTorrent's own reader uses and what the flag is supposed to mean. `prioritizeByteRange` keeps only what readers cannot do: the read position for the resume figures, and the jump log line.
1146
- - **Fix**: A seek releases the segment requests it made pointless. hls.js keeps one fragment load outstanding, so a request being held for the old position blocks the one for the new position — measured 2026-08-04: a backward seek into fully downloaded data waited **57 s** for a held request for `#609` to run out the 60 s hold, then fetched the segment it wanted in 15 ms. The same hold trapped 45 requests at once during a forward seek. A viewer seek now ends every wait that started before it with a retryable 503, as `hls-media-server` does (`research/hls-seek-prior-art-2026-08-02.md`, prescribed there and never built).
1147
-
1148
- ## 2.9.88
1149
-
1150
- - **Fix**: A seek no longer makes the swarm walk the file to get there. Two faults, both confirmed by running WebTorrent's own selection code on the numbers of a measured session (588 pieces, download at 38.4%, seek to 89.1%). First: a selection carries an `offset` — how many pieces from its start are already downloaded — and the picker scans from `from + offset`; `deselect` subtracts an interval and copies that offset into what survives, so demoting the pieces behind the playhead left `{523-587, offset 226}`, a selection whose scan begins at piece 749 of 587. The seek target ended up wanted by nobody. The range is now re-selected right after the demotion, which replaces the dead entry with a fresh one starting at the playhead. Second: a request with no byte range was reported as an ordinary read at offset 0, and ffmpeg opens its input with exactly such a request and abandons it as soon as it seeks — as do the keyframe index and the codec probe, four of them around every encoder restart. Each one re-selected the whole file from piece zero, undoing the seek; the picker then skipped what was on disk and downloaded forward from the first hole. Measured cost of the pair: a seek to 89.1% of a 4.7 GB film fetched **2.47 GB over 93 s** where one 8 MiB piece was needed. A range-less read now sets the read position only when nothing else has.
1151
-
1152
- ## 2.9.87
1153
-
1154
- - **Fix**: fMP4 playback no longer stops after the first segment. A segment's position was being written into **every** fragment it contains, and the explicit-cut muxer puts several in one segment — `frag_keyframe` opens a fragment at each keyframe while a cut point comes only every few keyframes. Measured: a 6 s piece carries three fragments per track, at 0, 2 and 4 s of its own clock; all three were stamped with the segment's start, so they claimed the same decode time and the player rejected the segment. In the field (2.9.86, this session) that showed as segments 1 and 2 requested in an endless alternation, each served in tens of milliseconds with the transcode healthy at 12x, while the picture froze a few seconds in. The position is now applied as a shift: each track's first fragment sets the base and the rest keep their distance from it. With one fragment per track — what the `hls` muxer produces — a shift and a write are the same thing, so the other path is unchanged. Verified end to end on the addon host: four pieces cut, split, stamped and reassembled the way a player does, then probed — 600 frames over 24 s, decode timestamps rising by exactly 0.04 s across every segment join, no duplicates, clean decode.
1155
-
1156
- ## 2.9.86
1157
-
1158
- - **Fix**: fMP4 playback starts again. The real reason ffmpeg exited before writing anything was the audio, not the file names: the MP4 muxer derives a copied AC-3 track's `dac3` box from the bitstream, so it cannot write `moov` until the first audio packet arrives, while our `empty_moov` demands it at header time — `Cannot write moov atom before AC3 packets. Set the delay_moov flag to fix this.`, captured in the field on a copied AC-3 source. `delay_moov` is now passed alongside it. The `hls` muxer sets that flag itself, which is why the fault appeared only once the muxing moved to the `segment` muxer in 2.9.84; MPEG-TS has no `moov` and was never affected. Verified in the addon container on an AC-3 source: without the flag the exact command the proxy runs fails, with it the segments are written, and the piece layout is unchanged (`ftyp moov moof mdat … mfra`), so the init split added in 2.9.84 still cuts in the same places — headers of consecutive pieces differ in four bytes, all inside `elst`, which the `tfdt` rewriting already overrides.
1159
- - **Chore**: Correcting the 2.9.85 entry below. It blames the `.m4s` extension, and that is false: with the arguments this proxy passes, ffmpeg 8.1.2 writes `.m4s` without complaint (re-measured on the same host, and on 6.1.1). The quoted error is what the same command produces when `-segment_format mp4` is missing — which the proxy never omits — and the field failure ends in `Invalid argument`, not `Muxer not found`. The rename is harmless and stays, but it fixed nothing.
1160
-
1161
- ## 2.9.85
1162
-
1163
- - **Fix**: fMP4 playback did not start at all in 2.9.84 — every request for the init segment answered 500. ffmpeg had refused to open the output: `Could not write header (incorrect codec parameters ?)`, because the `segment` muxer determines the container from the file extension and does not recognise `.m4s` for MP4, whatever `-segment_format` says. Segments are now written and named `.mp4` on both paths. The extension is internal: it appears only in our own playlist and in the temporary directory, so nothing outside changes.
1164
-
1165
- ## 2.9.84
1166
-
1167
- - **Fix**: fMP4 now cuts segments where the playlist says too, closing the gap left by 2.9.82 (which covered MPEG-TS only). The muxer that takes explicit cut times writes each fMP4 piece self-contained — `ftyp moov moof mdat … mfra`, confirmed on the field host — which is not what HLS wants, so the pieces are split on serve: the header is lifted out of the first one to become the init segment named by `#EXT-X-MAP`, and removed from every media segment along with the trailing random-access index, whose offsets describe a file that no longer exists. Timestamps still need stamping exactly as before: measured, all pieces of a run report a start of 0.080 s, each carrying its own zero, which is the same defect the existing rewriting already corrects. Verified end to end on a real piece from the field host — split into a 779-byte init and 221 KB of fragments, recombined, and decoded clean.
1168
-
1169
- ## 2.9.83
1170
-
1171
- - **Fix**: Playback died a few seconds in after 2.9.82. The previous muxer wrote each segment under a temporary name and renamed it once complete, so a file appearing WAS a finished segment; the one that takes explicit cut times has no such option and creates the file when writing starts. The route kept judging readiness by existence, so the player was handed a segment that was still being written, rejected it and stopped — while the encoder ran happily ahead, which is exactly how it looked in the field: three segments served, then silence with the transcode at 7.5x. A segment is now considered finished once the next one has been started, or once the run producing it has ended.
1172
-
1173
- ## 2.9.82
1174
-
1175
- - **Fix**: The playlist and the real segments now describe the same thing. On the copy path ffmpeg was given only a target duration and chose its own cut points, while the playlist was built from the container keyframe index — two independent calculations tied together by nothing but the assumption that they agree. They do not: the index is a navigation table and is not obliged to list every keyframe. On a field file it held 1902 while ffmpeg found roughly twice as many and cut twice as often, so segment #876 meant 1:26:50 to the player and about minute 58 to ffmpeg. A seek into the middle landed at the end and the reported duration drifted. ffmpeg now receives the very boundaries the playlist was built from, via the `segment` muxer, which takes the list outright — agreement by construction instead of by luck. Verified on deliberately uneven keyframes: cuts requested at 4.44, 10.36, 16.28, 22.2 and 28.12 s landed exactly there. Two measured details are encoded in the code: those times count from the start of the RUN, not of the file (starting at 12 s and asking for 18 s put the cut at 29.4), and a tolerance absorbs rounding so a boundary recorded a hair late cannot skip to the next keyframe and silently double a segment. MPEG-TS only for now — fMP4 can do this too, but only as self-contained fragments, which removes the shared init segment and the `tfdt` rewriting built around it; that is a separate change and not one to make blind.
1176
- - **New**: The stream route says why a read failed. A body that failed mid-flight was dropped silently — the connection closed with no status and no log line, which from the client looks like the proxy died and from the log like nothing happened; found while probing the route by hand, where every ranged read closed the socket without a word. It now reports the file, the range, how many bytes had been sent, and the error.
1177
-
1178
- ## 2.9.80
1179
-
1180
- - **Fix**: A seek backward could hang forever. `prioritizeByteRange` demotes the pieces behind the playhead with `deselect`, which removes them from the download set — and `critical`, which runs right after, only flags pieces that are already selected, so it never puts them back. A seek forward followed by a seek backward therefore left the target pieces wanted by nobody: the encoder waited on data the torrent had been told to stop fetching, while the swarm ran at full speed on pieces nobody needed. The read position is now re-selected whenever it moves back behind what an earlier seek deselected, tracked per file because WebTorrent does not report its own selection back.
1181
- - **Fix**: Two pieces could be given the same slot in the shared store. Eviction chose a victim, then **awaited** the spill write before removing it from the books, so a second claim arriving in that window chose the same victim and received the same slot — after which two pieces overwrote each other, both failed their hash, and the torrent downloaded them again indefinitely. From outside this looked exactly like a seek that never completes while the download runs at full speed. The victim is now claimed and unbooked in one uninterrupted step, and a reader that arrives mid-spill waits for the write instead of being told the piece is missing.
1182
- - **Fix**: A burst of concurrent `put`s could fail with "every resident piece is pinned" when nothing was pinned at all. Slots are claimed before the piece is copied into them, and pieces arrive from many peers at once, so the store saw an empty eviction list while its slots were already spoken for. Slots handed out but not yet recorded are now counted, and a claim that finds nothing waits for that work to land rather than declaring the store exhausted.
1183
- - **New**: Two figures the last field failure could not be diagnosed without. The store now reports `pinned=` alongside its other counters, so a leaked pin is visible while it is still harmless instead of only when eviction has nothing left to take; and a read position that jumps — a seek — is logged with its offset and percentage through the file, so it can be seen whether a seek reached the torrent at all.
1184
-
1185
- ## 2.9.79
1186
-
1187
- - **New**: The last copy is gone from the read path. `/stream` now writes the response straight out of the torrent's shared memory and releases each piece only when the socket write reports completion — which is the one moment that is safe, because a piece released earlier can be evicted and its slot refilled while those exact bytes are still on their way out. Both halves of that were verified before being relied on: a socket accepts a view into a `SharedArrayBuffer`, and overwriting the pool from inside the write callback leaves the client's copy intact while overwriting it before the callback corrupts it silently. Measured on the same host, 24 MB of already-downloaded data read in 2 MB ranges: **298 ms against 1008 ms**, 675 Mbit/s against 200, and far steadier (265-308 ms against 641-1338). Callers that keep what they are given — the subtitle route, anything using the plain stream — still get a copy and are unaffected; a source with no shared pool falls back to the previous path.
1188
- - **Chore**: Reading the response body by hand is what makes the release point observable, so the route writes and ends the response itself rather than handing Fastify a stream. A client that disconnects mid-response cancels the read, so pieces stop being fetched for a viewer who has gone.
1189
-
1190
- ## 2.9.78
1191
-
1192
- - **New**: Reads cross the thread boundary as **positions instead of bytes**. The pieces already live in a `SharedArrayBuffer`, so the torrent thread now sends an offset and a length and the main thread reads those bytes where they lie. What this removes is the copy that used to sit on the critical path — 18.84 ms per 10 MB segment on the field host, spent in the same thread that runs the torrent, at the moment a viewer is waiting for that segment. A piece is **pinned** for as long as a fragment of it is outstanding, and unpinned only once the main thread confirms it has finished reading, so eviction cannot take the memory out from under a reader; one fragment is in flight at a time, because the store guarantees only two resident pieces at its smallest budget and holding two pins while asking for a third would deadlock it. Verified against a partially downloaded 5.5 GB torrent: a range read whole matches the same range read in parts, a read spanning a piece boundary matches its two halves, and ffmpeg parses the file through this path (`matroska h264/ac3 5939 s`). The arithmetic is covered by tests, including a file that does not start on a piece boundary — the case where treating file offsets as torrent offsets returns the right number of wrong bytes.
1193
- - **Chore**: `SharedPieceStore` gained `reside`, which brings a piece into memory and reports where it sits without the copy `get` has to make (WebTorrent keeps what `get` returns), and `findSharedStore`, which walks WebTorrent's store wrappers to reach ours rather than assuming their number or order.
1194
- - **Known**: the copy is not gone from the system, only from the torrent thread — the main thread still copies each fragment out of the pool before handing it on, because nothing tells us when the socket has finished with those bytes, and releasing the piece earlier would risk serving whatever landed in the slot next. Removing that last copy needs the body write to report completion, which is a change to the stream route rather than to this transport.
1195
-
1196
- ## 2.9.77
1197
-
1198
- - **Fix**: Anything naming a source while that source was still being added got `Unknown source` — which is false, because the source exists and is merely not ready. Adding a magnet takes as long as its metadata does, seconds to tens of seconds, and the browser polls stats and asks for a playback plan throughout that window. The worker registered the torrent only once the add had **finished**; it now registers the pending add itself, so callers wait for it. Reproduced with a magnet nobody seeds: stats, the file listing and a read all failed instantly while the add was in flight, and all three now wait. A source that was never added is still an error, and a failed add is forgotten rather than replayed to every later caller.
1199
- - **Fix**: File claims are held per reader instead of per file. The proxy reads one file from several places at once — ffmpeg's input, the keyframe index, the codec probe, a second viewer — and claims keyed by `sourceKey:fileIndex` were therefore shared: the first reader to finish released the hold while the others were still reading, leaving the data free to be evicted under them. Each acquire now returns its own claim identity and a release names exactly that claim, so a duplicate or late release matches nothing, is logged, and harms no one. A counter would have restored the arithmetic but kept the ambiguity.
1200
- - **Fix**: `HEAD /stream` no longer starts a read of the whole file. Fastify serves HEAD from the GET handler, so a HEAD opened a full-file read whose body Node discarded while the read itself ran on, the response never completed, and the next request on that keep-alive connection waited behind it — measured in the field as headers in 23 ms followed by a 15 s stall, which is where a 73 s transcode-session create came from. It also has to report the real size: the keyframe index asks for it with this very request and treats zero as "no index", silently falling back to an invented segment grid, so the response is written to the raw socket rather than through `reply.send()`, which substitutes `content-length: 0` for an empty payload.
1201
- - **Chore**: `prefetchFileEdges` takes an options object at every layer, matching `TorrentPool`. The worker adapter declared positional parameters instead, so the planner's options object arrived as `headBytes` and only worked because it was passed along far enough to be destructured at the far end; anyone calling it as documented silently got the defaults.
1202
-
1203
- ## 2.9.76
1204
-
1205
- - **Fix**: A read that failed inside the torrent thread left the reader waiting forever, and a read that failed part-way looked like a file that had simply ended. Two halves of one hole, both present since the thread split: the worker sent the end-of-read marker from its `finally` even when the read had thrown, and the main thread had no handler for a read error at all — so the report was dropped as unknown. That is why the 2.9.71 defect took three releases to find: every symptom said "empty file", never "this read failed, here is why". Now the marker is sent only on success and the failure fails the caller's stream. Covered end to end by a test that hung before the fix.
1206
- - **Fix**: Reads and commands drew request ids from two independent counters into one namespace, so a read and a command could both be in flight as the same number. The worker's reply to the read then resolved the **command** — with the read's result, silently — and the command's real answer arrived later and was discarded as unknown. Depending on which command lost the race this produced empty stats, a prefetch that returned early, or a file claim released before its read had finished. Every id now comes from one sequence, which makes the collision impossible rather than unlikely; a test hands out ids down both paths and asserts they never repeat.
1207
-
1208
- ## 2.9.75
1209
-
1210
- - **New**: The piece store reports what it is doing — resident pieces against the budget, how many spilled to disk, what share of reads came from memory rather than disk, and how often eviction was refused because every piece was being read. Logged once a minute and only when something changed. Without this the component that decides whether a read is free or costs a disk trip was invisible in the field, and the first oddity would have had no evidence behind it.
1211
- - **Chore**: The memory budget is no longer a flat half-gigabyte guess, and no longer claimed up front. It defaults to a quarter of free memory, capped at 512 MB and floored at 64 MB, and the pool **grows into** that budget as pieces arrive instead of allocating it on `add`. Both matter because the budget is per torrent: measured on the field host after a single session, the proxy container sat at 796 MB with 4.1 GB free and 1.3 GB already in swap, so several concurrent viewers under the old scheme would have taken half a gigabyte each for pieces nobody had asked for. `--memory-bytes` overrides it.
1212
-
1213
- ## 2.9.74
1214
-
1215
- - **Fix**: Playback works again. Since 2.9.71 every read answered with headers and an empty body — ffmpeg reported `Stream ends prematurely at 0, should be <size>` and the loading screen sat on "Preparing HLS transcode" until it gave up. Root cause, reproduced locally on two different torrents once the right conditions were used (a large, **partially downloaded** file — a complete one never shows it): the worker transferred ownership of a buffer belonging to WebTorrent's piece cache, the cache's memory was detached, and from that moment every read failed with `Cannot perform %TypedArray%.prototype.slice on a detached ArrayBuffer`. Nobody saw that error, because the worker sent the end-of-read marker from its `finally` before posting the failure and the main thread had no handler for a read error at all — so a broken read was indistinguishable from an empty file. Fixed at the root by owning the memory (the new piece store) and at the boundary by having the transport copy into memory it allocated itself rather than trying to guess whether the caller's buffer was safe to take. Verified end to end on both an almost-complete and a freshly-added torrent: playback plan, byte range, transcode session, init segment and first media segment all produced.
1216
- - **New**: A piece store of our own (`services/piece-store/`): pieces live in a `SharedArrayBuffer`, spill to a single sparse file when the memory budget is full, and come back from it on demand. Owning the memory is what makes the thread split safe — WebTorrent's own cache hands out buffers it keeps using, which is why transferring one detached the cache and killed every subsequent read. Two properties are enforced rather than hoped for: a piece being read is **pinned** and cannot be evicted (with every piece pinned the store refuses to make room instead of taking memory from under a reader — the exact failure of 2.9.71), and a buffer handed to a caller is never invalidated by later writes. Sized by measurement on the field host: a piece copy costs 3.64 ms, reading one back from disk into a buffer we already own 7.63 ms, and re-downloading it from the swarm ~1430 ms — so memory first, disk under it, the swarm never twice. Found while testing: opening the spill file in append mode makes POSIX ignore the write position entirely, so pieces piled up in arrival order and reads returned whichever piece happened to sit at that offset.
1217
- - **Fix**: A torrent carrying a `wss://` tracker took the **whole proxy process** down from 2.9.71 — including the demo magnet on the site's own button. node-datachannel is native and cannot be used from two V8 isolates at once (`HandleScope: Entering the V8 API without proper locking in place`); measured identically on win32/x64 and linux/arm64, with one isolate fine either way, two fatal, and `preload()` in both no help. Before the thread split both users lived in one isolate; afterwards the browser's video channel sat on the main thread while the torrent's tracker announces created peer connections on the worker. Fixed by leaving the native stack where it carries video and giving the torrent thread a JavaScript one (`werift`) through a module-resolution hook scoped to that worker — no dependency is patched, which matters because the addon installs with `--ignore-scripts`. The shim supplies the three things werift's data channel lacks and `simple-peer` depends on: `binaryType` (without it every payload goes through a text decoder and arrives corrupted), the buffered-amount-low event (its backpressure never resumes without it), and a session description built from one object rather than two positional arguments (werift's own signature is `(sdp, type)`, so `{ type, sdp }` was rejected as `invalid sessionDescription`). Verified end to end: a magnet with **only** wss trackers now connects to browser peers and downloads the file completely.
1218
- - **Chore**: The proxy has tests, and publishing runs them. There were none before, and nothing stood between writing code and `npm publish` — which is how the 2.9.71 thread split reached the field with a defect that stopped every read. `npm test` (Node's own runner, no new dependencies) plus `prepublishOnly`, so an unproven package cannot be published. The first cases cover the transport's memory contract and are written to FAIL on the current code: sending a chunk must leave the source buffer usable by its owner, and a second read of the same piece must still return its bytes. Both fail today, which is the point — they describe the shipped defect.
1219
-
1220
- ## 2.9.73
1221
-
1222
- - **Fix**: File stats came back as `{}` after the torrent moved to its own thread (2.9.71), which left the loading screen with no peers, no speed and no progress. Two call sites — the stats route and the health report — invoked `getFileStats` **without awaiting**: it used to answer locally and immediately, and now crosses a thread boundary, so the reply was the pending promise itself, serialised to an empty object. Both now await it.
1223
- - **Chore**: Chunk transfer no longer hands over memory the chunk does not own outright. Node allocates small buffers from a shared 8 KB pool — several unrelated buffers occupy one region, each viewing its own slice (verified: a 1 KB buffer reports an 8192-byte region at offset 8) — so transferring that region would detach it from its neighbours. Chunks sourced from the network are small enough to be pooled while local disk reads are not, which is exactly the difference between the field host and the local test. Measured afterwards: pooled chunks cross intact, so this is a correctness guard rather than the cause of the field failure.
1224
-
1225
- ## 2.9.72
1226
-
1227
- - **Fix**: Playback broke entirely after the torrent moved to its own thread (2.9.71): the torrent was deleted **with its downloaded data** while still being read, after which every read hung and ffmpeg saw an empty input (`Stream ends prematurely at 0, should be 3303133078`), and the container-index read took 73 s to return nothing. Cause: `acquireFile` was dispatched without awaiting while its release was sent normally, so a release could overtake the acquire it belonged to; the reader count then hit zero mid-read and the idle sweep fired (`removed idle torrent ... and its store`). Two fixes, each sufficient alone: the release is now chained onto the acquire so it can never arrive first, and the worker additionally holds the file for the whole duration of the read — a claim that lives inside the read and so cannot be reordered against it. Not reproducible locally, where the test torrent was fully downloaded and never went idle.
1228
-
1229
- ## 2.9.71
1230
-
1231
- - **New**: The torrent client now runs on its own thread (`services/torrent-worker/`). Profiling a live seek (2026-08-02) found the main thread ~85% occupied by WebTorrent — buffer concatenation in `uint8-util` ~15%, `_updateWire` and its wrapper ~9%, garbage collection ~5%, and **no piece hashing at all**, which had been the standing assumption — while three of four cores idled. Serving a segment shared that thread, so reading an already-finished 10 MB file off SSD took **12-23 s** where handing it to the channel took 125 ms. Measured after the split, through the real `/stream` route: **3 MB in 0.05-0.12 s** (~500 Mbps), roughly a hundredfold improvement, with main-thread event-loop delay down from 300-390 ms to **28-38 ms**.
1232
- - **Chore**: The transport was chosen by measurement, not preference. A 10 MB body costs 37 ms structured-cloned, **104 ms through a transferable `ReadableStream`** (the obvious standard answer, and 22x worse), and **4.8-5.3 ms** transferring ownership of 1 MB chunks behind an ordinary `ReadableStream` wrapper — standard interface outside, ownership transfer inside, which is what shipped. Chunk size follows the same arithmetic: at ~100 µs per round trip, 64 KB chunks would spend 13 ms per segment on overhead versus ~1 ms at 1 MB. Backpressure caps chunks in flight so a fast disk cannot rebuild in the message queue the memory the transfers save.
1233
- - **Chore**: `WorkerTorrentPool` presents `TorrentPool`'s existing interface, so the switch is one line in `server.js` and none of the twelve call sites across the stream route, subtitle route, playback planner and health report changed. Torrent objects cannot cross a thread, so the worker keys them by `sourceKey` and hands back a stand-in exposing the `files[i].createReadStream()` shape callers already use.
1234
-
1235
- ## 2.9.70
1236
-
1237
- - **Chore**: Instrumentation to settle where a slow transfer actually loses its time, instead of arguing about it. Every data-channel body transfer now reports the split — `readMs` (reading the body from the local route), `chanMs` (handing chunks to the channel), `drainMs` (waiting for the channel queue) — plus `rate` and, decisively, the **event-loop delay** over the same window (`loopMean`/`loopP99`/`loopMax`, via `perf_hooks.monitorEventLoopDelay`). Synchronous work blocking the loop looks exactly like a slow network from the outside; these figures tell them apart. Prompted by a field seek where a 9.4 MB segment took 16.5 s to deliver with the channel queue **empty the whole time** (`maxBuffered=0`) while the encoder ran at 14x realtime and the file was already on disk — so none of encoder, torrent or channel capacity explained it, and no measurement existed that could. New `utils/perf.js` (`OperationTimer`, `eventLoopDelay`); deeper tools (`--trace-events-enabled`, `--cpu-prof`) remain for when these point somewhere specific.
1238
-
1239
- ## 2.9.69
1240
-
1241
- - **Fix**: Removed the last traces of the seek-start "pull", so nothing can move the encode position except the viewer's own seek. Root cause now measured rather than guessed: **during a scrub the player loads from wherever the slider pauses on its way**. Browser log 2026-08-02 — dragging from 0 to 23:34 lingered at 863.4 s, the player fetched segment #82 for that intermediate point, and a seek that had correctly resolved to start at #134 was dragged back to **#82**, then crawled forward for a minute. The browser's 300 ms debounce exists precisely to discard intermediate scrub positions; reading them back off the segment-request stream defeated it. Gone with it: `lowestAwaitedIndex` tracking, `SEEK_PULL_LIMIT_SEGMENTS`, and the reset paths they needed.
1242
-
1243
- ## 2.9.68
1244
-
1245
- - **Fix**: A seek could be dragged back to the position the viewer had just left. The encoder start was pulled down to the lowest segment the player had outstanding — a stand-in from when the distance to the preceding keyframe was unknown — but at seek time those requests still describe where the player was PLAYING, not where it is going. Field 2026-08-02: a seek to 23:34 (#135) correctly resolved to a start of #134, then got pulled to **#82** (14:15, the position just left) and crawled forward from there. Removed: since boundaries became real keyframes (2.9.65), exactly one segment back always suffices, so the pull has nothing left to correct for.
1246
-
1247
- ## 2.9.67
1248
-
1249
- - **Fix**: A seek waited far longer than it needed to — 56 s measured in the field, of which roughly 50 s was self-inflicted. `SEEK_BACKOFF_SEGMENTS` (how far before the requested segment the encoder starts) was **12**, chosen when segments were an invented 4 s apart and the distance to a usable keyframe was unknown. Since 2.9.65 every boundary IS a real keyframe read from the container index, so the single preceding segment is guaranteed to start on one — and with real 10.43 s segments the old value meant encoding **125 s of content** before reaching the viewer position. Lowered to **1**. Observed: the encoder started at #332 for a seek to #344 and the requested segment only arrived 56 s later, while every segment after it was served in ~100 ms.
1250
-
1251
- ## 2.9.66
1252
-
1253
- - **New**: The container keyframe index now covers **MP4/MOV and AVI** as well as Matroska. MP4 reads the sync-sample and time-to-sample tables from `moov` — found by stepping over top-level box headers, so it works whether `moov` sits at the file start or the end, without scanning the gigabytes of `mdat` between them (verified on a 2 GB field file: **1145 keyframes in 625 ms**). AVI reads the trailing `idx1` table, still worth having because older releases are largely XviD-in-AVI and are exactly the files served by copying rather than re-encoding. Formats left out are documented in the module with the reason: MPEG-TS/M2TS carry no index anywhere by design, fragmented MP4 spreads timing across fragments instead of one table, and FLV/ASF have tables but effectively never appear in these releases.
1254
-
1255
- ## 2.9.65
1256
-
1257
- - **Fix**: Segment boundaries on the video-COPY path are now the source's **real** keyframe positions, read from the container's own index (`services/container-index/`), instead of an invented 4 s grid. ffmpeg can only cut a copied stream at existing keyframes, so the declared grid was simply false — measured on a field file, the true keyframe spacing is **10.43 s**, meaning roughly two of every three declared boundaries could not exist. Players punish this in two ways, both seen in the field 2026-08-02: on a long file the player stops trusting the playlist and walks it from segment #1 to locate a seek (a 1:30 seek produced requests #1, #2, #45, #86 … #1187 and never arrived), and on a short one it presents **audio with no picture**, because a segment beginning without a keyframe has nothing to decode from.
1258
- - **New**: `services/container-index/` — reads a file's keyframe table directly from the container (Matroska Cues today; MP4/AVI to follow) via two point reads: the head, to learn where the table lives, then the table itself. Measured against a 5.5 GB torrent-backed file: **570 keyframes in 0.8 s from 16 KB**, versus a full packet scan that found 77 in 45 s and never finished. Transport-agnostic by construction — it takes a byte-range function and knows nothing of torrents, HTTP or sessions — and cached per (source, file), so re-opens and seeks reuse the first read. Files with no readable index (live captures, interrupted writes, damaged uploads, MPEG-TS) return nothing and keep the previous fallback.
1259
-
1260
- ## 2.9.64
1261
-
1262
- - **Fix**: The 2.9.63 pull-to-lowest-awaited-segment dragged the encoder to the start of the file. A seek to #1354 restarted at **#123** — the position of the *previous* watch — because requests outstanding from before the seek still counted toward `lowestAwaitedIndex`. Two fixes: the awaited floor is cleared the moment a new seek arrives (earlier requests describe where the player used to be, not where it is going), and the pull is bounded by `SEEK_PULL_LIMIT_SEGMENTS` (120) below the target — anything deeper is a leftover, not the preceding keyframe.
1263
-
1264
- ## 2.9.63
1265
-
1266
- - **Fix**: A seek landed the encoder on exactly the requested segment, which is the one position the player never asks for — so it produced files nobody was waiting for and playback hung. Per Apple HLS authoring guidance, a player given a position locates the nearest keyframe **preceding** it, decodes from there, and only then presents from the requested point; it therefore always fetches segments **below** the target. Measured on iOS: a seek to #1082 fetched from #1074 (8 back), one to #1358 fetched from #1301 (57 back) and asked for **nothing at or above** the target. The encoder now starts `SEEK_BACKOFF_SEGMENTS` (12) before the requested segment, and — since the needed depth varies and no fixed number covers it — is pulled down further to the lowest segment the player is actually waiting on, which its own requests report exactly (`lowestAwaitedIndex`). Only ever moves the start earlier, never later. Costs a few seconds of extra encoding per seek.
1267
-
1268
- ## 2.9.62
1269
-
1270
- - **Fix**: The seek target is now taken **explicitly from the browser** (`POST /api/transcode-sessions/:id/seek`) instead of being inferred from which segments the player requests. Measured 2026-08-02: one viewer seek leaves **~25 concurrent segment requests** outstanding spanning #904..#1101, all held for a full 60 s with none aborted — ordinary read-ahead, not probing. There is therefore no such thing as "the segment the player ended on", and any rule choosing among them chooses noise: the old debounce produced **nine encoder restarts in one minute** (#576→#885→#609→#591→#673→#833→#624→#1071→#1101), each killed 5-8 s in, turning a single seek into a ~70 s ordeal that still landed correctly only by luck. Segment requests are now purely data fetches — held until produced, served from disk when behind the encoder — and never reposition it. Same separation both production references use (Jellyfin `startTimeTicks`, webtor `?t=`); see research/hls-seek-prior-art-2026-08-02.md. A seek already covered by the running encode does not restart it at all.
1271
-
1272
- ## 2.9.61
1273
-
1274
- - **Chore**: Measurement build for the iOS player question. A request for a not-yet-produced segment is now held up to 60 s (was 2 s) and each hold logs `[hold] <file> <outcome> after <ms>` — where the outcome distinguishes the segment arriving, our own limit expiring, and **the client aborting the connection**. The 2 s refusal was introduced (2.9.57) to dodge a reported iOS AVPlayer ~3.5 s response-header deadline, but that error code never appears in our own logs, and all five reference projects hold instead of refusing (Jellyfin and hls-vod-too unbounded, hls-media-server 10 s — see research/hls-seek-prior-art-2026-08-02.md). This build measures the player's real patience on our own hardware so the final value comes from observation rather than from a number read elsewhere. Not a permanent setting.
1275
-
1276
- ## 2.9.60
1277
-
1278
- - **Fix**: Seeking restarted the encoder at the position it was **already encoding**, destroying the very work being waited for — visible in the field log 2026-08-02 as `restart at #865` twice within ten seconds, each killing a run that was encoding #865. While the target segment is being produced the player keeps re-requesting it, and every such request looks "far" from where the encoder USED to be, so each one re-triggered a restart at the position we had only just moved to; playback data kept appearing and vanishing, and a seek only completed when a segment happened to reach the player before the next restart. A settled seek whose target equals the current runs start index is now ignored outright. This is distinct from the 2.9.58 guard, which only decides whether to let the current run finish its first segment — not whether a new run is needed at all; that guard behaved correctly here (it logged `run produced 4.5s (first segment done)`) and still let the pointless restart through.
1279
-
1280
- ## 2.9.59
1281
-
1282
- - **Chore**: Diagnostics for seek handling. The session-start line now carries the proxy version (`transcode <id> start (proxy 2.9.59) "<file>"`), so a field report answers "is the host running the build I published?" by itself. And the seek restart guard added in 2.9.58 now states its decision: either `seek #N HELD — current run has produced Xs of the Ys first segment` or, on the restart line, why it was allowed (`run is dead` / `run produced Xs (first segment done)` / `grace expired`). Previously a permitted restart was indistinguishable in the log from the runaway ping-pong the guard exists to stop, which made diagnosing "seek still did not work" guesswork.
1283
-
1284
- ## 2.9.58
1285
-
1286
- - **Fix**: A single user seek could leave playback permanently stuck with a flickering loading pill and no video. The encoder was allowed to restart at a new position even when the current run had not yet produced a **single segment**, so each restart destroyed the previous one's work and began the wait again — self-perpetuating, because the first segment after a seek is the slowest thing the pipeline does (field log 2026-08-02: restarts at #617 → #717 → #732 → #732 every 5-7 s, none producing anything). The extra targets were not further user seeks: unable to get its segment, the player SCANS the playlist (our synthetic VOD playlist lists every segment, so from its side they all exist), and each far-enough probe looked like a fresh seek. A seek restart now waits for the current run to produce its first segment (bounded by a 30 s grace, and skipped entirely if the run has died), which makes the scan harmless and lets one genuine seek complete. Independent of segment format.
1287
- - **Fix**: The "segment not ready" response now carries `Retry-After`. A bare 503 reads as "nothing here" and invites the playlist scan described above; the header is the standard way to say "re-request this same segment shortly". hls.js retried the same fragment either way; whether iOS AVPlayer honours it is unverified (its behaviour is closed), but the previous response gave it no reason not to look elsewhere.
1288
-
1289
- ## 2.9.57
1290
-
1291
- - **Fix**: A request for a not-yet-produced segment was held open for up to **30 seconds** before answering. iOS's native HLS player (AVPlayer) enforces a hard **~3.5 s deadline on response headers** and raises `-12889` ("No response for media file") once it passes — it then cancels in-flight requests, probes neighbouring positions, and can restart the stream from the beginning. Because a seek restarts ffmpeg and its first segment takes far longer than 3.5 s to appear, that deadline was hit on **every** seek, which is the root of the field-reported "seek loads for ages, then jumps back to the start" and of the playlist-scanning traffic that 2.9.55 tried (and failed) to work around from the wrong end. The request is now held only ~2 s and then answered with the same retryable 503, which resets the player's deadline and lets it re-request; a ready or nearly-ready segment is still served on the first request, so the fast path is unchanged. Independent of segment format — it affected `fmp4` and `mpegts` equally. hls.js is unaffected (it consumes the 503 through its retry policy); the browser widens that retry budget to match (server-side change, `fragLoadPolicy` `maxNumRetry` 8 → 12).
1292
-
1293
- ## 2.9.56
1294
-
1295
- - **Fix**: Reverted the "only the newest request may steer the encoder" guard added in 2.9.55 — it made seeking worse, not better, and is withdrawn rather than patched over. Its premise was that the newest in-flight segment request is the one the viewer actually wants; that does not hold. When the player cannot get its target segment it starts SCANNING the playlist, firing dozens of requests spread across the whole file within half a second (field log: `#178`, `#681`, `#725`, `#807`, `#74`, `#245`, `#387` …). Under that traffic the "newest" request is an arbitrary scan probe, so the guard steered the encoder away from the real seek target; the target segment was never produced and the player gave up and reset to the beginning of the file. The underlying ping-pong (several requests from one scrub taking turns restarting ffmpeg) is a real defect and remains open — but a correct fix has to tell a VIEWER seek apart from the player's own scan, which arrival order does not express. The 2.9.55 progress-timeline fix (video-copy branch) is unaffected and stays.
1296
-
1297
- ## 2.9.55
1298
-
1299
- - **Fix**: One scrub of the seek bar could leave the encoder ping-ponging between positions with an empty player buffer for over a minute (field-diagnosed 2026-08-01). A single scrub makes the player fire SEVERAL segment requests within a few hundred milliseconds — field log: `#534`, `#694`, `#817`, `#828` within 361 ms — and each of them long-polls `getFileStream` every 300 ms until served. Every poll called `#ensureEncodingFor`, so the four in-flight requests took turns overwriting the seek target and restarting ffmpeg at each other's positions (`534→828→694→828→817→828`, six restarts), none surviving long enough to produce a segment: three of the four eventually timed out after 34-36 s and the fourth was served after 25 s, with the browser buffer at 0.0 s throughout. Fixed by giving each INCOMING request one sequence number (`nextRequestSeq`) that it keeps for all of its long-poll iterations, and letting only the newest request steer the encoder — an older request may still be served if its segment gets produced, but can no longer move the encode head. Verified by replaying the exact field sequence: 44 target switches before, 4 after (the initial burst, which the existing settle-debounce then collapses into a single restart), settling on the last-requested segment.
1300
- - **Fix**: The transcode percent read 0% for a whole run on **video-copy** sessions (`transcodeVideo:false`), the other half of the 2.9.53 timeline bug. That fix rebased ffmpeg's `-progress` `out_time` onto the absolute timeline only for the re-encode branch, on the assumption that `-copyts` already made the copy branch absolute. The assumption was never measured and is wrong: on the field host, `-ss 600 … -copyts -c:v copy` reports `out_time` = 0, 40.7, 54.9, 90.9 — relative to the run, exactly like the re-encode branch (field log: `processed=12.638` against `startPos=3312` at 12.6x speed). The rebase now applies to both branches, and both measurements are recorded in the code so neither can be exempted again without a fresh one.
1301
-
1302
- ## 2.9.54
1303
-
1304
- - **Fix**: Seeking left playback permanently frozen — the root cause behind the field reports of "seeking does nothing" / "100% • starting now on a dead player". After a seek the player fetched the target segment successfully, over and over (field log: segments 402 and 403 re-requested in a loop for more than two minutes at full link speed, ~250-340 KB each time, browser buffer stuck at 0.0 s) while the transcode itself was healthy. Cause: ffmpeg's HLS/fMP4 output writes `tfdt` (the box that says WHERE a fragment sits on the timeline) as **0** in every seek-restart run, and records the run's start offset in an `elst` edit list inside **that run's** init segment instead. That is self-consistent only while init and segments come from the same run — but the player fetches `#EXT-X-MAP` exactly once, so we must serve one init for the whole session. Read against that cached init, a post-seek segment loses its offset completely and claims to start at ~0 s; the player finds nothing at the position it seeked to, discards the segment and re-requests it, forever. **No ffmpeg configuration avoids this** — measured on the shipping build: HLS *and* DASH muxers, `-copyts`, `-output_ts_offset`, `-itsoffset`, `-avoid_negative_ts disabled`, `-movflags -use_edts/+dash/+frag_discont/+global_sidx`, `-video_track_timescale`; all emit `tfdt = 0`. Fixed by stamping each fragment's `tfdt` with the segment's true start time as it is served, which is what CMAF (ISO/IEC 23000-19) requires of an independently-addressable segment in the first place: the segment then carries its own position and is valid against any init for the same tracks. Verified against a reproduction of the field scenario (several consecutive seek-restarts, video+audio): a post-seek segment read with the session-cached init reports its true timestamp (80.1 s) instead of 0.083 s, and decodes cleanly.
1305
- - **New**: The HLS output container is now selectable — `--segment-format fmp4` (default) or `--segment-format mpegts` — in the spirit of Jellyfin's transcoding-container setting. Everything container-specific (muxer arguments, segment naming and matching, playlist header lines, and the per-segment serving hook) lives behind a single interface in `services/segment-formats/`, so `hls-session-manager` holds a format object and never branches on the container; adding a container means adding a module, not editing the session manager. The MPEG-TS path is the pre-fMP4 behaviour recovered from the original switch commit rather than a rewrite; its segments are self-contained (no init segment at all), so the entire class of problem fixed above cannot arise there, which makes it a genuine fallback rather than a downgrade.
1306
-
1307
- ## 2.9.53
1308
-
1309
- - **Fix**: On the video RE-ENCODE path, `processedSeconds` silently switched reference frame partway through every encode run — absolute (matching `startPositionSeconds`) for the placeholder set at restart, then RELATIVE-to-the-run (counting from ~0) the moment ffmpeg's own `-progress out_time`/`out_time_ms` started overwriting it — because `-output_ts_offset` (used to relabel the MUXED output's timestamps onto the absolute grid) does NOT affect what `-progress` reports; verified empirically (a 5s clip encoded with `-output_ts_offset 100` still reports `out_time` counting 0→5, not 100→105). Every consumer of `session.progress.processedSeconds` assumes it is absolute: `computeProgressMetrics` (percent/remaining), `#applyBudgetDownshift`'s mid-run restart point, and — the field-diagnosed symptom — `#ensureEncodingFor`'s look-ahead window, which anchors on `Math.max(head, segmentIndexForTime(processed))`; with `processed` wrongly near-zero this floor pins the window's advancing edge at the run's OWN start segment for its entire lifetime instead of tracking real progress, so any segment request more than `MAX_LOOKAHEAD_SEGMENTS` (8, ≈32s) past the SEEK TARGET reads as "far" and triggers ANOTHER restart — even while the encoder is happily producing well past that point. Field example (verified with a pure-math replay of the exact logged values): seek to 1824s, window pinned at segments 456–464 for the whole run regardless of real progress reaching segment 465+ within seconds, at 6x realtime. This is the mechanism behind "buffering pill stuck at 0% until playback finally starts" and very likely a contributor to the broader "seek gets stuck" class of reports this cycle. Fixed by rebasing `out_time`/`out_time_ms` onto the absolute timeline (`+ session.progress.startPositionSeconds`) for the re-encode branch only — the copy branch already reports absolute time via `-copyts`, unaffected. Verified: a standalone replay of the field's `processed`/`startPos` sequence through the actual `#segmentIndexForTime` algorithm shows the window frozen at the run's start before the fix, correctly advancing with real progress after.
1310
-
1311
- ## 2.9.52
1312
-
1313
- - **Chore**: `npm audit` fixes. `@fastify/static` 9.1.3 → 10.1.2 (fixes GHSA-83w8-p2f5-377r route-guard path-traversal bypass and GHSA-8pvw-jcv7-9cmj non-canonical-path authorization bypass — no API change to our usage, verified with a live smoke test: healthz, tunnel connect, and static registration all still work). `brace-expansion`/`fast-uri`/`find-my-way` bumped via `npm audit fix` (transitive, no direct dependency change). Residual: `ip` (via `webtorrent@2.8.5` → `torrent-discovery` → `bittorrent-tracker`) stays flagged high (GHSA-2p57-rm9w-gvfp / CVE-2024-29415, SSRF via `isPublic()` misclassification) — investigated and left as an accepted risk, not an oversight: the advisory has no upstream fix (`first_patched_version: null`, every published version of `ip` is flagged) and `npm audit fix --force`'s only offered fix is downgrading `webtorrent` to 0.7.3, which would reintroduce the exact download-freeze regressions 2.9.44 rolled back from 3.x to avoid. The only actual call site in our dependency tree (`bittorrent-tracker/lib/server/parse-udp.js`) uses `ip.toString()` for UDP-integer→string formatting in the tracker-SERVER's request parser — code we never execute (WebTorrent only uses `bittorrent-tracker` as a tracker CLIENT) — and the vulnerable function itself, `isPublic()`, is not called anywhere in the chain. Revisit if/when a maintained `ip` replacement lands upstream in `bittorrent-tracker`.
1314
-
1315
- ## 2.9.51
1316
-
1317
- - **Fix**: The 2.9.50 keyframe-snap seek fix did not reliably apply on the re-encode path for containers needing a full packet scan (observed: AVI). The probe ran with a 6 s cap shared with the video-copy path (there it is fast, moov-index based); on a container needing a full scan, 6 s was not enough, the probe returned null, and the seek fell back to the raw (unsnapped) target — the exact case the circuit breaker exists to catch, not prevent. Split the two paths: video-copy keeps the blocking 6 s probe (segment boundaries need it before the first segment can be produced); video re-encode now runs the probe in the BACKGROUND with a full 25 s budget, since segment boundaries there are the uniform grid and never depend on it — only a later seek benefits from the snap. `#startEncodeRun` already reads `session.keyframeTimes` fresh on every call, so a seek arriving after the background probe resolves picks up the snap automatically; one arriving before still falls back to the existing circuit breaker (no regression). Verified live on the field AVI: far seek to the previously-hanging segment now returns in ~12 s instead of the ~90 s stall.
1318
-
1319
- ## 2.9.50
1320
-
1321
- - **Fix**: Seeking could get stuck in an infinite restart loop on some containers (observed: AVI with VBR MP3 audio), producing nothing for ~90 s until the whole WebRTC session died — the on-screen symptom of "seeking does nothing." Root cause, two parts: (1) `-accurate_seek -ss X` before `-i` trusts the container's own on-the-fly seek/index to land near X; for this AVI it pointed at a position with no valid frame boundary at all, so ffmpeg failed outright ("Seek failed" / "Header missing") — not just imprecisely — and every retry re-tried the SAME bad container-computed position. (2) `#ensureEncodingFor`/`#fireSettledSeek` never checked for a `"failed"` session state, and `#startEncodeRun` unconditionally resets state back to `"starting"` on every call — so a failed run's next client poll silently re-armed and re-ran the identical failing seek, forever. Fixed both: the video-keyframe probe (previously only used for the copy path's segment boundaries) now also feeds a two-step seek — jump to the nearest REAL keyframe (a position ffmpeg has already proven it can decode, read directly from the packet list, not the container's live index) before `-i`, then trim the short residual precisely after `-i` (always frame-accurate, no reliance on `-accurate_seek`'s trust in the container). A circuit breaker caps consecutive fast failures (exits within 2 s — never did real work) at the SAME target to 3 before the session is left in its terminal `failed` state instead of looping — a different seek target still gets a fresh attempt budget. Verified: the keyframe-snap helper against synthetic data, and the breaker's state machine (3 attempts at one target → blocked, a different target → fresh budget, only 4 real ffmpeg spawns instead of an unbounded loop).
1322
-
1323
- ## 2.9.49
1324
-
1325
- - **New**: `getSessionProgress` (the transcode-session progress endpoint) now also reports `outputMbps` — the observed produced bitrate from recently completed segment files (already computed internally for the viewer-link budget check, `#checkLinkBudget`), so the browser can turn its OWN measured link throughput into a delivery-speed multiplier for the unified download/transcode/delivery playback-start ETA, the same way the transcode's own `speed` already is one.
1326
-
1327
- ## 2.9.48
1328
-
1329
- - **Fix**: The "bytes still needed to resume" figure shown while buffering could jump UP mid-poll even though nothing regressed, which read as confusing/broken. Root cause: the resume-window progress (`resumeNeededBytes`/`resumeDownloadedBytes`) was always computed against the LIVE read position, which slides forward as the file is read/transcoded further — so when the window moved past an already-downloaded piece into a fresh, never-touched one, "bytes needed" jumped up (a moving reference frame, not a real setback). `getFileStats` now accepts an optional `resumeAnchorByteStart` and always returns the byte offset the window was computed against; the browser client captures that offset on the FIRST poll of a buffering episode and sends it back on every subsequent poll of the SAME episode, so the window stays pinned to a fixed target and the figure only ever decreases as real download progress happens. Verified: with the anchor pinned, repeated polls report the same "needed" while the live read position moves with no new data, and a real download of a piece inside the frozen window correctly decreases it.
1330
- - **Fix**: A rapid sequence of seeks could leave playback permanently stuck — field-diagnosed from a live session (5 seek-restarts in 16 seconds), showing `failed to rename file segment-NNNNN.m4s.tmp` and a zombie ffmpeg still writing a `.tmp` file ~30 seconds after being "killed" by two later restarts, even after the session had already been released. Root cause: `#startEncodeRun` sent `SIGTERM` to the previous ffmpeg process and immediately spawned the replacement into the SAME session directory without waiting for it to actually exit. `ChildProcess.killed` only means a signal was sent, not that the process died — ffmpeg's own blocking read of our torrent-backed `/stream` input can defer signal handling for a long time while starved, so on a rapid sequence of seeks multiple ffmpeg processes ended up alive concurrently, fighting over CPU and racing each other's file writes in the same directory; none of them would finish a segment in time, which is what "stuck at seek" looks like to the viewer. Fixed by awaiting the previous process's exit (escalating from `SIGTERM` to `SIGKILL` if it does not exit within a grace period, reusing the `waitForChildExit` helper `disposeSession` already used correctly) before spawning the replacement. A new per-session generation counter (`encodeRunGeneration`) lets a restart that was superseded by an even newer seek while it was waiting abort instead of also spawning a process — verified with a standalone race simulation: 5 overlapping restarts against a slow-to-die previous process spawn exactly 1 process, matching the LATEST requested target.
1331
-
1332
- ## 2.9.47
1333
-
1334
- - **Fix**: Playback could get permanently stuck (hls.js endlessly re-fetching the manifest and the first segment, buffer never advancing) even though the transcode itself was encoding fine, running ahead of realtime. Root cause: ffmpeg creates the fMP4 `init.mp4` file before it finishes writing the codec-header boxes into it (unlike segments, its write is not gated behind an atomic rename), so a request could race a moment where the file exists but is still empty. That empty read was then cached forever as the session's init segment — a zero-length `Buffer` is still a truthy object, so the `if (session.initBytes)` cache guard treated it as "already resolved" and kept serving the empty file for the rest of the session, which hls.js can never initialize a SourceBuffer from. Fixed by treating a zero-byte read as not-yet-ready (keeps the caller's existing long-poll retrying) instead of caching it as final.
1335
-
1336
- ## 2.9.46
1337
-
1338
- - **New**: `getFileStats` now reports `resumeNeededBytes` / `resumeDownloadedBytes` — the bytes still to download in the 16 MB window ahead of the file's current read position (tracked per file by `prioritizeByteRange`, cleared on torrent removal), counted byte-accurately including partial pieces. Lets the browser show how much is left to download and the time to resume while buffering.
1339
-
1340
- ## 2.9.45
1341
-
1342
- - **New**: HLS transcode output switched from MPEG-TS (`.ts`) to **fMP4/CMAF** (`.m4s` segments + a shared `init.mp4`). Codec parameter sets (SPS/PPS) now live once in the init segment (referenced by `#EXT-X-MAP`) instead of being repeated in every segment. Benefits: (1) hardware encoders that do not repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce independently-usable segments (on `.ts` the segments after the first lacked SPS/PPS → "non-existing PPS", which is why v4l2m2m was rejected); (2) lower container overhead. The synthetic VOD playlist now emits `#EXT-X-VERSION:7` + `#EXT-X-MAP`; each seek-restart run rewrites `init.mp4`, so `getFileStream` caches and serves the FIRST init for the whole session — it is codec-config-only and position-independent (verified: a single init cleanly decodes segments produced by a later seek-restart run). Raised v4l2m2m `-num_capture_buffers` to 32 (the default 4 deadlocks / drops frames on the CM4). **Verified**: server-side clean decode of the synthetic playlist across seek-restart runs; end-to-end playback **and seek** in hls.js 1.6.16. **Still needs**: verification on native iOS HLS (Safari fMP4) before relying on it. NOTE: v4l2m2m still emits a residual no-picture access unit that the strict startup test rejects, so it continues to fall back to software for now (no regression); fMP4 removes the SPS/PPS blocker — the remaining quirk is separate.
1343
-
1344
- ## 2.9.44
1345
-
1346
- - **Fix**: Roll back to WebTorrent **2.8.5** (pinned) — 3.x introduced two regressions that broke downloading. (1) `torrent.downloaded`/`file.downloaded`/`file.progress` throw on a `deselect`-ed null piece (worked around in 2.9.43). (2) Worse: the internal piece picker itself throws `Cannot read properties of null (reading 'reserve'/'missing')` when it tries to request a block from a piece our seek prioritization (`prioritizeByteRange` `deselect`) removed — download freezes dead after a seek (field-observed: file stuck at ~51%, `down=0`, picker crashing every second). 2.8.5 is the known-good version: `select`/`deselect`/`critical` and the byte getters all work (verified — add, multi-file download, and the full deselect+critical seek pattern run with zero crashes on 2.8.5). Also pinned **`uint8-util` 2.2.6**: 2.8.5's own range is `^2.2.5`, which *allows* the incompatible 2.3.x that a fresh global install pulled (the original `arr2hex` crash), so the transitive version must be forced back — webtorrent dedupes to 2.2.6 while sub-deps that need 2.3.x keep their own nested copy. The 2.9.43 null-safe getter helpers are now redundant (2.8.5 getters never throw) but left in as harmless defensive code.
1347
-
1348
- ## 2.9.43
1349
-
1350
- - **Fix**: Torrents stalled at the metadata/download stage on WebTorrent 3.x — the adaptive upload throttle dropped the client-wide limit to `0` whenever no file had an active reader (e.g. the window before the first read is acquired). In WebTorrent 3.x `throttleUpload(0)` blocks the ENTIRE swarm exchange client-wide — even peer connections and DOWNLOAD — not just seeding (verified: `throttleUpload(0)` → 0 peers, 0 download; `throttleUpload(8KB/s)` → peers connect, multi-MB/s download). The idle branch now returns a minimal keep-alive floor (`UPLOAD_IDLE_FLOOR_BYTES` = 8 KB/s) instead of 0; still effectively no seeding, but the swarm stays alive.
1351
- - **Fix**: Spurious `uncaughtException: Cannot read properties of null (reading 'length')` every few seconds during playback. WebTorrent 3.x nulls `pieces[index]` for pieces we removed from the download set via `deselect` (file selection, seek-behind-playhead demotion), and its own `torrent.downloaded` / `file.downloaded` / `file.progress` getters do not guard that null — they threw in our disk-cap sweep and stats builder. Added null-safe `torrentDownloadedBytes` / `fileDownloadedBytes` helpers (a deselected piece = 0 downloaded, the correct value, while still counting every other piece) and use them in `#currentDiskBytes`/`#enforceDiskCap` and `getFileStats`. Verified byte-for-byte identical to WebTorrent's own getters when no piece is null. (The underlying WebTorrent getter bug is filed upstream; it is non-fatal — download survives it — but the throws were noisy and risky.)
1352
-
1353
- ## 2.9.42
1354
-
1355
- - **Fix**: Torrents failed to load with a proxy crash — the REAL root cause (2.9.41 misdiagnosed it). WebTorrent 2.8.5's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)`, but `parse-torrent` returns `infoHash` as a hex **string**. `uint8-util` **2.3.x** rewrote `arr2hex` to require a TypedArray (`Buffer.from(data.buffer …)`); a string's `.buffer` is `undefined` → `Buffer.from(undefined)` → `ERR_INVALID_ARG_TYPE` thrown in a detached microtask. `uint8-util` 2.2.x iterated the argument and tolerated a string, so it only broke once the addon's unpinned global `npm install` pulled 2.3.x. It hit **every** torrent (v1/v2/hybrid alike — `arr2hex` is always called). Diagnosed by reproducing `client.add` inside the addon container and isolating `arr2hex('<hex>')` throwing on 2.3.2 but not 2.2.6. Fix: update **WebTorrent 2.8.5 → 3.x**, where the maintainer replaced that line with `parsedTorrent.infoHash?.substring(0, 7)` (no `arr2hex` on the string) — a proper dependency-forward fix, not a version pin, so `uint8-util`/`parse-torrent` stay current. Verified: the exact broken combo (webtorrent 3.0.16 + uint8-util 2.3.2 + parse-torrent 11.0.23) now adds cleanly, and the full API the proxy uses (`select`/`deselect`/`critical`/`_critical`/`wires`/`throttleUpload`/`createReadStream`/`destroy({destroyStore})`) is unchanged in 3.x.
1356
- - **Fix**: Removed the 2.9.41 infohash pre-validation. It was based on the wrong diagnosis ("v2-only torrent") — the failing torrents were normal v1 — and it wrongly rejected legitimate v2/hybrid sources. WebTorrent (post-bump) handles v1, v2 and hybrid itself. The last-resort `uncaughtException`/`unhandledRejection` guard from 2.9.41 is kept as defense-in-depth.
1357
-
1358
- ## 2.9.41
1359
-
1360
- - **Fix**: A malformed or v2-only torrent source no longer crashes the whole proxy in a restart loop. WebTorrent's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)` assuming a BitTorrent v1 infohash exists; a v2-only / hybrid magnet (or a corrupt source) parses with `infoHash === undefined`, so that becomes `Buffer.from(undefined)` and throws in a microtask that bypasses the client `error` event — taking down the node and every viewer on it (observed: `ERR_INVALID_ARG_TYPE` → tunnel reconnect loop; the WebRTC session died ~6 s in as the process restarted under it). Two fixes: the torrent-add path now **pre-validates the infohash** with `parse-torrent` and rejects a source without a valid v1 40-hex infohash as a clean error the browser can show; and the process gained a **last-resort `uncaughtException`/`unhandledRejection` guard** that logs the full stack and keeps serving, so no single bad torrent can ever crash-loop the proxy. NOT a regression from the download-performance work (2.9.40) — those paths don't touch torrent parsing; it is a pre-existing crash surfaced by an unusual source.
1361
- - **New**: Longer idle retention so a brief absence resumes instead of restarting. The HLS transcode session idle TTL is raised from 2 min to **10 min** and the torrent-data idle TTL from 5 min to **15 min**. A viewer who pauses, backgrounds the tab, or turns the phone off for a few minutes now resumes without a cold ffmpeg restart and without re-downloading already-fetched data — the warm session also widens the seamless auto-reconnect window. An idle ffmpeg stops producing at the look-ahead cap, so the longer session TTL costs retained segments on disk rather than sustained CPU; the global disk cap still evicts torrent data earlier under pressure, and active playback keeps refreshing both timers so neither expires mid-watch.
1362
-
1363
- ## 2.9.40
1364
-
1365
- - **New**: Adaptive upload throttle. Seeding to the BitTorrent swarm does not help our viewer (we deliver over our own channel) — it is pure uplink cost and the riskiest legal act — so the client-wide upload limit now defaults to **off** (`throttleUpload(0)`, was WebTorrent's unlimited default) and is raised only when needed. A 5 s adjuster sets: **0** when no file has an active reader (stop seeding entirely once nothing is being watched); a low **floor** (50 KB/s) while a reader is active (a token upload so tit-for-tat does not choke us to zero); and a **boost** (512 KB/s) only when a torrent is starving (download barely trickling while it still needs data) AND its wires show reciprocity choke (≥2 peers we want data from are choking us) — earning unchoke slots to un-starve the download. The policy is a pure function (`decideUploadLimit`, unit-tested); each change is logged for field tuning. Client-wide limit (one active torrent is the norm today).
1366
- - **Fix**: Seek-aware piece prioritization now actually makes a far seek download the seek target first. On every `/stream` range request the proxy deselects the pieces BEHIND the read position, so WebTorrent's picker — which scans each selection sequentially from its first undownloaded piece — starts at the playhead instead of fetching the undownloaded gap behind it. Previously only a `critical()` window was marked, but `critical` does not reorder the scan (it only enables hotswap: re-requesting a block from a faster peer), so a seek into a large undownloaded region still waited behind the sequential backlog. Behind-playhead pieces are only dropped from the download set (stop fetching), never deleted — a backward seek re-selects them via the same call, and the whole file is re-selected on the next reader acquire; the pinned head/tail (codec probe) is unaffected. The critical read-ahead window (now 16 MB) is reset each call so it stays a moving window rather than accumulating over the whole file across seeks. Single-active-reader scope (the multi-viewer union window is roadmap item 23).
1367
-
1368
- ## 2.9.39
1369
-
1370
- - **Chore**: Log the stack (first frames) of WebTorrent `warning` events, not just the message. Field diagnosis: a playback froze mid-file with repeated `torrent-pool: … warning: Connection error: Cannot read properties of null (reading 'type')` (a WebTorrent µTP null-peer NPE, webtorrent#1932/#1940) while the swarm had seeders — peer connections were failing and the download starved. The old handler logged only the terse message, hiding which library path threw; the stack pinpoints it before we mitigate (next: prefer HTTP/DHT over the timing-out UDP trackers, then consider disabling µTP).
1371
-
1372
- ## 2.9.38
1373
-
1374
- - **New**: Adaptive bitrate for thin viewer links (OpenSpec change `adaptive-bitrate`). Field evidence (iPhone on cellular): uncapped complex scenes produced 4 s segments of ~18 Mbit/s against a 1–6 Mbit/s link — 45 s prebuffer and a draining buffer. Two parts. (a) Software encodes are now constrained-CRF: `-maxrate`/`-bufsize` per resolution rung (1080p→5000K, 720p→2800K, 480p→1400K, 360p→800K, 240p→400K nominal; ×1.3/×1.5 — webtor's production multipliers), so peaks stay bounded. (b) New data-channel route `POST /api/transcode-sessions/:id/net-report` accepts the browser's measured link throughput + buffered seconds; the realtime-budget loop gains a second downshift trigger — a FRESH report showing the usable link (×0.8 safety) sustainedly (15 s) below the observed produced bitrate while the viewer's buffer is low (<10 s) steps the encode one rung down via the existing machinery (shared 30 s cooldown, step cap, no upswitch). Log reason `viewer-link-bound` distinguishes it from CPU downshifts. Manual-quality sessions are exempt (no budget ladder); old clients that never report simply keep today's behaviour plus the caps.
1375
-
1376
- ## 2.9.37
1377
-
1378
- - **Fix**: Scrubbing (server-side seek) no longer hangs the player. A far segment request restarts ffmpeg at that position; native players (notably iOS HLS) issue a burst of scattered far requests after a scrub (observed: `367 → 732 → 369 → 368 → 370`, tens of seconds apart), and the old fixed 4 s cooldown only suppressed restarts within 4 s of the last — so each scattered request restarted ffmpeg and it ping-ponged between positions, producing nothing and stalling playback. Far requests are now **debounced**: the target index is recorded and a short settle timer armed (1.2 s quiet period, 2.5 s hard cap from the burst's first request); further far requests re-arm it and update the target to the latest index; when it settles, ffmpeg restarts once at that index. "Last index wins" self-corrects — a wrong target costs at most one extra settle, never the old infinite loop. The settle timer is cleared on session disposal. (OpenSpec change `seek-debounce`.)
1379
-
1380
- ## 2.9.36
1381
-
1382
- - **New**: Chunked request bodies over the data channel (OpenSpec change `chunked-request-bodies`). Large request bodies — notably the source registration, whose body is the base64 `.torrent` (hundreds of KB for a multi-season pack) — now arrive as bounded binary frames (the response-frame layout) announced by a `request-start` message, and are reassembled and run through the same path as a single-message request. Bounded: 32 MB per-body cap, a 60 s TTL for incomplete bodies, an abort frame that drops partial state at once, and all per-channel state freed on channel close. This removes the single-message size ceiling symmetrically with responses (which already stream in chunks). Logged as `body=<bytes> bytes (chunked)`.
1383
-
1384
- ## 2.9.35
1385
-
1386
- - **Fix**: Large torrents (many files / seasons) no longer fail with "Trying to send message larger than max-message-size" when a file is picked. The browser sends the source registration body — the base64-encoded `.torrent` — in a single data-channel message; a big multi-season pack's `.torrent` carries thousands of piece hashes (e.g. Poirot, 13 seasons: 420 KB → ~560 KB base64), exceeding libdatachannel's default advertised limit of 256 KB, so the browser's `channel.send()` threw. The proxy now advertises a 16 MB `a=max-message-size`, so a large single send still works while already-open tabs run the old bundle. Verified the SDP now carries `a=max-message-size:16777216` (was `262144`).
1387
-
1388
- ## 2.9.34
1389
-
1390
- - **New**: Cold-start reduction (OpenSpec change `cold-start`). Creating a transcode session no longer runs a second full ffmpeg input scan: the playback planner caches the media info (duration/resolution/fps/start-time/HDR) parsed from the probe it already ran, and `createSession` reuses it (falling back to its own probe only when the cache cannot serve — e.g. after a restart, or a missing critical field). The banner parsers now live in a shared `ffmpeg-banner.js` so both sides parse identically. Once a plan probe succeeds the proxy also warms the START of the file body (~16 MB, fire-and-forget) so the first segment's encode reads downloaded data instead of waiting on pieces. Session startup is now measurable in the log: `cold-start <id>: media-info=<ms> (cached|probed) keyframes=<ms|skipped> create-total=<ms>` and, once per session, `cold-start <id>: first-segment ready +<ms>`.
1391
-
1392
- ## 2.9.33
1393
-
1394
- - **New**: HDR / 10-bit tone mapping (OpenSpec change `transcode-quality`, part 3). An HDR source (BT.2020 with a PQ `smpte2084` or HLG `arib-std-b67` transfer) re-encoded to 8-bit SDR without tone mapping looks washed-out and desaturated. The proxy now detects HDR from the probe and, when re-encoding video on the software path, inserts a `zscale`+`tonemap` (hable) chain to convert HDR→BT.709 SDR properly. It is **gated on filter availability**: at startup the proxy checks this ffmpeg build for the `zscale` (libzimg) and `tonemap` filters (`hwaccel: HDR tone mapping available/unavailable …`); when either is missing it falls back to the previous plain 8-bit convert (still plays, just washed-out). The tone map runs after the downscale (cheaper on ARM). Logged per session as `hdr=1 tonemap=on|off`. Hardware encoders keep their current path for now (tone mapping there is a follow-up). No client change — the browser plays the resulting SDR HLS.
1395
-
1396
- ## 2.9.32
1397
-
1398
- - **New**: Manual quality support (OpenSpec change `transcode-quality`, part 4). The playback plan now reports the source coded resolution (`videoWidth`/`videoHeight`, parsed from the ffprobe banner) so the browser can offer a quality menu. `POST /api/transcode-sessions` accepts `manualQuality: true`: the requested target box is then encoded exactly (capped to the source, never upscaled) with the realtime budget disabled for that session — no startup auto-downscale and no runtime downswitch — so a viewer-forced resolution stays constant for the whole session. `manualQuality` is part of the session key (a forced-quality session is distinct from Auto). Logged as `enc=WxH@fps quality=manual`. Auto (no flag) is unchanged: the realtime budget decides. Pairs with the server release that adds the player quality menu.
1399
-
1400
- ## 2.9.31
1401
-
1402
- - **New**: Realtime transcode budget — startup resolution + preset selection (OpenSpec change `transcode-quality`, part 2.1). For the software encoder the proxy now picks the output RESOLUTION as well as the libx264 preset from the startup benchmark: the client-requested box (capped to the source, never upscaled) is the ceiling, and the proxy chooses the highest resolution rung at or below it that the benchmark predicts encodes faster than realtime (with the existing margin), then the best preset at that resolution. On a weak host this downscales (e.g. a 720p60→30 stream that ran at ~0.9× on a Home Assistant box now encodes at ~480p in realtime) instead of dropping into sub-realtime playback with constant stalls. Capable hosts keep full resolution and spend the headroom on a higher-quality preset; hardware encoders and the no-benchmark case are unchanged. Also fixed the realtime-need calculation to use the session's actual output frame rate instead of the fixed 24 fps constant (it under-counted for 25/30 fps content). The chosen encode resolution is logged (`enc=WxH@fps budget=on`). This scales down from the orientation-independent ceiling the browser now sends (server 0.8.43).
1403
- - **New**: Realtime transcode budget — runtime downswitch (OpenSpec change `transcode-quality`, part 2.2). If a software transcode runs below realtime for a sustained window (ffmpeg `speed` < ~0.95× for ~15 s), the proxy steps the resolution one rung down the ladder and restarts the encode at the segment the viewer is on, so a stream that starts fine but bogs down on a heavy passage recovers instead of stalling. It first checks the bottleneck: if the torrent download can't sustain the source's byte rate (and the file isn't fully downloaded), the limit is the download, not the encoder — the proxy logs that and does NOT degrade quality. Conservative guards prevent thrash: a 30 s post-action cooldown, at most 3 downshifts, a resolution floor, the slow window reset on every (re)start, and no automatic upswitch yet. The switch point uses a hard encoder restart (a brief blip is possible there; a seamless discontinuity/parallel tier is a later refinement). Logged as `[budget] … CPU-bound speed=… → downscale to WxH` or `… download-limited; not downscaling`.
1404
-
1405
- ## 2.9.30
1406
-
1407
- - **New**: The proxy owns subtitle conversion and detects the language from content (OpenSpec change `subtitle-language`). `GET /api/subtitles` now also serves EXTERNAL subtitle files (no `trackIndex`): it reads the file, decodes its encoding (UTF-8 or Windows-1251 — common for Russian `.srt`), converts `.srt`/`.ass`/`.ssa` → WebVTT on the proxy (the browser no longer converts), and reports the language in `X-Subtitle-Language`/`X-Subtitle-Language-Name`. Language is detected with `franc` (n-gram, MIT) restricted to a curated language set — it distinguishes Russian from Ukrainian (and Latin languages) and avoids short-text false positives, returning no header when undetermined. Embedded tracks detect from the first chunk of extracted VTT. Pairs with the server release that fetches VTT from here and applies the filename → content → audio-language priority.
1408
-
1409
- ## 2.9.29
1410
-
1411
- - **New**: Global disk cap with LRU eviction (OpenSpec change `disk-cap`; Disk hygiene Level 1, final piece). Downloaded torrent data was already removed on a 5-min idle TTL and at shutdown, but under pressure it could still fill a small Home Assistant host's disk (which can take down HA itself). The pool now caps total downloaded data — default min(10 GB, half of free disk), overridable with `--max-disk-bytes` (0 disables) — and, when exceeded, evicts whole torrents with no active reader least-recently-used first (checked every 30 s). A torrent that is currently playing is never evicted. (LRU = least-recently-used.)
1412
- - **New**: Output frame rate follows the source instead of a fixed 24 fps (OpenSpec change `transcode-quality`, part 1). 25/30 fps content no longer plays resampled to 24 (which caused judder). Frame-count-GOP encoders (software libx264, v4l2m2m) use an integer rate — source rounded, capped at 30 as a speed guard — with the fps filter and the GOP length kept in lockstep so a keyframe still lands on every segment boundary; the time-based-keyframe encoders (nvenc, vaapi, qsv) inherit the exact source rate untouched (nvenc previously forced 24 — its fps filter is removed). Source rate is parsed from the existing startup probe. (GOP = group of pictures, the span between keyframes; the segment grid needs a keyframe at each boundary.)
1413
- - **Fix**: `GET /api/sources/:key/files` no longer blocks until metadata arrives (or fails prematurely on a cold magnet). It now waits only a short per-request budget (`maxWaitMs`, default 8 s, cap 20 s) and returns `{ pending: true }` while the swarm fetch continues in the background, so the browser can poll — mirroring the cold-torrent playback-plan poll. Field-found: a magnet whose metadata had not arrived yet failed with "no peers" on the first paste, then succeeded on a second paste because the fetch had kept running in the background. A real fetch error now returns 502 (distinct from pending). Pairs with server 0.8.39 (which references this as "proxy 2.9.28" — that release was folded into 2.9.29 before publishing).
1414
-
1415
- ## 2.9.27
1416
-
1417
- - **Fix**: A magnet whose infoHash matches a torrent already loaded in the pool no longer fails with 500 "Cannot add duplicate torrent" (scenario: one viewer opened the .torrent file, another pasted the magnet of the same content — different source keys, one swarm). The duplicate-add error now resolves to the already-loaded torrent (waiting for its metadata when it is itself still cold), so both source keys share the swarm. Found by a field test of the magnet flow.
1418
-
1419
- ## 2.9.26
1420
-
1421
- - **New**: Track inventory in the playback plan (OpenSpec change `track-selection`). The codec probe now parses EVERY input stream from the same ffmpeg banner, and the plan returns `audioTracks` and `subtitleTracks` — type-relative index, codec, language tag, `title` metadata, default disposition, and (for subtitles) a `textBased` flag (PGS/VobSub cannot become WebVTT).
1422
- - **New**: Audio track selection for HLS sessions. `POST /api/transcode-sessions` accepts `audioTrackIndex`; the session maps `0:a:N` instead of always the first track, and the index is part of the session key, so switching tracks creates a fresh session (server-side restart) while the old one expires via the idle TTL.
1423
- - **New**: Embedded subtitle extraction — `GET /api/subtitles?sourceKey&fileIndex&trackIndex` streams the chosen text subtitle track converted to WebVTT. Extraction reads the file up to the last cue, so on a cold torrent it drives the sequential download; callers must use a generous timeout. Non-text tracks (or a dead extraction) return 422 before any body.
1424
- - **New**: `GET /api/sources/:sourceKey/files` lists the files of a registered source. Groundwork for magnet-link input (OpenSpec change `magnet-input` in the server repo): the browser parses `.torrent` files locally, but a magnet's file list only exists in swarm metadata — this route resolves the torrent (waiting for metadata on a cold magnet) and returns the inventory.
1425
- - **Chore**: The announce log line strips the query string from the tracker URL — private trackers embed the account passkey there.
1426
-
1427
- ## 2.9.25
1428
-
1429
- - **New**: Observability (OpenSpec change `proxy-observability`). (1) `/healthz` and `/health` now include the proxy `version` — the addon shipped a stale proxy for a whole release and nothing could detect it remotely. (2) Peer-discovery diagnostics in `torrent-pool.js`: each added torrent logs its file count, `private` flag and tracker count; torrent-level `warning` events (tracker rejections/errors) are logged; every tracker announce response is logged with the seeder/leecher counts the tracker returned — so a zero-peer torrent is now explainable from the addon log. (3) Client-level WebTorrent warnings are logged too.
1430
- - **Fix**: The `MaxListenersExceededWarning [Ssdp]` log flood is gone. The UPnP SSDP emitter inside `@silentbot1/nat-api` gains one listener per `map()`/renewal, and the WebRTC UDP mapper maps a 10-port range — exceeding Node's default limit of 10. The limit is lifted on that emitter right after the first successful mapping (`port-mapper.js`).
1431
-
1432
- ## 2.9.24
1433
-
1434
- - **New**: IPv6-first support (roadmap step 5a). (1) A second STUN server (`stun.cloudflare.com:3478`, alongside Google's) is added to the ICE config — both have IPv6 (AAAA) records, so when the proxy host has a global IPv6 address it gathers a `srflx` candidate over v6 too. IPv6 has no NAT, so if both the proxy and a (v6-native, e.g. cellular) viewer have global v6, the connection can go **direct** over v6 — sidestepping the whole NAT-traversal machinery. (2) Candidate logging now classifies each candidate by address scope — `v4-private` / `v4-public` / `v6-global` / `v6-ula` / `v6-linklocal` / `v6-loopback` (replaces the old private/public host label) — so the field log shows whether a global IPv6 path is actually being offered and chosen. Audited the candidate path: the proxy already forwards ALL candidates (incl. global v6) and the browser adds them all — nothing was dropping global v6, so no filter fix was needed. NOTE: not verifiable on the dev's proxy (its ISP exposes only ULA v6 `fd…`, no global v6); needs a proxy with global v6 to confirm in the field — the new `v6-global` log tag is there to spot it.
1435
- - **Fix**: Cold-start playback no longer fails with "Data channel request timed out". `POST /api/playback-plan` (`playback-planner.getPlan`) used to block up to 60 s waiting for the file header to download for the codec probe — exactly the transport's 60 s request timeout, so a cold torrent (peers still connecting, 0 % header) raced and failed. The planner now takes a short per-request budget (`maxWaitMs`, 8 s from the route): it prioritises the file header and probes, and if the header still isn't down it returns the plan flagged `pending: true` (uncached) instead of blocking. The browser polls again — each call keeps the header prioritised — so no single request approaches the 60 s limit and the existing `/stats` poll keeps showing live peers/speed/% the whole time. Pairs with server 0.8.24 (browser-side poll loop, already live).
1436
- - **New**: Disk hygiene (Level 1, `torrent-pool.js`). (1) A torrent with **zero active file readers** is now removed together with its on-disk store after a 300 s idle TTL (`torrent.destroy({ destroyStore: true })`), so downloaded data no longer accumulates while the proxy keeps running; re-requesting the torrent re-adds it. Re-acquiring a file cancels the pending removal, and the TTL is generous so brief gaps between ffmpeg range reads (or a short pause) never evict an in-use torrent. (2) **Startup orphan sweep**: leftover torrent data under `os.tmpdir()/webtorrent` from a previous hard kill (where graceful `destroyAll` never ran) is cleared at construction (safe — no torrents loaded yet). Still pending (Level 1): a global disk cap with LRU eviction.
1437
-
1438
- ## 2.9.23
1439
-
1440
- - **New**: Symmetric-NAT port prediction for WebRTC (roadmap step 4; `webrtc-manager.js` + `nat-classifier.js` delta + `cli.js` wiring). When the startup NAT classification reports a **symmetric** NAT, for each real IPv4 `srflx` candidate the proxy also offers predicted-port candidates at `base + delta*k` (k = 1..16, `delta` = the per-destination external-port step measured at startup), each with a unique ICE foundation. The browser probes these too; if one matches the external port the NAT assigns for the proxy→browser path, ICE connects — the practical, signalling-only form of the birthday-paradox trick (no node-datachannel changes, no extra sockets). **Scope**: covers sequential/predictable symmetric NATs; a fully-random symmetric NAT (where the true 256-socket birthday would be needed) is not solved by this and is out of reach on the node-datachannel stack. No-op for cone NATs (the fixed-port mapping already suffices) and IPv6 (no NAT). Diagnostics: logs the injected predicted ports per session (`symmetric NAT (delta=D) — injecting N predicted srflx candidates: …`); combined with the existing `selected pair local=[…]` log this shows whether a predicted port won. NOTE: could not be exercised end-to-end — the dev's home NAT is cone; needs a symmetric-NAT vantage to verify in the field (the logging is there to diagnose it when it appears).
1441
-
1442
- ## 2.9.22
1443
-
1444
- - **Fix**: The proxy no longer crashes on a repeat/remote WebRTC session (regression from 2.9.18). Two causes, both fixed: (1) `webrtc-manager.handleSignal` called `setRemoteDescription`/`addRemoteCandidate` with **no try/catch**, so when node-datachannel threw synchronously (`Failed to gather local ICE candidates`) the whole process died — killing every viewer and the tunnel — and was restarted by s6. It now contains the error per session (logs + closes only that session, never throws out of the handler). (2) Root cause of the gather failure: 2.9.18 set `enableIceUdpMux` per-PeerConnection but with **no persistent mux owner**, so the shared UDP socket was bound/freed with each connection — a session opened while a just-closed one still held the fixed port could not bind it and failed to gather. Fixed by creating ONE persistent `IceUdpMuxListener` on the fixed UDP port once at startup (owned by the WebRTC manager for the proxy's whole lifetime, released on shutdown via `dispose()`); every session keeps `enableIceUdpMux` + the same port and demuxes over the shared socket by ICE ufrag. This keeps the clean single-port model (one UDP port, one UPnP mapping, one reachable endpoint) while surviving session churn. Verified against libdatachannel issue #861 and locally: 5 sequential + 2 concurrent PeerConnections all gather on the one port with no error, and the srflx candidate carries the fixed port.
1445
-
1446
- ## 2.9.21
1447
-
1448
- - **Chore**: Diagnostic — the `/api/sources/:key/stats` route now logs the real swarm state on every poll: `[stats] <key> peers=N down=NKB/s file=N% header=down/totalB`. This surfaces a cold-start download stall (0 peers / header not advancing), which is what makes `POST /api/playback-plan` block on the codec probe until the browser's data-channel request times out. (Diagnosis: on the first/cold attempt the file header has not downloaded within ~60 s — likely worsened by `uTP not supported` on arm64/musl limiting peers — so the blocking probe times out; a warm attempt minutes later, with the header already cached, probes in ~25 ms and plays. Verified by the same torrent failing cold on cellular and playing warm on desktop.)
1449
-
1450
- ## 2.9.20
1451
-
1452
- - **New**: Startup NAT classification (`services/nat-classifier.js`, dependency-free — `node:dgram` + `node:crypto`). From a single local UDP socket the proxy sends a STUN Binding Request to two different public STUN servers (Google + Cloudflare) and compares the reflexive external port: same → **endpoint-independent (cone)** NAT (the fixed-port WebRTC mapping from 2.9.18 is sufficient, no port prediction needed); different → **symmetric** NAT (the mapped port varies per viewer, so WebRTC will need port prediction — a later roadmap step). The class is logged at startup. Best-effort: STUN probes are time-bounded and never block startup; an inconclusive probe is logged and ignored. Uses the modern dual-server, single-socket test (no RFC 3489 CHANGE-REQUEST, which public STUN servers like Google's do not support). Evaluated `@xmcl/stun-client`/`stun` (both MIT) but their public APIs create a fresh socket per query and/or rely on CHANGE-REQUEST, which is wrong for this test — hence the minimal in-house client.
1453
-
1454
- ## 2.9.19
1455
-
1456
- - **Chore**: Diagnostics for verifying remote WebRTC reachability and root-causing failures. `port-mapper.js` now logs a `removed mapping for <proto> <port>` line on clean shutdown unmap (previously silent on success). `webrtc-manager.js` now logs the **full** local ICE candidate (`addr:port typ …`, so the pinned UDP port is visible), every **ICE-state** transition (`checking → connected/failed`), and — on connect — the **selected candidate pair** (`local=[…] remote=[…]` with type/address/port), the single most useful line for "did the WebRTC path connect, and over which route (LAN / public srflx v4 / v6)".
1457
-
1458
- ## 2.9.18
1459
-
1460
- - **New**: WebRTC is now reachable behind NAT via a static UDP port mapping. All sessions are pinned to a single UDP port (same number as the HTTP port, default 9090) and multiplexed over it (`enableIceUdpMux` + `portRangeBegin`/`portRangeEnd` in `webrtc-manager.js`), and that UDP port is UPnP/NAT-PMP-mapped at startup (a second `port-mapper.js` instance, protocol UDP, removed on shutdown). Because the socket is bound to a fixed, statically-mapped port, the proxy's `srflx` ICE candidate now carries `publicIP:9090` — reachable from the browser even behind symmetric NAT for that port (previously WebRTC used an ephemeral UDP port that UPnP could not map). Verified: two PeerConnections share the one UDP port with no bind conflict; host + srflx (v4 and global v6) candidates all carry the fixed port. The UDP endpoint is not reported to the server (the browser learns it via ICE, not the TCP dial-back probe).
1461
-
1462
- ## 2.9.17
1463
-
1464
- - **New**: The proxy reports its UPnP-mapped external endpoint to the server over the tunnel (new `proxy-endpoint` message: `{ externalIp, externalPort, protocol }` from `port-mapper.getMappedEndpoint()`). Sent when the mapping completes and re-sent on every tunnel (re)connect, so the server can dial back and verify the proxy is reachable from the internet (server 0.8.22). No effect if port mapping is disabled or failed.
1465
-
1466
- ## 2.9.16
1467
-
1468
- - **New**: Automatic port mapping (`services/port-mapper.js`). At startup the proxy asks the home router to open its local port (default TCP 9090) via UPnP IGD / NAT-PMP using `@silentbot1/nat-api` (the same library WebTorrent already uses for the torrent port — no new host dependency). The mapping uses a 2 h lease auto-renewed while running and is removed on graceful shutdown (wired into the `cli.js` shutdown path; lease expiry is the backstop on a hard kill). Strictly best-effort: a router without UPnP/NAT-PMP is a normal case — it is logged and the proxy continues. Bounded by start/stop timeouts so a non-responding gateway never delays startup or hangs shutdown. Disable with `--no-port-mapping`. The discovered external endpoint is exposed via `getMappedEndpoint()` for the upcoming server-side reachability probe (not yet reported). `@silentbot1/nat-api` is now a direct dependency (was transitive via WebTorrent).
1469
-
1470
- ## 2.9.15
1471
-
1472
- - **Fix**: Torrent data is now cleaned up on graceful shutdown. `TorrentPool.destroyAll()` removes every torrent **with its on-disk store** (`torrent.destroy({ destroyStore: true })`) and then tears down the WebTorrent client; it is wired into the Fastify `onClose` hook (after `hlsSessionManager.disposeAll()`, so ffmpeg readers stop before their source files are removed). Previously nothing called `client.remove()`/`torrent.destroy()` anywhere, so downloaded files accumulated under `os.tmpdir()` until the process was killed — and even a clean SIGTERM/SIGINT left them behind. (First step of disk-hygiene Level 1; refcount/TTL removal and the startup orphan sweep are separate, still pending.)
1473
-
1474
- ## 2.9.14
1475
-
1476
- - **New**: `GET /api/sources/:sourceKey/stats` now reports `headerBytes` / `headerDownloadedBytes` — how much of the file's header/index region (leading 256 KB + trailing 2 MB, the bytes the codec probe needs) is downloaded, counted by whole torrent pieces from the bitfield. Lets the browser show the download phase's progress and ETA toward the next (transcode) phase. Coarse by design (piece granularity).
1477
-
1478
- ## 2.9.13
1479
-
1480
- - **Fix**: Video-copy path (`video=copy`, audio transcoded or copied) no longer drops video / desyncs audio at the start. The output timeline is now forced 0-based: the container `start_time` (parsed from the probe; many MKVs report ~0.1 s) is subtracted via `-output_ts_offset -start_time` together with `-copyts`, so segment 0 begins exactly at 0 with audio and video aligned (previously `-copyts` preserved the non-zero start, leaving a hole at the beginning where video was blank but audio played).
1481
- - **New**: Unified segment-boundary model. The synthetic VOD playlist and all seek math now come from a boundary table: a uniform grid for re-encoded video, and the source's **real keyframe positions** (probed once with ffprobe, normalized to 0) for copied video — so the declared segment boundaries match where a copied stream actually cuts, eliminating seek gaps. The keyframe probe is time-bounded (~6 s); on slow containers it falls back to the uniform grid (start still 0-based). Session log shows `seg=keyframe|uniform` and `start=…`.
1482
-
1483
- ## 2.9.12
1484
-
1485
- - **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
1486
- - **Branch A — video re-encoded** (`video=libx264`): use a fixed GOP (`-g`/`-keyint_min` = segmentDuration × fps, `-sc_threshold 0`) instead of `-force_key_frames expr:gte(t,n_forced*SEG)`. The old expression broke after a seek because `t` is shifted by `-output_ts_offset`, forcing keyframes at the wrong places and producing segments that did not line up with the playlist grid. A frame-count GOP is offset-independent → every segment is exactly segmentDuration and starts on a keyframe.
1487
- - **Branch B — video copied** (`video=copy`, only audio transcoded): keep the source's real timestamps with `-copyts` (and accurate seek) instead of relabelling onto a 4 s grid that does not match the source's own keyframe positions. Relabelling was the source of the holes in this mode.
1488
- - **Chore**: Session-start log tags the active branch (`branch=A(reencode,fixed-gop)` / `branch=B(copy,copyts)`) so glitches can be attributed to the right mode.
1489
- - **Fix**: Log timestamps reverted to UTC (`HH:MM:SS.mmm`) so the proxy and browser logs share one timezone and line up exactly when correlated.
1490
-
1491
- ## 2.9.11
1492
-
1493
- - **New**: Seek-aware torrent piece prioritization. On every `/stream` range request the proxy now marks the torrent pieces at the read position **critical** (`TorrentPool.prioritizeByteRange` → `torrent.critical`, ~8 MB window). After a seek, ffmpeg opens the input at a new byte offset; previously those pieces waited behind the sequential download backlog, so seeking into an undownloaded region stalled ~15-18 s while the proxy fetched data. Now the seek position jumps the download queue.
1494
-
1495
- ## 2.9.10
1496
-
1497
- - **Fix**: Raised the adaptive-preset speed margin (`PRESET_SPEED_MARGIN` 1.3 → 1.8). The preset benchmark runs at startup with an idle CPU, but during playback ffmpeg competes with in-process WebTorrent (download + SHA1 hashing) and delivery, so real throughput is lower than benchmarked. A 1.3× margin picked a preset that ran near/below realtime under load (e.g. `faster` at ~1.3×) and stalled; 1.8× picks a preset with genuine headroom (e.g. `veryfast`), keeping playback above 1× under real load.
1498
-
1499
- ## 2.9.9
1500
-
1501
- - **Fix**: Software (libx264) video transcode is much faster on weak ARM hosts, so playback keeps up with realtime: encode uses all CPU cores (`-threads`), and the scaler **never upscales** — the target box is capped to the source size via `min(W,iw)`/`min(H,ih)`, so a small source (e.g. 720x400) is encoded at its own resolution instead of being scaled up to the viewport (far fewer pixels).
1502
- - **New**: Adaptive software preset (preset auto-benchmark). At startup the proxy benchmarks libx264 presets (`fast`→`ultrafast`) on this host and records encode throughput (pixels/sec). Per stream, `hls-session-manager` picks the **highest-quality preset that still encodes the actual (source-capped) output resolution faster than realtime** with a safety margin, falling back to `ultrafast`. This maximises quality without dropping below 1× (which causes stalls). Logged as `video=libx264/<preset>` at session start.
1503
- - **New**: The input probe (`probeInputMediaInfo`, formerly `probeInputDurationSeconds`) now also extracts the source video resolution from the container header (used by the adaptive preset to compute the output pixel rate). Still returns on the header without decoding the stream.
1504
- - **Fix**: Transcode no longer thrashes between positions. `#ensureEncodingFor` now anchors the look-ahead window on the **current** encode position (not the run's start), and a `RESTART_COOLDOWN_MS` guard ignores competing seek-restart requests for a few seconds. Previously a stalled player requesting distant segments (e.g. #2 and #107) made ffmpeg ping-pong, restarting endlessly and producing nothing — which `Error opening input file` races confirmed.
1505
-
1506
- ## 2.9.7
1507
-
1508
- - **Fix**: `playback-planner` retries the codec probe while the file header is still downloading and no longer caches an **empty** probe result. Previously a transient empty probe (common for a later file in a multi-file torrent whose pieces arrive late) was cached permanently, so the file was mis-planned as directly playable forever — an unsupported video codec (e.g. xvid) got copied and played as a **black screen**. The probe now retries (up to 60 s) until at least one codec is detected, and only a successful detection is cached.
1509
-
1510
- ## 2.9.6
1511
-
1512
- - **Fix**: `probeInputDurationSeconds` now returns as soon as ffmpeg prints the container header (`Duration:`) instead of letting `-f null -` decode the whole stream until the 8 s timeout. Transcode-session creation was wasting ~8.6 s per session on this redundant decode (the duration was already available from the header, and `playback-plan` had probed it moments earlier). Cuts session-creation latency from ~9.7 s to ~1 s.
1513
- - **New**: `GET /api/transcode-sessions/:id/progress` now includes `segmentDurationSec`, so the browser can show progress toward the first segment (the only thing it waits for before playback) instead of a percentage of the whole-file transcode.
1514
-
1515
- ## 2.9.5
1516
-
1517
- - **Fix**: Segment files are now read with a 4 MB `highWaterMark` (`hls-session-manager.js` `getFileStream`) so the body is delivered in few, large chunks. On a busy ARM host the in-process WebTorrent hashing starves the Node event loop in bursts while the first segments are served; reading in fewer iterations cuts the time lost between chunks (the first segment previously transferred in ~79 × 43 KB reads spaced ~610 ms apart).
1518
-
1519
- ## 2.9.4
1520
-
1521
- - **Chore**: Temporary `[net-debug]` instrumentation in `data-channel-handler.js` now splits transfer timing into `fetchMs` (waiting for the local route, incl. ffmpeg segment finalization), `ttfbMs` (time to first body chunk), `sendMs` (channel send duration) and `chunks`, to locate where early-segment latency is spent (transport vs segment production).
1522
-
1523
- ## 2.9.3
1524
-
1525
- - **New**: WebRTC data-channel response bodies are now sent as **binary** frames (`sendMessageBinary`) instead of base64-encoded JSON `response-chunk` messages, removing the ~33% base64 overhead and the JSON encode cost. Frame layout: `[flags(1)][idLen(1)][requestId(ASCII)][payload]`. Control messages (`response-start`, `response-error`, `pong`) remain JSON strings. Requires the matching browser client (server ≥ 0.8.0); **deploy the server before the proxy**.
1526
- - **New**: Backpressure on the send loop — `data-channel-handler.js` pauses queuing body chunks once the channel's `bufferedAmount()` exceeds 8 MB and resumes when it drains below 1 MB (`setBufferedAmountLowThreshold` + `onBufferedAmountLow`), with a 5 s timeout fallback. Prevents the SCTP send buffer from ballooning and stalling throughput.
1527
-
1528
- ## 2.6.3
1529
-
1530
- - **Fix**: Data channel handler now logs **all** requests regardless of body presence — `GET /transcode/…`, `GET /api/…/progress`, `GET /api/…/stats` etc. were previously invisible in logs. Non-2xx response statuses and fetch errors are also logged, enabling diagnosis of HLS manifest load failures.
1531
-
1532
- ## 2.6.1
1533
-
1534
- - **Fix**: `TorrentPool.getTorrent()` — eliminated a race condition where two concurrent requests for the same torrent both found the cache empty and both called `client.add()`, causing WebTorrent to throw "Cannot add duplicate torrent". In-flight promises are now cached in a private `#pending` map; subsequent requests for the same key join the existing promise instead of triggering a second `client.add()`.
1535
-
1536
- ## 2.5.15
1537
-
1538
- - **New**: `GET /api/sources/:sourceKey/stats?fileIndex=N` — returns live torrent stats: connected peer count, download/upload speed, per-file download progress and size. Used by the browser to show meaningful feedback while waiting for file metadata.
1539
- - **New**: `TorrentPool.getFileStats()` — reads `torrent.numPeers`, `torrent.downloadSpeed`, `file.progress`, `file.downloaded`, `file.length` from the WebTorrent instance.
1540
-
1541
- ## 2.5.14
1542
-
1543
- - **New**: `TorrentPool.prefetchFileEdges()` — opens WebTorrent read streams for the first 256 KB and last 2 MB of a file before ffprobe runs. This prioritises the torrent pieces that contain file headers (FTYP box) and the MOOV atom (typically at end of non-faststart MP4), ensuring codec and duration detection succeeds even for freshly-added torrents. Timeout is 5 minutes; failure is non-blocking.
1544
- - **New**: Seek-to-position HLS transcode — `createOrGetSession` now accepts `startPositionSeconds`. ffmpeg is started with `-ss <pos>` (fast keyframe seek before `-i`) and `-output_ts_offset <pos>` so that output PTS matches the original timeline, keeping `video.currentTime` correct after a seek restart. Session cache key includes the rounded start position (10 s buckets) so nearby seeks share a session.
1545
- - **New**: `POST /api/transcode-sessions` accepts `startPositionSeconds` in the request body.
1546
- - **Chore**: `computeProgressMetrics` updated to compute percentage relative to the remaining duration from the seek point rather than the full file.
1547
-
1548
- ## 2.5.13
1549
-
1550
- - **Fix**: HLS playlist type changed from `vod` to `event`. With `vod`, ffmpeg only wrote `#EXT-X-ENDLIST` after transcoding the entire file, blocking playback start for large files indefinitely.
1551
- - **Fix**: `waitForHlsPlaylist` in the browser now unblocks as soon as `#EXTINF:` appears (first segment ready) instead of waiting for `#EXT-X-ENDLIST`. Latency to first frame drops from minutes to seconds.
1552
- - **Fix**: Codec detection in `PlaybackPlanner` — when ffprobe returns an empty audio codec (MOOV atom not yet downloaded), the plan now defaults to `direct` mode instead of forcing HLS transcode. The browser's range-request mechanism fetches the MOOV atom on demand.
1553
-
1554
- ## 2.5.12
1555
-
1556
- - **New**: Timestamps (`HH:MM:SS.mmm`) added to all log lines.
1557
- - **New**: Proxy version logged at startup (`Starting @torrent-tv/proxy vX.Y.Z`).
1558
- - **Fix**: WebRTC session torn down immediately after connect — `disconnected` ICE state is transient and no longer triggers `closeSession()`. Only `failed` and `closed` are terminal. This fixed data channels opening and closing within milliseconds.
1559
- - **Fix**: Fastify `bodyLimit` raised from 10 MB to 256 MB — large `.torrent` files encoded as base64 JSON exceeded the previous limit.
1560
-
1561
- ## 2.5.7
1562
-
1563
- - **Fix**: WebRTC connection failure behind symmetric NAT — all ICE candidates (private and public) are now sent to the browser immediately. The browser attempts all paths in parallel; the local LAN path succeeds when browser and proxy are on the same network. Chrome's Private Network Access dialog appears once on first connect.
1564
-
1565
- ## 2.5.6
1566
-
1567
- - **Fix**: ICE candidate filtering — private host candidates (RFC 1918, Docker bridge IPs, IPv6 ULA/loopback) are now buffered and suppressed when a public srflx candidate is available. This eliminates the Chrome/Brave Private Network Access permission dialog when connecting from a page served over HTTPS. Falls back to private candidates if no public srflx candidate is gathered (e.g. STUN unreachable), so connectivity is preserved at the cost of the PNA dialog.
1568
-
1569
- ## 2.5.5
1570
-
1571
- - **Fix**: Tunnel keepalive — proxy now sends a WebSocket ping to the server every 30 s to prevent Cloudflare's ~100 s idle-connection timeout from dropping the tunnel.
1572
-
1573
- ## 2.5.3
1574
-
1575
- - Internal: improved tunnel reconnect logic and error logging.
1576
-
1577
- ## 2.0.0
1578
-
1579
- - **New**: WebRTC P2P tunnel architecture — replaced direct HTTP streaming with a persistent WebSocket tunnel to the server. Video is delivered from the proxy to the browser over a WebRTC data channel; the server acts only as a signalling relay.
1580
- - **New**: `node-datachannel` dependency for server-side WebRTC.
1581
- - **Removed**: `public_base_url` config — no longer needed.
42
+ ## 2.78.0
43
+
44
+ - **Fix**: One authority over the encoders, where there were three. A viewer watching an episode on 2026-09-05 had their picture stop for 125 seconds while the proxy spawned and killed an encoder every 350-700ms, producing nothing. Two decisions were contradicting each other on every pass: the plan commanded a start inside the viewer's window, at #46; `planRunInterval` in the session manager moved that start to #78, because it counted every live run as claiming up to its head plus the look-ahead; and the plan then saw a run at #78 against a window of [27, 57], found no overlap and killed it as "nothing it was given is wanted". Neither coverage nor demand changed between two such passes, so the same start was commanded again, for as long as anybody watched. `planRunInterval` is deleted: where a run starts is the plan's decision and nothing moves it, and how far it may work is read off the one coverage map the plan itself uses.
45
+ - **Fix**: A run is never stopped for standing outside a viewer's window. While a file is being encoded it is encoded whole; a viewer decides the ORDER the work is taken in and, through the machine's budget, how many processes take it — never whether a run may go on living. The stop this replaced was the other half of the contradiction above, and with it went the last two uses of "does this run overlap a window" in the file.
46
+ - **New**: What a viewer needs is a map, and the maps of several viewers merge into one (`services/encode/DemandMap.js`). Three zones per viewer, in seconds of film: what must be ready before they set off so that they never stop, what the machine reaches while they watch that, and the rest of the track. Merging takes the highest priority per second, so film two people want is as urgent as the more urgent of them and is made once. Which viewer asked never reaches the encoders.
47
+ - **Fix**: The size of that first zone is measured rather than chosen. Below realtime an encoder loses `1 - speed` of a second of film for every second played, so over the film in front of a viewer the shortfall is `remaining × (1 - speed)`, and that much has to exist before they start or they meet a stall partway through — at 0.25x on 400 seconds of film, 300 seconds. Above realtime only the allowance this file's own recent interruptions have shown to be necessary is needed. The 120 seconds that used to size this window were `LOOKAHEAD_PAUSE_SECONDS`, the threshold of a suspended encoder, one chosen number answering seven unrelated questions.
48
+ - **Fix**: The look-ahead limiter is gone, with both of its chosen numbers. It suspended a run 120 seconds in front of the viewer and woke it at 60 — deliberately pushing a run past the window the plan was asking about, which is what the plan then killed it for. What remains of it is a reading: how much film is ready in front of the earliest viewer, which commands nothing.
49
+ - **Fix**: A zone's priority reaches the register instead of being dropped on the way. `want()` took only the two ends, so the order the map states was computed and then discarded. It is a property of the window now, and the claimant's name stays the person's, so a viewer who leaves takes all three of their zones with them.
50
+ - **Fix**: The measured encode speed is read from the field that exists. `progress.speedX` appears nowhere in this codebase, so the speed was always zero and the middle zone was never built at all; the reading the budget already trusts is `recentSpeed.speed`.
51
+
52
+ ## 2.77.0
53
+
54
+ - **Fix**: A viewer who has just arrived is watching. Presence and position were one field — the segment they last asked for — so a viewer who had asked for nothing counted as absent, and an output all of whose viewers count as absent has every encoder on it stopped. Field 2026-09-05: a soundtrack's encoder was stopped 1.25 s after it started, with nothing produced, so its `init.mp4` was never made; the picture could not be played without it, the browser waited sixty seconds and was told the proxy had sent no video. The viewer could not have rescued themselves — the segment request that would have marked them present needs the `init.mp4` the stopped encoder was going to make. They are now two facts: position is set the moment they arrive, from the position their own request names, and presence is a fact of their connection.
55
+ - **New**: The page says who is watching, once, over the control channel it opens. A connection closing is then a PERSON leaving, and every output they were watching — the picture, the quality step, the soundtrack — is told at once, instead of nothing being told at all. Two doors, because the first does not always come: the channel's own close event, and the watch that already exists beside it for a peer connection that dies without one. Silence is never a departure: a viewer on pause, a viewer whose tab is hidden and whose timers the browser has throttled, and a viewer holding two minutes of buffer are all silent and all still watching. The name belongs to the page, not to the connection, so the reconnect ladder swapping the transport under a running player does not make one person two.
56
+ - **New**: What encoders should exist is re-decided when its inputs change, not every five seconds. A viewer arriving, moving or leaving, and a run ending, each say so; the decision is taken once at the end of that turn however many of them happened. The five seconds were also the period of the restart loop seen in the field on 2026-09-05 — one ffmpeg spawned per tick for sixteen minutes — and a just-created output waited up to five seconds before anything noticed it had a viewer at all.
57
+ - **New**: A viewer asking for any file of a soundtrack or a quality step is that viewer watching it. Only a segment request counted before, and a segment cannot be asked for until the init has been served, which cannot be made for an output nobody is watching.
58
+ - **Fix**: What happens after an encode run ends had never once happened. The handler removed the run from its session and then asked whether the run was still that session's — a question that always answers "no" after the removal — so everything below that line was unreachable: the fallback from a failed hardware encoder to software, the retry when the torrent data goes away, the limit on retrying a position that keeps failing, and the error line naming the ffmpeg command. Verified over both of the field host's log files: zero occurrences of that error line and zero of `fast failure at segment`, across every session that proxy has ever run. The identity is now read before the removal.
59
+ - **Fix**: A start that cannot succeed is no longer commanded for ever. The limit on retrying a position counted only past segment #0 — it was written for seek restarts, and a seek is never to the beginning — which left the one position a first start happens at with no count at all. With the two defects above together and the decision now taken the moment its inputs change, ffmpeg failing to spawn produced fifty passes of the plan in the time a probe took to notice; on the five-second timer the same thing had read as a restart every five seconds for sixteen minutes in the field. The count now runs at every position, the plan consults it before commanding a start, a different position keeps its own budget, and a run that does real work resets it.
60
+ - **Chore**: A viewer's position is called `position`. It was `head`, which is also what a running encoder's position is called, and the two are different quantities on different objects. An output created on behalf of a picture keeps its made-up name out of the viewer registry, where — now that a viewer is present from the moment they are known — it would have kept its output producing for ever.
61
+
62
+ ## 2.76.6
63
+
64
+ - **New**: The proxy says which films it is already downloading, in the same health poll the browser makes before it picks one. A viewer sent to the proxy that has their film costs it the encode and nothing else — the torrent is there, its pieces are there, and where the output is identical they share that too — while the same viewer sent anywhere else starts the download from nothing. Answered from the torrent thread, which is the only truthful record: the main thread keeps stand-ins that are cleared on shutdown alone, so they name films this proxy let go of hours ago. A proxy that cannot answer is scored on its machine as before.
65
+ - **Chore**: Two doc comments in the torrent pool's interface described methods that had been moved or renamed out from under them, leaving the methods below without one. Each is back over its own.
66
+
67
+ ## 2.76.5
68
+
69
+ - **Chore**: What encoding costs this machine is a class of its own (`services/quality/EncodeCost.js`), the third part taken out of the session manager. One subject — seconds of work per second of video — asked about a picture being re-encoded, a soundtrack, a copy, everything running beside the rung being judged, and turned into the list of heights the machine can hold. It holds what an encoder taught it, since it is the only thing that reads those figures, and it is given what it cannot work out for itself: which sessions belong to one file, the host's own readings, how many encoders are running, and what the file costs merely by being fetched. The host is asked at the moment of the question rather than copied when the object is made — the share of the machine that is free is re-read every few seconds, and a copy would price every later rung against a machine that has gone. The manager keeps the three methods that LEARN those costs; moving them is the next step.
70
+ - **Fix**: A rung measured running below realtime is withdrawn even on a host whose startup benchmark is missing. That measurement is the strongest evidence there is about a rung — it ran, and it could not keep up — and it was being discarded along with the prediction it does not depend on. Found by a check written while the arithmetic was being moved.
71
+ - **Chore**: What a session's encoding is doing, as one state, is answered where the run states are defined (`encode/encode-run-state.js`) instead of by three functions in the manager. The boundary of the new directory is a lint rule, like every layer before it, and the rule was checked to fire rather than assumed to.
72
+
73
+ ## 2.76.4
74
+
75
+ - **Fix**: An encoder was started and killed every five seconds, each one producing 0-2 segments, for as long as anybody watched. Measured on the addon host: a run given no end carries a `to` below its `from` — which is how "no end" is written everywhere here — and two places read that as a number instead. The plan's test for "is anybody waiting for what this run was given" said no, so it was stopped as unwanted; and the stretch it claimed in the coverage map collapsed to a single segment, so the plan saw the rest of the film as free and started another encoder one number along. The two together are the loop. The rule is stated once now (`endOfRun`) and read from that one place, including where a run's own ending is judged.
76
+ - **Fix**: A run with no end holds the look-ahead in front of it, not the rest of the film. It is what actually bounds one — a run is suspended once it is that far in front of the segment its viewer asked for and produces nothing until somebody asks — and claiming further would leave a viewer who opens the same film further in with no encoder at all, waiting for that run to encode its way there. The rule was already applied where a session plans its own interval and not on the path the plan uses.
77
+
78
+ ## 2.76.3
79
+
80
+ - **Fix**: The proxy stopped answering anything — playback, health, its own log — a few seconds after a viewer opened a film, and burned a whole processor doing it. Measured on the addon host with the stack read out of the live process: the look-ahead timer asked the plan where a new encoder could start, and the walk that answers that walked one segment number at a time towards nine quadrillion, scanning every claim at each step. It did that because the map had no length, and the map had no length because the field naming it moved onto the timeline in 2.76.0 while three readers were left on the old name, where every session answers `undefined`. Those three are the whole defect: with them wrong, no run was ever given an end either, so the feature that lets two encoders share one output was inert as well.
81
+ - **Fix**: That walk can no longer do this whatever the length says. With no length known there is nothing to walk towards, and the answer — where the free stretch ends — is read off what the map already holds: the segments made and the stretches claimed, both finite however long the film is. A run then gets no end, which is what "the length is unknown" honestly means.
82
+ - **Chore**: Three test fixtures stated the moved field on the session, so the checks went on passing over code that could not work. They state the timeline now, which is where the product reads it.
83
+
84
+ ## 2.76.2
85
+
86
+ - **Fix**: How long a session waits for a film's keyframe table is bounded, and the bound is one the read already had rather than a new number. That table decides which branch a picture takes — with it the picture is passed through untouched, without it the whole picture is re-encoded — and nothing limited the wait, while the file comes off a torrent and the bytes the table lives in may still be arriving. Measured on the addon host over seventeen files from four containers, pieces from 0.25 to 16 MB (`research/keyframe-table-read-2026-09-04.md`): every table that arrived did so within 24.8 s and most within half a second, while two files answered nothing for 120.9 s and 120.5 s — which is exactly TWO of the sixty-second bound the read already has, one for the wait on the file's edges and one for the read, in series. A session now waits for one of them. The read is not cancelled: it goes on, is remembered on the file, and the next session of that file gets the copy.
87
+ - **Fix**: A table that has not arrived is not written onto the file as an absence. It would make a passing shortage of bytes look like a property of the bytes, and every later session of the file would then re-encode a picture that can be copied.
88
+ - **Chore**: The two lines about that read say which quantity each is. They differ by up to sixty seconds — one is the swarm delivering the file's edges plus the parse, the other only the parse — and reading them as one figure produced a wrong conclusion the same day.
89
+ - **Chore**: What a playlist says is written where a playlist belongs (`services/output/playlists.js`), the second part taken out of the session manager: the media playlist, the master with its quality steps and soundtrack group, and the lookup from a time to a segment. All three are statements about a timeline and about nothing else — not about a session, a viewer, an encoder or a disk — and they were private details of an eleven-thousand-line class, along with the HLS attribute quoting, the language tags and the bitrate a variant declares.
90
+
91
+ ## 2.76.1
92
+
93
+ - **New**: How long it takes to read a file's keyframe table is recorded, once per file, with the container that answered and how many times it found. That read decides which branch a picture takes — with the table it is copied, without it the whole picture is re-encoded — and nothing measured it. The one figure printed until now, `keyframes=` on the session-create line, is what the SESSION waited for, which is the remainder of a read the playback plan had already started, and reads zero whenever the plan finished first.
94
+ - **Chore**: The comment promising this read a short timeout and "never more than ~6 s to session start" is gone. Nothing in that path has ever had a timeout: the fall back to re-encoding fires when the table is ABSENT, which is a statement about the file, not about how long a read ran. The file comes off a torrent, so the bytes the table lives in may still be arriving and the read waits for them, without a bound.
95
+
96
+ ## 2.76.0
97
+
98
+ - **Fix**: A viewer who changed quality twice lost the soundtrack they had chosen. The three link fields between sessions were replaced by a relation indexed both ways — an output holds its viewers, a viewer holds its outputs — and leaving an output was written as "delete the viewer from the one they came off". The one they came off can be the picture itself, which happens whenever somebody goes down a step, back to the picture's own height and down again: the picture then forgot them entirely, and with them their language, their position and their link. What followed was not a lost setting but silence — the soundtrack they were listening to was the one nobody was listening to any more, so its encoder was stopped as unwanted. A viewer is never dropped from the picture: it is the only id the browser holds, and everything a viewer has chosen is recorded there. Reproduced through the public path before the fix and pinned by a check that fails without it.
99
+ - **Fix**: A viewer who leaves is subtracted from every output they were watching, and an output nobody is left watching is let go at once. Removing the closing chain left nothing in its place: nobody outside the session manager knows the id of a quality step or of a separately published soundtrack — the browser holds one id for the whole film — so when the picture ended, each of those went on holding a consumer, a claim on the torrent, a directory and a live encoder until the idle timer noticed half an hour later. The rule is now the viewers and not the picture, which is the difference that matters: an output with somebody still watching stays, whoever made it, and a soundtrack dies of having no listeners rather than of its picture ending.
100
+ - **Fix**: Whatever removes a viewer from an output releases what their watching claimed of production. The only place a claim is ever released is the plan's pass over an output's viewers, so a viewer deleted from that map by any other route — a switch away from a step, a track change, a head that went stale, a session being disposed — left a claim nothing could reach, and the plan went on planning encoders for somebody who had gone. Every removal goes through one method now, and that method releases the claim.
101
+ - **Fix**: One viewer is one object. It was made per SESSION, so the same person watching a picture, a quality step and a soundtrack was three objects, each with its own copy of what that person had chosen and where they were. The field saying which outputs a person is watching was worse than duplicated: being per session, each copy could only ever hold the id of the session that owned it, so it carried nothing, the one place that read it learned nothing from it, and three deletions written against it matched nothing. `services/viewer/Viewers.js` keys a viewer by the consumer id the browser sends — minted once per film opened in the page, so one id is one person watching one film — and writes both directions of the relation in one place. A viewer that cannot name itself still belongs to the session that met it, since two anonymous viewers are not one person.
102
+ - **Chore**: Which outputs of one file exist right now is a class of its own (`services/output/LiveOutputs.js`), the first part taken out of the session manager. Eight questions with one subject and no state — the picture a step belongs to, the steps, the soundtracks, the height a session is named by, the height its encoder actually produces, where the file can be spliced, whether it publishes a master at all — answered by walking the live sessions and looking at what each one IS. They were private methods of an eleven-thousand-line class, which meant the quality budget and the serving path could only reach them by being inside it. The ladder of heights went with them (`output/ladder.js`), because a list of heights derived from a source height is a fact about an output and about nothing else. Two orphaned doc comments were found by the move: each described a function that had been moved or renamed out from under it, and each stood above a different function that has its own.
103
+ - **Fix**: How many encoders may work on one output is bounded by measurement alone. It was bounded by a constant of four as well, and that constant could bind first: past the concurrency actually measured on a host, the penalty reading is HELD rather than extrapolated — which is the right answer to "what does this cost" and the wrong one to "may I start another", because it prices a fifth encoder at what a second was measured to cost. A fast host therefore kept dividing until the constant stopped it. The ladder now stops where the readings stop, and the constant is gone.
104
+ - **Fix**: A quality step is a session made as one, not "another session of this file that carries a picture". One file can hold two pictures at once — a browser that understands rendition groups and one that needs the sound muxed in produce two — and treating one as a step of the other would let a switch away from a step stop the encoder of somebody else's picture. What every session of one file DOES share is the machine they run on, which is what the family is asked for, so that is what the family now means: every live session of the file, with the steps and the soundtracks named among them by what they are.
105
+ - **Chore**: Every kind of encoder is a class of its own (`services/encode/`), and what a kind knows is written where the kind is. Five object literals inside `hwaccel.js` stated the arguments and nothing else; the arithmetic they are built from — output frame rate, the bitrate ladder and its caps, the frame box, the keyframe arguments — moved to `encode/args.js`, so a kind no longer drags detection and benchmarking behind it. The move was proved rather than reviewed: old and new built arguments over 2160 combinations of size, segment length, frame rate, preset, tone mapping, cut list and bitrate cap, and all 10800 came out identical element for element.
106
+ - **New**: A kind states its own ladder of speed settings, and whether that ladder has ever been measured. Only libx264's has. `h264_vaapi`'s setting is `-quality`, higher is faster, with a range belonging to the driver — read from ffmpeg on the addon host rather than from memory — and nothing passes it. `h264_qsv` and `h264_nvenc` are declared and unverified, no host here carrying either encoder to ask, while NVENC is given a hardcoded `p4`: one rung in the middle of a ladder nobody has measured. `h264_v4l2m2m` has no such setting at all and says why. On a pool machine with working hardware encoding this is the only way to change speed, so its absence is now visible in the code instead of looking like a decision.
107
+ - **New**: What has been made of an output and what is being made of it (`encode/CoverageMap.js`); what viewers want of it (`encode/SegmentDemand.js`); how many encoders there should be and where each belongs (`encode/EncodePlan.js`); one running encoder with the stretch it was given (`encode/EncodeRun.js`); and the layer tying them together (`orchestrators/EncodeOrchestrator.js`). A run is given a start AND an end, which no run has ever had — neither `-to` nor `-t` appears anywhere in the arguments this proxy builds — so two encoders can work on one output without writing over each other. A run that meets material somebody else has already made is moved forward to the first gap rather than killed, and is left to drive through instead when the covered stretch divided by that run's own measured speed comes out cheaper than a restart. Not yet wired to the session manager: nothing calls it yet.
108
+ - **New**: Every start and every end of an encoder is recorded with its cause, and endings are counted by cause over the life of the process. Exactly one ending is normal — a run reached the end of the stretch it was given and exited by itself — and our own kill is not one of them, because hiding it among the normal endings is what would make the count useless.
109
+ - **New**: One store of produced segments for the whole proxy, addressed by what the segments ARE rather than by which session's encoder wrote them (`encode/SegmentStore.js`). A segment used to live under the random id of its session, so two viewers of one film could not see each other's work even when their output was identical byte for byte. Two sessions of one output now write into one directory and each serves what the other has made; the run number belongs to the output too, or they would both claim `run-1`. The start position is deliberately not part of the address: segment 42 covers the same span whoever began where. A directory is removed when the last session on that output goes, not when the first one does.
110
+ - **New**: What a killed process left on the disk is taken back at startup instead of being abandoned. The kernel kills this proxy often enough for that to be an ordinary state — twice in one viewing on 2026-09-02 — and when it does, no exit handler runs and nothing is cleared up: memory is reclaimed but the files are not, `/tmp` on the addon host being the overlay filesystem rather than a memory one (measured 2026-09-04). Each directory now says which output it holds, so a new process can tell what it is looking at; segments whose closure is proven are kept, because a copied segment's bytes depend only on the source and re-encoding them costs the one thing the machine is short of; the single piece per output that was being written when the process died is removed. The sweep says what it found before it decides anything — for an encoder killed with the process, that line is the only record that it ended at all.
111
+ - **New**: A run is given a stretch — from a number and TO one — and every run of an output writes into one flat directory. Runs used to be kept apart by a directory each (`run-1`, `run-2`, …), because two of them writing one segment name at the same time produce a file belonging to neither; that was also the only reason a restart ever had to wait for its predecessor to die. The stretch removes the collision by construction: a run starts at the first number nobody has and nobody is making, and stops before the next number somebody does, so no two runs can want the same file. A run asked to start inside material that already exists is moved forward to the first gap instead, and where there is no gap ahead no encoder is started at all — which is what three ffmpeg processes making one identical picture cost on a CM4 in August. The `run-*` reading survives only to serve what an older version left on the disk.
112
+ - **New**: Which argument states a run's end is a property of the output branch, and it is measured rather than reasoned (`research/encoder-layer-2026-09-04.md` §11): `-t` is a duration on the output's own clock and `-to` a point on the input's, so the re-encode branch takes the duration and the copy branch — which runs with `-copyts`, where the input's clock is the source's — takes the absolute time. Swapping them is not a near miss: on the copy branch `-t` produced one segment where five were wanted, because the time it names is already past when the run starts.
113
+ - **Fix**: What an output is encoded AS is decided once, for the output (`services/output/Output.js`). The box in pixels, the frame rate, the speed setting and the tone map were decided per SESSION, by the realtime budget, from what the machine could hold at that moment — so two sessions of one output made minutes apart could be given different pictures while claiming the same identity, and everything downstream assumes they cannot be: a segment of one is supposed to stand in for a segment of the other, and the master names one `RESOLUTION` for both. What the budget learns afterwards still moves the rate cap, which stays with the run for the reason it always did — rate control appears in neither the SPS nor the PPS, so it can move under a player that has cached the init, and the size cannot.
114
+ - **New**: Where a file is cut is held once, by the file, instead of once per session (`services/output/Timeline.js`). Every quality step of one film must be cut at exactly the same times and every session of it must publish the same playlist — and that agreement was arranged by COPYING a table into each new session at creation. Copying is a thing somebody has to remember to do, and it drifted twice in the field: 0.6-2.9 s between two sessions of one film on 2026-08-17, and segments arriving a uniform 2.002 s before the times the playlist named for them on 2026-08-20, four times what a player bridges. Sessions hold the table itself now, so a correction found by one — a produced segment showing where the cut really is — is written once and seen by all, including sessions created afterwards. The two tables stay two things: what a run cuts at moves as the file is learned, what the player was told never does.
115
+ - **Fix**: An encoder handed to the plan from outside is released when it stops. A run the plan builds reports its own ending; a session whose encoder stopped makes no such promise, so its claim on a stretch would have sat in the coverage map for the life of the process, telling the plan that numbers nobody is making are being made — and nothing would ever have been started there again. Found reviewing the wiring, before it ran anywhere. Counted under an ending of its own, because "nobody watched it end" has to be distinguishable from the endings that are meant to happen.
116
+ - **New**: The plan decides the encoders, and it is asked of the sessions that exist. Three rules written into the session manager become one: where a run belongs, when it has been overtaken, and how many the machine affords. Every session on an output is handed to it as the run it is (`encode/SessionRun.js`), what each viewer is waiting for reaches it as a span with no name on it, and what the store already holds reaches it as coverage — so a second encoder is never started over numbers somebody is already making, and one making material nobody asked for is stopped. The bound on concurrent runs is measured rather than chosen: at 854x480 on the addon host one run makes 7.12x and two make 4.20x and 4.16x, so a second fits; at 1920x1080 one makes 1.96x and two make 0.99x, so it does not. Where what a second job costs has NOT been measured on this machine, the answer is one — an unmeasured penalty of 1 says nothing has been measured, not that a second encoder is free.
117
+ - **Fix**: A viewer jumping backwards no longer takes the picture away from one watching ahead. Two viewers who opened a film together share a session, a session holds one encoder, and repositioning it is repositioning theirs — so a jump of an hour stopped the other viewer's segments being made. The seek is not refused; it is given a run of its own at that position, which costs nothing extra now that segments are addressed by the output rather than by the session: both runs write into one directory and either viewer is served whatever either run has made. Only where it would actually cost somebody — a jump BACKWARDS, past a live viewer's own head, on a run that would have to restart. Alone, or forward, or into what the run already covers, nothing changes.
118
+ - **Chore**: A viewer is an object (`services/viewer/Viewer.js`), not six maps hung on the session and keyed by consumer id. What they are listening to and whether their browser needs it re-encoded, the quality step on their screen, the step and the track being warmed for them, where they are, what their link carries: six places to remember to update and six to remember to forget. The forgetting was already wrong — releasing a consumer emptied none of them, so a viewer who had left went on counting as wanting their soundtrack until their head expired, up to two minutes of an encoder nobody was listening to, and their entries stayed for the life of the session. One object, one deletion.
119
+ - **Fix**: A run with no end no longer claims the whole film. Bounding runs by their stretches introduced it: the first viewer's run, started on an empty output, was taken to own every segment to the last, so a second viewer opening the same film further on found every number claimed and got no encoder at all — they would have waited for the first run to encode its way there. A run's claim reaches as far as it will actually get, which is its head plus the look-ahead, because past that point it is suspended and produces nothing until somebody asks. And a run that walks into a stretch another run was expressly given stops there, since its own end was set from the gaps of the moment it began and a viewer who arrived afterwards was not in that picture.
120
+ - **Fix**: What produced segments cost is no longer decided by anybody's departure. Removing them when the last session on an output ended was still the session deciding, and a session ending says nothing about whether the material will be wanted: the viewer who closed the tab may come back, and a viewer who never had a session here may open the same film a minute later and find the work done. What decides now is when the output was last READ, and how much room the disk has — a share of what is FREE at that moment, re-read on every sweep for the same reason the piece store re-derives its memory allowance, since a Home Assistant install often runs from a 32 GB card carrying everything else in the house. Over the allowance, what was read longest ago goes first.
121
+ - **Chore**: Dead code removed, found with `knip`, now configured for this package. Four folders carried an index file that nothing imported at all — `container/`, `controllers/`, `download/`, `orchestrators/` — and three more re-exported names nobody took. Gone with them: `containerAudioTracksOf` and `forgetContainerTracks`, `anythingUrgentIsMissing` and `liveDemand`, the `OperationTimer` class and the import only it used, and two re-exports of figures nothing reads. What is left of the report is names used inside their own file and exported as well, which is not dead code.
122
+ - **Fix**: A session belongs to the tracks its output actually carries, and its key holds those tracks' parameters and nothing else. Measured 2026-09-03: two viewers of one COPIED picture got two sessions with descriptions identical word for word and output identical byte for byte — the same `segment-00000.mp4` of 4141899 bytes — four encoder processes for one film, the look-ahead paid twice at ~228 MB each, and the piece store at its ceiling with twelve readers where one viewer gives three. The key forked on the soundtrack number, carried by a session whose output is `video-only` and contains no audio at all. Three fields are now conditional: the soundtrack and whether it is re-encoded appear only where the output carries sound, and the target box only where the picture is re-encoded — a copy is the source's own size whatever box was asked for. The soundtrack is also named precisely: the file the track lives in and the `0:a:N` inside it, not the flat number the browser sends, which spans the picture's own tracks and the files beside it and so means different things for different pictures of one torrent. Reuse between viewers is not a feature built on top of this: it is what a correctly built key already means.
123
+ - **New**: The identity of an output is a thing with a name (`services/output/OutputSpec.js`) instead of a `join(":")` inside a 300-line function with no test and nothing printing it. Twelve checks state what may and may not tell two outputs apart.
124
+ - **New**: The key is printed — at creation, and when a second viewer joins an existing session, which said nothing at all before. A fork was undiagnosable: both create requests of 2026-09-03 were 265 bytes and nothing anywhere recorded what the two had been told apart by.
125
+ - **Fix**: Whether the sound travels separately is decided BEFORE the key and never derived again. It used to be settled after the session had already been filed, which was survivable only while the key carried the audio parameters unconditionally — with them conditional, a key saying "no sound in this output" over an output that muxes it would have let two viewers who chose different languages share one encode, and one of them would have heard the other's.
126
+ - **Fix**: A picture whose sound is published separately no longer opens a second input on the soundtrack's file, nor holds that file against the disk sweep for the session's life. It maps no audio at all (`-an`).
127
+ - **Fix**: Everything about the sound that used to be a field of the session is a fact about a VIEWER, because two viewers of one picture can want different things of it. Which soundtrack each is listening to, and whether their browser needs it re-encoded: a segment request from one used to read as everybody moving to that track, so with two viewers they would have switched each other's encoder off in turn, once per segment, for the whole film. A rendition is filed under the track AND how it is produced — a browser that can decode it as it stands gets a copy, one that cannot gets AAC — so the first viewer's codec support no longer decides for the second. The master marks the ASKING viewer's own track as the default one. A track is stopped when NO live viewer is listening to it, and "live" is the same head-expiry the encoder's steering already uses, so a tab that closed without releasing the session cannot hold an encoder for ever.
128
+ - **Fix**: The quality step each viewer has on screen is theirs, and the consumer id is out of the session key. A step is a session of its own, so with one answer per session a step taken by one viewer stopped the other's stream and forwarded that viewer's next seek to a rung they never chose — which is exactly why the key carried the viewer at all. The step being left is stopped only when nobody is left on it; a step anybody is watching is never withdrawn from the offer; a link report and a progress poll are answered from the reporter's own step; a seek moves the seeking viewer's own soundtrack and nobody else's.
129
+ - **New**: Where a file's keyframes are is a fact of the FILE. It is a property of immutable bytes, like the duration and the track list, and it is read once — by the same container that already answers those two, over the torrent instead of over this proxy's own HTTP. The wait belongs to the file as well: two sessions created in the same moment join one read instead of making two, which is what two viewers opening one film do (measured 13 ms apart). The answer decides whether a picture can be copied at all, so it has to be one answer, and it now is.
130
+ - **Chore**: A container reads its own keyframe table once. `Container.readKeyframeIndex` memoizes and `parseKeyframeIndex` is what each format implements — the same split `readMediaInfo` already had. A read that threw is not remembered as an answer: the bytes it needed may simply not have arrived yet.
131
+ - **Chore**: The session's shared position field is called what it is. `viewerPositionSeconds` was one number for a thing a session has as many of as it has viewers; it is `furthestViewerSeconds` now — the figure an encode run is placed by, because what lies behind the furthest viewer has already been made — and every reading that is about ONE viewer takes their own head instead, including the refusal line, which used to explain a refusal by somebody else's position. A head now remembers whether its viewer STATED that position or merely asked for a segment there, which is the distinction a cold open's soundtrack placement turns on.
132
+ - **Fix**: One rule decides which file beside the picture belongs to it, and the proxy is where it lives. It was implemented twice — here by comparing base names, in the browser by accepting any name that BEGINS with the video's — and the two answers were compared nowhere. Measured over the 115 real torrents in `Dropbox/trn`: of 1249 video files the two pairings agreed on 1239 and differed on 10, every one of the ten a `<video base>.<language>.ass` name, which is the shape Plex, Jellyfin, Kodi, Bazarr and OpenSubtitles all produce. The difference reached the viewer, because this side warms what IT paired while the browser offered what IT paired: a subtitle track offered but never warmed waits for its first piece off the swarm, 27.7 s in the field measurement of 2026-08-31. The name grammar — the union of what those five players implement, and until now the browser's alone — moved here with its checks, and the pairing asks it: a name pairs when it continues the video's at a token boundary and the remainder reads as languages, flags and title text. The boundary is what makes that safe, and a plain "begins with" is not the same rule — without it `Film.20.rus.srt` pairs with `Film.2.mkv`, measured, and the grammar then reports the leftover `0` as the track's title, which is the tell. Re-measured after the change: 1249 of 1249 agree. The plan now carries the paired subtitle files and the images beside the picture, each with what its own path says, so the browser reads no name at all.
133
+ - **New**: A still image shipped beside a video is recognised and paired with it — a contact sheet of frames, a cover, a poster. Two things a viewer can see follow from it: a pack of a hundred videos is unusable as a list of release names and many such packs ship one sheet per video, so the sheets can be the picker; and a film with a cover beside it has something to show while its first frame is being made. Paired more strictly than sound and subtitles: the one-video relaxation does not reach images, since a torrent's stray screenshot is not this film's cover, and the sheet's own naming shape is read — `Movie.mp4.jpg` beside `Movie.mp4`, verified on a pack of 106 videos with 106 sheets, which no base-name rule would match.
134
+ - **Chore**: Which files of a torrent belong together is the torrent's own statement, and it lives in `services/torrent/` now. A container knows only itself and a track only its container; "the file next to this one" is a notion that exists only where there is a list of files.
135
+ - **Chore**: Which layer may import which is a lint rule now, not a thing to remember. Nine folders were already right — `output/`, `demand/` and `viewer/` import nothing at all, `container/` only `tracks/` — and nothing held them there: the config checked five things and none of them was about imports. Each folder now states what it may not reach for, with the reason in the message, so an accidental edge fails in the editor rather than being found by a reviewer. Two files moved to make the rule need no exceptions: the run's state table into `encode/`, where its only two readers are, and language detection into `tracks/`, since what it detects is a subtitle track's own language. A layer that wants a logger takes one as a parameter, which is what `EncodeOrchestrator` already did.
136
+ - **Chore**: The full argument list for one encoder run is stated where the arguments live (`services/encode/run-command.js`), not as a 377-line method of the session manager reading fifteen of its fields. A run is a fact about WHAT is being produced and WHERE it begins, and about nothing else — so stated over the material and the stretch alone it can be built by whoever needs one, which is what lets an output have more than a single encoder. Six pure functions moved with it, each the answer to a question about a run rather than about a session: how a number of seconds is written for ffmpeg, which timeline the output is labelled on, where the player was told a segment begins, where the cuts are rebased to, which keyframe a seek snaps to and how much later than it to ask. The three things inside that method which were not command building — resuming a suspended predecessor, killing a superseded process, warning about a stale init — moved to the one place a run is started. Proved rather than reviewed: the manager as it stood before and the working tree built arguments over 4096 combinations of the twelve axes that change a branch — copy or re-encode of picture and sound, audio-only, a soundtrack published separately, either cut grid, a second input, a keyframe table or none, either start, an overridden position, an end or none, a non-zero container start, a rate cap — and all 20480 comparisons came out identical. The comparison was then shown to have teeth: one character changed in the seek-landing constant produced 768 disagreements.
137
+
138
+
139
+ ## 2.75.0
140
+
141
+ - **New**: The picture's facts are read from BOTH readings that state them, which audio and subtitles have done since their flags were first read from the file and video never did. Every figure the encode is planned from — the size, the frame rate, whether it is HDR, how many bits a sample carries — came from ffmpeg's `-i` banner alone, while the `VideoTrack` the container declares was read and then used for nothing but a line in the log. Which reading answers is decided per field by what each one IS, and the rule is the one `readMediaInfo` already states: the coded size and the frame rate are the BANNER's, because what the encoder receives is what the decoder produced and the ladder and the scale filter must be sized to that; the bit depth and the HDR signalling are the CONTAINER's where it states them, because they are not properties of the decoded frames at all but the file saying how its samples are to be read, and ffmpeg prints them only as a side effect of naming a pixel format; the display size is the container's alone, the banner having no such field. Where the two disagree on something both state, the disagreement is now reported — it is a fact about the file and nothing could see it before. HDR is deliberately not compared: both sides give a boolean, and a boolean cannot say "I did not look", so a container with no Colour element and one stating SDR are the same `false`. That was found by the test, not by reading.
142
+ - **Chore**: One reconciliation of ffmpeg's banner with a container's own table, instead of two. The rule is the same for every media kind — align by position, check each pair on language or title, and drop the container reading WHOLE if one pair agrees on neither, because a wrong flag is worse than a missing one — and it was written twice, down to a `pairingHolds` that was byte-for-byte the same function under two names. `Container.alignWithBanner` is the alignment; `mergeSubtitleFlags` and `mergeAudioFlags` are the two flag lists, both on the container, because what they correct is what the container declares.
143
+ - **Chore**: Keyframe reading moved into the containers, and the third place that decided what a file is went with it. `container-index/matroska.js`, `mp4.js` and `avi.js` were each a specification speaking about its own container — the Cues table, `stss`/`stts`/`ctts` and the edit list, `idx1` and its keyframe flag — reachable only through that container's `readKeyframeIndex`. `container-index/index.js` sniffed the header a third time to pick between them; that is `ContainerFactory.readKeyframeIndex` now, over the factory's own sniff. `services/container-index/` is gone; the generic EBML element walker moved beside its only user.
144
+ - **Fix**: Folding the MP4 readers together put two `findMoov` implementations side by side, and they were not the same: one guarded against a box declaring size 0 — legal for the last box of a file, and an infinite loop for anything stepping over it — and the other did not. The guarded one is what survives, which is a defect removed rather than a tidy-up.
145
+ - **Chore**: The codec-name table is `AudioTrack`'s. Which name ffmpeg gives `A_AAC`, `ac-3` or a bare `.dts` is a property of the codec, and it decides whether the browser can play a soundtrack at all — the same shape as the markup table that moved to `TextSubtitleTrack` in 2.74.0.
146
+
147
+ ## 2.74.1
148
+
149
+ - **Chore**: The subtitle path chose a container twice, from two different kinds of evidence, and the two had to agree. Once by file extension, to decide which class to build; once by whether a track carried a sample list, to decide how to read its cues. Nothing enforced the agreement — a `.mkv` whose track somehow carried samples would have called a method Matroska does not have. There is one choice now and it is made from the BYTES: `ContainerFactory` sniffs the header, because that is what the muxer wrote while a name is what somebody typed, and only where the head is not downloaded — the cue walk asks the swarm for nothing — does the name answer instead. `Container.readHeldCues(plan, track, progress)` is then the one question: Matroska walks the clusters its Cues table names and fills every track from one walk, an MP4 reads the samples its own table states, and the caller chooses between them nowhere.
150
+ - **Chore**: A subtitle plan has one shape whichever container answers it. `Mp4Container` used to hand back its own raw table for the caller to reshape, inside the branch that has now gone; it returns the common shape itself, and says in its own words why `declared` is empty for an MP4 — the format has no element meaning "show this subtitle track by default", so nothing is shown unasked.
151
+ - **Chore**: `ContainerFactory.create` passes its parameters through whole, so a caller with a torrent's two readers gets a container that has both instead of one built with the fetching reader only.
152
+
153
+ ## 2.74.0
154
+
155
+ - **Chore**: Everything about subtitles is now in the classes that own it, and six loose modules are gone. `matroska-subtitles.js`, `matroska-blocks.js` and `mp4-subtitles.js` were a container's own specification written outside the container: the track table, the Cues, the blocks inside a cluster, the sample table. They are `MatroskaContainer` and `Mp4Container` now. `subtitle-markup.js` and the cue half of `subtitle-convert.js` were a codec's own rules written outside the track — which markup a codec carries, how to take it off, how to write the cues out as WebVTT — and are `TextSubtitleTrack`. Reading a subtitle FILE is `SubtitleFileContainer`, and reconciling ffmpeg's banner with a container's own table (`subtitle-defaults.js`) is `Container`. Folding them in exposed the duplication that having two homes had allowed: one Matroska helper and three MP4 box helpers existed twice over, with different parameter names and the same behaviour, and twenty-two element-id constants were declared in both places — checked pair by pair for disagreement before the second copy was dropped, and there was none.
156
+ - **Chore**: The cluster walk moved with them, and the design question that had held it back is answered. Two readers of one file want opposite policies — the track table fetches what is missing, the cue walk reads only what is already downloaded so that turning subtitles on pulls nothing — and one container held one reader. A container now takes both, plus a predicate saying whether a range can be read without fetching, so one instance per file serves both and the per-file cache is kept. The container is NOT given the torrent: those three functions are the whole of what the caller knows and the container does not, and reducing it to them is what let the reading move to where the format is specified. `torrent-worker/subtitle-cues.js` supplies them and keeps what is its own — the found-order cursor, the per-file state, one walk at a time.
157
+ - **Chore**: The walk had no check of its own, which is why this began with writing one. `test/subtitle-cue-walk.test.js` builds a Matroska file with two text tracks and two clusters carrying real blocks, and states exactly what comes back: each cue's text and times, the found-order cursor, that an undownloaded cluster is left alone, that one walk fills every track, and that a torrent which cannot say what it holds is refused rather than answered emptily. It passed on the unmodified code first and is unchanged by the move — which is the only reason the move can be called safe. It also caught an error in its own fixture: Matroska writes eight fields before an ASS cue's text and the fixture had seven.
158
+
159
+ - **Fix**: The verdict that declares a wedged connection now counts the far end's own event-loop delay, which the browser has been reporting all along and nothing read. Field 2026-09-03, session `03f211b8`: the viewer paused at 15:24:21 with 121.5 s buffered, the tab went hidden at 15:24:35, and its loop fell behind — `loopLag` 681 → 1881 → 4297 → 5957 ms. At 15:26:05 the probes read `gap 12 of 11`, printed `association-stopped` and then `reverse-direction-gone`, kept four ring files (61 MB) and took a 180 s capture; seven seconds later the same connection read `flowing`. Nothing had stopped — a browser whose timers are frozen cannot answer a probe. The delay is a wait the answer has to fit inside, so it is a term in the allowance and in the bound on a stale echo, alongside the queue's drain time, the round trip and the peer's own echo cadence. It is measured on the peer and arrives on the direction that survives a freeze, so it is known BEFORE the silence — unlike the cadence, which can only grow after a late echo has already landed, and so was always too late for the first one. The line prints `peerLoopLag=` and `peerTab=` beside the verdict, because an allowance nobody can check is not arithmetic. Four false verdicts on 2026-09-03 alone, each costing kept evidence and a capture that the evidence for a real wedge has to fit beside.
160
+ - **New**: A forced subtitle track in an MP4 is read as forced. Apple's QuickTime File Format defines two bits of the subtitle sample entry's `displayFlags`: `0x40000000` "Some samples are forced" — at least one cue carries a forced (`frcd`) atom — and `0x80000000` "All samples are forced", which the handler treats as forced whatever the cues carry, and which requires the first to be set with it (`0xC0000000`). Either bit alone now marks the track forced: a file that sets only the second is saying what a well-formed one says twice. The Matroska side has had this since 2.57.1; MP4 had `isForced: false` written in.
161
+ - **New**: One statement of what a session has produced (`services/produced-index.js`). Three places asked and each answered for itself — the look-ahead counted numbers, the serving path looked for a file, the header derivation listed names — and on 2026-09-03 two of them held opposite beliefs about one file for ten minutes, which is what stopped playback for ever in 2.72.0. The definition is now in one place and they all ask it. It is also what those three cost: every one of them walked every run directory on the thread that carries the data channel, 1350 files for a 90-minute film, on every segment request. A directory's modification time changes when an entry is added or removed, so the index asks THAT of each run and re-reads only what moved — a quiet request costs one `stat` per run instead of a listing plus a `stat` per file, and the class counts its own listings so the claim can be checked rather than believed. Newest run still wins, and that rule is applied when the question is asked rather than when a directory is read, so re-reading an older run cannot overwrite a newer one's answer. Eight checks in `test/produced-index.test.js`, each built on real directories and real files.
162
+
163
+ ## 2.73.1
164
+
165
+ - **Fix**: The catch-up pull for embedded subtitles is answered by the thread that owns the torrent, so a viewer no longer loses the beginning of an episode. Field 2026-09-03 on `[HorribleSubs] Drifters - 04 [1080p].mkv`: the file had been downloaded in an earlier sitting, so the worker's cluster walk found cues 1.5 s after it was opened and pushed four batches — cursor 1 to 31, everything up to 81.7 s — before the browser had subscribed. The browser then did the pull that exists for exactly that case and got a seven-byte `WEBVTT` with `x-subtitle-covered-clusters: 0` against 283 indexed; it held 330 cues spanning 81.7-1310.2 s, which is precisely the first push it saw. The parsing was never at fault — the same file put through this proxy's own reader produces clean English from 5.4 s onward — the pull was simply running on the MAIN thread, where the torrent is a stand-in with no `bitfield` and no `pieceLength`, so every range read as "not downloaded" and nothing was walked. It goes to the worker now, over the command that already existed and had no caller. That also settles a second thing: the found-order cursor the browser follows comes from ONE register, so a cursor from a pull and a cursor from a push are comparable, which two separate walks could not guarantee.
166
+ - **Fix**: A walk asked of a torrent that cannot say which pieces it holds says so in the log instead of returning an empty document. Emptiness is the right answer for a file with no cues yet, and that is how the defect above stayed invisible for a whole session.
167
+
168
+ ## 2.73.0
169
+
170
+ - **New**: `Container.readMediaInfo()` — what a file declares about ITSELF: its format, its duration, and where its own timeline begins. Matroska reads Duration and TimestampScale from `Info` and takes the start from the first Cluster's timestamp (RFC 9559 states no start-time element, so that IS the start); MP4 reads `mvhd` and the empty edit of `elst` (ISO/IEC 14496-12 §8.6.6); AVI multiplies microseconds per frame by the frame count. `null` means the container does not declare the field, which is a final answer rather than "ask someone else".
171
+ - **Fix**: Session creation no longer spends eight seconds learning where a soundtrack shipped as its own file begins. It spawned an ffmpeg against this proxy's own `/stream` and waited out the probe's whole 8 s budget — measured in the field on 2026-09-03 at 8078 ms, 8112 ms and 8121 ms, three cold starts out of three. The container layer had read the same header of the same file **in 8 ms, in the same second**: two readers of one header, a thousandfold apart. Worse, the eight seconds bought nothing — the probe's early exit is gated on a parsed DURATION, and a partially downloaded file prints `Duration: N/A` with the start time on that very line, so the answer was in hand from the first second. The value now comes from the container reading, and creating a session does not wait for it at all: what is known is used, the reading runs behind, and the encode run takes the freshest value at spawn — the same shape `session.keyframeTimes` already had. Where the file has not said, the two timelines are taken to AGREE rather than assumed to differ, because asserting an offset nobody has read is inventing a number.
172
+ - **Chore**: Checked against ffmpeg by hand rather than assumed. A Matroska file offset by 0.130435 s: ffmpeg reported `Duration: 00:00:02.13, start: 0.130000`, this reader `durationSeconds 2.131, startTimeSeconds 0.13`. The same content as MP4: `start: 0.000000` against 0. Six checks in `test/container-media-info.test.js` build their fixtures byte by byte instead of running an encoder, so the suite measures the parsing and not the machine.
173
+ - **New**: `/progress` carries `inputBytes` — the bytes the swarm has delivered to THIS session's own input read. It is the only sign of life a session can give before its first frame exists, because `processedSeconds` cannot move until the decoder has one. Field 2026-09-03: a cold retry blocked 46.3 s on a single piece with `processedSeconds` frozen at the start position throughout, and the browser declared the proxy dead 0.4 s before the piece landed. Counted per SESSION and never per torrent — in that same episode the torrent received 55.9 MB while the picture's own reads received 4.5 MB of it, so a torrent-wide figure would have called a starved session healthy.
174
+ - **New**: `POST /api/sources/:key/warm` accepts `positionSeconds` and fetches the region under it. The warm-up fetched a file's two EDGES, because that is what the codec probe reads; the region the viewer will actually resume at was asked for by nobody until the encoder opened its input — 53 s after the Retry button in the field case, and it then took another 46 s. Where that region lies is worked out from the file's own length and duration, which is the first thing `readMediaInfo` is used for beyond its own purpose. Started after the edges and never awaited by them: the edges gate the playback plan.
175
+ - **Fix**: A blocked reader's tail is duplicated on every attempt, not only on the attempts where no ordinary request could be placed. A read ends when its LAST block arrives, and that block is reserved to one wire whether or not other blocks are still free to ask for. Field 2026-09-03: 46.3 s on one piece, ordinary requests placed on 54 of 87 attempts, a tail of 3 blocks of 512 held by wires at 51-99 KB/s to the very end, and duplication — which ran only on the attempts that placed nothing — managed 5 blocks in the whole wait. Bounded by the tail's own length: 16 blocks (256 KB) against a 4-16 MB piece, above which the piece is still arriving normally and a second copy would spend the shared link on bytes already on their way.
176
+ - **Fix**: One wire refusing a duplicate no longer ends the pass. Pipelines are per wire; stopping at the first refusal rested on the stated reasoning that the remaining wires were "no emptier", which is an assumption about other peers' queues that nothing here measures.
177
+ - **Chore**: `docs/container-architecture.md` states the boundary outright — a fact the container declares is read from the container, a fact only the media has is measured from the media — with the three jobs that stay ffmpeg's and the measurements behind each. It also records why byte access does not belong on a track, and names the one thing that is genuinely split (a subtitle track carries byte positions while the reading of them lives in the worker) together with the obstacle to closing it: the track table fetches what is missing while the cue walk deliberately reads only what is downloaded, and one container instance holds one reader.
178
+
179
+ ## 2.72.2
180
+
181
+ - **Fix**: A claim for room in the piece store could never end. Two waits inside one function shared a single field for when the wait began — the wait for the disk, and the wait for a piece that may be evicted — and each of them zeroed that field on giving up, which restarted the other's clock. Measured 2026-09-03: the claim cycled for ever, five seconds per side, `grewWaitingForDisk` and `waitedForPins` each climbing once every five seconds while `blockedByPins` stayed at 0, so the refusal the store is supposed to end with was unreachable. A claim's patience is now held by the claim itself and not by the store, which is also correct with several claimants: one caller giving up used to reset the wait of every other. A claim that cannot be met now fails after 5 s — measured 5025 ms — with `Every resident piece is pinned and nothing moved`.
182
+ - **Chore**: The whole suite can finish again. `test/piece-store-reservations.test.js` did not fail on the defect above, it HUNG, so `node --test` never completed at all and no release since 2.72.0 had been covered by a full run. The three checks that drive a held disk write now carry a 30 s bound, so a return of this defect is a failure in seconds rather than a stoppage.
183
+ - **Chore**: Two further checks in that file were failing and nobody could see it, because the file stopped before reaching them. Measured on the unmodified code, both fail there identically, so neither is from this release. One asked whether the claim was waiting by reading `waitedForPins` alone, while the store was demonstrably waiting for the disk and counting `waitedForDisk`; it now accepts either. The other gave the store three blocks and then required a piece to be admitted while a block was in flight, which is a full store by the store's own accounting — it has four now, so what the check measures is what it was written for: a completing spill must not put the stale copy back on disk.
184
+
185
+ ## 2.72.1
186
+
187
+ - **Fix**: Subtitles on an embedded ASS track were shown to the viewer as the whole dialogue row — `21,0,Default,,0000,0000,0000,,I am the powerful Demon King of the Sixth Heaven.` — because one function both unwrapped the container's framing and stripped the codec's markup, and decided which framing it held by counting commas. Matroska writes eight fields before the text and takes the two timing fields out into the block's own timestamp (`matroska.org/technical/subtitles.html`); a row in a `.ass` FILE has nine and states its own column order in `[Events]`. The function expected the file's shape, found nine fields where it wanted more than nine, and returned the row untouched. The test that covered this passed because its fixture carried the file's two timestamps — a shape that never occurs on that path.
188
+ - **New**: `Container.cueTextOf` — each container answers for its own framing, because how a cue's bytes are wrapped is stated by the container's specification and not by the subtitle format's. `MatroskaContainer` carries the eight-field rule with the quotation it comes from, `Mp4Container` the length-prefixed sample, and `AviContainer` throws rather than guessing at somebody else's framing. The unwrapping now happens where the cue is READ, which is the only place the container is known.
189
+ - **New**: `SubtitleFileContainer` — a file of subtitles is a container of its own. It reads the `Format:` line of `[Events]` for ASS, which is where a FILE states its field order, and SubRip's positional form; `.vtt` is passed through unparsed, since taking it apart to write it back would drop its styles, regions and cue identifiers for nothing.
190
+ - **New**: `services/tracks/subtitle-markup.js` — the other axis: `{\pos(…)}`, `\N`, `\n`, `\h`, the same wherever ASS is stored. One table for every name a codec has — Matroska CodecID, MP4 sample entry, file extension — because the answer for all of them is the same. `\h` reached the viewer as a backslash and an h on embedded tracks until now; the file path had always handled it.
191
+ - **Chore**: `finalizeCues` and the WebVTT writing move to `subtitle-convert.js` and exist once. The writer was duplicated between the controller and the file conversion, so a pushed cue and a pulled one were formatted by two different pieces of code.
192
+ - **Chore**: `test/subtitle-cue-framing.test.js`, 17 checks across both axes; three of them fail on the old code, including the field case itself and "one line of dialogue, two framings, one result". Measurements and the spec quotations: `research/subtitle-cue-framing-2026-09-03.md`.
193
+
194
+ - **Fix**: A file's NAME was being read as proof that a segment exists. The `segment` muxer creates its output the moment it OPENS it, so a run killed for a seek leaves a file of zero bytes behind whose name is indistinguishable from a finished piece's — measured 2026-09-03: ffmpeg exited 19 ms after SIGTERM leaving `segment-00025.mp4` empty, and that empty file closed the only hole in the numbering. The two sides of the session then deadlocked on it without either being able to see the other's reason: the look-ahead read `420s ahead of the viewer` and kept the encoder stopped because the segment was on disk, while the serving path refused the very same file for carrying no track. Both sides now ask `usableSegmentIndices`, where a number counts only when some run holds a NON-EMPTY copy of it — which is exactly the condition under which the serving path can answer.
195
+ - **Fix**: The piece a run had open when it ended is removed once that run's process is gone, and only when it is unusable — a run stopped between two cuts leaves a finished file, and deleting good output would mean encoding it a second time. The current run's own unfinished piece is waited for, never deleted.
196
+ - **Fix**: A segment is served from the newest copy that carries every track, across every run the session has had, instead of from whichever run wrote the name last. Where nothing can judge the copies — before the session has an init segment there is nothing to compare a piece against — the newest copy with bytes in it stands, and a piece that no run holds servably is still handed back so the readiness path can say WHY rather than answering "not produced".
197
+ - **Fix**: Two more places asked a file's name where they meant its contents. That a segment is finished is proved by the NEXT one existing, and the proof was taken from whichever run happened to hold that number — a run that has ended closed everything it wrote and needs no such proof, so the evidence now has to come from the same run. And the session's header is derived from the first piece that has bytes in it rather than the first name, since an empty file skips a number whose header is sitting in the run before it.
198
+ - **Chore**: Segment sizes are asked of the filesystem once per file. A piece that has bytes never loses them and a run rewriting a number writes into its own directory, so without that memory every request walked every segment of every run — 1350 of them for a 90-minute film — on the thread that also carries the data channel.
199
+ - **Chore**: `test/produced-copy-choice.test.js`, 9 checks: an empty file is not counted by the look-ahead, a killed run leaves nothing behind, a run that finished its last piece keeps it, a last piece short of a track goes even though it has bytes, and the sizes are read once.
200
+
201
+ ## 2.72.0
202
+
203
+ - **Fix**: The proxy was killed by the machine's out-of-memory killer at 4.37 GB, twenty minutes after 2.71.0 went out, and the cause was 2.71.0's own budget. A piece being written out to disk leaves the store's count of what it holds the moment the eviction begins, while its memory stays held until the write — which reads from that very block — has finished. The allowance counted resident pieces, so a block held by a pending write was counted nowhere: every admission turned one resident block into one held by the disk and took a fresh block for the arrival, and memory in use rose by one block per admission for as long as the disk was behind. It was behind by a factor of two: 233 evictions started against about 119 writes completed in the same minute. The store reported 203 blocks held with THREE pieces resident against 68 MB allowed. The allowance now bounds blocks in use — resident, reserved, and held by writes that have not finished — so a full store waits for the disk instead of evicting another piece, using the wait and the wake that were already there.
204
+ - **Fix**: And the reason there were so many evictions: 2.71.0 made the allowance equal to what the readers ask for, exactly. `6 reader(s) want 23 piece(s) of 23 the store may hold` — no free place ever exists, so every arriving piece must evict a wanted one. The allowance now includes room for what arrives while one write is finishing, measured from the store's own median write duration and its own arrival rate, and zero until both have been seen rather than invented in advance.
205
+ - **Chore**: `test/piece-store-slow-disk.test.js` drives a disk that answers only when the test says so, which is the field condition — writes slower than arrivals — and it fails without the fix. The check that shipped with the first attempt at this did not: it passed with the defect in place, which is worth recording, because a test that cannot fail proves nothing.
206
+
207
+ - **Fix**: A proxy reported how much memory it had free with `os.freemem()`, and on Linux that counts only the pages free at this instant — the kernel keeps that number low on purpose and fills the rest with cache, which it hands back the moment anything asks. A host with 4 GB of cache and 200 MB genuinely free called itself nearly full while it had 4.2 GB to give. That figure weighs 0.4 of every proxy's score, so every Linux proxy in the pool understated itself, each by a different amount according to how much cache it happened to hold. It reads `MemAvailable` now — the same fix the piece store's budget got on 2026-08-27, which had stayed in this file until today.
208
+ - **New**: A proxy can answer whether it could sustain a file it is only told ABOUT. The expensive half of that question is finding out what the file IS — add the torrent, wait for metadata, fetch the header, run ffmpeg — and it has already been paid by whichever proxy probed it. Its answer is a handful of numbers; every other proxy answers by arithmetic against its own startup benchmarks in milliseconds, without adding the torrent or fetching a byte. Asked over the tunnel as `can-serve-request`.
209
+ - **New**: The refusal added in 2.71.1 now carries that description, so a viewer whose proxy cannot keep up is moved to one that can instead of being shown an error. A viewer is given a proxy BEFORE the file is known, by a score that reads processor load, free memory and round-trip time — none of which can answer a question about a particular source — and this is where that ordering is repaired, after the fact and only when it went wrong.
210
+
211
+ ## 2.71.1
212
+
213
+ - **Fix**: A reader stated the same thing twice — `protectRange` to the piece store for memory, and a window to the torrent for download — and the two were separate lists that could drift. There is one statement now: `SwarmSelection.reconcile` derives both views from the register, so the swarm and the store are told what to do from the same words. Only the urgent levels reach memory: it holds what will be READ soon, and protecting the speculative tail would push out a piece the decoder is about to want.
214
+ - **New**: A file this machine cannot sustain at ANY height is refused rather than served badly. Both offered lists empty means not even copying the picture — which costs no encoder at all — can keep up, so a session made there produces a slideshow and takes the swarm and the processor from whoever is already watching. Field 2026-08-28: five sessions on one file put every rung at 0.04x of realtime. The plan now carries `cannotServe` with the reason, which is a different thing from a spinner that never ends.
215
+ - **Chore**: `SwarmSelection` takes its store lookup as a parameter, so the memory projection is driven by a test without constructing a real piece store.
216
+
217
+ ## 2.71.0
218
+
219
+ - **Fix**: The torrent thread's log lines never reached the log file. A worker thread loads its own instance of every module, so the logger's file handle — set once, on the main thread — was null there for the life of the process. Measured over a whole 49 938-line file: zero lines from the piece reader and zero from the torrent pool, against 52 and 36 of them in the container's output, which every release destroys. That is why the comparison of the two claim strategies could never be read: it was being printed into a place we wipe ourselves. The worker now sends its lines to the main thread, which is the only writer — two threads appending to one file would race on the rotation and could interleave mid-line.
220
+ - **New**: `services/demand/` — what anybody wants, stated once and in BYTES. `Window` (claimant, file, byte range, urgency), `Urgency`, `DemandRegister` (live windows by claimant), and `pieces.js`, the one place bytes become piece numbers. Nothing in it knows about WebTorrent, the piece store or pieces. The rounding it removes is where a real failure lived: with 16 MiB pieces a 64 MB allowance is four places while two readers asking for 96 MB each want six, and both figures had already been floored before anything could notice.
221
+ - **New**: `services/download/SwarmSelection.js` is the only thing in the proxy that calls `select`, `deselect` or `critical`. There were four callers before and their comments record them fighting: a whole-file read undid a seek that had just happened and the swarm walked forward from the first hole — on a 4.7 GB film, 2.47 GB over 93 s before the segment could be served.
222
+ - **Fix**: Urgency is no longer a number handed to the library, because the library does not keep it. Measured against the vendored 2.8.5: selections are sorted by priority only when one is inserted, and `shufflePriority` then moves the selection just served to the back of the whole non-zero group — so distinct numbers give an order once and a round robin thereafter. Five levels are kept here instead, and a level is stated only while every level above it is satisfied. The library is given the one distinction it honours: non-zero for what is wanted now, zero for the speculative tail.
223
+ - **Fix**: The speculative levels are withdrawn rather than lowered. A peer that cannot help with anything urgent falls through the selection list to whatever is below it, and with a permanently low priority would spend the shared link on pieces nobody is waiting for — about a second of its own throughput at a time. A withdrawn window is not in the download set at all. The condition is global across torrents: two films on one proxy share the link.
224
+ - **Fix**: Nothing is fetched until somebody states a need — the torrent is added with `deselect: true`. WebTorrent's own default is to select the whole torrent, and this proxy used to undo that afterwards by deselecting the files nobody had opened, so on a season pack every episode was fetched for as long as the viewer took to choose one.
225
+ - **Fix**: The background fill is stated per FILE, from the furthest window in that file to that file's end. It used to take the furthest window across all files and the last piece across all files and claim everything between: with two viewers on two episodes of one release, that claimed every episode lying between them.
226
+ - **Chore**: The two claim strategies are one. Each read was assigned at random to a single band or four, and the waits sorted by which; the split halved the sample and the ten-wait threshold was never reached in either arm — nine reads against three on 2026-08-28, and forty waits against one on 2026-08-29, the only day both arms printed. Waits are recorded by the LEVEL the reader was stopped in now, which says whether a band is too narrow rather than whether banding is the wrong idea. `TORRENT_TV_READ_MODE` is gone.
227
+ - **Chore**: `setActiveFile` deleted — no caller anywhere in the repository. `#reassertReaderWindows`, `#updateBackgroundFill`, `#tailAfterWindows` and `#syncSelections` deleted with the mechanisms they patched over; `claimWindow`, `releaseWindow`, `markCritical` and `clearCritical` deleted from the reader.
228
+ - **New**: `askFastestWiresFor` counts the requests refused because every block of the piece was already reserved and displacement did not happen. WebTorrent's displacement thresholds are constants, not settings — the asker must be above 16 KB/s, the holder below 48 KB/s and twice as slow — so a holder at 50 KB/s is never displaced however long the piece has been waited for. That counter is what would justify replacing the rule; a zero says the thresholds are not what we are short of.
229
+ - **Chore**: `docs/download-architecture.md`, in the shape of `docs/container-architecture.md`: the two axes, what is not a third, the layers as a diagram, and what was deleted with the reason. 19 checks in `test/demand-register.test.js` and `test/swarm-selection.test.js`.
230
+
231
+ ## 2.70.0
232
+
233
+ - **New**: The piece store keeps a pool of memory blocks instead of allocating one per piece. A block is one piece's worth of memory; it is taken from the free list, and put back there when its piece is written out. Field 2026-09-02: 7575 allocations of 4 MiB in 44 minutes, each released only when the collector reached it, which is why the process held 1.86 GB while the store's own accounting said 352 MB. A block goes back for re-use only AFTER the spill write has finished, because that write reads out of it; a block whose piece is re-put while a reader holds it is given up rather than recycled, and a counter says if one ever is not.
234
+ - **Fix**: The store's allowance could only ever fall. `#capacity` was computed once in the constructor and used as an upper bound on every revision, so a torrent opened while the machine was full kept a small allowance for its whole life however much memory was freed afterwards. The field is gone; the opening figure is now only where the store starts.
235
+ - **Fix**: `AVAILABLE_MEMORY_SHARE` (a quarter), `MIN_BUDGET_BYTES` (64 MB) and `MEMORY_BUDGET_CEILING_BYTES` (512 MB) are removed. All three trace to one observation of one host on 2026-08-03 and none was derived. The budget is now the smaller of what the readers have declared — the union of their windows, since picture and sound overlap — and what the machine allows, which is `MemAvailable` plus what the stores already hold, less what other processes have recently been seen to need. That last figure starts at zero and grows only on evidence: the fall in available memory between two readings, beyond what the stores themselves took.
236
+ - **Fix**: A piece no reader has declared, arriving at a store with no room, goes straight to disk instead of pushing out a piece a reader is about to read. It costs the same one write it would have cost when the next arrival evicted it. Before the first read nothing is declared and nothing is refused memory on a guess.
237
+ - **Fix**: Evicting a piece the disk already holds costs no second write. `#revive` reads a piece back and leaves the copy on disk, and only `put` removes it, so a piece revived and not re-put is identical to what is already written. There were 7575 revivals in that one session (roadmap item 66: 14.4 GB written in a single viewing).
238
+ - **Fix**: A store whose readers have GONE asks for nothing, where before it kept asking for what it held. Its torrent sits until the pool's idle timer removes it, and that timer needs a refcount of zero and can be a quarter of an hour away. A store that has never had a reader is a different case and keeps its opening share.
239
+ - **Fix**: A store is never cut below one reader's whole window, whatever the machine's share says. Obeying a smaller share would leave it unable to finish the read it is serving: every resident piece pinned, zero bytes returned, ffmpeg taking that for the end of the file — which killed every encoder on that file on 2026-08-15. The line says when the share was smaller than the window.
240
+ - **Chore**: What other processes need is a window of the last sixty observations, not a high-water. A single spike would otherwise squeeze the stores for the life of the process, which is the same mistake an all-time maximum makes of the block re-use gap.
241
+ - **New**: A spare block is given up once it has sat unused longer than the store's own working rhythm — the longest wait, over recent work, between a block falling free and being wanted again. Measured, not chosen: while a film is being watched a block is taken again within milliseconds, because one is taken for every piece that arrives.
242
+ - **New**: The store line says how many blocks of memory exist and how many are spare, how long a block waits before it is wanted again, how many were given back, how many pieces were admitted that were in nobody's window, and how many evictions needed no write. `committed` now means what the process holds — the blocks — rather than the pieces in them.
243
+ - **Chore**: Thirteen checks across `test/piece-lru.test.js`, `test/piece-store-eviction.test.js` and `test/memory-budget.test.js`, including that a re-used block never carries the previous piece's bytes into the next one.
244
+
245
+ ## 2.69.2
246
+
247
+ - **New**: The piece store says WHY it spills, which no reading has ever answered. A session on 2026-09-02 did 6565 spills and 7575 revivals in 44 minutes with only 53.6 % of reads served from memory, and nothing recorded whether that was an eviction order fighting the read order or a working set that simply does not fit. Three figures now settle it, on one line per store: what the live readers between them are asking to keep against what the store may hold; how many evictions had to take a piece a reader had declared it wants; and how long a revived piece had been on disk before it was wanted back.
248
+ - **Chore**: The demand is the UNION of the readers' windows, not their sum. Two readers of one file — picture and sound — overlap by construction, and summing them would report the store as short when it is not. A union wider than the capacity cannot be held however the eviction is ordered, which is the difference between a policy to fix and arithmetic to accept.
249
+ - **Chore**: `PieceLru.evictionChoice()` returns the victim with the two facts about it — whether protection had to yield, and how many pieces the victim lies from the nearest declared window, zero inside one and -1 when nothing is declared. `evictionCandidate()` is kept and delegates, so nothing else moved.
250
+ - **Chore**: A revived piece's age is kept as a bounded window of the last 200, because the figure wanted is a median rather than a history. The line also says how many of those came back within five seconds — a piece wanted again that soon should not have left. Seven checks in `test/piece-lru.test.js` and `test/piece-store-eviction.test.js`.
251
+
252
+ ## 2.69.1
253
+
254
+ - **Fix**: The change trigger on the torrent worker's memory line watched the one figure that does not move. A thread's watched quantity was `heapTotal`, and through the session of 2026-09-02 that stood at 31-173 MB while the same isolate's `arrayBuffers` swung between 130 and 950 MB — so nothing ever earned a line and every reading of the quantity that grew came out on the quiet minute. Each of `heapTotal`, `external` and `arrayBuffers` is now compared against its own last written value and any one of them moving writes the line. Nothing is summed: `arrayBuffers` is documented as part of `external` and reads larger than it here, and this code has no business resolving that.
255
+ - **Fix**: The process line was read once a minute, which cannot see what kills this process. Both out-of-memory kills of 2026-09-02 fell inside a single gap of that cadence — the last line before the first said `rss=602MB` and the kernel recorded `anon-rss:1722836kB` 34 seconds later; before the second it said `rss=1171MB` and the kernel recorded `anon-rss:1905024kB` nine seconds later. The figure is read every second now and written when `rss` has moved by 25 MB or when a quiet minute is up, so a rise of a gigabyte is a curve instead of one number and then a death.
256
+ - **Chore**: A quiet second costs one call to `process.memoryUsage()`. Every /proc read — `meminfo`, `smaps_rollup`, `smaps`, the free-space call — moved behind the decision to write, where only `smaps` already was. The `high rss … watch for OOM` warning moved with them, or at one reading a second it would have printed a line a second for as long as the process stayed large.
257
+ - **Chore**: The piece-buffer counters are on the memory line itself rather than on a timer of their own. Both sides read them at the same instant now — the worker's own buffers beside its `arrayBuffers`, and the ones handed to the main thread beside the process's `rss`. On separate timers they were up to a minute apart: 950 MB of `arrayBuffers` at 14:10:40 could only be checked against readings half a minute either side saying 14 and 130 buffers were alive, and the arithmetic did not close. `watchedFigures` is exported and pinned, with two checks in `test/memory-budget.test.js`.
258
+
259
+ ## 2.69.0
260
+
261
+ - **Fix**: The language detector no longer states an answer the text cannot support. Measured 2026-09-02 over Wikipedia extracts in seventeen languages, 120 random windows per language per length: franc's answer for Russian walks between Bulgarian, Serbian and Russian until about 650 characters, while Korean is settled by 40 and English by 130 — the figure is the language's own, because Russian competes with three neighbours for the same trigrams and Korean competes with nothing. The detector now asks franc for a candidate, refuses when the text is shorter than THAT candidate's measured figure, and refuses again when the answer does not survive losing either half of the text. The margin to the runner-up was tried first and rejected on measurement: it is 0.026 where the answer is wrong and 0.015 where it is right, so it separates nothing. `research/franc-boundary-2026-09-02.md`.
262
+ - **New**: The reading is re-taken as the film downloads. `warmSubtitleCues` re-runs detection over every cue held so far on each push and sends it as `detectedLanguage`; the browser moves the track's label onto it. This is what makes the refusal above cost the viewer nothing — a track opens as Unknown and becomes Russian a minute later, instead of opening as Bulgarian and staying wrong for the session. About 6 ms per push, measured; franc costs ~2 ms whatever the size.
263
+ - **Chore**: The stability test is worth its own line because it needs no table: across 38 400 trials franc alone was wrong 2197 times, and requiring the two halves to agree with the whole left 255 of those standing while keeping 81.8 % of the right answers.
264
+ - **Chore**: `test/worker-channel.test.js` waits for the chunk it is about instead of sleeping 50 ms and hoping. It has been on the flaky list since roadmap item 53, and this change is what pushed it over: the torrent worker gained one more module to load, and the chosen interval stopped being enough. A test that samples the clock does not test.
265
+
266
+ ## 2.68.1
267
+
268
+ - **Fix**: A Russian subtitle file was reported to the viewer as English, and the proxy was the one saying so. Field 2026-09-01: `Sub/[Stan WarHammer & Nesitach]/[HorribleSubs] Drifters - 03 [1080p].ass` came back with `X-Subtitle-Language: en` and a body whose first cue is `Ты видишь их?`. The cause is what the detector was FED, not the detector. An ASS file is markup by half — measured on that file, 5040 Latin characters of Aegisub headers, style and font names, `Format:`/`Dialogue:` field prefixes and `{\…}` override groups against 5983 Cyrillic characters of dialogue — and `franc` scores letter trigrams over the whole string it is handed: `franc(the file) = eng`, `franc(the dialogue) = rus`. The markup-free WebVTT was already built two lines above and the detection was given the raw file anyway.
269
+ - **Fix**: The same mistake at the two other places a language is read, both found by looking rather than reported. An embedded ASS track was detected on its RAW cue text — a cluster-held cue is the dialogue row without its `Dialogue:` header, so it carries nine comma-separated fields and the override groups, which are Latin on a Russian track; it now reads the cues through `finalizeCues`, the same conversion that produces what is served. And the ffmpeg extraction path detected on the first 4096 BYTES of its WebVTT output, where a byte cut lands mid-character on any non-Latin track and most of those bytes are timestamps; it now reads the whole document's cue text.
270
+ - **Chore**: `language-detect.js` gains `cueTextOfVtt` — the words of a WebVTT document, with the header, `NOTE`/`STYLE`/`REGION` blocks, cue identifiers, timing lines, inline tags and character references removed — and `detectLanguageFromVtt` over it. One rule does most of it: a block with no timing line is not a cue. Six checks in `test/subtitle-language.test.js`.
271
+ - **Chore**: The embedded path now detects on every cue held so far rather than on the `fresh` subset being sent, because a re-subscription after a reconnect asks for only what that page missed and a handful of lines is not a sample of a language. This matters more than it sounds: measured 2026-09-01, `franc`'s answer among the Cyrillic languages is unstable on a small sample — one Russian text grown line by line answered `bul` at 129 characters, `srp` at 158 and 241, `bul` again at 292, then `rus` at every length from 337 to 881. Every change here gives the detector more text rather than less; the instability itself is not fixed and is recorded as its own roadmap item.
272
+
273
+ ## 2.68.0
274
+
275
+ - **Fix**: The background fetch of a soundtrack now stands aside after a stall, not only during one. Pausing only while a reader is blocked is not enough on a swarm with no surplus: between stalls the fetch still takes bandwidth the picture needs, and the stalls themselves are the proof there was none to spare. Field 2026-08-31 — the swarm delivered 200-600 KB/s against the 399 KB/s the film eats, one piece waited 101 s after a seek, and the picture stood still 145.6 s before the player gave up. A chunk is fetched only when the stall count has not moved since the previous one, re-baselined after each quiet pause so it can resume; on a starving swarm it stops altogether, which is the right answer.
276
+ - **Chore**: `piece-reader.js` counts the stalls a torrent's readers have had, so work of lower importance can ask "did the viewer stall while I was busy?" — a stricter and more useful question than "is the viewer stalled right now".
277
+
278
+ ## 2.67.0
279
+
280
+ - **New**: A soundtrack or subtitle file shipped beside the picture is fetched WHOLE as soon as the viewer opens the episode, using only the room their own reading leaves. The ordering is the design: what plays now comes first — the picture at the playhead, the soundtrack being heard, the subtitles being shown — the other tracks next, and reading the film far ahead last. The middle tier stays below the first by a condition that is measured rather than chosen: it fetches only while NO reader on the torrent is inside a wait, re-asked before every piece, because a torrent that was healthy a moment ago is not evidence about the next second. A soundtrack is about a twentieth of the picture, and having it on disk is what turns a later switch into a local read: in the field on 2026-08-31 the first piece of one took 27.7 s to arrive, which is longer than a switch is willing to wait.
281
+ - **Chore**: That fetch is a bounded READ walking the file a piece at a time, deliberately not `file.select()` — selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against, and it cost a seek 93 s. The cushion-triggered fetch added in 2.66.0 now goes through the same guarded walk instead of a whole-file read of its own, so two triggers cannot put two readers on one soundtrack — only one of which would have stood aside for the picture.
282
+ - **Fix**: The speed a quality step had to reach to survive the swarm was computed as thousands, and every step was refused against it. Field 2026-08-31: `a step must run at 4422.00x (worst wait 13.26s, one every 0.00s, 2 measured)`. Two faults in one calculation, both now removed. Several readers walk one file — the picture and each audio rendition — so a piece that has not arrived blocks all of them and their waits end within milliseconds of each other; counted as separate interruptions they gave an interval of 0.00 s. Waits are merged into the stretches during which the supply was actually not delivering, however many readers noticed. And the interval is now measured from the end of one stall to the START of the next, which is when the encoder is running and building cushion — the previous end-to-end spacing counted a stall's own duration as part of the recovery from it, and so set the bar too low, in the same direction as the hand-chosen 1.5 it had replaced.
283
+ - **Chore**: The `supply` line says how many STALLS a figure rests on and how many waits they came from, because `2 measured` reading as two interruptions 3 ms apart is what made the 4422x line look better evidenced than it was.
284
+
285
+
286
+ ## 2.66.1
287
+
288
+ - **Fix**: When a re-encode cannot keep up and nothing LOWER is on offer, the budget asks for the COPY instead of leaving the viewer where they are. "Cheaper" in that rule meant "fewer pixels", and it had no way to express that a copied rung costs no encoder at all, whatever its size — so it looked down, found every rung below refused, and gave up. Field 2026-08-31 and it cost the whole film: a 444x240 ultrafast encode ran at 0.43-0.94x for fifty minutes while the source's own 1038p sat on offer beside it, copied and free; the line `nothing lower is on offer; leaving the picture alone` printed fifty times and the picture stood still 161 times for 940 seconds. On a copied source the way out is up, and it is both the fastest thing the host can serve and the best picture it has (`research/session-2026-08-31-seeks-and-the-copy.md`).
289
+
290
+ ## 2.66.0
291
+
292
+ - **New**: A soundtrack that ships beside the picture is fetched WHOLE once the swarm has capacity to spare, so a later switch to it does not wait. It is a twentieth of the picture — 30 MB against 566 MB on the field torrent — and having it on disk is the difference between an instant switch and one that pays for its own first pieces. The moment it starts is not a guess about the swarm: it is when the encoder is already as far ahead of the viewer as `--lookahead` lets it get, which is the cushion figure the session already measures and prints. Once per file, and only for a soundtrack in a file of its own.
293
+ - **Chore**: That fetch is a bounded READ of the file's length, deliberately not `file.select()`. Selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against — a claim covering everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film waited 93 s while the swarm fetched 2.47 GB in file order. The comment there records it; this goes through the same path the edge warm-up uses.
294
+
295
+ ## 2.65.0
296
+
297
+ - **New**: A soundtrack that ships as its own file beside the picture is offered like any other. Releases commonly put a dub in `Rus Sound/<name>.mka` and subtitles in `Sub/[team]/<name>.ass`; until now the video played with its original sound and nothing said the rest of the release existed. The pairing is by name — equal base names, or a shared release hash — and a torrent holding exactly one picture takes every sidecar beside it, since there is nothing else they could belong to. A torrent with several episodes never relaxes that: a wrong pairing would put the sound of one episode over the picture of another, and nothing downstream could notice.
298
+ - **New**: No new container class was needed for it, and that is the point. A `.mka` IS Matroska: `ContainerFactory` sniffs it, `MatroskaContainer` reads its `TrackEntry` list, and out comes an `AudioTrack` with its language, title, channel count and flags — the same reader that serves the picture. "External" is not a kind of track; it is the answer to WHERE a track's bytes are, which is torrent knowledge and now lives in `services/sidecar-files.js` (which file goes with which) and `services/audio-inventory.js` (one numbered list), both pure and both application-layer. `tracks/ExternalSubtitleFile.js` asserted the opposite — it did not extend `ContainerTrack`, duplicated four of its fields and was imported by nothing — and is deleted rather than extended.
299
+ - **New**: The audio menu is built from the container's own track table where one can be read, with ffmpeg's `-i` banner as the fallback. `FlagOriginal` (0x55AE), `FlagCommentary` (0x55AF), `FlagVisualImpaired` (0x55AC), `FlagEnabled` (0xB9) and `LanguageBCP47` appear nowhere in that banner, so a director's commentary and the film itself were indistinguishable in the menu — roadmap item 58, points 1-3. The two readings are lined up by position and the pairing is CHECKED, exactly as `subtitle-defaults.js` checks its own: a differing count, or one pair agreeing on neither language nor title, drops the container reading whole rather than attributing a flag to the wrong track. `0:a:N` is what the encoder is handed, so a wrong flag is worse than a missing one.
300
+ - **New**: One flat number spans the picture's own tracks and the files beside it, and one place resolves it. The browser's menu, the `audioTrackIndex` of a session request and the `a/<n>/` address of a rendition all mean the same number; `#resolveAudioSource` turns it into a file index and a `0:a:N` inside that file. Embedded tracks keep the numbers they have always had, so a session created against a plan cached by an older build still means what it meant.
301
+ - **New**: A sidecar soundtrack chosen by a browser that understands rendition groups costs no second input at all — the rendition reads that file as its only input, and the player joins picture and sound, which is what a rendition group is for. A browser without them gets the two muxed by ffmpeg from two inputs, with the seek applied to each and `-itsoffset` stating the soundtrack's timestamps on the picture's timeline. Both files begin at their own container start time and those need not be equal; the difference is measured from the file rather than assumed to be zero, which is what keeps the sound from sitting at a fixed offset for the whole film.
302
+ - **Fix**: A session now holds a claim on every file it reads. A rendition reading a sidecar claimed only the picture, and the disk sweep deletes what nobody holds — the failure that deleted a film being watched on 2026-08-06, one file over.
303
+ - **Fix**: `declaredTracks` no longer answers from the picture's probe alone. A release that ships its dub separately often ships the picture with no sound at all, so the probe said there was no audio while the output plainly carried some — leaving the header check expecting one track where two arrive, and telling the browser its sound had been lost.
304
+ - **New**: `Command.CONTAINER_TRACKS` reads any file's own track table on the thread that owns the torrent, fetching its head first — a sidecar has usually had nothing downloaded when it is first asked about. The picture's tracks come back the same way, so there is one command rather than two.
305
+ - **New**: The warm-up that runs when a viewer picks a file now warms what lies beside it, for the same reason it warms the picture: none of it depends on the viewer, and every second of it spent now is a second they do not spend waiting later. How much of each follows from what the file IS, not from a size anyone chose — a text subtitle file is smaller than one piece of the torrent, so its edges and the whole of it are the same pieces and all of it is fetched; a soundtrack is tens of megabytes and only its header is needed to name it, so it gets head and tail like the picture. Without this the first thing to ask for a dub's header was the playback plan, on the path to the first frame.
306
+ - **Fix**: The plan waits at most 3 s for a sidecar's header instead of up to two minutes. Reading one goes through the worker, which prefetches the file's edges (60 s) and then reads them (60 s), and neither was bounded from the plan's side — the route's own `maxWaitMs` covers the codec probe loop and nothing after it. A sidecar is a file of the torrent nobody has asked for yet, and on this torrent 256 KB of header means one whole 8 MiB piece. What the timeout costs is small and deliberate: the track is still offered, still numbered and still playable, only without the language and flags its header would have given — and the language the viewer sees comes from the folder the release put it in, which is in the file list and costs no bytes at all.
307
+ - **Fix**: The media-info probe no longer spends its whole 8 s timeout on a file with no picture. It bails early when it has both a duration and a video stream line; a soundtrack file never prints the second, so it waited out the timeout every time — on the path where a viewer is changing track and the browser refuses a switch that is not ready in time. `Duration:` carries the start time being asked for, and it is the last thing such a file has to say about itself.
308
+ - **Fix**: A soundtrack session sizes its read window from the file it READS. Sized from the picture, whose byte rate is twenty times higher over the same duration, the window bought twenty times the seconds it was meant to represent and the piece store held it.
309
+
310
+ ## 2.64.9
311
+
312
+ - **New**: The memory line says the SHAPE of the anonymous memory, not only its size — `mappings=[heap 40MB, 47 anon >=2MB = 388MB (largest 64MB), 812 anon <2MB = 231MB, files 31MB]`, read per mapping from `/proc/self/smaps`. The 700 MB that grew in the field on 2026-08-31 is none of the JavaScript heaps, none of the piece store and none of ffmpeg, and the three shapes it could have are three different diagnoses: one growing `[heap]` is the allocator keeping what we free, with no `malloc_trim` on musl to ask for it back; many large anonymous mappings are piece buffers, and whether their count tracks what the store says it holds decides whether anything is retaining them; many medium ones are the allocator's own arenas. Mappings with no resident pages are left out — those are V8's address-space reservations, tens of gigabytes of them, and counting them would drown the reading.
313
+ - **New**: Both threads count the piece buffers they have let go of against what the collector has actually taken back. Shared memory belongs to neither isolate and lives until BOTH have released it, so one side answers only half the question: the worker reports its own (`piece buffers: N let go, M collected`) and the main thread reports the handles it was passed. A gap that widens means a reference of ours outlives the piece, and then a heap snapshot can name the holder; a gap that does not widen means whatever grows is below us. This is the reading that separates the only two explanations left for roadmap item 2, and neither could be told from the other before.
314
+
315
+ ## 2.64.8
316
+
317
+ - **New**: A backward restart says what it costs. Nothing already written is lost — every run keeps its own directory and the session serves the union of all of them — so the price of moving the encoder back is not the files; it is work about to be done twice, because the new run walks forward through segments the old one had already finished and ffmpeg cannot know they exist, and it is the viewer in front, who has nothing produced ahead of them until the run gets back to where it already was. Neither had ever been counted. The line now says how far back it went, how many of the segments it will walk through are already on disk, and the running totals for the session. This is the reading roadmap item 64 needs before a session is allowed more than one concurrent run: if a viewer is behind the head twice a week, that design does not earn its complexity.
318
+
319
+ ## 2.64.7
320
+
321
+ - **Fix**: A file opened at a position starts its SOUND at that position. The audio rendition's start is worked out from the picture's read head less the buffer the viewer reports holding — sound, because a read head is the furthest request of any viewer and the picture sits behind it by however deep that buffer is. At a cold open there is no report yet, and the fallback subtracted the WHOLE 120 s look-ahead from a buffer that does not exist: field 2026-08-31, a page opened at 588 s started its sound at 460 s, 131 seconds of film nobody would hear, and the segment the viewer needed took 38.8 s to appear against the picture's 8.4 s — the audio encoder healthy at 2.3-3.1x throughout, simply given a running start it did not need. The reading now says WHICH of its three sources answered (`viewerPositionSource`): a seek and a served segment are request edges and keep the subtraction, while the opening position is not an edge — nothing has been asked for since the session was made, and a browser that has just opened holds nothing by construction (`research/cold-open-audio-start-2026-08-31.md`).
322
+ - **Fix**: The first encode run is positioned from the position the viewer ASKED for, not from the figure rounded to ten seconds. That rounding exists to answer one question — whether two viewers share a session — and `Math.round` can move a position FORWARD: 588 s became 590 s, which falls in segment #85 while the viewer at 588 s is inside #84. The player asked for a segment behind the run, the run was restarted onto it, and the 4.5 s it had already produced were thrown away.
323
+
324
+ ## 2.64.6
325
+
326
+ - **New**: The torrent worker reads its own memory once a SECOND, and writes a line only when something moved. A minute cannot see what kills it: three times — 2026-08-30 14:00 and 23:19, 2026-08-31 13:27 — the worker's own line read `heap=28-36MB`, and by the sample after next the thread had been terminated for reaching its heap ceiling, with the whole rise fitting inside a single sixty-second gap. The reading and the line are now separate cadences: taken every second, written when the heap has moved by 25 MB or when a quiet minute is up, so a healthy session costs the same one line a minute it costs today and a runaway is a curve rather than a step.
327
+ - **New**: A heap snapshot of the WORKER's own isolate, on every new high-water above 400 MB, into the state directory, three kept. Every snapshot ever written by this proxy has been of the MAIN isolate — `v8.writeHeapSnapshot` snapshots the thread that calls it, and the main thread's heap is 26 MB while the one that dies climbs to 2240 MB. So the isolate that has died three times has never once been looked at, and the question "what is holding this memory" has had no way to be answered. Deliberately NOT taken as the ceiling is approached: a snapshot is about the size of the heap it is of, so asking for one at 1.9 GB on a machine with 600 MB left is a way to cause the kill being studied — and at one reading a second no 400 MB step is ever missed. `stateDir` now travels to the worker for this, because a worker cannot change the process's working directory and had no way to choose where a file lands.
328
+ - **New**: The memory line says the heap ceiling beside the heap — `heap=1800MB/1904MB of 2240MB allowed`. That ceiling is what the runtime terminates a thread for reaching, it is inherited from the main isolate by a worker created without `resourceLimits`, and without it the log said "30MB" with no way to tell how far that was from the end.
329
+
330
+ - **Fix**: Room for a piece in the store is an owned reservation, released in a `finally`, instead of a number one function increments and another decrements. Every consequence of the old pairing was a defect, and all of them are gone with it: a failure between `#claimSlot` and `#registerPiece` — a disk read that throws — lost a slot for the life of the process, because there was no `try/finally` anywhere on that path; `put`'s error path tried to correct that by guessing, and its own comments said so ("Heuristic:", "We conservatively decrement"), so it could take back a reservation belonging to a different claim in flight and let the store admit past its allowance until the next minute's revision evicted the excess. `stats()` now reports `outstanding`, and the periodic `piece-store` line prints it when it is not zero: a reservation that never comes back is invisible until the store cannot admit anything, and by then the reason is long gone.
331
+ - **Fix**: A claim waits on PROGRESS, not on activity. The five-second "every resident piece is pinned" error was skipped entirely while a spill was in flight or any reservation was held, so ONE lost reservation from the defect above made that error permanently unreachable and a read retried every 50 ms for the rest of the process's life — never completing, never failing, and never saying anything. The clock now restarts when something actually moves (a piece admitted, a spill finished, a pin released) and the error is raised when nothing has moved for five seconds, whatever is nominally in flight.
332
+ - **Fix**: The wait between attempts allocates once instead of once per pending spill. It attached a fresh pair of handlers to EVERY spill in flight on EVERY attempt and left an uncancelled timer behind each time, so a claim that could not be satisfied allocated in proportion to attempts times spills. One idempotent handler now, its timer cleared when it is woken, and a settling spill wakes the store itself — which is where that belonged, and which `#claimSlotOnce`'s own spills were not doing at all.
333
+ - **Fix**: Closing or destroying the store wakes whoever is waiting for room, and a claim checks for a closed store on every attempt. Callers inside `#claimSlot` were simply left asleep for ever.
334
+ - **Fix**: A piece handed back to memory while its own spill is still being written no longer reappears on disk. `DiskTier.write` records the index when it COMPLETES, and `put` called `forget` before that, so the completing write put the stale copy back and a later read returned bytes from before the rewrite. `put` now waits for that piece's spill to finish before forgetting it.
335
+ - **Fix**: A spill that fails while the allowance is being lowered is counted (`spill-failures` in the store's line) instead of thrown into nothing. Nobody awaits those spills, so the rethrow was an unhandled rejection, and an unhandled rejection in the torrent worker ends the thread — a second way to lose the torrent client on top of the one being investigated.
336
+ - **Fix**: Two concurrent revivals of the same piece no longer each allocate and register a buffer for it, leaving whoever holds the first reading memory the store has stopped tracking. The second finds the piece already back and uses it.
337
+ - **Fix**: `PieceLru` follows the store's live allowance. It was built with the capacity the store was created with and never revised, so `isFull()` answered against a number that had stopped being the limit — dormant only because nothing calls it today.
338
+ - **Chore**: The store accepts an injected disk tier so a test can hold a write open or make one fail on purpose. Four of the defects above live in what happens when the disk does not answer at once, and none of them was reachable from outside before (`test/piece-store-reservations.test.js`).
339
+
340
+ All eight were found by reading the file after the torrent worker was killed by its own JS heap limit on 2026-08-31 (`research/worker-heap-oom-2026-08-31.md`, §5). None of them is proven to be that growth; they are what the reading found, and each is a defect in its own right.
341
+
342
+ ## 2.64.5
343
+
344
+ - **Fix**: A rung measured at 0.007x is no longer kept just because it is on screen. The `playingHeight` exemption is now checked after the `measured < 1` withdrawal, so a 4K HEVC transcode at 0.007x on a CM4 (field 2026-08-31, 0.1x at 23:45 and 0.007x at 06:57, 0.04s buffered) is withdrawn and the offer can become empty instead of stalling the viewer with no way to downgrade. `ownHeight` is kept only for a copied source (`!transcodeVideo`), not for a re-encode already running at 0.18x.
345
+
346
+ ## 2.64.4
347
+
348
+ - **Fix**: Piece store holds one `SharedArrayBuffer` per resident piece instead of a single growable pool that only ever grew. Evicting a piece deletes its buffer and the memory is reclaimable by GC — `committed` is now `resident * chunkLength`, not a high-water. On the field host a 6-piece 8 MiB overflow kept 48 MB committed; now it keeps 32 MB (roadmap 2).
349
+ - **Fix**: `reviseGrowthCeiling` eagerly evicts excess pieces when the allowance is lowered, so a machine that filled up releases memory immediately instead of holding it until the next `put`. Previously lowering a ceiling stopped growth but never freed what was already committed.
350
+ - **New**: `reviseGrowthCeiling` returns `evicted` and the periodic `piece-store` line reports `evictedOnRevise`; the worker logs `evicted N piece(s) to meet it` or `pinned, cannot shrink yet`.
351
+ - **Fix**: Cross-thread fragment path carries the piece's own buffer (`piece-reader` → `worker` `FRAGMENT` → `client`) instead of an offset into a single `sharedBuffer`. Removes dead code `sharedBuffer`/`_legacySharedBuffer`/`poolBySource`/`poolByRead`.
352
+
353
+ ## 2.64.0
354
+
355
+ - **Fix**: A reader sizes its window from the memory the store may hold NOW, not from the allowance it was created with. 2.63.0 made the allowance follow the machine but left the `capacity` getter answering the original reservation, and that getter is what `ceilingPieces` reads — so a reader would have gone on claiming pieces against an allowance the machine had already withdrawn.
356
+ - **New**: Container and track domain — `Container` (RFC 9559 / ISO 14496-12) with `MatroskaContainer` / `Mp4Container` / `AviContainer` via `ContainerFactory` (16-byte sniff), and `ContainerTrack` → `VideoTrack` / `AudioTrack` (`FlagOriginal`/`FlagCommentary`/`FlagVisualImpaired`) / `SubtitleTrack` → `TextSubtitleTrack` (`S_TEXT/UTF8`, `tx3g`, `wvtt`) / `ImageSubtitleTrack` (`PGS`, `VobSub`, `subp`) + `ExternalSubtitleFile`. Every class constructed from its spec section: `FlagForced` only on subtitles (RFC 9559 §5.1.4.1 0x55AA), `FlagEnabled`/`FlagDefault` + `LanguageBCP47` MUST on base, `track_enabled`/`alternate_group`/`elng` on MP4, `AVIIF_KEYFRAME` on AVI. `LanguageBCP47` overrides `Language` per MUST, disabled tracks kept for `declaredIndex` alignment with ffmpeg `0:s:N` (roadmap 62).
357
+ - **New**: Application layer — `ContainerOrchestrator` (per-file cache, `getTracks`/`getKeyframeIndex`) and `SubtitleOrchestrator` (wraps `torrent-worker/subtitle-cues.js` cluster walk behind track abstraction).
358
+ - **New**: Interface layer — `PlaybackController` / `SubtitleController`; `routes/api/playback-plan/post.js` and `routes/api/subtitles/get.js` delegate to controllers instead of calling services directly.
359
+ - **Chore**: `proxy/docs/container-architecture.md` with mermaid class/sequence diagrams and flags matrix; `proxy/CLAUDE.md` layout updated; `services/container-index/` marked as internal detail used by `container/*`.
360
+
361
+ ## 2.63.0
362
+
363
+ - **New**: The piece store says what it has TAKEN, not only what it holds. `committed` and `on-disk` stand beside `resident` in its line. The two are different quantities and the difference is the growth that had no explanation: the pool only ever grows — `SharedArrayBuffer` has no shrink — so a piece spilled to disk returns its slot to the free list and its memory to nobody. On 2026-08-28 the store reported "144MB" while the process held 893 MB.
364
+ - **New**: The torrent worker reports its OWN isolate's memory. The piece pool is a `SharedArrayBuffer` allocated there, so the main thread's `arrayBuffers` cannot see it however carefully it is read — which is why the main line said `no torrent stores` in the same second the worker logged a live one. `rss` and the kernel's rollup stay on the main line, because they belong to the process and are read once.
365
+ - **New**: The process line carries `anon` from `/proc/self/smaps_rollup` and the free space where pieces spill. What neither isolate claims is now visible, and both limits are measured rather than declared — a budget for memory alone is half a budget when the overflow goes to a card the host boots from.
366
+ - **Fix**: A store's allowance follows the machine instead of being settled at birth. It was `MemAvailable` divided between the stores at the moment one was created, so a store opened on an idle machine kept an idle machine's allowance for life and went on growing while everything else competed for what was left. It is re-derived every minute, within the reservation the store was created with (`maxByteLength` was fixed from that and `grow()` cannot pass it). Lowering it frees nothing already committed — the pool cannot shrink — but it stops the growth, sends those pieces to disk, and says so in the log when a store is already past its new allowance.
367
+ - **Fix**: The delivery probe no longer calls a healthy connection stopped. Its allowance had one load-dependent term, our own send queue, and that queue is empty by construction — it drains the moment libdatachannel accepts the bytes, whether or not usrsctp then puts them on the wire. So a browser filling its cushion showed an empty queue, a small allowance and a large gap, which reads exactly like a stopped association: four `association-stopped` in the first two minutes of a healthy session on 2026-08-28, and deepening the cushion from 30 s to 120 s had made that burst four times longer. The browser reports its own transport-level received total with every echo, so the question is now answered by a counter rather than a threshold: while that total is advancing, bytes are crossing and no verdict of a stopped association stands. A browser that does not report it is judged exactly as before.
368
+
369
+ ## 2.62.0
370
+
371
+ - **New**: Each session says what its cushion actually is, every half minute: how many seconds of film are ready ahead of the EARLIEST viewer's picture, roughly how many megabytes that is off the swarm, and how deep a buffer the browsers say they are holding. The three were never printed together and could not be reconstructed afterwards from anything that was — so whether deepening the browser's buffer (2.61.0) protected anybody, and what it cost the person hosting the proxy, had no answer. Every term is measured: the produced range from the segments on disk, the picture from the viewers' own reports, the byte rate from the file's length over its duration. The read window sits on top of the megabytes figure, so it is a floor.
372
+
373
+ ## 2.61.0
374
+
375
+ - **New**: The proxy states how far ahead of the viewer it lets an encoder run — `lookaheadSeconds` on the session-create response. The browser sizes its forward buffer from it, so the two sides agree by construction instead of each carrying a figure of its own: the browser held 30 s, with a ceiling of 60, while this proxy keeps 120 s produced and suspends the encoder there. Three quarters of the protection against every interruption was left on the disk.
376
+ - **Fix**: A held request is judged stale against the encoder's own look-ahead, measured on the session's own cut grid, instead of against `MAX_LOOKAHEAD_SEGMENTS`. Those eight segments bound a request ahead of the ENCODE HEAD and say nothing about how far ahead of the VIEWER a request may legitimately sit; the two happened to match a browser holding 30 s, and a browser holding the whole cushion would have had three quarters of its requests refused. The grid matters because a copied picture is cut at the source's keyframes, so its segments are not four seconds long.
377
+ - **Fix**: Each viewer of a session has their own head — the segment they last asked for, or the position they seeked to. The encoder is still steered by the FURTHEST of them, since what lies behind the leader has already been produced and is served from disk; what is no longer shared is the question "is THIS request still wanted". One session serves everyone watching a copied picture, so a seek by the viewer in front used to release every request being held for the viewer behind them — the freeze of 2026-08-18, one viewer at a time. A head unheard from for longer than the whole cushion plus a segment is forgotten: a viewer who is playing asks again within that, so a longer silence means paused or gone, and neither should hold the encoder ahead of them.
378
+ - **New**: `/seek` accepts `consumerId`, and a segment request carries `?consumer=`. Without them everything falls back to the one shared position, which is what a single viewer means anyway.
379
+
380
+ ## 2.60.0
381
+
382
+ - **Fix**: A separately published audio track starts where the EARLIEST viewer's picture is. A copied picture is one session shared by everyone watching it — the session key carries the consumer id only where the video is re-encoded — and the start was worked out as the read head less the buffer the browser reported. The read head is the furthest request of ANY viewer while the buffer belonged to whichever of them reported last, so with two viewers the two halves of that subtraction belonged to different people and the error was as large as the buffer is deep. The viewer now states where they are, in their own link report, and the track begins at the earliest of them. A browser that states nothing falls back to the old subtraction with the deepest buffer reported, which errs early — the cheap direction.
383
+ - **New**: Link reports are kept per viewer, keyed by consumer id, instead of one field per session overwritten by whoever reported last. The budget reads the worst of them: the slowest link and the emptiest buffer, which need not belong to the same person, because the question it asks is whether anybody is failing to keep up. A step UP has to be carried by all of them, so `#linkCouldCarry` reads the slowest link too. The reason line says how many viewers the figures were taken over.
384
+ - **Fix**: A report from a viewer who has left stops counting. Nothing releases a consumer when a data channel closes (roadmap item 55), so their last reading would otherwise go on deciding for the viewers still here; entries older than the report freshness window are dropped when the next report arrives.
385
+
386
+ ## 2.59.3
387
+
388
+ - **Fix**: two quality rungs that this machine encodes at the same size now share one encoder instead of starting one each. A variant was remembered under the height the browser ASKED for, while what it encodes is settled afterwards by the clamp that starts a manual pick at the top of the ladder this host can sustain — so on a weak machine a request for 360p and one for 540p both became a 426x240 encode, were filed under keys 360 and 540, and neither ever found the 240p session already making that exact picture. Field 2026-08-28: three ffmpeg processes on a CM4 producing one identical picture, every rung above 240p then measured at 0.04x of realtime and 240p itself at 0.30-0.72x, the viewer watching a slideshow that ended in a spinner, and the process dying eight minutes later after resident memory grew 121→810 MB. The comparison is made on the height PRODUCED, which is the only figure that cannot be wrong: predicting the clamp instead would be a second copy of the budget arithmetic, and the two would drift — the offer prices a rung from the startup measurement while the clamp prices it from what this file has since been seen to cost. A duplicate is let go the moment its size is known, and the height it was asked for then names the session that serves it, so no later request starts anything. A COPY is never adopted for a re-encoded rung: it costs no encoder and is the one thing a stranded viewer can always return to (`research/session-pileup-variant-key-2026-08-28.md`).
389
+ - **Fix**: disposing a variant clears every height that named it, not only the one it was created under. With one session now serving several requested heights, the old cleanup left the other keys pointing at a disposed session — recovered on the next request, but only after it had been found dead.
390
+
391
+ ## 2.59.2
392
+
393
+ - **New**: a failed encode run says what it asked the source for and what the source holds, beside the command it ran. Every `-map` this proxy builds carries the `?` suffix, so ffmpeg drops a mapping for an absent stream in silence; drop them all and the output has no streams, which it reports as `Output file does not contain any stream` and exit 255. Three sessions died that way on 2026-08-26 and the log held only the code — indistinguishable from any other refusal. The stream counts are read from the probe's own banner, which is the one place the source says what it carries, and kept for the failure to quote. Beside them the cause is unmistakable and it says which side is wrong: an audio index past the end of the list is ours, no streams at all is the source's. The argument list was already logged at spawn; it is now repeated at the failure, so it need not be found among the runs that succeeded around it (roadmap item 58, `research/session-pileup-variant-key-2026-08-28.md`).
394
+
395
+ ## 2.59.1
396
+
397
+ - **Fix**: the usrsctp state script no longer crashes when no association exists yet. Found while verifying 2.59.0 by hand against the live process with no viewer connected: `system_base_info`'s association hash table is a null pointer until usrsctp allocates its first association, and the walk dereferenced it unconditionally — `Cannot access memory at address 0x0` instead of the graceful "no association found" the script already prints for the equivalent case where the table exists but is empty. Guarded the walk behind `$hash != 0`.
398
+
399
+ ## 2.59.0
400
+
401
+ - **New**: a wedge (roadmap item 11) is now declared, and evidence gathered for it automatically, even when the small-message shape means nothing ever queues. The only trigger that existed (`wedgeIsCertain`) requires a nonzero channel queue — confirmed 2026-08-28 by reading the code, not assuming it from the note: `bufferedAmount()` reads 0 the instant our bytes reach usrsctp, whatever usrsctp does with them next, so a wedge carrying only probes and control messages never set it and the last real episode's ring was never saved automatically. The delivery probe already computes a queue-independent verdict (`association-stopped`); it is now wired to the same evidence-gathering, gated on its OWN certainty rule rather than the raw verdict — a connection can sit behind by a bounded, non-growing amount for minutes (measured the same day, a backgrounded tab: gap held at 6-7 probes for 95+ seconds while `seen` kept climbing right along with `sent`) without anything being wrong. What a true wedge shows instead, checked against a session already known to be one: `seen` frozen at one value for over a minute while `sent` climbs unbounded. `probeWedgeIsCertain` asks whether the counter has stopped moving for longer than this connection's own history says a healthy gap ever takes — the same shape `wedgeIsCertain` already uses, applied to the probe's own counter.
402
+ - **New**: `usrsctp`'s live association state (peer receive window, pending data, retransmission timeout, congestion window) can now be read on either wedge declaration, automatically — `node_datachannel.node` ships unstripped, so the read is a gdb attach against the running process, no rebuild. The walk and its healthy baseline are `research/session-2026-08-27-28-freeze-onset-and-sessions.md`, section 1; the script that performs it ships in the package (`assets/diagnostics/sctpstate.gdb`) instead of surviving only as long as someone remembers to copy it back onto a host after a container is recreated. Nine episodes of this item have lacked exactly this reading.
403
+
404
+ ## 2.58.3
405
+
406
+ - **New**: the proxy says what it is holding, once a minute — resident memory, heap, external and array buffers, the torrent stores in BYTES, and what the machine has left. It was killed on 2026-08-28 by the kernel's own out-of-memory killer at 2.4 GB resident (`exit code 137`, no core dump, `Out of memory: Killed process ... anon-rss: 2422628kB`) and the log had never recorded a single figure about memory. There was one final reading, taken by the kernel, and no series leading to it.
407
+ - **Fix**: the torrent stores share ONE budget instead of each taking its own. It was per torrent, so two torrents meant two of it, and nothing anywhere asked what the process as a whole was holding. On the film the proxy died under, one store had taken the full 504 MB.
408
+ - **Fix**: that budget is a share of `MemAvailable` rather than of `os.freemem()`. On Linux the second counts only the pages free at that instant, while the kernel deliberately keeps that number low by filling the rest with reclaimable cache — so the share it produced had little to do with what an allocation could actually obtain. The kernel publishes the estimate; we read it.
409
+ - **New**: the piece-store line reports megabytes beside its piece count. The count alone says nothing without the piece size, and the piece size differs per torrent: on that film, "63" meant 504 MB.
410
+
411
+ ## 2.58.2
412
+
413
+ - **Fix**: `utp-native` moves to 2.5.3-ttv.8, which removes the whole crash family rather than another instance of it. Nine deaths in a fortnight had one shape — libuv holding a pointer into memory that had gone — because the structs carrying `uv_udp_t`, `uv_timer_t` and `uv_udp_send_t` were allocated by JavaScript as `Buffer.alloc(sizeof(...))`, putting them under the garbage collector while libuv's rule is that they must live until the close or completion callback has run. Every earlier fix reconciled the two owners with a rule and the next release found another way through. The module now allocates and frees that memory itself, at the point libuv has provably finished; JavaScript holds only a token, and freeing points the token at nothing so a late call does nothing instead of faulting. On the environment's own teardown nothing is freed at all — a deliberate leak while the process ends beats touching napi as it goes. Checked on the target: 77 tests pass, and 60 create/serve/destroy cycles leave memory flat.
414
+
415
+ ## 2.58.1
416
+
417
+ - **Fix**: `utp-native` moves to 2.5.3-ttv.7, which removes a defect ttv.6 itself introduced. That release registered an environment cleanup hook per uTP context and never removed it on the ordinary close path, so once a context finished closing and its buffer was collected the hook stayed registered against freed memory. At teardown it then ran there and called `uv_close` on handles that no longer existed, putting a dead handle into libuv's closing machinery; the fault surfaced later and elsewhere, when a healthy handle was unlinked and its neighbour in the loop's handle queue turned out to be that dead one. Read from `core.WorkerThread.81.1787857798` down to the faulting instruction — `QUEUE_REMOVE`'s second store with an unmapped operand — and by walking the loop's handle queue until a node could not be read (`research/worker-crash-cleanup-hook-2026-08-27.md`).
418
+
419
+ ## 2.58.0
420
+
421
+ - **Fix**: `utp-native` moves to 2.5.3-ttv.6, which removes the fault the ninth core dump named. Its stack carries no uTP frame at all — `uv_timer_stop` ← `uv_close` ← `PerIsolatePlatformData::Shutdown` ← `NodePlatform::UnregisterIsolate` ← `Worker::Run` — the torrent worker thread ending and node walking its loop into memory that had gone. `uv_udp_t` and `uv_timer_t` are fields of a struct that lives inside a JavaScript Buffer, and they are registered on the loop, so libuv holds pointers into collector-owned memory for as long as they stay registered; the thread ending takes that memory with the heap. On a main thread nobody notices because the process is ending anyway, and this proxy runs the torrent client in a worker. Three changes: a cleanup hook takes the handles off the loop before the environment goes; a plain int says whether the environment can still be called into and every callback reads it first; a strong reference holds the buffer until both handles have closed. 77 checks pass on the target.
422
+ - **New**: the torrent worker says why it is ending, from inside itself, before anything is torn down — `beforeExit` with what still held the loop open, `exit` with the code, and uncaught errors and rejections. The parent already had a line for an unexpected exit and it never printed once: the fault happens during the thread's own teardown, before the parent's `exit` event is delivered, so the reading that would name the cause was being eaten by the failure it was meant to explain.
423
+ - **Fix**: the delivery probe's allowance takes in the far end's own answering cadence, measured on the same connection. Without it the allowance collapsed to a single probe whenever the queue was empty, while a hidden tab echoes about once a second because the browser throttles its timers — 67 `association-stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection carrying 3.4 MB/s with every queue at zero (2026-08-27).
424
+
425
+ ## 2.57.1
426
+
427
+ - **New**: The flags a Matroska file states about its own subtitle tracks are read, instead of being guessed from the words a releaser typed into a track's name. `FlagForced` (`0x55AA`): "Set to 1 if the track is eligible for automatic selection by the player if it matches the user's language preference, even if the user's preferences would not normally enable subtitles with the selected audio track" — a track of signs and foreign speech, not of dialogue. `FlagHearingImpaired` (`0x55AB`): "suitable for users with hearing impairments". Both travel with the playback plan, so the browser can say which is which; until now "fors" and "SDH" in a menu were whatever text someone happened to write.
428
+ - **Fix**: A track the file marks unusable is no longer offered. `FlagEnabled` (`0xB9`, default 1): "Set to 1 if the track is usable." It is left out of the extraction list but KEPT in the declared one, because ffmpeg keeps it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as `EBML_NONE`, reading the element and storing nothing, so the stream is created and numbered like any other. Dropping it would have shifted every track after it off `0:s:N`, which is the numbering defect 2.56.0 exists to remove. An element written with zero length carries its default, and a value outside the declared 0-1 range is not a statement either — only an explicit zero takes a track away.
429
+ - **Fix**: `LanguageBCP47` (`0x22B59D`) is read and takes precedence, which the specification states as a MUST: "If this element is used, then any Language elements used in the same TrackEntry MUST be ignored." A file writing `pt-BR` beside `por` was being shown as plain Portuguese. The tag replaces the code in the list this proxy owns and rides BESIDE it in the list that is lined up against ffmpeg's `-i` banner, which prints the three-letter form — reporting `pt-BR` there would break the pairing and cost the `FlagDefault` reading with it.
430
+
431
+ ## 2.57.0
432
+
433
+ - **New**: the packet witness records the seconds BEFORE a freeze, not only after it. A size-bounded ring (4 x 16 MB, wrapping) runs on the WebRTC UDP port for exactly as long as a data channel is open, and its files are copied aside the moment a wedge is declared. The two field captures that placed this defect (`research/delivery-freeze-sender-silent-2026-08-26.md`) both began 30 s after the queue was already stuck, so neither holds the onset.
434
+ - **Fix**: the capture rotates by SIZE. `-G <seconds>` with `-W <count>` and a file name carrying no strftime field made every rotation write the same name, so the four files the witness believed it kept were one file overwritten four times — which is why both field captures hold 28 s instead of the intended 120.
435
+ - **New**: the capture that follows a wedge runs for 180 s, three times usrsctp's 60 s retransmission ceiling. That length is what turns silence into a statement: a stalled sender whose peer advertises a zero window must probe once per timeout, and no probe appeared in either 28 s capture.
436
+ - **Fix**: a wedge is declared when it is CERTAIN rather than after a chosen delay. Three facts that are not ambiguous together: the queue has not fallen, the accepted-byte counter has not moved for longer than either the queue's own drain time at the best rate this connection has been seen to move bytes at OR the longest pause this connection has shown while healthy, and the peer has sent something during the wedge. The middle term matters twice over — the LAST rate is usually the quietest (with the browser's buffer full the only traffic is the probe, a few hundred bytes a second, and dividing a queue by that gives hours), and an ordinary retransmission timeout stops the counter dead for as long as it lasts. On the field episode's own numbers the answer comes in seconds instead of thirty; on a thin link with a large queue it waits as long as the arithmetic says it should.
437
+ - **New**: ring files left behind by a process that was KILLED are kept at the next startup instead of being deleted by the next viewer's first channel. Seven deaths in a fortnight (roadmap item 1) each threw away the last seconds of wire before them.
438
+ - **Fix**: the ring is stopped and its files removed on shutdown, and it is released by the watch that owns it rather than by the channel's `onClosed`, which does not always come. Either gap left a tcpdump writing on an idle proxy for the life of the process.
439
+ - **Fix**: captures are pruned after each one is written, not only at startup. One episode writes up to six files and the cooldown allows six episodes an hour.
440
+ - **Fix**: the delivery probe's verdict is derived, not chosen. `MISSES_FOR_VERDICT = 4` was compared against a delay that is measurable: SCTP orders per stream but schedules per ASSOCIATION, so a probe waits behind whatever is queued ahead of it on any channel, the unordered one included. Measured 2026-08-26, the old rule printed `association-stopped` at gap 4-7 while 110-150 Mbps crossed that same association and 7.34 GB went through without a failure. The allowance is now that channel's queue divided by the connection's measured rate, plus one round trip; with no rate measured the line says `no-rate-yet` instead of naming a fault.
441
+
442
+ ## 2.56.0
443
+
444
+ - **Fix**: A subtitle track is asked for by ffmpeg's own number, `0:s:N`, which counts EVERY subtitle stream the file carries — and the container plan counted only the ones it can turn into text, dropping PGS and VobSub before numbering. On a release whose picture-based track comes first the two numberings ran a place apart, with two consequences, both silent: a pushed cue named a track the browser does not know and was dropped, and the browser's own request found no track at all and fell through to the ffmpeg extraction, which reads the whole film for a few kilobytes of text (752 s measured on one file, 2026-08-19). Every text track now carries `declaredIndex` — its position among all the file's subtitle tracks, counted by what a track IS and not by what this code can read — and both the push and `/api/subtitles?trackIndex=` use it. In an MP4 that count includes the `subp` and `clcp` handlers, which ffmpeg also calls subtitle streams.
445
+ - **Fix**: One walk of a file at a time. The walk marks a cluster as read only after fetching and parsing it — two suspension points — while it is started both on every verified piece and on a 3 s timer, so on a fast download several passes read and parsed the same cluster and could push one line twice under different found-order numbers. Each of those reads is a WebTorrent file stream, which selects and deselects its pieces, so the repetition reached the piece picker as well. Walks are now serialized per file, the plan is read once even when two callers arrive together, a warmup triggered while the previous one is still walking is dropped rather than queued, and forgetting a file waits for its walk instead of leaving one running beside a fresh copy of the state.
446
+ - **Fix**: A read of already-downloaded bytes that never ends is given up after 30 s, with a line saying so. It had no bound, and with walks now serialized one such read would have held that file's queue — the browser's own request for its subtitles included — for the rest of the session.
447
+ - **New**: A subtitle push says what it is ABOUT: the film-time span its new cues cover, how many of the file's indexed clusters have been walked, and the found-order cursor. Read against the position being played — which the browser now logs beside it — that separates cues that arrived late from cues that arrived early for a stretch nobody is watching. Report 2026-08-26: embedded subtitles appear "after some time", and no line on either side could say whether the cues held covered the playhead (`research/subtitle-delay-2026-08-26.md`).
448
+ - **New**: The push carries a `cursor`, so a browser that loses the subscription — which a reconnect does, since the subscription belongs to the channel — can ask for exactly what it missed instead of the whole track.
449
+
450
+ ## 2.55.14
451
+
452
+ - **Fix**: `utp-native` moves to 2.5.3-ttv.5, which carries ten defects found by reading the whole binding after the seventh crash of this family named its frame. Three callbacks read the connection pointer without checking it exists — and the socket carries none unless it was accepted or dialled, while its destructor announces itself regardless; the ttv.4 patch created one such path itself. The read callback copied at an accumulated offset without ever consulting the buffer length it maintains, so a peer sending more between two hand-offs wrote past the end — heap corruption does not fault where it happens, which is what six deaths inside libuv bookkeeping look like. A connection could be destroyed twice, deleting already-deleted napi references, and the socket pointer was never cleared. The callback macro checked none of the three napi results it then used. Plus IPv6, which the module never had at all, and the resolver that fed it addresses it could not use. Tests on the target: 77 checks, no failures — two of them were failing or hanging before.
453
+ - **Chore**: `--sctp-debug` is removed. It set node-datachannel's log level to verbose and filtered for `usrsctp:` lines, which only exist in a binary built with `SCTP_DEBUG=ON` — a source rebuild of libdatachannel that is not done any more. A flag that cannot do anything in any image we ship is an untruthful interface; the delivery probes of 2.55.13 answer the question it was wanted for (a probe on an unordered, no-retransmit channel passes head-of-line blocking in another stream but not a closed receive window) without touching the native build.
454
+
455
+ ## 2.55.13
456
+
457
+ - **New**: numbered delivery probes (`services/delivery-probe.js`). Every channel of a connection carries a numbered probe twice a second; the far end echoes back the highest number it saw on each, and the gaps are read into a verdict — `flowing`, `stream-stuck` (a retransmission held up in one stream), `association-stopped` (the window shut or transmission halted), `reverse-direction-gone`. The proxy's own counters could never separate those: libdatachannel's `bytesSent` counts bytes accepted into usrsctp, not bytes put on the wire. Logged as `[dc-probe]`, with the numbers that produced each verdict beside it.
458
+ - **New**: the far end's own account of itself is logged as `[dc-far]` — tab visibility, event-loop lag, the longest the channel's message handler ran, the transport's received bytes and per-channel message counts. A page that has stopped draining the channel and a sender that has stopped transmitting look identical from here; this is the difference.
459
+ - **New**: `--delivery-sink` serves `GET /api/delivery-sink?bytes=N`, a torrent-free stream of a repeating pattern, so gigabytes can be pushed through a data channel on demand. Off without the flag (404), and it exists because the delivery freeze takes hundreds of megabytes and the real transport to appear at all.
460
+
461
+ ## 2.55.12
462
+
463
+ - **Fix**: `--sctp-debug` now passes the required callback to `initLogger` (`node-datachannel` 0.32.x needs two args). The 2.55.10 single-arg call always threw `Function expected` and left verbose logging off.
464
+
465
+ ## 2.55.11
466
+
467
+ - **Fix**: `initLogger('Verbose')` call for `--sctp-debug` used the wrong `this` binding and never enabled verbose logging (`Function expected`). Call it as a method on the imported module's default export so the SCTP debug lines appear.
468
+
469
+ ## 2.55.10
470
+
471
+ - **Change**: Add `--sctp-debug` (off by default). When enabled, the proxy calls `node-datachannel.initLogger('Verbose')` early so SCTP-level lines (`usrsctp: …`) become visible. Useful only with an image rebuilt with `SCTP_DEBUG=ON` (addon 0.48.0) — there they carry the SACK `a_rwnd` and gap information that separates the two remaining hypotheses for the delivery-side freeze of 2026-08-24/25.
472
+
473
+ ## 2.55.9
474
+
475
+ - **New**: A send queue that stays wedged for over 30 s now records the wire itself. Field session 2026-08-24 (`research/dead-channel-2026-08-24.md`): the proxy counted bytes as sent that never reached the viewer's SCTP stack, and every counter above the wire reported success for 88 minutes — the two candidate causes inside SCTP separate by one look at the packets (duplicate SACKs naming a missing TSN with no retransmission vs SACKs advertising `a_rwnd=0`), but occurrences are rare, so waiting to be asked meant waiting forever. When the stuck warning crosses 30 s, the proxy spawns a bounded tcpdump on the WebRTC UDP port filtered to that session's remote address: snaplen 128 B, ring of 4 × 30 s files beside the core dumps (`--state-dir`), killed after 120 s + grace, one capture at a time with a 10-minute cooldown, old captures pruned at startup the way core dumps are. Where no tcpdump exists it degrades to a single log line; the address travels into the filter only as a validated IPv4/IPv6 literal (zone suffixes stripped), spawned as an argv array without a shell.
476
+
477
+ ## 2.55.8
478
+
479
+ - **Fix**: The proxy died twice in one evening (2026-08-22, 15:50:12 and 16:12:21 UTC) with no stop order given, and both deaths are the same fault. The torrent worker thread ends itself when its event loop drains — every recurring interval there is unref'd, upload is disabled by default, and idle peer connections close about half a minute after the traffic stops — so when a viewer paused or left, the thread finished ~35 s later on its own and Node began tearing it down. That teardown touched memory already freed or overwritten (SIGSEGV inside `uv_timer_stop`, reached through `PerIsolatePlatformData::Shutdown`; two core dumps captured identical stacks), and a fault in any thread kills the whole process instantly — HTTP server, tunnel and data channels together, with no log line and no way to restart anything from inside. The HA supervisor restarted the container each time (~15 s), but the browser's reconnect ladder had already given up by then. The worker now keeps ONE interval accounted for (no `.unref()`): an empty tick every 5 s costs nothing, the event loop can never drain while the process lives, and the teardown path — with whatever structure is corrupted inside it — stays unreachable, regardless of which native module is guilty; the three earlier crashes of this family (2026-08-18..21) stay documented under roadmap item 1.
480
+
481
+ ## 2.55.7
482
+
483
+ - **Fix**: The subtitle push subscription is now recorded under the torrent pool's own key, not the browser's registry key — the two are different strings whenever a source was added by a `.torrent` file (a `.torrent` and a magnet for the same film are different request bytes, hashed into different registry keys, but the SAME infohash) and were silently different in every other case too: a registry key is `sha1(sourceType:source)`, one per API session; the pool's key is `torrent:<infohash>`, deliberately shared across a magnet and a `.torrent` for the same content (item 10). The diagnostic logging added in 2.55.6 caught it directly, field case 2026-08-22: cues were found and logged repeatedly, and every push answered `found no subscribed channel` — the subscription and the publish had never been able to agree on a key, for any torrent, since the push feature shipped in 2.55.5. `data-channel-handler.js` now resolves the browser's registry key through `sourceRegistry` to `(sourceType, source)` and runs it through the same `deriveSourceKey` the pool itself uses, at the one point both keys are in hand — the subscribe intercept, before the request is even forwarded.
484
+
485
+ ## 2.55.6
486
+
487
+ - **Chore**: Every step of the subtitle push chain now logs on success, not only on failure. Field report 2026-08-22, playing `Minions.and.Monsters.1080p.mkv`: a track was switched on over a minute after the seed fetch found nothing (`bytes=7`, an empty `WEBVTT` — expected, the torrent had barely started), and no cues appeared. The proxy log carried no evidence either way — `warmActiveFiles` posted `Event.SUBTITLE_CUES_READY` silently, `publishSubtitleCues` sent (or found no subscriber for) a push silently, and `subscribeSubtitles` registered a channel silently. Confirmed separately by reading WebTorrent's own source that `verified` fires on every live piece completion (`_markVerified` inside `store.put`'s callback in `torrent.js`, not only at startup), so the event source itself is real; what could not be told apart without these lines is subscription, discovery, and delivery. Logs now name each: `subtitle push: channel subscribed to …`, `… cue(s) found, posting to main thread`, `… sent N cue(s) … to M/T channel(s)` (or `found no subscribed channel`).
488
+
489
+ ## 2.55.5
490
+
491
+ - **New**: Subtitle cues are now PUSHED to the browser the moment they are read, over the WebRTC data channel — not fetched by the browser on a timer. Every declared track was already being warmed off the piece-`verified` event (2.55.4); what changed is that the result now travels to the browser unprompted instead of sitting on the proxy until the next poll asked for it. `data-channel-handler.js` remembers which channel last asked about a file's subtitles (piggy-backing on the browser's own first `/api/subtitles?trackIndex=` request — no separate subscribe message) and sends new cues there directly (`{ type: "subtitle-cues", fileIndex, trackIndex, cues, language }`), for every track the container declares, not only the one on screen. Rides the existing `proxy-control` data channel — the same one the request itself used, which is never the one carrying segment bytes, so a push cannot queue behind video. `finalizeCues` (end-time synthesis + ASS-dialogue stripping) is factored out of the HTTP route into `services/torrent-worker/subtitle-cues.js` so a pushed cue and a pulled one are built the same way. A browser's one-off seed fetch per track (for whatever is already read at the moment a file opens) and the external-subtitle-FILE path (`.srt`/`.ass` beside the video — a single whole-file read, no incremental delivery to begin with) are unchanged.
492
+ - **Chore**: The push subscription only fires for an embedded-track request (`trackIndex` present) — an external subtitle file's request carries a different file's own index in `fileIndex` and was being registered under a key nothing ever publishes to. Harmless (the worker's plan for a non-container file is empty, so nothing was ever sent there), but pointless bookkeeping is still a bug waiting to be one.
493
+
494
+ ## 2.55.4
495
+
496
+ - **Fix**: A file's subtitle cues are now walked the moment a piece verifies, not on a 3 s poll. The poll (2.55.3) closed the worst of it but still left every new cluster waiting up to 3 s after its piece arrived, and "waiting" at all was the thing objected to — a cue's readiness must not depend on which of two independent timers happens to fire first. `torrent.on("verified", …)` is WebTorrent's own signal for exactly this instant, set in the same place the bitfield itself is (`_markVerified`), so the walk now runs off the same event that makes a piece a piece rather than off a schedule. The 3 s poll stays as a fallback — it only matters for a listener attached after some pieces already verified, or if a `verified` handler ever throws — so nothing that used to be caught can now be missed.
497
+
498
+ - **Fix**: A track's subtitle cues are now walked ahead of being asked for, instead of only when a browser first requests them. `cuesHeldFor` only ever read clusters on demand, inside the `/api/subtitles` request itself — cheap once caught up, but the FIRST call for a track had to walk the whole backlog of already-downloaded-but-unparsed clusters serially, with no `pending`/streaming pattern the way the ffmpeg fallback has one. On a film well into playback that backlog is not small (`Minions.and.Monsters.1080p.mkv` indexes over a thousand cluster positions per track), so a viewer who turned subtitles on after watching for a while waited on that catch-up instead of seeing cues appear at once — the opposite of the rule this file states its own reason for existing ("the region the viewer is watching is downloaded before they reach it, so its cues are ready before they are needed"): true of the DATA, not of when it got READ. A new periodic pass in the torrent worker (`warmSubtitleCues`, every 3 s, one per actively-read file) walks new clusters as they arrive, reusing `cuesHeldFor`'s own memoized state — a file nobody has opened costs nothing, and a file being watched is caught up by the time a track is switched on.
499
+
500
+ - **Fix**: The decode pipe's sanity log carried a fixed editorial line — "a reading where these are far apart is worth a second look" — printed on every single reading regardless of whether the two figures actually were, which was noise the first time it ran in the field. The comparison is also now taken over the same window the speed itself is (bytes are snapshotted alongside each progress sample), rather than over the whole run from process start, which read systematically low for no reason but that mismatch. The line only says "worth a second look" when the two figures are actually more than 1.5x apart.
501
+
502
+ ## 2.55.1
503
+
504
+ - **Fix**: A source is now keyed by its own infohash, not by a hash of the request bytes. A magnet URI and a `.torrent` file for the same content are different bytes, so the old key (`sha1` of the source) named the same film as two unrelated sources — measured 2026-08-19, `a518ff46…` and `7ab2fb5d…` for one infohash `11f09299…` — sharing neither the swarm, nor a cache, nor any work already downloaded, and surfacing as `WebTorrent client error: Cannot add duplicate torrent`. The new key (`services/torrent-source-key.js`) reads the infohash straight out of the magnet's `btih` or the `.torrent`'s own `info` dictionary via `parse-torrent`, synchronously, with no network round trip — so both forms of the same torrent now share one entry from the first request, on both the pool's own map (`torrent-pool.js`) and the worker-thread boundary (`torrent-worker/pool-adapter.js`), instead of relying on WebTorrent's own duplicate-add error to reconcile them after the fact.
505
+ - **Chore**: The decode-cost reading now logs how many MB/s the pipe was actually fed against how many MB/s the measured speed implies were needed (item 4(d2)) — a divergence between the two is worth a second look before trusting the reading. A `write()`-return-value signal was tried first, to say outright whether the pipe or the decoder was the limit, and measured false on every reading taken while building it — including clips this host decodes at 15-80x with slack to spare — so it does not discriminate and was not shipped; only the byte count, which is real, is kept.
506
+
507
+ ## 2.55.0
508
+
509
+ - **Fix**: The decode calibration was measuring the loop rather than the decode. It looped each clip with `-stream_loop -1`, and a loop is not free: measured 2026-08-22, a restart costs **0.03 s on the 480p clip and 0.12 s on the 1080p one** — it scales with the picture, so it is the decoder tearing down and re-allocating its frame buffers rather than anything about reading the file. A five-second clip decoded at 55x restarts eleven times a second, and that cost dominated the reading: the same clips measured 53.7x looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse, the bias depends on BOTH the clip's own resolution and the host's speed — the two axes the fit exists to separate — so it did not cancel out, it tilted the fit. That is the fast-host failure recorded on 2026-08-20, where a desktop read 1080p at 9.35 Mbit/s as cheaper than 720p at 9.94, an ordering no decoder produces, and the H.264 fit refused to solve at all. A host that could not fit H.264 got no decode figure whatsoever, which is exactly the host most able to serve.
510
+ - **New**: The clip is fed to the decoder as ONE Annex-B elementary stream, written to its stdin over and over. Parameter sets are inline in Annex-B and it can be joined by plain byte concatenation — that is what a broadcast is — so more bytes are simply more stream: nothing re-opens, nothing re-initialises, and there is no restart inside the measured window. The lift out of the container is a copy, not a re-encode, and it goes straight to a pipe: no temporary media is written at any point. Error against the continuous-pass truth is now −0.2 % and −5.5 %, with the readings spread 2-6 %, against −25 % and −33 % for the loop. Every reading on the developer's desktop moved, by up to 66 %, and they are monotonic in both axes for the first time.
511
+ - **New**: The measured window is half a second instead of one. What used to make a long window necessary was the clip restarting inside it; with the stream continuous, the only thing left to average over is the timing jitter of two progress lines, which is milliseconds. Measured at half a second: −3.0 % and +0.6 %.
512
+ - **Fix**: The contention penalty was wrong for the same reason. It compares a decode alone against the same decode beside an encoder, and both readings carried the loop, but not equally — the machine's speed differs between them. Decoding alone now reads 77.5x where it read about 53x.
513
+ - **Chore**: A codec family's clips are lifted out of their containers in ONE ffmpeg run, and the startup is shorter than before rather than longer. The lift is a copy, so its cost is almost entirely the process: one per clip added 11 s here, and running them concurrently did not help — six at once took 4.75 s against 0.89 s for one, so the machine serialises them. One run with many inputs and many outputs costs one process. Measured end to end on the same desktop, alternating old and new: **35.3 s before, 31.6 s after**, with the readings corrected. The contention benchmark lifts its clip once and decodes the same bytes three times instead of lifting it again for each reading. Done before the measurements and never beside them: a remux running next to a decode is a second job on the machine, and this benchmark exists to find out what one job costs.
514
+ - **Fix**: The lift has a time bound and is killed on it. It was the only ffmpeg run in this file without one, and it is awaited before the proxy's tunnel opens — so a remux that never exited was a startup that never finished, with nothing said. Its failures, and the decode's, now carry ffmpeg's own last line instead of "said nothing".
515
+ - **Fix**: A codec family with no Annex-B mapping fails by name instead of being lifted with H.264's filter. This matters for what comes next: AV1 has no Annex-B form at all — its packaging is OBU — and MPEG-2 and VC-1 have no `*_mp4toannexb` filter, so all three of the families the roadmap plans need another route through here, and finding that out as "the clip failed" would send the reader after the clip.
516
+ - **Chore**: `test/decode-measurement.test.js` states the orderings the readings must have — a bigger picture costs more than a smaller one at the same bitrate, a thicker stream more than a thin one at the same size, HEVC more than H.264 — rather than any number, since the numbers belong to whatever machine runs them. Those are the properties the loop inverted, and nothing was checking them.
517
+
518
+ ## 2.54.0
519
+
520
+ - **Fix**: The automatic quality step no longer changes the SIZE of the picture underneath a session the browser is already decoding. The fMP4 init segment is fetched once — a player reads `#EXT-X-MAP` and never asks again — and `avc1` keeps SPS and PPS in it rather than in the fragments, so every fragment produced after a size change was decoded against parameter sets describing a picture that was no longer being made. Measured 2026-08-21 across five viewing attempts: both re-encoded sessions of the five were destroyed by it. On `LXH-12.TS` the encoder left 1280x720 for 960x540 at 13:30:06 and the browser went on reporting `decode … size=1280x720` for the next three and a half minutes — 67 readings, not one of them 960x540 — while the viewer watched a band of macroblock garbage over a smeared field. On `c0930.com_chijyo0073.wmv` the same act at 13:35:36 made the element error on the first mismatched fragment, close the MediaSource, throw `bufferAppendError InvalidStateError` on both tracks and sit at `size=0x0 readyState=0` for four and a half minutes. Which of the two happens is the decoder's choice, not ours, and no layer reported an error either time. A change of resolution is a change of VARIANT, as the standard has it: every height is already published in the master with its own init, so the proxy now ASKS the browser to move — the same act the manual menu performs, which has never had this fault.
521
+ - **Fix**: The step is decided on the rate the encoder is making NOW, read as the slope between two progress reports. ffmpeg's `-progress speed=` is cumulative — output time over wall time since the run began — so a run starved early carries that average for life. `LXH-12.TS` spent its first four minutes on a swarm giving ~100 KB/s and the budget correctly refused to act while it could see that, five times. The download recovered at 13:29:56; ten seconds later the cumulative figure still read 0.39x, the machine was now genuinely busy, the classifier answered CPU, and it stepped down a rung that the progress lines themselves show running at **1.30x** — 13 s of video in 10.02 s of wall clock. The same mistake was found and solved once already: the startup decode benchmark reads the slope between two reports for exactly this reason.
522
+ - **New**: The step BACK UP exists. `budgetRungIndex` was written in exactly one place in this codebase's whole life, `nextIndex = session.budgetRungIndex + 1`, so there was no way up from anywhere. A session whose encoder has stayed ahead of realtime and whose viewer's link can carry the next rung's allowed peak, unbroken for four times the window a step down needs, is asked back up one rung at a time — never above the source. A bitrate cap is lifted before the picture is enlarged, because it is the cheaper of the two and the one the viewer notices first.
523
+ - **New**: The quality step exists on the COPY path, which had no automatic behaviour at all — the budget loop left on `!session.transcodeVideo`, so a copied picture too thick for the viewer's link had nothing to answer with. A copy is not being encoded, so it has no rate to lower; the only way to send fewer bits is another rendering of the film, which is a re-encoded rung and therefore a change of variant. A copy is never stepped down for the PROCESSOR, because moving that viewer to a re-encoded rung costs the machine more, not less.
524
+ - **New**: A picture that the viewer's measured link cannot carry is bounded by that measurement, at the size it is already being made. `-maxrate`, `-bufsize` and CRF do not appear in the SPS — x264 writes no HRD parameters unless asked — so one init segment goes on describing every fragment, and there was no separate lever for bitrate before this. The target is not chosen: it is the link the browser reported, less the share protocol overhead and measurement noise take out of it. The preset is deliberately NOT part of this step, and that is a correction to the plan rather than an omission: a preset change moves `profile_idc`, `num_ref_frames`, `entropy_coding_mode_flag` and `transform_8x8_mode_flag`, all of which live in the same init segment as the size, so moving it in place would reproduce the fault this release exists to remove. It rides with the variant change, where the init is that variant's own.
525
+ - **New**: A run about to encode a picture the served init does not describe says so, once per distinct disagreement — the shape 2.48.0 uses for the TIME a run begins at. The size is read out of the init's own bytes rather than taken from our record of what the encoder was told, because those two disagreeing IS the fault. This whole class was silent: the encoder healthy, segments served in milliseconds, and nothing anywhere naming what the viewer was looking at.
526
+ - **Chore**: The per-session resolution ladder state is gone — `budgetLadder`, `budgetRungIndex`, `budgetDownshifts` and the cap on how many steps a session might take. A step is a change of variant now, and a variant is a session with its own init, so there is no rung index to walk. The ladder still chooses the STARTING rung when a session is made, which is untouched.
527
+ - **Fix**: A height this machine has been MEASURED failing at is withdrawn from the offer once the viewer has left it. The base session's own height was exempt from every refusal, which was harmless while a step rewrote the encode inside the base — that height then always WAS the rung on screen. With the step moving the viewer to another variant, the height they left went on being offered, and the way back up would have asked for the one rung the host had just been seen failing at: down, up, down, about every hundred seconds for the length of the film, each move costing a buffer flush and a cold encoder start. The rung on screen keeps its exemption, which is the one that matters; the copied source height cannot reach the refusal at all, since only re-encoding sessions have a reading to be withdrawn on.
528
+ - **Fix**: Lifting a bitrate cap and enlarging the picture are decided by two different questions. Both used to be answered by one — "can the link carry the NEXT rung" — and a session already at the top offered height has no next rung, so the answer was an unconditional yes: the cap came off a link measured at a fifth of what the picture needs, and fifteen seconds later `#checkLinkBudget` put it back. Two ffmpeg restarts every minute and a half, on exactly the thin cellular viewer the cap exists for. Whether to lift a cap is now asked about the picture the cap is ON.
529
+ - **Fix**: The bitrate cap has a floor, and a link report that is not a positive finite number is not a measurement. `linkMbps` reaches an encoder's `-maxrate` from the browser and was taken verbatim: a reading of 0.05 produced `-maxrate 40k` on a 720p encode, and because the cap only ever tightened, one bad reading pinned the session there for the rest of the film. The floor is what the SMALLEST picture this file is offered at is sized to carry — below that the link is not short of bitrate at this size, it is short of the size, and the answer is a smaller variant.
530
+ - **Fix**: One condition decides whether a stream publishes a master playlist, so the builder and the budget cannot disagree about it. A copied stream whose keyframe index could not be read falls back to an even grid ffmpeg does not cut on; the builder refuses it, but the budget looked only at how many heights could in principle be spliced, and recorded requests against a player that has no variants — once per window, for the whole film. It now refuses the same streams, and says why once per session rather than once per window.
531
+ - **Fix**: The line about a run leaving the served init behind is not written when nothing was told. `computeOutputDimensions` reads a zero target width as "no width constraint" while the encoder descriptors read it as their own default of 1280, so on a hardware host — where the budget returns nothing and the width stays zero — a 1920x816 scope source at the 720p variant would have been reported as a size disagreement that does not exist. A line whose whole purpose is to name an otherwise-silent fatal class must not cry wolf on letterboxed content.
532
+ - **Chore**: One pass of the quality budget has a public name, `runQualityBudgetOnce`. A loop that decides what the viewer sees and can only be reached through `setInterval` is a loop nothing can check; it now has nine tests.
533
+
534
+ ## 2.53.0
535
+
536
+ - **Fix**: The torrent thread no longer dies of an answer that never came. Our build of `utp-native` moves to 2.5.3-ttv.4, which stops `on_utp_accept` reading a `napi_value` the callback never wrote: it asks JavaScript for the buffer that will hold the NEXT connection and handed the returned handle straight to `napi_get_buffer_info`, while the handle was an uninitialised local and the macro that fills it inspects exactly one failure — `napi_pending_exception` — and even for that one reports the exception and carries on. Every other status is discarded, and in none of those cases does napi write anything. Read from two core dumps on 2026-08-21, at 16:49 and 19:50, both on the thread that owns the uTP socket and both with the same top frames — `v8::Value::IsArrayBufferView` under `napi_get_buffer_info` under `on_utp_accept` — over an ordinary `SpinEventLoopInternal`, so it is NOT the shutdown race that 2.49.0 narrowed. Every live session on the proxy died with the process, five times in three days. The handle is now initialised and every status read; a buffer that never arrives clears `next_connection` instead of leaving it pointing at memory just handed to the connection being accepted, and an accept with no buffer is refused rather than written through null.
537
+
538
+ ## 2.52.0
539
+
540
+ - **Fix**: A file opened at a position puts the SOUND there too. Where the audio rendition starts is computed from where the viewer is, and that reading consulted only two things — a position seeked to, and the last segment the session had served — both written by events that have not happened yet at the moment a file is opened partway through. The answer was therefore zero. Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address bar at 52:07: the picture session was created at `start=3130s` and ran from segment #781, and half a second later the audio rendition was created at `start=0s` with no `-ss` at all and set about re-encoding the film from the beginning. The player asked both for #782; the picture had it, the sound reached 57.5 s of 3130 in the 45 s the request lasted and then answered 404 — which the viewer was shown as "the proxy accepted the request but sent no video". The position a session was OPENED at is now the third reading, and `resolveViewerPosition` is pure and tested. The same calculation prepares a track for a language change, so that case is covered by the same fix.
541
+ - **Fix**: The line describing a swarm answers the question it is asked. It printed `peers=N` beside `wires=?`, which reads as two quantities of which one is unknown — while WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js`, identically in 2.8.5 and 3.0.21), so the first was the connection count and the second was a field that has never printed anything in any line it has ever written: the torrent lives on a worker thread and that property does not exist on the side doing the printing. What was missing was the other half of the question, and it is now there — how many peer addresses the client HOLDS, how many are queued to be tried, and what the tracker said the swarm has. Five offered and none connected is a connectivity fault; nobody offered is a supply fault; they need opposite investigations and one line now tells them apart. What the trackers said is kept PER TRACKER and reported as the best answer any of them gave: they answer separately, and a dead one replying `0` after a live one replied `500` would otherwise turn "several offered" into "nobody offered", inverting the very distinction being drawn.
542
+ - **New**: The wait for the first connected peer is measured, said once when it ends, and carried in the stats while it is still going. Measured 2026-08-21 on `JUFD665.mp4`: the tracker answered `seeders=5` at 13:40:30 and the first wire arrived at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait, after which the file's 12 MiB of edges arrived at 6.8 MB/s and the plan finished in three seconds. The whole cold start was that one number, and it was neither counted nor shown. The watching is attached to what `add` returns rather than inside its ready callback, because for a magnet everything it watches happens before `ready`: peer discovery starts before the metadata arrives, so the trackers' answers land before any listener exists, and the peer that DELIVERED the metadata connected before `ready` fired — `wire` is emitted on connection and never replayed. It is also attached once per torrent: WebTorrent answers a duplicate add by handing back the torrent it already has, and re-attaching reset the timing of a live swarm, after which the next connection would print "first peer connected after 0.3s" about a torrent that had been connected for minutes.
543
+ - **Fix**: One name for a torrent, and it is the infohash. The pool's `added`, `announce` and `warning` lines were labelled with the first eight characters of a sha1 of the SOURCE BYTES, the upload lines used the infohash, and the stats line used the registry's own key — three different hashes of one film, printed in the same second, none matching. The infohash is now on all of them, and on every stats line rather than only on the ones that look empty.
544
+ - **Chore**: `askedFor=0` is called `fileIndex=0`. It is the index of the file being asked about, and it was printed under a name that reads as "nothing was asked for" — in a line whose subject is a download that is not happening.
545
+
546
+ ## 2.51.0
547
+
548
+ - **Fix**: A run is POSITIONED where the player was told the segment begins, on the same table its cuts are stated on. There are two boundary tables — the one the playlist text was written from, which never changes, and the live one, corrected as produced segments reveal where the file's cuts truly are. 2.45.0 moved the CUT LIST onto the published table and left the position on the live one, and that is one fault rather than two: `-segment_times` are measured from wherever the run really began, so any distance between the two carries into EVERY cut the run makes. The corrections run backwards, so each restart began a little earlier than the grid its cuts were stated on, and because the corrections accumulate, so did the distance. Measured 2026-08-21 on `JUFD665.mp4` — an MP4 whose index was read cleanly, 1765 keyframes, served by copy: after one seek restart a produced segment held the boundary **two** places before its own number (16.684 s, exactly 2.0000 segments), after the next restart **four** (33.5 s). The player's buffer then stops extending at all, because every fragment's content lands before the time its playlist entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far` warnings until hls.js gave up and jumped the viewer 16.8 s forward. Four of those jumps in one window is what the viewer reported as sticking on every seek.
549
+ - **Fix**: The line about a segment that began away from its grid follows the numbers instead of the branch it is printed from. On the copy path it always read "the container's keyframe index disagrees with the file", including in the case this code's own neighbouring comment defines as a fault in this code — a segment holding another boundary **of the same list**. It now says which of the two it is, because they need opposite fixes and that sentence is what sent the reading of the session above after the file instead of after the arithmetic. The soundtrack's variant of the sentence stops claiming anything about a keyframe index: a soundtrack has no keyframes and is cut exactly where it was asked to be.
550
+ - **New**: A run says where it was positioned — the time, the boundary it came from, that boundary's time in the PUBLISHED table and in the LIVE one, the distance between them, and the number it starts counting from. Four numbers whose disagreement was invisible everywhere else.
551
+ - **New**: A correction to the live boundary table states the total drift it is part of. Corrections are applied one boundary at a time and each is small enough to look harmless; nobody was watching the sum, which is the quantity that actually moves the cuts.
552
+ - **Fix**: The realignment that starts the sound where a copied picture truly begins is given that instant EXPLICITLY. It used to be smuggled through the live boundary table — the correction wrote the measured time there and the restarted run read its position from the same place — which stops working the moment a run positions itself on the table the player holds, as it now must. Smuggled, the restart would land exactly where it already was: picture and sound stay apart AND a healthy soundtrack run is discarded for nothing, which is the shape the field already showed on 2026-08-21 — eleven audio restarts in under four minutes, eight of them dying with `run had produced 0.0s`.
553
+ - **Fix**: What a run was asked for is taken from the run rather than looked up again in a table. Two lines did the second — the one reporting where a run landed, and the one explaining a held segment — and once the position moved to the published grid they could disagree with the run by the distance between the two tables: a perfect landing reported as a drift, a real drift of the same size cancelled to zero, and a held segment printing a negative "produced" that sends the reader after the torrent when the encoder is the subject.
554
+ - **New**: The player's own "this fragment is nowhere near my buffer" reading is answered instead of being left in its console. `POST /api/transcode-sessions/:id/fragment-far` takes the report and the proxy replies in the log with the one fact only this side holds: which boundary the segment of that number really begins at, and whether that is the one its number claims. The report says which stream it is about, and a report about the soundtrack is answered from the soundtrack's own session: picture and sound are produced by two sessions positioned by two runs, which is how they come apart, so answering one from the other's records would state something confident about the wrong stream. Diagnostic only — nothing is repositioned on the strength of a browser's reading, because a wrong answer there would restart an encoder a viewer is waiting on.
555
+ - **Fix**: The DHT is given entry points that answer. Measured 2026-08-21 from the addon host: of the three bootstrap nodes the library ships, `router.bittorrent.com` and `router.utorrent.com` replied to nothing while a control datagram to a DNS server came back in 20 ms, and the third, `dht.transmissionbt.com`, is alive — it answered `find_node` with eight nodes — but on a host with global IPv6 its name resolves to an IPv6 address first while the DHT's socket is IPv4, so by name it was never reached. The list now carries a live node, and the names are resolved to IPv4 here rather than left to whichever family the host prefers. Measured on that host: 0 nodes after 21 s by name, 22 nodes in 5 s by address. The resolution is capped at two seconds per name, because it is awaited before the torrent client exists and a host whose resolver black-holes would otherwise hold that thread for c-ares' own four tries with nothing said. What it cost: on `JUFD665.mp4` the tracker answered `seeders=5` at 13:40:30 and the first peer connected at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait with an empty routing table beside them. A private torrent still ignores the DHT, by specification.
556
+ - **New**: The size of the DHT's routing table is said a minute after start, and an empty one is said as a warning. A bootstrap list rots — that is exactly what had happened here — and nothing reported it for as long as it was wrong, so the next list to die would be found the same way this one was: by hand, after a viewer waited four minutes.
557
+
558
+ ## 2.50.0
559
+
560
+ - **Fix**: An AVI seek is asked for late enough to survive the container's own arithmetic. AVI names a keyframe by its frame NUMBER, and `services/container-index/avi.js` turned that into a time by multiplying by the frame duration the header declares — which lands 10-44 ms from the presentation time the demuxer computes, always under one frame (measured 2026-08-21 against the files themselves: 1196 index entries against 1196 real keyframes and 901 against 901, the frames exactly right and only their names off). A name sitting just BELOW its real keyframe seeks to before it and lands on the one before that, which is the same fault the landing offset already exists for. The reader now declares how far its times may be, and the request carries that on top. Matroska and MP4 declare nothing, because they state instants outright — nine files and 11 665 keyframes with not one disagreement.
561
+ - **Fix**: A container with no keyframe index is re-encoded rather than copied against a grid nobody knows. MPEG-TS carries no index of any kind — measured the same day, 669 real keyframes and nothing to read them from without walking the file — and a copied picture can only be cut at the source's own keyframes, so declaring an even grid is a falsehood the player punishes: it walks the whole file to rebuild the timeline, or presents audio with no picture because a segment begins with nothing decodable, both field-observed 2026-08-02. Re-encoding PLACES keyframes on our own cuts, so the grid is right by construction whatever the container. A container whose index could not be read inside the budget lands here too, for the same reason. It costs an encoder, and the alternative was a broken playlist.
562
+
563
+ ## 2.49.0
564
+
565
+ - **Fix**: The torrent worker is allowed to END rather than being torn down under itself. A core dump read on 2026-08-21 named the fault the proxy has been dying of: `SIGSEGV` in `v8::Value::IsArrayBufferView` reached through `napi_get_buffer_info` from utp-native's `on_utp_accept`, called from its UDP read — all of it inside `node::Environment::CleanupHandles`, under `FreeEnvironment`, on `Worker::Run`. That is a teardown race, not a data fault, which is why neither patch our forked library already carries touched it: a datagram arriving while the environment is being freed walks into an isolate that no longer exists. `destroyAll` called `Worker.terminate()` immediately after destroying the client inside, and `terminate()` frees the environment with libuv's handle callbacks still queued. It now waits for the thread to exit by itself — once the client is destroyed nothing holds its loop open — with `terminate()` kept as a five-second fallback, because a shutdown that hangs is worse than one that is forced.
566
+ - **New**: A worker thread that ends is noticed. Only `message` and `error` were listened for, so when the thread went away the proxy simply stopped and the log ended mid-sentence — five times in three days with not one line to say so, and no way to tell our own shutdown from the thread dying. An `exit` handler now says which of the two it was, and fails everything waiting rather than leaving it hanging.
567
+ - **New**: Core dumps are capped at the newest two at startup. Each is the worker's whole address space — 4.18 GB on the field host — and four of them had nearly filled a 235 GB disk. The newest stay because they are the evidence for the fault still open. `dumpsToRemove` is pure and tested.
568
+
569
+ ## 2.48.0
570
+
571
+ - **Fix**: A copied picture now begins where it was asked to, so its cuts land on the times its playlist names. ffmpeg's own CLI moves an input seek back by `3*AV_TIME_BASE / 23` — **130.435 ms** — whenever the container does not declare `AVFMT_SEEK_TO_PTS` (Matroska does not) and a stream carries B-frames, which is sound in itself: such containers seek in decode order while the caller asks in presentation order. The consequence for a copy is that asking for a keyframe lands on the one BEFORE it, deterministically; and since `-segment_times` is measured from where the run really began while this code computed those offsets from the time it asked for, every cut of the run inherited one whole keyframe interval. Field 2026-08-20: 119 of 125 segments arriving a uniform 2.002 s early against the 0.5 s hls.js bridges, so every fragment was refused and re-fetched — on 2026-08-17 two of them 1908 times each. The request is now made that much later, bounded by half the distance to the next keyframe. Measured 2026-08-21 on Matroska with keyframes every 2 s: `-ss 10` produced a first segment starting at 8.000, `-ss 10.130435` one starting at 10.000; on MP4, where the heuristic does not fire, 10, 10.130435 and 10.2 all produced 10.000 — right in one case and harmless in the other. Not applied when the picture is re-encoded: a re-encode discards frames up to the requested time and already begins exactly there (`-ss 11` copied starts at 10.000, re-encoded at 11.000).
572
+ - **New**: A run that did not begin where it was asked to says so. The first piece a run produces is the only statement of its real origin that exists, and nothing compared the two — which is why the fault above stayed silent through two releases that touched the same grid. Said once per run, and only past what a player bridges.
573
+ - **Chore**: The tunnel-renewal test shuts its stand-in registry down deterministically. `WebSocketServer.close` waits for every connection to end and a renewal can leave one still closing, so a full suite run could hang for nine minutes on it.
574
+
575
+ ## 2.47.0
576
+
577
+ - **Fix**: The tunnel is replaced before anything upstream ends it, so a viewer no longer arrives to find no proxy. Something between the proxy and the server closes the socket after exactly **100 min 15 s** — measured across a day of logs 2026-08-20, three intervals of 100:15 wherever a restart did not reset the clock, `code=1006` each time, and with the 30 s keepalive running throughout, so it is a lifetime cap and not an idle timeout. Reconnecting afterwards takes five seconds during which this proxy does not exist as far as the registry is concerned. The connection is now replaced at ninety minutes and the replacement takes over FIRST: the new socket registers itself, the server atomically supersedes the old one, and only then does the old one close — so there is no instant with nothing registered. A socket that finds itself superseded says so rather than reporting the tunnel as down, and an abrupt close nobody asked for still reconnects as before. Pinned by a test against a real WebSocket server.
578
+
579
+ ## 2.46.0
580
+
581
+ - **New**: The cost of DECODING is measured per codec family, not once on H.264. A video that has to be re-encoded is by definition one the browser could not play — HEVC, 10-bit — so the one model the host had was fitted on the codec it is least often asked about, and those decode dearer per pixel on the same box. There are now sets for HEVC 8-bit and HEVC Main 10 beside the H.264 one (`assets/calibration/`, four clips each: two sizes at two bitrates, the smallest grid that keeps the axes independent and still leaves a spare), the source's own codec and bit depth choose the constants, and a family with no set of its own is priced as H.264 — said in the log rather than left to be inferred. Measured on a desktop 2026-08-20, the same 1080p picture at ~5.8 Mbit/s: 7.7x as 8-bit HEVC against 6.3x as 10-bit, which is why ten bits is its own family and not a multiplier. AV1 has no set yet; the release survey of 2026-07-10 found it rare where HEVC was 18 %.
582
+ - **New**: The video's bit depth is read from the probe (`parseFfmpegBitDepth`), and it travels with the source's pixel and bit rates because it decides which measurement of this host applies.
583
+ - **Chore**: When the H.264 clips do not fit, the line now says which families did. Measured on a fast desktop the same day, the H.264 readings stopped being ordered — 1080p at 9.35 Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is not a thing a decoder does — so a failure there is a measurement problem and not a missing file, and the two have to be tellable apart.
584
+
585
+ ## 2.45.0
586
+
587
+ - **Fix**: A run is cut where the PLAYER was told the cuts are. There are two boundary tables — the live one, corrected as produced segments reveal where the file's cuts truly are, and the one the playlist text was written from — and a player places every fragment by the text it holds, which never changes. The cut list handed to ffmpeg came from the live table, so every correction moved the run away from the timeline the player is reading: measured 2026-08-20 on `Minions.and.Monsters.1080p.mkv`, 119 of 125 produced segments arrived a uniform 2.002 s before the times their playlist named, against the 0.5 s hls.js bridges, and a fragment that does not land is fetched again — on 2026-08-17 two of them 1908 times each. A seek is resolved on the same table for the same reason: the time being resolved came out of that playlist. The corrections keep their purpose, which is to describe the FILE — a variant created later inherits the corrected table and publishes it, so its own playlist and its own cuts agree from the start — but they may no longer move the cuts of a session already being read.
588
+ - **New**: `FlagDefault` is read from the container itself rather than from ffmpeg's description of it. Matroska's flag DEFAULTS TO 1 and ffmpeg has applied that default by the time it prints `(default)`, so a file whose muxer marked no subtitle track is indistinguishable in the banner from one that marked every track — and the difference is the whole question, since one means "show this" and the other means "the file has no opinion". The EBML reader already walks the Tracks element for subtitle extraction and now also records whether the element was WRITTEN. Lining the two readings up is the awkward part and it is checked rather than assumed: ffmpeg numbers `0:s:N` over every subtitle stream in container order, so position is the correspondence, but each pair must agree on language or on title — one that agrees on neither, or a differing count, and the container reading is not used at all, leaving the probe's own flags with nothing claimed for them. `mergeContainerSubtitleFlags` is pure and tested.
589
+ - **Chore**: The line reporting a segment that began away from its grid said "the playlist says", while the figure it prints comes from the live table. Reading that log on 2026-08-20 cost a wrong diagnosis; it now says "the grid says".
590
+
591
+ ## 2.44.0
592
+
593
+ - **Fix**: The cues a browser is missing are found by the order they were READ, not by where they sit in the film. Cues come out of whichever clusters are downloaded, and a torrent does not arrive in film order, so the set of known cues grows in the MIDDLE as well as at the end — and the cursor shipped in 2.43.1 was a time. Measured 2026-08-20: a viewer at 272 s was answered with cues out to 1176 s, and from that moment every cue between the two was filtered away for the rest of the session, with 59 of 276 clusters read. The subtitles the viewer was about to need had become unreachable, while cues fifteen minutes ahead kept arriving. Each cue now carries the order it was found in, `?since=<n>` selects by that, and the answer states the next cursor in `X-Subtitle-Cursor`. `?after=<seconds>` still works, for a browser that has not been reloaded.
594
+ - **Fix**: One walk over the container fills EVERY subtitle track, instead of one walk per track. A Matroska cluster carries the blocks of every track that has anything to say over its span, so the bytes that answer one track answer them all — but the set of clusters already read was kept per track, so the same bytes were fetched and parsed as many times as the film has subtitle tracks. On the field film that was five reads of everything, each costing 0.2-5.2 s, for cues that together weigh a few kilobytes. The union of the tracks' cluster lists is walked once and every track is filled from it, which is also why offering all of them costs no more than offering one.
595
+
596
+ ## 2.43.2
597
+
598
+ - **Fix**: The proxy no longer dies without a word in the middle of a film. It was a segmentation fault in the uTP native library — `on_uv_read` parsed a sender address that a FAILED read never produced, and libuv passes null there — so a read error dereferenced a null pointer on the thread that owns the torrent client. Three core dumps in two days, each about three and a half hours into an otherwise healthy run, each with the same top frame; the last one on a swarm of 63-75 peers delivering 13 MB/s, one segment after a successful 158 Mbit/s send. Fixed in our build (`@torrent-tv/utp-native@2.5.3-ttv.3`, which this now depends on) and absent from upstream master. Detail: `research/utp-native-null-addr-2026-08-20.md`.
599
+ - **Fix**: A soundtrack no longer moves the grid the picture is cut on. The boundary table is the picture's cut list — it is built from the container's keyframe index, and a copied stream can be cut nowhere else — but a produced sound segment was writing its own start into it too, and the two readings are of different things: a soundtrack has no keyframes and is cut exactly where `-segment_times` asks, to within one audio frame, while the picture's cuts are the file's real keyframes. Measured 2026-08-20 on `Minions.and.Monsters.1080p.mkv`, boundary #521 was corrected 2086.084s → 2084.082s by the picture and 2084.082s → 2086.033s by the sound 1.6 s later — **1.951 s apart**, against the 0.25 s that stops a correction and the 0.5 s a player bridges. Each reading contradicted the table the other had just written, so it never converged and the correction repeated for as long as the film ran. Only a session carrying picture may correct the grid now.
600
+ - **Chore**: The line that reports a produced segment starting away from the playlist says what it measures. On the picture that is the container's keyframe index being wrong, which is what it always claimed; on a soundtrack there is no keyframe involved at all, and what it measures is how far the grid has moved since that run was launched. The per-boundary warning and the periodic summary both name the two apart now (`keyframe-index` against `sound-vs-grid`), and the summary no longer ends a soundtrack's figures with a count of keyframes read.
601
+ - **Chore**: The per-boundary warning is limited to once per segment per five seconds, like the playlist-disagreement line beside it. A run keeps cutting on the `-segment_times` list it was launched with, so once the picture has moved the grid under a soundtrack every one of that run's segments deviates — and the same segment is produced and served again and again while a player refuses it. A line each time buries the first one, which is the one somebody is reading the log for. The soundtrack summary also keeps the tolerance its count was made against; only the count of keyframes read is dropped, since a soundtrack has none.
602
+ - **Note, so the next field session is not read as a regression**: this closes the oscillation, not the gap. A run already producing keeps cutting on the `-segment_times` list it was launched with, so after a correction its segments still begin at pre-correction times until it is restarted — only a member whose run BEGINS at the corrected boundary is moved. Making the published grid agree with where runs really begin is the separate piece of work the code has been carrying a note about since 2026-08-17.
603
+
604
+ ## 2.43.1
605
+
606
+ - **New**: A subtitle request can say where the browser's copy ends (`?after=<seconds>`) and gets back only the cues past it. A track read out of downloaded clusters grows as the film does, and the browser was being sent all of it every few seconds — 76 KB a time on the field file — for the few lines at its end. The language is still detected from every cue held rather than from the handful being sent, because three lines say much less about a language than a whole track does.
607
+
608
+ ## 2.43.0
609
+
610
+ - **New**: An MP4's text subtitles are read the same way, and more cheaply than Matroska's. Where a Matroska cue costs whatever cluster holds it — the picture around it included — an MP4 states every sample's own byte range in its sample table (ISO/IEC 14496-12 §8.6.1.2, §8.7.3-8.7.5), so a cue costs its own few dozen bytes and nothing else. The tables are read out of the `moov` the keyframe reader already fetches: `stts` for when each cue starts and how long it lasts, `stsz` for its length, `stsc` with `stco`/`co64` for where its bytes are. `tx3g` (3GPP timed text) and `wvtt` (WebVTT in MP4) are decoded; `stpp` (TTML) is XML and is deliberately left out rather than half-shown. An empty sample is the format's way of saying nothing is on screen and is not turned into a blank cue. Same rule as before: only samples whose bytes are already downloaded are read, so a cue never costs a request.
611
+ - **Chore**: The MP4 reader has its own tests over a file built byte by byte — the sample table walked into times and offsets, the gap sample dropped, a `wvtt` payload decoded, and a file with no text track answering with nothing.
612
+
613
+ ## 2.42.0
614
+
615
+ - **New**: Embedded text subtitles are read out of the clusters the film is already downloading, and no longer extracted with ffmpeg. Measured 2026-08-19 on `Minions.and.Monsters.1080p.mkv`: the browser asked for a track, gave up at its own 60 s limit, and the proxy answered **752 seconds later** with 3040 bytes — because a subtitle stream is sparse and ffmpeg walks the whole container whatever range is asked of it. Measured twice more to be sure: `-ss 1200 -t 4` read to the end of the file and pulled the download from 2.7 % to 81 % of 6.5 GB, and `-copyts -ss 600 -to 604` took 154 s on a copy already 81 % local and still emitted the whole track. A subtitle block sits in the same cluster as the picture around it, so those clusters are in hand anyway: the cue points of the subtitle track name them, the blocks are read where every piece covering them is already downloaded, and nothing is requested from the swarm. **Cost: zero extra bytes**, and the cues for the part being watched are ready before the viewer reaches it — which is the rule this was held to, subtitles arriving like the picture or not at all. On the field file the plan reads in 3.8 s over the swarm and names all four tracks with their languages, and the cues come out with their real times (`118.41s → 125.71s «МАГИЯ ГОЛЛИВУДА»`). A file this cannot be read from falls back to the old extraction, unchanged. `S_TEXT/UTF8` needs no conversion; `S_TEXT/ASS` and `S_TEXT/SSA` have their dialogue fields stripped; image subtitles (PGS, VobSub) are deliberately not offered, since this path cannot show them.
616
+ - **Chore**: The Matroska block reader is its own module with its own tests (`services/container-index/matroska-blocks.js`): cluster time plus the block's own offset, the duration out of the block group, other tracks skipped, negative offsets placed correctly, and lacing stepped over rather than read as text.
617
+
618
+ ## 2.41.0
619
+
620
+ - **Fix**: An embedded subtitle track is prepared in the background and kept, instead of being extracted afresh inside a request the browser cannot hold open. Extracting one makes ffmpeg read the WHOLE film, because subtitles are interleaved through it — measured 2026-08-19 on a release with three tracks: track 0 produced **3040 bytes over 752 seconds**, track 1 76 KB over 193 s, track 2 68 KB over 55 s, with the data channel idle throughout (`maxBuffered=0`, the time all in reading the body). The browser gives up at sixty seconds, and every retry started the same twelve-minute scan again, so the first track never arrived at all. The route now starts the work once per `(source, file, track)`, answers `202 { pending: true }` while it runs, and serves the kept result the moment it exists. The scan still takes what it takes; what changes is that it happens once and its result is not thrown away.
621
+ - **New**: Every read says which way it claimed its pieces, whatever the outcome. The arm — `flat` or `bands`, chosen at random per read so the two accumulate side by side — was named only beside a WAIT, and across eight sessions on 2026-08-19 there were none: the swarm kept up, the log recorded nothing, and the comparison the arms exist for could not tell whether either had ever run. A read now reports its arm, what it delivered, how long it took and how much of that was spent waiting, at its end and under every outcome. "No wait" is the result worth counting, and it was the one being discarded.
622
+
623
+ ## 2.40.2
624
+
625
+ - **Fix**: The second place `utp-native` read a callback result that was never written. 2.40.1 got our patched build into the loading path at last, and the process went on dying — twice within an hour, 22:32 and 22:50 — with a stack naming `on_utp_accept` rather than the `on_utp_read` the patch had covered. The code there carried the comment "will never throw due to the event being NTed in js" and then read `next` unconditionally; throwing is not the only way a callback fails, and once the environment is closing or the function reference has gone, `napi_make_callback` returns without writing anything. `next` was then whatever the stack happened to hold, and V8 dereferenced it. Both places are now guarded the same way, and every other call in that file passes NULL for the result and cannot have the fault. `@torrent-tv/utp-native@2.5.3-ttv.2`.
626
+
627
+ ## 2.40.1
628
+
629
+ - **Fix**: The patched `utp-native` now replaces every copy in the tree, not just the top one. Installed at this package's own level, it left `webtorrent/node_modules/utp-native` untouched — and Node resolves from the requiring module outward, so WebTorrent went on loading the published build with the defect in it. The crash of 2026-08-19 21:03 names that exact path in frame 2, and every earlier one did too: the substitution shipped in 2.36.2 was never once in the loading path. `overrides` in this package's manifest now redirects the whole tree, npm applies it because a global install makes this package the root, and the addon image additionally deletes any nested copy and FAILS THE BUILD if a surviving `utp_native.node` belongs to another package. A silent fallback to the broken one is what made a fix that changed nothing look like a fix that worked.
630
+
631
+ ## 2.40.0
632
+
633
+ - **New**: What a reader wants is claimed in four bands of decreasing urgency instead of one, and which way it claims is decided per read so the two can be compared on real viewing. Until now there was one band at priority 1 with WebTorrent's own whole-file selection at 0 beneath it, so "what the viewer reaches in seconds" and "the rest of the film" were the same thing to the picker. The bands are: what the viewer reaches in seconds, anchored at the first piece not already held; the near lead; the far lead; and, only once the lead has covered everything to the end of the file, whatever was never downloaded BEHIND the position — which a backward seek needs and which must never compete with the picture being watched. Priorities are 4, 3, 2, 1 and none of them zero, because zero is where the library's own background fill sits, and they are distinct because the library deliberately shuffles selections of equal non-zero priority against each other.
634
+ - **New**: The widths of the lead bands are derived from what has been measured about this file on this swarm, not chosen. The near band covers the worst interruption this reader has actually met (`worst wait × the rate the consumer is taking bytes at`); the far band covers what the swarm can put ahead of the viewer between interruptions (`(download rate - consumption) × the median interval between them`). A swarm with no surplus produces no far band, which is right: there is nothing to get ahead with. The consumption rate is measured by the reader as it hands bytes over, so nothing has to be passed in or assumed about who is reading. Until two interruptions have been seen there are no figures and both bands fall back to the reader's own window, and the log says which of the two it is.
635
+ - **New**: Every wait line names the mode it happened under and the bands as they stood (`mode=bands p4:340-352 p3:353-370 p2:371-500`), and the periodic supply summary compares the two arms directly — `flat N waits median Xms worst Yms, bands M waits median Zms worst Wms` — appearing only once both have samples. `TORRENT_TV_READ_MODE=flat|bands` pins one arm for a deployment that wants no alternation.
636
+ - **New**: A wait is also recorded against whether the blocked piece was steered onto another peer at all, and the summary states both — `steered N waits median Xms, unsteered M waits median Yms`. The steering itself has been logged per wait since 2.29.0; what could not be read from it was what it bought, which is a difference between waits and not a property of one.
637
+
638
+ ## 2.39.1
639
+
640
+ - **Fix**: The block duplication of 2.39.0 is removed, because measured against what a viewer actually feels it never paid. 2.39.0 was shipped on a measurement of the median wait for a piece; the quantity that matters is the seconds the picture stands still, and measured on that (2026-08-19, a reader paced at the film's own byte rate with an eight-second lead, arms alternated per position) it is neutral at best and costly at worst. On a well-seeded film every arm read 67 MB in 59 s and stopped for at most 2.6 s — nothing to improve. With the download capped just above the film's rate, which is what a home line IS whenever the swarm can fill it, duplication made the picture stop for **28.8 s against 12.8 s** at one position and left the other unchanged: under one shared budget a duplicate spends the very bytes it is trying to go around. The regime where it could pay — several slow peers, each with capacity of its own — could not be reproduced: the thin-swarm candidate turned out to have no live swarm at all (4 peers, 179.5 s of a 209 s run spent stopped). A lever with no measured gain and a measured cost does not stay on by default. Narrowing the read window to the blocked piece was tried in the same experiment and is not shipped for the same reason: it was never better and reached **44.8 s against 12.8 s**. What stays is the measurement that decided it (`research/tail-duplication-2026-08-19.md`) and the `tail …` line from 2.38.1, which is what will say whether a real thin swarm ever behaves differently.
641
+
642
+ ## 2.39.0
643
+
644
+ - **New**: When a reader is blocked and nothing can be steered, the blocks it is still waiting on are asked of a second wire as well. WebTorrent reserves each block for exactly one wire, so once `Piece.reserve()` answers -1 the read ends when the holder of the last block delivers it, however fast the rest of the swarm is. The library's own `_hotswap` does exactly the right thing — `piece.cancel(block)` frees the reservation while the first request stays in flight — but only for a wire under 48 KB/s and twice as slow as the asker, and measured on a real swarm the tails a reader waits on sit at 109-886 KB/s. Peers the library rightly calls good, because for bulk downloading they are; the gate is about throughput across a torrent and knows nothing about a reader blocked on one piece now. Speed is not even what is wrong with them: two blocks — 32 KB — on a wire measured at 109 KB/s is 0.3 s of transfer, and that read waited 4.6 s, because the blocks are queued behind that wire's other work. **Measured against itself, same film, same positions, arms alternated, six pairs across two pacing rates: the median wait for a piece fell in all six — 39 %, 48 %, 68 %, 6 %, 50 % and 28 %.** In the one pair where the swarm had spare capacity throughout, the wait fell 7226→5174 ms and the lead the reader kept ended at +2.0 MB instead of +0.3 MB. Cost: one duplicate per candidate wire per attempt, about 3 % extra traffic, and only while a reader is blocked with every block already spoken for. The tail measurement added in 2.38.1 reports what it placed: `duplicated 14 blocks`.
645
+ - **Chore**: The first attempt at this was reverted the same day on a measurement that could not have shown it. That probe read 64 MB flat out, so its total time was bytes ÷ aggregate rate by construction — a quantity no reordering of requests can change. Reordering moves latency between pieces; it does not add throughput. The corrected probe consumes at a film's own byte rate and keeps a lead, which is the state a viewer is in, and there the effect is plain. Recorded in `research/blocked-piece-tail-2026-08-19.md` so the next such decision is not made on the wrong quantity.
646
+
647
+ ## 2.38.1
648
+
649
+ - **New**: When a blocked piece cannot be steered anywhere, the wait line says what is holding it. The steering added in 2.29.0 often places nothing — `steered onto 0 of 9 asks (8 peers held it)`, measured 2026-08-18 while eight peers had the piece — because every block is already reserved and WebTorrent will not hand out a second request for the same block (`Piece.reserve()` answers -1; the only mention of an endgame in the library is a commented-out line). Duplicating those blocks is the standard remedy and costs a block's traffic each time, so this measures the tail before anything is built on it: `tail 2/512 blocks missing, held by 1@12KB/s 1@900KB/s`, slowest wire first, and `held by nobody` when the piece has not been asked for at all. Sampled at the instant an attempt placed nothing rather than once at the start, so the numbers and the reason they are printed describe the same moment. If the missing blocks turn out to sit on one slow wire, duplication is aimed at the right thing; if they are spread across fast ones, the wait has another cause and that work should not be done.
650
+
651
+ ## 2.38.0
652
+
653
+ - **Fix**: An MP4's keyframe times are read as composition times, on the track the handler names. Two faults, both measured on real releases over the swarm (`research/mp4-composition-times-2026-08-19.md`). (1) The reader took sample times from `stts`, which is DECODE order, and used neither `ctts` nor `elst`: ISO/IEC 14496-12 says `CT(n) = DT(n) + CTTS(n)` (§8.6.1.3) and the edit list then shifts that (§8.6.6.3). Every LostFilm MP4 measured carries a composition offset AND an edit list cancelling it exactly, which is why decode times had been right on them; `Firefly.S01E03.720p.mp4` carries the same 2002-tick offset with NO edit list, and its times were **62.1 ms early on all 34 keyframes** compared against ffmpeg's own `pts_time` — a constant that closes to four decimals as offset (0.08342 s) minus the container start (0.02133 s). After the fix that file matches ffmpeg to the container start, which `computeSegmentBoundaries` already subtracts, and `Superman.720p` — where the terms cancel — is unchanged and exact to 0.0000 s. Version 1 offsets are read as SIGNED, which is what that version exists for; an empty edit (`media_time = -1`) is skipped rather than treated as a shift. (2) The video track was "the first one carrying sync samples", and the handler was never read. That worked only because all seven measured files put video first; the standard identifies a track by `hdlr`, and a file whose audio track carries sync samples, or one leading with a cover-art video track, would have been read from the wrong place — the same defect fixed in the Matroska reader the day before, arrived at from the other side.
654
+
655
+ ## 2.37.1
656
+
657
+ - **Fix**: The cost of a seek no longer counts against the quality offer. `requiredSpeed` — the speed a step must sustain to survive a swarm — is built from the reader's interruptions, and the wait on the first piece after a JUMP is not one of them: those pieces have not been asked for yet and the encoder is restarting, so it measures the move, not the supply. Measured 2026-08-18: `proxy now offers 720p` landed 131 ms after a seek, collapsing a five-rung menu to one while the player was already hunting for a fragment, and another session churned `640p` → `640p 540p` → `640p 240p`. The wait is still reported, saying plainly that it belongs to the jump and is not counted, so a gap in the history cannot be mistaken for a swarm that never made the reader wait.
658
+
659
+ ## 2.37.0
660
+
661
+ - **Fix**: The segment the viewer seeks TO is no longer refused as stale. A seek bumps the wait epoch so requests made for the position being LEFT stop being held, and the epoch alone cannot tell those apart from the request for the position just arrived at — hls.js asks for it within milliseconds of the seek, and it raced the bump. Measured 2026-08-18: a seek to 1061.0 s, `segment-00101` answered 503 twice within 80 ms, the player never asked for it again, and instead re-fetched `a/0/segment-00103` and `a/0/segment-00104` **737 and 736 times over 149 seconds** — about half a gigabyte of the same two segments — while the picture stood at `t=1061.0s readyState=1` until the session ended. A held request is now released only when its segment lies behind where the viewer now is, or so far ahead that the running encode will not reach it; anything between is what the viewer is waiting for and is held.
662
+ - **New**: The log survives the container. `--log-file <path>` writes every line to a file as well as the console, appending across restarts and rotating at 32 MB with one previous turn kept. The console is the container's stdout, and the container is exactly what does not survive a crash: thirteen SIGSEGVs on 2026-08-18 each had the watchdog recreate it, taking every line before the crash away, and a deploy of ours destroyed the evidence for two field reports the same day. Opt-in and named by the caller, so nothing here assumes Home Assistant — the addon points it at `/data`.
663
+ - **New**: A refusal says what it refused. `[hold] <segment> refused: the viewer is at <position>s and this is not the segment there`, and a request kept across a seek says so too. The old line said only "superseded", which is why the freeze above took a day to explain.
664
+
665
+ ## 2.36.2
666
+
667
+ - **Fix**: A live session no longer answers 404 to the master playlist it has just published. The browser is handed `master.m3u8` when the session is created, and which rungs are worth OFFERING is recomputed every five seconds — so on 2026-08-18 a five-rung offer became a one-rung offer **192 ms** after creation (the session's own encoder started, charging the contention penalty of 2.35.0, and the first supply reading raised the bar of 2.36.0 from 1.00x to 1.06x), `buildMasterPlaylist` returned null for having fewer than two rungs, and the master answered 404. hls.js treats that as fatal and unrecoverable, so nothing played at all. The master now lists what CAN be spliced onto this session's cut grid — a fact about the source, settled once — while the live judgement stays where it belongs, in `offeredHeights` and in every progress report, which is what the viewer's menu already follows. The variant routes honour the published set too, so a quality switch can no longer meet a 404 on a rung the master named.
668
+ - **Fix**: Peer discovery no longer starves behind name resolution. Node resolves host names on the libuv thread pool, which holds four threads by default; a torrent announces to every tracker in its file at once, so four names resolve and the rest queue — and a tracker that no longer exists holds its thread for the resolver's full ten-second timeout while every announce behind it blows its own fifteen-second deadline. Measured inside the addon container: the ten trackers of one film took **7.58 s** to resolve as a burst and **27-42 ms** each with a larger pool. That film has 517 seeders on a tracker that answers in 50 ms, and it spent eleven minutes with **zero peers** while four other torrents in the same process were fine — they were the ones whose live trackers happened to fall in the first four. The pool is now stated before anything can create it (`services/thread-pool.js`, imported first by the entry point), and a deployment that states its own size is left alone.
669
+
670
+ ## 2.36.1
671
+
672
+ - **Fix**: The cut list of a copied picture is built from the picture's own keyframes, and no longer from every entry in the container's table. A Matroska CuePoint belongs to the track named inside it, and RFC 9559 leaves the muxer free to index whichever tracks it likes — both field files index their subtitles as well. Measured over the swarm on 2026-08-18, reading only the head and the table: `Minions.and.Monsters.1080p.mkv` has **2778 video entries, one every 2.002 s, and 4669 more across four subtitle tracks**; `Moana.2.2024.720p.BluRay … MegaPeer.mkv` has **1055 video entries and 5007 across five**. Read without the track, the extra times entered the cut list as though they were keyframes; ffmpeg can only cut a copied picture at a real keyframe at or after the time it is given, so each such cut landed at the next one instead — which is exactly the disagreement the field measured, and why it was always positive: 2.002 s on the first file (its own keyframe spacing), a median of 6.3 s and a worst case of 21 s on the second. The reader now takes the first video track's number from Tracks — already inside the head it fetches, with one short extra read only for a file that keeps Tracks elsewhere — and keeps the entries of that track. Nothing else about the two-read approach changes, and a session costs nothing more. With the fix the same two files read 2778 and 1055 times, all of them keyframes. When the filter leaves NOTHING — a table whose entries name a track number Tracks never declares — the unfiltered table is used rather than no table: that case is this reader failing to recognise the file, and answering with nothing would put an even grid on a copied picture, which is the failure it exists to prevent.
673
+ - **Chore**: `scripts/read-container-index.mjs` reads the index of any `.torrent` over the swarm — two short ranged reads, in memory, no file written — so a claim about what a container says can be checked against a real film in seconds. Written after the measurement above was made by hand three times.
674
+
675
+ ## 2.36.0
676
+
677
+ - **New**: The torrent is charged for the megabytes it is measured to be moving, and the price it is charged at no longer contains work that is not the torrent's. Two faults, both visible in one field log from the addon host (2026-08-18): the same session reported **145.4 ms of CPU per MB over 8.7 MB and 23.1 ms per MB over 54 MB**, a sixfold disagreement that followed the size of the interval rather than anything about the torrent — because a process with nothing to do still runs its timers, its tunnel and its session sweeps, and that draw does not shrink when fewer megabytes move. A minimum-megabytes threshold stood against exactly this and did not hold, because a chosen number was standing in for a measured one. The draw is now measured directly, in the intervals where nothing encodes and not one byte moves, and subtracted before the rest is called the torrent's (`services/torrent-cost.js`). What the threshold was reaching for is arrived at from the readings instead: the draw's own readings disagree by a measured amount, that disagreement is worth `scatter × elapsed` seconds over an interval, and a remainder smaller than it measures the wobble in the subtraction rather than the torrent — so a small interval fails on the same arithmetic that lets a large one through, with no size chosen anywhere. The second fault: the price was then charged against the file's own byte rate — what the viewer consumes — so a fully downloaded file moving nothing still paid, and a file being fetched ahead of the viewer, which is how every session starts, paid too little. It is charged against the rate the torrent is measured to be moving, sampled every five seconds per watched torrent and divided among the files of it being read, so two episodes of one pack do not each pay for the whole download.
678
+ - **New**: No separate penalty for downloading, because the measurement says none is needed. The readings of 2026-08-18 have decoding at 10.1-11.2x with nothing running and 6.45-6.71x while the torrent pulled 1.8 MB/s — an extra 0.050-0.066 seconds of work per second of content, which is **28-37 ms per megabyte moved**, against the 20-35 ms/MB the host measures for itself. The download's effect on other work is the processor time it consumes, and that is already priced; unlike a second encoder, which costs 2.71× and is not explained by any sum. So this closes roadmap item 6 with a subtraction rather than another multiplier.
679
+ - **New**: A quality step is judged against the speed this file's own supply demands, not against a chosen margin. `1.5` (and `1.8` where decoding had no price) stood for "faster than realtime by enough", and what "enough" means is measured per file and per swarm by the reader that waits for the pieces: `1 + worst wait / median interval`, which the proxy has been printing since 2.30.0 without using. On the field torrent of 2026-08-17 that is 1.67, and on the 720p rip of the same evening 4.04-8.14 — a torrent on which no re-encoded step could have kept up, and which a fixed 1.5 admitted. Where the swarm has not been measured yet — fewer than two interruptions — the bar is realtime, which is the one thing that can be said without measuring it, and the offer is restated as soon as the reader has something to say. The refusal line names the figure it refused against and where it came from. One chosen number survives, and only where nothing can be measured: a host whose calibration produced no decode term at all is judged on an encode-only prediction that was several times optimistic in the field, and its bar stays at the 1.8 it has had since before decoding was priced, because lowering it to realtime would make the least-measured hosts the most permissive.
680
+ - **Chore**: Two learning thresholds removed, both of which were chosen numbers standing in for measurements. A reading of an encoder's speed is no longer withheld for the first twenty seconds of a run: each sample now carries the serial of the run it was taken from and a pair whose serials differ is discarded, which is what the wait stood in for — a restart spends up to a second and a half making its directory and burying its predecessor, and a sample taken in that window carries the old run's position, so paired with the new one it reads a twenty-minute seek as twenty minutes produced in five seconds. The wait cost every reading a short run could have given, which is how a rung spent three minutes below realtime teaching nothing (2026-08-15); the serial costs none. And a new median is adopted when it has moved further than the readings behind it disagree with each other, instead of by more than five per cent (`services/learned-median.js`). What remains chosen is the length of the history a median is taken over, and it is now written down as such rather than given a measured-sounding reason.
681
+
682
+ ## 2.35.0
683
+
684
+ - **New**: A second job's cost is measured on the host instead of being added as though jobs were independent. Measured on the addon host 2026-08-18, decoding the same clip: **2.10-2.25x alone, 0.79-0.90x with one encoder beside it, 0.56-0.64x with two** — the same work costs 2.6× more for having company, and 3.7× for having two. Heat is not the cause: the hot idle machine (68 °C, a lower reported clock) was the fastest reading of all, which settles what roadmap item 6 was opened for. Four cores sharing one path to memory is the cause, and it contradicts the SHAPE of the budget rather than its constants — everything in the quality offer adds seconds of work per second of content, and these readings say two jobs that each fit alone do not fit together. So the penalty is now measured at startup the way everything else is (the cheapest clip decoded alone, then again while an encoder of it runs), and the offer multiplies a step's cost by it when anything else is encoding. Beyond the readings it holds the largest rather than extrapolating: two points say nothing about a fourth job, and a budget that guesses at a memory bottleneck will be wrong in whichever direction it guesses. With nothing measured, nothing is corrected. This is separate from the availability share of 2.33.0, which removes work nobody has been charged for; this is our own work colliding with itself.
685
+
686
+ ## 2.34.0
687
+
688
+ - **New**: The proxy tells the browser the smallest buffer at which no interruption reaches the viewer, measured on the file being watched. It is one whole segment — the one being played — plus the worst wait its own reader met before the buffer could refill, from that file's recent interruptions on that swarm. On the field torrent of 2026-08-17 that is 7-9 s, where the browser has been waiting for a hand-chosen 25: sixteen seconds of spinner that nothing had shown to be necessary. Null until the reader has seen two interruptions — one wait shows no interval, and an interval invented from one point is what this work exists to remove — and the browser keeps its own figure until then. The reader measures it, the session manager states it with its own segment length, and the progress reply carries it.
689
+ - **Chore**: Removed `services/torrent-worker/supply-interruptions.js`, a second copy of the same arithmetic that was wired to nothing.
690
+
691
+ ## 2.33.0
692
+
693
+ - **New**: A quality step is judged on the machine it will actually run on. The encoder benchmark measures a QUIET host — one ffmpeg and nothing else — while the addon host was measured 99 % busy, and a step predicted at 1.83x ran at 1.01-1.12x (2026-08-17). The offer now multiplies each prediction by the share of the machine that is free, taken from the same `host-load` reading that is already printed every five seconds. What is subtracted is ONLY the work nobody has been charged for — the kernel, the container, whatever else the owner runs — because our own encoders are already priced by the concurrency arithmetic and the proxy's own work per megabyte moved. Charging those here as well is what shipped in 2.21.0 and emptied the quality menu down to a single copied height. On the field reading the correction is about 0.77, and the "not offering" line now says what the machine had to spare when it decided.
694
+ - **New**: Each step reports what its prediction was worth. When a step runs with the machine to itself, the log states the speed it was predicted at, the speed it measured, and the ratio — so the error that REMAINS after the availability correction is a number in the field rather than an argument. It is written when it moves by more than a tenth, so a steady step says it once. On the field case that correction takes 1.83x to 1.41x against 1.01-1.12x measured: part of the gap, not all of it, and this line is how the rest gets found.
695
+
696
+ ## 2.32.0
697
+
698
+ - **New**: The decode cost is fitted from a clip set that can be checked, and a term the measurements do not determine is refused instead of published as a zero. The set that shipped until now was three clips for three unknowns — an EXACT system, with two of the clips at the same pixel rate — and such a system cannot fail visibly: it returns whatever satisfies its equations. On 2026-08-17 it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`, so a film's own bitrate never entered its price, and the prediction built on it was 1.8-2.2x optimistic against the same file measured while playing. The new set is six clips — three sizes × two bitrates, the axes varied INDEPENDENTLY — cut from the same Netflix Open Content "Meridian" footage (CC BY 4.0, `assets/calibration/NOTICE.md`), 7.7 MB against 8.8 MB before. Three spare measurements give the fit a residual, and with it two questions it could not ask before: whether a term's whole effect across the measured range exceeds the scatter, and whether the coefficient exceeds its own standard error. A term that fails either is dropped, the rest are fitted again, and the log names it — a zero now means "not measured" only when it says so. A NEGATIVE coefficient is dropped too rather than clamped to zero: more pixels cannot cost less work, so a negative fit is noise beating an effect, not a discovery about the host. Measured on the developer's machine, the new set determines all three terms (`0.000520 × Mpx/s + 0.002086 × Mbit/s + 0.0033 s/s`, typical disagreement 0.0012 s/s), and the bitrate term it recovers matches the difference between the two 1080p clips to 15 %. The arithmetic is a pure module with the degenerate case as a test (`services/decode-cost-fit.js`).
699
+ - **Chore**: What the calibration costs at startup, measured rather than assumed: six clips take 14.8 s on the developer's Windows box, of which the decoding is about 50 ms per clip — an empty ffmpeg spawn there costs 774 ms, and opening each file most of the rest. Shortening the clips would therefore save nothing; the cost is spawning ffmpeg once per clip, and it is paid before any viewer exists.
700
+
701
+ ## 2.31.0
702
+
703
+ - **Chore**: The encoder run's two status strings are gone; both are now outputs of the state table shipped in 2.23.0. `session.progress.state` was maintained by hand at seven sites and `session.state` at nine, and neither could answer on its own — the warm-up test had to read both under an `||`, because one said "starting" from the first spawn until something overwrote it while the other said it again on its own schedule. What the browser is told is computed where it is sent (`wireState(runState)`), and `session.state` is reduced to the session's own lifetime: it exists, or it has been disposed. That deletes the line in the spawn path that read `state === "disposed" ? "disposed" : "starting"` — two lifetimes in one variable, which is what it was there to paper over. Verified before the change that nothing in the browser reads the wire string, so the value set is unchanged and unobserved either way; the four values it can take are the same four as before.
704
+
705
+ ## 2.30.2
706
+
707
+ - **Fix**: The cut-time shift of 2.28.0 is reverted — the field measured it and it moved the cuts OFF the source's keyframes rather than onto them. Of 75 pieces the picture produced afterwards, only **nine** began at a time the container's own table names, against **70 of 75** for the soundtrack, which the change never touched; the median distance from the playlist went from 0.04 s to 4.33 s. Before it, every piece began exactly on a named keyframe and it was the playlist that disagreed with them — which is the correction path's business, not the cut list's. The reasoning that produced the shift (that the muxer decides its cuts before the output is relabelled) was argued from ffmpeg's semantics rather than measured, and the measurement says otherwise.
708
+ - **Fix**: The steering line compared two different things. `steered onto N of M holders` summed the successes over every attempt of a wait while taking M from the last attempt alone, which is how the log came to read `steered onto 12 of 6 holders`. Both halves are now totals over the same attempts: `steered onto N of M asks (K peers held it)`.
709
+
710
+ ## 2.30.1
711
+
712
+ - **Fix**: A seek was undone a second after it was made. Measured 2026-08-17: the viewer jumped to 2083.4 s, both runs restarted at segment #373 — correctly — and then a request for #371, issued by the player BEFORE the jump and reissued a second later, dragged the encoder back to #370. The viewer sat at #374 waiting for it to return. Two things let that happen, and both are fixed. The behind-head repair refuses a request that is behind the position the VIEWER themselves reported: its existing guard only holds while a seek is still settling, which by then it was not. And a segment request may no longer move the recorded viewer position BACKWARDS past a reported seek — playback only ever moves forward from one, so nothing legitimate is lost, while a stale request can no longer rewrite the viewer's own statement, which is how the repair came to believe it. A reported seek is the viewer stating where they are; a request is evidence about where the player is reading, and evidence may refine a statement forward, never contradict it backwards. Pinned by `test/stale-request-after-seek.test.js`, whose control case shows the same traffic still repairing a genuinely misplaced run when the viewer has said nothing.
713
+
714
+ ## 2.30.0
715
+
716
+ - **New**: The speed a step must sustain, and the smallest buffer that hides an interruption, are now COMPUTED from the supply's own behaviour instead of being chosen by hand — printed first, used later. A step producing at `v` gains `v - 1` seconds of cushion per second and an interruption of `W` seconds costs `W`, so it survives its own supply only while `(v - 1) × T > W`, that is `v > 1 + W / T`, with `W` the worst recent wait for a piece and `T` the median interval between such waits. On the field torrent of 2026-08-17 that is **2.42x**, against the 1.5 assumed today and the 1.05 measured on the step that stalled; on the same file's copied stream it is 1.31 against 8x measured, which is why a copy never stalls. The buffer follows from the same readings: one whole segment — the one being played — plus the worst interruption that can arrive before it refills, whichever source it comes from, which was **7-9 s** where the browser waits for 25. Both figures are logged per file every half minute, so the next session says whether the arithmetic describes reality BEFORE anything is decided by it. The arithmetic is a pure module with the field session's own numbers as its tests (`services/supply-margin.js`).
717
+
718
+ ## 2.29.0
719
+
720
+ - **New**: A piece a reader is blocked on is handed to the fastest peers that hold it. Measured 2026-08-17: the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s — a fivefold surplus — and the reader still blocked 47 times in two minutes, 1.0-4.5 s each, on pieces a median of five peers already had. A block belongs to exactly one wire, so the read ends when the SLOWEST holder delivers, and `critical()` only lets the library take a block from a slow wire when its own picker happens to visit an idle one. This asks for it deliberately: when the wait starts, and again on the sampling tick that already runs while it lasts, the piece is pushed onto the three fastest unchoked holders through the library's own request entry with hotswap enabled. Nothing is duplicated — the library moves a block to a wire at least twice as fast, which bounds how often it can move at all. A refusal is counted rather than ignored (a full pipeline, or nothing reservable even with hotswap, means the piece waits on the wire and not on the picker), and a build that offers no such entry says so instead of failing silently. The wait line now reports `steered onto N of M holders`, so the next session says by number whether the tail shortened.
721
+
722
+ ## 2.28.0
723
+
724
+ - **Fix**: The playlist and the media agree again, and the container's keyframe table was never at fault. On a file whose first timestamp is 2.002 s, the copied picture was asked to cut at 808.808 s on the 0-based grid and cut at 806.806 s — exactly the container's start time early, because that branch keeps the source's own timestamps and re-labels the output afterwards, so a cut list stated in 0-based terms is applied 2 s away from where it means. The soundtrack, re-encoded and on the other branch, cut where it was asked. The two then wrote different values into the shared boundary table and corrected each other for the whole session (#202: 808.808 → 806.806 → 808.750 → …), the playlist drifted a whole segment from the media, and the player refetched fragments it could not place. The cut list is now stated in the source's terms on that branch, which is the same shift the seek on it already applies.
725
+ - **Chore**: Which timeline a session works on is answered by one exported predicate instead of two expressions that could disagree — and their disagreement is exactly what desynced picture from sound. Pinned by `test/cut-times-timeline.test.js`, with the field numbers in its header.
726
+
727
+ ## 2.27.0
728
+
729
+ - **Fix**: Picture and sound now begin a run at the same instant. They were asked for the same time and landed in different places: a copied picture may begin only at a real keyframe and may not begin before the time asked for — that content belongs to the previous segment — so it moves FORWARD to the next keyframe, by up to the keyframe spacing (0.58-2.96 s measured on the field file); a soundtrack has no keyframes and begins exactly where asked, to within one audio frame. So after every seek the two runs of one film began up to three seconds apart. The picture's true start is measured from the piece it produces, and that measurement now moves every other member of the family whose run begins at the same boundary. Restarted at the boundary rather than seeked to the time, deliberately: a seek decides by segment index, finds the run already begins there and answers "already within the running encode" — true about the index and false about the instant, which is why the first version of this fix moved nothing at all.
730
+
731
+ ## 2.26.0
732
+
733
+ - **New**: The keyframe-index measurement now answers the question it was raising. Each file's summary reports the distribution of how far produced segments fell from the playlist (median and worst, not one extreme), how many keyframes were read from the container, and — the discriminator — **how many of the disagreeing segments began at ANOTHER time the same table names**. That separates the two explanations that have been argued rather than measured: a table describing times the file does not have, against a table listing only some keyframes with our grid built over its gaps. Every deviation measured on 2026-08-17 was positive, 0.58-2.96 s, which is what a cut pushed forward to the next real keyframe looks like. The summary is also written every 25 distinct boundaries instead of only when a session is disposed, because a proxy restart — every addon update is one — takes its sessions with it and the summary was routinely never written.
734
+
735
+ ## 2.25.1
736
+
737
+ - **Fix**: Picture and sound are back in step. Two releases in a row moved a segment's stamp toward the playlist — 2.24.1 per session, 2.25.0 by one offset for the whole family — and both desynced playback in the field the same day. The reason is what the first segment of a run is: it is not CUT at all, it begins where ffmpeg's seek landed, and the picture must land on a keyframe while the sound needs none, so after every restart the two runs genuinely begin at different real times and the whole run carries that difference (measured: the sound's #292 began at 1587.892 s and #293 at 1592.692 s, one segment apart, the run shifted 2.5 s from the grid). Labelling each track with its own true time is what keeps them together in real time; a segment is stamped with its own start again, as it was for weeks before 2.24.1. What stays from those releases is the part that was right: one published timeline per family, and a warning when a piece lands further from the playlist than a player will bridge.
738
+ - **Chore**: The run's state now answers the questions its process handle used to be asked. Ten sites that re-derived "is this run alive" from a child-process handle, and every read of "is it suspended", now read the state machine shipped in 2.23.0; the `encoderPaused` field is gone. The two places that ask about a NAMED process — the predecessor a restart is replacing, and a deliberate stop — still ask the OS, which remains the authority on whether a pid exists.
739
+
740
+ ## 2.25.0
741
+
742
+ - **Fix**: Picture and sound drifted apart after a seek, by exactly the amount the grid had been corrected. 2.24.1 made every segment stamp itself against the playlist its own session published — but each session froze that playlist at its own creation, and a soundtrack or a quality step is created later than the picture it accompanies, so it froze a table that had since been corrected. Two members of one family then stated the same moment differently, and the corrections measured on the field file are 0.6-2.9 s. A family now publishes ONE timeline: a session created inside a family takes its base's published table verbatim and writes its own playlist from it, while the live table goes on being corrected for cutting, which is what keeps a re-encoded step aligned with the copy it joins.
743
+ - **New**: The read window grows into a lead instead of staying a fixed length. Every wait that cost time widens it by a piece; every piece already in hand gives one back, down to the size the caller sized from the file's own byte rate. The ceiling is this reader's share of the store's memory, so widening can never ask for more than the store can hold. Measured 2026-08-17, the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s while the reader still blocked 47 times in two minutes — a fivefold surplus that never became distance ahead of the head.
744
+
745
+ ## 2.24.1
746
+
747
+ - **Fix**: Seeking could leave a film dead. After a seek the encoder restarts at the segment before the target, and every segment it then produces states its own position, read out of the piece. On a file whose container index is wrong those positions disagree with the playlist the player is holding — measured 2026-08-17, a seek to 1590.4 s produced audio segments #292 and #293 carrying 1587.892 s and 1592.692 s against a playlist saying 1585.376 s and 1590.585 s. A fragment landing further from where the playlist put it than a player will bridge (hls.js bridges `maxBufferHole`, 0.5 s by default) is not recognised as buffered, so the browser asks for it again: those two segments were fetched **1908 times each over ten minutes**, every one served in 4 ms, with the picture frozen and nothing in either log saying why. A segment is now stamped where the playlist the player holds says it begins, whenever the piece's own figure is further away than that; within it the piece's own figure is kept, which is what keeps speech and subtitles together on a file whose index is slightly out. The boundary table goes on being corrected from produced segments — that is what lets a re-encoded step be cut like the copy it joins — but the correction no longer moves segments under a player holding the original playlist: the published table is frozen when the playlist text is written from it. Pinned by `test/published-timeline.test.js` with the field figures.
748
+
749
+ ## 2.24.0
750
+
751
+ - **New**: The budget's two remaining holes are closed, which is the rest of roadmap item 6. A soundtrack published on its own is a second encoder running for as long as the picture does, and it was charged at nothing; a picture being RE-ENCODED beside the step being judged — which is what every quality switch does, two encoders on purpose — was charged at nothing too. Both are priced now: the soundtrack from its own measured speed, the second picture from what it was last seen doing alone, falling back to the same model that judges every step. Nothing is charged for an encode nobody has measured and no model can price: a guess there would refuse steps on arithmetic no one performed.
752
+ - **Fix**: A soundtrack could never have been priced as shipped. Four things each made it impossible: the reading path refused renditions a measurement outright, the only call that would have filed one sat behind a guard its caller had already made, a family never contained its renditions at all — so every sum over the family missed them — and the rule that a price may only be learned from an encoder alone on the machine excludes a rendition by construction, since it runs for exactly as long as the picture it accompanies. All four found by review before release. A soundtrack's share is now recovered by subtracting what the machine is already known to be spending, which is the same arithmetic that recovers a source's decoding from a running encoder, and only when every other running encode has a price — unpriced work must never land in the soundtrack's account, because an overpriced soundtrack refuses steps the host could hold.
753
+ - **Fix**: A quality step being warmed was charged its own cost while being judged, so the step the viewer had just asked for was dropped from the offer by the act of warming it — and with every route guard reading that list, its next segment would answer 404 on a stream that was playing. On the field figures of 2026-08-15 that is 1.83x judged as 1.03x. A height is now judged against what the machine spends on everything EXCEPT it.
754
+ - **Fix**: A speed measured while the torrent was what was short is no longer filed as an encode's price. It was recorded before the check that exists to reject it, so a run starved for twenty seconds priced itself at three seconds of work per second of video — more than the machine has — and every other step was refused on the download's account.
755
+ - **Fix**: The "not offering" line is written when the ANSWER changes, not every time it is recomputed — this is asked on the path that serves every playlist, init and segment, so an unconditional line was about seven hundred identical lines an hour into a log buffer that holds five hundred.
756
+ - **Fix**: A speed measured before a downshift no longer prices the encode that replaced it. It described a picture the session had stopped producing, and it kept the step it was measured on withdrawn from the offer although nothing was producing that step any more.
757
+ - **Fix**: The offered list is recomputed when what an encoder was last seen doing changes. That figure both withdraws a step measured below realtime and prices every running picture, and it was missing from what identifies a cached answer — on a COPIED picture nothing else in the key ever moves, so the menu could stay pinned to what was computed before anything had been measured. It enters the key as the two decisions it feeds — below realtime or not, and the cost rounded — rather than as a raw speed, which moves on every reading and would defeat the cache on the hottest path in the proxy.
758
+
759
+ ## 2.23.0
760
+
761
+ - **New**: The encoder run is a transition table, and the table is the specification rather than a description of code written elsewhere. `services/encode-run-state.js` declares eight states, ten events named for what happened, two superstates and the answers each state gives — whether the input is being read, whether the process can be signalled, what a missing segment gets, what the browser is told, whether a restart is allowed. What it buys is not tidiness: five field failures in a row were empty cells — a pair of state and event nobody had considered — and a table makes an empty cell visible before a release. The edges that must NEVER exist are data too, each naming the release it cost: the 2.9.93 sawtooth where any segment request released a suspended encoder, and the 2.9.93 dead-run shortcut where the handle pointed at a corpse and every later seek was waved through as already covered.
762
+ - **New**: Every transition a real run makes is logged as state, event and target (`run-state <id> STARTING --FIRST_SEGMENT--> PRODUCING`), and a pair the table does not declare is logged as a refusal instead of being obeyed. Nothing READS the state yet — the fields it will replace keep their current writes — because whether the model matches reality is a measurement to take in the field, not an assumption to build on. This release exists to take it.
763
+ - **New**: The picture in `docs/encode-run-state.md` is rendered FROM the table (`npm run graph`), and a test regenerates it and compares, so a drawing that disagrees with the code cannot be committed.
764
+ - **Fix**: The ffmpeg a seek kills is no longer handled as the session's own run dying — and this was found by writing the table down, before it shipped. The exit handler decides whether an exit is its own by comparing against `session.ffmpeg`, and during a restart that field still names the process being killed, because the replacement is spawned a few hundred lines later. So every seek and every quality switch ran the failure branch for its predecessor: a spurious `failed` for the moment between the kill and the spawn, which a segment request landing in that window is answered 500 for; a fast-failure tally against a target that never failed; and on any host with a hardware encoder, the runtime safety net firing on each seek — the proxy downgraded itself to libx264 permanently and started an extra run at the OLD index, which took the generation and made the real restart abort. A process is now marked superseded BEFORE it is signalled.
765
+ - **Fix**: Losing the torrent's data no longer condemns a working hardware encoder. The hardware-failure fallback was asked of every non-zero exit, including a run that died because its input went away — which says nothing about the encoder. What an exit means is now classified in one place (`services/encode-exit.js`, tested by its four field cases) and the fallback is asked only of a genuine encoder failure.
766
+
767
+ - **Fix**: What the torrent costs this machine can actually be measured now. The reading is taken when no encoder is RUNNING, and a SUSPENDED encoder was being counted as one — so on a host with two sessions parked by the look-ahead cap the moment never arrived: measured 2026-08-15, four minutes of `encoders=0 running +2 suspended` in which the price could have been taken and was not. A suspended encoder costs nothing, which is exactly why that moment is the right one.
768
+
769
+ ## 2.22.0
770
+
771
+ - **Fix**: What a rung is OFFERED on is the startup measurement again, not the figure learned from a live session. The startup one is taken on a quiet machine against known clips and does not move; the learned one moves with whatever else the box was doing that second, and three field sessions in a row show the price of that: decoding learned at 0.87x, then at 1.34-1.57x, against calibration's 2.6x — each reading refusing another rung until the offer held a single height and the quality menu vanished with it.
772
+ - **New**: A live reading keeps the one thing it is authority on — itself. A rung that has actually been seen running below realtime, with the machine to itself, is withdrawn on that evidence whatever any prediction says. A rung nobody has run is judged by the startup measurement like any other, because a measurement of one rung is not a prediction about the rest.
773
+
774
+ ## 2.21.1
775
+
776
+ - **Fix**: A cost is learned only from an encoder that had the machine to itself. Beside another encoder a reading already contains that other work, and the budget then ADDS the same work again when it predicts — so the price of a file grew with every reading. Measured in the field 2026-08-15: copying, whose truth is 7.9x, was learned as 2.03x; decoding, whose calibration clips say 2.6x, as 0.87x. Every re-encoded rung was then refused (`not offering 720p=0.56x … 240p=0.66x`), the offer collapsed to the one copied height, and the viewer lost the quality menu entirely.
777
+
778
+ ## 2.21.0
779
+
780
+ - **Fix**: The two costs added in 2.18.0 and 2.19.0 were never measured in production — both features were inert. The torrent's cost read `torrentPool.client`, a field that belongs to the pool implementation that no longer runs on this thread (the WebTorrent client lives on the worker), so the byte totals were always zero and the guard that needs two megabytes of movement never passed. The copy's cost sat in a branch its only caller had already filtered out, so it never ran. The totals now come from the worker over its own protocol, and the caller admits a copying session.
781
+ - **Fix**: A speed is read as the DIFFERENCE between two readings of an uninterrupted stretch, not from ffmpeg's cumulative figure. The cumulative one counts every second the look-ahead cap keeps the encoder stopped, and a copy spends most of its life stopped — it reaches the cap in about fifteen seconds and then waits a minute, so a copy running at 8x reports 1.6x and falling. Filed as the price of copying, that would have refused rungs on a measurement of a pause. The pair is dropped whenever the encoder is paused, resumed or restarted, so every surviving pair spans real work.
782
+ - **Fix**: The torrent's cost is divided by the core count. `process.cpuUsage()` adds up every thread, while everything it is added to is wall seconds per second of video — undivided on the four-core addon host it overstated the torrent fourfold, which on the field's own rung is the difference between offering it and refusing it. Only DOWNLOADED bytes are counted, since a byte sent back to the swarm is neither hashed nor stored, and the file is priced by its own length rather than by the video stream's bitrate — the torrent moves the container, and two or three audio tracks are 10-25 % of it.
783
+ - **Fix**: The offered list is recomputed when either new figure changes, and the FIRST offer — the one a viewer sees on opening a file — is priced with the torrent's cost too. Keyed only on the decode version, the cache could never change for a copied picture, which is precisely the case these costs exist for.
784
+
785
+ ## 2.20.0
786
+
787
+ - **Fix**: A file's read window is shared between the readers it has, instead of being granted whole to each. The window is stated in seconds of playback and the piece store's memory is one budget for the whole torrent, so a viewer with a picture and a separately published audio track asked for twice what the budget was written against, and a warm-up made it three times. On 2026-08-15 that ended as it had to: every resident piece held at once, a read that returned zero bytes, and every encoder on the file taking that for the end of it. This is the first step of the sliding window, not the whole of it — pieces still leave memory only by the store's own eviction.
788
+
789
+ ## 2.19.0
790
+
791
+ - **New**: What the torrent itself costs this machine is measured and charged. Downloading a file, verifying every piece of it and pushing segments down a data channel are work on the same box as the encoder, they scale with the file's own bitrate, and the budget counted none of it — measured on the addon host with every encoder suspended, the machine was still 20-29 % busy. The figure is taken only while NOTHING is encoding, which is the one moment it can be attributed without arithmetic, and it is expressed per megabyte moved so any file's rate can be priced from it. A viewer's file is then charged at its own byte rate when deciding what quality this host can offer.
792
+ - **New**: The host-load line reports the proxy's own share of the machine beside the encoders'. The two answer different questions — whether ffmpeg is getting the cores, and how much of the box goes to everything around it — and only the first was visible.
793
+
794
+ ## 2.18.0
795
+
796
+ - **New**: Copying the picture is no longer priced at nothing. It demuxes, re-encodes the audio and writes segments, and it is what runs BESIDE every rung warmed for a quality change — the field measured it at 7.92-8.02x, about an eighth of a second of work per second of video. The figure is not a constant: a session that is copying reports its own speed, and the reciprocal of that IS the cost, learned per file as the decode cost already is (median of recent readings, only from a run past its own start, never from a suspended one).
797
+ - **New**: A rung is judged against the machine it will actually have. The cost of what the family is already committed to is added to the rung's own before the check, so the arithmetic of 2026-08-15 comes out as it did in the field: 0.125 for the copy plus about 1.05 for the 240p rung is more than the one second of work per second the machine has. Unmeasured means zero, so a host that has observed nothing is exactly as permissive as before.
798
+ - **Fix**: A copy reading taken while the torrent is short is discarded. A re-encode near realtime may be the host's limit; a copy near realtime is a copy waiting for data, since copying runs at eight times realtime — and filing that as the price of copying would refuse rungs on the download's account. An audio rendition is excluded from this learning too: it carries no picture, and its speed is the price of a soundtrack, not of a copy.
799
+
800
+ ## 2.17.0
801
+
802
+ - **New**: The encoder is benchmarked on real footage instead of a generated test pattern, and measured by ffmpeg's own progress rather than by the clock around the process. The pattern has flat areas and no grain and encodes **1.23x** cheaper than film on the same machine and preset — an error that always points at offering a rung the host cannot hold. Timing whole runs was the second error: process startup is ~0.4 s, which put `fast` and `ultrafast` within 1.24x of each other when they differ by three times. The clip is decoded once to raw frames in a temp file (feeding them through a pipe measured the pipe: the fastest presets want hundreds of megabytes a second), each preset is read from the slope between two progress reports, and the run is stopped as soon as a second of it has been covered.
803
+ - **Fix**: A preset that ends before its window is covered is still measured, but never over a window of no width — two reports a millisecond apart would have called a host twenty times faster than it is, and one such reading is what every ladder decision is then taken from. A position ffmpeg reports as the smallest signed 64-bit integer (some builds print that instead of `N/A` before the first packet) is discarded, and a slope above a thousand times realtime is treated as a fault rather than as a fast machine.
804
+ - **Fix**: Which rungs may be offered is decided from the CHEAPEST preset's throughput, not from the largest reading in the array. Measurements scatter on a busy machine — `faster` read below `fast` twice on 2026-08-15 — and taking the maximum let one noisy reading of an expensive preset raise the bar that decides what is offered. Choosing a preset still scans every entry rather than stopping at the first miss, because there the direction of that error costs picture quality, not playback.
805
+ - **Fix**: A host with nothing measured says so in those words — `the quality ladder is UNFILTERED on this host` — because that is what an empty benchmark means, and the previous wording said only that presets were unmeasured. The benchmark also can no longer stop the proxy from starting: a missing or read-only temp directory, or a locked file after a kill, is a host left unmeasured, not a process that fails to listen.
806
+ - **Fix**: The host-load line counts CPU per PROCESS across readings, and only for processes present in both. A seek kills ffmpeg and starts another whose counter begins at zero, so subtracting one total from another printed shares like `-598%`; and on a host without `/proc` the sum of no readings was reported as a confident `0%` beside honest `n/a`s.
807
+
808
+ ## 2.16.0
809
+
810
+ - **New**: While an encoder runs, one line every five seconds says what the MACHINE is doing: the share of it ffmpeg is getting, the share everything else is taking, the share spent waiting on a disk, the CPU's current clock and its temperature. The budget predicts a rung from benchmarks taken at startup on an idle box, and on 2026-08-15 it predicted 1.83x for a rung that then ran at 0.90-0.999x with nothing else encoding — and no log anywhere could say which of the candidate reasons it was. Now the reading exists: an encoder starved of cores, a machine that has dropped its clock or grown hot, and work around the encode that nobody counted all look different in this line. Linux-only and best effort — a host without `/proc` writes nothing and nothing else changes.
811
+
812
+ ## 2.15.3
813
+
814
+ - **Fix**: A magnet whose swarm never answered no longer poisons the film for good. It leaves a torrent with the right infohash and no file list, and WebTorrent then refuses the same film opened from a `.torrent` as a duplicate — so the answer to every later attempt came from the entry that knows nothing: `Proxy playback plan request failed (404): File index was not found in torrent`, reproduced in a browser 2026-08-15, and no reload could clear it because the useless entry outlives them all. A source that carries the metadata now replaces one that lacks it.
815
+
816
+ ## 2.15.2
817
+
818
+ - **Fix**: A segment request that can never be answered is answered as absent instead of being held for a minute. Changing audio track makes hls.js ask the new stream for segment #0 before anything else; the run was at #354, the repair reaches sixty segments back and no further, no seek was coming, and an encoder only moves forward — so the request was unanswerable from the moment it arrived, and holding it simply spent the player's own patience. Measured 2026-08-15: the track was made ready in 7.1 s at the viewer's position, and the viewer then watched a spinner for **63 s** — sixty of them the hold, the rest the player recovering after it failed. Deliberately narrower than the refusal 2.14.1 shipped and 2.14.2 withdrew: a request within the repair's reach, or one with a seek on its way, is still held, because for those the encoder is about to be moved there.
819
+
820
+ ## 2.15.1
821
+
822
+ - **Fix**: A magnet that never found its metadata no longer makes the same film unplayable from its own `.torrent`. One infohash is one torrent, so the second add is refused and the pool takes the one already there — which is right when it is ready and wrong when it is not: a magnet whose swarm has not answered has no file list, and everything bound to it is answered 404. Measured 2026-08-15 on the addon host: a magnet with no reachable trackers was added first, the film's own `.torrent` then joined that empty torrent instead of replacing it, `/stream` answered 404, the encoder died on its first read, and the film stayed unplayable until the proxy was restarted. A `.torrent` carries the file list, the piece hashes and the trackers outright, so when it meets a torrent with no metadata it now replaces it; two magnets still wait, because neither has anything the other lacks.
823
+
824
+ ## 2.15.0
825
+
826
+ - **New**: An audio track is prepared before the player is told to change to it — `GET /transcode/:id/a/:track/warm?position=<seconds>`, the same shape the quality rung has had since 2.12.0. Changing track makes the player discard the audio it holds, and it cannot show a frame until the new track covers the playhead: switching first and producing second therefore put the track's whole cold start on screen as a spinner over a stopped picture. Prepared first, the player finds the bytes already made. A track prepared for a change the viewer then did not make is stopped, as a warmed rung is.
827
+ - **Fix**: The audio track a viewer leaves is stopped, and a seek reaches only the track being listened to. Each track is an ffmpeg process AND a reader holding pieces of the torrent, and one viewer who had changed track once had three readers on one file — picture, the track chosen and the track left. At a seek all three revived their windows at once, every resident piece was pinned, a read ended with zero bytes, ffmpeg read that as the end of the file, and every encoder died; the sessions answered 500 to everything after that until the viewer gave up.
828
+ - **Fix**: A read waits for a piece to be released instead of failing outright. Every resident piece being read at once is not a permanent condition — a pin lasts one read of one piece — so the store now waits for one, and a released pin wakes whoever is waiting. Failing there ended a read with zero bytes, which is indistinguishable from the end of the file to the process reading it. A five-second deadline keeps a genuine deadlock visible, and the wait re-checks on a timer: waiting on events alone hung, because when everything is pinned and nothing else is in flight there is no event left to fire — it hung this store's own test for the ten minutes a run is allowed.
829
+
830
+ ## 2.14.3
831
+
832
+ - **Fix**: A separately published audio track begins where the PICTURE is, measured rather than guessed. The position this class keeps is the read head, and the viewer sits behind it by whatever the player has buffered — a figure the browser already reports with every link report, so the playhead is one subtraction away (less one segment of margin, since the report can be ten seconds old). 2.14.2 subtracted the whole look-ahead instead, which was safe but made the encoder produce up to two minutes of audio nobody would hear before reaching the part that was wanted. A report older than fifteen seconds is ignored — a viewer may have seeked since — and then the whole look-ahead is subtracted as before.
833
+ - **Fix**: A request behind the encode run is acted on when the player ASKS AGAIN, not after three seconds of waiting. Repetition is the player saying it still needs that exact segment; a delay only says time has passed, and those three seconds were part of the twenty a track change cost. A scan is told apart by what else is being asked for — more than three distinct segments behind the run within two seconds is the player sweeping the playlist, and moving the encoder to one of them would be moving it to a number picked at random.
834
+ - **Fix**: That scan count is taken over a two-second window rather than over the life of the run. Accumulated, it would have crossed the threshold on any long session and disabled the repair for good — silently, since nothing about a repair that never fires is logged.
835
+
836
+ ## 2.14.2
837
+
838
+ - **Fix**: A separately published audio track starts BEHIND the picture's read head, and a request behind its run is answered as before. Two mistakes compounded in 2.14.1 and left the viewer on a spinner that never ended. The position this class keeps is written by the segments a session serves — the READ head — while the viewer's picture sits behind it by everything they have buffered, so the track was started AHEAD of them: field 2026-08-15, the run placed at segment #16 while the player asked for #10. On top of that, 2.14.1 had begun answering such a request "not found" at once instead of holding it, which turned a condition the encoder used to correct in twenty seconds into a permanent refusal: hls.js retried #10 for a minute and a half, raised a fatal network error, recovered, and retried it again. The prompt refusal is withdrawn — it was written for a probe and met a real request — and the track now starts a whole look-ahead behind the read head, which is exactly how far apart the two can be. The price is audio the player already holds: at ten to twenty times realtime and 75 KB a piece, a second or two of work.
839
+
840
+ ## 2.14.1
841
+
842
+ - **Fix**: Changing the audio track no longer costs twenty seconds of silence. The player asks the NEW rendition for its segment #0 before anything else — measured 2026-08-15, a track changed at 159 s with the rendition correctly placed at #26 — and the repair that exists for a run placed WRONGLY took that literally: it killed the run and restarted the encoder at the beginning of the film, so the segment the viewer was waiting for arrived 20.5 s later. A rendition is never repaired by moving it, because its run is placed where the viewer is and the request behind it is the player probing; and such a request is now answered at once rather than held, since holding it spends the player's patience on a fragment that can never be produced. The refusal stands down while a seek of the rendition's own is settling: a viewer going BACKWARDS is reported to the base and forwarded to the rendition, but its run only moves when the settle fires, so until then the requests for the new position are behind the old one — and those are exactly the ones the viewer is waiting for.
843
+
844
+ ## 2.14.0
845
+
846
+ - **Fix**: A rung the source is served at by COPY is never withdrawn from the offer, and the offer is one answer for the whole file rather than one per rung. Which heights this host can serve is a property of the FILE, but a rung is a session of its own and knows only its own encode — so, asked while the viewer watched 240p, the 240p session priced the 1080p rung as a re-encode, because ITS video is re-encoded, and refused it on a machine that had been serving that exact height by copy a minute earlier. Field 2026-08-15: `proxy now offers 360p 240p` four seconds after the switch, and the viewer could not go back to the quality that worked. A copied rung costs no encoder at all, so no measurement of the host can be a reason to drop it — it is precisely where a viewer on a rung the machine cannot hold returns to.
847
+ - **Fix**: What a file costs to decode is learned from every running encoder, ahead of the realtime budget's own conditions rather than inside them. Those conditions decide whether to step the quality DOWN, and they exclude most of what is worth measuring: a rung already at the foot of its ladder has nowhere to step, and a 240p variant is its whole ladder — which is exactly the rung the field ran at 0.95x for three minutes on 2026-08-15 while learning nothing from it. A reading is refused where it would describe something other than this machine on this source: a suspended encoder (ffmpeg's `speed=` is cumulative, so a look-ahead pause decays it while nothing is being encoded), a figure that has not moved since the last one (the loop runs every five seconds and would otherwise fill the window with one frozen sample), and a run whose input is what is short.
848
+ - **New**: Audio is published once for the file, as its own rendition group in the master playlist, instead of being muxed into every quality rung. On a host that struggles to encode one stream, encoding the same AC-3 track again for each of six rungs is work spent on nothing — the tracks are identical. Each track becomes an `#EXT-X-MEDIA` entry served under `a/<track>/`, cut on the same grid as the picture so the two play together, and every `#EXT-X-STREAM-INF` names the group. Changing track is then the player fetching another rendition rather than this proxy rebuilding the session, which is a cold start with the screen empty.
849
+ - **New**: A session carries audio, or a picture, or both, and says which. A rendition is one audio track with no picture (`-vn`); a stream whose audio is published separately carries the picture alone (`-an`); everything else is muxed as before. The three are different encodes of one file and share no session, directory or encoder.
850
+ - **Fix**: Where the audio travels is settled once, when the session is made, and every variant and rendition of it inherits that answer. Derived per session instead, it disagreed with itself: a 540p rung of a copied 1080p source is offered no rungs of its own, so it would conclude "audio muxed" and carry a second copy of the track the player was already fetching from the rendition — and the same predicate could flip mid-session as the host learned what the source costs, giving a silent stream after the next restart.
851
+ - **Fix**: An audio rendition is cut on the grid of the picture it accompanies, and labelled on the same timeline. Created with no video, it was falling into the video-COPY path — the source's keyframe times and `-copyts` — while the re-encoded picture beside it was on the even grid labelled from zero: the segments the player was told about and the ones ffmpeg made drifted further apart with every cut, and the two streams were offset by the container's start time.
852
+ - **Fix**: A seek reaches the audio. The browser names one session and means the picture, so nothing repositioned the rendition, and a request far ahead of its run is not treated as a seek anywhere — after a forward jump the audio was held, refused, and left grinding forward from where it was, for as long as the jump.
853
+ - **Fix**: A rendition starts where the viewer is, read the way a quality variant reads it. The base's own position field is written only by a seek or by a segment it served itself, so on a resume-from-position open it is still unset while the player asks for segment #537 — the audio began at zero and, with the seek gap above, never caught up.
854
+ - **Fix**: Audio renditions are released with the session they belong to. Nothing outside this proxy knows their ids, so nothing else could ever release the consumer, the claim on the torrent, the directory and the encoder each of them holds.
855
+ - **Fix**: A session declares the tracks it actually produces. With the picture and the audio in separate streams it still claimed both, so the check that waits for a complete init header could never be satisfied and warned on every one, and the browser was told a stream carries audio that is not in it.
856
+ - **Fix**: Renditions are published only where there is a master playlist to publish them in, and only to a browser that asked for them. A stream served as a single media playlist has nowhere to carry an `#EXT-X-MEDIA` line, so taking its audio out would leave a picture and silence; a browser that does not know about renditions must be sent audio in its stream. Both conditions are checked in one place, and the ffmpeg arguments, the master and the rendition route all read it.
857
+
858
+ ## 2.13.0
859
+
860
+ - **New**: A quality rung is offered only where this host can produce it faster than it is watched, and the budget now knows what DECODING costs. It priced the encoder alone, which is half the work — a re-encode decodes the whole source first — so on the addon host the startup benchmark read 11.2 Mpx/s against the 2.45 Mpx/s a 240p rung needs, declared it clear by two and a half times, and the rung then ran at 0.388-0.947x: first segment 30 099 ms, later segments held 21 951 ms and 10 662 ms, while the 1080p it replaced was being COPIED at 7.8-8.9x. Choosing a lower quality was what broke playback. Three bundled clips (`assets/calibration/`, cut from Netflix Open Content "Meridian", CC-BY 4.0) are decoded once at startup and solve this host's cost as `a × Mpixel/s + b × Mbit/s + c`; a rung is then priced as `1/(1/decodeSpeed + 1/encodeSpeed)` and left out of the master playlist unless it clears realtime by the margin. Checked against a file the fit was not made from: 4.8 % error, where the encoder-only model was 209 % out on that rung. Real footage rather than a generated pattern, because `testsrc2` decodes 158 % away from a real film where these clips are 11 % away.
861
+ - **New**: The playback plan carries the heights this host could serve the file at, for both branches — copied video and re-encoded — so the viewer's quality menu is right from the moment a file is opened rather than from the moment an encoder exists. Only the browser knows which branch it will take, so both are answered; a session that then runs replaces the estimate with what its encoder really does.
862
+ - **Chore**: The decode measurement reads ffmpeg's own progress rather than the clock around the process. Starting ffmpeg costs about a second, and on a quick machine a five-second clip decodes in a tenth of that — so timing the process measured the process starting, and working around it by repeating passes and subtracting took 20-26 s and still produced a fit that had to be rejected. Progress lines arrive twice a second after startup, and the slope between two of them contains no part of it by construction: one run per clip, stopped as soon as a second of decoding has been observed. Measured on a desktop: 7.4 s and 7.9 s for the whole benchmark on two runs, agreeing to 5 %, where the differencing method gave anything between "no model" and a fivefold spread.
863
+ - **New**: A session opened at a named height starts no higher than this host can hold. The height comes from a browser that was told what is on offer, but a stale tab or a repeated address can still name a rung that was refused, and starting there means the encode never catches up — the runtime downshift would step down eventually, and the viewer would watch it happen. The ladder beneath the request is kept, as before.
864
+ - **New**: The session-create response carries `offeredHeights`, the heights this host will actually serve the file at. A stream without variants still changes quality by re-opening the session, and the browser was composing that list itself from the source height and a fixed ladder — a statement about the file where the question is about the host.
865
+ - **New**: What a source really costs to decode is learned from the encoder running on it, and replaces the estimate made from the startup clips. A re-encode pays for both halves and ffmpeg reports the sum, so subtracting the encode half — priced for the preset and pixel rate actually in use — leaves this file's own decoding, on this machine, under whatever else it is doing. The clips are H.264 while a source that needs re-encoding usually is not, which is exactly where the model was optimistic: on the field case of 2026-08-14 the clips priced that film's 240p rung at 1.58x and admitted it, while one reading of the rung itself prices it at 0.95x and refuses it. The fastest reading is kept rather than the latest, since a slow moment can be a starved download rather than a slow host. It also prices a host whose clips were never fitted, which until now could refuse nothing.
866
+ - **Fix**: The rung a viewer is WATCHING is never withdrawn from the offer. The list is recomputed as the host learns, and the reading that teaches it comes from the rung just switched to — so the rung that taught the lesson would be the first dropped, and every route guard reads that list: the next segment of a playing stream would 404 with its own encoder still running. It leaves the offer when the viewer leaves it.
867
+ - **New**: The progress response carries the rungs still on offer, restated about once a second. The menu the viewer sees is corrected as the host learns what this source costs, so a rung beyond the machine disappears from it instead of being found by switching to it.
868
+ - **Fix**: The decode cost is priced from the VIDEO stream's bitrate, not the container's. The calibration clips carry video alone and are decoded with `-an`, so the fitted term describes video bits, while the container figure adds every audio and subtitle track — a release with two or three AC-3/DTS tracks carries 1-2 Mbit/s of them, which inflated the predicted cost by 10-25 % and refused rungs on the strength of audio the benchmark never decoded. The term is also not the weak one it was recorded as: on the shipped clips an 11.7× bitrate change moves the cost 2.47×, and it is about two thirds of the predicted cost of a high-bitrate 1080p source.
869
+ - **Fix**: What a file's offer is computed from is answered on every response instead of being frozen into the cached plan — the defect fixed once in 2.9.106, in the same object and three lines under the comment recording it. A plan is cached for the life of the process while what the host will serve is not, so every later open of a file handed back the first guess and undid what the encoder had learned.
870
+ - **Fix**: A downshift chooses its preset with decoding priced, as the offer and the starting rung already did. Choosing it on the encoder alone treats decoding as free, which is what made the check and the encode disagree — and it mattered most here, on a host that has already failed to keep up and is spending one of its three downshifts.
871
+ - **New**: `--state-dir` says where to keep what this host has measured about itself; without it the file stays beside the installed proxy, exactly where it has always been. Deployments differ in what survives: on the Home Assistant addon both the install directory and the working directory sit in the container's writable layer and are discarded when an update rebuilds it, so only a directory the host keeps — `/data` there — makes the measurements outlive an update. Which directory that is cannot be decided here without putting one deployment's assumptions into proxy code, so the deployment names it.
872
+ - **Chore**: The margin is 1.5 where the prediction includes decoding, and stays 1.8 where it cannot. An encoder-only figure was several times too optimistic on the rung this exists for, so it is not fit to refuse anything: a host with no usable fit offers the whole ladder exactly as before, and its preset is still chosen against the old 1.8.
873
+ - **Chore**: Where the three-point fit produces a negative term the bitrate term is dropped and the remaining two are fitted by least squares over all three points. A negative term does not describe a host; it says the difference it was solved from is inside the noise between runs, which is what a fast machine produces — measured on a desktop, the 720p clip took longer per second of video than the low-bitrate 1080p one, because process startup is a large share of a decode lasting a second. If even the pixel slope comes out non-positive there is no measurable dependence on the source, and the fit is refused rather than invented.
874
+ - **Known limits**, both measured rather than assumed: the prediction describes an idle machine, so on the very host above the 240p rung predicts 1.58x and clears a margin of 1.5 while the field measured it at 0.388-0.947x under real load (copying 1080p, downloading, serving) — the margin is what has to carry that, and 1.5 does not. And the fit is made from H.264 clips, so it describes H.264 decoding: a source that must be re-encoded is by definition not H.264, and HEVC or AV1 decode dearer per pixel on the same box.
875
+
876
+ ## 2.12.2
877
+
878
+ - **Fix**: The transport heartbeat is written once per connection, with each channel's queue beside it. The counters it reports belong to the peer connection, not to a channel, so printing the line per channel produced two byte-for-byte identical readings — `sent=5153491` under both "proxy" and "proxy-control" on 2026-08-14 — which read as two independent measurements agreeing. The one figure that IS per channel, its queue depth, was the only real difference and was buried in a line that looked like a duplicate, leaving the second channel unobservable in the log.
879
+ - **Fix**: A channel watch ends when the transport stops knowing about its session, not only when the channel reports itself closed. The close callback is the ordinary way it ends and it does not always arrive — a peer connection can die without one — leaving a timer sampling a session that no longer exists for the life of the process.
880
+ - **Chore**: `host-timings.json` is no longer under version control, and no longer ships in the package. It is runtime state the proxy rewrites every session, so it arrived in every diff, would have carried one developer machine's medians into every published version, and would have conflicted on every release.
881
+
882
+ ## 2.12.1
883
+
884
+ - **Fix**: The grid a copied stream is cut on now describes the FILE, not the container's index. A copy can only be cut where a keyframe already is, and nothing cheaper than the index can say where that is before a byte is encoded — but an index can be wrong. Reproduced 2026-08-12 against one file, both ways: with an honest index every produced segment started exactly where declared; with the index moved 1.8 s, every segment started 1.8 s early and matched no boundary at all. The field showed the second shape, so the mechanism was never at fault and the data was. The truth arrives anyway, one segment at a time — a produced piece states where it really begins — and it is now written back into the grid, which the whole family shares. That is what lets a re-encoded rung be cut to match a copied one: it is forced onto times the copy really uses. A correction that would cross its neighbours is refused, since that is a reading from a run that began somewhere else.
885
+ - **Fix**: A warm-up is no longer cancelled by the stream that is still playing. The cancellation stood before the check for whether the active rung had actually changed, and the rung on screen asks for its own segments every few seconds — so the rung being prepared was stopped 117 ms and 1.5 s after two warm-ups began (measured 2026-08-12), and the viewer then waited out the full thirty-second warm-up for a segment nobody was making, and waited again for the switch. One switch took 43.6 s.
886
+ - **Fix**: Warming the height the base session itself serves repositions it. It was skipped because it "is the base", but the base is parked wherever the viewer left it with its encoder stopped: warming 400p found it still at `run from #0`, so the switch had nothing to fetch.
887
+ - **Fix**: Repositioning inside this class names the session it means. `requestSeek` forwards to the rung on screen, which is right for the browser — it knows only the base id — and wrong for everything internal: warming a rung moved the rung already playing instead. Split into the public forwarding call and an internal literal one.
888
+
889
+ ## 2.12.0
890
+
891
+ - **Fix**: A rung warmed for a switch the viewer did not make is stopped. Only becoming active stopped the rung being left, so trying two rungs in a row left the first encoding for nobody — three encoders at once on a host sized for one, which is the opposite of what warming is for.
892
+ - **Fix**: The warm-up closes the handle it opened. It answers without sending the bytes, and on formats whose segments are served straight off disk that left a file descriptor behind on every quality pick; enough of them and every read fails, segments included.
893
+
894
+ - **New**: A quality rung is prepared before the player is told to switch to it — `GET /transcode/:id/v/:height/warm?position=<seconds>`. A rung is an encoder that does not exist until it is asked for, so switching first and waiting second put the whole of its cold start on screen as a spinner: measured 2026-08-11, the first segment of a 240p rung producing at 1.2x took 15 988 ms, and the viewer watched all of it. The rung on screen deliberately keeps its own encoder until the player actually moves, so the wait happens behind a picture that is still playing. Both encoders run for the length of the warm-up, which is what the switch costs to be invisible.
895
+ - **Fix**: A viewer who names a resolution keeps the ladder beneath it. Forcing a rung disabled the realtime budget outright, so on 2026-08-11 a viewer picked 480p on a host that encodes it at 0.27-0.78x and the stream simply never caught up — nothing could step in, because the one thing that steps in had been switched off. The encode now STARTS at the size asked for and may still be stepped down under it. The rung's height is its name and does not move with a downshift, so the player goes on addressing it by the height it chose; what changes is the picture, and a smaller picture that plays beats a correct label that freezes.
896
+ - **New**: When a produced segment starts somewhere other than the playlist says, the line now names which boundary it DOES fall on. The two possible faults need opposite fixes and the numbers alone do not separate them: matching boundary #N-1 means this proxy's own numbering is shifted, matching none means the container's index describes times the file does not have. Measured 2026-08-11 on a 1080p Matroska, three samples out by 3.5-4.6 s, all matching #N-1.
897
+
898
+ ## 2.11.0
899
+
900
+ - **Fix**: A quality change places the new rung where the PLAYER asked for it, not where the rung being left had read to. After a level switch hls.js discards what it had buffered ahead and fetches from the picture's own position, so its first request for the new rung IS that position; the read head is a whole buffer further on. Measured 2026-08-11 on a switch back up to 400p: a 240p rung encoding at 5-6x had read 56 s past the picture, the run was placed at 3084 s, the player needed 3028 s, and nothing it asked for was ever produced.
901
+ - **Fix**: A segment request BELOW the running encode is repaired instead of being held for ever. The encoder only moves forward from where its run began, so such a request cannot be answered by anything that run does — every other far request is a claim the run may yet reach, this one is a hole. Same session: it was held for two minutes forty-one while the encoder produced 409 s of video nobody had asked for at 2.48x, and the viewer sat on a loading screen until they gave up. It now moves the encoder there, through the same settle a reported seek goes through, and never overrides a seek the viewer has actually stated.
902
+ - **New**: A quality variant can now accompany a COPIED video. The obstacle was never the encoder but the cut points: a copy can only be cut where the source already has a keyframe, while a re-encode was always cut on an even grid, so a rung's segment covered a different span from the copy's and could not stand in its place. A session now carries which grid it is cut on as a fact of its own, separately from whether its video is copied, and a variant of a keyframe-cut session inherits that grid — the same times serve as the muxer's cut list and as the keyframes the encoder is told to force. A copied stream is offered variants only when its own grid is real: with no readable keyframe index it falls back to an even grid that ffmpeg does not cut on, and nothing can be aligned to a fiction.
903
+ - **New**: What a container's keyframe index says about its own file is now counted and reported. The cut times of a copied video ARE that index, and an index can be wrong — measured 2026-08-06, one claimed a keyframe four seconds from where the real ones were. Every produced segment states where it truly begins and is already read whole in order to be stamped, so the comparison costs a subtraction and no scan: nothing is downloaded for it, and only boundaries somebody actually watched are counted. Each session ends with one line naming the container, how many boundaries were examined, how many disagreed and by how much — so silence can be told from nobody having watched, which the per-boundary warning alone could not do.
904
+ - **Fix**: The repair above is bounded, and cannot become the request-steering this proxy removed in 2.9.100. A player that cannot get what it wants scans the playlist — field log 2026-08-02, probes at #178, #681, #725, #807, #74, #245, #387 within half a second — and moving the encoder to the lowest of those would put it at the start of the film with the viewer's own requests unreachable ahead of it. What separates the two: a run placed wrongly is out by at most the buffer the player was holding, fourteen segments in the measured case, while a scan probe is out by anything at all. So only a request within sixty segments behind the head is repaired, only while an encoder is actually running (a rung the viewer switched away from stays parked), and never over a seek the viewer has stated or a target the circuit breaker has already refused.
905
+ - **Fix**: How long a segment has gone unanswered is measured against the run in force. The record was kept for the life of the session, so a timestamp left by an abandoned scan probe minutes earlier said a fresh request had already waited long enough — which would have fired the repair on the first poll, before the browser's own seek report could arrive. It is cleared with each run, which also stops the map growing all session.
906
+ - **Fix**: A copied video whose keyframe index could not be read keeps its explicit cut list. Making the list conditional on the keyframe grid dropped that case onto the `hls` muxer, which takes no cut list and writes no self-contained pieces — so nothing could read where a segment truly begins and each was stamped with a time the file does not have. That is the 4.17 s drift between speech and subtitles, which had already cost one release.
907
+ - **New**: A rung that does not cut where its grid says now says so. The check above runs on re-encoded variants too, where the meaning is different: the encoder was TOLD to put a keyframe there and did not, so a switch to that rung will not join cleanly. Hardware encoders honouring an explicit cut list is unverified — this is what will name it if one does not.
908
+
909
+ ## 2.10.0
910
+
911
+ - **New**: Quality can be changed without interrupting playback. A session that re-encodes its video now also publishes a master playlist — `GET /transcode/:id/master.m3u8` — listing every height the file can be served at, each as an ordinary HLS variant under `v/<height>/`. The player then does the switching itself: it fetches the other variant, appends it after what is already buffered, and changes the decoder's type if the codec parameters differ. Until now a change of resolution could only re-open the session, which is a cold start with the picture gone. Rewriting the media playlist underneath the player is not an alternative — ours is VOD and terminated with `#EXT-X-ENDLIST`, and hls.js re-reads only a live playlist, so anything written into it afterwards is never seen.
912
+ - **New**: A variant IS a session — same source, same file, another encode — so nothing parallel was invented for it. It is created on the first request for it and not before, which is what keeps a weak host running one encoder: the player's own bitrate adaptation is off, so no variant is ever asked for unless the viewer picked it. Two viewers on the same rung of the same file share one encode, as sessions already do.
913
+ - **New**: Only one encoder runs. A SEGMENT request for another variant is what says the viewer has moved — a playlist or an init segment is fetched to decide with, and the player fetches both for levels it may never use. On that signal the previous variant's encoder is stopped, every request still held on it is answered at once instead of running out its minute, and the new variant is pointed at where the viewer stands. It has to be told: since 2.9.100 a segment request steers the encoder nowhere, so a variant watched a minute ago is parked wherever it was left.
914
+ - **New**: The session's OWN height is one of the variants, even when the realtime budget settled on something that is not a ladder rung. Leaving it out would mean the player, on loading the master, immediately asks for a height nobody is encoding — a second cold start in place of the run already serving segments. The create response names it (`variantHeight`) so the browser can pin the player to it.
915
+ - **New**: Seek, progress, link reports and release are addressed to the session the browser was given and answered from the variant on screen. The browser holds one id for the whole file and never learns a variant's — which is what keeps the switch out of its state machine.
916
+ - **New**: A variant's playlist is answered from the base session, and no encoder is started for it. Every variant of a file has the same media playlist — same duration, same boundaries, same init name — which is precisely what makes them interchangeable. The player fetches a level's playlist to decide with and may never switch to it, so building a session there would leave a second encoder running on a host with capacity for one. Only the init segment and the segments themselves belong to a variant.
917
+ - **New**: A re-encoded session is no longer shared between viewers. A quality change acts on the session — it stops the encoder of the rung being left and repositions the one being joined — so shared, one viewer's change would kill the stream the other was watching, and that viewer's seek would then be forwarded to a variant they never asked for. The sharing given up was always narrow: two viewers had to open the same file at the same size within the same ten seconds, and a shared seek already dragged both of them. Restoring it needs the active variant tracked per consumer rather than per session.
918
+ - **Fix**: Stopping an encoder clears everything armed to start it again. The input-retry timer fires seconds after a run dies of torrent starvation — routine here — and would have spawned a run for a rung nobody was watching; the seek-settle timer did the same on a quick second switch.
919
+ - **Fix**: A variant made for a session that ended while it was being made is released at once. Making one takes seconds (a probe and a keyframe index) and the viewer can leave inside that window; registered onto a disposed session it would be reachable by nobody, since the browser never learns a variant's id, and would hold an encoder, a directory and a claim on the torrent until its own idle timer noticed half an hour later.
920
+ - **Fix**: Where the viewer is is now taken from the segments they ask for, not only from a reported seek. Playback reports no position at all, so the recorded one was as old as the last scrub — and it is what places the next variant's first encode run. A variant started an hour behind the viewer produces segments nobody will ever request, and since a segment request steers nothing, the ones they DO request would never be made.
921
+ - **Fix**: A variant's first run starts on the ten-second grid that session keys are bucketed to, floored rather than rounded. A position rounded up starts the run past the viewer, so the run just spawned is torn down and restarted before it has produced anything — about half of all switches paid that twice over.
922
+ - **Fix**: A variant that cannot be prepared answers a retryable 503 rather than a bare 500. hls.js treats a 500 on a level playlist as fatal and ends the stream, over a probe or an input that the next attempt may well get past.
923
+ - **New**: A variant's height is its NAME, settled once. The player fetches the master exactly once and addresses the variant by that name for the rest of the session, while the height a session encodes at is not stable — the realtime budget steps it down when the host cannot keep up. Deriving the name afresh would let a downshift silently rename the variant being watched, and the next segment request under the old name would build a SECOND session at the very height the host had just proved it could not manage. A downshift changes the picture inside the variant instead, as it always has.
924
+ - **Chore**: What makes the splice possible is pinned by a test: on the re-encode path the cut times are a uniform grid with keyframes forced onto it, so segment N covers the same span at every height and the source's own keyframes cannot move the cuts. Variants stop being interchangeable the moment that stops holding.
925
+
926
+ ## 2.9.141
927
+
928
+ - **New**: A held segment says whether the encoder is actually moving. The line already reported that the run was alive and at the right index and stopped there, which left the two possible causes indistinguishable: an encoder waiting on torrent pieces looks exactly like one that is encoding and has not finished. It now reports how much media the run has produced since it started and at what speed, and says outright when the position has not moved at all — which means the input is what is being waited for. Measured 2026-08-11: segment #675 was held with the run started at #675 and the encoder alive, nothing in the log could say why, and the browser then abandoned the session and built another — which is where the "second session after a seek" came from.
929
+
930
+ ## 2.9.140
931
+
932
+ - **New**: What this host takes to produce a first segment is now derived from the startup benchmark, so a machine answers correctly on its very first run. Encoder detection already encodes `testsrc2` through the real HLS pipeline and records each preset's throughput in pixels per second; one segment is a known quantity of pixels, so the time follows by division. No coefficient is involved — it is a measurement of this machine taken minutes earlier, applied to a known amount of work. Until now there was no answer at all before the first session finished, and the browser filled the gap with an assumed rate of exactly one, which was wrong by a factor of four in both directions.
933
+ - **New**: Recorded medians survive a restart, in `host-timings.json` beside the proxy. Previously every restart went back to knowing nothing and the first viewer after it saw a figure with no measurement behind it.
934
+ - **New**: Both are logged together on every real measurement — `first-segment synthetic=Xms measured=Yms ratio=Z`. The intent is to stop carrying history: if the synthetic figure tracks the measured one, the file can go and every machine is right from its first second. A ratio that varies with content instead would say the synthetic figure needs the source's own character as an input, which the probe already has. Reasoning recorded in the meta roadmap.
935
+
936
+ ## 2.9.139
937
+
938
+ - **Fix**: The proxy actually states which tracks its output will carry. It has been declaring `{video: false, audio: false}` for every session since the declaration was written, because it reads the codecs off the planner's media-info cache and that cache has only ever stored dimensions, duration, fps, start time and an HDR flag. Reading a field that is not there yields `undefined`, and `Boolean(undefined)` is `false`, so the promise was empty and silently so. Measured 2026-08-11 in the field: `declared tracks video=false audio=false`. Two things depended on it and both were disarmed — the browser could not tell "this file has no video" from "the video was lost on the way", and the init-segment guard computed a requirement of zero tracks and therefore accepted any header at all, including the audio-only one that leaves a session playing sound with no picture. The codecs are now stored where they are read.
939
+ - **Chore**: A test compares the fields the declaration READS with the fields the cache STORES. Nothing caught this: the writer and the reader each look correct in isolation, and no test had ever put the two shapes side by side.
940
+
941
+ ## 2.9.138
942
+
943
+ - **Fix**: The init segment is now required to declare every track before it is cached — the requirement was computed and then ignored. One pass worked out how many tracks a complete header must have; the next returned the FIRST header it found, whatever it declared. A piece written before the video was muxed therefore supplied an audio-only header, and that header is kept for the session's whole life, because the player fetches `#EXT-X-MAP` exactly once and never again. The browser then has no video source buffer however much video arrives afterwards. Measured 2026-08-11 on the field host: `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — an element perfectly content, playing sound, with no picture in it for as long as the session lasted. A header short of a track is now kept only as a fallback and used only if no complete one is found, which is also when the log says so.
944
+
945
+ ## 2.9.137
946
+
947
+ - **New**: A session states the track set its output will carry, and sends it to the browser. The proxy knows the set exactly — it chose it: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output. That statement is now used twice, which is the point of making it: the init segment is checked against it here, and the browser checks what it actually received against the same statement (server 0.8.161). A track lost between the encoder and the element was previously noticed only by its absence, minutes later, as a black picture with working sound.
948
+
949
+ ## 2.9.136
950
+
951
+ - **Fix**: What a complete init segment must describe is now taken from what the proxy DECLARES it will output, not from a count. 2.9.135 required two tracks, which is a guess — wrong for a film with no soundtrack, and meaningless for a source carrying several dubs, subtitles or a cover-art video stream. The output does not inherit the source's track list: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output at all. So the proxy knows the output's set exactly, because it chose it — the probe says which kinds exist, the mapping says how many are taken. Deriving the figure from produced pieces instead reads correctly only once a piece carrying every track exists, and the moment that matters is the one before that: an early piece written before the video was muxed sets the requirement to one and waves through precisely the header this exists to reject. Pieces remain as a floor, since a piece carrying more than declared is evidence, and evidence outranks a declaration.
952
+
953
+ ## 2.9.135
954
+
955
+ - **Fix**: A session no longer plays sound with no picture at all. The init segment — the header that tells the browser which tracks exist — is lifted out of the first self-contained piece and then cached for the WHOLE session, because the player fetches `#EXT-X-MAP` once and never again. A piece written before the video track had been muxed declares audio alone, and the browser then has no video source buffer for the rest of the session however much video arrives afterwards. Measured 2026-08-10 from the browser's own counters: sixty-five seconds of playing sound with `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element perfectly satisfied, with no picture in it. A header short of a track is now passed over and the next piece tried; if no piece carries the full set the richest one found is served and the shortfall is logged, so a source that genuinely lacks a stream still plays while the other possibility stays visible.
956
+
957
+ ## 2.9.134
958
+
959
+ - **Fix**: The line reporting how long a segment waited before anyone decided to restart for it now prints. A restart backs off a segment or two from what was asked for, so the request that prompted it is recorded under a higher index than the run starts at; looking it up by the start index alone found nothing, and the instrument added in 2.9.132 never said a word. It now takes the earliest request at or above the index the run begins from.
960
+
961
+ ## 2.9.133
962
+
963
+ - **Fix**: A restarted encoder no longer waits for its predecessor to die — every seek is about a second shorter. Runs shared one output directory, so two of them writing `segment-00042.mp4` at once would produce a file that is neither; the only defence was to kill the old run and block until it was gone. Measured 2.9.132 across four seeks: 712, 852, 882 and 1297 ms, against 11-15 ms of everything else a restart does. So that wait WAS the restart. Each run now writes into a directory of its own, which makes the collision impossible, so the new run starts at once and the old one is left to die in the background. Serving a segment searches the run directories newest-first, because a later run's answer supersedes an earlier one's — the older file may be the truncated output of a run that was killed mid-write, which is precisely what sharing a directory used to hide. Covered by a test that lays out two runs and insists the newer one wins.
964
+ - **New**: How long a segment waited before anyone decided to restart for it. The restart costs about a second; a seek costs five to eight, so most of the wait happens before the decision is even taken, and nothing measured that gap.
965
+
966
+ ## 2.9.132
967
+
968
+ - **New**: A restarted encoder run says what its restart cost, and how much of that was waiting for the previous run to die. A seek costs 5-8 s in the field, and the reason on record — waiting for the previous ffmpeg to exit, measured once at 0.54-1.47 s — does not account for it. The remedy under consideration is a separate output directory per run, which removes the wait entirely but makes serving a segment a search across runs: the hottest path in the proxy, rebuilt on a guess about where the seconds go. So each stage states its own cost first. Two lines: how long SIGTERM took to be obeyed, and the total from the restart being asked for to the new run being announced.
969
+
970
+ ## 2.9.131
971
+
972
+ - **Fix**: The first segment of an encoder run is served once the encoder has passed it, instead of waiting for a successor nobody is producing. A segment counted as finished only when the NEXT one had been started — sound while a run moves forward through a file, and meaningless for the segment a run BEGINS at, because the run has only just arrived there. That is precisely the segment a resume or a seek depends on. Measured 2026-08-09 with the hold instrument: `#807 exists, but the next segment (#808) has not been started yet`, held while it lay complete on disk; in August the same shape held `#317` for 46 s and then answered 404 to a browser that had already given up, three releases in a row. A run whose reported output position is past a segment's end has necessarily closed that segment, so that is what decides it now. Covered by a test that builds exactly the resume shape — a run start with no successor on disk — and insists on the bytes.
973
+
974
+ ## 2.9.130
975
+
976
+ - **New**: A segment being held says WHY, at most once every five seconds per file. A hold was silent, and that silence has now cost three releases: a file that exists, a route answering "not yet", and nothing anywhere naming which of the several reasons applied. Measured 2026-08-09 — a run begun mid-file at segment #317 produced two minutes of video from #317 upwards at 10.5x, while #317 itself was held 46 s and then answered 404 once the browser had given up, with not one line about the cause. The line names the reason, the segment the run began at, where the viewer is, whether the encoder is alive, and which index was asked for. The case that matters most is called out on its own: the first segment of a run started mid-file is held by a rule that waits for the NEXT one to exist, and that is exactly the segment a resume depends on.
977
+
978
+ ## 2.9.129
979
+
980
+ - **New**: A held segment says why it is held. A hold was silent, and that silence has now cost three releases: a file that exists, a route answering "not yet", and nothing saying which of the several reasons applied. Measured 2026-08-09 — a run begun mid-file at segment #317 produced two minutes of video from #317 upwards at 10.5x while #317 itself was held for 46 116 ms and then answered 404, once the browser had already given up. At most once every five seconds per file it now names the reason (not on disk, or present but the next segment has not been started), together with the index the run began at, where the viewer is, and whether the encoder is alive. The readiness rule — a segment counts as finished once the NEXT one exists — is the suspect for a resume, because for the first segment of a mid-file run that rule decides whether playback begins at all.
981
+
982
+ ## 2.9.128
983
+
984
+ - **Fix**: A read that ends because the reader left is no longer reported as a failure. ffmpeg is terminated on every seek and whenever the look-ahead bound suspends it, and its connection closes with it, so `write ECANCELED` on the stream route is the ordinary end of a read — yet it was logged as a warning several times a minute through healthy playback. On 2026-08-09 it was read as the cause of broken audio, which it was not. The line now says whose end it was: a reader that disconnected is recorded at debug and says so, anything else stays a warning.
985
+
986
+ ## 2.9.127
987
+
988
+ - **New**: A read that hands the file over out of order now says so. A sequential read walks forwards, so each fragment either continues the piece before it or moves to the very next one; anything else means the bytes reaching the decoder are not the file's bytes in order. Measured 2026-08-09 on a 1080p file with an AC-3 track: the encoder ran at 7.7-9.3x, produced its first segment in 9.1 s, reached 00:02:19 of 02:29:58 — and the AC-3 decoder reported "new coupling strategy must be present in block 0", "exponent 26 is out-of-range" and "invalid coupling range" while the piece store showed no spills and 100% of reads served from memory. Video was being COPIED in the same run, so the viewer lost the picture and the sound together: one fault, not two. The bounds check added in 2.9.126 catches a fragment outside the shared pool and stayed silent throughout, so the bytes came from the pool legitimately and belonged somewhere else. This names which piece arrived where.
989
+
990
+ ## 2.9.126
991
+
992
+ - **Fix**: A read whose offset lies outside its piece pool ends short and says so, instead of taking the whole source down without a word. The pool is a growable `SharedArrayBuffer` shared with the torrent thread, and an offset only means anything against the buffer of the store that produced it; when the two disagreed, building the view threw `RangeError: Invalid typed array length: 8388608` — one piece — which the process-wide handler swallowed. Reads then stopped answering for good. Measured 2026-08-09: ffmpeg was fed cut-up frames and reported them as a broken AC-3 stream ("new coupling strategy must be present in block 0"), no segment could be closed because no audio frames were produced, and segment #305 was held for a minute eight times running while 76 seeders delivered 35 MB/s. The file was fine; the reads were not. The log line now carries the offset, the length and the pool's size, so a recurrence names its own cause instead of being reconstructed from a decoder's complaints.
993
+
994
+ ## 2.9.125
995
+
996
+ - **Fix**: Playback works again. 2.9.124 shipped two names it never declared — `readSelfContainedStartSeconds`, called in `segment-formats/fmp4.js` and imported nowhere, and `SEGMENT_START_DISAGREEMENT_SEC` on the line after it — so preparing any segment cut at keyframes threw a `ReferenceError`. That is every ordinary file. Measured 2026-08-08: the playlist and the init segment were served, twelve finished segments lay in the session directory, and the request for segment #0 was held for 45 281 ms and then answered 404 because the browser had given up and released the session. Peers, download rate and transport were all healthy throughout, so nothing in the logs pointed anywhere near the cause.
997
+ - **Fix**: A fault while preparing a file that EXISTS is now reported instead of being passed off as "not produced yet". One `try` covered both the existence check and everything after it, and its `catch` meant only the first, so the exception above came out as "still warming up" — the request was held, the next poll threw the same exception, and so on until the viewer left. Nothing was logged at any point. The existence check now stands alone; a file that goes away between the check and the read still means "not ready", and anything else is logged with its stack and answered as a failure. The same split applies to the init segment.
998
+ - **Chore**: `npm test` runs the linter before the tests. The linter added in 2.9.103 exists precisely to catch an undeclared name, and it names both of these — it simply was not run before releasing 2.9.124, because nothing ran it.
999
+ - **Chore**: A test asks the session manager for a segment that exists and insists on getting the bytes back. Every unit test of the fMP4 path passed while playback was dead: they import the function straight from `mp4-boxes.js`, so the missing import in its CALLER was invisible. A second test pins that a fault in preparing an existing segment is answered as a failure, not as an endless wait.
1000
+
1001
+ ## 2.9.124
1002
+
1003
+ - **Fix**: Subtitles no longer drift away from the picture. A segment was stamped with the time the PLAYLIST assigned it, and the playlist is built from the container keyframe index — which can be wrong. Measured 2026-08-06 on a Matroska file whose index claimed a keyframe at 157.99 s where the real ones were 153.820 and 164.247: ffmpeg cut at 153.820, the stamp said 157.99, and the player was told that picture belonged 4.17 s later than it did. Subtitles, extracted straight from the source with no offset of any kind, kept the true times, so speech and text sat 4.17 s apart for the whole stretch. The stamp now comes from the piece itself — the muxer records its position as an empty edit at the head of the track edit list, read before the header is stripped — and falls back to the playlist only when the piece does not say. Identical to the old figure whenever the index is honest, so a well-formed file is unaffected. When the two disagree by more than a quarter of a second the log names both, so an index that lies is visible rather than merely felt. Verified against ffmpeg on pieces cut the same way (12/30/36 s read back exactly), and covered by tests including a 64-bit edit list and a non-default movie timescale.
1004
+
1005
+ ## 2.9.123
1006
+
1007
+ - **Fix**: The check added in 2.9.121 was deleting the file an encoder was writing into. A segment short of a track means one of two very different things — left behind by a run that was killed, or simply not finished yet — and treating them alike removed the file mid-write, after which ffmpeg went on writing to something nobody could open and the segment never appeared. Measured 2026-08-06: segment #225 was deleted 14 s into the run producing it and answered 404 thirty-three seconds later. The readiness rule could not prevent it, because it waves a segment through once the NEXT one exists and that next one had been left by an older run. Ownership decides it now: the current run writes from its start index upwards, so a file at or above that index while the run is alive is unfinished and is waited for, and only a file below it, or any file once no run is producing, is a leftover worth removing.
1008
+
1009
+ ## 2.9.122
1010
+
1011
+ - **Fix**: A session created with a start position begins encoding there, instead of at the top of the file. The position was honoured everywhere except the one place that mattered: it went into the session key and into the log line, and then the first run started at index 0 regardless. Measured 2026-08-06 on a Retry after the proxy had restarted — the session was created with `start=1580s`, the encoder began at #0, the player asked for #152, and 45 s later the browser gave up with "no data arrived from the proxy" while the transcode ran happily at 9.9x through the opening credits.
1012
+
1013
+ ## 2.9.121
1014
+
1015
+ - **Fix**: A segment that is short of a track is no longer served, which is what left a seek hanging with the proxy answering every request in 98 ms. A run that is terminated closes its current output file properly — trailing index and all — but the file holds only what had been muxed by then, and after a seek-restart that is routinely one track of two. Nothing about such a file looks unfinished: it exists, the next one exists, so the readiness rule called it done. Measured 2026-08-06 on a stuck session: segment #133 carried one `tfdt` where #131 and #134 carried two, and #132 was zero bytes. The fragments are already walked to stamp their timestamps, so counting the tracks in them costs nothing; a segment missing one is deleted and produced again.
1016
+ - **New**: The session-start line says where the browser asked the encoder to begin. A resume that reaches hls.js but not this call makes the player request a segment nobody was told to produce — measured the same day, the session began at #0 while the player asked for #127 and gave up 45.6 s later — and neither side said what it meant.
1017
+
1018
+ ## 2.9.120
1019
+
1020
+ - **Fix**: The rest of the file is fetched while the viewer needs nothing, instead of the link sitting idle. The background fill was re-evaluated only when a reader window MOVED, so during exactly the state it exists for — the encoder held back by the look-ahead cap, the viewer comfortably ahead, the link free — nothing was fetched at all. It is now owned by the pool's own timer rather than by the reader, because a parked reader cannot act, and parked is the whole point. Priority 0 against the window's 1, and withdrawn the moment any reader window wants something, so it can never take capacity from the picture.
1021
+ - **Fix**: The stall warning stays quiet when a download of zero is correct. It fired all through a healthy session on 2026-08-06 — 65.3% of the file present, the encoder 134-159 s ahead, its window complete — and a warning that goes off when everything is right teaches the reader to ignore it. It now checks whether any reader window is actually missing a piece before saying anything.
1022
+ - **New**: The progress response carries what this host takes to create a session and to produce a first segment. Both are on the playback plan too, but the browser reads that once per file: measured 2026-08-06 across four seeks, a proxy that had just restarted answered null for both, so every later seek estimated the wait with one term of four — the figure reached zero after 3.5 s of an 11.8 s wait and read "starting now" for the remaining 8.4 s. This response is polled about every 1.5 s.
1023
+
1024
+ ## 2.9.119
1025
+
1026
+ - **Fix**: The progress report says the height the viewer is actually watching, not only the one an encoder is producing. It was zero whenever the video was copied — which is most sessions — so the quality menu read a bare "Auto" in exactly the case it was built to explain. Copying reports the source height, re-encoding reports the rung the proxy has settled on.
1027
+
1028
+ ## 2.9.118
1029
+
1030
+ - **New**: Suspending the encoder says what the decision was taken on — where the viewer is, how far the unbroken run of segments reaches from there, and how many segment files the session directory holds. Suspending stops the only thing that reads the input, so a wrong reading here stops the download as well: measured 2026-08-06, the log announced "135s ahead of the viewer" while three segments totalling 31 s lay on disk, and neither figure could be checked against the other because the line carried no evidence. The directory keeps the segments of every run a session has had, so which of them were counted is the whole question.
1031
+
1032
+ ## 2.9.117
1033
+
1034
+ - **Fix**: A seek backwards no longer kills playback. How far the encoder is ahead of the viewer was measured as the highest segment number lying in the session's directory, which equals the look-ahead only while a viewer moves forward through one run. Measured 2026-08-06: a seek forward left segments 662-665 on disk, the viewer seeked BACK to 646, and the limiter compared 6950 s of output against a viewer at 6700 s, called it "250s ahead" and suspended the new run **136 ms after it started**, before it had produced anything. With the encoder stopped nothing read the input, so no pieces were requested — `0 selection(s)` with 33 peers connected — and segment 646 was never made; the viewer sat on a spinner for four minutes while a stopped ffmpeg was held back for being too far ahead. The measure is now the unbroken run of segments starting where the viewer is, and a viewer whose own segment is missing is not "zero ahead" but waiting, which resumes the encoder instead of pausing it. Covered by tests, including the field case.
1035
+ - **New**: Groundwork for switching quality without interrupting playback: the heights a source can be served at, the master playlist that offers them as HLS variants, and creation of a variant's encoder on first request. Not yet reachable — the routes come with the browser side. Reasoning in `research/seamless-switching-2026-08-06.md`.
1036
+
1037
+ ## 2.9.116
1038
+
1039
+ - **New**: A progress report says which height is being produced right now. Under automatic quality the proxy steps the resolution down when the host cannot encode in realtime or the viewer's link cannot carry the stream, and nothing said so — the menu read "Auto" whatever it had settled on. Zero when the video is copied, because then nothing is being chosen and the source's own height is what plays.
1040
+
1041
+ ## 2.9.114
1042
+
1043
+ - **Fix**: A seek leaked a pinned piece, and enough of them destroyed the torrent. A piece is pinned before its fragment is handed to the reader and released by whoever received it — but a consumer that ABANDONS the read never gets the chance, and a seek abandons it every time: the encoder is killed, the response is torn down, the loop is left between two fragments. Field 2026-08-06, one seek was enough: the store answered `Every resident piece is pinned; no slot can be freed`, and it answered it to the WebTorrent client, which closed the store and destroyed the torrent — after which every read failed with `File 0 not found`, the session went terminal, and the segment the viewer was waiting for returned an instant error. The pin of a fragment still in the consumer's hands is now dropped by the reader itself on every exit, abandonment included. Covered by a test that abandons a read mid-fragment and checks the store has nothing pinned.
1044
+ - **Chore**: The look-ahead reports that ffmpeg's position and the segments on disk disagree on the EDGES of that state — once when they part company, once when they meet again, with how long it lasted. It was printed per call of a function that runs on every segment request, which after a seek meant three hundred times a minute; a rate limit would only have hidden that the line was in the wrong place, and it could not have said how long the disagreement went on.
1045
+
1046
+ ## 2.9.113
1047
+
1048
+ - **New**: The transport's own counters are written to the log for as long as a channel is open, not only when its send queue backs up. The queue was the wrong thing to watch: field 2026-08-06, a 9.26 MB segment was accepted by the transport with `maxBuffered=0 bufferedAtEnd=0` and reported as sent at 274 Mbit/s, and it never arrived — after which everything the proxy sent vanished the same way while requests kept arriving in the other direction. With nothing ever queued the existing watcher never woke, so the one question that decides the cause — did those bytes leave the machine — had no answer in the log. Every five seconds it now records bytes sent and received by the transport itself, the queue depth, the round-trip time, and the path in use. The browser records the matching figures on the same cadence (server 0.8.110), so a recurrence is settled by subtracting one line from the other rather than by reasoning.
1049
+
1050
+ ## 2.9.112
1051
+
1052
+ - **New**: A session whose data went away now waits for it to come back instead of dying. Losing the input is not the session failing — the torrent can be added again and the pieces downloaded again — but a run that died that way marked the session terminal, and every request for the playlist answered 500 from then on, although the swarm was right there and the data would have returned in seconds. Such a run is now retried at the position the viewer is waiting at, backing off from 2 s to at most 15 s so a source that is genuinely unavailable costs a process every few seconds rather than continuously, and the requests being held are simply held: nothing is broken and there is nothing for the viewer to retry. The circuit breaker stays for what it was built for — a target that truly cannot be encoded — and no longer condemns a session that merely lost its data. Which of the two happened is decided by the message, tested against the exact ones the field produced.
1053
+
1054
+ ## 2.9.111
1055
+
1056
+ - **Fix**: The film being watched could be deleted mid-seek. A torrent's data is protected only by a claim that READS take, and a seek leaves a gap with no read at all — the old encoder is dead, the new one has not started. The thirty-second disk sweep met that gap on 2026-08-06: with the cap exceeded it evicted "the idle torrent" that a viewer was in the middle of, deleted six gigabytes, and the new encoder found nothing to read. The session's own thirty minutes never governed the data underneath it, because the pool was never told a session existed. A session now holds its source for as long as it lives, and lets go when it is disposed.
1057
+ - **Fix**: The encoder is no longer suspended on ffmpeg's word alone. How far it has run ahead was taken from the position ffmpeg reports for itself, and that is not evidence: field 2026-08-06, it claimed 6012 s processed at `speed=1.18e+03x` on a file one percent downloaded with exactly one segment on disk. The limiter believed it, suspended the encoder twelve seconds into the session, and segment #1 — which nobody was now producing — was held for 45.7 s until the viewer gave up and seeked. It is now measured by the segments that exist, which is what the viewer can actually be served, and a wide disagreement between the two is logged, since that is the only trace of whatever made ffmpeg report a position it had not reached.
1058
+
1059
+ ## 2.9.110
1060
+
1061
+ - **Fix**: A torrent the pool had destroyed was still being handed to readers, which killed every later session for that source. The torrent thread remembers each source as a promise and only ever forgot one when the ADD failed — but the pool destroys a torrent that has gone unread for a quarter of an hour, and under disk pressure, clearing its own map and knowing nothing about this one. The promise then resolved to a corpse: a destroyed torrent keeps its object and loses its files. Nothing noticed, because by then everything else answers from cache — measured 2026-08-06 on two sessions in a row, the plan came back in 23 ms and the session was created in 2 ms, so no step waited for metadata, and ffmpeg's first read died 130 ms in with `File 0 not found in torrent:…`; every request for the playlist then answered 500 until the viewer gave up. Both sessions were from a phone on a cellular link, which is what made it look like a connectivity problem — ICE had in fact connected in 0.84 s over reflexive addresses and both data channels were open. A handle that cannot be read from is now replaced rather than returned: the source is added again, using the recipe the thread now keeps for exactly this. Covered by tests.
1062
+
1063
+ ## 2.9.109
1064
+
1065
+ - **Chore**: A session now outlives a vanished browser by thirty minutes instead of ten. The number means something different since server 0.8.103: a browser that holds a session re-asserts it every 30 s, so an open tab never consumes this at all — not while paused, not across a three-hour film. What is left is the case where the browser has genuinely gone, and keeping the session means such a viewer returns to a warm encoder rather than a cold start. While nobody is there the encoder is suspended and burns no CPU; the cost is disk for the produced segments, already bounded by the pool's 10 GB cap with eviction. Thirty minutes covers a meal, a phone call or a lift ride.
1066
+
1067
+ ## 2.9.108
1068
+
1069
+ - **New**: A send queue that stops draining now says WHY, instead of leaving the cause to be guessed at. Field 2026-08-06: a channel stayed open, kept accepting requests and delivered nothing for eleven minutes — the queue grew from 214 049 to 239 731 bytes in fourteen seconds and never fell, while the route reported answering in 15 ms and the channel reported itself open. `bufferedAmount` alone cannot distinguish the possible causes; it only proves the bytes are still ours. Each channel is now sampled every second, and once its queue has failed to fall for five seconds the transport itself is asked: bytes sent, bytes received, round-trip time, connection and ICE state, and the candidate pair in use — then again every second, so the trend of each counter is in the log rather than one snapshot. The reading is decided in advance and written beside the code: bytes-sent rising with the queue means packets leave and nothing acknowledges them (the return path is broken); bytes-sent flat with the queue rising means SCTP is not transmitting at all (the peer's receive window is shut, or congestion control has collapsed); bytes-received still rising in either case proves the peer is alive and the failure is one-directional.
1070
+
1071
+ ## 2.9.107
1072
+
1073
+ - **Fix**: 2.9.106 could not produce a playback plan at all — `Failed to prepare playback plan: firstSegmentMs is not defined`. Moving the two host timings to be read when a plan is ANSWERED removed the two variables but left the object literal still naming them, on the path that builds a fresh plan. My own linter reports it in four seconds and I did not run it, which is the second time an undeclared name has reached a release; `npm publish` now runs it, so this class of error cannot leave the machine again. The test added with 2.9.106 did not catch it because it exercises the cached path only — the fresh-plan path needs a real probe.
1074
+
1075
+ ## 2.9.106
1076
+
1077
+ - **Fix**: The two figures the browser needs to say how long until playback now reach it for the file that needs them most. Both are medians of sessions already finished on this host, and the plan read them at the moment it was BUILT and then cached the result — so the very first file opened after a restart got `null` for both and kept answering `null` for the life of the process, however many sessions ran afterwards. Measured 2026-08-05: a fresh proxy answered `null`, then created the session in 6 ms and produced the first segment in 21 479 ms. They are now read when the plan is ANSWERED, so a cached plan reports what the host currently knows. Covered by tests.
1078
+ - **New**: When the stats route has nothing to report it says which thing is missing — the torrent handle, the file index, or neither. A source answered `peers=0 file=n/a header=n/a` for minutes on 2026-08-05 while that very torrent was announcing to trackers with hundreds of seeders, and the line could not tell those cases apart. That line is what the viewer's loading screen shows, so it has to be answerable from the log.
1079
+
1080
+ ## 2.9.105
1081
+
1082
+ - **Fix**: A reader's claim on pieces is put back when WebTorrent drops it, which is what stopped a download dead for eleven minutes. A reader declares the window it needs as a selection and withdraws it when it ends; that claim turns out not to be durable — the library deletes a selection the moment every piece in it is present (`remove fully downloaded selection`). While the reader keeps moving this is invisible, because the next window is claimed at once. It is fatal when the reader STOPS: the encoder gets held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a window that is fully downloaded, the selection disappears, and no code of ours can notice because the reader is parked inside a write. Measured 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at 22:45:05 and stayed there for eleven minutes with 150 peer connections open and the new diagnostic reading `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in flight`; when the encoder was let go there was nothing ahead of it. Live reader windows are now re-asserted from the pool's own timer, using the set the piece store already keeps, and only where something is actually missing — re-claiming a satisfied window would only be deleted again on the next pass.
1083
+
1084
+ ## 2.9.104
1085
+
1086
+ - **Fix**: An encoder run that stops because its input ran dry is no longer reported as a finished file. ffmpeg exits 0 both when it reaches the end of the source and when the source simply stops delivering, and over HTTP it cannot tell the two apart — so when a torrent's download died mid-session (field 2026-08-05), a run that had produced 188 segments of 624 logged `encode-run complete`, the player consumed what was already on disk and then froze for 60 s on the first segment nobody was making. The claim is now checked against the playlist that was published: a run that stopped short is a failure, which the session can restart, rather than a completed file.
1087
+ - **New**: A download that stalls says so, and says which of the two possible reasons it is. The same session spent five minutes at **1 KB/s** with 186 peer connections open and trackers reporting ~300 seeders, on a torrent that was not finished, and produced no log line at all — the collapse had to be reconstructed afterwards from three unrelated counters. A torrent with an active reader that drops below 32 KB/s for ten seconds now reports how many pieces are selected and still missing, how many are marked critical, how many peers hold what we want, how many are choking us, how many are being asked and how many blocks are in flight. That separates "the swarm was never told what we need" from "it was told and will not deliver", which the previous evidence could not.
1088
+
1089
+ ## 2.9.103
1090
+
1091
+ - **Fix**: Playback worked in neither 2.9.101 nor 2.9.102. Both cold-start estimates keep a window of recent samples, and the constant naming that window was used twice and declared nowhere. The session-create one runs on every new session, so `POST /api/transcode-sessions` answered 500 to every viewer and the browser then reported the first segment missing. Field session 2026-08-05: the plan succeeded in 5858 ms, the session request failed 47 ms later, the data channel closed 16 ms after that.
1092
+ - **Fix**: The fallback read path threw the same way. `createReadStream` passed a `windowBytes` its own signature never accepted — a reference to nothing, which in a module is an error, not an undefined. It is the path taken for a source with no shared piece pool, so it had never run on a host where it would have been noticed.
1093
+ - **Fix**: A failed session no longer leaves its directory behind. It was created before the probe and the keyframe index, both of which can fail, and nothing tracks or sweeps a directory whose session was never registered.
1094
+ - **New**: The transcode-session route says why it failed, on the proxy's own log and with the stack. It caught, answered 500 and stayed silent, so the log carried only the data-channel layer's bare `→ 500`: the cause of the defect above had to be recovered by replaying the request against the live proxy.
1095
+ - **Chore**: The proxy has a linter. It had none, and the rule for an undeclared name catches this whole class outright — it found the second occurrence above on its first run. Biome, `npm run lint`, limited to the correctness rules that describe real faults rather than style.
1096
+
1097
+ ## 2.9.102
1098
+
1099
+ - **New**: The playback plan also reports what this host takes to CREATE a session — median of the last eight, 116-843 ms depending on whether the keyframe index is already in hand. It is the second term of the browser's end-to-end estimate, which is being rebuilt as a sum over the stages that have not happened yet rather than a choice between figures that each describe only one of them (`research/playback-eta-2026-08-05.md`).
1100
+
1101
+ ## 2.9.101
1102
+
1103
+ - **New**: The playback plan reports what this host takes to produce a session's first segment — the median of its last eight, measured from session-create to a servable segment (782-1518 ms on the field host). The browser needs it for the gap between "the file is downloaded" and "a segment exists", where until now it assumed the pipeline merely keeps up with realtime and therefore showed 15 s where 3.8 s were left. It is per-host, so a weak box and a fast one each answer for themselves.
1104
+
1105
+ ## 2.9.100
1106
+
1107
+ - **New**: A long wait for a piece now says who was working on it. The open question about a seek is that a single 8 MiB piece takes 3.0-4.6 s while the swarm as a whole moves 4-6 MB/s, so only about 2 MB/s reaches the piece being waited for — and whether that is because few peers hold it, few are being asked, or each is slow could not be told apart from outside. The line now carries the rate achieved on that piece and, sampled at its peak while waiting, how many connected peers had it, how many were asked, and how many blocks were in flight.
1108
+ - **New**: The keyframe index is read alongside the codec probe instead of after it. Both wait for the same tail of the file; measured 2026-08-04, a probe of 722-1206 ms was followed by an index read of 311-430 ms, all of it before the first segment could be produced. Started together the second is free. Fire and forget, sharing the cache a session would fill itself.
1109
+ - **Chore**: Every encoder run is numbered in the log. A burst of seeks starts several runs within a second and every line about them carries the session id, which is the same for all of them — so the command that failed could not be told from the ones that succeeded around it. That is the state the unexplained `Cannot write moov atom before AC3 packets` was found in.
1110
+
1111
+ ## 2.9.99
1112
+
1113
+ - **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.
1114
+ - **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.
1115
+
1116
+ ## 2.9.98
1117
+
1118
+ - **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.
1119
+ - **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.
1120
+ - **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.
1121
+
1122
+ ## 2.9.97
1123
+
1124
+ - **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.
1125
+
1126
+ ## 2.9.96
1127
+
1128
+ - **New**: The proxy uploads generously at the two moments a viewer is provably waiting — when a torrent is added, and when the viewer seeks — for 25 s, which is two of BitTorrent's unchoke cycles. Peers serve those who serve them: each re-ranks its takers about every 10 s and opens a few slots to whoever uploaded most, plus one at random, so uploading a token 8-50 KB/s means being picked at random, one slot per cycle. Measured on a session where 96 peers were already connected within 2 s: 64 KB/s after 2 s, 1.6 MB/s after 4 s, 4.8 MB/s after 8 s — and the 16 MB the codec probe needs took **8.36 s of the 11.46 s** before playback could start. The existing reciprocity boost could not help, because it waits for the download to be all but dead (below 200 KB/s) with peers visibly choking us, and a ramp is neither: in that same session it first moved the limit 13.3 s after the torrent was added and reached the generous rate at 43.7 s, both after the wait they were meant to shorten. Seeding policy is otherwise unchanged — near-silence when nothing is being watched, a token upload while reading.
1129
+ - **New**: Every encode run logs the exact ffmpeg command line. A failure is otherwise reported with ffmpeg's message and nothing about what it was asked to do, and the two are not always deducible from each other: a run died with `Cannot write moov atom before AC3 packets` although both muxing paths were then verified to handle a copied AC-3 track on that very host, so the arguments that run actually received are the missing evidence.
1130
+
1131
+ ## 2.9.95
1132
+
1133
+ - **New**: The rest of the file is downloaded in the background — but only while that cannot cost the viewer anything. The tail enters the download set at the lowest priority ONLY when every piece of the reader's near window is already on hand, and leaves it the moment one is missing, the window slides onto undownloaded content, or a seek moves it. Relying on priority ordering alone would be weaker: it decides which selection a wire is offered first, not what that wire already has outstanding, so a seek would still queue behind whatever was in flight. What it buys is a file that ends up downloaded while it is watched, making every later seek into it instant.
1134
+ - **Fix**: `/stream` answers when the torrent is not ready instead of holding the connection open in silence. Reproduced 2026-08-04 with a magnet whose metadata never arrived: a ranged GET and a HEAD both returned nothing at all for the full 30 s the client was willing to wait — no status, no headers, and nothing in the log — because the route awaited `getTorrent` with no bound and adding a magnet takes as long as its metadata does. The wait is now capped at 10 s and answers a retryable 503; the add itself continues, so the next attempt is likely to find it ready.
1135
+ - **New**: The read-ahead window is sized in seconds of playback instead of bytes. A flat 32 MB is about half a minute of a 1080p film and roughly four seconds of a disc remux, and the torrent thread cannot tell the difference — it knows only bytes. The transcode session, which knows both the duration and the file size, now works out the file's own byte rate, asks for 30 seconds of it (bounded to 16-96 MB against an odd rate) and puts the figure on the ffmpeg input URL. Without it the reader keeps its previous default.
1136
+ - **Fix**: Everything a reader needs next is marked urgent, not just the piece it is standing on. `critical` is what enables hotswap — a block reserved by a slow peer is re-requested from a faster one — and with pieces of 4 MB the old rule (`min(1 MB / pieceLength, 2)`) marked exactly one. Measured 2026-08-04: the first segment after a seek took 7.2 s while its four pieces arrived one after another at ~2.2 MB/s, with single-piece waits of 1.3 s and 2.8 s. This is not the earlier behaviour returning — that marked the whole requested range, which for ffmpeg's input is every piece to the end of the file.
1137
+ - **Fix**: The piece a viewer is about to watch is no longer as evictable as one fetched forty minutes ahead. Each reader declares its window to the store and the eviction order takes something else while it can; measured in a session where the encoder ran ahead, the hit rate fell from 100% to 45.7% with 221 pieces read back from disk. It is a preference, not a hold: when everything resident is declared, protection yields, because at its smallest budget the store guarantees only two resident pieces and an absolute hold would deadlock it. Pins are unchanged — a piece being read now can never be taken.
1138
+ - **New**: A seek into content already downloaded but spilled to disk brings the whole window back at once. A spilled piece used to be revived only when the reader reached it, one disk round trip at a time, in step with decoding. The disk is local, so the window can be restored while the reader is still on its first piece.
1139
+ - **New**: The playback plan reports where its time goes — waiting for the torrent, waiting for the file's head and tail, and the codec probe itself, with the number of probe attempts. Everything from the transcode-session request onwards was already broken down by `cold-start`, but the plan runs before that and was one opaque wait: a field session spent 5.7 s in it with the torrent already in the store and the probe cached, and nothing said which part was slow.
1140
+
1141
+ ## 2.9.93
1142
+
1143
+ - **Fix**: A seek could kill playback outright. Restarting at a position that lands exactly on a keyframe leaves a floating-point residue — `seekSeconds - snappedKeyframe` came out as `3.3333333249174757e-7` — and `String()` renders anything below 1e-6 in exponential notation, which ffmpeg's duration parser rejects: `Invalid duration for option ss`. The run died on startup, and from then on every segment request answered 500. Time arguments are now formatted in fixed notation, and a residue under a millisecond is dropped rather than passed on, because it is not a real offset.
1144
+ - **Fix**: A session could never recover from a dead encoder. The "already covered by the running encode, not restarting" shortcut did not check that the run was alive, so once one had died `session.ffmpeg` still pointed at the corpse and every later seek was waved through as already covered. One ffmpeg failure therefore became a session that answered 500 for as long as the viewer kept trying.
1145
+ - **Fix**: The look-ahead bound held the encoder back but did not keep it there. Any segment request released it, including a request for something produced ten minutes earlier, so it sawtoothed between suspended and running and drifted from 155 s to 922 s ahead of the viewer over three minutes. A request now re-evaluates the same condition the monitor uses instead of resuming outright.
1146
+
1147
+ ## 2.9.92
1148
+
1149
+ - **Fix**: A seek acts on what the viewer asked for, instead of waiting out guards built for a signal that no longer exists. Three delays sat in front of every seek, all of them there because a far segment REQUEST used to steer the encoder and the player's playlist scan produced dozens of them. Requests stopped steering anything when the position became explicit, so what arrives now is only ever a position the viewer stated. The settle window drops from 1.2 s to 300 ms (the browser already collapses a drag into one report at 300 ms — this was a second debounce on an already-debounced signal, and it cost 1.2 s of every measured seek). The floor between restarts drops from 4 s to 500 ms, now a guard against a client spamming the endpoint rather than a policy about noise. And a run in progress is no longer protected for up to 30 s while it reaches its first segment: finishing a segment for a position the viewer has left is work nobody wants, and the hold could delay a genuine second seek by the whole grace. Measured cost of the old behaviour, 2026-08-04: two seeks 1.3 s apart produced two restarts 4.4 s apart, the first encoding 119.5 s of content before the second killed it.
1150
+ - **Chore**: Removed `ENCODER_STALL_MS`, declared with a paragraph describing a watchdog that was never wired to anything.
1151
+
1152
+ ## 2.9.91
1153
+
1154
+ - **Fix**: The encoder no longer runs away from the viewer. Nothing bounded how far ahead it produced: measured 2026-08-04, three minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51 source at 12.8x while the viewer was still at the start, and the torrent had pulled 80% of 4.7 GB to feed it — the pool owner's bandwidth and disk spent on a viewer who may watch two minutes, the pieces being read evicted from memory by pieces forty minutes ahead, and the swarm busy with anything but the segment being waited for. An encoder more than two minutes of content ahead of the last segment its viewer asked for is now **suspended**, and released once the viewer is within a minute of it — or at once when a segment is requested. Suspended rather than killed on purpose: restarting costs about nine seconds on this hardware, so a viewer reaching the end of the produced range would stall every time, while suspending keeps the process, its input and its position. POSIX only; where `SIGSTOP` does not exist the attempt fails once, is logged, and that session keeps the old behaviour. Every path that terminates an encoder now releases it first — a suspended process does not act on `SIGTERM` until it is continued, which would have hung the wait a seek performs before starting its replacement.
1155
+ - **New**: A reader reports what it waited for. When a read blocks a second or more on a piece, the log names the piece, its position in the read, and the offset the read started at. The first segment after a seek-restart costs 9.2-9.4 s and there was no way to tell whether that is the swarm, the piece picker or ffmpeg; now there is.
1156
+
1157
+ ## 2.9.90
1158
+
1159
+ - **New**: The output container is chosen per session, by the viewer, instead of once per proxy. `POST /api/transcode-sessions` accepts `segmentFormat`; `--segment-format` remains the default for a client that expresses no preference, and an unrecognised value falls back to it rather than to the library default. The browser is the only party that knows what its media stack will accept for the tracks it asked to be copied: a copied MP3 track cannot be appended from fMP4 at all (`audio/mp4; codecs="mp4a.69"` is refused by MediaSource) but works from MPEG-TS, which hls.js demuxes itself and hands to a plain `audio/mpeg` buffer — the same file, the same browser, silent loop one way and normal playback the other. Sessions are keyed by container too, so two viewers wanting different ones do not share an encoder. Nothing branches on the format outside `services/segment-formats/`; the manager now reads it off the session.
1160
+
1161
+ ## 2.9.89
1162
+
1163
+ - **Fix**: What the torrent downloads is now decided by the readers, and by nobody else. Three places were claiming pieces for the same file and overwriting each other on every request: `acquireFile` selected the whole file, `prioritizeByteRange` selected from the read position to the end, and the reader selected its entire requested range. The reader's claim was the worst of the three — ffmpeg opens its input as `bytes <position>-<EOF>`, so the first read of a session claimed the **whole file** and marked **every piece critical**, and nothing ever gave it back, because that read is abandoned a second later when ffmpeg seeks. No later prioritisation could outrank a permanent whole-file claim, which is why 2.9.88 changed nothing measurable. Each read now holds a moving window ahead of its own head, as a **stream selection** — the kind WebTorrent counts rather than merges, so several parallel readers (the codec probe's head and tail, subtitles, one input per viewer) produce the union of their windows — and releases it on completion, cancellation and abandonment. `critical` marks only the piece being waited for and at most two more, which is the rule WebTorrent's own reader uses and what the flag is supposed to mean. `prioritizeByteRange` keeps only what readers cannot do: the read position for the resume figures, and the jump log line.
1164
+ - **Fix**: A seek releases the segment requests it made pointless. hls.js keeps one fragment load outstanding, so a request being held for the old position blocks the one for the new position — measured 2026-08-04: a backward seek into fully downloaded data waited **57 s** for a held request for `#609` to run out the 60 s hold, then fetched the segment it wanted in 15 ms. The same hold trapped 45 requests at once during a forward seek. A viewer seek now ends every wait that started before it with a retryable 503, as `hls-media-server` does (`research/hls-seek-prior-art-2026-08-02.md`, prescribed there and never built).
1165
+
1166
+ ## 2.9.88
1167
+
1168
+ - **Fix**: A seek no longer makes the swarm walk the file to get there. Two faults, both confirmed by running WebTorrent's own selection code on the numbers of a measured session (588 pieces, download at 38.4%, seek to 89.1%). First: a selection carries an `offset` — how many pieces from its start are already downloaded — and the picker scans from `from + offset`; `deselect` subtracts an interval and copies that offset into what survives, so demoting the pieces behind the playhead left `{523-587, offset 226}`, a selection whose scan begins at piece 749 of 587. The seek target ended up wanted by nobody. The range is now re-selected right after the demotion, which replaces the dead entry with a fresh one starting at the playhead. Second: a request with no byte range was reported as an ordinary read at offset 0, and ffmpeg opens its input with exactly such a request and abandons it as soon as it seeks — as do the keyframe index and the codec probe, four of them around every encoder restart. Each one re-selected the whole file from piece zero, undoing the seek; the picker then skipped what was on disk and downloaded forward from the first hole. Measured cost of the pair: a seek to 89.1% of a 4.7 GB film fetched **2.47 GB over 93 s** where one 8 MiB piece was needed. A range-less read now sets the read position only when nothing else has.
1169
+
1170
+ ## 2.9.87
1171
+
1172
+ - **Fix**: fMP4 playback no longer stops after the first segment. A segment's position was being written into **every** fragment it contains, and the explicit-cut muxer puts several in one segment — `frag_keyframe` opens a fragment at each keyframe while a cut point comes only every few keyframes. Measured: a 6 s piece carries three fragments per track, at 0, 2 and 4 s of its own clock; all three were stamped with the segment's start, so they claimed the same decode time and the player rejected the segment. In the field (2.9.86, this session) that showed as segments 1 and 2 requested in an endless alternation, each served in tens of milliseconds with the transcode healthy at 12x, while the picture froze a few seconds in. The position is now applied as a shift: each track's first fragment sets the base and the rest keep their distance from it. With one fragment per track — what the `hls` muxer produces — a shift and a write are the same thing, so the other path is unchanged. Verified end to end on the addon host: four pieces cut, split, stamped and reassembled the way a player does, then probed — 600 frames over 24 s, decode timestamps rising by exactly 0.04 s across every segment join, no duplicates, clean decode.
1173
+
1174
+ ## 2.9.86
1175
+
1176
+ - **Fix**: fMP4 playback starts again. The real reason ffmpeg exited before writing anything was the audio, not the file names: the MP4 muxer derives a copied AC-3 track's `dac3` box from the bitstream, so it cannot write `moov` until the first audio packet arrives, while our `empty_moov` demands it at header time — `Cannot write moov atom before AC3 packets. Set the delay_moov flag to fix this.`, captured in the field on a copied AC-3 source. `delay_moov` is now passed alongside it. The `hls` muxer sets that flag itself, which is why the fault appeared only once the muxing moved to the `segment` muxer in 2.9.84; MPEG-TS has no `moov` and was never affected. Verified in the addon container on an AC-3 source: without the flag the exact command the proxy runs fails, with it the segments are written, and the piece layout is unchanged (`ftyp moov moof mdat … mfra`), so the init split added in 2.9.84 still cuts in the same places — headers of consecutive pieces differ in four bytes, all inside `elst`, which the `tfdt` rewriting already overrides.
1177
+ - **Chore**: Correcting the 2.9.85 entry below. It blames the `.m4s` extension, and that is false: with the arguments this proxy passes, ffmpeg 8.1.2 writes `.m4s` without complaint (re-measured on the same host, and on 6.1.1). The quoted error is what the same command produces when `-segment_format mp4` is missing — which the proxy never omits — and the field failure ends in `Invalid argument`, not `Muxer not found`. The rename is harmless and stays, but it fixed nothing.
1178
+
1179
+ ## 2.9.85
1180
+
1181
+ - **Fix**: fMP4 playback did not start at all in 2.9.84 — every request for the init segment answered 500. ffmpeg had refused to open the output: `Could not write header (incorrect codec parameters ?)`, because the `segment` muxer determines the container from the file extension and does not recognise `.m4s` for MP4, whatever `-segment_format` says. Segments are now written and named `.mp4` on both paths. The extension is internal: it appears only in our own playlist and in the temporary directory, so nothing outside changes.
1182
+
1183
+ ## 2.9.84
1184
+
1185
+ - **Fix**: fMP4 now cuts segments where the playlist says too, closing the gap left by 2.9.82 (which covered MPEG-TS only). The muxer that takes explicit cut times writes each fMP4 piece self-contained — `ftyp moov moof mdat … mfra`, confirmed on the field host — which is not what HLS wants, so the pieces are split on serve: the header is lifted out of the first one to become the init segment named by `#EXT-X-MAP`, and removed from every media segment along with the trailing random-access index, whose offsets describe a file that no longer exists. Timestamps still need stamping exactly as before: measured, all pieces of a run report a start of 0.080 s, each carrying its own zero, which is the same defect the existing rewriting already corrects. Verified end to end on a real piece from the field host — split into a 779-byte init and 221 KB of fragments, recombined, and decoded clean.
1186
+
1187
+ ## 2.9.83
1188
+
1189
+ - **Fix**: Playback died a few seconds in after 2.9.82. The previous muxer wrote each segment under a temporary name and renamed it once complete, so a file appearing WAS a finished segment; the one that takes explicit cut times has no such option and creates the file when writing starts. The route kept judging readiness by existence, so the player was handed a segment that was still being written, rejected it and stopped — while the encoder ran happily ahead, which is exactly how it looked in the field: three segments served, then silence with the transcode at 7.5x. A segment is now considered finished once the next one has been started, or once the run producing it has ended.
1190
+
1191
+ ## 2.9.82
1192
+
1193
+ - **Fix**: The playlist and the real segments now describe the same thing. On the copy path ffmpeg was given only a target duration and chose its own cut points, while the playlist was built from the container keyframe index — two independent calculations tied together by nothing but the assumption that they agree. They do not: the index is a navigation table and is not obliged to list every keyframe. On a field file it held 1902 while ffmpeg found roughly twice as many and cut twice as often, so segment #876 meant 1:26:50 to the player and about minute 58 to ffmpeg. A seek into the middle landed at the end and the reported duration drifted. ffmpeg now receives the very boundaries the playlist was built from, via the `segment` muxer, which takes the list outright — agreement by construction instead of by luck. Verified on deliberately uneven keyframes: cuts requested at 4.44, 10.36, 16.28, 22.2 and 28.12 s landed exactly there. Two measured details are encoded in the code: those times count from the start of the RUN, not of the file (starting at 12 s and asking for 18 s put the cut at 29.4), and a tolerance absorbs rounding so a boundary recorded a hair late cannot skip to the next keyframe and silently double a segment. MPEG-TS only for now — fMP4 can do this too, but only as self-contained fragments, which removes the shared init segment and the `tfdt` rewriting built around it; that is a separate change and not one to make blind.
1194
+ - **New**: The stream route says why a read failed. A body that failed mid-flight was dropped silently — the connection closed with no status and no log line, which from the client looks like the proxy died and from the log like nothing happened; found while probing the route by hand, where every ranged read closed the socket without a word. It now reports the file, the range, how many bytes had been sent, and the error.
1195
+
1196
+ ## 2.9.80
1197
+
1198
+ - **Fix**: A seek backward could hang forever. `prioritizeByteRange` demotes the pieces behind the playhead with `deselect`, which removes them from the download set — and `critical`, which runs right after, only flags pieces that are already selected, so it never puts them back. A seek forward followed by a seek backward therefore left the target pieces wanted by nobody: the encoder waited on data the torrent had been told to stop fetching, while the swarm ran at full speed on pieces nobody needed. The read position is now re-selected whenever it moves back behind what an earlier seek deselected, tracked per file because WebTorrent does not report its own selection back.
1199
+ - **Fix**: Two pieces could be given the same slot in the shared store. Eviction chose a victim, then **awaited** the spill write before removing it from the books, so a second claim arriving in that window chose the same victim and received the same slot — after which two pieces overwrote each other, both failed their hash, and the torrent downloaded them again indefinitely. From outside this looked exactly like a seek that never completes while the download runs at full speed. The victim is now claimed and unbooked in one uninterrupted step, and a reader that arrives mid-spill waits for the write instead of being told the piece is missing.
1200
+ - **Fix**: A burst of concurrent `put`s could fail with "every resident piece is pinned" when nothing was pinned at all. Slots are claimed before the piece is copied into them, and pieces arrive from many peers at once, so the store saw an empty eviction list while its slots were already spoken for. Slots handed out but not yet recorded are now counted, and a claim that finds nothing waits for that work to land rather than declaring the store exhausted.
1201
+ - **New**: Two figures the last field failure could not be diagnosed without. The store now reports `pinned=` alongside its other counters, so a leaked pin is visible while it is still harmless instead of only when eviction has nothing left to take; and a read position that jumps — a seek — is logged with its offset and percentage through the file, so it can be seen whether a seek reached the torrent at all.
1202
+
1203
+ ## 2.9.79
1204
+
1205
+ - **New**: The last copy is gone from the read path. `/stream` now writes the response straight out of the torrent's shared memory and releases each piece only when the socket write reports completion — which is the one moment that is safe, because a piece released earlier can be evicted and its slot refilled while those exact bytes are still on their way out. Both halves of that were verified before being relied on: a socket accepts a view into a `SharedArrayBuffer`, and overwriting the pool from inside the write callback leaves the client's copy intact while overwriting it before the callback corrupts it silently. Measured on the same host, 24 MB of already-downloaded data read in 2 MB ranges: **298 ms against 1008 ms**, 675 Mbit/s against 200, and far steadier (265-308 ms against 641-1338). Callers that keep what they are given — the subtitle route, anything using the plain stream — still get a copy and are unaffected; a source with no shared pool falls back to the previous path.
1206
+ - **Chore**: Reading the response body by hand is what makes the release point observable, so the route writes and ends the response itself rather than handing Fastify a stream. A client that disconnects mid-response cancels the read, so pieces stop being fetched for a viewer who has gone.
1207
+
1208
+ ## 2.9.78
1209
+
1210
+ - **New**: Reads cross the thread boundary as **positions instead of bytes**. The pieces already live in a `SharedArrayBuffer`, so the torrent thread now sends an offset and a length and the main thread reads those bytes where they lie. What this removes is the copy that used to sit on the critical path — 18.84 ms per 10 MB segment on the field host, spent in the same thread that runs the torrent, at the moment a viewer is waiting for that segment. A piece is **pinned** for as long as a fragment of it is outstanding, and unpinned only once the main thread confirms it has finished reading, so eviction cannot take the memory out from under a reader; one fragment is in flight at a time, because the store guarantees only two resident pieces at its smallest budget and holding two pins while asking for a third would deadlock it. Verified against a partially downloaded 5.5 GB torrent: a range read whole matches the same range read in parts, a read spanning a piece boundary matches its two halves, and ffmpeg parses the file through this path (`matroska h264/ac3 5939 s`). The arithmetic is covered by tests, including a file that does not start on a piece boundary — the case where treating file offsets as torrent offsets returns the right number of wrong bytes.
1211
+ - **Chore**: `SharedPieceStore` gained `reside`, which brings a piece into memory and reports where it sits without the copy `get` has to make (WebTorrent keeps what `get` returns), and `findSharedStore`, which walks WebTorrent's store wrappers to reach ours rather than assuming their number or order.
1212
+ - **Known**: the copy is not gone from the system, only from the torrent thread — the main thread still copies each fragment out of the pool before handing it on, because nothing tells us when the socket has finished with those bytes, and releasing the piece earlier would risk serving whatever landed in the slot next. Removing that last copy needs the body write to report completion, which is a change to the stream route rather than to this transport.
1213
+
1214
+ ## 2.9.77
1215
+
1216
+ - **Fix**: Anything naming a source while that source was still being added got `Unknown source` — which is false, because the source exists and is merely not ready. Adding a magnet takes as long as its metadata does, seconds to tens of seconds, and the browser polls stats and asks for a playback plan throughout that window. The worker registered the torrent only once the add had **finished**; it now registers the pending add itself, so callers wait for it. Reproduced with a magnet nobody seeds: stats, the file listing and a read all failed instantly while the add was in flight, and all three now wait. A source that was never added is still an error, and a failed add is forgotten rather than replayed to every later caller.
1217
+ - **Fix**: File claims are held per reader instead of per file. The proxy reads one file from several places at once — ffmpeg's input, the keyframe index, the codec probe, a second viewer — and claims keyed by `sourceKey:fileIndex` were therefore shared: the first reader to finish released the hold while the others were still reading, leaving the data free to be evicted under them. Each acquire now returns its own claim identity and a release names exactly that claim, so a duplicate or late release matches nothing, is logged, and harms no one. A counter would have restored the arithmetic but kept the ambiguity.
1218
+ - **Fix**: `HEAD /stream` no longer starts a read of the whole file. Fastify serves HEAD from the GET handler, so a HEAD opened a full-file read whose body Node discarded while the read itself ran on, the response never completed, and the next request on that keep-alive connection waited behind it — measured in the field as headers in 23 ms followed by a 15 s stall, which is where a 73 s transcode-session create came from. It also has to report the real size: the keyframe index asks for it with this very request and treats zero as "no index", silently falling back to an invented segment grid, so the response is written to the raw socket rather than through `reply.send()`, which substitutes `content-length: 0` for an empty payload.
1219
+ - **Chore**: `prefetchFileEdges` takes an options object at every layer, matching `TorrentPool`. The worker adapter declared positional parameters instead, so the planner's options object arrived as `headBytes` and only worked because it was passed along far enough to be destructured at the far end; anyone calling it as documented silently got the defaults.
1220
+
1221
+ ## 2.9.76
1222
+
1223
+ - **Fix**: A read that failed inside the torrent thread left the reader waiting forever, and a read that failed part-way looked like a file that had simply ended. Two halves of one hole, both present since the thread split: the worker sent the end-of-read marker from its `finally` even when the read had thrown, and the main thread had no handler for a read error at all — so the report was dropped as unknown. That is why the 2.9.71 defect took three releases to find: every symptom said "empty file", never "this read failed, here is why". Now the marker is sent only on success and the failure fails the caller's stream. Covered end to end by a test that hung before the fix.
1224
+ - **Fix**: Reads and commands drew request ids from two independent counters into one namespace, so a read and a command could both be in flight as the same number. The worker's reply to the read then resolved the **command** — with the read's result, silently — and the command's real answer arrived later and was discarded as unknown. Depending on which command lost the race this produced empty stats, a prefetch that returned early, or a file claim released before its read had finished. Every id now comes from one sequence, which makes the collision impossible rather than unlikely; a test hands out ids down both paths and asserts they never repeat.
1225
+
1226
+ ## 2.9.75
1227
+
1228
+ - **New**: The piece store reports what it is doing — resident pieces against the budget, how many spilled to disk, what share of reads came from memory rather than disk, and how often eviction was refused because every piece was being read. Logged once a minute and only when something changed. Without this the component that decides whether a read is free or costs a disk trip was invisible in the field, and the first oddity would have had no evidence behind it.
1229
+ - **Chore**: The memory budget is no longer a flat half-gigabyte guess, and no longer claimed up front. It defaults to a quarter of free memory, capped at 512 MB and floored at 64 MB, and the pool **grows into** that budget as pieces arrive instead of allocating it on `add`. Both matter because the budget is per torrent: measured on the field host after a single session, the proxy container sat at 796 MB with 4.1 GB free and 1.3 GB already in swap, so several concurrent viewers under the old scheme would have taken half a gigabyte each for pieces nobody had asked for. `--memory-bytes` overrides it.
1230
+
1231
+ ## 2.9.74
1232
+
1233
+ - **Fix**: Playback works again. Since 2.9.71 every read answered with headers and an empty body — ffmpeg reported `Stream ends prematurely at 0, should be <size>` and the loading screen sat on "Preparing HLS transcode" until it gave up. Root cause, reproduced locally on two different torrents once the right conditions were used (a large, **partially downloaded** file — a complete one never shows it): the worker transferred ownership of a buffer belonging to WebTorrent's piece cache, the cache's memory was detached, and from that moment every read failed with `Cannot perform %TypedArray%.prototype.slice on a detached ArrayBuffer`. Nobody saw that error, because the worker sent the end-of-read marker from its `finally` before posting the failure and the main thread had no handler for a read error at all — so a broken read was indistinguishable from an empty file. Fixed at the root by owning the memory (the new piece store) and at the boundary by having the transport copy into memory it allocated itself rather than trying to guess whether the caller's buffer was safe to take. Verified end to end on both an almost-complete and a freshly-added torrent: playback plan, byte range, transcode session, init segment and first media segment all produced.
1234
+ - **New**: A piece store of our own (`services/piece-store/`): pieces live in a `SharedArrayBuffer`, spill to a single sparse file when the memory budget is full, and come back from it on demand. Owning the memory is what makes the thread split safe — WebTorrent's own cache hands out buffers it keeps using, which is why transferring one detached the cache and killed every subsequent read. Two properties are enforced rather than hoped for: a piece being read is **pinned** and cannot be evicted (with every piece pinned the store refuses to make room instead of taking memory from under a reader — the exact failure of 2.9.71), and a buffer handed to a caller is never invalidated by later writes. Sized by measurement on the field host: a piece copy costs 3.64 ms, reading one back from disk into a buffer we already own 7.63 ms, and re-downloading it from the swarm ~1430 ms — so memory first, disk under it, the swarm never twice. Found while testing: opening the spill file in append mode makes POSIX ignore the write position entirely, so pieces piled up in arrival order and reads returned whichever piece happened to sit at that offset.
1235
+ - **Fix**: A torrent carrying a `wss://` tracker took the **whole proxy process** down from 2.9.71 — including the demo magnet on the site's own button. node-datachannel is native and cannot be used from two V8 isolates at once (`HandleScope: Entering the V8 API without proper locking in place`); measured identically on win32/x64 and linux/arm64, with one isolate fine either way, two fatal, and `preload()` in both no help. Before the thread split both users lived in one isolate; afterwards the browser's video channel sat on the main thread while the torrent's tracker announces created peer connections on the worker. Fixed by leaving the native stack where it carries video and giving the torrent thread a JavaScript one (`werift`) through a module-resolution hook scoped to that worker — no dependency is patched, which matters because the addon installs with `--ignore-scripts`. The shim supplies the three things werift's data channel lacks and `simple-peer` depends on: `binaryType` (without it every payload goes through a text decoder and arrives corrupted), the buffered-amount-low event (its backpressure never resumes without it), and a session description built from one object rather than two positional arguments (werift's own signature is `(sdp, type)`, so `{ type, sdp }` was rejected as `invalid sessionDescription`). Verified end to end: a magnet with **only** wss trackers now connects to browser peers and downloads the file completely.
1236
+ - **Chore**: The proxy has tests, and publishing runs them. There were none before, and nothing stood between writing code and `npm publish` — which is how the 2.9.71 thread split reached the field with a defect that stopped every read. `npm test` (Node's own runner, no new dependencies) plus `prepublishOnly`, so an unproven package cannot be published. The first cases cover the transport's memory contract and are written to FAIL on the current code: sending a chunk must leave the source buffer usable by its owner, and a second read of the same piece must still return its bytes. Both fail today, which is the point — they describe the shipped defect.
1237
+
1238
+ ## 2.9.73
1239
+
1240
+ - **Fix**: File stats came back as `{}` after the torrent moved to its own thread (2.9.71), which left the loading screen with no peers, no speed and no progress. Two call sites — the stats route and the health report — invoked `getFileStats` **without awaiting**: it used to answer locally and immediately, and now crosses a thread boundary, so the reply was the pending promise itself, serialised to an empty object. Both now await it.
1241
+ - **Chore**: Chunk transfer no longer hands over memory the chunk does not own outright. Node allocates small buffers from a shared 8 KB pool — several unrelated buffers occupy one region, each viewing its own slice (verified: a 1 KB buffer reports an 8192-byte region at offset 8) — so transferring that region would detach it from its neighbours. Chunks sourced from the network are small enough to be pooled while local disk reads are not, which is exactly the difference between the field host and the local test. Measured afterwards: pooled chunks cross intact, so this is a correctness guard rather than the cause of the field failure.
1242
+
1243
+ ## 2.9.72
1244
+
1245
+ - **Fix**: Playback broke entirely after the torrent moved to its own thread (2.9.71): the torrent was deleted **with its downloaded data** while still being read, after which every read hung and ffmpeg saw an empty input (`Stream ends prematurely at 0, should be 3303133078`), and the container-index read took 73 s to return nothing. Cause: `acquireFile` was dispatched without awaiting while its release was sent normally, so a release could overtake the acquire it belonged to; the reader count then hit zero mid-read and the idle sweep fired (`removed idle torrent ... and its store`). Two fixes, each sufficient alone: the release is now chained onto the acquire so it can never arrive first, and the worker additionally holds the file for the whole duration of the read — a claim that lives inside the read and so cannot be reordered against it. Not reproducible locally, where the test torrent was fully downloaded and never went idle.
1246
+
1247
+ ## 2.9.71
1248
+
1249
+ - **New**: The torrent client now runs on its own thread (`services/torrent-worker/`). Profiling a live seek (2026-08-02) found the main thread ~85% occupied by WebTorrent — buffer concatenation in `uint8-util` ~15%, `_updateWire` and its wrapper ~9%, garbage collection ~5%, and **no piece hashing at all**, which had been the standing assumption — while three of four cores idled. Serving a segment shared that thread, so reading an already-finished 10 MB file off SSD took **12-23 s** where handing it to the channel took 125 ms. Measured after the split, through the real `/stream` route: **3 MB in 0.05-0.12 s** (~500 Mbps), roughly a hundredfold improvement, with main-thread event-loop delay down from 300-390 ms to **28-38 ms**.
1250
+ - **Chore**: The transport was chosen by measurement, not preference. A 10 MB body costs 37 ms structured-cloned, **104 ms through a transferable `ReadableStream`** (the obvious standard answer, and 22x worse), and **4.8-5.3 ms** transferring ownership of 1 MB chunks behind an ordinary `ReadableStream` wrapper — standard interface outside, ownership transfer inside, which is what shipped. Chunk size follows the same arithmetic: at ~100 µs per round trip, 64 KB chunks would spend 13 ms per segment on overhead versus ~1 ms at 1 MB. Backpressure caps chunks in flight so a fast disk cannot rebuild in the message queue the memory the transfers save.
1251
+ - **Chore**: `WorkerTorrentPool` presents `TorrentPool`'s existing interface, so the switch is one line in `server.js` and none of the twelve call sites across the stream route, subtitle route, playback planner and health report changed. Torrent objects cannot cross a thread, so the worker keys them by `sourceKey` and hands back a stand-in exposing the `files[i].createReadStream()` shape callers already use.
1252
+
1253
+ ## 2.9.70
1254
+
1255
+ - **Chore**: Instrumentation to settle where a slow transfer actually loses its time, instead of arguing about it. Every data-channel body transfer now reports the split — `readMs` (reading the body from the local route), `chanMs` (handing chunks to the channel), `drainMs` (waiting for the channel queue) — plus `rate` and, decisively, the **event-loop delay** over the same window (`loopMean`/`loopP99`/`loopMax`, via `perf_hooks.monitorEventLoopDelay`). Synchronous work blocking the loop looks exactly like a slow network from the outside; these figures tell them apart. Prompted by a field seek where a 9.4 MB segment took 16.5 s to deliver with the channel queue **empty the whole time** (`maxBuffered=0`) while the encoder ran at 14x realtime and the file was already on disk — so none of encoder, torrent or channel capacity explained it, and no measurement existed that could. New `utils/perf.js` (`OperationTimer`, `eventLoopDelay`); deeper tools (`--trace-events-enabled`, `--cpu-prof`) remain for when these point somewhere specific.
1256
+
1257
+ ## 2.9.69
1258
+
1259
+ - **Fix**: Removed the last traces of the seek-start "pull", so nothing can move the encode position except the viewer's own seek. Root cause now measured rather than guessed: **during a scrub the player loads from wherever the slider pauses on its way**. Browser log 2026-08-02 — dragging from 0 to 23:34 lingered at 863.4 s, the player fetched segment #82 for that intermediate point, and a seek that had correctly resolved to start at #134 was dragged back to **#82**, then crawled forward for a minute. The browser's 300 ms debounce exists precisely to discard intermediate scrub positions; reading them back off the segment-request stream defeated it. Gone with it: `lowestAwaitedIndex` tracking, `SEEK_PULL_LIMIT_SEGMENTS`, and the reset paths they needed.
1260
+
1261
+ ## 2.9.68
1262
+
1263
+ - **Fix**: A seek could be dragged back to the position the viewer had just left. The encoder start was pulled down to the lowest segment the player had outstanding — a stand-in from when the distance to the preceding keyframe was unknown — but at seek time those requests still describe where the player was PLAYING, not where it is going. Field 2026-08-02: a seek to 23:34 (#135) correctly resolved to a start of #134, then got pulled to **#82** (14:15, the position just left) and crawled forward from there. Removed: since boundaries became real keyframes (2.9.65), exactly one segment back always suffices, so the pull has nothing left to correct for.
1264
+
1265
+ ## 2.9.67
1266
+
1267
+ - **Fix**: A seek waited far longer than it needed to — 56 s measured in the field, of which roughly 50 s was self-inflicted. `SEEK_BACKOFF_SEGMENTS` (how far before the requested segment the encoder starts) was **12**, chosen when segments were an invented 4 s apart and the distance to a usable keyframe was unknown. Since 2.9.65 every boundary IS a real keyframe read from the container index, so the single preceding segment is guaranteed to start on one — and with real 10.43 s segments the old value meant encoding **125 s of content** before reaching the viewer position. Lowered to **1**. Observed: the encoder started at #332 for a seek to #344 and the requested segment only arrived 56 s later, while every segment after it was served in ~100 ms.
1268
+
1269
+ ## 2.9.66
1270
+
1271
+ - **New**: The container keyframe index now covers **MP4/MOV and AVI** as well as Matroska. MP4 reads the sync-sample and time-to-sample tables from `moov` — found by stepping over top-level box headers, so it works whether `moov` sits at the file start or the end, without scanning the gigabytes of `mdat` between them (verified on a 2 GB field file: **1145 keyframes in 625 ms**). AVI reads the trailing `idx1` table, still worth having because older releases are largely XviD-in-AVI and are exactly the files served by copying rather than re-encoding. Formats left out are documented in the module with the reason: MPEG-TS/M2TS carry no index anywhere by design, fragmented MP4 spreads timing across fragments instead of one table, and FLV/ASF have tables but effectively never appear in these releases.
1272
+
1273
+ ## 2.9.65
1274
+
1275
+ - **Fix**: Segment boundaries on the video-COPY path are now the source's **real** keyframe positions, read from the container's own index (`services/container-index/`), instead of an invented 4 s grid. ffmpeg can only cut a copied stream at existing keyframes, so the declared grid was simply false — measured on a field file, the true keyframe spacing is **10.43 s**, meaning roughly two of every three declared boundaries could not exist. Players punish this in two ways, both seen in the field 2026-08-02: on a long file the player stops trusting the playlist and walks it from segment #1 to locate a seek (a 1:30 seek produced requests #1, #2, #45, #86 … #1187 and never arrived), and on a short one it presents **audio with no picture**, because a segment beginning without a keyframe has nothing to decode from.
1276
+ - **New**: `services/container-index/` — reads a file's keyframe table directly from the container (Matroska Cues today; MP4/AVI to follow) via two point reads: the head, to learn where the table lives, then the table itself. Measured against a 5.5 GB torrent-backed file: **570 keyframes in 0.8 s from 16 KB**, versus a full packet scan that found 77 in 45 s and never finished. Transport-agnostic by construction — it takes a byte-range function and knows nothing of torrents, HTTP or sessions — and cached per (source, file), so re-opens and seeks reuse the first read. Files with no readable index (live captures, interrupted writes, damaged uploads, MPEG-TS) return nothing and keep the previous fallback.
1277
+
1278
+ ## 2.9.64
1279
+
1280
+ - **Fix**: The 2.9.63 pull-to-lowest-awaited-segment dragged the encoder to the start of the file. A seek to #1354 restarted at **#123** — the position of the *previous* watch — because requests outstanding from before the seek still counted toward `lowestAwaitedIndex`. Two fixes: the awaited floor is cleared the moment a new seek arrives (earlier requests describe where the player used to be, not where it is going), and the pull is bounded by `SEEK_PULL_LIMIT_SEGMENTS` (120) below the target — anything deeper is a leftover, not the preceding keyframe.
1281
+
1282
+ ## 2.9.63
1283
+
1284
+ - **Fix**: A seek landed the encoder on exactly the requested segment, which is the one position the player never asks for — so it produced files nobody was waiting for and playback hung. Per Apple HLS authoring guidance, a player given a position locates the nearest keyframe **preceding** it, decodes from there, and only then presents from the requested point; it therefore always fetches segments **below** the target. Measured on iOS: a seek to #1082 fetched from #1074 (8 back), one to #1358 fetched from #1301 (57 back) and asked for **nothing at or above** the target. The encoder now starts `SEEK_BACKOFF_SEGMENTS` (12) before the requested segment, and — since the needed depth varies and no fixed number covers it — is pulled down further to the lowest segment the player is actually waiting on, which its own requests report exactly (`lowestAwaitedIndex`). Only ever moves the start earlier, never later. Costs a few seconds of extra encoding per seek.
1285
+
1286
+ ## 2.9.62
1287
+
1288
+ - **Fix**: The seek target is now taken **explicitly from the browser** (`POST /api/transcode-sessions/:id/seek`) instead of being inferred from which segments the player requests. Measured 2026-08-02: one viewer seek leaves **~25 concurrent segment requests** outstanding spanning #904..#1101, all held for a full 60 s with none aborted — ordinary read-ahead, not probing. There is therefore no such thing as "the segment the player ended on", and any rule choosing among them chooses noise: the old debounce produced **nine encoder restarts in one minute** (#576→#885→#609→#591→#673→#833→#624→#1071→#1101), each killed 5-8 s in, turning a single seek into a ~70 s ordeal that still landed correctly only by luck. Segment requests are now purely data fetches — held until produced, served from disk when behind the encoder — and never reposition it. Same separation both production references use (Jellyfin `startTimeTicks`, webtor `?t=`); see research/hls-seek-prior-art-2026-08-02.md. A seek already covered by the running encode does not restart it at all.
1289
+
1290
+ ## 2.9.61
1291
+
1292
+ - **Chore**: Measurement build for the iOS player question. A request for a not-yet-produced segment is now held up to 60 s (was 2 s) and each hold logs `[hold] <file> <outcome> after <ms>` — where the outcome distinguishes the segment arriving, our own limit expiring, and **the client aborting the connection**. The 2 s refusal was introduced (2.9.57) to dodge a reported iOS AVPlayer ~3.5 s response-header deadline, but that error code never appears in our own logs, and all five reference projects hold instead of refusing (Jellyfin and hls-vod-too unbounded, hls-media-server 10 s — see research/hls-seek-prior-art-2026-08-02.md). This build measures the player's real patience on our own hardware so the final value comes from observation rather than from a number read elsewhere. Not a permanent setting.
1293
+
1294
+ ## 2.9.60
1295
+
1296
+ - **Fix**: Seeking restarted the encoder at the position it was **already encoding**, destroying the very work being waited for — visible in the field log 2026-08-02 as `restart at #865` twice within ten seconds, each killing a run that was encoding #865. While the target segment is being produced the player keeps re-requesting it, and every such request looks "far" from where the encoder USED to be, so each one re-triggered a restart at the position we had only just moved to; playback data kept appearing and vanishing, and a seek only completed when a segment happened to reach the player before the next restart. A settled seek whose target equals the current runs start index is now ignored outright. This is distinct from the 2.9.58 guard, which only decides whether to let the current run finish its first segment — not whether a new run is needed at all; that guard behaved correctly here (it logged `run produced 4.5s (first segment done)`) and still let the pointless restart through.
1297
+
1298
+ ## 2.9.59
1299
+
1300
+ - **Chore**: Diagnostics for seek handling. The session-start line now carries the proxy version (`transcode <id> start (proxy 2.9.59) "<file>"`), so a field report answers "is the host running the build I published?" by itself. And the seek restart guard added in 2.9.58 now states its decision: either `seek #N HELD — current run has produced Xs of the Ys first segment` or, on the restart line, why it was allowed (`run is dead` / `run produced Xs (first segment done)` / `grace expired`). Previously a permitted restart was indistinguishable in the log from the runaway ping-pong the guard exists to stop, which made diagnosing "seek still did not work" guesswork.
1301
+
1302
+ ## 2.9.58
1303
+
1304
+ - **Fix**: A single user seek could leave playback permanently stuck with a flickering loading pill and no video. The encoder was allowed to restart at a new position even when the current run had not yet produced a **single segment**, so each restart destroyed the previous one's work and began the wait again — self-perpetuating, because the first segment after a seek is the slowest thing the pipeline does (field log 2026-08-02: restarts at #617 → #717 → #732 → #732 every 5-7 s, none producing anything). The extra targets were not further user seeks: unable to get its segment, the player SCANS the playlist (our synthetic VOD playlist lists every segment, so from its side they all exist), and each far-enough probe looked like a fresh seek. A seek restart now waits for the current run to produce its first segment (bounded by a 30 s grace, and skipped entirely if the run has died), which makes the scan harmless and lets one genuine seek complete. Independent of segment format.
1305
+ - **Fix**: The "segment not ready" response now carries `Retry-After`. A bare 503 reads as "nothing here" and invites the playlist scan described above; the header is the standard way to say "re-request this same segment shortly". hls.js retried the same fragment either way; whether iOS AVPlayer honours it is unverified (its behaviour is closed), but the previous response gave it no reason not to look elsewhere.
1306
+
1307
+ ## 2.9.57
1308
+
1309
+ - **Fix**: A request for a not-yet-produced segment was held open for up to **30 seconds** before answering. iOS's native HLS player (AVPlayer) enforces a hard **~3.5 s deadline on response headers** and raises `-12889` ("No response for media file") once it passes — it then cancels in-flight requests, probes neighbouring positions, and can restart the stream from the beginning. Because a seek restarts ffmpeg and its first segment takes far longer than 3.5 s to appear, that deadline was hit on **every** seek, which is the root of the field-reported "seek loads for ages, then jumps back to the start" and of the playlist-scanning traffic that 2.9.55 tried (and failed) to work around from the wrong end. The request is now held only ~2 s and then answered with the same retryable 503, which resets the player's deadline and lets it re-request; a ready or nearly-ready segment is still served on the first request, so the fast path is unchanged. Independent of segment format — it affected `fmp4` and `mpegts` equally. hls.js is unaffected (it consumes the 503 through its retry policy); the browser widens that retry budget to match (server-side change, `fragLoadPolicy` `maxNumRetry` 8 → 12).
1310
+
1311
+ ## 2.9.56
1312
+
1313
+ - **Fix**: Reverted the "only the newest request may steer the encoder" guard added in 2.9.55 — it made seeking worse, not better, and is withdrawn rather than patched over. Its premise was that the newest in-flight segment request is the one the viewer actually wants; that does not hold. When the player cannot get its target segment it starts SCANNING the playlist, firing dozens of requests spread across the whole file within half a second (field log: `#178`, `#681`, `#725`, `#807`, `#74`, `#245`, `#387` …). Under that traffic the "newest" request is an arbitrary scan probe, so the guard steered the encoder away from the real seek target; the target segment was never produced and the player gave up and reset to the beginning of the file. The underlying ping-pong (several requests from one scrub taking turns restarting ffmpeg) is a real defect and remains open — but a correct fix has to tell a VIEWER seek apart from the player's own scan, which arrival order does not express. The 2.9.55 progress-timeline fix (video-copy branch) is unaffected and stays.
1314
+
1315
+ ## 2.9.55
1316
+
1317
+ - **Fix**: One scrub of the seek bar could leave the encoder ping-ponging between positions with an empty player buffer for over a minute (field-diagnosed 2026-08-01). A single scrub makes the player fire SEVERAL segment requests within a few hundred milliseconds — field log: `#534`, `#694`, `#817`, `#828` within 361 ms — and each of them long-polls `getFileStream` every 300 ms until served. Every poll called `#ensureEncodingFor`, so the four in-flight requests took turns overwriting the seek target and restarting ffmpeg at each other's positions (`534→828→694→828→817→828`, six restarts), none surviving long enough to produce a segment: three of the four eventually timed out after 34-36 s and the fourth was served after 25 s, with the browser buffer at 0.0 s throughout. Fixed by giving each INCOMING request one sequence number (`nextRequestSeq`) that it keeps for all of its long-poll iterations, and letting only the newest request steer the encoder — an older request may still be served if its segment gets produced, but can no longer move the encode head. Verified by replaying the exact field sequence: 44 target switches before, 4 after (the initial burst, which the existing settle-debounce then collapses into a single restart), settling on the last-requested segment.
1318
+ - **Fix**: The transcode percent read 0% for a whole run on **video-copy** sessions (`transcodeVideo:false`), the other half of the 2.9.53 timeline bug. That fix rebased ffmpeg's `-progress` `out_time` onto the absolute timeline only for the re-encode branch, on the assumption that `-copyts` already made the copy branch absolute. The assumption was never measured and is wrong: on the field host, `-ss 600 … -copyts -c:v copy` reports `out_time` = 0, 40.7, 54.9, 90.9 — relative to the run, exactly like the re-encode branch (field log: `processed=12.638` against `startPos=3312` at 12.6x speed). The rebase now applies to both branches, and both measurements are recorded in the code so neither can be exempted again without a fresh one.
1319
+
1320
+ ## 2.9.54
1321
+
1322
+ - **Fix**: Seeking left playback permanently frozen — the root cause behind the field reports of "seeking does nothing" / "100% • starting now on a dead player". After a seek the player fetched the target segment successfully, over and over (field log: segments 402 and 403 re-requested in a loop for more than two minutes at full link speed, ~250-340 KB each time, browser buffer stuck at 0.0 s) while the transcode itself was healthy. Cause: ffmpeg's HLS/fMP4 output writes `tfdt` (the box that says WHERE a fragment sits on the timeline) as **0** in every seek-restart run, and records the run's start offset in an `elst` edit list inside **that run's** init segment instead. That is self-consistent only while init and segments come from the same run — but the player fetches `#EXT-X-MAP` exactly once, so we must serve one init for the whole session. Read against that cached init, a post-seek segment loses its offset completely and claims to start at ~0 s; the player finds nothing at the position it seeked to, discards the segment and re-requests it, forever. **No ffmpeg configuration avoids this** — measured on the shipping build: HLS *and* DASH muxers, `-copyts`, `-output_ts_offset`, `-itsoffset`, `-avoid_negative_ts disabled`, `-movflags -use_edts/+dash/+frag_discont/+global_sidx`, `-video_track_timescale`; all emit `tfdt = 0`. Fixed by stamping each fragment's `tfdt` with the segment's true start time as it is served, which is what CMAF (ISO/IEC 23000-19) requires of an independently-addressable segment in the first place: the segment then carries its own position and is valid against any init for the same tracks. Verified against a reproduction of the field scenario (several consecutive seek-restarts, video+audio): a post-seek segment read with the session-cached init reports its true timestamp (80.1 s) instead of 0.083 s, and decodes cleanly.
1323
+ - **New**: The HLS output container is now selectable — `--segment-format fmp4` (default) or `--segment-format mpegts` — in the spirit of Jellyfin's transcoding-container setting. Everything container-specific (muxer arguments, segment naming and matching, playlist header lines, and the per-segment serving hook) lives behind a single interface in `services/segment-formats/`, so `hls-session-manager` holds a format object and never branches on the container; adding a container means adding a module, not editing the session manager. The MPEG-TS path is the pre-fMP4 behaviour recovered from the original switch commit rather than a rewrite; its segments are self-contained (no init segment at all), so the entire class of problem fixed above cannot arise there, which makes it a genuine fallback rather than a downgrade.
1324
+
1325
+ ## 2.9.53
1326
+
1327
+ - **Fix**: On the video RE-ENCODE path, `processedSeconds` silently switched reference frame partway through every encode run — absolute (matching `startPositionSeconds`) for the placeholder set at restart, then RELATIVE-to-the-run (counting from ~0) the moment ffmpeg's own `-progress out_time`/`out_time_ms` started overwriting it — because `-output_ts_offset` (used to relabel the MUXED output's timestamps onto the absolute grid) does NOT affect what `-progress` reports; verified empirically (a 5s clip encoded with `-output_ts_offset 100` still reports `out_time` counting 0→5, not 100→105). Every consumer of `session.progress.processedSeconds` assumes it is absolute: `computeProgressMetrics` (percent/remaining), `#applyBudgetDownshift`'s mid-run restart point, and — the field-diagnosed symptom — `#ensureEncodingFor`'s look-ahead window, which anchors on `Math.max(head, segmentIndexForTime(processed))`; with `processed` wrongly near-zero this floor pins the window's advancing edge at the run's OWN start segment for its entire lifetime instead of tracking real progress, so any segment request more than `MAX_LOOKAHEAD_SEGMENTS` (8, ≈32s) past the SEEK TARGET reads as "far" and triggers ANOTHER restart — even while the encoder is happily producing well past that point. Field example (verified with a pure-math replay of the exact logged values): seek to 1824s, window pinned at segments 456–464 for the whole run regardless of real progress reaching segment 465+ within seconds, at 6x realtime. This is the mechanism behind "buffering pill stuck at 0% until playback finally starts" and very likely a contributor to the broader "seek gets stuck" class of reports this cycle. Fixed by rebasing `out_time`/`out_time_ms` onto the absolute timeline (`+ session.progress.startPositionSeconds`) for the re-encode branch only — the copy branch already reports absolute time via `-copyts`, unaffected. Verified: a standalone replay of the field's `processed`/`startPos` sequence through the actual `#segmentIndexForTime` algorithm shows the window frozen at the run's start before the fix, correctly advancing with real progress after.
1328
+
1329
+ ## 2.9.52
1330
+
1331
+ - **Chore**: `npm audit` fixes. `@fastify/static` 9.1.3 → 10.1.2 (fixes GHSA-83w8-p2f5-377r route-guard path-traversal bypass and GHSA-8pvw-jcv7-9cmj non-canonical-path authorization bypass — no API change to our usage, verified with a live smoke test: healthz, tunnel connect, and static registration all still work). `brace-expansion`/`fast-uri`/`find-my-way` bumped via `npm audit fix` (transitive, no direct dependency change). Residual: `ip` (via `webtorrent@2.8.5` → `torrent-discovery` → `bittorrent-tracker`) stays flagged high (GHSA-2p57-rm9w-gvfp / CVE-2024-29415, SSRF via `isPublic()` misclassification) — investigated and left as an accepted risk, not an oversight: the advisory has no upstream fix (`first_patched_version: null`, every published version of `ip` is flagged) and `npm audit fix --force`'s only offered fix is downgrading `webtorrent` to 0.7.3, which would reintroduce the exact download-freeze regressions 2.9.44 rolled back from 3.x to avoid. The only actual call site in our dependency tree (`bittorrent-tracker/lib/server/parse-udp.js`) uses `ip.toString()` for UDP-integer→string formatting in the tracker-SERVER's request parser — code we never execute (WebTorrent only uses `bittorrent-tracker` as a tracker CLIENT) — and the vulnerable function itself, `isPublic()`, is not called anywhere in the chain. Revisit if/when a maintained `ip` replacement lands upstream in `bittorrent-tracker`.
1332
+
1333
+ ## 2.9.51
1334
+
1335
+ - **Fix**: The 2.9.50 keyframe-snap seek fix did not reliably apply on the re-encode path for containers needing a full packet scan (observed: AVI). The probe ran with a 6 s cap shared with the video-copy path (there it is fast, moov-index based); on a container needing a full scan, 6 s was not enough, the probe returned null, and the seek fell back to the raw (unsnapped) target — the exact case the circuit breaker exists to catch, not prevent. Split the two paths: video-copy keeps the blocking 6 s probe (segment boundaries need it before the first segment can be produced); video re-encode now runs the probe in the BACKGROUND with a full 25 s budget, since segment boundaries there are the uniform grid and never depend on it — only a later seek benefits from the snap. `#startEncodeRun` already reads `session.keyframeTimes` fresh on every call, so a seek arriving after the background probe resolves picks up the snap automatically; one arriving before still falls back to the existing circuit breaker (no regression). Verified live on the field AVI: far seek to the previously-hanging segment now returns in ~12 s instead of the ~90 s stall.
1336
+
1337
+ ## 2.9.50
1338
+
1339
+ - **Fix**: Seeking could get stuck in an infinite restart loop on some containers (observed: AVI with VBR MP3 audio), producing nothing for ~90 s until the whole WebRTC session died — the on-screen symptom of "seeking does nothing." Root cause, two parts: (1) `-accurate_seek -ss X` before `-i` trusts the container's own on-the-fly seek/index to land near X; for this AVI it pointed at a position with no valid frame boundary at all, so ffmpeg failed outright ("Seek failed" / "Header missing") — not just imprecisely — and every retry re-tried the SAME bad container-computed position. (2) `#ensureEncodingFor`/`#fireSettledSeek` never checked for a `"failed"` session state, and `#startEncodeRun` unconditionally resets state back to `"starting"` on every call — so a failed run's next client poll silently re-armed and re-ran the identical failing seek, forever. Fixed both: the video-keyframe probe (previously only used for the copy path's segment boundaries) now also feeds a two-step seek — jump to the nearest REAL keyframe (a position ffmpeg has already proven it can decode, read directly from the packet list, not the container's live index) before `-i`, then trim the short residual precisely after `-i` (always frame-accurate, no reliance on `-accurate_seek`'s trust in the container). A circuit breaker caps consecutive fast failures (exits within 2 s — never did real work) at the SAME target to 3 before the session is left in its terminal `failed` state instead of looping — a different seek target still gets a fresh attempt budget. Verified: the keyframe-snap helper against synthetic data, and the breaker's state machine (3 attempts at one target → blocked, a different target → fresh budget, only 4 real ffmpeg spawns instead of an unbounded loop).
1340
+
1341
+ ## 2.9.49
1342
+
1343
+ - **New**: `getSessionProgress` (the transcode-session progress endpoint) now also reports `outputMbps` — the observed produced bitrate from recently completed segment files (already computed internally for the viewer-link budget check, `#checkLinkBudget`), so the browser can turn its OWN measured link throughput into a delivery-speed multiplier for the unified download/transcode/delivery playback-start ETA, the same way the transcode's own `speed` already is one.
1344
+
1345
+ ## 2.9.48
1346
+
1347
+ - **Fix**: The "bytes still needed to resume" figure shown while buffering could jump UP mid-poll even though nothing regressed, which read as confusing/broken. Root cause: the resume-window progress (`resumeNeededBytes`/`resumeDownloadedBytes`) was always computed against the LIVE read position, which slides forward as the file is read/transcoded further — so when the window moved past an already-downloaded piece into a fresh, never-touched one, "bytes needed" jumped up (a moving reference frame, not a real setback). `getFileStats` now accepts an optional `resumeAnchorByteStart` and always returns the byte offset the window was computed against; the browser client captures that offset on the FIRST poll of a buffering episode and sends it back on every subsequent poll of the SAME episode, so the window stays pinned to a fixed target and the figure only ever decreases as real download progress happens. Verified: with the anchor pinned, repeated polls report the same "needed" while the live read position moves with no new data, and a real download of a piece inside the frozen window correctly decreases it.
1348
+ - **Fix**: A rapid sequence of seeks could leave playback permanently stuck — field-diagnosed from a live session (5 seek-restarts in 16 seconds), showing `failed to rename file segment-NNNNN.m4s.tmp` and a zombie ffmpeg still writing a `.tmp` file ~30 seconds after being "killed" by two later restarts, even after the session had already been released. Root cause: `#startEncodeRun` sent `SIGTERM` to the previous ffmpeg process and immediately spawned the replacement into the SAME session directory without waiting for it to actually exit. `ChildProcess.killed` only means a signal was sent, not that the process died — ffmpeg's own blocking read of our torrent-backed `/stream` input can defer signal handling for a long time while starved, so on a rapid sequence of seeks multiple ffmpeg processes ended up alive concurrently, fighting over CPU and racing each other's file writes in the same directory; none of them would finish a segment in time, which is what "stuck at seek" looks like to the viewer. Fixed by awaiting the previous process's exit (escalating from `SIGTERM` to `SIGKILL` if it does not exit within a grace period, reusing the `waitForChildExit` helper `disposeSession` already used correctly) before spawning the replacement. A new per-session generation counter (`encodeRunGeneration`) lets a restart that was superseded by an even newer seek while it was waiting abort instead of also spawning a process — verified with a standalone race simulation: 5 overlapping restarts against a slow-to-die previous process spawn exactly 1 process, matching the LATEST requested target.
1349
+
1350
+ ## 2.9.47
1351
+
1352
+ - **Fix**: Playback could get permanently stuck (hls.js endlessly re-fetching the manifest and the first segment, buffer never advancing) even though the transcode itself was encoding fine, running ahead of realtime. Root cause: ffmpeg creates the fMP4 `init.mp4` file before it finishes writing the codec-header boxes into it (unlike segments, its write is not gated behind an atomic rename), so a request could race a moment where the file exists but is still empty. That empty read was then cached forever as the session's init segment — a zero-length `Buffer` is still a truthy object, so the `if (session.initBytes)` cache guard treated it as "already resolved" and kept serving the empty file for the rest of the session, which hls.js can never initialize a SourceBuffer from. Fixed by treating a zero-byte read as not-yet-ready (keeps the caller's existing long-poll retrying) instead of caching it as final.
1353
+
1354
+ ## 2.9.46
1355
+
1356
+ - **New**: `getFileStats` now reports `resumeNeededBytes` / `resumeDownloadedBytes` — the bytes still to download in the 16 MB window ahead of the file's current read position (tracked per file by `prioritizeByteRange`, cleared on torrent removal), counted byte-accurately including partial pieces. Lets the browser show how much is left to download and the time to resume while buffering.
1357
+
1358
+ ## 2.9.45
1359
+
1360
+ - **New**: HLS transcode output switched from MPEG-TS (`.ts`) to **fMP4/CMAF** (`.m4s` segments + a shared `init.mp4`). Codec parameter sets (SPS/PPS) now live once in the init segment (referenced by `#EXT-X-MAP`) instead of being repeated in every segment. Benefits: (1) hardware encoders that do not repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce independently-usable segments (on `.ts` the segments after the first lacked SPS/PPS → "non-existing PPS", which is why v4l2m2m was rejected); (2) lower container overhead. The synthetic VOD playlist now emits `#EXT-X-VERSION:7` + `#EXT-X-MAP`; each seek-restart run rewrites `init.mp4`, so `getFileStream` caches and serves the FIRST init for the whole session — it is codec-config-only and position-independent (verified: a single init cleanly decodes segments produced by a later seek-restart run). Raised v4l2m2m `-num_capture_buffers` to 32 (the default 4 deadlocks / drops frames on the CM4). **Verified**: server-side clean decode of the synthetic playlist across seek-restart runs; end-to-end playback **and seek** in hls.js 1.6.16. **Still needs**: verification on native iOS HLS (Safari fMP4) before relying on it. NOTE: v4l2m2m still emits a residual no-picture access unit that the strict startup test rejects, so it continues to fall back to software for now (no regression); fMP4 removes the SPS/PPS blocker — the remaining quirk is separate.
1361
+
1362
+ ## 2.9.44
1363
+
1364
+ - **Fix**: Roll back to WebTorrent **2.8.5** (pinned) — 3.x introduced two regressions that broke downloading. (1) `torrent.downloaded`/`file.downloaded`/`file.progress` throw on a `deselect`-ed null piece (worked around in 2.9.43). (2) Worse: the internal piece picker itself throws `Cannot read properties of null (reading 'reserve'/'missing')` when it tries to request a block from a piece our seek prioritization (`prioritizeByteRange` `deselect`) removed — download freezes dead after a seek (field-observed: file stuck at ~51%, `down=0`, picker crashing every second). 2.8.5 is the known-good version: `select`/`deselect`/`critical` and the byte getters all work (verified — add, multi-file download, and the full deselect+critical seek pattern run with zero crashes on 2.8.5). Also pinned **`uint8-util` 2.2.6**: 2.8.5's own range is `^2.2.5`, which *allows* the incompatible 2.3.x that a fresh global install pulled (the original `arr2hex` crash), so the transitive version must be forced back — webtorrent dedupes to 2.2.6 while sub-deps that need 2.3.x keep their own nested copy. The 2.9.43 null-safe getter helpers are now redundant (2.8.5 getters never throw) but left in as harmless defensive code.
1365
+
1366
+ ## 2.9.43
1367
+
1368
+ - **Fix**: Torrents stalled at the metadata/download stage on WebTorrent 3.x — the adaptive upload throttle dropped the client-wide limit to `0` whenever no file had an active reader (e.g. the window before the first read is acquired). In WebTorrent 3.x `throttleUpload(0)` blocks the ENTIRE swarm exchange client-wide — even peer connections and DOWNLOAD — not just seeding (verified: `throttleUpload(0)` → 0 peers, 0 download; `throttleUpload(8KB/s)` → peers connect, multi-MB/s download). The idle branch now returns a minimal keep-alive floor (`UPLOAD_IDLE_FLOOR_BYTES` = 8 KB/s) instead of 0; still effectively no seeding, but the swarm stays alive.
1369
+ - **Fix**: Spurious `uncaughtException: Cannot read properties of null (reading 'length')` every few seconds during playback. WebTorrent 3.x nulls `pieces[index]` for pieces we removed from the download set via `deselect` (file selection, seek-behind-playhead demotion), and its own `torrent.downloaded` / `file.downloaded` / `file.progress` getters do not guard that null — they threw in our disk-cap sweep and stats builder. Added null-safe `torrentDownloadedBytes` / `fileDownloadedBytes` helpers (a deselected piece = 0 downloaded, the correct value, while still counting every other piece) and use them in `#currentDiskBytes`/`#enforceDiskCap` and `getFileStats`. Verified byte-for-byte identical to WebTorrent's own getters when no piece is null. (The underlying WebTorrent getter bug is filed upstream; it is non-fatal — download survives it — but the throws were noisy and risky.)
1370
+
1371
+ ## 2.9.42
1372
+
1373
+ - **Fix**: Torrents failed to load with a proxy crash — the REAL root cause (2.9.41 misdiagnosed it). WebTorrent 2.8.5's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)`, but `parse-torrent` returns `infoHash` as a hex **string**. `uint8-util` **2.3.x** rewrote `arr2hex` to require a TypedArray (`Buffer.from(data.buffer …)`); a string's `.buffer` is `undefined` → `Buffer.from(undefined)` → `ERR_INVALID_ARG_TYPE` thrown in a detached microtask. `uint8-util` 2.2.x iterated the argument and tolerated a string, so it only broke once the addon's unpinned global `npm install` pulled 2.3.x. It hit **every** torrent (v1/v2/hybrid alike — `arr2hex` is always called). Diagnosed by reproducing `client.add` inside the addon container and isolating `arr2hex('<hex>')` throwing on 2.3.2 but not 2.2.6. Fix: update **WebTorrent 2.8.5 → 3.x**, where the maintainer replaced that line with `parsedTorrent.infoHash?.substring(0, 7)` (no `arr2hex` on the string) — a proper dependency-forward fix, not a version pin, so `uint8-util`/`parse-torrent` stay current. Verified: the exact broken combo (webtorrent 3.0.16 + uint8-util 2.3.2 + parse-torrent 11.0.23) now adds cleanly, and the full API the proxy uses (`select`/`deselect`/`critical`/`_critical`/`wires`/`throttleUpload`/`createReadStream`/`destroy({destroyStore})`) is unchanged in 3.x.
1374
+ - **Fix**: Removed the 2.9.41 infohash pre-validation. It was based on the wrong diagnosis ("v2-only torrent") — the failing torrents were normal v1 — and it wrongly rejected legitimate v2/hybrid sources. WebTorrent (post-bump) handles v1, v2 and hybrid itself. The last-resort `uncaughtException`/`unhandledRejection` guard from 2.9.41 is kept as defense-in-depth.
1375
+
1376
+ ## 2.9.41
1377
+
1378
+ - **Fix**: A malformed or v2-only torrent source no longer crashes the whole proxy in a restart loop. WebTorrent's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)` assuming a BitTorrent v1 infohash exists; a v2-only / hybrid magnet (or a corrupt source) parses with `infoHash === undefined`, so that becomes `Buffer.from(undefined)` and throws in a microtask that bypasses the client `error` event — taking down the node and every viewer on it (observed: `ERR_INVALID_ARG_TYPE` → tunnel reconnect loop; the WebRTC session died ~6 s in as the process restarted under it). Two fixes: the torrent-add path now **pre-validates the infohash** with `parse-torrent` and rejects a source without a valid v1 40-hex infohash as a clean error the browser can show; and the process gained a **last-resort `uncaughtException`/`unhandledRejection` guard** that logs the full stack and keeps serving, so no single bad torrent can ever crash-loop the proxy. NOT a regression from the download-performance work (2.9.40) — those paths don't touch torrent parsing; it is a pre-existing crash surfaced by an unusual source.
1379
+ - **New**: Longer idle retention so a brief absence resumes instead of restarting. The HLS transcode session idle TTL is raised from 2 min to **10 min** and the torrent-data idle TTL from 5 min to **15 min**. A viewer who pauses, backgrounds the tab, or turns the phone off for a few minutes now resumes without a cold ffmpeg restart and without re-downloading already-fetched data — the warm session also widens the seamless auto-reconnect window. An idle ffmpeg stops producing at the look-ahead cap, so the longer session TTL costs retained segments on disk rather than sustained CPU; the global disk cap still evicts torrent data earlier under pressure, and active playback keeps refreshing both timers so neither expires mid-watch.
1380
+
1381
+ ## 2.9.40
1382
+
1383
+ - **New**: Adaptive upload throttle. Seeding to the BitTorrent swarm does not help our viewer (we deliver over our own channel) — it is pure uplink cost and the riskiest legal act — so the client-wide upload limit now defaults to **off** (`throttleUpload(0)`, was WebTorrent's unlimited default) and is raised only when needed. A 5 s adjuster sets: **0** when no file has an active reader (stop seeding entirely once nothing is being watched); a low **floor** (50 KB/s) while a reader is active (a token upload so tit-for-tat does not choke us to zero); and a **boost** (512 KB/s) only when a torrent is starving (download barely trickling while it still needs data) AND its wires show reciprocity choke (≥2 peers we want data from are choking us) — earning unchoke slots to un-starve the download. The policy is a pure function (`decideUploadLimit`, unit-tested); each change is logged for field tuning. Client-wide limit (one active torrent is the norm today).
1384
+ - **Fix**: Seek-aware piece prioritization now actually makes a far seek download the seek target first. On every `/stream` range request the proxy deselects the pieces BEHIND the read position, so WebTorrent's picker — which scans each selection sequentially from its first undownloaded piece — starts at the playhead instead of fetching the undownloaded gap behind it. Previously only a `critical()` window was marked, but `critical` does not reorder the scan (it only enables hotswap: re-requesting a block from a faster peer), so a seek into a large undownloaded region still waited behind the sequential backlog. Behind-playhead pieces are only dropped from the download set (stop fetching), never deleted — a backward seek re-selects them via the same call, and the whole file is re-selected on the next reader acquire; the pinned head/tail (codec probe) is unaffected. The critical read-ahead window (now 16 MB) is reset each call so it stays a moving window rather than accumulating over the whole file across seeks. Single-active-reader scope (the multi-viewer union window is roadmap item 23).
1385
+
1386
+ ## 2.9.39
1387
+
1388
+ - **Chore**: Log the stack (first frames) of WebTorrent `warning` events, not just the message. Field diagnosis: a playback froze mid-file with repeated `torrent-pool: … warning: Connection error: Cannot read properties of null (reading 'type')` (a WebTorrent µTP null-peer NPE, webtorrent#1932/#1940) while the swarm had seeders — peer connections were failing and the download starved. The old handler logged only the terse message, hiding which library path threw; the stack pinpoints it before we mitigate (next: prefer HTTP/DHT over the timing-out UDP trackers, then consider disabling µTP).
1389
+
1390
+ ## 2.9.38
1391
+
1392
+ - **New**: Adaptive bitrate for thin viewer links (OpenSpec change `adaptive-bitrate`). Field evidence (iPhone on cellular): uncapped complex scenes produced 4 s segments of ~18 Mbit/s against a 1–6 Mbit/s link — 45 s prebuffer and a draining buffer. Two parts. (a) Software encodes are now constrained-CRF: `-maxrate`/`-bufsize` per resolution rung (1080p→5000K, 720p→2800K, 480p→1400K, 360p→800K, 240p→400K nominal; ×1.3/×1.5 — webtor's production multipliers), so peaks stay bounded. (b) New data-channel route `POST /api/transcode-sessions/:id/net-report` accepts the browser's measured link throughput + buffered seconds; the realtime-budget loop gains a second downshift trigger — a FRESH report showing the usable link (×0.8 safety) sustainedly (15 s) below the observed produced bitrate while the viewer's buffer is low (<10 s) steps the encode one rung down via the existing machinery (shared 30 s cooldown, step cap, no upswitch). Log reason `viewer-link-bound` distinguishes it from CPU downshifts. Manual-quality sessions are exempt (no budget ladder); old clients that never report simply keep today's behaviour plus the caps.
1393
+
1394
+ ## 2.9.37
1395
+
1396
+ - **Fix**: Scrubbing (server-side seek) no longer hangs the player. A far segment request restarts ffmpeg at that position; native players (notably iOS HLS) issue a burst of scattered far requests after a scrub (observed: `367 → 732 → 369 → 368 → 370`, tens of seconds apart), and the old fixed 4 s cooldown only suppressed restarts within 4 s of the last — so each scattered request restarted ffmpeg and it ping-ponged between positions, producing nothing and stalling playback. Far requests are now **debounced**: the target index is recorded and a short settle timer armed (1.2 s quiet period, 2.5 s hard cap from the burst's first request); further far requests re-arm it and update the target to the latest index; when it settles, ffmpeg restarts once at that index. "Last index wins" self-corrects — a wrong target costs at most one extra settle, never the old infinite loop. The settle timer is cleared on session disposal. (OpenSpec change `seek-debounce`.)
1397
+
1398
+ ## 2.9.36
1399
+
1400
+ - **New**: Chunked request bodies over the data channel (OpenSpec change `chunked-request-bodies`). Large request bodies — notably the source registration, whose body is the base64 `.torrent` (hundreds of KB for a multi-season pack) — now arrive as bounded binary frames (the response-frame layout) announced by a `request-start` message, and are reassembled and run through the same path as a single-message request. Bounded: 32 MB per-body cap, a 60 s TTL for incomplete bodies, an abort frame that drops partial state at once, and all per-channel state freed on channel close. This removes the single-message size ceiling symmetrically with responses (which already stream in chunks). Logged as `body=<bytes> bytes (chunked)`.
1401
+
1402
+ ## 2.9.35
1403
+
1404
+ - **Fix**: Large torrents (many files / seasons) no longer fail with "Trying to send message larger than max-message-size" when a file is picked. The browser sends the source registration body — the base64-encoded `.torrent` — in a single data-channel message; a big multi-season pack's `.torrent` carries thousands of piece hashes (e.g. Poirot, 13 seasons: 420 KB → ~560 KB base64), exceeding libdatachannel's default advertised limit of 256 KB, so the browser's `channel.send()` threw. The proxy now advertises a 16 MB `a=max-message-size`, so a large single send still works while already-open tabs run the old bundle. Verified the SDP now carries `a=max-message-size:16777216` (was `262144`).
1405
+
1406
+ ## 2.9.34
1407
+
1408
+ - **New**: Cold-start reduction (OpenSpec change `cold-start`). Creating a transcode session no longer runs a second full ffmpeg input scan: the playback planner caches the media info (duration/resolution/fps/start-time/HDR) parsed from the probe it already ran, and `createSession` reuses it (falling back to its own probe only when the cache cannot serve — e.g. after a restart, or a missing critical field). The banner parsers now live in a shared `ffmpeg-banner.js` so both sides parse identically. Once a plan probe succeeds the proxy also warms the START of the file body (~16 MB, fire-and-forget) so the first segment's encode reads downloaded data instead of waiting on pieces. Session startup is now measurable in the log: `cold-start <id>: media-info=<ms> (cached|probed) keyframes=<ms|skipped> create-total=<ms>` and, once per session, `cold-start <id>: first-segment ready +<ms>`.
1409
+
1410
+ ## 2.9.33
1411
+
1412
+ - **New**: HDR / 10-bit tone mapping (OpenSpec change `transcode-quality`, part 3). An HDR source (BT.2020 with a PQ `smpte2084` or HLG `arib-std-b67` transfer) re-encoded to 8-bit SDR without tone mapping looks washed-out and desaturated. The proxy now detects HDR from the probe and, when re-encoding video on the software path, inserts a `zscale`+`tonemap` (hable) chain to convert HDR→BT.709 SDR properly. It is **gated on filter availability**: at startup the proxy checks this ffmpeg build for the `zscale` (libzimg) and `tonemap` filters (`hwaccel: HDR tone mapping available/unavailable …`); when either is missing it falls back to the previous plain 8-bit convert (still plays, just washed-out). The tone map runs after the downscale (cheaper on ARM). Logged per session as `hdr=1 tonemap=on|off`. Hardware encoders keep their current path for now (tone mapping there is a follow-up). No client change — the browser plays the resulting SDR HLS.
1413
+
1414
+ ## 2.9.32
1415
+
1416
+ - **New**: Manual quality support (OpenSpec change `transcode-quality`, part 4). The playback plan now reports the source coded resolution (`videoWidth`/`videoHeight`, parsed from the ffprobe banner) so the browser can offer a quality menu. `POST /api/transcode-sessions` accepts `manualQuality: true`: the requested target box is then encoded exactly (capped to the source, never upscaled) with the realtime budget disabled for that session — no startup auto-downscale and no runtime downswitch — so a viewer-forced resolution stays constant for the whole session. `manualQuality` is part of the session key (a forced-quality session is distinct from Auto). Logged as `enc=WxH@fps quality=manual`. Auto (no flag) is unchanged: the realtime budget decides. Pairs with the server release that adds the player quality menu.
1417
+
1418
+ ## 2.9.31
1419
+
1420
+ - **New**: Realtime transcode budget — startup resolution + preset selection (OpenSpec change `transcode-quality`, part 2.1). For the software encoder the proxy now picks the output RESOLUTION as well as the libx264 preset from the startup benchmark: the client-requested box (capped to the source, never upscaled) is the ceiling, and the proxy chooses the highest resolution rung at or below it that the benchmark predicts encodes faster than realtime (with the existing margin), then the best preset at that resolution. On a weak host this downscales (e.g. a 720p60→30 stream that ran at ~0.9× on a Home Assistant box now encodes at ~480p in realtime) instead of dropping into sub-realtime playback with constant stalls. Capable hosts keep full resolution and spend the headroom on a higher-quality preset; hardware encoders and the no-benchmark case are unchanged. Also fixed the realtime-need calculation to use the session's actual output frame rate instead of the fixed 24 fps constant (it under-counted for 25/30 fps content). The chosen encode resolution is logged (`enc=WxH@fps budget=on`). This scales down from the orientation-independent ceiling the browser now sends (server 0.8.43).
1421
+ - **New**: Realtime transcode budget — runtime downswitch (OpenSpec change `transcode-quality`, part 2.2). If a software transcode runs below realtime for a sustained window (ffmpeg `speed` < ~0.95× for ~15 s), the proxy steps the resolution one rung down the ladder and restarts the encode at the segment the viewer is on, so a stream that starts fine but bogs down on a heavy passage recovers instead of stalling. It first checks the bottleneck: if the torrent download can't sustain the source's byte rate (and the file isn't fully downloaded), the limit is the download, not the encoder — the proxy logs that and does NOT degrade quality. Conservative guards prevent thrash: a 30 s post-action cooldown, at most 3 downshifts, a resolution floor, the slow window reset on every (re)start, and no automatic upswitch yet. The switch point uses a hard encoder restart (a brief blip is possible there; a seamless discontinuity/parallel tier is a later refinement). Logged as `[budget] … CPU-bound speed=… → downscale to WxH` or `… download-limited; not downscaling`.
1422
+
1423
+ ## 2.9.30
1424
+
1425
+ - **New**: The proxy owns subtitle conversion and detects the language from content (OpenSpec change `subtitle-language`). `GET /api/subtitles` now also serves EXTERNAL subtitle files (no `trackIndex`): it reads the file, decodes its encoding (UTF-8 or Windows-1251 — common for Russian `.srt`), converts `.srt`/`.ass`/`.ssa` → WebVTT on the proxy (the browser no longer converts), and reports the language in `X-Subtitle-Language`/`X-Subtitle-Language-Name`. Language is detected with `franc` (n-gram, MIT) restricted to a curated language set — it distinguishes Russian from Ukrainian (and Latin languages) and avoids short-text false positives, returning no header when undetermined. Embedded tracks detect from the first chunk of extracted VTT. Pairs with the server release that fetches VTT from here and applies the filename → content → audio-language priority.
1426
+
1427
+ ## 2.9.29
1428
+
1429
+ - **New**: Global disk cap with LRU eviction (OpenSpec change `disk-cap`; Disk hygiene Level 1, final piece). Downloaded torrent data was already removed on a 5-min idle TTL and at shutdown, but under pressure it could still fill a small Home Assistant host's disk (which can take down HA itself). The pool now caps total downloaded data — default min(10 GB, half of free disk), overridable with `--max-disk-bytes` (0 disables) — and, when exceeded, evicts whole torrents with no active reader least-recently-used first (checked every 30 s). A torrent that is currently playing is never evicted. (LRU = least-recently-used.)
1430
+ - **New**: Output frame rate follows the source instead of a fixed 24 fps (OpenSpec change `transcode-quality`, part 1). 25/30 fps content no longer plays resampled to 24 (which caused judder). Frame-count-GOP encoders (software libx264, v4l2m2m) use an integer rate — source rounded, capped at 30 as a speed guard — with the fps filter and the GOP length kept in lockstep so a keyframe still lands on every segment boundary; the time-based-keyframe encoders (nvenc, vaapi, qsv) inherit the exact source rate untouched (nvenc previously forced 24 — its fps filter is removed). Source rate is parsed from the existing startup probe. (GOP = group of pictures, the span between keyframes; the segment grid needs a keyframe at each boundary.)
1431
+ - **Fix**: `GET /api/sources/:key/files` no longer blocks until metadata arrives (or fails prematurely on a cold magnet). It now waits only a short per-request budget (`maxWaitMs`, default 8 s, cap 20 s) and returns `{ pending: true }` while the swarm fetch continues in the background, so the browser can poll — mirroring the cold-torrent playback-plan poll. Field-found: a magnet whose metadata had not arrived yet failed with "no peers" on the first paste, then succeeded on a second paste because the fetch had kept running in the background. A real fetch error now returns 502 (distinct from pending). Pairs with server 0.8.39 (which references this as "proxy 2.9.28" — that release was folded into 2.9.29 before publishing).
1432
+
1433
+ ## 2.9.27
1434
+
1435
+ - **Fix**: A magnet whose infoHash matches a torrent already loaded in the pool no longer fails with 500 "Cannot add duplicate torrent" (scenario: one viewer opened the .torrent file, another pasted the magnet of the same content — different source keys, one swarm). The duplicate-add error now resolves to the already-loaded torrent (waiting for its metadata when it is itself still cold), so both source keys share the swarm. Found by a field test of the magnet flow.
1436
+
1437
+ ## 2.9.26
1438
+
1439
+ - **New**: Track inventory in the playback plan (OpenSpec change `track-selection`). The codec probe now parses EVERY input stream from the same ffmpeg banner, and the plan returns `audioTracks` and `subtitleTracks` — type-relative index, codec, language tag, `title` metadata, default disposition, and (for subtitles) a `textBased` flag (PGS/VobSub cannot become WebVTT).
1440
+ - **New**: Audio track selection for HLS sessions. `POST /api/transcode-sessions` accepts `audioTrackIndex`; the session maps `0:a:N` instead of always the first track, and the index is part of the session key, so switching tracks creates a fresh session (server-side restart) while the old one expires via the idle TTL.
1441
+ - **New**: Embedded subtitle extraction — `GET /api/subtitles?sourceKey&fileIndex&trackIndex` streams the chosen text subtitle track converted to WebVTT. Extraction reads the file up to the last cue, so on a cold torrent it drives the sequential download; callers must use a generous timeout. Non-text tracks (or a dead extraction) return 422 before any body.
1442
+ - **New**: `GET /api/sources/:sourceKey/files` lists the files of a registered source. Groundwork for magnet-link input (OpenSpec change `magnet-input` in the server repo): the browser parses `.torrent` files locally, but a magnet's file list only exists in swarm metadata — this route resolves the torrent (waiting for metadata on a cold magnet) and returns the inventory.
1443
+ - **Chore**: The announce log line strips the query string from the tracker URL — private trackers embed the account passkey there.
1444
+
1445
+ ## 2.9.25
1446
+
1447
+ - **New**: Observability (OpenSpec change `proxy-observability`). (1) `/healthz` and `/health` now include the proxy `version` — the addon shipped a stale proxy for a whole release and nothing could detect it remotely. (2) Peer-discovery diagnostics in `torrent-pool.js`: each added torrent logs its file count, `private` flag and tracker count; torrent-level `warning` events (tracker rejections/errors) are logged; every tracker announce response is logged with the seeder/leecher counts the tracker returned — so a zero-peer torrent is now explainable from the addon log. (3) Client-level WebTorrent warnings are logged too.
1448
+ - **Fix**: The `MaxListenersExceededWarning [Ssdp]` log flood is gone. The UPnP SSDP emitter inside `@silentbot1/nat-api` gains one listener per `map()`/renewal, and the WebRTC UDP mapper maps a 10-port range — exceeding Node's default limit of 10. The limit is lifted on that emitter right after the first successful mapping (`port-mapper.js`).
1449
+
1450
+ ## 2.9.24
1451
+
1452
+ - **New**: IPv6-first support (roadmap step 5a). (1) A second STUN server (`stun.cloudflare.com:3478`, alongside Google's) is added to the ICE config — both have IPv6 (AAAA) records, so when the proxy host has a global IPv6 address it gathers a `srflx` candidate over v6 too. IPv6 has no NAT, so if both the proxy and a (v6-native, e.g. cellular) viewer have global v6, the connection can go **direct** over v6 — sidestepping the whole NAT-traversal machinery. (2) Candidate logging now classifies each candidate by address scope — `v4-private` / `v4-public` / `v6-global` / `v6-ula` / `v6-linklocal` / `v6-loopback` (replaces the old private/public host label) — so the field log shows whether a global IPv6 path is actually being offered and chosen. Audited the candidate path: the proxy already forwards ALL candidates (incl. global v6) and the browser adds them all — nothing was dropping global v6, so no filter fix was needed. NOTE: not verifiable on the dev's proxy (its ISP exposes only ULA v6 `fd…`, no global v6); needs a proxy with global v6 to confirm in the field — the new `v6-global` log tag is there to spot it.
1453
+ - **Fix**: Cold-start playback no longer fails with "Data channel request timed out". `POST /api/playback-plan` (`playback-planner.getPlan`) used to block up to 60 s waiting for the file header to download for the codec probe — exactly the transport's 60 s request timeout, so a cold torrent (peers still connecting, 0 % header) raced and failed. The planner now takes a short per-request budget (`maxWaitMs`, 8 s from the route): it prioritises the file header and probes, and if the header still isn't down it returns the plan flagged `pending: true` (uncached) instead of blocking. The browser polls again — each call keeps the header prioritised — so no single request approaches the 60 s limit and the existing `/stats` poll keeps showing live peers/speed/% the whole time. Pairs with server 0.8.24 (browser-side poll loop, already live).
1454
+ - **New**: Disk hygiene (Level 1, `torrent-pool.js`). (1) A torrent with **zero active file readers** is now removed together with its on-disk store after a 300 s idle TTL (`torrent.destroy({ destroyStore: true })`), so downloaded data no longer accumulates while the proxy keeps running; re-requesting the torrent re-adds it. Re-acquiring a file cancels the pending removal, and the TTL is generous so brief gaps between ffmpeg range reads (or a short pause) never evict an in-use torrent. (2) **Startup orphan sweep**: leftover torrent data under `os.tmpdir()/webtorrent` from a previous hard kill (where graceful `destroyAll` never ran) is cleared at construction (safe — no torrents loaded yet). Still pending (Level 1): a global disk cap with LRU eviction.
1455
+
1456
+ ## 2.9.23
1457
+
1458
+ - **New**: Symmetric-NAT port prediction for WebRTC (roadmap step 4; `webrtc-manager.js` + `nat-classifier.js` delta + `cli.js` wiring). When the startup NAT classification reports a **symmetric** NAT, for each real IPv4 `srflx` candidate the proxy also offers predicted-port candidates at `base + delta*k` (k = 1..16, `delta` = the per-destination external-port step measured at startup), each with a unique ICE foundation. The browser probes these too; if one matches the external port the NAT assigns for the proxy→browser path, ICE connects — the practical, signalling-only form of the birthday-paradox trick (no node-datachannel changes, no extra sockets). **Scope**: covers sequential/predictable symmetric NATs; a fully-random symmetric NAT (where the true 256-socket birthday would be needed) is not solved by this and is out of reach on the node-datachannel stack. No-op for cone NATs (the fixed-port mapping already suffices) and IPv6 (no NAT). Diagnostics: logs the injected predicted ports per session (`symmetric NAT (delta=D) — injecting N predicted srflx candidates: …`); combined with the existing `selected pair local=[…]` log this shows whether a predicted port won. NOTE: could not be exercised end-to-end — the dev's home NAT is cone; needs a symmetric-NAT vantage to verify in the field (the logging is there to diagnose it when it appears).
1459
+
1460
+ ## 2.9.22
1461
+
1462
+ - **Fix**: The proxy no longer crashes on a repeat/remote WebRTC session (regression from 2.9.18). Two causes, both fixed: (1) `webrtc-manager.handleSignal` called `setRemoteDescription`/`addRemoteCandidate` with **no try/catch**, so when node-datachannel threw synchronously (`Failed to gather local ICE candidates`) the whole process died — killing every viewer and the tunnel — and was restarted by s6. It now contains the error per session (logs + closes only that session, never throws out of the handler). (2) Root cause of the gather failure: 2.9.18 set `enableIceUdpMux` per-PeerConnection but with **no persistent mux owner**, so the shared UDP socket was bound/freed with each connection — a session opened while a just-closed one still held the fixed port could not bind it and failed to gather. Fixed by creating ONE persistent `IceUdpMuxListener` on the fixed UDP port once at startup (owned by the WebRTC manager for the proxy's whole lifetime, released on shutdown via `dispose()`); every session keeps `enableIceUdpMux` + the same port and demuxes over the shared socket by ICE ufrag. This keeps the clean single-port model (one UDP port, one UPnP mapping, one reachable endpoint) while surviving session churn. Verified against libdatachannel issue #861 and locally: 5 sequential + 2 concurrent PeerConnections all gather on the one port with no error, and the srflx candidate carries the fixed port.
1463
+
1464
+ ## 2.9.21
1465
+
1466
+ - **Chore**: Diagnostic — the `/api/sources/:key/stats` route now logs the real swarm state on every poll: `[stats] <key> peers=N down=NKB/s file=N% header=down/totalB`. This surfaces a cold-start download stall (0 peers / header not advancing), which is what makes `POST /api/playback-plan` block on the codec probe until the browser's data-channel request times out. (Diagnosis: on the first/cold attempt the file header has not downloaded within ~60 s — likely worsened by `uTP not supported` on arm64/musl limiting peers — so the blocking probe times out; a warm attempt minutes later, with the header already cached, probes in ~25 ms and plays. Verified by the same torrent failing cold on cellular and playing warm on desktop.)
1467
+
1468
+ ## 2.9.20
1469
+
1470
+ - **New**: Startup NAT classification (`services/nat-classifier.js`, dependency-free — `node:dgram` + `node:crypto`). From a single local UDP socket the proxy sends a STUN Binding Request to two different public STUN servers (Google + Cloudflare) and compares the reflexive external port: same → **endpoint-independent (cone)** NAT (the fixed-port WebRTC mapping from 2.9.18 is sufficient, no port prediction needed); different → **symmetric** NAT (the mapped port varies per viewer, so WebRTC will need port prediction — a later roadmap step). The class is logged at startup. Best-effort: STUN probes are time-bounded and never block startup; an inconclusive probe is logged and ignored. Uses the modern dual-server, single-socket test (no RFC 3489 CHANGE-REQUEST, which public STUN servers like Google's do not support). Evaluated `@xmcl/stun-client`/`stun` (both MIT) but their public APIs create a fresh socket per query and/or rely on CHANGE-REQUEST, which is wrong for this test — hence the minimal in-house client.
1471
+
1472
+ ## 2.9.19
1473
+
1474
+ - **Chore**: Diagnostics for verifying remote WebRTC reachability and root-causing failures. `port-mapper.js` now logs a `removed mapping for <proto> <port>` line on clean shutdown unmap (previously silent on success). `webrtc-manager.js` now logs the **full** local ICE candidate (`addr:port typ …`, so the pinned UDP port is visible), every **ICE-state** transition (`checking → connected/failed`), and — on connect — the **selected candidate pair** (`local=[…] remote=[…]` with type/address/port), the single most useful line for "did the WebRTC path connect, and over which route (LAN / public srflx v4 / v6)".
1475
+
1476
+ ## 2.9.18
1477
+
1478
+ - **New**: WebRTC is now reachable behind NAT via a static UDP port mapping. All sessions are pinned to a single UDP port (same number as the HTTP port, default 9090) and multiplexed over it (`enableIceUdpMux` + `portRangeBegin`/`portRangeEnd` in `webrtc-manager.js`), and that UDP port is UPnP/NAT-PMP-mapped at startup (a second `port-mapper.js` instance, protocol UDP, removed on shutdown). Because the socket is bound to a fixed, statically-mapped port, the proxy's `srflx` ICE candidate now carries `publicIP:9090` — reachable from the browser even behind symmetric NAT for that port (previously WebRTC used an ephemeral UDP port that UPnP could not map). Verified: two PeerConnections share the one UDP port with no bind conflict; host + srflx (v4 and global v6) candidates all carry the fixed port. The UDP endpoint is not reported to the server (the browser learns it via ICE, not the TCP dial-back probe).
1479
+
1480
+ ## 2.9.17
1481
+
1482
+ - **New**: The proxy reports its UPnP-mapped external endpoint to the server over the tunnel (new `proxy-endpoint` message: `{ externalIp, externalPort, protocol }` from `port-mapper.getMappedEndpoint()`). Sent when the mapping completes and re-sent on every tunnel (re)connect, so the server can dial back and verify the proxy is reachable from the internet (server 0.8.22). No effect if port mapping is disabled or failed.
1483
+
1484
+ ## 2.9.16
1485
+
1486
+ - **New**: Automatic port mapping (`services/port-mapper.js`). At startup the proxy asks the home router to open its local port (default TCP 9090) via UPnP IGD / NAT-PMP using `@silentbot1/nat-api` (the same library WebTorrent already uses for the torrent port — no new host dependency). The mapping uses a 2 h lease auto-renewed while running and is removed on graceful shutdown (wired into the `cli.js` shutdown path; lease expiry is the backstop on a hard kill). Strictly best-effort: a router without UPnP/NAT-PMP is a normal case — it is logged and the proxy continues. Bounded by start/stop timeouts so a non-responding gateway never delays startup or hangs shutdown. Disable with `--no-port-mapping`. The discovered external endpoint is exposed via `getMappedEndpoint()` for the upcoming server-side reachability probe (not yet reported). `@silentbot1/nat-api` is now a direct dependency (was transitive via WebTorrent).
1487
+
1488
+ ## 2.9.15
1489
+
1490
+ - **Fix**: Torrent data is now cleaned up on graceful shutdown. `TorrentPool.destroyAll()` removes every torrent **with its on-disk store** (`torrent.destroy({ destroyStore: true })`) and then tears down the WebTorrent client; it is wired into the Fastify `onClose` hook (after `hlsSessionManager.disposeAll()`, so ffmpeg readers stop before their source files are removed). Previously nothing called `client.remove()`/`torrent.destroy()` anywhere, so downloaded files accumulated under `os.tmpdir()` until the process was killed — and even a clean SIGTERM/SIGINT left them behind. (First step of disk-hygiene Level 1; refcount/TTL removal and the startup orphan sweep are separate, still pending.)
1491
+
1492
+ ## 2.9.14
1493
+
1494
+ - **New**: `GET /api/sources/:sourceKey/stats` now reports `headerBytes` / `headerDownloadedBytes` — how much of the file's header/index region (leading 256 KB + trailing 2 MB, the bytes the codec probe needs) is downloaded, counted by whole torrent pieces from the bitfield. Lets the browser show the download phase's progress and ETA toward the next (transcode) phase. Coarse by design (piece granularity).
1495
+
1496
+ ## 2.9.13
1497
+
1498
+ - **Fix**: Video-copy path (`video=copy`, audio transcoded or copied) no longer drops video / desyncs audio at the start. The output timeline is now forced 0-based: the container `start_time` (parsed from the probe; many MKVs report ~0.1 s) is subtracted via `-output_ts_offset -start_time` together with `-copyts`, so segment 0 begins exactly at 0 with audio and video aligned (previously `-copyts` preserved the non-zero start, leaving a hole at the beginning where video was blank but audio played).
1499
+ - **New**: Unified segment-boundary model. The synthetic VOD playlist and all seek math now come from a boundary table: a uniform grid for re-encoded video, and the source's **real keyframe positions** (probed once with ffprobe, normalized to 0) for copied video — so the declared segment boundaries match where a copied stream actually cuts, eliminating seek gaps. The keyframe probe is time-bounded (~6 s); on slow containers it falls back to the uniform grid (start still 0-based). Session log shows `seg=keyframe|uniform` and `start=…`.
1500
+
1501
+ ## 2.9.12
1502
+
1503
+ - **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
1504
+ - **Branch A — video re-encoded** (`video=libx264`): use a fixed GOP (`-g`/`-keyint_min` = segmentDuration × fps, `-sc_threshold 0`) instead of `-force_key_frames expr:gte(t,n_forced*SEG)`. The old expression broke after a seek because `t` is shifted by `-output_ts_offset`, forcing keyframes at the wrong places and producing segments that did not line up with the playlist grid. A frame-count GOP is offset-independent → every segment is exactly segmentDuration and starts on a keyframe.
1505
+ - **Branch B — video copied** (`video=copy`, only audio transcoded): keep the source's real timestamps with `-copyts` (and accurate seek) instead of relabelling onto a 4 s grid that does not match the source's own keyframe positions. Relabelling was the source of the holes in this mode.
1506
+ - **Chore**: Session-start log tags the active branch (`branch=A(reencode,fixed-gop)` / `branch=B(copy,copyts)`) so glitches can be attributed to the right mode.
1507
+ - **Fix**: Log timestamps reverted to UTC (`HH:MM:SS.mmm`) so the proxy and browser logs share one timezone and line up exactly when correlated.
1508
+
1509
+ ## 2.9.11
1510
+
1511
+ - **New**: Seek-aware torrent piece prioritization. On every `/stream` range request the proxy now marks the torrent pieces at the read position **critical** (`TorrentPool.prioritizeByteRange` → `torrent.critical`, ~8 MB window). After a seek, ffmpeg opens the input at a new byte offset; previously those pieces waited behind the sequential download backlog, so seeking into an undownloaded region stalled ~15-18 s while the proxy fetched data. Now the seek position jumps the download queue.
1512
+
1513
+ ## 2.9.10
1514
+
1515
+ - **Fix**: Raised the adaptive-preset speed margin (`PRESET_SPEED_MARGIN` 1.3 → 1.8). The preset benchmark runs at startup with an idle CPU, but during playback ffmpeg competes with in-process WebTorrent (download + SHA1 hashing) and delivery, so real throughput is lower than benchmarked. A 1.3× margin picked a preset that ran near/below realtime under load (e.g. `faster` at ~1.3×) and stalled; 1.8× picks a preset with genuine headroom (e.g. `veryfast`), keeping playback above 1× under real load.
1516
+
1517
+ ## 2.9.9
1518
+
1519
+ - **Fix**: Software (libx264) video transcode is much faster on weak ARM hosts, so playback keeps up with realtime: encode uses all CPU cores (`-threads`), and the scaler **never upscales** — the target box is capped to the source size via `min(W,iw)`/`min(H,ih)`, so a small source (e.g. 720x400) is encoded at its own resolution instead of being scaled up to the viewport (far fewer pixels).
1520
+ - **New**: Adaptive software preset (preset auto-benchmark). At startup the proxy benchmarks libx264 presets (`fast`→`ultrafast`) on this host and records encode throughput (pixels/sec). Per stream, `hls-session-manager` picks the **highest-quality preset that still encodes the actual (source-capped) output resolution faster than realtime** with a safety margin, falling back to `ultrafast`. This maximises quality without dropping below 1× (which causes stalls). Logged as `video=libx264/<preset>` at session start.
1521
+ - **New**: The input probe (`probeInputMediaInfo`, formerly `probeInputDurationSeconds`) now also extracts the source video resolution from the container header (used by the adaptive preset to compute the output pixel rate). Still returns on the header without decoding the stream.
1522
+ - **Fix**: Transcode no longer thrashes between positions. `#ensureEncodingFor` now anchors the look-ahead window on the **current** encode position (not the run's start), and a `RESTART_COOLDOWN_MS` guard ignores competing seek-restart requests for a few seconds. Previously a stalled player requesting distant segments (e.g. #2 and #107) made ffmpeg ping-pong, restarting endlessly and producing nothing — which `Error opening input file` races confirmed.
1523
+
1524
+ ## 2.9.7
1525
+
1526
+ - **Fix**: `playback-planner` retries the codec probe while the file header is still downloading and no longer caches an **empty** probe result. Previously a transient empty probe (common for a later file in a multi-file torrent whose pieces arrive late) was cached permanently, so the file was mis-planned as directly playable forever — an unsupported video codec (e.g. xvid) got copied and played as a **black screen**. The probe now retries (up to 60 s) until at least one codec is detected, and only a successful detection is cached.
1527
+
1528
+ ## 2.9.6
1529
+
1530
+ - **Fix**: `probeInputDurationSeconds` now returns as soon as ffmpeg prints the container header (`Duration:`) instead of letting `-f null -` decode the whole stream until the 8 s timeout. Transcode-session creation was wasting ~8.6 s per session on this redundant decode (the duration was already available from the header, and `playback-plan` had probed it moments earlier). Cuts session-creation latency from ~9.7 s to ~1 s.
1531
+ - **New**: `GET /api/transcode-sessions/:id/progress` now includes `segmentDurationSec`, so the browser can show progress toward the first segment (the only thing it waits for before playback) instead of a percentage of the whole-file transcode.
1532
+
1533
+ ## 2.9.5
1534
+
1535
+ - **Fix**: Segment files are now read with a 4 MB `highWaterMark` (`hls-session-manager.js` `getFileStream`) so the body is delivered in few, large chunks. On a busy ARM host the in-process WebTorrent hashing starves the Node event loop in bursts while the first segments are served; reading in fewer iterations cuts the time lost between chunks (the first segment previously transferred in ~79 × 43 KB reads spaced ~610 ms apart).
1536
+
1537
+ ## 2.9.4
1538
+
1539
+ - **Chore**: Temporary `[net-debug]` instrumentation in `data-channel-handler.js` now splits transfer timing into `fetchMs` (waiting for the local route, incl. ffmpeg segment finalization), `ttfbMs` (time to first body chunk), `sendMs` (channel send duration) and `chunks`, to locate where early-segment latency is spent (transport vs segment production).
1540
+
1541
+ ## 2.9.3
1542
+
1543
+ - **New**: WebRTC data-channel response bodies are now sent as **binary** frames (`sendMessageBinary`) instead of base64-encoded JSON `response-chunk` messages, removing the ~33% base64 overhead and the JSON encode cost. Frame layout: `[flags(1)][idLen(1)][requestId(ASCII)][payload]`. Control messages (`response-start`, `response-error`, `pong`) remain JSON strings. Requires the matching browser client (server ≥ 0.8.0); **deploy the server before the proxy**.
1544
+ - **New**: Backpressure on the send loop — `data-channel-handler.js` pauses queuing body chunks once the channel's `bufferedAmount()` exceeds 8 MB and resumes when it drains below 1 MB (`setBufferedAmountLowThreshold` + `onBufferedAmountLow`), with a 5 s timeout fallback. Prevents the SCTP send buffer from ballooning and stalling throughput.
1545
+
1546
+ ## 2.6.3
1547
+
1548
+ - **Fix**: Data channel handler now logs **all** requests regardless of body presence — `GET /transcode/…`, `GET /api/…/progress`, `GET /api/…/stats` etc. were previously invisible in logs. Non-2xx response statuses and fetch errors are also logged, enabling diagnosis of HLS manifest load failures.
1549
+
1550
+ ## 2.6.1
1551
+
1552
+ - **Fix**: `TorrentPool.getTorrent()` — eliminated a race condition where two concurrent requests for the same torrent both found the cache empty and both called `client.add()`, causing WebTorrent to throw "Cannot add duplicate torrent". In-flight promises are now cached in a private `#pending` map; subsequent requests for the same key join the existing promise instead of triggering a second `client.add()`.
1553
+
1554
+ ## 2.5.15
1555
+
1556
+ - **New**: `GET /api/sources/:sourceKey/stats?fileIndex=N` — returns live torrent stats: connected peer count, download/upload speed, per-file download progress and size. Used by the browser to show meaningful feedback while waiting for file metadata.
1557
+ - **New**: `TorrentPool.getFileStats()` — reads `torrent.numPeers`, `torrent.downloadSpeed`, `file.progress`, `file.downloaded`, `file.length` from the WebTorrent instance.
1558
+
1559
+ ## 2.5.14
1560
+
1561
+ - **New**: `TorrentPool.prefetchFileEdges()` — opens WebTorrent read streams for the first 256 KB and last 2 MB of a file before ffprobe runs. This prioritises the torrent pieces that contain file headers (FTYP box) and the MOOV atom (typically at end of non-faststart MP4), ensuring codec and duration detection succeeds even for freshly-added torrents. Timeout is 5 minutes; failure is non-blocking.
1562
+ - **New**: Seek-to-position HLS transcode — `createOrGetSession` now accepts `startPositionSeconds`. ffmpeg is started with `-ss <pos>` (fast keyframe seek before `-i`) and `-output_ts_offset <pos>` so that output PTS matches the original timeline, keeping `video.currentTime` correct after a seek restart. Session cache key includes the rounded start position (10 s buckets) so nearby seeks share a session.
1563
+ - **New**: `POST /api/transcode-sessions` accepts `startPositionSeconds` in the request body.
1564
+ - **Chore**: `computeProgressMetrics` updated to compute percentage relative to the remaining duration from the seek point rather than the full file.
1565
+
1566
+ ## 2.5.13
1567
+
1568
+ - **Fix**: HLS playlist type changed from `vod` to `event`. With `vod`, ffmpeg only wrote `#EXT-X-ENDLIST` after transcoding the entire file, blocking playback start for large files indefinitely.
1569
+ - **Fix**: `waitForHlsPlaylist` in the browser now unblocks as soon as `#EXTINF:` appears (first segment ready) instead of waiting for `#EXT-X-ENDLIST`. Latency to first frame drops from minutes to seconds.
1570
+ - **Fix**: Codec detection in `PlaybackPlanner` — when ffprobe returns an empty audio codec (MOOV atom not yet downloaded), the plan now defaults to `direct` mode instead of forcing HLS transcode. The browser's range-request mechanism fetches the MOOV atom on demand.
1571
+
1572
+ ## 2.5.12
1573
+
1574
+ - **New**: Timestamps (`HH:MM:SS.mmm`) added to all log lines.
1575
+ - **New**: Proxy version logged at startup (`Starting @torrent-tv/proxy vX.Y.Z`).
1576
+ - **Fix**: WebRTC session torn down immediately after connect — `disconnected` ICE state is transient and no longer triggers `closeSession()`. Only `failed` and `closed` are terminal. This fixed data channels opening and closing within milliseconds.
1577
+ - **Fix**: Fastify `bodyLimit` raised from 10 MB to 256 MB — large `.torrent` files encoded as base64 JSON exceeded the previous limit.
1578
+
1579
+ ## 2.5.7
1580
+
1581
+ - **Fix**: WebRTC connection failure behind symmetric NAT — all ICE candidates (private and public) are now sent to the browser immediately. The browser attempts all paths in parallel; the local LAN path succeeds when browser and proxy are on the same network. Chrome's Private Network Access dialog appears once on first connect.
1582
+
1583
+ ## 2.5.6
1584
+
1585
+ - **Fix**: ICE candidate filtering — private host candidates (RFC 1918, Docker bridge IPs, IPv6 ULA/loopback) are now buffered and suppressed when a public srflx candidate is available. This eliminates the Chrome/Brave Private Network Access permission dialog when connecting from a page served over HTTPS. Falls back to private candidates if no public srflx candidate is gathered (e.g. STUN unreachable), so connectivity is preserved at the cost of the PNA dialog.
1586
+
1587
+ ## 2.5.5
1588
+
1589
+ - **Fix**: Tunnel keepalive — proxy now sends a WebSocket ping to the server every 30 s to prevent Cloudflare's ~100 s idle-connection timeout from dropping the tunnel.
1590
+
1591
+ ## 2.5.3
1592
+
1593
+ - Internal: improved tunnel reconnect logic and error logging.
1594
+
1595
+ ## 2.0.0
1596
+
1597
+ - **New**: WebRTC P2P tunnel architecture — replaced direct HTTP streaming with a persistent WebSocket tunnel to the server. Video is delivered from the proxy to the browser over a WebRTC data channel; the server acts only as a signalling relay.
1598
+ - **New**: `node-datachannel` dependency for server-side WebRTC.
1599
+ - **Removed**: `public_base_url` config — no longer needed.