diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_replug_restores_enrichment.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_replug_restores_enrichment.py | 218 |
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() |