aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
...
* | docs: cite MESHBAY_DESIGN.md and a section instead of the merged notesChristophe Besson7 days100-411/+447
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-feature design notes were merged into docs/MESHBAY_DESIGN.md and deleted from the tree on 2026-09-11, but ~230 comments across the three packages still named them — usually written `docs/musicbay.md §3.2`, as though the file were still in docs/. A reader had to know §16 existed to resolve any of them. They now name the section directly. Every mapping comes from §16, the concordance, which already records where each old section landed: musicbay -> §9.8, mediacenter -> §9.7 for the Videos app and §6.5 where the subject is derived data, photos -> §9.9, auth-confirm -> §3.6, refactoring-search -> §9.11, invite-pairing-v1 -> §3.4, per-node-identity-v1 -> §3.2, captcha -> §7.7, chat-sender-keys -> §4.5, apps/refactor-groups -> §9.1–§9.4, desktop-client-v1 per section. Bare citations of the same documents (`draft-v6 §2.11`, `§4.8`, `§3.4`) are retargeted too: those collide with real section numbers in the design document, so leaving them would have been worse than the named form. Four cases the concordance does not cover, each decided rather than guessed: Sub-item references into documents that no longer exist — mediacenter's `§3.3 row 4`, `§3.4b/c`, `apps.md §3b` — name rows and sub-items §9.7 and §9.2 do not reproduce. The module-level citation stays; the sub-item pointer is dropped. The V-findings keep their labels but lose the dead `§10.1/` prefix. §13.8 lists V1–V13 as per-application open items, which is not what the labels mean in these comments, so pointing them at §13.8 would have been a false citation. `apps.md §5`'s virtualization requirement has no counterpart anywhere in the design document. The requirement is stated in the comment itself, so the citation is dropped rather than aimed at a section that does not say it. Comments that attributed a *sentence* to an old note — musicbay's "several thousand files" example, its "what got measured" note, its measured ~11%/~26% cover-art figures, the "original no root, whole shared tree" call — state the fact without attribution now. §9.8 does not contain those sentences and citing it for them would have been wrong. CLAUDE.md's "a reference to a document that no longer exists" row now says the concordance is for git history and out-of-tree material; the code cites sections directly. Verified: 2851 passed, 4 skipped. The 12 errors in the run are the Firefox leg of test_sticky_header.py's browser harness, which is broken at the browser level on this machine — headless Firefox (snap) dies with `[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer`, renders nothing, and the probe exits `{"error": "no measurement"}` after its full 90s wait. Chrome runs the same 12 assertions in 3.2s and passes. Nothing here can affect it: every changed line in style.css is inside a comment. Also checked: ast.parse on every changed .py, `node --check` on every changed .js, the /* */ balance in style.css, and that no changed line exceeds the width its file already used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | docs: drop the references to design notes that live outside the repoChristophe Besson7 days6-48/+37
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Seventeen comments across the Windows packaging targets pointed at C:\Users\admin\devel\light-client.md and msix-installer.md -- absolute paths on one developer's machine, unreadable to anyone else who clones this repository and unverifiable by any test here. The surrounding prose already carried the substance in every case, so these are removals, not rewrites, with two exceptions where the pointer was doing real work: electron-builder.msix.yml's header told the reader to go read §4 and §8 first. It now states the fact directly (an AppX/MSIX install never elevates, by design, so the package carries none of installer.nsh's elevation logic) and says the open items are called out at each declaration below -- which they already were, at `capabilities` and `customExtensionsPath`. The two "msix-installer.md §8" citations become "an open item" / "the other open item", beside the description of the item that was already there. Paragraphs the removals left ragged are re-wrapped. Verified: node suite 1403 pass / 4 skip (71 of them test_packaging_win.py), both electron-builder configs still parse as YAML, node --check on main.js. The .ps1 edits are inside <# #> comment headers plus one deleted Write-Host in a block that keeps two others; no pwsh on this machine to parse them. Not touched, and much larger: ~230 comments elsewhere in the codebase cite per-feature design notes (musicbay.md, mediacenter.md, auth-confirm.md and twenty more) that were merged into docs/MESHBAY_DESIGN.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): a group whose node is down is no longer something the reader waits forChristophe Besson7 days4-33/+424
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A node is a machine in somebody's house, so with a handful of groups one is always off. Search treated that as the exception and charged the reader for it. Measured on twelve groups against a virtual clock, a live group answering in 200 ms and a dead one taking the full deadline: one node down in the first batch put a blank page and a progress bar in front of the reader for **ten seconds**, while two groups of that same batch had answered in two hundred milliseconds and nine others had not been dialled at all. Four down, spread out — the shape a real reader has — was ten seconds to the first result and **forty** to the last. Two causes, neither of them the connection deadline. Results were drawn once a batch was complete rather than as each arrived, so an index already in hand waited on a node that was not answering. And the batches were sequential, so a dead group did not merely cost its own deadline, it postponed every group behind it. So the three is a ceiling on concurrency and never a batch — `inFlight` starts the next group the moment one ends — and each index is drawn when it lands. That alone is not enough, and the tests say where it stops: a ceiling still lets silent nodes hold every place at once, and with four of twelve down the last three live groups still waited out a deadline. So the browser remembers which groups were silent and dials them last, which puts all eight on screen in 600 ms. The list is advisory and rewritten from what each sweep saw: a private window, storage that refuses, a first visit or a node that has come back all fall through to the hub's own order, cost one sweep, and correct themselves. The "n groups unreachable" line also waited for the sweep to finish, which is the one moment it is no longer needed. It now appears as they are found. First result, before and after, twelve groups: one down 10 s → 200 ms; four down 10 s → 200 ms with every reachable group on screen by 600 ms; three down and listed first 10 s → 200 ms from the second visit on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): Search waits on a connection that stalls, not on one that is slowChristophe Besson7 days5-5/+329
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a group from the sidebar has no deadline of its own and gets the transport's: 30 s for the DataChannel, 30 s for each request after it. Search wrapped the same `connect()` in a flat 10 s, and that 10 s had to cover the hub round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the channel opening and the handshake's own round trips. On a phone on 4G the budget was met by luck rather than margin, and the same group then failed in Search while it opened from the sidebar, against the same node. The budgets were inverted: the phase full of round trips had a third of what one request on an open channel got. Raising the number would have been the wrong repair. `fetchAllIndexes` fans out three at a time and waits for the slowest of each batch, so a page of unreachable groups costs batches x the deadline in spinner: a bigger number taxes every dead group for the sake of the live ones. So the deadline measures stalling. A node that is not there reports nothing and still fails in `SEARCH_STALL_MS`, unchanged at 10 s, which is what keeps the fan-out where it was. A node that answers ICE, then opens a channel, buys another window at each step, up to `SEARCH_MAX_MS` — a deadline that only ever resets has none, and a node that answers and then goes quiet would otherwise never be given up on. The transport reports those steps through `onConnectProgress`, set by the one caller that imposes a deadline of its own. `connected`/`completed` is the signal and not `checking`, because the first means a candidate pair answered and the second means this side is still trying addresses that may all be dead. A caller's callback cannot break the connection it is reporting on. The tests run the shipped `connectToGroup`, lifted out as text, against a fake clock — a real one would make each scenario a minute and blur the only thing worth asserting, which is when the deadline fires. Dead node: 10 s. Slow but moving: connects at 20 s where it used to fail at 10. Answers then stops: 18 s. Progress that never finishes: the 30 s ceiling. Two dead nodes: two windows, both transports closed. Checked against the flat deadline, which fails three of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: the desktop stages D5 and D6 are built, elsewhereChristophe Besson7 days1-2/+10
| | | | | | | | | | | | | | | | | | | Both described panels the desktop client would grow of its own. §8.3 settled that differently: one UI source means anything written for the client is something the browser then lacks, so both were built in `static/` instead and the client ships them. D5 is `node-page.js` over the operator ops — status, groups, roots with eject, plug and add, roster, peers, pairing, audit export. D6 is `create-group-page.js`, which detects the node, provisions it, takes the group's name and roots, initialises the key and pairs. The service unit is the one genuinely native part, and the desktop main process already runs it: `systemctl --user` on Linux, Task Scheduler on Windows. Left standing in §15.2 they read as work outstanding, which is how a plan outlives the decision that replaced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): creating and removing a folder are syscalls tooChristophe Besson7 days5-53/+122
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The last two handlers on the loop. Both were synchronous, so a member creating a folder on a root that had spun down held the node for the spin-up, exactly as a chunk read did. Where a check and an act belong together they are now one call rather than two awaits, and the single disk thread is what makes that atomic: `_mkdir_if_absent` so two members creating the same name cannot both find nothing there and have the second `mkdir` raise where a refusal was meant, and `_rmdir_if_empty` for the reason the caller already re-tested emptiness — the first test happened before a round trip to the operator's browser, and a file can land in between. Two awaits would reopen that window one size smaller. The guard is now the whole class rather than the calls that were fixed. It walks the module's syntax tree and fails on any filesystem call outside the handful of functions written to be run through `off_disk` — a new handler that stats a root inline would pass every measured test, because those exercise the handlers that exist today. Checked by putting a call back: it names the function and the line. It leaves ffmpeg's own scratch files out, listed rather than silently allowed: they are under `tempfile.mkstemp` on the system disk, not on a group root, so they are not what spins down — but they do read a whole transcode into memory from the loop, and the day that matters it is a different measurement from this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): take the availability poll and every upload write off the loopChristophe Besson7 days14-112/+255
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The rest of AV9's disk half. Serving a file left the loop in the commit before this one; two paths were still on it. **The availability poll.** `RootSet.refresh_availability` stats every root, and eleven call sites reached it from `async def` — the reconcile loop among them, on a timer. On a sleeping disk that is a stall once per tick, and the stat is also what keeps the disk awake, so a node paid spin-up for a library nobody was reading. All eleven now go through `off_disk`, `Root.is_live` included. **The upload write.** `open`/`write`, and the resolve, the stat, the free-name search, the rename and the unlink around it. This one could not simply be awaited: the handler was synchronous, so nothing could come between the `chunk_index != state.next_index` check and the `advance` that answers it, and that is the whole of the chunk-ordering rule. Awaiting the write opens the gap — chunk 1 arriving while chunk 0 is in the disk thread reads a position that has not moved and is refused as out of order, so an upload would fail on a slow disk and nowhere else. Verified, not assumed: without the lock the new ordering test refuses three chunks of four. So the check, the write and the advance are one critical section again, under a lock held **per group**. Not per session: `partial_uploads` lives in the group context so a reconnecting client finds its upload where it left it, which means two sessions of one member share the position of one `.part` file. Arrival order is preserved by construction — the dispatcher creates one task per message as it arrives, tasks start in creation order, and the lock is the first thing each one waits on, so its waiters queue in arrival order too. `_do_file_upload` is a coroutine now, which is why forty-two test call sites gain an `await`. Their outcomes are unchanged, file by file, against the run before the change. `test_ops.py` asked which public coroutines `ops` exposes and got `off_disk`, imported rather than defined there. It now asks for the ones written in the module, which is what its own docstring means; all forty-three operations are still checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): serve a file from a disk thread, never from the event loopChristophe Besson7 days5-34/+338
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A root that has spun down, or that lives on a network mount, answers its first syscall in seconds rather than microseconds. Made from the event loop, that stalls the whole node: no other group is served, no stream is fed, no chat message is delivered and the hub socket is not read, for as long as the platter takes to come back. It was found from the other end — a client's connection attempt timing out on a node with one member, while the disk woke up — and it is AV9's lesson with the disk in the place of the mail server. Every filesystem call on a group's content now goes through `off_disk`, onto a thread that belongs to that group's root set. The stat goes with the read: a stat is what *wakes* a sleeping disk, so offloading only the read would move the stall rather than remove it, and the read would then find the disk already awake. `_locate` is the one place allowed to call `entry_abs_path`, which is `Path.resolve()` and therefore syscalls too. Five handlers touched: a chunk request, an audio transcode, a subtitle request, a video stream and a delete. The delete became a coroutine, which its one caller already was. One worker per root set, not a pool and not one for the node. One worker for the same reason the indexer's executor has one — two interleaved reads of a spinning drive seek-thrash rather than go faster — and it keeps two threads from being inside the same file at once, which is what makes the `seek`/`read` pair safe without a lock. Per root set, because a node serves several groups and their roots are not all on the same volume: a single worker would put one group's sleeping USB drive in front of another's SSD, which is this symptom one level down. The thread is created on the first read, so a group nobody downloads from never starts one, and the daemon stops them all on the way out. The tests measure rather than read: a ticker counts its own wake-ups beside a request made slow on purpose, and a handler that blocks the loop takes every one of them with it. Put either call back inline and both tests report zero wake-ups, which was checked before they were trusted. QUIC still reads on its loop. Its handler is synchronous by construction, no client speaks it, and its distance from parity is already recorded in the design document (§15.3, L3); moving it is part of bringing it to parity, not of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: state what is built, and what the packaged install has answeredChristophe Besson7 days2-14/+15
| | | | | | | | | | | | | | | | | | | | | | | | | | | The two documents disagreed with the code they describe. §15.2 still listed playlists and Chromecast subtitles as not built, both shipped and tested; the protocol document's header said wire version 3.2 beside its own §13 saying 3.3, which is §12's "a number restated away from its definition is a number that will drift" happening to the document that states the rule. The packaged install is no longer an open question: the artefacts have been taken to a running hub and node on Ubuntu 26.04 (.deb), Fedora 44 (.rpm) and Windows 11 (.exe). What is left of D11 is signing the binary, which is a different question from whether it installs. The migration row overstated its own risk. Nothing replays the revisions behind the first packaged release — they were development no installation ever ran — so the exposure is the next migration, not the chain. The disk-I/O row gains the consequence that was measured rather than reasoned: a root waking from standby stalls the loop long enough for a client's WebRTC connection to time out, on one node with one member. `refresh_availability` is named beside the two chunk paths, because a stat on a sleeping disk is what wakes it while reading nothing. A blank line had also split §15.3's table in two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: decode and re-encode video on the GPU where there is oneChristophe Besson7 days17-31/+985
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A 1080p film is decoded by whoever watches it and re-encoded by the node when no browser can decode the source. Both were on the CPU, and on an Atom or Celeron mini-PC neither reaches real time — which is what `transcode_incompatible_video` exists to refuse. This adds the mechanism that makes refusing it unnecessary. Node — `hwaccel.py`: VA-API on Linux, Quick Sync or NVENC on Windows, established by encoding 1080p and reading the file back with ffprobe. Nothing is accepted that does not produce the exact profile and level `stream_init` announces, since the client checks that string before it trusts a byte: an encoder that wrote another level would make the node's own codec string a lie, and ffmpeg takes `-level 4.1` and `-level 41` from h264_qsv without saying which it understood. Three modes per stream — hardware decode and encode, hardware encode alone, libx264 — demoted per source codec, because a GPU that decodes HEVC may have no decoder for MPEG-4 Part 2 and only asking it finds out. A mode that fails is detected on an empty stdout before `stream_init` goes out, so the viewer sees one working stream and never an error. Client — Chromium ships VA-API off on Linux. It is enabled where a render node and a driver are present, then verified through `navigator.mediaCapabilities`: a no moves to the next GL backend on the next launch and an exhausted list drops the switches, so a renamed feature cannot pass for a feature that is on and `--ignore-gpu-blocklist` cannot survive on a machine it did not help. Feature lists now merge rather than overwrite — `appendSwitch` replaces the value, and a second caller would have silently cancelled the mDNS switch aiortc depends on. Packaging — the drivers are weak dependencies on all four formats, so a machine without a GPU installs exactly as before. `dpkg -i` and `rpm -ivh` ignore weak deps; `packaging/README.md` now says so. Windows needs no driver: the bundled ffmpeg already carries h264_qsv and h264_nvenc, and a re-pin that dropped them would cost every low-power Windows node its hardware encoding silently. AMD on Windows (AMF) and macOS (VideoToolbox) are named gaps, not oversights: neither could be tried anywhere in this project, and both re-encode in software as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cast): put the relay's progress log behind the same switchChristophe Besson7 days2-33/+75
| | | | | | | | | | | | | | | | | | | | | | | Most of what the relay says repeats without bound — a line every fiftieth fragment for the length of a film, one per dropped fragment whenever a client falls behind — and it lands in the terminal the app was started from. Those go through `debuglog` now, restored with: NODE_DEBUG=cast-relay npm start The loss of fMP4 framing stays on console.warn. It recovers by rescanning, so nothing throws and no other part of the system hears about it; that line is the only trace that the picture on the television is missing a piece. Every other failure here throws, and the renderer already reports the rejection. The logging guard now covers both cast modules, each with the one line it is meant to keep audible. Also corrects the header comment, which still claimed CORS was granted on the subtitle path only. It has been on both since the stream needed it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(cast): keep the Chromecast progress log out of the terminalChristophe Besson7 days2-10/+57
| | | | | | | | | | | | | | | | | | The receiver reports its state on a timer, so `player status: PLAYING` repeats for as long as a film runs and buries everything else in the terminal the app was started from. The progress lines now go through Node's own `debuglog`, which costs nothing when disabled — the message is never formatted — and is restored with: NODE_DEBUG=cast-chromecast npm start The socket error stays on console.error. It fires when the connection to the receiver dies and is the only trace of why a cast stopped; routing it through the same switch would make the one failure worth seeing the one that disappears. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* feat(cast): carry subtitles to a Chromecast, on the relay's clockChristophe Besson8 days9-35/+741
| | | | | | | | | | | | | | | | | | | | | | | | The relay forwards the node's fragments untouched, and those begin at zero at the seek point. The player never notices because its SourceBuffer is given `timestampOffset = start`; a receiver has no equivalent, so the cues are shifted by `-start` before they leave, recomputed at every restart of the relay. Sent as they are, a subtitle would be out by the whole seek. The document is served from the relay's own port at /subs.vtt, behind the same token as the stream and with CORS: a receiver fetches a side-loaded track with XHR from its own origin, and without the headers it fails as a network error with nothing on screen to say so. The URL carries a version because a track is cached by address — changing the cues behind a fixed URL leaves the previous language showing. Cues that end before the stream begins are dropped rather than clamped, so a line from before the seek cannot appear over the first frames after it. The relay is plain Node, so the tests start it and fetch from it rather than reading its source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): size the subtitle budget to the file, and extract each track onceChristophe Besson8 days3-42/+162
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Verifying the feature across a library turned up two faults, one of which broke it outright. **The extraction budget was a constant and the cost is not.** Extracting demuxes the whole container, so the file sets the price: measured at 9.8 s per GB on a library held on an external disk — 36 s for a 3.9 GB title, 71 s for a 7.3 GB one. Against a flat 60 s that worked on most of a library and failed on the big films, which to whoever is watching one is indistinguishable from a broken feature. `Avatar.The.Way.of.Water` timed out every time. The budget is now three times the measured rate per gigabyte, floored at the old 60 s and capped so no container can pin a transcode slot indefinitely. The client's own timeout follows it: the node always answers, so that one is a backstop against a silent peer rather than a deadline for the work. **Two clicks ran two extractions.** The cache is consulted on the way in, so a second request arriving while the first was still running missed it and ran its own — seen in the log as two identical extractions of one 4.3 GB file overlapping, each holding a transcode slot and reading the file end to end. Latecomers now wait on the answer the first is producing. The in-flight entry is registered *before* the first await, not after. The first version registered it after the probe, two concurrent requests both got past the check while neither had registered, and the test for it failed — which is the only reason this note can be written from the right side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): measure where a seek lands instead of predicting itChristophe Besson8 days4-95/+135
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous fix read ffprobe's key frames and took the last one at or before the request. It was wrong twice, and a viewer felt the difference: subtitles went from 5 s early to 2–3 s late. Matroska's Cues index only some keyframes, so an index seek backs off to an indexed one that the frame list does not single out. And the landing point moves with **which streams are mapped**, because the container is positioned where every mapped stream has data — on the reported title, a seek to 4913.7 s landed at 4909.863 with video alone and at 4907.236 with the second audio track mapped beside it. The frame scan gave the first number; the stream delivered the second; the gap was 2.65 s, and the measured audio displacement in the served stream was 2.65 s. So the node asks ffmpeg instead: the same seek, the same mapping, one copied frame under `-copyts`, and the answer read back off the result. 0.06–0.07 s, cheaper than the scan it replaces. The `-ss` argument stays at the request, so the bytes served are exactly the ones served before — only the number naming them changes. The probe runs after the audio track is resolved, because it cannot be right before that is known. An answer after the request, or further before it than any real keyframe gap, is discarded in favour of the old label: a number wrong by seconds beats a fabricated one. Found by decoding the served stream and locating its first frame in the source, which put it at 4907.213 s against an announced 4909.863 s. The test does the same thing rather than comparing the announced number against a second reading of the same probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): log a subtitle request before the cache answers itChristophe Besson8 days1-5/+13
| | | | | | | | | | | | | | | | A cached track replied without leaving a line, so the journal showed nothing for a viewer who had asked for subtitles and been served instantly. That made "no request in the log" look like evidence nobody had asked — which it was not entitled to be, and which cost a wrong conclusion about whether a fix had been exercised at all. The request is logged on arrival now, before the cache is consulted, and a cache hit says so with the size it served. Second time in this feature that a silent success was read as an absence; the first was an attach-time state that was correct while the screen stayed empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): a seek reports where the picture begins, not where it was askedChristophe Besson8 days4-4/+276
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Copied video can only start on a keyframe, so `-ss t` with `-c:v copy` delivers the keyframe at or before `t`. The node reported `t` anyway and the client builds `SourceBuffer.timestampOffset` out of that number, so everything downstream believed the picture stood a few seconds further along than it did. That was a wrong label while only the scrubber read it — it is recorded as such, low priority, in the design document. Subtitles made it a wrong answer: their cues carry the source's own absolute timestamps, so the mismatch put every line on screen before it was spoken. Reported from real use on an H264 title, where seeking to 600 s, 2650 s and 5000 s lands on keyframes 0.82 s, 1.56 s and 4.64 s earlier. The copy path now resolves the request to that keyframe, seeks to it, and reports it. The bytes delivered do not change — ffmpeg lands on the same frame either way — only the number that names them. The look-up reads the thirty seconds before the request and cost 0.12–0.51 s on that title, which is the price already costed in §15.3 and never paid. Re-encoded video is untouched: it can begin exactly where it is asked to, and does. Two details worth their lines. The keyframe is passed back to ffmpeg at six decimals, because rounding a keyframe's own timestamp down puts it before the frame it names and selects the previous one — the same fault again, smaller. And the ffprobe window ends past the request, since an interval whose end is the request never emits a keyframe sitting exactly on it: the resume position is the one place a viewer asks for the same instant twice, and it would have been answered a whole GOP early. The test decodes the first frame served and matches it against the source frame at the position announced, rather than comparing `start` to an expected number — both sides of that comparison would be reading the same ffprobe and would agree by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* feat: tell a forced subtitle track from a full oneChristophe Besson8 days15-29/+116
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "I click a subtitle and nothing appears", on three films. Nothing was broken. The track selected was the container's forced track, which carries signage and foreign dialogue only: measured on the film in question, 30 cues and 77 seconds of text across 2h32 — 0.8% of the running time, against 1559 cues and 41.8% for the full track sitting beside it under the same language tag. At all three positions tested there was genuinely no cue to show; the full track would have shown one at two of them. So the defect is that the menu could not say which was which. The label used the container's title tag, which said "Forced" on that film and says nothing at all on most, and no other field was carried. The disposition is the half that is always there: `probe_video` now reads `forced` and `hearing_impaired`, `stream_init` carries them, and the label states them in the reader's own language rather than repeating an English word a muxer happened to type. The node fixture grows a forced track with no title, because a title would let the old code pass. The label harness's `t` stub took a parameters object unconditionally and threw on a key that has none — a fixture narrower than production, fixed here rather than worked around. Also removes the activeCues probe that found this. It answered its question: mode showing, cues 30, active 0, none due at that instant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(hub): sample activeCues, the number that decides whether a subtitle showsChristophe Besson8 days1-0/+27
| | | | | | | | | | | | | | | | | | | Attach-time state — one track, showing, cues parsed — was all correct while nothing appeared on screen, so it was measuring the wrong thing. A re-render can replace the <track> and reset a mode nothing sets again, and a cue list that does not cover the playhead looks identical to one that does. The probe samples activeCues for ten seconds alongside the playhead, the mode, and the cue that ought to be on screen at that instant, so "no cue is active" and "a cue is active and is not painted" stop being the same observation. Verified beforehand that the mechanism itself is sound: a real Chrome driven over CDP, fed through MediaSource with timestampOffset 2690 and given a track appended after playback started, reports activeCues 1 on the cue bracketing the playhead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(hub): trace the subtitle path end to end in the playerChristophe Besson8 days1-2/+24
| | | | | | | | | | | | | | | | A subtitle button that spins for ever and never shows a track could not be told apart from a node that answered, a blob that never arrived, or a track attached with no cues in it: the whole path was silent. Every step of it happens on someone else's machine, over a link, against a file that may be gigabytes, so the only question worth asking when it does not finish is which step did not — and nothing recorded that. It now logs the request, the node's answer with hash, size and elapsed time, the fetched blob and its chunk count, the attachment, and the live TextTrack's mode and cue count. A failure says how long it took before failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* fix(node): a subtitle request that is cancelled must still answerChristophe Besson8 days1-4/+24
| | | | | | | | | | | | | | | | | | | | | Two faults, both found while a hang could not be diagnosed from the node's own journal. `except Exception` does not catch `CancelledError`, which derives from BaseException. A cancelled extraction therefore sent no reply and logged nothing at all, leaving the client waiting on something nothing would ever answer — the one shape that cannot be reported by whoever hits it. It now answers, logs, and re-raises so the cancellation still propagates. And the handler logged nothing on any path, so the journal could not even say whether a request had arrived. It now names the file, the track, the file size and the free slot count on entry, the moment the transcode slot is taken, and the duration and byte count on the way out — the size and duration because the extraction is a whole-file demux and its cost is set by the file, not by the subtitle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* docs: install the [dev] extras, not a hand-written listChristophe Besson8 days1-2/+13
| | | | | | | | | | | | | | | | | | | | | | | | The documented setup was `pip install pytest pytest-asyncio aiosqlite`, three lines under a rule saying not to install packages that are not declared in a pyproject.toml. All three packages declare a `dev` extra, and the hand-written list missed what was in it. It cost three invisible things. Without `piexif`, `test_enrich_photo.py` fails at import and the whole node suite stops at collection — not one test, the run. Without `pytest-timeout`, `timeout = 60` in pyproject.toml is an unknown config option, so the protection its own comment describes ("a hung test must not wedge the run") was absent, announced only in a warning among the deprecation notices. Without `ruff`, the project's linter is not installed. `aiosqlite` never needed naming: it is a runtime dependency of the hub and the node and arrives with the `-e` installs. Verified with the corrected line: 2804 tests collected, 2800 passed, 4 skipped, and no unknown-option warning — nothing exceeds the timeout that is now actually enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* docs(hub): say what the subtitle track's mode effect was measured to doChristophe Besson8 days1-5/+9
| | | | | | | | | | | | | | The comment claimed a <track> appended after playback started is not shown by its `default` attribute. Chrome shows it: in a headless run the TextTrack read back "showing" before the effect had touched it, with its cues already parsed. The effect stays, for the two reasons that are true — `default` says nothing about turning subtitles off again, and an explicitly assigned mode means the same thing in every engine — but the reason written beside it was not one of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* feat: embedded subtitles in the video player (MNP 3.3)Christophe Besson8 days22-12/+1088
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | MSE decodes no in-band text track, so a subtitle cannot ride inside the fragmented MP4 the player is fed. The node extracts one track whole, converts it to WebVTT and caches it under its own hash; the client pulls that blob through the ordinary file_req/chunk path and hangs a <track> on the video element — the same indirection as a TMDB poster or an audio transcode, which is what makes a film's subtitles extracted once in the life of the file rather than once per viewing. Whole-file also makes the cues absolute, so a seek and an audio-language change both leave the track untouched. **The ordinal counts every subtitle stream, including the ones never listed.** Only text codecs are offered: a bitmap track (PGS, VOBSUB — about a fifth of a real library) has no path to WebVTT without OCR, and one extracted anyway yields a header with no cues, which is a menu entry that shows nothing and reports no error. Numbering the survivors of that filter would give a PGS/SRT/SRT file the ordinals 0 and 1 for its text tracks and `-map 0:s:0` would then extract the PGS — the same trap `AudioTrack.ordinal` exists for, one level deeper. A fixture whose first subtitle stream cannot be decoded pins it, and the handler checks membership of the probed list, never a range. Additive and MINOR: the selector is drawn from `subtitle_tracks` in the node's own `stream_init` and from no version number, so `subtitle_req` is never sent to a peer that would not answer it. The floor stays at 3.0. Also here: a failed extraction never touches playback, a superseded reply cannot install its blob over a newer choice, and `_languageName` is shared with the audio labels — lifted by both label harnesses, since a lift that names one function stops covering the rule the moment logic moves out of it. Tests: 9 node (tracks told apart by the words in the extracted cues, not by tags), 10 client. Full suite green: 1545 node/common, 1252 hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* feat(hub): give the audio-language button a glyph of its ownChristophe Besson8 days2-1/+10
| | | | | | | | | | | | | | | | The audio-track selector was drawn with `volume` — the same glyph the music player uses for its level control, and the same one the browser draws a few dozen pixels below it, since the video player mounts `<video controls>` and gets the native volume speaker with it. One drawing, two meanings, in one window. `speech-pair` is two offset bubbles: the same line, said twice, which is what the button chooses. Nothing crosses anything — the set is stroked with no fill, and two overlapping outlines read as a tangle at 20 px — and it leaves the conventional subtitle glyph free for the button planned beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
* docs(site): the hub's password verifier is 64 MiBChristophe Besson8 days1-1/+1
| | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): the password verifier is Argon2id 64 MiB, and a hash's version ↵Christophe Besson8 days6-27/+116
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | names its parameters `pw_version` 4: Argon2id 64 MiB, t=3, lanes=4 — RFC 9106's second recommended setting. A v3 hash (256 MB) still verifies at its own parameters and is rewritten at the new ones on the next sign-in, through the rehash path that already existed. Why not more. The verifier matters against an offline attacker holding the database; online guessing is bounded by the sign-in lockout. That attacker pays the client's 600 000 PBKDF2-SHA512 iterations and the hub's Argon2id per guess, since `auth_key` is 256 bits and cannot be searched directly. Memory above 64 MiB multiplies that cost by a constant — at most 16 at 256 MB, less with PBKDF2 counted — while the hub pays the same memory at every sign-in, one derivation at a time. Measured on meshbay.org: 450 ms at 256 MB, 105 ms at 64 MiB, so a burst of sign-ins clears about four times faster. Changing the current version exposed a latent lockout. `hash_password` always used the current version's parameters, while the raw-password scheme recorded `pw_version = 2` — harmless while versions 2 and 3 shared their parameters, and with version 4 every legacy registration and v1→v2 rehash would have stored a 64 MiB hash labelled 256 MB, which nothing could then verify. Seventeen tests caught it. `hash_password` now takes the version it is hashing for. The OpenSSL deadlock between two concurrent `lanes=4` derivations is the same at 64 MiB, so Argon2 stays on its single worker. The loop-stall test measures against a v3 hash, because half of a 45 ms inline derivation is too close to scheduling noise to be a reliable bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
* docs: record the seek label mismatch as openChristophe Besson8 days1-0/+1
| | | | | | | | A seek now begins picture and sound together at the keyframe, but stream_init still reports the position that was asked for. §15.3 says what the fix costs and why it has not been made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): a seek left the audio a GOP behind the pictureChristophe Besson8 days4-8/+292
| | | | | | | | | | | | | | | -ss before -i cannot trim copied video, which must begin on a keyframe, but accurate_seek did trim the re-encoded audio to the exact request. Every seek on a copied stream therefore opened with a GOP-wide hole in the audio and ran a GOP out of sync afterwards — 9.979s on a real film with a 10s keyframe interval. Accurate seeking is now off wherever video is copied, and stays on where it is re-encoded, which is the only path that could already begin where it was asked to. Every timestamp was correct throughout, which is why nothing caught it; the tests assert on decoded audio and on frames compared against the source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* auth: a sign-out during a renewal must not write half a sessionChristophe Besson9 days3-1/+242
| | | | | | | | | | | | `{ ..._auth }` after the await spreads a null _auth to {}, so the renewal stored a token with no username and no userId. The app renders the signed-in interface from that and throws on user.username[0] — a blank page on every load, in localStorage, until the site's data is cleared. The sign-out wins the race now, and loadAuth treats an identity-less object as signed out so a browser already holding one heals itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: let the viewer pick the audio trackChristophe Besson9 days22-63/+860
| | | | | | | | | | | | | The streaming path mapped 0:a:0 unconditionally, so a dubbed film played in whichever language was muxed first and the others were unreachable. The node now enumerates the tracks in stream_init and honours audio_track in stream_req; switching is the seek path, since one ffmpeg carries one track. MNP 3.2, additive: the player draws its selector from the node's own list and never from a version number, so an older node is never asked for a track it would ignore. MNP_MIN_SUPPORTED does not move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* spa: a blank page can never be silent againChristophe Besson9 days5-2/+621
| | | | | | | | | | | | | | boot-guard.js is a classic script loaded before the module graph, so it survives the graph failing to link. If #app is still empty after ten seconds it names what failed and offers a reset of this origin — cache, storage, databases and the service worker, which clearing the cache does not touch. Two real defects found building it: openDB never settled when an upgrade was blocked by another tab, and a connection it gave up on stayed open and squatted the database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: promise the count that holds when nothing compressesChristophe Besson9 days5-19/+78
| | | | | | | | The ceiling is 1200 tracks on a library that repeats and 660 on one that never does; the message said 1000, which would send a reader back into the wall at 660. It says 500, measured at both ends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: refuse a body no frame can carry, and say which oneChristophe Besson9 days15-19/+189
| | | | | | | | | | | A DataChannel send() throws above the max-message-size the node advertises (aiortc: 65536), so the 1 MB body cap was unreachable and each body push swallowed the difference in a bare catch. Cap at 62 KB after sealing, name the playlist in the sync result, keep syncing the rest. Measured: 1000 tracks seal to 53 276 bytes, so the ceiling is ~1200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: drive the blob ops over a real DataChannelChristophe Besson9 days3-5/+221
| | | | | | | | | The six MNP 3.1 ops were the only new ones never crossing a channel in a test. Two cases on the existing aiortc harness: a round trip read back on a second connection, and a 256 KB body. Found that the 1 MB body cap is unreachable from a browser — docs/playlists.md §15.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: playlists §15 was out of date on where sync livesChristophe Besson9 days1-4/+10
| | | | | | | Sync moved to the shell; the Search-page gap it lists is the bug that was fixed. And two devices converging has now been seen working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* menu: give the panel the room that is actually below itChristophe Besson9 days5-23/+215
| | | | | | | | | `max-height: calc(100vh - 16px)` says how tall the menu may be and nothing about where its bottom lands, so one opened partway down the window ran past it and its last rows scrolled out of reach. Measure from where the panel was placed, and re-measure when a submenu opens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* menu: do not close on the panel's own scrollingChristophe Besson9 days4-6/+318
| | | | | | | | The dismiss-on-scroll listener is on the capture phase, because `scroll` does not bubble — so it also heard the menu scrolling itself, and a long tracklist closed the moment it was wheeled. Filter on the event's origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: make the writes actually leave the browserChristophe Besson9 days17-15/+633
| | | | | | | | | | | | | | | | | | | | | | | | | | | Reported from a phone: signing in with the same account showed no playlists. syncWith was called from exactly one place in the interface, so creating a playlist, deleting one, removing a track and saving the queue all wrote to IndexedDB and stopped there. The store pushes itself now, coalesced, so a new mutation cannot forget to. Silence was the real defect. The node audited only successes, so a refusal left no trace and user_blob_list none at all; the background push swallowed its reason; the interface said nothing. All three report now, and "Sync now" says what happened either way. An unreadable blob on a node was treated as a fetch failure and returned before the push — permanent, once the node held anything. It is an absence: the client is the authority, and it gets overwritten. A sign-in reconciles whatever this browser already holds, a pending push is flushed when the page goes away, and a push that did not land is retried once. no_key is spelled out: a client that signs in with its remembered device key only ever has a bundle key persisted before the playlist subkey existed, and an AES handle is non-extractable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* music: pool single-album artists into shared rowsChristophe Besson9 days4-18/+549
| | | | | | | | | | | | | | | An artist with one album got a heading and one cover on a row that fits five, and a library is mostly single-album artists. Consecutive singles share one grid, in place, so the page stays in artist order. Each pooled cover keeps its artist's name above it in the same type as a section heading. Dropping it was the first version and it was wrong: scrolling then alternates between artists written large and small. Measured on the probe's fixture: 4208px to 1895px, and a walk of the page reaches all 21 covers instead of 9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: playlists, corrected against what building it neededChristophe Besson9 days2-119/+293
| | | | | | | | | | | | | | | | | Fourteen places the proposal was wrong, listed rather than edited away: the MNP bump was 2.1 and is 3.1; the kind pattern refused the one playlist every account has; confirm() is the house style and prompt() is what throws; submenus expand in place; deflate is 4.5x and the first fixture's 23x was a property of the fixture. Adds the hazard the proposal missed entirely: two wrappers that take two arguments and forward two, so every "add to queue" arrived as a plain play. The acceptance section now says what is checked and what is not — no real daemon round trip, no two-browser convergence, no Firefox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* music: the playlist menusChristophe Besson9 days23-34/+1248
| | | | | | | | | | | | | | | | | | | | One button in Music's sticky toolbar — load, create, delete, remove a track, sync now — and "add to playlist" on every cover and row. Both surfaces share one list, read from the manifest, so they open instantly with every node offline and no body is fetched until one is wanted. Submenus expand in place rather than flying out: the account menu's language list already does this, and a flyout has nowhere to go at 400px. The tracklist under "remove a track" loads when it is expanded. A name is typed into a field. Electron has no prompt — it throws. Also splits the two playback failures: a decode failure belongs to that file and keeps the bounded counter, a connection failure belongs to the group and skips all of its queued tracks at once. Six dead tracks are one more than the bound, which is where a playlist would otherwise stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: the store, and putting it on nodesChristophe Besson9 days4-1/+952
| | | | | | | | | | | | | | | | | | playlists.js is IndexedDB, WebCrypto and a transport, and node has no IndexedDB — so it is driven in Chrome against a node stubbed to record what it was handed, which is also how what leaves the browser is checked to be sealed. Sync asks the node what it holds (user_blob_list) rather than comparing against the merged watermark, which says nothing about that node: the first version pushed every body on every sync. A tombstoned playlist's body is deleted as each node is reached, or the quota fills with graves. The database version and its stores stay in hub-client.js — two modules opening one database at versions of their own is a VersionError thrown at whichever runs second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* playlists: merge rules, sealing, and the keyChristophe Besson9 days7-4/+1340
| | | | | | | | | | | | | | | | | | playlist-merge.js and playlist-crypto.js have no imports and are run by their tests, which is the only real evidence this feature can have. Two revision counters per playlist, not one: a rename on one device and a track added on another both write n+1, and a single counter makes two edits that do not overlap collide. One Argon2 run at sign-in, two handles. The AES handle is imported non-extractably, so nothing can be derived from it — hence a second import of the same bytes as HKDF rather than a derivation. Measured: ~270 bytes a track, deflate worth 4.5x on realistic data, so the 1 MB body cap holds about 17000 tracks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* mnp 3.1: per-account blobs the node cannot readChristophe Besson9 days8-10/+822
| | | | | | | | | | | | | | | One row per playlist plus a manifest, so starring a track rewrites that playlist rather than the whole collection. blob_enc is a BLOB, not base64 TEXT: these run to hundreds of kilobytes. user_id comes from the session and never from the message; kind is validated against a pattern; every cap refuses with a stated reason rather than truncating. Additive, so MNP_MIN_SUPPORTED does not move — a 3.0 node answers "unknown message type" and the client writes to the next one it reaches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* music: play, play next, add to queueChristophe Besson9 days24-97/+1431
| | | | | | | | | | | | | | | | The player's queue could only be replaced: every onPlayQueue reset tracks/order/pos together. It becomes one reducer (queue-ops.js) with an `op`, because two appends batched into one tick cannot both read the track count out of separate useStates. A shared pop-up menu (menu.js) carries the three verbs, on right-click and on a dots button. A track row is now a div holding two buttons: a button cannot contain a button. Found by the browser probe: both music wrappers took two arguments and forwarded two, so every "add to queue" arrived as a plain play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: playlists — storage split, queue ops, and the UI designChristophe Besson10 days2-290/+831
| | | | | | | | | | | | | The 256 KB cap contradicted its own worked example; entries were also missing `size`/`name`, without which a stored track cannot be fetched at all. Storage becomes a manifest plus one blob per playlist, compressed, so starring a track no longer rewrites the collection. Adds the UI pass that was missing: the queue verbs, the menus, and the one real code change behind them — the player's queue can today only be replaced, not appended to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): the Files listing sorts its folders by the chosen column tooChristophe Besson10 days2-11/+123
| | | | | | | | Folders were always ordered by name, whatever the column and direction, so reversing the name sort or sorting by size moved only the files. A folder's size is what it holds and its date its newest file; ties go to the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): drop files and folders onto Files to upload themChristophe Besson10 days17-42/+492
| | | | | | | | | Into the folder on screen, under the Upload button's rule. A name already there, or one the node would refuse, cancels the whole drop with a message. Folders are recreated level by level; files go out a few at a time. The in-flight upload guard is keyed by folder and name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): Videos and Music list their cards a page at a timeChristophe Besson10 days25-82/+428
| | | | | | | | Previous/next arrows in the pinned toolbar, on group pages and in Search. Page size is an account preference (Settings → Defaults), 50 by default, 10 to 200 in steps of 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>