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/src/meshbay_node/indexer/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/src/meshbay_node/indexer/enrich_audio.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py | 61 |
1 files changed, 48 insertions, 13 deletions
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 |