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. --- .../meshbay-node/src/meshbay_node/media_cache.py | 89 +++++++++++++++++++++- 1 file changed, 85 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/media_cache.py') 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() -- cgit v1.2.3