aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
Commit message (Collapse)AuthorAgeFilesLines
* fix(node): carry enrichment across a rescan instead of re-deriving itChristophe Besson2026-09-071-12/+54
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | e1dbdf0 made a replugged root re-enrich, which was correct and not enough: the operator still watched their albums vanish. Measured on the reported library with a cold metadata cache, the node broadcast twice — the first delta stripped every album, the second put them back 14 seconds later. Fourteen seconds of "no music found" is the bug, whatever happens after. An entry's id is its content hash, so an entry that comes back under the same id, name and path is the same bytes in the same place and everything enrichment derived from it still holds. `_rescan_root` now carries those fields across the drop-and-rescan that `reconcile` and `plug_root` share. Re-enrichment stays as the fallback for what genuinely changed: a different id is different content, and a different name or path can change the folder and filename fallbacks that artist, album, display_title and track_no rest on, so those entries are still handed to the daemon through `rescanned_ids`. `uploader_id`/`uploader_pk` ride along. They are the same shape of field — set once on an entry, readable from nowhere on disk — and they decide who may delete the file, so losing them to a replug quietly took a right away. Verified on the running node: one broadcast 550ms after the plug, carrying the albums, and no metadata lookups at all. The tests now assert the field on the entry rather than a call to an enricher. Counting calls is what let the previous version of this file pass while the operator still saw an empty tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(node): a replugged root came back without its metadataChristophe Besson2026-09-071-0/+25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported live: a removable root ejected from Files and plugged back in returned with its files and without its albums. Music showed "no music found" and stayed there through a force reload — the loss was on the node, not in the client. `plug_root` drops the root's entries and rescans, which is right; the drive may have changed while it was away. What comes back is a bare IndexEntry: `_hash_or_cached` fills id/name/path/size/type and nothing else. Every enrichment field goes with the old object, and the Music tag fields are cached nowhere by design (enrich_audio.py re-reads them so a rename can re-derive the filename fallback), so re-enrichment is the only way back. Two gates then made sure it never ran: * enrichment is scheduled for `delta.additions`, and ejecting broadcasts nothing, so `_last_broadcast_snapshot` still held those ids — the rebuilt entries diffed as updates, not additions; * `_enrich_new_*_entries` skips anything in `_enriched_attempted`, which is only discarded for `delta.deletions` — and dropping and rescanning inside one call broadcasts no deletion either. A restart cleared both, since an empty snapshot makes every entry an addition. Nothing short of one did. The indexer now records the ids it rebuilt and the daemon drains them at broadcast time: their "already attempted" mark is discarded and they rejoin the entries offered to the three enrichment passes. Not Music-specific — Videos lost durations and titles and Photos lost thumbnails the same way; Music is just where an untagged file has no album to file itself under, so the app goes empty rather than plain. `reconcile()` does the same drop-and-rescan when a root reappears on its own, so a USB drive that fell off and re-mounted hit this with nobody touching the UI. Covered too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(groups): finish Phase 1 — MNP root management, upload targets, eject stateChristophe Besson2026-09-061-0/+16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Review of the Phase 1 commit found the RO/RW model sound but three paths unfinished, each of which broke the flow the phase exists to deliver. Plus 29 test failures it introduced and no coverage for anything it added. Uploads went to the wrong directory. The node read a `root` field on file_upload that no client ever sent, so every upload landed in the first writable root while the Files toolbar offered its button based on the root being browsed — with two writable roots, uploading from one wrote into the other. Files now names the root it is showing; Chat names one chosen in the shell (an operator-configured directory arrives in Phase 2); the node refuses an unknown name rather than falling back, and refuses read-only and ejected roots by code. Shared directories were unreachable on the web. The table read its roots only from the loopback API, which resolves to "not available" in a browser, so the section rendered for nobody there — while the Uploads controls it replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods beside it were dead. MNP is now the path, loopback the fallback for a local node with no live connection, and adding a root over MNP takes a typed path since no web page can browse a remote disk. Ejecting updated nobody's screen. transport.js resolves an admin ack against the pending request and returns, which is right for every op whose caller knows the value it chose; the root acks carry state only the node can compute, so the operator who clicked Eject was the one client that never saw it happen. And the ejected flag reached roster.db but was never read back, so a restart undid it and the next scan read an empty mount point as an erased library. Also: the member-upload endpoint answered 200 and did nothing (removed); the wizard ignored the first root's RW switch; reload compared roots on name and path, so editing writable in node.toml did nothing; the table had no path column, which is the only thing separating two libraries sharing a basename; apps_enabled normalisation differed between the two sides of a signed subject. Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no coverage at all. test_member_upload_policy.py is replaced by test_root_writable_policy.py — it tested a removed feature — and every property worth keeping from it moved rather than being dropped. Docs: draft-v6 structural decision 9 is annotated as superseded (the operator can no longer have a directory only they may write to — a real capability removed, flagged rather than hidden), the man page documents the root verb and the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong. Suite: 41 failures before, 13 after — all 13 pre-existing on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat: groups refactor Phase 1 — root RO/RW model + shared directories UIChristophe Besson2026-09-061-0/+38
| | | | | | | | | | | | Replace the upload boolean with per-root writable/removable/ejected flags. Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol messages, live RootSet updates so API always reflects current state, CLI root subcommand (add/remove/set/list/eject/plug). Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug in Files and Settings, upload gated on root.writable, ejected-root filtering in all media apps, updated Create Group wizard, 10-locale i18n. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): prevent watchdog from overwriting index entries with duplicate ↵Christophe Besson2026-09-061-3/+9
| | | | | | | | | | | | | content The real-time watchdog path lacked the duplicate-content guard that reconciliation already had. When a file with identical content appeared (e.g. browser download appending " (2)"), add_entry overwrote the original's index entry — making it vanish from the file list despite still being on disk. Now check get_entry before adding, matching the reconciliation logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): indexing v2 — partial-read hashing for files above 40 MBChristophe Besson2026-09-061-17/+50
| | | | | | | | | | | | | | | | | Files above 40 MB are no longer read in full. Instead, blake3 hashes 45 MB of samples (first 20 MB + last 20 MB + 5 MB at 50% offset). Files at or below 40 MB are unchanged (full read, hash_version 1). A new `hash_version` field on IndexEntry (default 1) travels on the wire and through the cache so both versions coexist without breaking existing nodes or clients. The IndexCache auto-migrates its schema on open (ALTER TABLE), so no manual step is required on upgrade. A standalone migration script is available in QE/migration/ for operators who want to preview or force a full re-hash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub,node): Create Group wizard silently skipped apps, and lost track of ↵Christophe Besson2026-08-251-16/+49
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | scanning progress Two real-world bugs found together while testing multi-root group creation: - CreateGroupWizard only sent the enabled-apps PUT when the operator had *unchecked* something, assuming "every box left checked" already matched the node's own default (Roster.DEFAULT_APPS = chat, files). It doesn't — so leaving every app checked, the common case, silently left Videos/Music/Photos disabled on the node. Now sent unconditionally. - The wizard's "add extra roots" step never polled index-status, so once step 3 (which only watches the first/upload root) finished, the progress bar froze while the node kept scanning the remaining roots for minutes, unwatched. Added waitForRootsIndexed (platform.js), mirroring waitForGroupHosted's own race handling. That fix exposed a deeper one: indexer.py's _scan_root() only flipped `progress.scanning` on *after* walking the directory and stat()-ing every file — both off-loop, but slow enough on a large root that a poller's grace period (waitForRootsIndexed's 5s) could expire before ever observing `scanning: true` (confirmed against production logs: a GEK-init step fired 5.058s after a root started scanning, matching the grace period almost exactly). The stat() pass was also a synchronous loop directly on the asyncio event loop — blocking the whole daemon (WebRTC, chat, admin UI) for as long as it took on a root with many files. Both fixed: `scanning` now flips on before the walk starts, and stat()-ing is now off-loop too (_size_files). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* fix(node): skip indexing audio files under 50KB, likely-corrupt sourceChristophe Besson2026-08-241-0/+17
| | | | | | | | | | The "P.H. Theme" failure investigated earlier turned out to be a genuinely corrupt 1256-byte source file with no audio stream at all, just an ID3 tag — a real, if rare, corruption pattern worth guarding against directly rather than only handling gracefully at playback time. Scoped to audio only, applied wherever a file actually gets hashed/typed (fresh scan and the cache-miss rehash path alike) — a tiny file of any other type is still indexed normally.
* feat(node): recognize WMA and Musepack as audio, read their real tag keysChristophe Besson2026-08-241-1/+6
| | | | | | | | | | | | | | | | | | | | | | A real-library scan turned up 250 .wma and 23 .mpc files that the indexer was silently classifying as "other" — genuinely lost from the Music app, not a consolidation-rule artifact (checked separately: the grouping logic itself drops nothing). Both are now indexed as audio and tagged properly: - WMA has no mutagen "easy" wrapper, so the generic tag reader was reading nothing from it at all. Reads the real ASF keys directly instead (Title/Author/WM-AlbumTitle/WM-TrackNumber), confirmed against a real sample file before writing the mapping. - Musepack's format auto-detection is unreliable enough (misidentified a real .mpc as MP3 in spot checks) that it now always opens by its own class instead of guessing from content. - Filters out another placeholder value found along the way: a French ripping tool's auto-generated "Album inconnu (<timestamp>)". Neither format decodes natively in a browser's <audio> element, so this gets them correctly visible, tagged, and covered — not yet playable in-browser. That would need server-side transcoding, deliberately left out of this change.
* fix(node,hub): HEVC transcode fallback, live-add progress, per-group TMDB toggleChristophe Besson2026-08-241-30/+78
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three bugs found live testing the Videos app against a real HEVC/EAC3 show, plus a design change requested afterward: - Streaming always did "-c:v copy", which faithfully reports a source's real hev1 codec string but is unplayable in a browser with no HEVC decoder (most Chrome/Linux builds). The node now transcodes to H264 whenever the probed codec is browser-incompatible (media_probe.py's new BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video` node.toml opt-out for operators who know their viewers already decode it. - Dropping a whole season into an already-watched folder gave no scanning indicator and no progress bar: IndexProgress was only ever updated by the two bulk scan paths, never by the real-time per-file watchdog path (_schedule_update/_debounce/_update_entry). That path now accounts a "burst" the same way, without double-counting a file rewritten mid-debounce. - A stray literal "0" rendered in the video detail modal when there was no TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm, not nothing) — `confident` is now a real boolean. - Whether TMDB is used at all moves from a node-wide setting to per-group (OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT): an operator running a real media-library group alongside test/demo groups on one node wants outbound TMDB traffic for the one that needs it, not all of them. The custom API token and query language stay node-wide, one shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning). MNP_VERSION 0.6 -> 0.7, additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
* feat(node): persistent index cache, visible scan progress, adaptive ↵Christophe Besson2026-08-231-49/+216
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | reconcile, and delta sync Indexer performance work, in four parts: - Persistent (path, size, mtime) -> hash cache (indexer/cache.py) so a node restart no longer re-hashes every file — measured at 23 minutes for a 114 GB library on a slow disk before this, near-instant after. Hashing is deliberately kept sequential (max_workers=1): it was never actually concurrent despite the pool size, and two interleaved reads seek-thrash a spinning disk instead of going faster. - Byte-based scan progress (IndexProgress), surfaced via the loopback index-status route, the handshake ack, and a periodic INDEX_PROGRESS push to connected peers — drives a progress bar in the Create Group wizard and "add a directory" in Settings, and an animated presence dot. Guaranteed to settle back to idle via try/finally and a final push on the scanning->false transition. - The reconcile backstop's directory walks now run in the executor instead of blocking the daemon's event loop; its interval defaults to 10 min (was 60s) with adaptive backoff to 2h when nothing changes, reset on a real change or a peer connecting, and is now a per-group operator setting (signed op + group Settings UI). - INDEX_DELTA wired up (protocol support existed, nothing called it): _on_index_change now sends additions/deletions instead of rebuilding the full entries list, coalesced over a short window so a burst of file events produces one push, and the hub swarm registration for public groups only (re-)registers newly added hashes. Also fixes several bugs found while testing the above against real libraries (a 114 GB and a 100+ GB group on a USB HDD): - /api/reload blocked until the reload — including a brand-new group's full initial scan — finished, which the Electron bridge's fixed 30s call timeout turned into a hard failure on any real library. The route now fires the reload without waiting (ops.start_reload), matching add_root/remove_root's existing pattern; the wizard's own step order was fixed to wait for the group to actually appear hosted before the steps that need it (extra roots, GEK), with retries for the residual race between that and the daemon's own bookkeeping. - transport.js's hand-rolled msgpack codec had no case for uint64/int64 (0xcf/0xd3) and crashed decoding any message containing one — hit by IndexProgress.scanned_bytes/total_bytes for any group over ~4.3 GB. Verified against real msgpack-encoded bytes from the Python side. - chat_hist_resp, and this change's own index_progress and set_scan_settings_ack pushes, were not routed by message type and could be handed to an unrelated pending request by the transport's "oldest pending" fallback, stalling it until its own 30s timeout and corrupting whatever received the wrong reply in its place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* fix: first-run wizard reliability and node startup performanceChristophe Besson2026-08-221-3/+9
| | | | | | | | | | | | | Node daemon no longer blocks startup on slow directory scans — initial indexing runs in the background so the node reaches "running" immediately after transports are up. Fixes the wizard failing to detect the node when large USB/NAS roots take minutes to scan. Also: wizard key-linking deadlock resolved (main.js links during poll), invite form stays in DOM during reconnects (disabled instead of destroyed), pairing code bridges to renderer, and firewall docs for LAN casting added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): several named roots per group, and one implementation per operationChristophe Besson2026-08-181-50/+350
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: Phase 9 — Web client SPA with WebRTC P2P transportChristophe Besson2026-08-111-16/+35
| | | | | | | | | | | | | | | | Complete browser-based client: Preact SPA with login, group file browser, encrypted download, video playback, group chat, i18n, and dark/light theme. Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel (aiortc). Hub handles signaling only — all data flows E2E. Performance: pipelined downloads (8-chunk sliding window), binary msgpack wire format (no base64), redundant I/O elimination. Large file downloads stream to disk via File System Access API (showSaveFilePicker). Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6. 132 tests passing. Deployed to meshbay.org + Orange node. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: 4 corrections — streaming hash, watchdog bug, cipher doc, depsChristophe Besson2026-08-091-3/+11
| | | | | | | | | | | | | | | | | | | 1. indexer.py: streaming blake3 (8MB chunks) instead of read_bytes(). Large files (initrd.img, ISOs, VM images) no longer load into RAM. 2. QE/demo-v1/run_node.py: call indexer.start() not initial_scan(). initial_scan() alone never starts the watchdog observer — files added after startup were silently ignored. Added indexer.stop() on shutdown. 3. USERGUIDE.md §8: clarify symmetric vs asymmetric. Ed25519/X25519 = asymmetric (key pairs). ChaCha20-Poly1305 and AES-256-GCM = symmetric AEAD 256-bit (content encryption). ChaCha20 is PRIMARY; AES-GCM is optional browser-compat variant only. 4. pyproject.toml: aioquic, websockets, aiosqlite, slowapi added to proper package deps (were installed manually, now declared). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* feat(node): add directory indexer and Mesh Group IndexChristophe Besson2026-08-091-0/+213
GroupIndex: msgpack→zstd→GEK-encrypt→sign for private groups, plaintext+sign for public groups. DirectoryIndexer: watchdog-based watcher, async initial scan via thread pool, on_change callback. Delta support (diff between versions). 10/10 tests passing. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>