aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py63
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py61
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py17
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py89
-rw-r--r--packages/meshbay-node/tests/test_enrich.py73
-rw-r--r--packages/meshbay-node/tests/test_enrich_audio.py80
-rw-r--r--packages/meshbay-node/tests/test_enrich_photo.py28
7 files changed, 377 insertions, 34 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
index 4dddc27..ae3f3dc 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
@@ -129,15 +129,40 @@ class Enricher:
) -> None:
async with self._sem:
fields: dict = {}
+
+ # entry.id is the file's own content hash — a probe/thumbnail
+ # cache hit here means this exact content was already handled
+ # (this run, an earlier one, even a previous daemon process).
+ # Neither survives a restart on its own (the in-memory
+ # GroupIndex entry is rebuilt from scratch every time), but
+ # media_cache.db does — nothing was checking it before spawning
+ # ffprobe/ffmpeg again on every file, every restart.
+ #
+ # Deliberately *not* cached this way: display_title/season/
+ # episode. Those come from guessit against entry.name, which is
+ # exactly what a rename needs re-derived —
+ # _reenrich_renamed_video_entries exists for precisely that —
+ # and reusing a stale parse under a new name would silently
+ # defeat it. ffprobe's own output has no such concern: the same
+ # bytes probe the same regardless of what the file is called.
+ cached_meta = await self._media_cache.get_video_meta(entry.id)
duration: float | None = None
- try:
- _codec, duration, _has_audio, width, height, _raw = await asyncio.wait_for(
- probe_video(str(file_path)), timeout=PROBE_TIMEOUT_SECS)
- fields["duration"] = int(duration) if duration else None
- fields["width"] = width
- fields["height"] = height
- except Exception as e:
- log.warning("Probe failed for %s: %s", file_path, e)
+ if cached_meta is not None:
+ fields["duration"] = cached_meta["duration"]
+ fields["width"] = cached_meta["width"]
+ fields["height"] = cached_meta["height"]
+ duration = cached_meta["duration"]
+ else:
+ try:
+ _codec, duration, _has_audio, width, height, _raw = await asyncio.wait_for(
+ probe_video(str(file_path)), timeout=PROBE_TIMEOUT_SECS)
+ fields["duration"] = int(duration) if duration else None
+ fields["width"] = width
+ fields["height"] = height
+ except Exception as e:
+ log.warning("Probe failed for %s: %s", file_path, e)
+ await self._media_cache.put_video_meta(
+ entry.id, fields.get("duration"), fields.get("width"), fields.get("height"))
ep = title_parse.parse_episode_filename(entry.name)
if ep.episode is not None:
@@ -153,14 +178,18 @@ class Enricher:
mv = title_parse.parse_movie_filename(entry.name)
fields["display_title"] = mv.display_title or mv.naive_title
- try:
- thumb = await _make_thumbnail(file_path, duration)
- except Exception as e:
- log.warning("Thumbnail generation failed for %s: %s", file_path, e)
- thumb = None
- if thumb:
- thumb_hash = blake3.blake3(thumb).hexdigest()
- await self._media_cache.put_thumb(thumb_hash, entry.id, thumb)
- fields["thumb_hash"] = thumb_hash
+ cached_thumb_hash = await self._media_cache.get_thumb_hash_by_file_id(entry.id)
+ if cached_thumb_hash:
+ fields["thumb_hash"] = cached_thumb_hash
+ else:
+ try:
+ thumb = await _make_thumbnail(file_path, duration)
+ except Exception as e:
+ log.warning("Thumbnail generation failed for %s: %s", file_path, e)
+ thumb = None
+ if thumb:
+ thumb_hash = blake3.blake3(thumb).hexdigest()
+ await self._media_cache.put_thumb(thumb_hash, entry.id, thumb)
+ fields["thumb_hash"] = thumb_hash
await on_done(entry.id, fields)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
index a15da6f..a93df51 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
@@ -235,7 +235,9 @@ def _read_format_specific_tags(path: Path) -> tuple[dict, float | None] | None:
return None
-def _read_generic_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]:
+def _read_generic_tags_and_cover(
+ path: Path, skip_cover: bool = False,
+) -> tuple[dict, float | None, bytes | None]:
tags: dict = {}
duration: float | None = None
try:
@@ -257,20 +259,23 @@ def _read_generic_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes
tags["track_no"] = int(m.group())
cover: bytes | None = None
- try:
- raw = MutagenFile(str(path))
- except Exception:
- raw = None
- if raw is not None:
+ if not skip_cover:
try:
- cover = _extract_cover(raw)
+ raw = MutagenFile(str(path))
except Exception:
- cover = None
+ raw = None
+ if raw is not None:
+ try:
+ cover = _extract_cover(raw)
+ except Exception:
+ cover = None
return tags, duration, cover
-def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]:
+def _read_tags_and_cover(
+ path: Path, skip_cover: bool = False,
+) -> tuple[dict, float | None, bytes | None]:
"""
Synchronous — always called via asyncio.to_thread. Returns a partial
`tags` dict (only keys actually found and not a known placeholder:
@@ -278,13 +283,19 @@ def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]:
and raw cover bytes (None if absent, embedded and sibling-file both
checked). Never raises for an unreadable/corrupt file — the caller
falls back to filename parsing entirely in that case.
+
+ `skip_cover` is set when the caller already has a cached cover for this
+ exact content (AudioEnricher._run, keyed by the file's own content
+ hash) — it skips the second file open plus the sibling-directory scan
+ entirely, neither of which the rename-refresh path needs redone: a
+ cover is derived from content, never from the filename.
"""
format_specific = _read_format_specific_tags(path)
if format_specific is not None:
tags, duration = format_specific
cover = None # neither WMA nor Musepack has a convenient embedded-cover path here
else:
- tags, duration, cover = _read_generic_tags_and_cover(path)
+ tags, duration, cover = _read_generic_tags_and_cover(path, skip_cover=skip_cover)
if "title" in tags:
# Some taggers copy the bare filename into `title` verbatim, track
@@ -292,7 +303,7 @@ def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]:
# that pollution would otherwise beat a cleaner one (docstring above).
tags["title"] = title_parse.strip_track_prefix(tags["title"]) or tags["title"]
- if cover is None:
+ if cover is None and not skip_cover:
sibling = _find_sibling_cover(path.parent)
if sibling is not None:
try:
@@ -422,9 +433,31 @@ class AudioEnricher:
) -> None:
async with self._sem:
fields: dict = {}
+
+ # entry.id is the file's own content hash — a cover already
+ # cached under it means this exact content's cover was already
+ # extracted (this run, an earlier one, even a previous daemon
+ # process), so the second file open (embedded APIC/covr scan)
+ # and the sibling-directory disk read are both skippable.
+ #
+ # Tags themselves are *not* skipped this way, deliberately:
+ # unlike a cover, artist/album/title/track_no can fall back to
+ # the filename or the folder name (_artist_album_from_ancestors
+ # above) when no tag is present, which is exactly what a rename
+ # needs re-derived — _reenrich_renamed_audio_entries exists for
+ # precisely that. Caching the *result* the same way enrich.py's
+ # duration/width/height is cached doesn't apply cleanly here:
+ # mutagen reads tags and duration in the same call as cover, so
+ # skipping that call to save time would also skip the
+ # rename-sensitive fields, and skipping only the parts that are
+ # safe to skip needs the cover check below, not a separate
+ # cache of the tag-derived fields.
+ cached_thumb_hash = await self._media_cache.get_thumb_hash_by_file_id(entry.id)
try:
tags, duration, cover = await asyncio.wait_for(
- asyncio.to_thread(_read_tags_and_cover, file_path), timeout=READ_TIMEOUT_SECS)
+ asyncio.to_thread(
+ _read_tags_and_cover, file_path, skip_cover=bool(cached_thumb_hash)),
+ timeout=READ_TIMEOUT_SECS)
except Exception as e:
log.warning("Tag read failed for %s: %s", file_path, e)
tags, duration, cover = {}, None, None
@@ -446,7 +479,9 @@ class AudioEnricher:
fields["artist"] = artist
fields["album"] = album
- if cover:
+ if cached_thumb_hash:
+ fields["thumb_hash"] = cached_thumb_hash
+ elif cover:
thumb_hash = blake3.blake3(cover).hexdigest()
await self._media_cache.put_thumb(thumb_hash, entry.id, cover)
fields["thumb_hash"] = thumb_hash
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
index 44f9ebb..93a68cc 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
@@ -143,6 +143,22 @@ class PhotoEnricher:
on_done: Callable[[str, dict], Awaitable[None]],
) -> None:
async with self._sem:
+ # entry.id is the file's own content hash — the same bytes
+ # produce the same thumbnail, so a hit here means this exact
+ # content was already decoded, resized and EXIF-read at some
+ # point (this run, an earlier one, even a previous daemon
+ # process — media_cache.db is the durable half of this).
+ # Without this check, every restart re-ran Pillow over every
+ # image in every configured photo_root from scratch — the
+ # in-memory GroupIndex enrichment fields don't survive a
+ # restart, but this cache does, and nothing was reading it
+ # before spawning the expensive work.
+ cached_hash = await self._media_cache.get_thumb_hash_by_file_id(entry.id)
+ cached_meta = await self._media_cache.get_photo_meta(entry.id) if cached_hash else None
+ if cached_hash and cached_meta:
+ await on_done(entry.id, {**cached_meta, "thumb_hash": cached_hash})
+ return
+
fields: dict = {}
try:
thumb, width, height, taken_at, camera = await asyncio.wait_for(
@@ -153,6 +169,7 @@ class PhotoEnricher:
fields["camera"] = camera
thumb_hash = blake3.blake3(thumb).hexdigest()
await self._media_cache.put_thumb(thumb_hash, entry.id, thumb)
+ await self._media_cache.put_photo_meta(entry.id, width, height, taken_at, camera)
fields["thumb_hash"] = thumb_hash
except Exception as e:
log.warning("Photo enrichment failed for %s: %s", file_path, e)
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 47032b8..63cda41 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -54,6 +54,24 @@ CREATE TABLE IF NOT EXISTS season_meta (
fetched_at REAL NOT NULL,
PRIMARY KEY (tmdb_id, season)
);
+-- Videos app: the ffprobe fields enrich.py derives alongside the
+-- thumbnail — duration/width/height only, deliberately *not*
+-- display_title/season/episode. Those come from guessit against the
+-- filename, which is exactly what a rename needs re-derived
+-- (_reenrich_renamed_video_entries exists for precisely that); caching
+-- them here would silently defeat that mechanism by handing back the old
+-- name's parse under the new name. ffprobe's own output has no such
+-- concern — the same bytes probe the same regardless of what the file is
+-- called — so only the content-only fields are safe to skip recomputing.
+-- thumb_hash is not duplicated here either, for the same reason it isn't
+-- in photo_meta — get_thumb_hash_by_file_id(file_id) already answers that,
+-- and it too is content-only (a frame grab doesn't depend on the name).
+CREATE TABLE IF NOT EXISTS video_meta (
+ file_id TEXT PRIMARY KEY,
+ duration INTEGER,
+ width INTEGER,
+ height INTEGER
+);
CREATE TABLE IF NOT EXISTS file_mbid (
file_id TEXT PRIMARY KEY,
mbid TEXT NOT NULL
@@ -63,6 +81,21 @@ CREATE TABLE IF NOT EXISTS mbid_meta (
json TEXT NOT NULL,
fetched_at REAL NOT NULL
);
+-- Photos app (docs/photos.md): the technical/EXIF fields enrich_photo.py
+-- reads alongside the thumbnail. Durable for the same reason `thumbs` is —
+-- without this, only the thumbnail bytes survived a restart, and every
+-- image was still fully re-decoded through Pillow just to re-derive
+-- width/height/taken_at/camera, which get_thumb_hash_by_file_id's own
+-- cache hit had already proven unnecessary. thumb_hash is not duplicated
+-- here — get_thumb_hash_by_file_id(file_id) already answers that, and a
+-- second copy would just be one more place for the two to drift.
+CREATE TABLE IF NOT EXISTS photo_meta (
+ file_id TEXT PRIMARY KEY,
+ width INTEGER,
+ height INTEGER,
+ taken_at INTEGER,
+ camera TEXT
+);
"""
# TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not
@@ -231,17 +264,65 @@ class MediaCache:
)
await self._db.commit()
+ # ── photo technical/EXIF fields (Photos app) ─────────────────────────────
+
+ async def get_photo_meta(self, file_id: str) -> dict | None:
+ async with self._db.execute(
+ "SELECT width, height, taken_at, camera FROM photo_meta WHERE file_id = ?",
+ (file_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ return {"width": row[0], "height": row[1], "taken_at": row[2], "camera": row[3]}
+
+ async def put_photo_meta(self, file_id: str, width: int | None, height: int | None,
+ taken_at: int | None, camera: str | None) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO photo_meta (file_id, width, height, taken_at, camera) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (file_id, width, height, taken_at, camera),
+ )
+ await self._db.commit()
+
+ # ── video technical fields (Videos app) ──────────────────────────────────
+ #
+ # duration/width/height only — see the schema comment on why
+ # display_title/season/episode are deliberately not cached here.
+
+ async def get_video_meta(self, file_id: str) -> dict | None:
+ async with self._db.execute(
+ "SELECT duration, width, height FROM video_meta WHERE file_id = ?",
+ (file_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ return {"duration": row[0], "width": row[1], "height": row[2]}
+
+ async def put_video_meta(self, file_id: str, duration: int | None,
+ width: int | None, height: int | None) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO video_meta (file_id, duration, width, height) "
+ "VALUES (?, ?, ?, ?)",
+ (file_id, duration, width, height),
+ )
+ await self._db.commit()
+
# ── pruning ───────────────────────────────────────────────────────────────
async def prune_file(self, file_id: str) -> None:
"""
Called when a file leaves the index (deletion, unshared root). Removes
- its thumbnail and its file->tmdb/file->mbid mappings. `tmdb_meta`/
- `mbid_meta` rows are left alone — they're keyed by tmdb_id/mbid, not
- file_id, and other files (other episodes of the same show, other
- tracks of the same release) may still reference the same entry.
+ its thumbnail, its per-app technical fields, and its file->tmdb/
+ file->mbid mappings. `tmdb_meta`/`mbid_meta` rows are left alone —
+ they're keyed by tmdb_id/mbid, not file_id, and other files (other
+ episodes of the same show, other tracks of the same release) may
+ still reference the same entry.
"""
await self._db.execute("DELETE FROM thumbs WHERE file_id = ?", (file_id,))
+ await self._db.execute("DELETE FROM photo_meta WHERE file_id = ?", (file_id,))
+ await self._db.execute("DELETE FROM video_meta WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM file_mbid WHERE file_id = ?", (file_id,))
await self._db.commit()
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):
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
diff --git a/packages/meshbay-node/tests/test_enrich_photo.py b/packages/meshbay-node/tests/test_enrich_photo.py
index e0c1b73..877a71d 100644
--- a/packages/meshbay-node/tests/test_enrich_photo.py
+++ b/packages/meshbay-node/tests/test_enrich_photo.py
@@ -62,6 +62,34 @@ async def test_enricher_populates_dimensions_and_stores_thumbnail(tmp_path, medi
@pytest.mark.asyncio
+async def test_enricher_reuses_cached_meta_without_touching_the_file_again(tmp_path, media_cache):
+ """
+ The gap this closes: enrichment fields only ever lived in the in-memory
+ GroupIndex, so every daemon restart re-ran Pillow over every photo in
+ every configured root from scratch, even though media_cache.db (the
+ thumbnail bytes) already had the answer. A second PhotoEnricher sharing
+ the same media_cache — standing in for "the daemon restarted" — must
+ reuse it instead. Proven strongly: the source file is deleted between
+ the two runs, so a second real decode attempt would fail outright
+ rather than merely being redundant.
+ """
+ img = tmp_path / "reused.jpg"
+ _save_jpeg(img, size=(300, 200))
+ entry = IndexEntry(id="fileid_reuse", name=img.name, path=img.name,
+ size=img.stat().st_size, type="image", added_at=0)
+
+ first_enricher = PhotoEnricher(media_cache)
+ _, first_fields = await _run(first_enricher, entry, img)
+ assert first_fields.get("thumb_hash")
+
+ img.unlink() # a real second decode would now raise, not just be wasteful
+ second_enricher = PhotoEnricher(media_cache)
+ _, second_fields = await _run(second_enricher, entry, img)
+
+ assert second_fields == first_fields
+
+
+@pytest.mark.asyncio
async def test_enricher_no_exif_degrades_gracefully(tmp_path, media_cache):
"""A screenshot or a re-saved image with no EXIF block at all is the
ordinary case, not an error — must not raise and must leave taken_at/