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.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.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich.py | 63 |
1 files changed, 46 insertions, 17 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) |