aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
Commit message (Collapse)AuthorAgeFilesLines
...
* feat(node): always-on file selection with select-all checkboxChristophe Besson2026-09-061-27/+30
| | | | | | | | | | Remove the Select/Done toggle — checkboxes and action buttons are always visible. A select-all checkbox in the header row follows the standard pattern: check selects all visible items, uncheck clears the entire selection, indeterminate when partially selected. Navigation and file preview are unaffected (only the checkbox selects). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(client): hide the tray button on the narrow layoutChristophe Besson2026-09-051-0/+5
| | | | | | | | | | | | Same 768px breakpoint the hamburger appears at, where the nav is tightest and the button has least to offer. Belt and braces rather than the only guard: `capabilities.tray` is false without the Electron bridge, so no browser has ever rendered this. What this covers is the app's own window dragged narrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(client): tray menu starts and stops the node, and a clearer iconChristophe Besson2026-09-0512-4/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The first icon was an arrow dropping into a receptacle, which is the download glyph -- a vertical stem above a container reads that way whatever the context. Replaced with a window folding a chevron into itself: no stem, and the frame says which object is being minimised. Applied to both the nav button and the panel indicator, which carried the same wrong shape. The menu now offers Start or Stop for the node daemon, chosen from its actual state and shown only when there is a daemon to act on: `supported && installed`, so a machine with no node installed gets no entry rather than a control that fails when used. `restart` is the start verb -- systemd's restart starts a stopped unit, and there is no separate one to call. The three service handlers become named functions so the tray drives exactly what the Node page drives, instead of a second copy of the systemctl and Task Scheduler branches. A read of the state that throws is treated as no control at all. The menu is rebuilt on a 5s timer while an indicator exists, and again straight after an action. libappindicator has no "menu is about to open" event, so a menu built once would show a stale Start/Stop for the life of the process; `systemctl --user show` costs a few milliseconds. Locales: tray.start_node / tray.stop_node in all ten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(client): minimise to a system tray indicator (GNOME)Christophe Besson2026-09-0514-2/+82
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A dedicated monochrome button in the nav, immediately left of the notification bell, hides the window to a tray indicator. Linux only for now; Windows is being done on that OS, and the capability is declared per platform so the button never appears where the desktop shows no indicator -- there it would hide the window for good. Hides, never closes: `window-all-closed` quits the app, so closing here would make "minimise" mean "exit" and drop the session, the transfers and the node connection. `second-instance` now calls the same restore path, since focusing a hidden window does nothing visible. The context menu is not decoration. Under libappindicator -- how GNOME shows a tray at all, via the AppIndicator extension -- `tray.on('click')` never fires; the indicator only opens its menu. A tray whose sole affordance was a click would be inert on the one desktop this targets. The click handler is kept for desktops that do send it. Menu labels come from the renderer with the IPC call: the locale files are the interface's, the main process has no i18n, and a second string table is how two of them start disagreeing. English fallbacks if none arrive. The icon lives in src/, not build/: package.json `files` packages only `src/**` and `ui/**`, so an icon under build/ is present in a dev run and missing from every installed one. Monochrome, stroked, matching the nav glyph. Verified on this host: Ubuntu GNOME with ubuntu-appindicators@ubuntu.com and libayatana-appindicator3 present, so the indicator has somewhere to appear. Not yet run end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(win): graceful shutdown, one startup-mode control, and a stray-\r bugChristophe Besson2026-09-0511-84/+127
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Windows-only changes, all found by actually running the previous session's work rather than by review alone: - CTRL_CLOSE_EVENT/LOGOFF/SHUTDOWN handler (platform.py, ctypes SetConsoleCtrlHandler) so closing a console window, signing off, or a system shutdown runs the daemon's real _shutdown() instead of Windows just ending the process — closing WebRTC sessions and any in-flight ffmpeg transcode instead of orphaning it. `taskkill /F` itself stays uncatchable (like SIGKILL), so autostart_run() now spawns with CREATE_NEW_PROCESS_GROUP instead of DETACHED_PROCESS and autostart_end() tries CTRL_BREAK_EVENT against the recorded pid first, falling back to the hard kill only if that doesn't stop it in time. - Replaced the Node page's two independent autostart/service-mode toggles with one "start automatically" select (off / at sign-in / as a background service). The old pair let both be active at once — starting the daemon twice, at boot and at sign-in — and their layout broke wrapping inside .node-service's flex row. The new control always removes whichever mechanism is active before installing the target; platform.py's service_install() does the same on the CLI side. The "background service" option disables itself (with a hint pointing at the CLI) when running unpackaged, since service-mode.ps1/service.ps1/firewall.ps1 all assume an installed build's layout — verified live rather than assumed by actually running those scripts unelevated. - findNodeBinary() no longer bakes a stray \r into resolved paths. Found by rebooting after enabling per-user autostart: where.exe listed two matches, and stdout.trim().split('\n')[0] only strips the whole string's ends, leaving line one's own trailing \r attached — which landed inside the Startup .vbs's quoted path and broke it with "Unterminated string constant" at boot. Fixed by splitting on \r?\n and trimming every line. - Dependency audit for the Windows installer (docs/WINDOWS-PORT.md): no VC++ Redistributable needed, confirmed by inspecting the built node-runtime's actual import table rather than assuming. New docs/windows-build.md: a concise clone-to-installer build guide. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(client): a Node-page toggle to switch into/out of service modeChristophe Besson2026-09-0512-0/+84
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The installer's own mode question is effectively one-shot: customInstall skips it entirely once the firewall rules already exist, for any reason -- and per-user mode sets those up on its own, with no Scheduled Task involved. So declining once (or the rules existing from something unrelated, as happened on a dev machine this session) was a dead end: no reinstall, repair, or uninstall/reinstall cycle could ever bring the question back, since uninstall defaults to leaving both alone. Add the other door in (and out): a checkbox on the Node page, next to the existing per-user autostart toggle, wired main.js -> preload.js -> platform.js -> node-page.js. It runs packaging/win/service-mode.ps1 -- the exact script installer.nsh already runs -- via one Start-Process -Verb RunAs elevation, so the two paths can never disagree about what service mode means. The elevation helper writes a tiny param()-based .ps1 to %TEMP% so the target script path and its arguments bind through real PowerShell parameters instead of nested string-quoting. Also fixes a real pre-existing gap found while checking this: 8 of the 10 locale catalogues (all but en/fr) were missing the autostart/service-mode keys added in an earlier commit this session (b782886) -- test_locales.py's key-set-parity check uses a for-loop with an inline assert, so it stopped at the first mismatch (fr) and never actually reached the other eight. Backfilled all five keys (three pre-existing, two new) in de/es/it/ja/nl/pl/pt-BR/zh-CN. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: opt-in Windows service mode (boot-time, one elevation) + v1.0.0Christophe Besson2026-09-043-0/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-user Startup-folder launcher (W3) only ever runs after this user signs in. A real Windows Service would start earlier, but under LocalSystem/NetworkService -- accounts with no normal profile, so %LOCALAPPDATA%\meshbay\ (config, keystore, data) would not exist for it. Relocating storage to make that work is real surgery, deliberately not done here. Instead: a Scheduled Task, created once with admin rights, that runs AS THIS USER at boot without needing them to sign in first. `schtasks /create ... /ru <user> /rp ""` with no `/it` registers an S4U (Service For User) logon -- no password stored anywhere, and unlike LocalSystem it loads this account's own profile, so config_dir()/ data_dir() need zero changes. The cost: S4U carries no network credential, which the node never needed -- everything it touches is local disk plus outbound internet. Creating the task needs admin (a boot trigger touches system-wide scheduler state, the same reason /sc onlogon needed it); querying/starting/stopping an existing one does not -- Task Scheduler grants the owning user that much itself, which is what lets the Node page's Start/Stop/Restart drive it with no further UAC prompts. meshbay_node/platform.py service_install/_remove/_status/_run/_end -- mirrors autostart_* but for the Scheduled Task; TASK_NAME moved here (was decorative before) meshbay_node/daemon.py new `service install|remove|start|stop|status` verb; restart-daemon and reset now check for the service task too packaging/win/service.ps1 the installer-side equivalent (extraResource); status/run/end never self-elevate -- only install/remove do, exactly matching what Task Scheduler itself requires packaging/win/service-mode.ps1 ONE elevated helper running service.ps1 + firewall.ps1 together, so choosing service mode costs exactly one UAC prompt, not two build/installer.nsh the install-time choice: "run as a background service?" (one elevation, both jobs) vs the existing per-user + separate firewall question. Checked first, unelevated, so re-running setup with everything already configured asks nothing. Uninstall offers the matching one-elevation cleanup, default No. src/main.js winServiceTaskStatus/Run/End, wired into node:installed, node:service-status/-stop/-restart and node:start: when the Scheduled Task exists, drive it; otherwise fall back to the existing per-user spawn/kill path. This is the hard requirement -- Start/Stop/Restart from the Node page must work in either mode. node-page.js / locales a hint explaining why the per-user autostart toggle is absent when service mode is active (info.mode from the backend, no new field to gate on -- it just isn't sent in that case) package.json: 0.1.0 -> 1.0.0. Verified: electron-builder compiles the new NSIS choice logic and ships all three scripts; service.ps1's S4U install fails cleanly (Access denied) when run unelevated, and its status/run/end never touch "runas". Cannot verify the elevated success path myself (no admin in this session) -- that needs a real UAC click. Node suite 843 pass / 25 skip; test_packaging_win.py pins the one-elevation property, the S4U flags, and that main.js actually checks the service task in all three handlers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): Stop/Restart buttons and an autostart toggle on the Node pageChristophe Besson2026-09-044-0/+37
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Root cause of both reports: node:service-status's `installed` field was winAutostartInstalled() -- whether the Startup-folder launcher exists -- not whether the daemon can be managed at all. The Node page gates Stop/Restart on `installed`, so with no autostart configured (the default -- nothing installs it automatically) those buttons silently never rendered, leaving only Start. A perfectly manageable daemon looked unmanageable because a different, unrelated setting was off. installed now reflects the actual daemon binary (findNodeBinary()), same definition node:installed already used; autostart moves to its own field carrying what installed used to mean. That field also fixes the other half: there was no way to turn autostart on except the CLI. NodeServicePanel now shows a toggle-switch next to Start/Stop/Restart, wired through a new platform.node.autostart (install/remove) that mirrors the existing service.* pattern -- the preload/main.js bridge (W3) was already there, just never called from the interface. English and French strings; other locales fall back to English per the project's own stated policy (test_locales.py's own docstring). Verified: node --check on every edited file; hub/node suites green (863 pass, 0 fail — pre-existing test_locales.py encoding failures on this Windows checkout are unrelated, reproduced identically on the clean tree). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: drop the retired Mozilla STUN server from the defaultsChristophe Besson2026-09-042-2/+3
| | | | | | | | | | | stun.services.mozilla.com no longer resolves — Mozilla shut the service down — so every ICE gather waited out a DNS timeout on it. Removed from the node defaults (config.py), the browser defaults (transport.js) and the Node page's "reset to defaults" (node-page.js). Google (two endpoints) plus Cloudflare still give two-provider coverage against a single outage, which is the §2.12 resilience claim. draft-v6 §2.12 updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): resolve STUN hostnames in the main processChristophe Besson2026-09-041-8/+31
| | | | | | | | | | | | | | | | | | | | | | | Chromium's P2P socket manager failed every STUN hostname with ERR_NAME_NOT_RESOLVED in a restricted-resolver environment (a Windows KVM guest), even though its own general network stack, the OS resolver and Node's resolver all resolved the same names -- and mapping the names to IPs with --host-resolver-rules changed nothing, so it is not ordinary resolution. WebRTC was left with no server-reflexive candidate. The desktop client now resolves the STUN hostnames in the main process (`ice:resolve-stun`, Node's dns.resolve4) and hands `transport.js` the IP form; a name that will not resolve (the decommissioned Mozilla host) is dropped. In a browser there is no `meshbay` bridge and the hostnames are used unchanged -- a browser resolves them fine, so that path is untouched. Falls back to the hostname form if the bridge call throws. Also, scoped to win32: disable WebRtcHideLocalIpsWithMdns, so the client publishes its real local IP instead of a `.local` name the node's ICE stack cannot resolve across the KVM bridge. Changes nothing on Linux/macOS. Test-env workaround, revisit before release. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat!: MNP 1.0 — seal index and handshake_ack under the group keyChristophe Besson2026-09-033-20/+255
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
* refactor(hub): drop the base64 chunk fallback and the dead HTTP file clientChristophe Besson2026-09-032-78/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | With MNP 0.15 no node can emit a base64 `file_chunk`, so the browser's fallback for that shape is unreachable. Three things go with it: - `file-utils.js` kept a third branch below the fallback that base64-decoded `chunkMsg.ct_b64 || chunkMsg.data_b64` when neither was present, i.e. decoded `undefined` and wrote the result into the file the user was saving. A chunk we cannot decrypt now stops the download with an error naming the file and suggesting the node is older than the page. Deliberately not in `_isRetryableTransportError`: this is a version mismatch, not a bad moment on the link. - `crypto.js` `decryptChunk` (base64) was the real path until Phase 9.15 and has had no caller since. - `crypto.js` `decryptFile` was never called in any commit. It fetched `${nodeUrl}/file/${id}/${chunk}?token=` in a loop — the node's unauthenticated HTTP file API, which is finding C1 and was deleted in Phase 11.5. A client for an endpoint that no longer exists, kept alive by being exported. `decryptChunkBin` — every file download and every video segment — is untouched. `packages/meshbay-client/ui/` was resynchronised with `npm run sync-ui`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* fix(hub): route the chat ack to the request that asked for itChristophe Besson2026-09-031-0/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Typing a message froze the Chat tab: the composer stopped taking clicks and keystrokes, the message never appeared, and it was there all along on the next visit to the tab. The node answers a chat message with a bare {"type": "ack"} -- no request id, no type of its own -- so _dispatch had nothing to match it on and left it to the arrival-order guess at the end of the function. That guess is wrong the moment anything else this browser asked for is still waiting: the ack went to *that* request, and the chat send waited out _sendAndWait's own 30s timeout. Since the composer is disabled while a send is in flight, that reads as a frozen tab; the node had stored the message and answered, into somebody else's promise. An outstanding request is the ordinary case, not a rare one. The node refuses an unknown file_id with a bare `error`, which names no request either and so reaches none, leaving the Videos tab's media_meta_req in _pending for the full 30s. That is the one that was live when this was found. - `ack` is now matched by request type: chat_msg, or the keypair-bundle store and delete, which name themselves in `detail`. A node naming neither still has its reply placed rather than dropped. Every line of chat-app.js is correct and every routed message in transport.js is routed correctly -- the defect is in the seam, so tests/harness/ chat_send_probe.py drives the two together: the real ChatPanel over the real MeshBayTransport, with only the DataChannel replaced by a stand-in answering what the node answers. test_chat_send.py asserts against it, and with the fix reverted all three of its tests fail on the three visible halves of the defect -- the composer still disabled, the message absent, and the ack resolving the unrelated request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFF4BL8VSKrghkSLzCrTVs
* fix(hub): merge duplicate sources in Search's Music and Photos tooChristophe Besson2026-09-0216-34/+158
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Phases 5-8 of docs/refactoring-search.md, extending the Videos merge outward. A library shared by two groups now lists each track once inside an album and each photo once inside a photo album, and a card served by several groups says "N sources" instead of naming one of them. Units come from each application's own grouping, never a copy of its keys. For Music that meant exporting foldKey: groupMusicEntries folds case to group but keeps the first-seen spelling to display, and which group is seen first is whichever index arrived first — so keying a unit on the display strings would let the chosen source change between page loads. A group whose connection fails is marked down and stops being chosen, so a unit fails over to another group that has the file. Eviction is not a failure. Every source being down still yields an entry: a tile that fails to load beats a film that vanished from the grid. sourceLabel now takes the whole unit rather than one entry. A show's poster entry is picked for its thumbnail, so a show in two groups whose cover episode sits in only one of them would have claimed a single source. SourceTag lives in group-name.js — source-merge.js must keep importing nothing (its test executes it standalone), and a copy in each of the three apps is three chances to disagree. test_search_files_unmerged.py holds the one thing that must not change: the Files explorer is not merged, because there each group is a folder and merging would remove a file from one of them. It also asserts the other three lists are merged, or deleting the merge outright would leave it passing and saying nothing. One plan item was dropped as wrong rather than built: the Music queue in onPreview needed no change. It filters by groupId and is reachable only from FilesPanel, which is not merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(hub): one entry per file in the Search view's Videos gridChristophe Besson2026-09-023-6/+217
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A library shared by two groups arrived in the cross-group Search view as two entries per file: every film was two poster cards, every episode was listed twice in the season list under the synopsis. Inside one group this cannot happen — GroupIndex is keyed by blake3 — so the duplication was the Search page's own, from concatenating N independently keyed indexes. source-merge.js folds entries on the content hash and resolves one source per *unit* (a film, a whole show), so a season does not scatter across two nodes. A group hosted by the reader's own node wins; failing that the pick is a hash of the unit key and the reader's id, stable across renders and reloads — a source that changed mid-stream would tear down the connection under a film that is playing — and spread across readers and units. The units come from video-app.js's own groupVideoEntries rather than a second copy of its keys here. Only the Videos view is wired up so far; Music, Photos, failover and the "N sources" badge are phases 5-8 of docs/refactoring-search.md. Every test was checked against the fix removed. That is how the first version of "a unit's files share its source" turned out to prove nothing: with every episode in every group, per-file and per-unit picking give the same answer, so it passed against a per-file implementation. It now uses a unit whose files have unequal sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(hub): dismissing a notification deletes itChristophe Besson2026-09-021-3/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit filtered the list to unread, which corrected what the reader saw and left every dismissed row in the table, invisible for ever. That is a place to hide the disagreement rather than a resolution, and the operator said so: "elles s'accumulent pour rien." So dismissing drops the row. It is the reasoning `purge_notifications` has carried all along — "these are signals, not a record: the group is still there, the message is still in the chat, the invitation is still an invitation" — applied one at a time instead of only in bulk. - `DELETE /v1/notifications/{id}` is the honest name and what the SPA calls. - `POST /{id}/read` reaches the same handler and now deletes too. It has to keep working: the interface ships inside the desktop package, so a hub is always answering some client older than itself, and giving the old path the new behaviour means those clients stop hoarding as well rather than only the updated ones. - `read-all` deletes rather than marking, which makes it `DELETE ""` under an older name. Marking would have made it the one route still filling the table. Nothing in this repo calls it, but a reachable endpoint is one that can be called. `Notification.read` is now vestigial — nothing stored can be read, because reading it deletes it. It stays because dropping a column is a migration for no gain, and `unread_only` stays because a SPA newer than its hub still needs it to be right. Both are said in the module docstring rather than left to be worked out. Two existing tests encoded the old semantics and now assert the opposite; test_notification_dismissal.py gains one for the old `/read` path, because version skew is the normal case here and not the exception. 617 hub tests pass. docs/USERGUIDE.md's endpoint table updated in both places it lists them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): a dismissed notification stays dismissed across a restartChristophe Besson2026-09-021-2/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking a notification navigated to the group and the entry disappeared — the intended behaviour — and it was back on the next launch. Neither half was wrong on its own, which is why it survived. `markRead` drops the entry locally *and* marks it read on the hub, deliberately: "Reading it is the point of clicking it: it goes, here and in the count, rather than sitting there greyed out." The hub honoured that and persisted it. But the startup fetch asked for `/v1/notifications?limit=20` with no filter, and the endpoint returns read and unread alike, so every dismissed notification came straight back. It asks for `unread_only=true` now — a parameter the endpoint already had and already tested. The feed still carried the fossil of the older intent: `class="notif-item ${n.read ? '' : 'notif-unread'}"`, styling for a read entry rendered greyed out, from before clicking meant dismissing. Nothing read reaches the feed any more, so that branch was dead code describing behaviour the application had abandoned — and noticing it is what made the two halves' disagreement visible. Removed. `unread_count` is computed server-side over the whole table and is unaffected by the filter, so the bell is unchanged. test_notification_dismissal.py holds both halves: the API round trip that is the reported bug (list, read, list again), its mirror showing the unfiltered endpoint still returns it — so the fix cannot read as a coincidence — and a static check that the SPA asks for the filter, which is the only one of the three that catches the defect that actually happened. Verified by dropping the parameter again: that one fails, the API tests do not. Read notifications now accumulate unread in the table rather than being deleted. Purge removes them; the volume is small. Making dismissal a delete would suit `purge_notifications`' own docstring — "these are signals, not a record" — but it would leave `/read` a misnomer and `read-all` inconsistent, so it is a separate decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): the Node page takes the width of a settings pageChristophe Besson2026-09-021-1/+7
| | | | | | | | | | | | | | | | | | | | | | | | `.node-page` carried `max-width: 700px` of its own while Settings, Profile and the create-group wizard take `.main`'s. The audit tab is a six-column table — timestamp, event, user, IP, group, detail — with every fixed-shape column set `white-space: nowrap` so an IP is never clipped, so at 700px it scrolled sideways inside `.node-table-scroll` with a couple of hundred pixels of `.main` empty beside it. Measured at 1024: 596px of table box where the page had 856px to give. The rule goes; the class stays as the anchor for the assertions. `.node-group` and `.settings-section` are already the same rule twice over (same background, border, radius, padding), so the two pages now line up card for card. test_node_page_width_measured.py: the Node page and a Settings page are the same width at every width from 320 up, their cards the same rectangle, the audit table no longer wider than its scroller at 1024, and — the narrow case being the design rather than a regression — the table still scrolling inside its own box on a phone without pushing the document sideways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): a show opens on its first season, not its first thumbnailChristophe Besson2026-09-021-10/+26
| | | | | | | | | | | | | | | | | | | | | | | | | | | Reported live: a show with a dozen seasons opened on season 6. Every season was in the picker and none was missing — the default was wrong. VideoDetailModal took it from `repEntry.season`. `repEntry` is the show's "representative entry", which the poster grid picks as `episodes.find((e) => e.thumb_hash) || episodes[0]`: the first episode that has a thumbnail, so the card has a fallback frame when TMDB has no poster. That is from the original Videos commit; the season tabs came later and read the same entry as "the episode the reader is looking at", which it never was on that path. Episodes are sorted by (season, episode), so a show whose first five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg failing on those files — hands back a season-6 episode. `defaultSeason(show)` reads the season list and nothing else: the lowest season present, specials only when there is nothing else, and the lowest *number* rather than the first entry so it does not quietly depend on buildSeasons keeping its sort. The effect's dependency on repEntry goes with it — nothing in it reads that any more. test_video_default_season.py runs the function in node. No input it takes can carry a thumbnail, which is the point. docs/mediacenter.md §10.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): the show detail modal must not move when the season doesChristophe Besson2026-09-022-9/+88
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous pass fixed the synopsis and the cast, and the dialog still jumped: the episode count moves things a fixed-height synopsis cannot reach. - The body scrolled as a whole, so a thirteen-episode season pushed the modal to its max-height where a six-episode one had not. `.video-overlay` centres its child, so the taller modal also *started higher up the screen* — title bar, close button and all. `.video-detail-steady` (a multi-season show only) gives the modal a height rather than a max-height, makes the body a flex column, and hands the leftover to the episode list as the one scrolling part. A constant-height box is centred in the same place every time, so both halves settle at once. - A scrolling season draws a scrollbar where a non-scrolling one draws none, which is a scrollbar's width of content and re-wrapped the file path above it, shifting everything below by a line. `scrollbar-gutter: stable`. - The season panel was clipped by the modal's own `overflow: hidden` whenever the seasons outran the room under the picker — at a 740px viewport it wanted 320px and had 288, and the rest sat where no scroll could reach it. It is `position: fixed` now, placed by `placeSeasonPanel()`, which takes the trigger's rect and the window height, picks whichever side has more room, and caps the panel to it. Scoped to multi-season shows throughout: a movie has no season to switch to and a fixed height would buy it nothing but empty space. test_video_detail_measured.py now builds each block inside a real `.video-overlay`, since the centring is half the defect, and asserts the modal top and height as well as the picker's offset — for a long and a short synopsis and for a six- and a twenty-four-episode season. test_season_panel_placement.py runs placeSeasonPanel() in node over a rect and a window height. Two guards are declarations rather than rectangles and say so in their docstrings: headless Chrome gives the probe zero-width overlay scrollbars, so the gutter cannot be measured there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): steady the show detail modal, and give a series its directorChristophe Besson2026-09-0212-31/+276
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a different season of the same show moved everything under the synopsis, which is where the season control and the episode list are, so the thing just clicked was no longer under the pointer. - The synopsis is exactly three lines for a multi-season show, with a "read more" link floated into the third line box (-webkit-line-clamp only ever puts its ellipsis at the end of the last line and leaves no room after it). Clamped from above and pinned from below to the same number: a constant, not a range — a season summary runs two lines and the next one twelve, and a band still reads as a jump. Whether three lines is all of it depends on the modal's width, so it is measured in the browser and re-measured on a resize. - The cast is clamped to two lines. - SeasonMenu replaces SeasonTabs: the tab row scrolled sideways once a show had more seasons than fit, which is close to unusable on a phone. One trigger reading "Season 5 · 1997" and a menu of every season with its episode count, one row high whatever the season count. - media_meta_resp.director was filled from the credits crew's job == "Director", a movie shape. TMDB's aggregate tv_credits crew is routinely empty and never carries that job, so every show answered null and the modal dropped the line. It now comes from created_by on the show details. Cached show metadata keeps its null until TMDB_META_TTL_SECS expires or an operator re-matches. The facts line is joined rather than concatenated (a title with no rating used to open with " · ") and carries the show's own year next to the director; the selected season's air year moved onto the picker. test_video_detail_measured.py asserts rectangles through layout_probe.py, not declarations: the picker's offset inside its own modal body is the same pixel either way, the synopsis and cast heights, where the read-more link lands, and the open menu at 320 px. Each measured block sits in a whole-pixel-height container, or two identical layouts an eighth of a pixel apart round to tops one pixel apart. test_tmdb_show_director.py covers the credit. docs/mediacenter.md §10.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): fall back to the group's first app when the landing tab is absentChristophe Besson2026-09-021-1/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group could open on a tab that rendered nothing: no panel, no tab shown active, and nothing on screen to explain it. The landing tab is chosen at mount from a preference -- default_tab for the group, else the account-wide one, else 'chat'. Which applications the group runs comes from the node, in the handshake ack, several awaits later. A preference is a preference, not a promise that the app exists here, so the two disagree in two ordinary cases: the group has Chat disabled while 'chat' is everyone's default, or the reader prefers an app this group does not run. `apps.map(a => tab === a.key && ...)` then matches nothing. The first app the group does offer answers both. Two more cases come free: a preference naming an app that no longer exists, and an operator disabling the app someone is currently looking at -- enabledApps changes live over apps_enabled, and being moved to a working tab beats staring at an empty panel. Settings is exempt: it is not an application, and the create-group wizard lands on it deliberately. `const apps` moves above the effect that reads it; a const further down would be in its temporal dead zone, which is the hook-ordering trap already recorded in CLAUDE.md. tests/harness/group_tab_probe.py renders the real GroupPage against a stub node answering a chosen enabled_apps and reads the tab bar back, over five cases. With the fix reverted the three fallback cases report no active tab at all and four of the six tests fail; the two that pass either way are the ones that must not change -- a group running everything, and a preference the group does honour (Videos stays selected, so the fallback has not become "always the first app"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
* fix(hub): let the reader scroll up in the chat againChristophe Besson2026-09-011-18/+81
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The chat could not be read back: any wheel gesture was undone in the frame it happened in, and the "jump to latest" button never appeared. None of the pins in ChatPanel are at fault -- every one of them is guarded by "only if the reader is at the bottom". The reader never got to stop being at the bottom. fit() set the panel's height, read documentElement.scrollHeight back and subtracted the overflow, so the document alternately did and did not overflow the window. The page scrollbar appeared and vanished with it and visualViewport fired resize at every pass -- the event fit() is bound to. It therefore re-entered itself for the life of the panel: measured at 240 firings in two seconds on a page nobody was touching, against 2 for a bare document. Each pass ran fitAndPin, which re-pinned the list to the bottom before the scroll event that would have recorded the gesture was delivered a frame later, so atBottomRef never went false. - fit() learns the space below the panel once and remembers it on the element instead of re-deriving it by writing and measuring back. At the steady state it writes nothing, so it produces no resize. A real window resize or an orientation change forgets the learnt value and measures again (the page under the panel may have reflowed); visualViewport deliberately does not, since a phone fires it constantly. - The scroll-to-bottom is now scoped to *arrival*, which is all it was ever for: opening the group, or coming back to the Chat tab, including the thumbnails and link-preview cards that keep growing the list for a second afterwards. It ends when the reader takes hold of the scroll, and the ResizeObserver disconnects there. - That release is recorded from the gesture (wheel/touchmove/pointerdown/ keydown), not from the scroll event, which arrives too late to protect anything. Unchanged: landing on the newest message, following new messages while already at the bottom, the "load older" anchor and the unread marker. tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser and reads a conversation back; test_chat_scroll_up.py asserts against it. With the fix reverted, five of its six tests fail and the sixth -- landing on the newest message -- still passes, which is the property that must not have been traded away. A structural test cannot see any of this, which is why it is measured. test_layout_responsive.py pinned the listener's name and follows the rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
* fix(hub): enforce registration captcha for every clientChristophe Besson2026-09-012-2/+13
| | | | | | | | | | | | | | | | | | | | | | The server only checked the captcha when auth_key was absent — but every real client (browser included, via the password split) sends auth_key, so the check was off for everyone, and a bot skipped it by including the field. The Register form still made humans solve a widget whose token was never transmitted. Gate is now unconditional on captcha.enabled. The web client (registerUser in keyderive.js) forwards captcha.token; RegisterPage resets the (single-use) token on a failed attempt. The desktop client shares this UI source and is Chromium, so it renders the same widget (see the paired meshbay-client commit for the CSP change that allows it). Tests: test_register_captcha.py. Third security review, finding M1 (Option A). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* fix(hub): adjust no-group prompt and post-create wizard stepChristophe Besson2026-09-0113-16/+53
| | | | | | | | | | | | | | | No-group home/explore message: when the hub offers no public groups, drop the "browse public groups" invitation and just ask to be invited by an admin. New home.invite_only key added to all ten catalogues. Create-group wizard done step: reword the message to point at inviting members, and send the button to the group's Settings tab (where the invite form lives) instead of a stale /groups/<id> path that never matched the router. The landing tab is a one-shot session hint, so the usual per-user default-tab preference is left untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNPfgH6VWcRzJDZGuzy1jJ
* fix(hub): stop Search-view video posters flickering to spinnersChristophe Besson2026-09-012-32/+132
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The cross-group "Search files" view caps its WebRTC connection pool at MAX_POOL_SIZE but pre-connected every indexed group, so any account with more groups than the cap thrashed the pool. An evicted transport was never removed from SearchPage's own groupConns map, so every tile of that group kept a `_tRef` pointing at a closed transport; MediaThumb and useMediaMeta bail on a disconnected transport with no retry, so posters rendered for a moment then fell back to a loading spinner for good. Every connect also fired the module-wide bumpMediaMetaGeneration(), clearing every mounted tile's metadata across all groups and flickering the whole grid through the warm-up walk. A one-group account (grenet) never hit it; a many-group account (cbesson) always did. - ConnectionPool takes an onEvict callback; SearchPage prunes groupConns and rebuilds the entry lists when a connection is evicted or closed. - MAX_POOL_SIZE 3 -> 12; the pre-connect walk is capped to it. Groups past the cap connect lazily when a tile scrolls into view (onNeedConn). - connectGroup() no longer fires the module-wide meta/thumb generation bumps on a normal connect (kept for operator TMDB override/rematch). Per-group gen counter + concurrent-caller guard: a group's tiles refetch once per (re)connect, not once per mounting tile. - MediaThumb/useMediaMeta take an optional reloadKey; each group's `_connGen` is threaded through so a tile refetches when its group reconnects instead of staying stuck on a spinner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4Yj8EuXkjjYxbeS5kPd3U
* refactor(node): JSON-only control API, Node page absorbs the admin dashboardChristophe Besson2026-09-0112-43/+918
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
* fix(hub): remove "shown only once, hub never sees it" from recovery introChristophe Besson2026-09-0110-10/+10
| | | | | | | The clause was redundant with the surrounding UI context. Removed from all 10 locale files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): reCAPTCHA v2 on Register and Password Reset pagesChristophe Besson2026-09-0111-16/+115
| | | | | | | | | | Server-side verification module, CaptchaConfig in hub.toml, captcha_site_key exposed via /v1/hub/info, useCaptcha() hook in the SPA with stable DOM rendering (strength bar always present to avoid Preact re-ordering the captcha widget). Native clients (auth_key path) skip captcha. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: passphrase change and account recovery (auth-confirm)Christophe Besson2026-09-0117-47/+1514
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
* feat: email verification for registration, email change, and invitationsChristophe Besson2026-08-3113-97/+389
| | | | | | | | | | | | | | | | | | Registration now creates a pending account and sends a 6-digit code via email; the account activates only after verification. Email changes on the profile page follow the same flow. Group invitations send a notification email to the invitee (without revealing their address to the inviter) containing the invite code and hub link. Backend: blind HMAC-SHA256 email index for uniqueness without decryption, mail.py for localhost Postfix delivery, verification endpoints, cleanup of expired codes and stale pending accounts, startup backfill of email_hash for existing users. Frontend: 3-phase register page, inline email change verification on profile, invite-notify call with status display. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Explore, Login, Register and CreateGroup from app.jsChristophe Besson2026-08-314-904/+829
| | | | | | | | | | | | | ExplorePage → explore-page.js (static import), LoginPage/RegisterPage/ FirstRunPage → auth-page.js (static import, LoginPage receives onLogin as a prop), CreateGroupPage/wizard → create-group-page.js (lazy-loaded via dynamic import(), same pattern as AdminPage/NodePage). app.js goes from 1803 to 914 lines. webapp.py _ASSETS extended with the three new files and the previously missing extracted pages. Test fixtures updated to follow the moved components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Settings, Profile and Admin pages from app.jsChristophe Besson2026-08-304-951/+982
| | | | | | Admin page is lazy-loaded so non-admin users never fetch it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(ui): use toggle switches in Settings page, trim verbose hintsChristophe Besson2026-08-3011-25/+28
| | | | | | | | | Replace checkboxes with the existing toggle-switch component for notifications, keep-screen-on, and allow-public-groups. Remove the "Notifications" label from per-group lines, and shorten the music-keep-screen-on and hub-URL hints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(ui): configurable ICE interfaces in the Node pageChristophe Besson2026-08-3011-0/+191
| | | | | | | | The ice_interfaces setting (auto-exclude vs explicit whitelist) is now editable from the Node page, persisted via the settings API and roster, and hot-swapped at runtime by re-installing the aioice filter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: configurable STUN server fallbacks for WebRTC ICEChristophe Besson2026-08-3013-1/+303
| | | | | | | | | | The WebRTC transport relied on a single Google STUN server — if it was unreachable, ICE gathering waited the full 4s timeout. Now four public servers are used by default (Google ×2, Cloudflare, Mozilla), configurable via node.toml, the Node page UI, and the CLI (meshbay-node stun list|add| remove|reset). Changes are hot-swapped on the live transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Node page to dedicated module, use local APIChristophe Besson2026-08-302-737/+574
| | | | | | | | | | | | Move NodePage and NodeServicePanel from app.js into node-page.js, following the existing pattern (group-page.js, search-page.js). Lazy-loaded via dynamic import so browser users never fetch it. Replace all MNP/WebRTC calls with platform.node.call() (loopback HTTP API), eliminating the ~6s ICE gathering delay. The MNP protocol types and server-side handlers are kept for potential future browser use. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: scrub copyrighted names from tests, comments and docsChristophe Besson2026-08-301-2/+2
| | | | | | | | | | | | | | Real franchise / show / release-group names had crept back into test fixtures, code comments, a docstring and docs/mediacenter.md while fixing the saga-match and misclassification bugs. Replace them all with invented placeholders ("Some Saga", "A Different Show") and shape descriptions ("a franchise-origin film", "a 3-season show"). Behaviour and assertions unchanged; 738 node tests still pass. Record the rule in CLAUDE.md so it stops recurring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* revert(hub): drop the poster-grid movie merge (V12)Christophe Besson2026-08-3012-77/+17
| | | | | | | | | | | | | | | | | | | | V12 collapsed movies that TMDB resolved to the same tmdb_id into one card. With the matcher still imperfect that fuses *different films*: every numbered entry of a saga whose bare title resolves to the same base id becomes one card, and two unrelated movies sharing a title do too (seen live on a 9-film saga and a 2-film pair). A tmdb_id-keyed merge only works once matching is reliable, which it is not yet. Movies render one card per file again; VideoDetailModal loses the `files` prop and the versions list, back to a single Play button; `.video-version-list` and the `video.versions` key (×10 locales) are removed. mergedShows (V6) is untouched — the operator's report was about movies. docs/mediacenter.md §10.1: V12 un-struck, marked reverted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): make "Re-match" and "Fix match" the same size in the detail modalChristophe Besson2026-08-301-2/+4
| | | | | | | | | | | | .video-fix-match carried `margin: 8px 0` from when it was a standalone button; now that it sits in the .video-admin-actions flex row next to the plain .admin-btn "Re-match", `align-items: stretch` (the flex default) made the margined button 16px shorter than its neighbour — "Re-match" looked twice as big. Drop the margin (the row already has its own), pin the font-size to .admin-btn's, and center the row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): show the Files refresh button only on the Search pageChristophe Besson2026-08-302-5/+7
| | | | | | | | | | | | | In a real group the node streams every index change to connected clients as it happens (onIndexSync / onIndexDelta), so a manual re-fetch button there just duplicates the live push. The cross-group Search page has no such connection — its file list is a one-shot cached fetch — so the button belongs there and only there. Gated on a new explicit `showRefresh` prop that only search-page.js passes; `onRefreshIndex` stays in commonProps for ChatPanel's own use after an attachment upload. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): wire the Files refresh button on the Search pageChristophe Besson2026-08-301-2/+6
| | | | | | | | | | | | The refresh button (added in 1cb9054, left of the breadcrumb home icon) was gated on `onRefreshIndex`, which the Search page's embedded FilesPanel did not pass — so it never showed on the one page it was asked for. The Search page now passes an `onRefreshIndex` that re-runs its all-groups index fetch (the only cache it has); the existing progress bar is the feedback. Unchanged in the normal group Files view. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* Merge remote-tracking branch 'origin/main'Christophe Besson2026-08-2913-0/+275
|\
| * feat(node): editable node settings in the Node page (D5)Christophe Besson2026-08-2913-0/+275
| | | | | | | | | | | | | | | | | | | | | | | | | | Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes, max_concurrent_streams and transcode_incompatible_video in the Node management panel. Changes are applied immediately via roster.db and written back to node.toml so they survive a DB wipe. On startup, roster overrides take precedence over node.toml defaults. Draft v6 §2.11 documents the design; MNP gains node_settings_set / node_settings_set_ack for the browser path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* | Merge branch 'ui/search-title-and-refresh-button'Christophe Besson2026-08-2914-13/+39
|\ \ | |/ |/|
| * feat(hub): drop the Search page title, add a Files refresh buttonChristophe Besson2026-08-2914-13/+39
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 1. The "Search Files" <h2> is removed from the search page — it duplicated the nav and cost a line of vertical space on mobile. `search.title` is dropped from all ten locales (its only use). 2. A monochrome refresh button sits left of the Files breadcrumb home icon. It calls `onRefreshIndex` (re-fetch the group index from the node — the file list is a view over a cached copy with no other way to pull a fresh one), spins while in flight, and only renders where an `onRefreshIndex` is wired (the real group view, not the cross-group search results). New `refresh` icon; `group.refresh_index` in all ten locales. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* | fix: on restart, serve a video's cached TMDB match instead of re-searchingChristophe Besson2026-08-291-4/+12
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Root of the 2026-08-29 demo35 storms. `_do_media_meta_request` could not tell "this is a movie" from "this video isn't enriched yet" — both have season/episode None — so during a slow initial scan with a browser on the Videos tab, every show episode requested was run through the *movie* search path with its raw filename as the query (`search/movie?query=Show S01E01 1080p WEB DL ...`), hundreds per second, until TMDB rate-limited and posters stopped loading. Worse, an un-enriched show episode's own valid cached "tv" match was treated as stale (its provisional kind was "movie"), so a file already resolved got re-queried anyway. - While a video is un-enriched (no display_title — enrich.py always sets one), never run a TMDB *search*. Serve the cached match if the content hash has one, honouring the cached kind ("tv"/"movie") rather than the provisional split; otherwise answer confidence 0. - Once enriched, the strict `cached_media_type == media_type` check is unchanged: an enrichment fix that reclassifies a folder movie->tv still drops the stale match and re-resolves. - video-app.js: `useMediaMeta` gains an `enrichSig` dependency (`entry.display_title`) so the client refetches once the enriched fields arrive on an index delta — the fileId is a content hash and never changes, so nothing else would retrigger it. Not caused by the V8-V13 work; it raised the per-file call count so the pre-existing race became a visible storm. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat: V13 — per-card "re-match this one file" (MNP 0.13)Christophe Besson2026-08-2913-4/+55
| | | | | | | | | | | | | | | | | | | | | | | | A one-click alternative to the full "Fix match" search-and-pick flow, and reachable without SSH (`meshbay-node video rematch` clears a whole group). - MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node logs "unknown type", the button just does nothing). OP_TMDB_REMATCH, signed like tmdb_override (media_cache is shared node-wide). - media_cache.drop_tmdb_match(file_id): forgets the match AND the override marker — deliberately stronger than clear_file_tmdb, since the operator is explicitly asking for a fresh resolution. - webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch + admin-response routing, broadcasts tmdb_rematch_ack. - transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack. - video-app.js: a "Re-match" button beside "Fix match" in the detail modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one key in all ten locales. docs/mediacenter.md §10.1: V8–V13 marked done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(hub): V12 — merge movies by TMDB id in the poster gridChristophe Besson2026-08-2912-17/+77
| | | | | | | | | | | | | | | | | Two movie files that TMDB resolves to the same id (the same film at two resolutions, or the same rip in two folders) now collapse to one poster card, mirroring the existing show merge — same keying discipline so an unmerged movie keeps its card and a merge updates props rather than remounting. The detail modal lists the versions (resolution · duration · size), each a Play button, when there is more than one; a single-file movie is unchanged. New `video.versions` key in all ten locales. Known edge, noted in §10.1: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* Merge branch 'fix/videos-tmdb-matching'Christophe Besson2026-08-2912-3/+69
|\