diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py | 197 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/title_parse.py | 41 |
2 files changed, 238 insertions, 0 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 new file mode 100644 index 0000000..17a58d6 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py @@ -0,0 +1,197 @@ +""" +Index-time enrichment for the Music group app: embedded tag/cover +extraction (mutagen) and filename-parse fallback for a newly-added audio +IndexEntry (docs/musicbay.md §2.1, §6). + +Runs through its own small bounded worker pool, the same discipline as the +Videos app's `enrich.py` — separate from any other pool, never blocking a +scan or the watchdog. Unlike `enrich.py`, this one shells out to nothing: +`mutagen` is pure Python, synchronous I/O only, so there is no subprocess to +spawn, no pipe to drain, and no ffmpeg-shaped deadlock risk here at all — +the bounded pool exists to keep a large library's indexing burst bounded, +not to contain a process. Reads run via `asyncio.to_thread` so they never +block the event loop. + +MusicBrainz lookups are **not** done here. Tag/cover extraction is free and +local, so it runs for every audio file the Music app is enabled for, +regardless of whether MusicBrainz itself is turned on for the group — the +flat view (docs/musicbay.md §5.2) needs nothing more than this. MusicBrainz +is a separate, lazy, per-request enrichment (`music_meta_req`, handled in +webrtc_server.py), the same "fetched on demand, cached once" shape TMDB +already uses. +""" + +import asyncio +import logging +import re +from collections.abc import Awaitable, Callable +from pathlib import Path + +import blake3 +from meshbay_common.protocol import IndexEntry +from mutagen import File as MutagenFile + +from meshbay_node.indexer import title_parse +from meshbay_node.media_cache import MediaCache + +log = logging.getLogger(__name__) + +# Higher than the video pool's default (2): mutagen reads a few KB of tag +# data synchronously, no subprocess, no decode — cheap enough that a wider +# pool doesn't cost much and finishes a large library's initial scan sooner. +DEFAULT_MAX_CONCURRENT = 4 +READ_TIMEOUT_SECS = 10 + +_TRACK_NO_RE = re.compile(r"\d+") + + +def _extract_cover(mf) -> bytes | None: + """ + Best-effort embedded cover art across the tag formats mutagen exposes + differently: ID3 (MP3) keeps pictures as APIC frames on `.tags`, FLAC + exposes `.pictures` on the file object itself, MP4/M4A keeps a `covr` + atom on `.tags`. Returns the first picture found, or None — most of a + real library has no embedded art at all, which is not an error. + """ + tags = mf.tags + if tags is not None and hasattr(tags, "getall"): + pics = tags.getall("APIC") + if pics: + return bytes(pics[0].data) + pictures = getattr(mf, "pictures", None) + if pictures: + return bytes(pictures[0].data) + if tags is not None and hasattr(tags, "get"): + covr = tags.get("covr") + if covr: + return bytes(covr[0]) + return None + + +def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]: + """ + Synchronous — always called via asyncio.to_thread. Returns a partial + `tags` dict (only keys actually found: title/artist/album/track_no), + duration in seconds (None if unreadable), and raw cover bytes (None if + absent). Never raises for an unreadable/corrupt file — the caller falls + back to filename parsing entirely in that case. + """ + tags: dict = {} + duration: float | None = None + try: + easy = MutagenFile(str(path), easy=True) + except Exception: + easy = None + if easy is not None: + if easy.info is not None: + duration = getattr(easy.info, "length", None) + for field in ("title", "artist", "album"): + values = easy.get(field) + if values and str(values[0]).strip(): + tags[field] = str(values[0]).strip() + track_raw = easy.get("tracknumber") + if track_raw: + m = _TRACK_NO_RE.match(str(track_raw[0])) + if m: + tags["track_no"] = int(m.group()) + + cover: bytes | None = None + try: + raw = MutagenFile(str(path)) + except Exception: + raw = None + if raw is not None: + try: + cover = _extract_cover(raw) + except Exception: + cover = None + + return tags, duration, cover + + +def _artist_album_from_ancestors(file_path: Path) -> tuple[str | None, str | None]: + """ + `Artist/Album/track.mp3` is the common shape (docs/musicbay.md §2.1) — + used only to fill whatever the tags left empty. No attempt to validate + against the group's actual roots (enrich.py's ancestor walks don't + either): a flat `Artist/track.mp3` layout, or a various-artists + compilation folder, just yields a plausible-but-not-guaranteed album + name from the immediate parent and nothing further up — good enough for + a fallback, not asserted as accurate. + """ + album_folder = file_path.parent + if album_folder == album_folder.parent: + return None, None + artist_folder = album_folder.parent + album = album_folder.name or None + artist = artist_folder.name if artist_folder != artist_folder.parent else None + return artist, album + + +class AudioEnricher: + """Owns the node's bounded audio index-time enrichment pool.""" + + def __init__(self, media_cache: MediaCache, max_concurrent: int = DEFAULT_MAX_CONCURRENT): + self._media_cache = media_cache + self._sem = asyncio.Semaphore(max_concurrent) + self._tasks: set[asyncio.Task] = set() + + def spawn( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> asyncio.Task: + """ + Fire-and-forget one file's enrichment — same contract as + `enrich.Enricher.spawn`: `on_done(file_id, fields)` is awaited with + the index fields to merge once ready, never blocks the caller, and + the returned task must be held by the caller for the same reason + `WebRTCPeerSession._spawn` holds streaming tasks (a bare + `ensure_future` can be garbage-collected mid-flight). + """ + task = asyncio.ensure_future(self._run(entry, file_path, on_done)) + self._tasks.add(task) + + def _cleanup(t: asyncio.Task) -> None: + self._tasks.discard(t) + if not t.cancelled() and t.exception(): + log.error("Audio enrichment failed for %s: %s", entry.id[:12], t.exception(), + exc_info=t.exception()) + task.add_done_callback(_cleanup) + return task + + async def _run( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> None: + async with self._sem: + fields: dict = {} + try: + tags, duration, cover = await asyncio.wait_for( + asyncio.to_thread(_read_tags_and_cover, file_path), timeout=READ_TIMEOUT_SECS) + except Exception as e: + log.warning("Tag read failed for %s: %s", file_path, e) + tags, duration, cover = {}, None, None + + if duration: + fields["duration"] = int(duration) + + parsed = title_parse.parse_track_filename(entry.name) + fields["display_title"] = tags.get("title") or parsed.title or parsed.naive_title + fields["track_no"] = tags.get("track_no") if "track_no" in tags else parsed.track_no + + artist = tags.get("artist") + album = tags.get("album") + if not artist or not album: + fallback_artist, fallback_album = await asyncio.to_thread( + _artist_album_from_ancestors, file_path) + artist = artist or fallback_artist + album = album or fallback_album + fields["artist"] = artist + fields["album"] = album + + if cover: + thumb_hash = blake3.blake3(cover).hexdigest() + await self._media_cache.put_thumb(thumb_hash, entry.id, cover) + fields["thumb_hash"] = thumb_hash + + await on_done(entry.id, fields) diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py index ef522ad..6676e9b 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py @@ -146,6 +146,47 @@ def parse_movie_filename(filename: str) -> ParsedName: ) + +# ── Music app (docs/musicbay.md §2.1) ──────────────────────────────────────── +# +# Filename parsing is the *fallback* here, not the primary source (unlike +# Videos, where guessit does all the work): embedded ID3/Vorbis tags are read +# first by enrich_audio.py, and this only fills whatever a tag left empty. +# Scope is narrower than the video parser too — a track number and a title, +# nothing guessit-shaped is needed since there is no season/episode grammar +# to parse. + +# "01 - Venus As A Boy.mp3", "03. Human Behaviour.mp3", "12_Some_Title.mp3" — +# a leading track number, optionally disc-prefixed ("1-01 "), then a +# separator before the title. Capped at 3 digits so a filename that merely +# starts with a year ("1999 - Some Title.mp3") isn't misread as track 199. +_TRACK_PREFIX_RE = re.compile(r"^(?:\d+[\s._-]+)?(\d{1,3})[\s._-]+(?=\S)") + + +@dataclass +class ParsedTrack: + title: str | None + track_no: int | None + naive_title: str = "" + + +def parse_track_filename(filename: str) -> ParsedTrack: + """ + Split a leading track-number prefix from the rest of the filename and + clean up the remainder into a title. `track_no` is None when there's no + recognizable prefix — the caller (enrich_audio.py) then falls back to + the tag or leaves it unset, never guesses a number. + """ + stem = filename.rsplit(".", 1)[0] if "." in filename else filename + m = _TRACK_PREFIX_RE.match(stem) + track_no = int(m.group(1)) if m else None + rest = stem[m.end():] if m else stem + rest = re.sub(r"[._]+", " ", rest) + rest = re.sub(r"^[\s-]+", "", rest) # a leftover " - " separator + title = re.sub(r"\s+", " ", rest).strip() or None + return ParsedTrack(title=title, track_no=track_no, naive_title=naive_title(filename)) + + def parse_episode_filename(filename: str) -> ParsedName: """ Parse an episode filename. `display_title` may come back None (e.g. |