From 9e66c11b15b7103e963dcecf88fb152ed1e74253 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 25 Aug 2026 12:52:32 +0200 Subject: fix(node): make Photos/Video/Music enrichment survive node restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/meshbay-node/tests/test_enrich.py | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) (limited to 'packages/meshbay-node/tests/test_enrich.py') diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py index cff4d50..2205c7e 100644 --- a/packages/meshbay-node/tests/test_enrich.py +++ b/packages/meshbay-node/tests/test_enrich.py @@ -101,6 +101,79 @@ async def test_enricher_populates_fields_and_stores_thumbnail(tmp_path, media_ca assert stored is not None and len(stored) > 0 +async def _run(enricher, entry, path): + 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) + return await asyncio.wait_for(done, timeout=30) + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_reuses_cached_probe_and_thumbnail_without_touching_the_file_again( + tmp_path, media_cache): + """ + The gap this closes: duration/width/height/thumb_hash only ever lived + on the in-memory GroupIndex entry, so every daemon restart re-ran + ffprobe and ffmpeg over every video in every group from scratch, even + though media_cache.db already had the answer. Proven strongly: the + source file is deleted between the two runs, so a real second probe or + frame grab would fail outright rather than merely being redundant. + """ + clip = tmp_path / "Some.Movie.2015.1080p.mkv" + _make_clip(clip) + entry = IndexEntry(id="fileid_reuse", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="video", added_at=0) + + first_enricher = Enricher(media_cache) + _, first_fields = await _run(first_enricher, entry, clip) + assert first_fields.get("thumb_hash") + + clip.unlink() + second_enricher = Enricher(media_cache) + _, second_fields = await _run(second_enricher, entry, clip) + + assert second_fields == first_fields + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_a_renamed_file_re_parses_its_title_even_with_a_cached_probe(tmp_path, media_cache): + """ + A cached probe/thumbnail must never leak into a stale title parse. + display_title/season/episode come from guessit against the *filename*, + which is exactly what a rename needs re-derived + (test_rename_reenrichment.py covers the scheduling half of this) — the + new cache-hit path added alongside them must not accidentally reuse a + stale parse just because it took the same shortcut for duration/width/ + height. Proven the same strong way: the renamed file never exists on + disk at all, so a correct implementation still succeeds (guessit only + reads entry.name) while a regression that tried to re-probe or + re-thumbnail the "new" path would fail outright. + """ + clip = tmp_path / "Old.Name.2015.mkv" + _make_clip(clip) + entry = IndexEntry(id="fileid_rename", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + _, first_fields = await _run(enricher, entry, clip) + assert first_fields["display_title"] == "Old Name" + + renamed_path = tmp_path / "New.Name.2020.mkv" # never created — proves nothing re-reads it + renamed_entry = IndexEntry(id="fileid_rename", name=renamed_path.name, + path=renamed_path.name, size=entry.size, type="video", added_at=0) + _, second_fields = await _run(enricher, renamed_entry, renamed_path) + + assert second_fields["display_title"] == "New Name" + assert second_fields["width"] == first_fields["width"] + assert second_fields["height"] == first_fields["height"] + assert second_fields["thumb_hash"] == first_fields["thumb_hash"] + + @pytestmark_ffmpeg @pytest.mark.asyncio async def test_enricher_handles_episode_with_season_from_folder(tmp_path, media_cache): -- cgit v1.2.3