summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/media_cache.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-25 12:52:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-25 12:52:32 +0200
commit9e66c11b15b7103e963dcecf88fb152ed1e74253 (patch)
tree6ef831d5616af824eb3293d769ac9473ebada0a7 /packages/meshbay-node/src/meshbay_node/media_cache.py
parent2fcdd07d1e5d331ad02b723f1c45603a0989c264 (diff)
downloadmeshbay-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/media_cache.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py89
1 files changed, 85 insertions, 4 deletions
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()