From 171a3e175889ce30f201fcdf5c4632f480344ae6 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 19 Sep 2026 17:42:11 +0200 Subject: fix(node): the Music app's tags survive a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media_cache held video_meta, photo_meta and thumbs; the audio tags lived only in the in-memory IndexEntry. So every start re-read every audio file the node serves, and until that pass landed it served an index with no artist on any track — one the Music app cannot group. Over a real 6176-file library the pass costs 27.8s cold and 6.4s from audio_meta. Only what the bytes decided is stored. The filename and folder fallbacks still run live, or a renamed file would get the old name's answer; the sibling-cover scan reads the folder, so it stays live too; and a read that failed is not cached, or one bad read becomes permanent. Co-Authored-By: Claude Opus 5 --- .../meshbay-node/src/meshbay_node/media_cache.py | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) (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 9dfdf73..9c692ca 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -107,6 +107,40 @@ CREATE TABLE IF NOT EXISTS mbid_meta ( -- 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. +-- Music app (docs/MESHBAY_DESIGN.md §9.8): what a read of the file's own bytes +-- produced. Music was the one app whose index-time enrichment survived +-- nothing: `video_meta` and `photo_meta` are here, `thumbs` is here, and +-- artist/album/track_no/title lived only in the in-memory IndexEntry. So every +-- node start re-read the tags of every audio file it serves, and until that +-- pass landed the index it served carried no artist on any track — which is an +-- index the Music app cannot group, and looks from the outside like a tab that +-- lost its content. Measured over a real 6176-file library: the pass costs +-- 27.8s with nothing cached and 6.4s with this table populated. It does not +-- make the node's start-up window vanish — video enrichment and re-sealing an +-- 8845-entry index dominate that — it makes the artist and the album come back +-- in seconds rather than in tens of them. +-- +-- Only what the bytes decide, for the reason video_meta states: `display_title` +-- and `track_no` as the app finally sees them can also come from the *filename* +-- (title_parse, and _artist_album_from_ancestors for artist/album), and a +-- rename has to re-derive those — _reenrich_renamed_audio_entries exists for +-- it. The raw tag is not rename-sensitive, so it is what is stored, and the +-- fallback chain still runs live on top of it. +-- +-- `cover_seen` records that the *embedded* art scan ran for this content; +-- combined with get_thumb_hash_by_file_id it says whether the file has to be +-- opened again at all. The sibling-image scan is deliberately not covered by +-- it — that one reads the *folder*, so a cover dropped in later must still be +-- found, and it costs 0.8s across the same library. +CREATE TABLE IF NOT EXISTS audio_meta ( + file_id TEXT PRIMARY KEY, + title TEXT, + artist TEXT, + album TEXT, + track_no INTEGER, + duration INTEGER, + cover_seen INTEGER NOT NULL DEFAULT 0 +); CREATE TABLE IF NOT EXISTS photo_meta ( file_id TEXT PRIMARY KEY, width INTEGER, @@ -460,6 +494,34 @@ class MediaCache: ) await self._db.commit() + # ── audio tags (Music app) ─────────────────────────────────────────────── + # + # The tag as read, never the field as the app finally sees it — see the + # schema comment on why the filename-derived half stays out. + + async def get_audio_meta(self, file_id: str) -> dict | None: + async with self._db.execute( + "SELECT title, artist, album, track_no, duration, cover_seen " + "FROM audio_meta WHERE file_id = ?", + (file_id,), + ) as cur: + row = await cur.fetchone() + if not row: + return None + return {"title": row[0], "artist": row[1], "album": row[2], + "track_no": row[3], "duration": row[4], "cover_seen": bool(row[5])} + + async def put_audio_meta(self, file_id: str, tags: dict, duration: int | None, + cover_seen: bool) -> None: + await self._db.execute( + "INSERT OR REPLACE INTO audio_meta " + "(file_id, title, artist, album, track_no, duration, cover_seen) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (file_id, tags.get("title"), tags.get("artist"), tags.get("album"), + tags.get("track_no"), duration, 1 if cover_seen else 0), + ) + await self._db.commit() + # ── video technical fields (Videos app) ────────────────────────────────── # # duration/width/height only — see the schema comment on why @@ -498,6 +560,8 @@ class MediaCache: 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 audio_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 tmdb_override WHERE file_id = ?", (file_id,)) await self._db.execute("DELETE FROM file_mbid WHERE file_id = ?", (file_id,)) -- cgit v1.2.3