diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-25 12:52:32 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-25 12:52:32 +0200 |
| commit | 9e66c11b15b7103e963dcecf88fb152ed1e74253 (patch) | |
| tree | 6ef831d5616af824eb3293d769ac9473ebada0a7 /packages/meshbay-node/tests/test_enrich_audio.py | |
| parent | 2fcdd07d1e5d331ad02b723f1c45603a0989c264 (diff) | |
| download | meshbay-9e66c11b15b7103e963dcecf88fb152ed1e74253.tar.gz | |
fix(node): make Photos/Video/Music enrichment survive node restarts
Only thumbnail bytes were ever durable in media_cache.db — every other
derived field (photo width/height/EXIF, video ffprobe duration/dims,
audio cover art) lived solely on the in-memory GroupIndex entry, so a
node restart re-decoded every photo through Pillow, re-ran ffprobe on
every video, and re-scanned for every album cover from scratch, even
though the answers already sat in the cache.
Adds photo_meta and video_meta tables (content-only fields, keyed by
file_id) and checks them before doing the expensive work. Audio gets no
new table: mutagen reads tags and duration in one inseparable call, so
caching duration alone buys nothing — instead cover-art extraction alone
is skipped via a new skip_cover flag when a cached cover already exists.
Deliberately excluded from all three caches: anything derived from the
filename or folder path (video display_title/season/episode via guessit,
audio artist/album folder-fallback) — those must keep being recomputed
fresh so a rename/move is still correctly re-derived by the existing
_reenrich_renamed_*_entries mechanisms, instead of silently handing back
a stale parse under the new name/location.
Regression tests prove cache reuse by deleting the source file (or cover)
between two enrichment runs, and prove rename/move correctness survives
the new cache by renaming/moving to a path that never exists on disk.
Diffstat (limited to 'packages/meshbay-node/tests/test_enrich_audio.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_enrich_audio.py | 80 |
1 files changed, 80 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_enrich_audio.py b/packages/meshbay-node/tests/test_enrich_audio.py index 5d52317..1485a1f 100644 --- a/packages/meshbay-node/tests/test_enrich_audio.py +++ b/packages/meshbay-node/tests/test_enrich_audio.py @@ -292,6 +292,86 @@ async def test_enricher_uses_a_sibling_cover_file_when_no_embedded_art(tmp_path, assert stored == b"\xff\xd8\xff\xe0fake-jpeg-bytes" +async def _run(enricher, entry, path, root_path=None): + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, path, on_done, root_path) + return await asyncio.wait_for(done, timeout=30) + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_reuses_a_cached_cover_without_rereading_it(tmp_path, media_cache): + """ + The gap this closes: a cover already cached under this exact content + hash used to be re-extracted (or, for a sibling file, re-read from + disk) on every run regardless — media_cache.db had the answer and + nothing checked it first. Proven strongly: the sibling cover file is + deleted between the two runs, so a real second lookup would find + nothing rather than merely redo cheap work. + """ + folder = tmp_path / "Some Artist" / "Some Album" + folder.mkdir(parents=True) + clip = folder / "01 - A Track.mp3" + _make_clip(clip) + (folder / "Folder.jpg").write_bytes(b"\xff\xd8\xff\xe0fake-jpeg-bytes") + entry = IndexEntry(id="fileid_reuse", name=clip.name, + path=str(clip.relative_to(tmp_path)), + size=clip.stat().st_size, type="audio", added_at=0) + + first_enricher = AudioEnricher(media_cache) + _, first_fields = await _run(first_enricher, entry, clip, tmp_path) + assert first_fields.get("thumb_hash") + + (folder / "Folder.jpg").unlink() + second_enricher = AudioEnricher(media_cache) + _, second_fields = await _run(second_enricher, entry, clip, tmp_path) + + assert second_fields["thumb_hash"] == first_fields["thumb_hash"] + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_a_moved_track_re_resolves_artist_album_with_a_cached_cover(tmp_path, media_cache): + """ + A cached cover must never leak into stale artist/album folder context. + Those come from _artist_album_from_ancestors against the file's + *location*, which is exactly what a move needs re-derived + (test_rename_reenrichment.py's audio equivalent covers the scheduling + half) — skipping the cover lookup must not accidentally skip that too. + """ + old_folder = tmp_path / "Old Artist" / "Old Album" + old_folder.mkdir(parents=True) + clip = old_folder / "01 - A Track.mp3" + _make_clip(clip) + (old_folder / "Folder.jpg").write_bytes(b"\xff\xd8\xff\xe0fake-jpeg-bytes") + entry = IndexEntry(id="fileid_move", name=clip.name, + path=str(clip.relative_to(tmp_path)), + size=clip.stat().st_size, type="audio", added_at=0) + + enricher = AudioEnricher(media_cache) + _, first_fields = await _run(enricher, entry, clip, tmp_path) + assert first_fields["artist"] == "Old Artist" + assert first_fields.get("thumb_hash") + + new_folder = tmp_path / "New Artist" / "New Album" + new_folder.mkdir(parents=True) + new_clip = new_folder / clip.name + clip.rename(new_clip) # no cover moved along with it + moved_entry = IndexEntry(id="fileid_move", name=new_clip.name, + path=str(new_clip.relative_to(tmp_path)), + size=entry.size, type="audio", added_at=0) + _, second_fields = await _run(enricher, moved_entry, new_clip, tmp_path) + + assert second_fields["artist"] == "New Artist" + assert second_fields["album"] == "New Album" + assert second_fields["thumb_hash"] == first_fields["thumb_hash"], ( + "the cover is content-derived, not location-derived — it must still be reused") + + @pytestmark_ffmpeg def test_extract_cover_returns_none_when_no_apic_frame(tmp_path): from mutagen import File as MutagenFile |