summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 02:26:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 02:26:17 +0200
commite1dbdf0b7bebc27c2da7ae0c7095c5f88d4e1967 (patch)
tree0ff9fbf6beed03c6247934e5ae168bb19d1cf27d /packages/meshbay-node/src
parent4e0c0d615c3915c485697a7db580d985de77fde5 (diff)
downloadmeshbay-e1dbdf0b7bebc27c2da7ae0c7095c5f88d4e1967.tar.gz
fix(node): a replugged root came back without its metadata
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
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py18
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py25
2 files changed, 43 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 74fff4c..f9e992c 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -1172,6 +1172,24 @@ class NodeDaemon:
# this broadcast — enrichment fields arrive later as their own
# INDEX_DELTA update (_on_enriched below).
new_entries = delta.additions if delta is not None else list(idx.entries)
+
+ # A root that was ejected and plugged back in, or that fell off and
+ # re-mounted, has had its entries thrown away and rebuilt from disk
+ # (`indexer._drop_root_entries`). The rebuilt entry has the same
+ # content-hash id and none of the enrichment fields, so the diff above
+ # reports neither an addition nor a deletion — and `_enriched_attempted`
+ # still says "done" for a file whose album and cover no longer exist.
+ # Found live: a Music library came back with its files and without its
+ # albums, and stayed that way, because only a restart (which starts
+ # with no snapshot, making every entry an addition) could clear either
+ # gate. Treated here as what it is — those entries are new again.
+ rebuilt_ids = indexer.drain_rescanned_ids()
+ if rebuilt_ids:
+ rebuilt = [e for e in idx.entries if e.id in rebuilt_ids]
+ for entry in rebuilt:
+ self._enriched_attempted.discard((group_id, entry.id))
+ seen = {e.id for e in new_entries}
+ new_entries = new_entries + [e for e in rebuilt if e.id not in seen]
asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries))
# Music app (docs/musicbay.md §6): same shape, gated on audio_root
# exactly like video_root above (added later — musicbay.md's
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index f7ffdca..eea5b4f 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -314,6 +314,14 @@ class DirectoryIndexer:
# stay up for exactly as long as the slow part (hashing) is running.
self._burst_inflight = 0
self._burst_sizes: dict[str, int] = {}
+ # Ids whose entry this indexer threw away and rebuilt from disk, since
+ # the last time a consumer drained this. A rebuilt entry carries only
+ # what `_hash_or_cached` fills in — every enrichment field the Videos,
+ # Music and Photos apps put there is gone — but its id is the file's
+ # content hash, so a diff against the last broadcast sees no addition
+ # and no deletion and nothing downstream can tell the fields were
+ # wiped. See `_drop_root_entries`.
+ self.rescanned_ids: set[str] = set()
@property
def index(self) -> GroupIndex:
@@ -704,9 +712,26 @@ class DirectoryIndexer:
if fold(e.path).split("/", 1)[0] == prefix]
def _drop_root_entries(self, root: Root) -> None:
+ """
+ Throw away a root's entries, always in order to rescan it.
+
+ Both callers — `reconcile` when a root reappears, `plug_root` when the
+ operator plugs one back in — rebuild immediately, so nothing outside
+ ever observes the gap: no deletion is broadcast, and the entries that
+ come back have the same content-hash ids they had before. What they do
+ not have is anything enrichment put on them, which is why the ids are
+ recorded for `daemon._broadcast_index_change` to re-enrich rather than
+ simply forgotten.
+ """
for entry in self._entries_under(root):
+ self.rescanned_ids.add(entry.id)
self._index.remove_entry(entry.id)
+ def drain_rescanned_ids(self) -> set[str]:
+ """Take the ids rebuilt since the last call; leave the set empty."""
+ drained, self.rescanned_ids = self.rescanned_ids, set()
+ return drained
+
@staticmethod
def _entry_path(root: Root, entry: IndexEntry) -> Path | None:
_, _, tail = entry.path.partition("/")