## 2.55.7

- **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.

## 2.55.6

- **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`).

## 2.55.5

- **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.
- **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.

## 2.55.4

- **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.

- **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.

- **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.

## 2.55.1

- **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.
- **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.

## 2.55.0

- **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.
- **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.
- **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 %.
- **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.
- **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.
- **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".
- **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.
- **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.

## 2.54.0

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 2.53.0

- **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.

## 2.52.0

- **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.
- **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.
- **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.
- **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.
- **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.

## 2.51.0

- **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.
- **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.
- **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.
- **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.
- **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`.
- **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.
- **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.
- **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.
- **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.

## 2.50.0

- **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.
- **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.

## 2.49.0

- **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.
- **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.
- **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.

## 2.48.0

- **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).
- **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.
- **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.

## 2.47.0

- **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.

## 2.46.0

- **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 %.
- **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.
- **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.

## 2.45.0

- **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.
- **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.
- **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".

## 2.44.0

- **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.
- **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.

## 2.43.2

- **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`.
- **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.
- **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.
- **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.
- **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.

## 2.43.1

- **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.

## 2.43.0

- **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.
- **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.

## 2.42.0

- **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.
- **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.

## 2.41.0

- **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.
- **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.

## 2.40.2

- **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`.

## 2.40.1

- **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.

## 2.40.0

- **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.
- **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.
- **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.
- **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.

## 2.39.1

- **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.

## 2.39.0

- **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`.
- **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.

## 2.38.1

- **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.

## 2.38.0

- **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.

## 2.37.1

- **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.

## 2.37.0

- **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.
- **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`.
- **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.

## 2.36.2

- **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.
- **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.

## 2.36.1

- **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.
- **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.

## 2.36.0

- **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.
- **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.
- **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.
- **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.

## 2.35.0

- **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.

## 2.34.0

- **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.
- **Chore**: Removed `services/torrent-worker/supply-interruptions.js`, a second copy of the same arithmetic that was wired to nothing.

## 2.33.0

- **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.
- **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.

## 2.32.0

- **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`).
- **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.

## 2.31.0

- **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.

## 2.30.2

- **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.
- **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)`.

## 2.30.1

- **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.

## 2.30.0

- **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`).

## 2.29.0

- **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.

## 2.28.0

- **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.
- **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.

## 2.27.0

- **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.

## 2.26.0

- **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.

## 2.25.1

- **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.
- **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.

## 2.25.0

- **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.
- **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.

## 2.24.1

- **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.

## 2.24.0

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 2.23.0

- **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.
- **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.
- **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.
- **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.
- **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.

- **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.

## 2.22.0

- **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.
- **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.

## 2.21.1

- **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.

## 2.21.0

- **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.
- **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.
- **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.
- **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.

## 2.20.0

- **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.

## 2.19.0

- **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.
- **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.

## 2.18.0

- **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).
- **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.
- **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.

## 2.17.0

- **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.
- **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.
- **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.
- **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.
- **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.

## 2.16.0

- **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.

## 2.15.3

- **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.

## 2.15.2

- **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.

## 2.15.1

- **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.

## 2.15.0

- **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.
- **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.
- **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.

## 2.14.3

- **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.
- **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.
- **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.

## 2.14.2

- **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.

## 2.14.1

- **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.

## 2.14.0

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 2.13.0

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 2.12.2

- **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.
- **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.
- **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.

## 2.12.1

- **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.
- **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.
- **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.
- **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.

## 2.12.0

- **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.
- **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.

- **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.
- **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.
- **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.

## 2.11.0

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 2.10.0

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 2.9.141

- **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.

## 2.9.140

- **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.
- **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.
- **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.

## 2.9.139

- **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.
- **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.

## 2.9.138

- **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.

## 2.9.137

- **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.

## 2.9.136

- **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.

## 2.9.135

- **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.

## 2.9.134

- **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.

## 2.9.133

- **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.
- **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.

## 2.9.132

- **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.

## 2.9.131

- **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.

## 2.9.130

- **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.

## 2.9.129

- **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.

## 2.9.128

- **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.

## 2.9.127

- **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.

## 2.9.126

- **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.

## 2.9.125

- **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.
- **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.
- **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.
- **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.

## 2.9.124

- **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.

## 2.9.123

- **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.

## 2.9.122

- **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.

## 2.9.121

- **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.
- **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.

## 2.9.120

- **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.
- **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.
- **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.

## 2.9.119

- **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.

## 2.9.118

- **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.

## 2.9.117

- **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.
- **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`.

## 2.9.116

- **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.

## 2.9.114

- **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.
- **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.

## 2.9.113

- **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.

## 2.9.112

- **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.

## 2.9.111

- **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.
- **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.

## 2.9.110

- **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.

## 2.9.109

- **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.

## 2.9.108

- **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.

## 2.9.107

- **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.

## 2.9.106

- **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.
- **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.

## 2.9.105

- **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.

## 2.9.104

- **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.
- **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.

## 2.9.103

- **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.
- **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.
- **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.
- **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.
- **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.

## 2.9.102

