diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py | 96 |
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) |