@torrent-tv/proxy 2.9.74 → 2.9.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,369 +1,374 @@
1
- ## 2.9.74
2
-
3
- - **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.
4
- - **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.
5
- - **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.
6
- - **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.
7
-
8
- ## 2.9.73
9
-
10
- - **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 reportinvoked `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.
11
- - **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.
12
-
13
- ## 2.9.72
14
-
15
- - **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.
16
-
17
- ## 2.9.71
18
-
19
- - **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**.
20
- - **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.
21
- - **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.
22
-
23
- ## 2.9.70
24
-
25
- - **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.
26
-
27
- ## 2.9.69
28
-
29
- - **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.
30
-
31
- ## 2.9.68
32
-
33
- - **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.
34
-
35
- ## 2.9.67
36
-
37
- - **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.
38
-
39
- ## 2.9.66
40
-
41
- - **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.
42
-
43
- ## 2.9.65
44
-
45
- - **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.
46
- - **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.
47
-
48
- ## 2.9.64
49
-
50
- - **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* watchbecause 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.
51
-
52
- ## 2.9.63
53
-
54
- - **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.
55
-
56
- ## 2.9.62
57
-
58
- - **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.
59
-
60
- ## 2.9.61
61
-
62
- - **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.
63
-
64
- ## 2.9.60
65
-
66
- - **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.
67
-
68
- ## 2.9.59
69
-
70
- - **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.
71
-
72
- ## 2.9.58
73
-
74
- - **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.
75
- - **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.
76
-
77
- ## 2.9.57
78
-
79
- - **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).
80
-
81
- ## 2.9.56
82
-
83
- - **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.
84
-
85
- ## 2.9.55
86
-
87
- - **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.
88
- - **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.
89
-
90
- ## 2.9.54
91
-
92
- - **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.
93
- - **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.
94
-
95
- ## 2.9.53
96
-
97
- - **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.
98
-
99
- ## 2.9.52
100
-
101
- - **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`.
102
-
103
- ## 2.9.51
104
-
105
- - **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.
106
-
107
- ## 2.9.50
108
-
109
- - **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).
110
-
111
- ## 2.9.49
112
-
113
- - **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.
114
-
115
- ## 2.9.48
116
-
117
- - **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.
118
- - **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.
119
-
120
- ## 2.9.47
121
-
122
- - **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.
123
-
124
- ## 2.9.46
125
-
126
- - **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.
127
-
128
- ## 2.9.45
129
-
130
- - **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.
131
-
132
- ## 2.9.44
133
-
134
- - **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.
135
-
136
- ## 2.9.43
137
-
138
- - **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.
139
- - **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 nullthey 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.)
140
-
141
- ## 2.9.42
142
-
143
- - **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.
144
- - **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.
145
-
146
- ## 2.9.41
147
-
148
- - **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.
149
- - **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.
150
-
151
- ## 2.9.40
152
-
153
- - **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).
154
- - **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).
155
-
156
- ## 2.9.39
157
-
158
- - **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).
159
-
160
- ## 2.9.38
161
-
162
- - **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.
163
-
164
- ## 2.9.37
165
-
166
- - **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`.)
167
-
168
- ## 2.9.36
169
-
170
- - **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)`.
171
-
172
- ## 2.9.35
173
-
174
- - **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`).
175
-
176
- ## 2.9.34
177
-
178
- - **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>`.
179
-
180
- ## 2.9.33
181
-
182
- - **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.
183
-
184
- ## 2.9.32
185
-
186
- - **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.
187
-
188
- ## 2.9.31
189
-
190
- - **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).
191
- - **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`.
192
-
193
- ## 2.9.30
194
-
195
- - **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.
196
-
197
- ## 2.9.29
198
-
199
- - **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.)
200
- - **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 ratesource 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.)
201
- - **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).
202
-
203
- ## 2.9.27
204
-
205
- - **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.
206
-
207
- ## 2.9.26
208
-
209
- - **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).
210
- - **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.
211
- - **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.
212
- - **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.
213
- - **Chore**: The announce log line strips the query string from the tracker URL — private trackers embed the account passkey there.
214
-
215
- ## 2.9.25
216
-
217
- - **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.
218
- - **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`).
219
-
220
- ## 2.9.24
221
-
222
- - **New**: IPv6-first support (roadmap step 5a). (1) A second STUN server (`stun.cloudflare.com:3478`, alongside Google's) is added to the ICE configboth 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.
223
- - **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).
224
- - **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.
225
-
226
- ## 2.9.23
227
-
228
- - **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).
229
-
230
- ## 2.9.22
231
-
232
- - **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.
233
-
234
- ## 2.9.21
235
-
236
- - **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.)
237
-
238
- ## 2.9.20
239
-
240
- - **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.
241
-
242
- ## 2.9.19
243
-
244
- - **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)".
245
-
246
- ## 2.9.18
247
-
248
- - **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).
249
-
250
- ## 2.9.17
251
-
252
- - **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.
253
-
254
- ## 2.9.16
255
-
256
- - **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).
257
-
258
- ## 2.9.15
259
-
260
- - **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.)
261
-
262
- ## 2.9.14
263
-
264
- - **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).
265
-
266
- ## 2.9.13
267
-
268
- - **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).
269
- - **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=…`.
270
-
271
- ## 2.9.12
272
-
273
- - **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
274
- - **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.
275
- - **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.
276
- - **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.
277
- - **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.
278
-
279
- ## 2.9.11
280
-
281
- - **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.
282
-
283
- ## 2.9.10
284
-
285
- - **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.
286
-
287
- ## 2.9.9
288
-
289
- - **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).
290
- - **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 (which causes stalls). Logged as `video=libx264/<preset>` at session start.
291
- - **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.
292
- - **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.
293
-
294
- ## 2.9.7
295
-
296
- - **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.
297
-
298
- ## 2.9.6
299
-
300
- - **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.
301
- - **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.
302
-
303
- ## 2.9.5
304
-
305
- - **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).
306
-
307
- ## 2.9.4
308
-
309
- - **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).
310
-
311
- ## 2.9.3
312
-
313
- - **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**.
314
- - **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.
315
-
316
- ## 2.6.3
317
-
318
- - **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.
319
-
320
- ## 2.6.1
321
-
322
- - **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()`.
323
-
324
- ## 2.5.15
325
-
326
- - **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.
327
- - **New**: `TorrentPool.getFileStats()` — reads `torrent.numPeers`, `torrent.downloadSpeed`, `file.progress`, `file.downloaded`, `file.length` from the WebTorrent instance.
328
-
329
- ## 2.5.14
330
-
331
- - **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.
332
- - **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.
333
- - **New**: `POST /api/transcode-sessions` accepts `startPositionSeconds` in the request body.
334
- - **Chore**: `computeProgressMetrics` updated to compute percentage relative to the remaining duration from the seek point rather than the full file.
335
-
336
- ## 2.5.13
337
-
338
- - **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.
339
- - **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.
340
- - **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.
341
-
342
- ## 2.5.12
343
-
344
- - **New**: Timestamps (`HH:MM:SS.mmm`) added to all log lines.
345
- - **New**: Proxy version logged at startup (`Starting @torrent-tv/proxy vX.Y.Z`).
346
- - **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.
347
- - **Fix**: Fastify `bodyLimit` raised from 10 MB to 256 MB — large `.torrent` files encoded as base64 JSON exceeded the previous limit.
348
-
349
- ## 2.5.7
350
-
351
- - **Fix**: WebRTC connection failure behind symmetric NATall 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.
352
-
353
- ## 2.5.6
354
-
355
- - **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.
356
-
357
- ## 2.5.5
358
-
359
- - **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.
360
-
361
- ## 2.5.3
362
-
363
- - Internal: improved tunnel reconnect logic and error logging.
364
-
365
- ## 2.0.0
366
-
367
- - **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.
368
- - **New**: `node-datachannel` dependency for server-side WebRTC.
369
- - **Removed**: `public_base_url` config — no longer needed.
1
+ ## 2.9.75
2
+
3
+ - **New**: The piece store reports what it is doingresident 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.
4
+ - **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.
5
+
6
+ ## 2.9.74
7
+
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+
13
+ ## 2.9.73
14
+
15
+ - **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.
16
+ - **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.
17
+
18
+ ## 2.9.72
19
+
20
+ - **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.
21
+
22
+ ## 2.9.71
23
+
24
+ - **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**.
25
+ - **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.
26
+ - **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.
27
+
28
+ ## 2.9.70
29
+
30
+ - **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.
31
+
32
+ ## 2.9.69
33
+
34
+ - **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.
35
+
36
+ ## 2.9.68
37
+
38
+ - **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.
39
+
40
+ ## 2.9.67
41
+
42
+ - **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.
43
+
44
+ ## 2.9.66
45
+
46
+ - **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.
47
+
48
+ ## 2.9.65
49
+
50
+ - **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 falsemeasured 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.
51
+ - **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.
52
+
53
+ ## 2.9.64
54
+
55
+ - **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.
56
+
57
+ ## 2.9.63
58
+
59
+ - **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.
60
+
61
+ ## 2.9.62
62
+
63
+ - **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.
64
+
65
+ ## 2.9.61
66
+
67
+ - **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.
68
+
69
+ ## 2.9.60
70
+
71
+ - **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.
72
+
73
+ ## 2.9.59
74
+
75
+ - **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.
76
+
77
+ ## 2.9.58
78
+
79
+ - **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.
80
+ - **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.
81
+
82
+ ## 2.9.57
83
+
84
+ - **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).
85
+
86
+ ## 2.9.56
87
+
88
+ - **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.
89
+
90
+ ## 2.9.55
91
+
92
+ - **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.
93
+ - **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.
94
+
95
+ ## 2.9.54
96
+
97
+ - **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.
98
+ - **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.
99
+
100
+ ## 2.9.53
101
+
102
+ - **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.
103
+
104
+ ## 2.9.52
105
+
106
+ - **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`.
107
+
108
+ ## 2.9.51
109
+
110
+ - **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.
111
+
112
+ ## 2.9.50
113
+
114
+ - **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).
115
+
116
+ ## 2.9.49
117
+
118
+ - **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.
119
+
120
+ ## 2.9.48
121
+
122
+ - **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.
123
+ - **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.
124
+
125
+ ## 2.9.47
126
+
127
+ - **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.
128
+
129
+ ## 2.9.46
130
+
131
+ - **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.
132
+
133
+ ## 2.9.45
134
+
135
+ - **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.
136
+
137
+ ## 2.9.44
138
+
139
+ - **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.
140
+
141
+ ## 2.9.43
142
+
143
+ - **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.
144
+ - **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.)
145
+
146
+ ## 2.9.42
147
+
148
+ - **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.
149
+ - **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.
150
+
151
+ ## 2.9.41
152
+
153
+ - **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.
154
+ - **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.
155
+
156
+ ## 2.9.40
157
+
158
+ - **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).
159
+ - **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).
160
+
161
+ ## 2.9.39
162
+
163
+ - **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).
164
+
165
+ ## 2.9.38
166
+
167
+ - **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.
168
+
169
+ ## 2.9.37
170
+
171
+ - **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`.)
172
+
173
+ ## 2.9.36
174
+
175
+ - **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)`.
176
+
177
+ ## 2.9.35
178
+
179
+ - **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`).
180
+
181
+ ## 2.9.34
182
+
183
+ - **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>`.
184
+
185
+ ## 2.9.33
186
+
187
+ - **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.
188
+
189
+ ## 2.9.32
190
+
191
+ - **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 sessionno 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.
192
+
193
+ ## 2.9.31
194
+
195
+ - **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).
196
+ - **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`.
197
+
198
+ ## 2.9.30
199
+
200
+ - **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.
201
+
202
+ ## 2.9.29
203
+
204
+ - **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.)
205
+ - **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.)
206
+ - **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).
207
+
208
+ ## 2.9.27
209
+
210
+ - **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.
211
+
212
+ ## 2.9.26
213
+
214
+ - **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).
215
+ - **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.
216
+ - **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.
217
+ - **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.
218
+ - **Chore**: The announce log line strips the query string from the tracker URLprivate trackers embed the account passkey there.
219
+
220
+ ## 2.9.25
221
+
222
+ - **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.
223
+ - **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`).
224
+
225
+ ## 2.9.24
226
+
227
+ - **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.
228
+ - **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).
229
+ - **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.
230
+
231
+ ## 2.9.23
232
+
233
+ - **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).
234
+
235
+ ## 2.9.22
236
+
237
+ - **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.
238
+
239
+ ## 2.9.21
240
+
241
+ - **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.)
242
+
243
+ ## 2.9.20
244
+
245
+ - **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.
246
+
247
+ ## 2.9.19
248
+
249
+ - **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)".
250
+
251
+ ## 2.9.18
252
+
253
+ - **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).
254
+
255
+ ## 2.9.17
256
+
257
+ - **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.
258
+
259
+ ## 2.9.16
260
+
261
+ - **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).
262
+
263
+ ## 2.9.15
264
+
265
+ - **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.)
266
+
267
+ ## 2.9.14
268
+
269
+ - **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).
270
+
271
+ ## 2.9.13
272
+
273
+ - **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).
274
+ - **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=…`.
275
+
276
+ ## 2.9.12
277
+
278
+ - **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
279
+ - **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.
280
+ - **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.
281
+ - **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.
282
+ - **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.
283
+
284
+ ## 2.9.11
285
+
286
+ - **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.
287
+
288
+ ## 2.9.10
289
+
290
+ - **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 under real load.
291
+
292
+ ## 2.9.9
293
+
294
+ - **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).
295
+ - **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.
296
+ - **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.
297
+ - **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.
298
+
299
+ ## 2.9.7
300
+
301
+ - **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.
302
+
303
+ ## 2.9.6
304
+
305
+ - **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.
306
+ - **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.
307
+
308
+ ## 2.9.5
309
+
310
+ - **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).
311
+
312
+ ## 2.9.4
313
+
314
+ - **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).
315
+
316
+ ## 2.9.3
317
+
318
+ - **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**.
319
+ - **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.
320
+
321
+ ## 2.6.3
322
+
323
+ - **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.
324
+
325
+ ## 2.6.1
326
+
327
+ - **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()`.
328
+
329
+ ## 2.5.15
330
+
331
+ - **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.
332
+ - **New**: `TorrentPool.getFileStats()` reads `torrent.numPeers`, `torrent.downloadSpeed`, `file.progress`, `file.downloaded`, `file.length` from the WebTorrent instance.
333
+
334
+ ## 2.5.14
335
+
336
+ - **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.
337
+ - **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.
338
+ - **New**: `POST /api/transcode-sessions` accepts `startPositionSeconds` in the request body.
339
+ - **Chore**: `computeProgressMetrics` updated to compute percentage relative to the remaining duration from the seek point rather than the full file.
340
+
341
+ ## 2.5.13
342
+
343
+ - **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.
344
+ - **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.
345
+ - **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.
346
+
347
+ ## 2.5.12
348
+
349
+ - **New**: Timestamps (`HH:MM:SS.mmm`) added to all log lines.
350
+ - **New**: Proxy version logged at startup (`Starting @torrent-tv/proxy vX.Y.Z`).
351
+ - **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.
352
+ - **Fix**: Fastify `bodyLimit` raised from 10 MB to 256 MB — large `.torrent` files encoded as base64 JSON exceeded the previous limit.
353
+
354
+ ## 2.5.7
355
+
356
+ - **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.
357
+
358
+ ## 2.5.6
359
+
360
+ - **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.
361
+
362
+ ## 2.5.5
363
+
364
+ - **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.
365
+
366
+ ## 2.5.3
367
+
368
+ - Internal: improved tunnel reconnect logic and error logging.
369
+
370
+ ## 2.0.0
371
+
372
+ - **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.
373
+ - **New**: `node-datachannel` dependency for server-side WebRTC.
374
+ - **Removed**: `public_base_url` config — no longer needed.