summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
Commit message (Collapse)AuthorAgeFilesLines
* fix(node): register the service-mode task with Register-ScheduledTask ↵Christophe Besson2026-09-052-25/+68
| | | | | | | | | | | | | | | | -LogonType S4U schtasks.exe has no flag naming the logon type directly -- it only infers S4U vs Interactive from whether /rp is present, and both readings broke live on a blank-password account: /rp "" fails schtasks' own credential validation, and omitting /rp registers "Interactive only", which never launches the process at boot or on demand despite installing cleanly. Register-ScheduledTask -LogonType S4U names the logon type explicitly, no inference. Confirmed live: install, manual start, and unattended boot-time start all now work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(win): graceful shutdown, one startup-mode control, and a stray-\r bugChristophe Besson2026-09-052-4/+207
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-051-0/+51
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(packaging): bundle ffmpeg in the Windows installer by defaultChristophe Besson2026-09-051-0/+87
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | winget install ffmpeg was considered and rejected as the mechanism: it needs network access and winget/App Installer present at the exact moment setup runs, and its failure mode is silent -- video just does not stream, with nothing pointing back at ffmpeg. Not viable for a non-technical install. MeshBay transcodes browser-incompatible video to H.264 (-c:v libx264, webrtc_server.py) -- a real encode, not remux -- so this needs a genuine GPL ffmpeg build; no LGPL-only build includes an H.264 encoder, since libx264 itself is GPL. packaging/win/fetch-ffmpeg.ps1 (new) Downloads, checksum-verifies and stages ffmpeg for the build. Source: BtbN/FFmpeg-Builds' Windows x86_64 gpl-shared preset -- shared DLLs rather than two independent static binaries, which is what nearly tripled this: the "full" static build many devs already have via winget is ~220 MB *per executable*. Pinned to one dated release tag (immutable once published) and its own sha256, not the "latest" alias BtbN repoints on every auto-build -- verified by hand first (downloaded, hash matched, ran a real encode+probe with libx264) before pinning. ffplay.exe (an SDL2 player, ~17 MB) is dropped; MeshBay never invokes it. Cached after the first build. Runs its own smoke test (encode + probe a real clip) so a broken fetch fails at build time, not for the first user who tries to watch something. packaging/win/LICENSE-ffmpeg.txt (new) GPLv3 notice + where the corresponding source is, required because this redistributes a GPL binary even though it is unmodified and only ever invoked as a subprocess. Ships alongside ffmpeg.exe in the installer. build-node-runtime.ps1 / build-win.ps1 Bundling is now the DEFAULT, replacing the old opt-in -FfmpegDir (which copied from a local directory and left most builds without ffmpeg at all). -SkipFfmpeg opts out for a smaller, streaming-less local-iteration build. Also fixes a real bug the ffmpeg change exposed rather than caused: the final `--help` smoke test did `$help -notmatch "meshbay-node"` against $help captured as a PowerShell ARRAY (one element per line) -- -notmatch on a collection is a FILTER, not a boolean test, and returns the non-matching elements; any non-empty array is truthy in if() regardless of content. Once --help wrapped past one line (it now does, with autostart/service in the verb list) this threw unconditionally. Fixed by joining to one string before matching, and pinned by a new test so a future edit cannot silently reintroduce the collection-vs-scalar trap. Verified: downloaded and hashed the pinned release by hand (matches), ran a real libx264 encode + ffprobe against the extracted build, fetch-ffmpeg.ps1 end to end (161 MB staged), a full build-node-runtime.ps1 run (308 MB node-runtime/) and a full installer build (MeshBay-Setup- 1.0.0.exe, 210.8 MB with ffmpeg bundled). Node suite 850 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(node): pin the fresh-install-before-provisioning safety propertyChristophe Besson2026-09-042-0/+61
| | | | | | | | | | | | | | | | | | | | | | | | | | | Two properties discussed but not yet pinned by a test, both load-bearing for service mode: 1. A bare `meshbay-node` with no config yet -- exactly what the W3 Startup .vbs and the service-mode Scheduled Task both run unattended, on the very first boot after a fresh install, quite possibly before the user has ever opened the client -- must fail closed, fast, and without a trace. Measured by hand first (under a second, zero bytes written against a real empty %LOCALAPPDATA%); this pins it as a test so it can't regress silently. load_config() already returns an empty Config on a missing path rather than raising, so main() reaches its own "hub.username not set" exit before ever touching NodeDaemon() or asyncio.run() -- nothing here has to mock the daemon startup. 2. node:start (main.js) must call provisionNode() before it ever checks for the service task or spawns -- reversed, the wizard's first Start on a fresh service-mode install would run/query the daemon before node.toml exists for it to read. Source-read, same technique as test_desktop_shell.py: the only evidence available without a live Electron run. Node suite 845 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: opt-in Windows service mode (boot-time, one elevation) + v1.0.0Christophe Besson2026-09-042-21/+121
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(packaging): re-running setup no longer re-prompts for firewall accessChristophe Besson2026-09-041-0/+18
| | | | | | | | | | | | | | | | | | | Every run of customInstall showed the "Allow MeshBay through Windows Firewall?" question and, on Yes, a fresh UAC prompt -- an upgrade or repair install would ask again even with all four rules already in place. customInstall now checks first: firewall.ps1 check, unelevated (Get-NetFirewallRule needs no admin, only New/Remove do), exits 0 if every rule already exists. Only a nonzero result reaches the MessageBox and the elevated add. A second run of setup on an already-configured machine now asks nothing. Verified unelevated: check exits 1 and logs which rules are missing on a machine with none of them (the fresh-install case); electron-builder compiles the nsExec::Exec / Pop $0 / ${If} wiring. Node suite 837 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): cover LAN casting in the Windows firewall step, tooChristophe Besson2026-09-041-0/+24
| | | | | | | | | | | | | | | | | | | | | | | firewall.ps1 only handled WebRTC. The cast HTTP relay (src/cast-relay.js, fixed TCP 19550-19553) and Chromecast/Smart TV mDNS discovery (src/cast-chromecast.js, bonjour-service, UDP 5353) are a separate surface on the client and were still hitting Windows' own "Allow access" dialog. Now four named rules: MeshBay / MeshBay Node stay program-scoped with no port restriction (there is no fixed WebRTC port to name -- the Windows equivalent of the broad 1024-65535/udp range the Linux packaging needs, since netfilter has no program scoping); MeshBay Cast / MeshBay Cast Discovery add the program AND the fixed port, matching packaging/firewall/*/meshbay-cast.xml exactly. test_packaging_win.py cross-checks the port numbers against cast-relay.js's own constants and the Linux firewalld definition, so the three descriptions of one port range can't quietly drift apart. Verified: rebuilt MeshBay-Setup-0.1.0.exe; the deployed firewall.ps1 carries all four rules. Node suite 836 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): offer one elevated firewall step instead of two dialogsChristophe Besson2026-09-041-0/+55
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Installing used to mean clicking through two separate Windows "Allow access" prompts later — one for MeshBay.exe, one for meshbay-node.exe — each confusing on its own and worse before the exe carried a version resource. Adding a firewall rule needs admin, and the installer is deliberately per-user with no elevation, so this can only ever be opt-in. packaging/win/firewall.ps1 (new, shipped as an extraResource at resources\firewall.ps1): idempotent add/remove of the two inbound UDP rules ("MeshBay", "MeshBay Node"), grouped, logged to %TEMP%\meshbay-firewall.log. Locates both executables from its own path, no arguments needed beyond the action. build/installer.nsh: customInstall asks "Allow MeshBay through Windows Firewall now?" and runs firewall.ps1 via NSIS ExecShellWait "runas" — one UAC prompt — only when not ${Silent}; declining or dismissing UAC falls back to Windows' own per-process prompts, unchanged. customUnInstall offers the same in reverse, defaulted to No (a stale rule for a deleted exe is inert, so this should not nag on the way out) and skipped for a silent uninstall. Verified: rebuilt MeshBay-Setup-0.1.0.exe (electron-builder compiles the new LogicLib.nsh / ExecShellWait NSIS successfully); firewall.ps1 run unelevated fails cleanly into its log ("Access is denied") rather than silently doing nothing, confirming the fallback path. Node suite 835 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): stamp the frozen node exe, document Windows networkingChristophe Besson2026-09-041-0/+13
| | | | | | | | | | | | | | | | | meshbay-node.spec now builds a VSVersionInfo (ProductName "MeshBay Node", version read from the installed package so it tracks pyproject) and passes it to EXE(version=...). The Windows Firewall prompt, Task Manager and the file's Properties then show "MeshBay Node" instead of a bare exe name — the prompt the operator has to answer on first run. packaging/win/README.md gains a Networking section: the first-run firewall prompt (allow Private AND Public — a VM adapter is Public), that a flat LAN needs nothing else, what a routed/multi-subnet LAN additionally needs, the libvirt-NAT caveat, and a pointer to the packaged Linux firewall profiles. test_packaging_win.py covers the version resource. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(node): say so when MusicBrainz lookups are inertChristophe Besson2026-09-041-0/+17
| | | | | | | | | | | | | | Staying inert without a contact is the documented policy (module docstring, musicbay.md §3.1): the usage policy wants a contact in the User-Agent, so an unidentified client is never sent. Staying *silent* about it was not a decision — the operator sees Music tiles with no metadata or cover art and has nothing to search the logs for. Warns once per client rather than once per lookup, since the condition is constant for the client's lifetime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(packaging): actually ship the TMDB token, on Linux and WindowsChristophe Besson2026-09-041-0/+80
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | default.env was empty in every build, for three independent reasons: 1. build-node.sh read QE/node.env, which does not exist. Even pointed at the real file it would have failed: its `grep MESHBAY_TMDB_DEFAULT_TOKEN=` cannot match QE/tmdb.txt, which is a free-form note, not KEY=VALUE. 2. Nothing consumed default.env. packaging/README.md and build-node.sh both claimed `meshbay-node init` copies it to <config>/node.env; grep found the name in exactly two places, the README and the script that writes it. No code implemented the copy, and `EnvironmentFile=-` hid the absence. 3. build-win.ps1 had no env handling at all, so Windows was empty for a different reason than Linux. Now: the build extracts the v4 read token -- tmdb.py sends `Authorization: Bearer`, so it is the JWT, not the 32-char v3 key beside it in the same file -- matching KEY=VALUE first and then by shape, from MESHBAY_TMDB_TOKEN, MESHBAY_TMDB_TOKEN_FILE, QE/node.env, QE/tmdb.txt. It writes default.env 0600 and *fails the build* if no token resolves; MESHBAY_ALLOW_NO_TMDB=1 opts out. An empty default.env is invisible until a user opens Videos and finds no metadata, which is how this shipped empty on two platforms at once. platform.py gains packaged_default_env()/install_node_env()/load_node_env(). init copies the packaged file once, never overwriting an existing node.env, and the daemon loads node.env itself at startup: systemd does this on Linux via EnvironmentFile, but Windows autostart is a Startup-folder .vbs with no equivalent. Already-set variables always win. Also fixes an UnboundLocalError in main(): `config_dir` was assigned at the top of the init branch, which made it function-local for all of main(), while the reset branch calls `config_dir()` as the imported function. init returns before that line, so `meshbay-node reset` could only ever raise. The local is now cfg_dir. Verified end to end on Linux: token baked (239 chars), init writes <config>/node.env 0600 with it. The PowerShell half is written but unrun -- no pwsh on this machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(node): make ice_interfaces match adapters on Windows (W9)Christophe Besson2026-09-041-0/+163
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ice_interfaces` compared the operator's entry against ifaddr's `adapter.name` only -- the kernel name on Linux (`wlp3s0f0`), but the adapter GUID on Windows (`{846EE342-...}`). A setting written on Linux, or copied into a Windows node's node.toml, matched no adapter at all. The failure was silent and total rather than partial: aioice binds one socket per host address, so an empty list means no sockets, no host candidates, and an SDP offering only a reflexive address. The settings field is free text with no picker, and on Windows the operator sees neither the GUID nor the description -- `ipconfig` shows the connection name -- so an entry now matches the adapter name, the device description, or one of the adapter's own IPv4 addresses, case-insensitively. An address is the one identifier visible on every platform. A filter that matches nothing now falls back to the unfiltered list with a warning. Losing the 5 s timeout saving is a regression; being silently unconnectable is a defect. Also fixes IPv4/IPv6 discrimination in the same loop: the two were told apart by falling through to an `elif` that index-probed `ip.ip[0]` and `ip.ip[2]`, which on an IPv4 str yields characters that compared unequal by luck rather than by design. Now discriminated by isinstance. WINDOWS-PORT.md claimed Transport had "no platform dependency"; it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(packaging): put the bundled meshbay-node on the per-user PATHChristophe Besson2026-09-041-0/+22
| | | | | | | | | | | | | | | | The installer had no console entry point, so running the CLI meant cd-ing into resources\node-runtime\ every time. installer.nsh now adds that dir to HKCU\Environment\Path on install and removes it on uninstall, via stock WordFunc.nsh (electron-builder's NSIS bundle has no EnVar plugin). It points at the real meshbay-node.exe dir — not a shim — so `where meshbay-node`, findNodeBinary and the W3 autostart launcher all resolve to the same binary. A WM_SETTINGCHANGE broadcast nudges open shells; new ones just work. Rebuilt: electron-builder --win nsis compiles the WordFunc includes fine, MeshBay-Setup-0.1.0.exe (155 MB). test_packaging_win.py covers the add/remove symmetry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: Windows installer (W4) — one per-user NSIS package, client + nodeChristophe Besson2026-09-041-0/+153
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `npm run dist:win` produces MeshBay-Setup-<version>.exe: the Electron client and, beside it under resources/node-runtime/, the frozen meshbay-node daemon (meshbay-common inside it). No hub. Per-user, no elevation — matches the W3 constraint that a logon-triggered scheduled task needs admin. electron-builder / package.json build.win nsis, build/icon.ico, extraResources -> node-runtime/ build.nsis oneClick:false perMachine:false allowElevation:false allowToChangeInstallationDirectory:true dist:win -> packaging/win/build-win.ps1 (mirrors dist -> build-client.sh) packaging/win/ meshbay-node.spec + node-entry.py PyInstaller freeze of meshbay_node.daemon:main. The awkward deps (aiortc, av, aioquic, pydantic_core, uvicorn, watchdog, guessit, blake3, tzdata) are pulled in whole with collect_all — that list is expected to grow when a frozen run raises ModuleNotFoundError. build-node-runtime.ps1 throwaway venv -> pip install -> PyInstaller -> packages/meshbay-client/node-runtime/ (gitignored) build-win.ps1 Node>=22 check, npm ci, Electron bump, sync-ui, node runtime, electron-builder --win nsis bump-electron.mjs the Chromium-CVE "build against latest Electron" policy, out of the PS script (5.1 here-string terminator rules) README.md PyInstaller, not the python-embed zip: the frozen meshbay-node.exe is a genuine relocatable single binary, which is what src/main.js:findNodeBinary spawns (process.resourcesPath/node-runtime/meshbay-node.exe when packaged) and what the W3 autostart launcher points at. The embeddable zip needs pip to make that wrapper and the wrapper bakes in an absolute interpreter path. build/installer.nsh: on uninstall, taskkill meshbay-node.exe and delete the W3 Startup .vbs (it would point wscript at a deleted binary every sign-in). %LOCALAPPDATA%\meshbay\ — node.toml, keystore.enc — is never touched. ffmpeg is not bundled by default (node finds it on PATH); build-win.ps1 -FfmpegDir copies ffmpeg.exe/ffprobe.exe in for a self-contained installer. Verified on the Windows guest: PyInstaller freeze builds first try (node-runtime 147 MB), frozen `meshbay-node status` talks to the live daemon's loopback API; electron-builder --win nsis produces MeshBay-Setup-0.1.0.exe (155 MB), oneClick/perMachine flags applied, node-runtime bundled at the path findNodeBinary expects. test_packaging_win.py (14) pins the config invariants and the NSIS <-> platform.py autostart seam. Node suite 798 pass / 34 skip. Open: Authenticode signing (13.9 — unsigned => SmartScreen), Windows CI (18.3), electron-updater. First clean-machine install + DPAPI + autostart round-trip is a manual check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: Windows daemon lifecycle (W3) — Startup-folder autostartChristophe Besson2026-09-042-2/+80
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The Linux node runs under `systemctl --user`. Windows has no per-user equivalent that works without elevation: `schtasks /create /sc ONLOGON` (even `/rl LIMITED /it`) fails with "Access is denied" for a non-admin user, because a logon trigger touches machine-wide scheduler state. So autostart is a `.vbs` in the per-user Startup folder instead: CreateObject("WScript.Shell").Run Chr(34) & "<exe>" & Chr(34), 0, False wscript runs it at every sign-in, hidden (0) and non-blocking. No admin, no console window, no new dependency. Verified end to end: the launcher brings the daemon up with no window and it answers its loopback API. node/platform.py autostart_install/remove/status — write / delete / detect the launcher autostart_run/end — start now (DETACHED|NO_WINDOW) / taskkill _node_exe — PATH, then next to sys.executable, then argv[0] node/daemon.py new `autostart install|remove|start|stop|status` verb reload (win32) -> POST /api/reload on the loopback API restart-daemon (win32) -> autostart_end + autostart_run reset (win32) -> also removes the launcher client/main.js, preload.js node:autostart handler + winAutostart* helpers (kept in step with platform.py) node:service-status (win32) probes the daemon; stop/restart/start use taskkill + a detached, windowless spawn Tests: 8 autostart cases in test_platform.py (mocked sys.platform, APPDATA pointed at tmp); `autostart status` added to the CLI dispatch sweep. Full meshbay-node suite green on Windows (784 passed / 34 skipped). Still open: no CTRL_CLOSE_EVENT handler, so a bare taskkill / window close does not run _shutdown() (SetConsoleCtrlHandler, follow-up). Service mode (pywin32/NSSM) stays Phase 2. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(node): run on the default Windows event loop (Proactor)Christophe Besson2026-09-041-4/+14
| | | | | | | | | | | | | | | | | | Verified end to end: a live browser peer on Windows connecting to a Windows node — handshake, index sync, file download and an ffmpeg-transcoded video stream all work on the ProactorEventLoop. aiortc only hangs on it in the same-process loopback the tests use, which the repo-root conftest already handles for the suite. So the daemon no longer forces the SelectorEventLoop: that fixed aiortc-in-one-process but broke ffmpeg (no subprocess support on a Windows SelectorEventLoop). `use_compatible_event_loop()` becomes `configure_event_loop()` — a no-op unless MESHBAY_NODE_EVENT_LOOP=selector is set explicitly, as an escape hatch that probably never needs pulling. This drops the planned "move ffmpeg off the asyncio loop" work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(node): make init, node.toml editing and CLI output work on WindowsChristophe Besson2026-09-041-2/+40
| | | | | | | | | | | | | | | | | | | | | Found by running the daemon on Windows for the first time: - `meshbay-node init` wrote `unlock_file = "C:\Users\..."`, and attach_group / add_root write `path = "C:\..."` — a raw Windows path in a TOML basic string is a parse error (`\U`, `\a`, ... are escape sequences), so the config would not load. All now write `Path(...).as_posix()`; pathlib reads the forward-slash form fine on Windows. - any `print()` carrying a `->` arrow or em dash (the CLI help and messages are full of them) raised UnicodeEncodeError on a cp1252 console and took the command down. New `platform.force_utf8_stdio()` reconfigures stdout/stderr to UTF-8, called at the top of `main()`. Verified on Windows: init writes parseable LF node.toml, the keystore Argon2-decrypts, the loopback control API binds 127.0.0.1, and `_update_node_toml` reads a CRLF file and rewrites it LF-only with its standalone comments intact. Two regression tests added in test_ops.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(node): make the suite pass on WindowsChristophe Besson2026-09-0415-29/+167
| | | | | | | | | | | | | | | | | | | - `.read_text()` on source files now `encoding="utf-8"` — cp1252 chokes on the em dashes / box-drawing chars those files contain. - test node.toml templates embed paths via `Path.as_posix()`: a raw Windows path in a basic TOML string is a parse error (`\U`, `\a`, ... are escapes). - new `test_platform.py` covers `meshbay_node.platform` by mocking `sys.platform` / `os.environ` — runs on both OSes. - `skipif(sys.platform == "win32")`, in `conftest.needs_subprocess` and inline, for the documented gaps: ffmpeg/ffprobe via asyncio subprocess (the win32 selector loop, forced for aiortc, cannot spawn one), the systemd `reload`/`restart-daemon` delegation (Windows path is W3), the keystore `st_mode == 600` assertion (NTFS ignores mode bits), and the symlink-escape test (needs Developer Mode). Windows: 781 passed, 25 skipped. No change on Linux. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat!: MNP 1.0 — seal index and handshake_ack under the group keyChristophe Besson2026-09-034-11/+300
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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
* fix(node): reload the user manager before reload/restart-daemonChristophe Besson2026-09-031-1/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported live: after installing the .deb, systemd printed "Warning: The unit file, source configuration file or drop-ins of meshbay-node.service changed on disk. Run 'systemctl --user daemon-reload' to reload units." Both postinst scripts (deb and rpm) already run a daemon-reload, but only for the system manager — they run as root, and the unit that changed is the *user* unit (packaging/systemd/meshbay-node-user.service), owned by each signed-in person's own user manager, a different process root cannot reach. Iterating over logged-in users from postinst was considered and rejected: fragile (depends on machined and each user's session bus), and root has no business doing a user's job. `_systemctl_user` — the one place `reload` and `restart-daemon` already shell out to systemd — now reloads the user manager first, under the correct privilege, right before the verb that would otherwise act on a stale unit. Best-effort and unchecked, like the postinst's own daemon-reload: a reload the manager did not need must never block what the operator asked for, and systemd still reports a genuine failure from the verb itself. Does not touch the postinst scripts. On a package upgrade the warning can still appear once, before the next reload/restart-daemon (or a login, which starts a fresh user manager that reads the current file); this closes it from the CLI's own lifecycle commands rather than reaching into every session from root. test_lifecycle_commands_delegate_to_systemctl_user now expects the daemon-reload call ahead of the verb — checked failing against the previous code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* refactor!: one file_chunk and index_sync encoder for every transportChristophe Besson2026-09-033-26/+151
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `file_chunk` and `index_sync` were each built twice, once per transport, and the two copies did not agree. WebRTC sent binary, unsigned chunks carrying a `file_id`; QUIC sent base64 fields, two BLAKE3 hashes, a per-chunk Ed25519 signature and no `file_id`. `index_sync` was plain entries on one transport and a `GroupIndex.serialize()` envelope on the other. One message type, two shapes, one consumer each, and nothing that failed when they drifted — finding C6 one size down, in the two places the handshake unification did not reach. Phase 9.15 moved WebRTC to the binary format and dropped the per-chunk signature; the QUIC encoder was never brought along. It is dropped here rather than reintroduced: the AES-GCM tag authenticates the ciphertext under a GEK-derived key, and since C3 the node authenticates itself once in the handshake instead of once per megabyte. `meshbay_common.protocol` now owns the chunk codec (`chunk_ciphertext`, `file_chunk_wire`, `file_chunk_plaintext`) and `meshbay_node/transport/wire.py` the index builder, which also absorbs the delta the daemon used to hand-build. `test_transport_wire_parity.py` fails if either server grows its own copy back. `ChunkRequest`/`ChunkResponse` are deleted. `ChunkResponse` described the QUIC half while reading like the contract for both, which is what made the fork hard to see at all. BREAKING CHANGE: MNP 0.15 changes the encoding of `file_chunk` and `index_sync` on the QUIC transport. The WebRTC shapes are byte for byte unchanged and no QUIC client ships, which is why this is a MINOR bump; a deployed QUIC peer would have made it MAJOR. Also fixes a test fixture that put a `Path` where the daemon puts a `RootSet`. Nothing caught it: the old QUIC index handler never touched `roots`, and `entry_abs_path` fell through `Path.resolve(strict=...)`, reading the virtual path as a truthy flag and returning the right file by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
* test(node): check where the systemd units are staged, not what the spec saysChristophe Besson2026-09-031-10/+38
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | test_each_unit_is_installed_where_it_can_run read meshbay-node.spec's %install for `install -D` lines and their destination on the following line. The packaging overhaul (2026-08-31) replaced that section with `cp -a %{_staging_root}/* %{buildroot}/`: the spec no longer places individual files, build-node.sh does, and the spec only declares them in %files. So the test failed against packaging that was correct all along — it was checking a mechanism that no longer existed while the property it defends still held. Both units do land where they can run: build-node.sh:71-76 copies meshbay-node.service to /usr/lib/systemd/system/meshbay-node@.service and meshbay-node-user.service to /usr/lib/systemd/user/meshbay-node.service. It now reads that script. Same guarantee, aimed at the thing that does the work: the template — the one carrying User=%i — into the system directory, and the user unit, which cannot carry User=, into the user one. Checked against three reintroductions of the original defect: swapped destinations, the user unit dropped from staging, and the template renamed. Not verified here: %{_unitdir} and %{_userunitdir} really expanding to those paths. There is no rpm on this machine, so that comes from the RPM convention rather than from `rpm --eval`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(node): an invitation the hub never registered is a code nobody can useChristophe Besson2026-09-032-1/+68
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `create_invite` wrote the invite to the roster and *then* asked for the hub. An unreachable hub therefore raised "Hub not connected" after the code was already stored: the operator saw an error and no code, and a valid invitation sat in the roster that nobody had been given. Every retry left another. Registering first means a failure costs nothing — no code exists to be orphaned. A membership row without an invite is harmless: without the code there is still no group key. The endpoint is idempotent (`if not mem: db.add(...)`, no 409), so the SPA registering the same membership again right after createInvite costs nothing either. The registration is now fatal rather than swallowed, which is the part that matters. `/v1/groups/mine` joins GroupMember, so someone who was never registered does not see the group at all and can never redeem the code. Tolerating that failure handed the operator a code that cannot work and said nothing — a worse outcome than the error, because it is silent. Skipped only when there is no username to register with: the MNP path allows an empty one and there the SPA is the one that registers. Found by test_invite_then_join_delivers_the_gek, whose fixture had no hub and which passed only because the failure was swallowed. It has one now. And 0443cf8 added this registration to the CLI path without any test asserting it happened, which is how it came to be skipped whenever the hub was merely absent — test_cli_invite_asks_the_hub_for_an_account_ never_a_key checks it now, and test_an_unreachable_hub_leaves_no_invite_behind covers the orphan (verified failing against the previous ordering). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
* fix(hub): steady the show detail modal, and give a series its directorChristophe Besson2026-09-021-0/+101
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(node): bound and tighten the chat link-preview SSRF surfaceChristophe Besson2026-09-012-2/+88
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The link-preview fetch is an outbound request to an address a member chose. safe_url() already blocked non-public addresses and re-checked each redirect hop; this adds the parts that were missing: - Rate limit. `_do_link_preview_request` was reachable by any member with no ceiling, so a member — or a hub minting tokens for many accounts — could drive unbounded outbound HTTP from the operator's machine (amplification / DoS / on-demand IP disclosure to arbitrary hosts). Now bounded per connection (15) and node-wide (60) over a 60 s window; only a real fetch counts, a cache hit is free, and over the ceiling the reply is a plain `ok: false` (bare link), not cached. - Port allowlist. safe_url() passed `parts.port` straight through, so a member could aim the node at `http://<public-host>:<any-port>`. Restricted to {80, 443, 8080, 8443} — every real OpenGraph page, none of SSH / mail / DB / cache / search / admin ports. - DNS rebinding. The connection's actual peer address is now re-checked against the public-address rule (`_reject_if_rebound`), so a name that resolves clean and then to something internal does not get its body read. Best-effort (no `network_stream` extension, no check); a full literal-pin is noted as remaining hardening. - Decompression bomb. `_downscale` now refuses an image whose header dimensions exceed ~40 MP before convert()/thumbnail() decode it. Third security review, finding M3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* feat(node): [node] quic_enabled flag, off by defaultChristophe Besson2026-09-011-0/+53
| | | | | | | | | | | | | | | | | | | | | | | The QUIC MNP listener was started unconditionally whenever aioquic was importable — but nothing speaks QUIC: the browser and desktop clients use WebRTC, QuicChunkClient has no production caller, and the hub-less `group://` sidecar (D9) is unbuilt. So on every node it was an open UDP port with no client and no working NAT traversal (`punch_nat()` is a direct-connection helper, not a traversal stack). daemon startup now gates QuicChunkServer on `self._config.node.quic_enabled` (default False; `MESHBAY_QUIC_ENABLED` overrides). The generated node.toml templates (config.py, the CLI, the desktop client) carry the line, commented for what it is. Removes the exposure the third review's M2 lives on until a QUIC client exists; the parity fix for the handlers themselves is the next commit. Third security review, finding M2 (mitigation). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
* refactor(node): JSON-only control API, Node page absorbs the admin dashboardChristophe Besson2026-09-013-43/+46
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(node): make WebRTC STUN fallback actually use every configured serverChristophe Besson2026-09-011-0/+138
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | aiortc's connection_kwargs() keeps only the first STUN URI from RTCConfiguration.iceServers ("only a single STUN server is supported"), and aioice.ice.Connection has a single stun_server field. So the node's four default STUN servers -- and anything added on the Node page or with `meshbay-node stun add` -- collapsed to stun:stun.l.google.com:19302. When that one server was slow or unreachable from the node, ICE gathering (get_component_candidates, timeout=5) burned its full 5 s with no server-reflexive candidate, adding seconds to every browser connection. The multi-server fallback of draft-v6 s2.12 was configuration only. transport/stun_multi patches aioice.ice.server_reflexive_candidate (same monkey-patch technique ice_filter.py uses on get_host_addresses) so a single ICE gather races the STUN binding request against every configured server on the one bound socket and takes the first answer. One reachable server anywhere in the list now yields a reflexive candidate in one RTT. - daemon: install_stun_multi() alongside install_ice_filter() - ops.set_node_settings: push the list to stun_multi.set_servers() so the CLI / Node-page hot-swap takes effect without a restart - webrtc_server.handle_offer: log ICE gather time and srflx count - test_stun_multi.py: fan-out, first-answer-wins, all-fail, empty-list fallback, DNS failure Verified end to end with a real RTCPeerConnection: with a black-hole STUN server first in the list, gathering still completes in ~0.07 s with full srflx candidates (previously a 5 s stall). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSsQhfxEAhwi4nqc4hASmq
* feat: passphrase change and account recovery (auth-confirm)Christophe Besson2026-09-013-1/+86
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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: configurable STUN server fallbacks for WebRTC ICEChristophe Besson2026-08-301-0/+1
| | | | | | | | | | 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>
* chore: scrub copyrighted names from tests, comments and docsChristophe Besson2026-08-305-24/+24
| | | | | | | | | | | | | | 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
* fix(node): stop a numbered saga all matching its first filmChristophe Besson2026-08-301-3/+14
| | | | | | | | | | | | | | | | | | | | | Every "<Saga> Episode <N> - <subtitle>" file in a numbered franchise resolved to the series' first entry (a real, older film). `sequel_variants` stripped "Episode <N>" and offered the bare "<Saga>" as a candidate query; that matches the first film's original_title at ratio 1.0 and beat PASS 1's correct-but-lower hit. A franchise's bare name is very often a real, different film. When a Part/Episode/Chapitre/… keyword carried the index, sequel_variants no longer emits the bare base — only "<base> <digit>" and "<base> <roman>". Without a keyword ("<Franchise> 3") the bare base is still offered, so that fix is untouched. Verified live against TMDB: the franchise's episodes each resolve to their own entry; the earlier numbered-sequel, two-part-film and franchise-subtitle regressions all hold. docs/mediacenter.md §10.3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(node): stop a movie with a mangled quality tag being shelved as a seriesChristophe Besson2026-08-292-0/+63
| | | | | | | | | | | | | | | | | | | | | | Found on demo35: "Some.Film.2017.MULTI.108.grp.mkv" — the release name's "1080p" truncated to "108" — makes guessit read S01E08, so enrich.py's flat-library branch (elif ep.episode is not None) filed a standalone film as a series. "Fix match" then only offered TV results for the phantom show, so there was no way out from the UI. - title_parse.has_episode_marker(): true only for an explicit SxxExx / 1x08 / "Episode N" / "Season N" token, not a bare 3-4 digit run. - enrich.py: the flat-library branch now needs ep.season AND ep.episode, plus either a real marker or the absence of a "(2019)"-style year. Every genuine flat-dumped episode in the corpus carries a marker, so real shows are untouched; the same misparse on "1280" ("...2013.1280...") is covered too. docs/mediacenter.md §10.2. Known gap left open: no operator control over the movie/show kind itself — a "this is a movie / a show" toggle would. 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-1/+60
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-292-0/+160
| | | | | | | | | | | | | | | | | | | | | | | | 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(node): V8–V11 — show-branch ladder, year-aware _best_match, wider ↵Christophe Besson2026-08-293-2/+153
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | sequel_variants V8: the TV/show branch of _tmdb_search used the old "first candidate over 0.6 wins" shape. It now shares one _tmdb_ladder helper with the movie branch — score every candidate query, keep the best, fast-path a confident primary hit. A year lifted off the show's folder name (title_parse.year_in, e.g. "Some.Show.2022.S01") rescues a sub-0.6 hit that lands on the exact year. title_parse.clean_query de-dots a folder-derived title without naive_title's extension-stripping trap. V9: _best_match gains an optional `year`. When the top result is not a confident textual hit (ratio < 0.6) and a year was requested, a different result of that exact release year is preferred — TMDB already year-filtered the search, so this is a hard corroboration, not the fuzzy re-rank §3.3 warns against. A confident top hit is never overridden. search_movie/search_tv forward the year. V10: sequel_variants widened — trailing Roman→digit as well as digit→Roman, spelled-out indices (one..twelve / un..douze / ordinals), and a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year. V11: when the primary hit is already decent (>= 0.6) and there is nothing more specific to try (no alternative_title, no sequel variant — only a punctuation restatement left), the ladder returns without the extra requests. The clean-title common case is back to one call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(node): correct TMDB movie matching, per-file overrides, rematchChristophe Besson2026-08-297-0/+458
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contactChristophe Besson2026-08-293-201/+13
| | | | | | | | | | | | | | | | | | | | | | | | Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): a pinned device can be invited to a further invite-only groupChristophe Besson2026-08-281-2/+86
| | | | | | | | | | | | | | | | | | | | | | | | | | | Regression from device linking (Stage C, 2026-08-18). Once a device is pinned on a node — as a member of one group, or an operator pairing — the `known` fast-path in `_do_join_request` dropped straight into `_join_ok`. For any *other* invite-only group it had no roster row for, that answered `not_authorized_for_group` and stopped there: the client never got `code_required`, so the pairing-code form never appeared and a legitimately invited member could not join. The `known` branch now, when there is no membership for the group being opened: - with a valid code → consumes the invite and admits (as the unknown- device path already does); - with no code but an invite waiting for this user here → `code_required`, so the client prompts; - with no code and nothing inviting them → `not_authorized_for_group`, unchanged, so the H3 guarantee (a hub-invented pin gets no key) holds. Also fixed: the group's own roster row is now consulted first, so an existing member opening their group is never mistaken for a stranger. Tests in test_roster_pairing.py cover all three branches plus the H3 guard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(chat): link previews for pasted URLsChristophe Besson2026-08-282-0/+253
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Paste an http(s) link in a group's chat and it unfurls into an OpenGraph card — title, description, site name, and image — the way WhatsApp/Signal/ Slack do it. The fetch is the node's, never the browser's or the hub's. The browser cannot: a strict img-src/connect-src and CORS block it, and a direct fetch would leak every reader's IP to the linked host on each render. The hub must not touch group content (draft-v6 §2.5). The node already fetches third-party metadata for the Videos and Music apps, over the same authorised path. Flow mirrors media_meta_req: the client sends `link_preview_req {url}`, the node replies `link_preview_resp` with the card fields (or `ok: false`), and any OG image is stored under its blake3 in the existing media_cache thumb store — the client then fetches it via the normal file_req path, exactly like a poster. Nothing durable is added: the card text lives in a bounded in-memory TTL cache on the node (draft-v6 §2.7 — enrichment on demand, the asking device caches), and MNP goes 0.11 → 0.12 (additive: an older node logs "unknown type" and the client shows the bare link). Because the URL is chosen by a *member* and triggers an outbound request from the operator's machine, `linkpreview.safe_url` is an SSRF gate: http(s) only, no credentials, and every resolved address must be globally routable — no loopback, private, link-local, multicast or reserved range, cloud-metadata included. Redirects are followed by hand so each hop is re-checked. Residual, documented in the module: DNS rebinding between the check and connect, closed properly by pinning the checked IP — a follow-up. Also fixes a long-standing chat annoyance the preview cards made worse: opening the Chat tab landed a screen or two above the newest message because the scroll-to-bottom ran before attachment thumbnails and (now) preview cards had loaded and grown the content. A ResizeObserver keeps the view pinned to the bottom through late content growth, and does nothing once the reader scrolls up. Tests: test_linkpreview.py (the SSRF gate and the OpenGraph parse, incl. redirect re-validation and image downscaling) and test_link_preview_request.py (reply shape, the media_cache image round-trip, the result cache). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(music): stop sharing one folder's cover across unrelated tracks, and ↵Christophe Besson2026-08-261-0/+38
| | | | | | | | | | | | | | | | | | | | | | | | | | | merge various-artists compilations into one album Two real, confirmed bugs in a large flat music library: - enrich_audio.py's sibling-cover fallback assumed one folder is one release. A large flat "chart ranking" folder mixing dozens of unrelated artists carried several distinct WMP AlbumArt-cache guids (one per original album a track was ripped from), and the fallback picked whichever one WMP had copied to Folder.jpg — attaching one unrelated release's cover to every other track in the folder. Now refuses to pick a cover at all once 2+ distinct guids show up, rather than guess. - music-app.js's groupMusicEntries grouped by artist first, album second, so a various-artists compilation (many genuinely different per-track artists, one shared album tag, no album-artist tag at all — a real ~20-track soundtrack rip has exactly this shape) could never be recognized as one release: every track landed alone in its own artist's bucket and got folded into a singleton pile. Now detects an album key shared across 2+ distinct artist keys and merges those tracks into one compilation card under a "Various" heading instead. Both verified against real, previously-affected files and live in the browser: the shared wrong cover is gone, and the compilation renders as one card with all its tracks in order.
* fix(video): a TMDB override never stored the metadata its chosen id namesChristophe Besson2026-08-261-0/+58
| | | | | | | | | | | | | | | | | | | | | | | Found live: "fix match" appeared to work for two shows but not for a movie whose own automatic search kept landing on the same wrong result. The override only ever recorded the file->tmdb_id mapping — never the metadata that id actually names. _do_media_meta_request's cache check agrees the mapping is fresh (same media_type) but finds nothing under that *new* id in tmdb_meta, since nothing had ever fetched it, and falls through to a brand-new search using the file's own title — reproducing the exact match the override was meant to replace. This stayed invisible for the two shows only because their own title happened to be enough for that fallback search to land on the right answer anyway, entirely independent of whatever the override recorded — never because the override was actually being honored. It surfaced on a movie whose own title search kept landing on the same wrong match regardless. Fetches and stores the real metadata for the chosen tmdb_id up front (via the existing _tmdb_build_meta, which needs only the id — no search result object required), so a later lookup finds the override itself instead of falling through to a search blind to it.
* fix(video): recognize "S1"/"S2"-style season folders, not just full wordsChristophe Besson2026-08-261-0/+18
| | | | | | | | | | | | | | | | | Found live: a real show organized its first season as "House.of.the.Dragon. S01E01...mkv" inside a folder named "S1" (the show name embedded in every filename, so grouping worked by guessit's own title alone), but its later seasons as "S02E01. Episode's Own Title.mkv" inside "S2"/"S3" — no show name in any filename at all, relying entirely on the folder. Those seasons showed up as loose individual entries instead of grouped under the show: season_from_folder_name only recognized "season"/"saison"/"livre" as full words, so "S2" didn't register as a season folder at all, and the ancestor-based show-grouping fix (previous commits) never triggered for those seasons. A bare "S" + 1-2 digits as the *whole* folder name is now recognized too — anchored to the entire name so it can't match some unrelated folder that merely starts with "s" followed by digits.
* fix(video): fix two remaining bugs in Specials numbering and long-season ↵Christophe Besson2026-08-262-11/+86
| | | | | | | | | | | | | | | | | | | | | | | episodes 1. A season spanning more than one folder (a per-book Bonus folder nested inside every numbered season) got the same synthetic episode numbers handed out again in each folder independently — three unrelated Specials all showing up as "S0E01". _synthetic_episode_number now ranks across the whole show for the target season, not just one file's own folder. 2. guessit reads a bare 3-digit leading episode number as a concatenated season+episode guess rather than a plain episode number — "100" parses as season=1, episode=0, not episode=100, with nothing in its output distinguishing that from a real 2-digit episode. A season-like ancestor already overrides guessit's season (previous commit); this applies the same fix to episode via a direct regex on the leading number, capped at 3 digits so a leading year (4 digits) is never misread the same way. Both confirmed against the real library this whole fix was found on: per-book Bonus features across two books no longer collide, and a real 100th-episode file now resolves to episode 100 instead of 0.
* fix(video): group every episode under the show's own folder, not a per-file ↵Christophe Besson2026-08-262-44/+76
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | guessit title The mechanism itself was wrong, not just the TMDB matching: a bare episode numbering convention with no show name in the filename at all (`001 Episode's Own Title.ext`, no SxxExx, no show prefix — entirely ordinary on its own) makes guessit invent a "title" from whatever text follows the number. That text is the individual episode's own name, and differs for every episode in the folder — trusting it, as the code did, groups nothing together at all: every episode became its own single- episode "show", searched against TMDB by that one-off title alone. Whenever a season-like ancestor folder exists (a numbered season, or Specials/Bonus/Extras -> season 0), its own root folder now names the show unconditionally — never a per-file guessit title, which cannot tell a show's real name from an individual episode's one-off name when the filename carries no reliable ShowName/SxxExx structure. The walk continues past *every* consecutive season-like ancestor, not just the first: a per-season Bonus folder (Show/Season N/Bonus/file.ext) is nested two levels inside the show, both "Bonus" and "Season N" season-like on their own, and stopping at the first would hand back "Season N" as the show's name instead of "Show". Also adds "livre" ("book") to the season-word vocabulary (§3.4) — some shows number their seasons that way (Roman numerals) rather than "season"/"saison". Supersedes _title_from_show_siblings from the previous commit (removed): that fallback assumed a per-file title could still be trusted often enough to be worth borrowing from a sibling season folder — this fix means it never needs trusting in the first place once a season-like ancestor exists. Verified directly against the real library this was found on, not just synthetic tests: every sample file (a numbered episode, a Book/Bonus feature, a Book-root "making of") now resolves to the show's real name.
* fix(video): TMDB match cache never invalidated on movie<->show reclassificationChristophe Besson2026-08-261-0/+37
| | | | | | | | | | | | | | | | The real reason a node restart alone didn't fix already-indexed entries after the previous commit's enrichment change: _do_media_meta_request checked media_cache's file->tmdb mapping (keyed by content hash) and trusted it unconditionally, before ever comparing it against the file's *current* movie/show classification. A file whose season/episode changed on a later scan — exactly what the Specials-folder fix does, for every file it reclassifies from "movie" to "tv" — kept answering with its stale, wrong-kind-of-match forever, since nothing about a reclassification touches this cache or its key. Now falls through to a fresh search whenever the cached media_type disagrees with what the entry resolves to right now, rather than trusting a mapping that predates the file's current classification.
* fix(video): a Specials/Bonus folder's episodes were matched to TMDB as ↵Christophe Besson2026-08-261-1/+98
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | unrelated standalone movies Found live: a "Specials" folder full of one-off-named bonus episodes had every file appear as its own poster, matched against TMDB by its own title, because guessit finds no season/episode grammar at all in a filename with no SxxExx of its own — so the classification (episode vs standalone movie), based solely on that, fell to the movie branch. Real, unrelated films happened to share several of those one-off titles and matched confidently, one per Special, cluttering the Videos view with dozens of wrong posters instead of grouping under the show. An ancestor folder saying this is part of a show — a numbered season, or Specials/Bonus/Extras -> season 0 — is now trusted over the filename having no SxxExx of its own. The show's name can't come from this file's own guessit title (that's the bug) or from siblings in the same Specials folder (every one of them has the same gap) — it's borrowed from the show's ordinary season folders next door, which do carry it in the usual ShowName.SxxExx shape (_title_from_show_siblings). A synthetic, stable episode number (alphabetical rank among the folder's video files) stands in for the real one nothing in a Specials folder provides. Generic, not specific to a folder literally named "Specials": the same fallback fires for any season-like ancestor folder whose files lack per-file episode grammar, numbered seasons included (covered by a dedicated test). Also hardens _title_from_siblings to require the sibling's own episode number too, not just a title — otherwise it would borrow one Special's one-off title as if it were representative, on a folder like this one. Existing already-indexed entries will need a rescan (restart the node) to be re-enriched under this logic — nothing re-derives them on its own.
* fix(node,hub): key music/media metadata lookups by file_id, not pathChristophe Besson2026-08-263-11/+304
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | IndexEntry.path is the *folder* a file is in (indexer.py's _virtual_dir docstring: "the directory a file appears in"), not the file itself. GroupIndex.get_entry_by_path() treated it as if it named one file, and every one of its four callers did too: _do_music_meta_request, _do_media_meta_request, _do_tmdb_override, and _admin_exec_tmdb_override. Any two files sharing a folder — an album is one folder with many tracks, a season is one folder with many episodes — collided: a lookup by path silently returned whichever entry the index happened to iterate to first, regardless of which file the client actually asked about. Found live (2026-08-25): three unrelated albums ("High Tone - Various", two "Le Peuple de l'Herbe" albums) all showed the same MusicBrainz cover, because all their representative tracks happened to sit in one "high_tone" folder alongside a track that legitimately matched that cover. A force-reload didn't help — the bug is server-side, not a stale client state. Fixed by keying these four request/response pairs by `file_id` (the entry's own content hash — already unique, already how every other lookup in the system identifies a file) instead of `path`, both in the wire messages (music_meta_req/resp, media_meta_req/resp, tmdb_override) and in music-app.js/video-app.js's own hooks. GroupIndex.get_entry_by_path is now unused and removed — GroupIndex.get_entry(file_id) already did the right thing. No test previously exercised either handler with two entries sharing a folder — the only existing coverage (test_tmdb_override_policy.py) gave each entry its own folder, so the bug never had a chance to show up. Added that scenario there and in two new test files, all confirmed failing against the pre-fix code before being confirmed green against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* feat(node): share the (path,size,mtime)->hash index cache across every groupChristophe Besson2026-08-264-0/+288
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An operator routinely shares the same physical folder into more than one group (a music library, a Séries drive) — IndexCache used to be opened once per group (data_dir/{group_id}/index_cache.db), so the second group to reference an already-fully-hashed multi-terabyte folder paid the same full content read the first one did. IndexCache itself carried no group_id in its schema; only daemon.py's wiring did. Now one instance, opened once at startup (data_dir/index_cache.db), shared by every group's DirectoryIndexer. Confirmed against a real deployment (2026-08-25/26): a group sharing an already-indexed folder with an existing group indexes it instantly, with zero rehashing. Also fixes a related cross-group correctness gap found during this work: media_cache.db (thumbnails, TMDB/MusicBrainz metadata — already node-wide, untouched by this change) was pruned for a file the moment it left *one* group's index, even if another group's index still held the same content hash — forcing a redundant re-fetch/re-probe/re-thumbnail for a group that never actually lost anything. Prune now runs only once no group's index references the file_id any more. Adds a node admin UI action ("Maintenance" card, prune-index-cache) to drop cache rows that no longer belong to any group's roots — skips anything under a root that is merely temporarily unavailable (indexer.py's "a root that goes away freezes, never empties" rule extends to this cache too, or a reconnected drive would pay a full rehash for no reason). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3