aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
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/tests
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/tests')
-rw-r--r--packages/meshbay-node/tests/test_replug_restores_enrichment.py218
1 files changed, 218 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_replug_restores_enrichment.py b/packages/meshbay-node/tests/test_replug_restores_enrichment.py
new file mode 100644
index 0000000..23fc97e
--- /dev/null
+++ b/packages/meshbay-node/tests/test_replug_restores_enrichment.py
@@ -0,0 +1,218 @@
+"""
+A root that comes back keeps its Videos/Music/Photos metadata.
+
+Reported live against a real library: a removable root ejected from the
+Files app and plugged back in came back with its files but *without* its
+albums — Music showed "no music found" and stayed that way through a force
+reload, because the loss was on the node, not in the client.
+
+`DirectoryIndexer.plug_root` drops the root's entries and rescans, which is
+right: the drive may have changed while it was away. What it produces is
+bare `IndexEntry` objects — `_hash_or_cached` fills id/name/path/size/type
+and nothing else. Every enrichment field (`artist`, `album`, `track_no`,
+`duration`, `thumb_hash`, `display_title`, `taken_at`, ...) is gone, and
+unlike the video/photo probe results those tag fields are cached nowhere:
+`enrich_audio.py` deliberately re-reads them each time so a rename can
+re-derive the filename fallback. Re-enrichment is the only way back.
+
+Two independent gates then made sure it never ran:
+
+* `_broadcast_index_change` schedules enrichment for `delta.additions`.
+ Ejecting never broadcasts, so `_last_broadcast_snapshot` still held those
+ ids — the re-added entries diffed as *updates*, not additions.
+* `_enrich_new_*_entries` skips anything already in `_enriched_attempted`,
+ which is only discarded for `delta.deletions`. Dropping and rescanning
+ inside one call means no deletion is ever broadcast, so the mark survived
+ a wipe of the very fields it was standing for.
+
+A restart cleared both (an empty snapshot makes every entry an addition),
+which is why this looked like it might fix itself and never did.
+
+The same drop-and-rescan runs in `reconcile()` — "Root %r is back" — so a
+USB drive that falls off and returns on its own hits this without anybody
+touching the UI.
+"""
+
+import asyncio
+import os
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_node.config import (Config, GroupConfig, HubConfig, KeystoreConfig,
+ NodeConfig)
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.roots import RootSet
+from meshbay_node.roster import Roster
+
+pytestmark = pytest.mark.asyncio
+
+# Above indexer.py's MIN_AUDIO_SIZE_BYTES, or nothing would be indexed.
+_AUDIO_BYTES = os.urandom(60 * 1024)
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+class _CountingEnricher:
+ """Stands in for AudioEnricher: records who it was asked to enrich."""
+
+ def __init__(self):
+ self.spawned: list[str] = []
+
+ def spawn(self, entry, file_path, on_done, boundary=None):
+ self.spawned.append(entry.name)
+
+
+async def _daemon(tmp_path, shared, group_id):
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=_free_port(),
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await daemon._media_cache.open()
+ daemon._roster = Roster(db_path=tmp_path / "roster.db")
+ await daemon._roster.open()
+ return daemon
+
+
+async def _settled(daemon, indexer):
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+
+async def test_a_replugged_root_gets_its_music_metadata_back(tmp_path):
+ group_id = "a" * 32
+ library = tmp_path / "music"
+ (library / "an album").mkdir(parents=True)
+ (library / "an album" / "track.mp3").write_bytes(_AUDIO_BYTES)
+
+ daemon = await _daemon(tmp_path, library, group_id)
+ enricher = _CountingEnricher()
+ daemon._audio_enricher = enricher
+ try:
+ await daemon._roster.set_app_directories(
+ group_id, "music", ["music/an album"], set_by="op")
+
+ roots = RootSet.build([{"path": str(library), "removable": True}])
+ indexer = DirectoryIndexer(
+ roots=roots, group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await _settled(daemon, indexer)
+ assert enricher.spawned == ["track.mp3"], "the first pass never ran"
+
+ # The operator ejects the drive from Files, then plugs it back in.
+ indexer.eject_root("music")
+ await indexer.plug_root("music")
+ await _settled(daemon, indexer)
+
+ entry = next(iter(indexer.index.entries))
+ assert entry.artist is None and entry.album is None, (
+ "the rescan is supposed to produce a bare entry — if this ever "
+ "stops being true the rest of this test is measuring nothing")
+ assert enricher.spawned == ["track.mp3", "track.mp3"], (
+ "a replugged root came back with its files and without its "
+ "albums, and nothing was ever going to fill them in again")
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+async def test_a_root_that_returns_on_its_own_is_treated_the_same(tmp_path):
+ """
+ `reconcile()` rescans a root that reappears without anyone asking — a USB
+ drive re-mounting. It goes through the same drop-and-rescan, so it loses
+ the same fields, with no click anywhere to blame it on.
+ """
+ group_id = "b" * 32
+ library = tmp_path / "music"
+ (library / "an album").mkdir(parents=True)
+ (library / "an album" / "track.mp3").write_bytes(_AUDIO_BYTES)
+
+ daemon = await _daemon(tmp_path, library, group_id)
+ enricher = _CountingEnricher()
+ daemon._audio_enricher = enricher
+ try:
+ await daemon._roster.set_app_directories(
+ group_id, "music", ["music/an album"], set_by="op")
+
+ roots = RootSet.build([{"path": str(library), "removable": True}])
+ indexer = DirectoryIndexer(
+ roots=roots, group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await _settled(daemon, indexer)
+ assert enricher.spawned == ["track.mp3"]
+
+ # Gone, then back — availability is what reconcile() watches.
+ roots.roots[0].available = False
+ await indexer.reconcile()
+ await _settled(daemon, indexer)
+ await indexer.reconcile()
+ await _settled(daemon, indexer)
+
+ assert enricher.spawned.count("track.mp3") >= 2, (
+ "a drive that fell off and came back left the library with no "
+ "metadata until the next daemon restart")
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+async def test_videos_and_photos_lost_the_same_fields(tmp_path):
+ """
+ Nothing here is specific to Music — Videos and Photos hang off the same
+ `new_entries` list in `_broadcast_index_change`, so a replug took their
+ durations, titles and thumbnails with it too. Music is simply where it
+ shows up loudest: a track with no tags has no album to file it under, so
+ the app goes empty rather than merely plain.
+ """
+ group_id = "c" * 32
+ library = tmp_path / "media"
+ (library / "films").mkdir(parents=True)
+ (library / "films" / "clip.mkv").write_bytes(os.urandom(60 * 1024))
+ (library / "album").mkdir(parents=True)
+ (library / "album" / "shot.jpg").write_bytes(os.urandom(60 * 1024))
+
+ daemon = await _daemon(tmp_path, library, group_id)
+ video, photo = _CountingEnricher(), _CountingEnricher()
+ daemon._enricher, daemon._photo_enricher = video, photo
+ try:
+ await daemon._roster.set_app_directories(
+ group_id, "video", ["media/films"], set_by="op")
+ await daemon._roster.set_app_directories(
+ group_id, "photo", ["media/album"], set_by="op")
+
+ roots = RootSet.build([{"path": str(library), "removable": True}])
+ indexer = DirectoryIndexer(
+ roots=roots, group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await _settled(daemon, indexer)
+ assert video.spawned == ["clip.mkv"] and photo.spawned == ["shot.jpg"]
+
+ indexer.eject_root("media")
+ await indexer.plug_root("media")
+ await _settled(daemon, indexer)
+
+ assert video.spawned == ["clip.mkv"] * 2, "the film lost its probe"
+ assert photo.spawned == ["shot.jpg"] * 2, "the photo lost its thumbnail"
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()