- **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`).

## 2.9.101

- **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.

## 2.9.100

- **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.
- **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.
- **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.

## 2.9.99

- **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.
- **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.

## 2.9.98

- **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.
- **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.
- **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.

## 2.9.97

- **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.

## 2.9.96

- **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.
- **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.

## 2.9.95

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## 2.9.93

- **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.
- **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.
- **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.

## 2.9.92

- **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.
- **Chore**: Removed `ENCODER_STALL_MS`, declared with a paragraph describing a watchdog that was never wired to anything.

## 2.9.91

- **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.
- **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.

## 2.9.90

- **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.

## 2.9.89

- **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.
- **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).

## 2.9.88

- **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.

## 2.9.87

- **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.

## 2.9.86

- **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.
- **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.

## 2.9.85

- **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.

## 2.9.84

- **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.

## 2.9.83

- **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.

## 2.9.82

- **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.
- **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.

## 2.9.80

- **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.
- **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.
- **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.
- **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.

## 2.9.79

- **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.
- **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.

## 2.9.78

- **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.
- **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.
- **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.

## 2.9.77

- **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.
- **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.
- **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.
- **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.

## 2.9.76

- **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.
- **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.

## 2.9.75

- **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.
- **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.

## 2.9.74

- **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.
- **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.
- **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.
- **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.

## 2.9.73

- **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.
- **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.

## 2.9.72

- **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.

## 2.9.71

- **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**.
- **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.
- **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.

## 2.9.70

- **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.

## 2.9.69

- **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.

## 2.9.68

- **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.

## 2.9.67

- **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.

## 2.9.66

- **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.

## 2.9.65

- **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.
- **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.

## 2.9.64

- **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.

## 2.9.63

- **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.

## 2.9.62

- **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.

## 2.9.61

- **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.

## 2.9.60

- **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.

## 2.9.59

- **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.

## 2.9.58

- **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.
- **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.

## 2.9.57

- **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).

## 2.9.56

- **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.

## 2.9.55

- **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.
- **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.

## 2.9.54

- **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.
- **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.

## 2.9.53

- **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.

## 2.9.52

- **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`.

## 2.9.51

- **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.

## 2.9.50

- **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).

## 2.9.49

- **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.

## 2.9.48

- **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.
- **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.

## 2.9.47

- **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.

## 2.9.46

- **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.

## 2.9.45

- **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.

## 2.9.44

- **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.

## 2.9.43

- **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.
- **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.)

## 2.9.42

- **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.
- **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.

## 2.9.41

- **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.
- **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.

## 2.9.40

- **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).
- **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).

## 2.9.39

- **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).

## 2.9.38

- **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.

## 2.9.37

- **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`.)

## 2.9.36

- **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)`.

## 2.9.35

- **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`).

## 2.9.34

- **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>`.

## 2.9.33

- **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.

## 2.9.32

- **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.

## 2.9.31

- **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).
- **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`.

## 2.9.30

- **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.

## 2.9.29

- **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.)
- **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.)
- **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).

## 2.9.27

- **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.

## 2.9.26

- **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).
- **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.
- **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.
- **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.
- **Chore**: The announce log line strips the query string from the tracker URL — private trackers embed the account passkey there.

## 2.9.25

- **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.
- **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`).

## 2.9.24

- **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.
- **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).
- **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.

## 2.9.23

- **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).

## 2.9.22

- **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.

## 2.9.21

- **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.)

## 2.9.20

- **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.

## 2.9.19

- **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)".

## 2.9.18

- **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).

## 2.9.17

- **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.

## 2.9.16

- **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).

## 2.9.15

- **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.)

## 2.9.14

- **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).

## 2.9.13

- **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).
- **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=…`.

## 2.9.12

- **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
  - **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.
  - **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.
- **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.
- **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.

## 2.9.11

- **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.

## 2.9.10

- **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.

## 2.9.9

- **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).
- **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.
- **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.
- **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.

## 2.9.7

- **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.

## 2.9.6

- **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.
- **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.

## 2.9.5

- **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).

## 2.9.4

- **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).

## 2.9.3

- **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**.
- **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.

## 2.6.3

- **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.

## 2.6.1

- **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()`.

## 2.5.15

- **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.
- **New**: `TorrentPool.getFileStats()` — reads `torrent.numPeers`, `torrent.downloadSpeed`, `file.progress`, `file.downloaded`, `file.length` from the WebTorrent instance.

## 2.5.14

- **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.
- **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.
- **New**: `POST /api/transcode-sessions` accepts `startPositionSeconds` in the request body.
- **Chore**: `computeProgressMetrics` updated to compute percentage relative to the remaining duration from the seek point rather than the full file.

## 2.5.13

- **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.
- **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.
- **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.

## 2.5.12

- **New**: Timestamps (`HH:MM:SS.mmm`) added to all log lines.
- **New**: Proxy version logged at startup (`Starting @torrent-tv/proxy vX.Y.Z`).
- **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.
- **Fix**: Fastify `bodyLimit` raised from 10 MB to 256 MB — large `.torrent` files encoded as base64 JSON exceeded the previous limit.

## 2.5.7

- **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.

## 2.5.6

- **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.

## 2.5.5

- **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.

## 2.5.3

- Internal: improved tunnel reconnect logic and error logging.

## 2.0.0

- **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.
- **New**: `node-datachannel` dependency for server-side WebRTC.
- **Removed**: `public_base_url` config — no longer needed.
