aboutsummaryrefslogtreecommitdiffstats
path: root/docs
Commit message (Collapse)AuthorAgeFilesLines
* docs: qualify what an invitation link changes about the codeChristophe Besson2 days4-2/+14
| | | | | | | | | The security claims table, §3.4's property 2, the protocol's stated limits, the quickstart and the user guide's defaults now say where a link's code differs: bound to its account only when redeemed, and held by the hub when the inviter asks it to mail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(node): member invite --link and member cancel in the CLIChristophe Besson2 days2-5/+12
| | | | | | | | | The CLI makes both halves itself — the node's code, then the hub's ticket bound to the address — and prints the link; a refused ticket takes the code back, and cancel takes back both. The CLI never asks the hub to mail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): open, create and join invitation links in the interfaceChristophe Besson2 days2-2/+29
| | | | | | | | | | | #/invite takes the link out of the address on load and keeps it in the tab through registration and sign-in; joining is one click, only the ticket goes to the hub, and the code goes only to the node the link names once it has signed its challenge. Members tab gains "Invite by link" (shared e-mail box, pending list, cancel both halves); home page takes a pasted link. Browser probe drives the real app, signed out and in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): invitation-link tickets bound to a verified addressChristophe Besson2 days1-9/+73
| | | | | | | | | | | group_invite_links holds sha256(ticket) and the invitee's address blind index; redeeming grants membership to that account only. Owner-only create/list/cancel (a node token may create, never mail), 20 outstanding per group, optional mail written by the hub itself and capped at 10 per sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now carries the whole link design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: drop the invitation-link plan from the treeChristophe Besson2 days2-362/+1
| | | | | | | | The plan was a working document; what it decided lives in MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md. Code and tests now cite those instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(node): invitation links — a code bound to no account until redeemedChristophe Besson2 days3-5/+408
| | | | | | | | | | New invite kind "link": member of one group, once, never operator, not spendable by an active member, capped at 20 per group, cancellable by handle. Signed ops invite_link_create / invite_cancel, loopback routes, and the known-device join path now accepts a link code. Adds the plan, docs/invite-links.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat: the node signs its handshake challenge (MNP 3.4)Christophe Besson2 days2-13/+54
| | | | | | | | | node_pk in handshake_challenge is now signed over the channel binding and both nonces, so a client can check the node key before a join rather than only at the ack. Both transports; the browser and the QUIC client refuse a wrong signature and treat an absent one as an older node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): make mailing an invitation a remembered choiceChristophe Besson2 days3-5/+26
| | | | | | | | A "Send the invitation by e-mail" box under the Invite member field, checked by default and stored as the invite_email preference. Unchecked, invite-notify is never called and the hub never sees the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): right-click menu in the Files tabChristophe Besson2 days1-0/+3
| | | | | | | | The toolbar's actions on the row under the pointer, sharing one action list with the toolbar — which keeps showing what does not apply, disabled, while the menu leaves it out. A count only where more than one item is concerned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: ask in the page instead of native confirm/alertChristophe Besson4 days2-6/+7
| | | | | | | | A native confirm() or alert() leaves the desktop client unable to type until the window is refocused. ask.js draws both in the page; the SPA test now bans all three browser dialogs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: the loopback removal path writes no audit entryChristophe Besson5 days1-0/+1
| | | | | | | | | `ops.revoke_member` and `ops.unpin_member` log nothing to audit.db while the MNP handlers doing the same work audit it. Parked in §15.3: a removal from the node page or the CLI leaves a refusal with nothing to explain it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): keep the video read-ahead across a reconnectionChristophe Besson5 days1-1/+18
| | | | | | | | | | | A reconnection restarted the stream at the playhead, and that empties the source buffer — spending the whole read-ahead at the one moment it was the thing carrying the film. It carries on from the end of the buffer instead, except where the buffer is short, the stream already ended, or a cast is active: the receiver is fed from the wire and never had that buffer, so resuming there would skip it forward by the lot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(hub): bound video read-ahead by bytes, not by secondsChristophe Besson5 days1-0/+36
| | | | | | | | | | A browser limits bytes, so a bound in seconds had to be sized for the highest-bitrate file and every ordinary one then held a fraction of what the same buffer would have taken. Floored at the old 90 s so nothing pulls less than before, and walked up rather than declared. A refused append now waits for an eviction instead of retrying on every tick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: removing someone who never redeemed their invitationChristophe Besson5 days2-0/+17
| | | | | | | | | | A member row appears only when a code is consumed, so revoking someone invited to the wrong group was refused for having no row — and the node's refusal aborted the browser's removal before its hub half, leaving them a member everywhere with a live code. Revoking now cancels unredeemed codes for that group, and a node refusal no longer cancels the hub removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: bump version to 0.15.0Christophe Besson6 days1-1/+1
| | | | Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: the node key is linked from Profile, not SettingsChristophe Besson6 days2-2/+10
| | | | | | | | | QUICKSTART Step 4 named the wrong page. Recorded in §15.3 alongside it: the key is read from `meshbay-node status` or the desktop client and from nowhere else, which is a decision rather than a gap — anyone linking a node is already at a shell prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(node): the Music app's tags survive a restartChristophe Besson6 days1-1/+0
| | | | | | | | | | | | | | | media_cache held video_meta, photo_meta and thumbs; the audio tags lived only in the in-memory IndexEntry. So every start re-read every audio file the node serves, and until that pass landed it served an index with no artist on any track — one the Music app cannot group. Over a real 6176-file library the pass costs 27.8s cold and 6.4s from audio_meta. Only what the bytes decided is stored. The filename and folder fallbacks still run live, or a renamed file would get the old name's answer; the sibling-cover scan reads the folder, so it stays live too; and a read that failed is not cached, or one bad read becomes permanent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): a track with no artist tag reaches the Music gridChristophe Besson6 days1-1/+0
| | | | | | | | | | | | | | | The grid's unit is an album, so a track whose artist tag is empty was drawn nowhere — while `empty` counted it and stayed false, so no message appeared either. An untagged library rendered a toolbar over a blank page, with every track one mode-switch away and nothing saying so. It gets a card, the same shape the singleton folding already mints. No cover is looked up for it, or for any album this file invented: the release name is one the browser wrote, and the request cannot match. music_untagged_probe.py renders the real grid and reads the page back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hub): a reconnect re-reads the index, not just the handshakeChristophe Besson6 days1-0/+2
| | | | | | | | | | | | | | | | | _reconnectLoop re-did the handshake and nothing else, so a page kept whatever it last saw until someone reloaded it. That is invisible until the node restarts: it rebuilds its index from index_cache.db, which holds no enrichment, and Music is the one app whose enrichment is persisted nowhere — for the length of the re-read pass it serves tracks with no artist, and the album grid drew nothing. onReconnected was one slot the video player took on open and cleared on close; it is a listener set now, and carries the fresh ack. docs/MESHBAY_DESIGN.md §15.3 records the two defects found alongside and not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Merge origin/main: the operator's upload ceiling beside the disk-thread workChristophe Besson6 days4-25/+1308
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One conflict, in §15.3's open list, and it was two changes agreeing rather than disagreeing: this side removed the rows for the third-party search bound and the node-announcement bound because both are now built (AV27, AV28), while the other side kept them and added a new one. Resolved by keeping what is genuinely still open — per-device revocation having no CLI — and leaving the two closed. `webrtc_server.py` merged without conflict but the two sides met inside one function: `_upload_chunk` gained the operator's `max_upload_gb` ceiling from there and the per-group lock and `off_disk` calls from here. Read back rather than trusted: the operator's ceiling now sits inside the critical section that keeps chunk ordering, and the unlink beside it goes to the disk thread with everything else. 2893 passed. The twelve `test_sticky_header.py[firefox]` setup errors are the open Firefox on this machine, as before.
| * fix(node): node.toml's transfer pools reach the transportChristophe Besson7 days1-1/+0
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The daemon built the defaults dict for `Roster.node_settings` by hand and left out `max_concurrent_downloads` and `max_concurrent_uploads`. Absent from the dict, both resolved to None, were assigned back onto the config, and the transport skipped them — so node.toml was parsed, validated, and then replaced by `transfers.py`'s own 8. Invisible to anyone who left the value at 8, which is the value the template suggests. There were three copies of that dict and they all disagreed: node_status' was missing those two and `max_upload_gb` besides. One builder now, `config.node_settings_defaults`, and the resolver's key list is a class attribute the tests hold it to, along with the writer's. 1429 passed against a baseline of 1423; the two new behavioural tests fail with the builder reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| * docs: correct the spec where it had drifted from the codeChristophe Besson7 days1-14/+43
| | | | | | | | | | | | | | | | | | | | | | | | | | Found by writing the user guide against the CLI rather than against this document. §6.7 listed a `member device` verb nobody ever wrote; §9.9 still called several folders a Photos-only shape after app_directories gave every application a set; §6.8 said five settings and the Node page shows eight; §5.5 counted one transfer pool where there are three. The per-device revocation gap is real and stays open — §15.3 names it as the capability it is, not as a documentation error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| * docs: add a quickstart and a user guideChristophe Besson7 days3-4/+1252
| | | | | | | | | | | | | | | | | | | | | | | | | | QUICKSTART takes one Linux machine from downloaded packages to a working group with a second member. USERGUIDE covers using a group and running a node, for a reader who is not a developer. Windows stays in PACKAGING-GUIDE.md. Both are written from the code — the CLI, ops.py, the systemd units and the interface catalogue — rather than from the specification, and they state what is not built as readily as what is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| * feat(node): per-file upload ceiling is an operator setting, default 8 GBChristophe Besson7 days2-7/+14
| | | | | | | | | | | | | | | | | | | | | | Was a 4 GB constant in webrtc_server.py, the same on a small board and on a machine holding a library. Now max_upload_gb in node.toml, on the Node page and via `meshbay-node transfers max-size`, read from the transport context per chunk so a change reaches an upload already running. MESHBAY_DESIGN.md §6.4; §15.3 records a defect found beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | docs: say who the concordance is still forChristophe Besson6 days1-6/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | §16 opened by saying "code comments, tests and older documents cite sections of the documents this one replaces", and closed by saying "nothing needs editing to follow a reference". Neither holds since 20a8241: the code carries no reference to a removed document, and that commit is precisely the editing the second sentence said was unnecessary. Left as it was, that sentence tells the next reader a dead reference may stay dead. The table itself is unchanged and stays whole — every row still resolves something. What changed is who asks: 629 commits of history that cannot be rewritten, the superseded documents kept under docs/ (cast-smart-tv.md and WINDOWS-PORT.md both route readers here by name), and notes held outside this repository. The intro now says that, and states the rule the code follows instead — cite this document and a section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | fix: the two ceilings §13.5b was still missing, as AV27 and AV28Christophe Besson7 days1-2/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **A free-text TMDB search spends the operator's credential.** TMDB rates it, and the automatic matching every member sees runs on the same one, so a member holding down the search box — or a script doing it — degrades the library for everyone and empties a quota the operator pays for. The handler had no ceiling of any kind, where link previews beside it carry two. §6.5's standing rule is a bound and a named adversary in the same commit; this arrived with neither. Per member and not per connection, unlike link previews: three tabs is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the group context, so a reconnect does not reset it — a client that drops its channel between searches would otherwise have no ceiling at all. The node-wide window stays too, because the two answer different questions: one keeps a member from spending everyone's quota, the other keeps a roomful of them from doing it together. Ten a minute each, thirty for the node — a search every six seconds, sustained, is past what anyone types. The refusal is an error rather than an empty list. An empty list is what "no such film" looks like, and telling somebody their film is unknown when the node simply declined to ask is a worse answer than the truth; `video-app.js` already puts `detail` on screen. **How many node keys one account may announce.** Each is a row in `nodes` plus a row in the IP log, and the IP log is kept for a year, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. M8 settled whose key it is and said nothing about how many. Ten: a node is a machine left running, and an account wanting an eleventh *identity* rather than an eleventh machine is the case this refuses. Counted only where a row is added. Applied to every announce it would freeze the address of every node an account already runs the moment it reached the limit, and a node that cannot re-announce is unreachable after its ISP renumbers it — an outage caused by the protection. There is a test for exactly that. Both tests are two accounts, per §13.5b: a ceiling one person can exhaust for another is not a ceiling but a queue, and a ceiling shared between accounts would let one member stop every other from bringing a machine online. Checked by removing each ceiling: seven tests fail. `test_season_and_search_requests.py` built its session without a `_user_id`, which production guarantees — `_dispatch_message` refuses every message until the handshake settles it. The fixture was narrower than the node, so it could not exercise a per-member bound at all; it has one now. Twelve `test_sticky_header.py[firefox]` setup errors in a full run here: Firefox is open on this machine, the trap CLAUDE.md describes, and its twelve `[chrome]` tests covering the same geometry pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* | fix(hub): stop keeping a copy of every group's file listing in the browserChristophe Besson7 days1-0/+8
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `group_indexes` was an IndexedDB store holding a decrypted copy of each group's index — every file's name, path, size, hash and uploader — written on every index and on every delta, from three call sites. It was the cross-group search of Phase 10b: `doSearch` read `getAllCachedIndexes` and searched those records instead of dialling anything. On 2026-08-28 Search began dialling the nodes, and that commit removed the reader and left the writers. Since then the browser has gone on building a cleartext file listing that nothing consulted, that no sign-out removed — the key database is a different one — and that grew with every group ever opened. L7, at rest: kept code that nothing calls does not sit still. Drawing a group's files while its node is unreachable is the only thing such a cache buys, and it is not wanted: a listing that cannot be opened is worse than an honest absence. So there is nothing to read it with, and the writers go. The store stays in the schema and is emptied instead. Dropping it needs a version bump, a version bump is an upgrade another tab can block, and playlists share this database — so the tidier change is the one with a failure mode. `purgeGroupIndexCache` runs once per browser behind a flag, which clears what is already on people's machines; a browser that refuses storage simply runs it again, which is harmless because it is idempotent. Three guards, each checked by reintroducing the fault: only `openDB` and the purge may touch the store, the purge may only clear it, and the purge must actually be called at start-up — a purge nobody calls is the same defect wearing the opposite hat. `test_sticky_header.py[firefox]` reports twelve setup errors in a full run here. A Firefox instance is open on this machine, which is the trap CLAUDE.md describes; the same twelve appear with these changes stashed, and the `[chrome]` half of the same file, covering the same geometry, is clean. 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 days1-0/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days1-0/+12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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): take the availability poll and every upload write off the loopChristophe Besson7 days1-2/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* 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 days1-0/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* feat(cast): carry subtitles to a Chromecast, on the relay's clockChristophe Besson8 days1-0/+19
| | | | | | | | | | | | | | | | | | | | | | | | 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): measure where a seek lands instead of predicting itChristophe Besson8 days2-9/+14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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): a seek reports where the picture begins, not where it was askedChristophe Besson8 days2-2/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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: embedded subtitles in the video player (MNP 3.3)Christophe Besson8 days2-7/+21
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* fix(hub): the password verifier is Argon2id 64 MiB, and a hash's version ↵Christophe Besson8 days1-1/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 Besson9 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 Besson9 days2-1/+19
| | | | | | | | | | | | | | | -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>
* feat: let the viewer pick the audio trackChristophe Besson9 days2-15/+52
| | | | | | | | | | | | | 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>
* playlists: promise the count that holds when nothing compressesChristophe Besson9 days1-8/+19
| | | | | | | | 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 days1-10/+19
| | | | | | | | | | | 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 days2-5/+42
| | | | | | | | | 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 days1-2/+17
| | | | | | | | | `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 days1-4/+11
| | | | | | | | 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>
* 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>
* mnp 3.1: per-account blobs the node cannot readChristophe Besson10 days1-4/+48
| | | | | | | | | | | | | | | 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>
* 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>