From 941d1a135dd7b03834576855e8e9fdaa24c4e406 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 17:12:36 +0200 Subject: feat(node): Music app node-side — indexing, MusicBrainz enrichment, protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the node half of docs/musicbay.md against MNP 0.8: - IndexEntry gains artist/album/track_no (reuses duration/thumb_hash/ display_title, already generic). New musicbrainz_config/_enabled and music_meta_req/_resp message pairs, mirroring the TMDB shape. - title_parse.parse_track_filename: track-number-prefix + title parsing, fallback-only (embedded tags are the primary source, unlike Videos). - indexer.enrich_audio.AudioEnricher: mutagen-based tag/embedded-cover extraction through its own bounded pool (asyncio.to_thread, no subprocess — no ffmpeg-shaped deadlock risk). Gated on "music" in a group's enabled_apps rather than a video_root-style scoped folder. - musicbrainz.py: MusicBrainzClient — no API key (unlike TMDB), just a self-imposed ~1 req/s pace and a configurable, non-default User-Agent contact string; inert (no calls at all) when no contact is configured, never sends an unidentified client. - media_cache.py: file_mbid/mbid_meta tables alongside the existing TMDB ones, cover art reusing the thumbs table via a synthetic musicbrainz:{mbid} id, pruned on file deletion. - roster.py/ops.py/webrtc_server.py: musicbrainz_contact (node-wide) and musicbrainz_enabled (per-group, from the start) as signed operator settings, ALLOWED_APPS gains "music", _do_music_meta_request resolves and caches a release-level MusicBrainz match per (artist, album). - daemon.py: AudioEnricher/MusicBrainzClient wired alongside the video ones; a group's existing library is swept when "music" is newly enabled (no video_root equivalent — see musicbay.md §2.1). 41 new tests (musicbrainz.py against a mocked transport, admin-op policy for both new settings, media_cache round-trip/pruning, enrich_audio end-to-end against real ffmpeg-generated MP3s). Full suite (common + node + hub): 1116 passed, no regressions. Client-side (music-app.js, persistent player bar) not started yet. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy --- .../src/meshbay_node/indexer/enrich_audio.py | 197 +++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py (limited to 'packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py') 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) -- cgit v1.2.3