summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/indexer
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-19 17:42:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-19 17:42:11 +0200
commit171a3e175889ce30f201fcdf5c4632f480344ae6 (patch)
tree7e5dd693f8281faa25e3bb6c5651bbb212eb78c2 /packages/meshbay-node/src/meshbay_node/indexer
parent34d74cba0421ac88505ac620158882f90cf5db7e (diff)
downloadmeshbay-171a3e175889ce30f201fcdf5c4632f480344ae6.tar.gz
fix(node): the Music app's tags survive a restart
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py96
1 files changed, 65 insertions, 31 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 d484e35..dd6b2c0 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
@@ -320,16 +320,30 @@ def _read_tags_and_cover(
tags["title"] = title_parse.strip_track_prefix(tags["title"]) or tags["title"]
if cover is None and not skip_cover:
- sibling = _find_sibling_cover(path.parent)
- if sibling is not None:
- try:
- cover = sibling.read_bytes()
- except OSError:
- cover = None
+ cover = _read_sibling_cover(path)
return tags, duration, cover
+def _read_sibling_cover(path: Path) -> bytes | None:
+ """
+ The cover image sitting beside the track, as bytes.
+
+ Its own function because it is the one part of the read that depends on the
+ *folder* rather than on the file's bytes: `audio_meta` remembers what the
+ bytes said and lets `AudioEnricher._run` skip opening the file at all, and
+ this still has to run on top of that, or a cover dropped in after the first
+ pass could never be found again. Blocking; called via asyncio.to_thread.
+ """
+ sibling = _find_sibling_cover(path.parent)
+ if sibling is None:
+ return None
+ try:
+ return sibling.read_bytes()
+ except OSError:
+ return None
+
+
# A folder name used as a last-resort artist/album, cleaned of the
# punctuation-as-separator and release-tag noise this era of rip is full of
# (underscores standing in for spaces, a bitrate/quality tag still attached
@@ -450,33 +464,53 @@ class AudioEnricher:
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.
+ # entry.id is the file's own content hash, so everything a read of
+ # those bytes produced is cacheable under it — the tags, the
+ # duration, and whether the embedded-art scan has already run
+ # (media_cache.audio_meta, plus get_thumb_hash_by_file_id for the
+ # cover itself). With both in hand the file is not opened at all,
+ # which is the whole point: this pass used to re-read every audio
+ # file on the node at every start, and until it landed the index
+ # the node served carried no artist on any track.
#
- # 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.
+ # What is *not* cached, and must not be: the fallbacks below.
+ # artist/album/title/track_no can come from the filename or the
+ # folder name (_artist_album_from_ancestors above) when no tag is
+ # present, and those a rename has to re-derive —
+ # _reenrich_renamed_audio_entries exists for precisely that. The
+ # raw tag is not rename-sensitive, so the tag is what is stored and
+ # the chain still runs live on top of it. The sibling-image scan is
+ # the other one: it reads the folder, not the file, so a cover
+ # dropped in afterwards must still be found.
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, 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
+ cached = await self._media_cache.get_audio_meta(entry.id)
+ cover_settled = bool(cached_thumb_hash) or bool(cached and cached["cover_seen"])
+ if cached and cover_settled:
+ tags = {k: cached[k] for k in ("title", "artist", "album", "track_no")
+ if cached[k] is not None}
+ duration = cached["duration"]
+ cover = None if cached_thumb_hash else await asyncio.to_thread(
+ _read_sibling_cover, file_path)
+ else:
+ try:
+ tags, duration, cover = await asyncio.wait_for(
+ asyncio.to_thread(
+ _read_tags_and_cover, file_path, skip_cover=bool(cached_thumb_hash)),
+ timeout=READ_TIMEOUT_SECS)
+ except Exception as e:
+ # Not cached. A file that genuinely carries no tags reads
+ # fine and returns an empty dict, which is an answer worth
+ # keeping; this is a drive that did not answer, and writing
+ # "says nothing" for it would make one bad read permanent.
+ log.warning("Tag read failed for %s: %s", file_path, e)
+ tags, duration, cover = {}, None, None
+ else:
+ # `cover_seen` is false when the scan was skipped, so a file
+ # whose cover was cached and has since been evicted is
+ # looked at again rather than left without one for good.
+ await self._media_cache.put_audio_meta(
+ entry.id, tags, int(duration) if duration else None,
+ cover_seen=not cached_thumb_hash)
if duration:
fields["duration"] = int(duration)