summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 17:12:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 17:12:36 +0200
commit941d1a135dd7b03834576855e8e9fdaa24c4e406 (patch)
tree5d7c27d45a3f1e77320e089f6a4522b8383cbd45 /packages/meshbay-node/src/meshbay_node
parent16bc07acf053d7d14f8182f5523da1d179154a15 (diff)
downloadmeshbay-941d1a135dd7b03834576855e8e9fdaa24c4e406.tar.gz
feat(node): Music app node-side — indexing, MusicBrainz enrichment, protocol
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py110
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py197
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py41
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py77
-rw-r--r--packages/meshbay-node/src/meshbay_node/musicbrainz.py168
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py42
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py33
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py218
8 files changed, 867 insertions, 19 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 7691467..913ad68 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -47,8 +47,10 @@ from meshbay_node.roots import RootSet, RootError, entry_abs_path
from meshbay_node.hub_client import HubClient, HubConfig
from meshbay_node.indexer import DirectoryIndexer, IndexCache, GroupIndex
from meshbay_node.indexer.enrich import Enricher
+from meshbay_node.indexer.enrich_audio import AudioEnricher
from meshbay_node.media_cache import MediaCache
from meshbay_node.tmdb import TmdbClient
+from meshbay_node.musicbrainz import MusicBrainzClient
from meshbay_node.keystore import create_keystore, load_keystore, load_or_create_keystore
from meshbay_node.roster import Roster
from meshbay_node.transport import (
@@ -144,11 +146,15 @@ class NodeDaemon:
self._media_cache: MediaCache | None = None
self._enricher: Enricher | None = None
self._tmdb_client: TmdbClient | None = None
+ self._audio_enricher: AudioEnricher | None = None
+ self._musicbrainz_client: MusicBrainzClient | None = None
# A file id attempted at most once per daemon run, success or
# failure — a persistently unprobeable file (corrupt, still being
# written) does not get re-queued on every coalesced broadcast. A
# restart retries everything, matching the "disposable, rebuildable"
# stance the rest of this cache takes (docs/mediacenter.md §1/§2).
+ # Shared across the video and audio enrichment paths — content-
+ # addressed ids never collide between the two.
self._enriched_attempted: set[str] = set()
self._roster: Roster | None = None
self._indexers: list[DirectoryIndexer] = []
@@ -340,6 +346,10 @@ class NodeDaemon:
# shape as video_root above.
"tmdb_enabled": await self._roster.tmdb_enabled(
group_cfg.id) if self._roster else True,
+ # Music app equivalent of tmdb_enabled — per-group from
+ # the start (docs/musicbay.md §6).
+ "musicbrainz_enabled": await self._roster.musicbrainz_enabled(
+ group_cfg.id) if self._roster else True,
}
if not groups_ctx:
@@ -380,6 +390,15 @@ class NodeDaemon:
tmdb_token, tmdb_language = await self._roster.tmdb_config()
self._state["tmdb_token_customized"] = bool(tmdb_token)
self._state["tmdb_language"] = tmdb_language or ""
+
+ # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own
+ # enricher (mutagen, not ffmpeg) and its own MusicBrainz client.
+ # No token to read here (§3.1): only a contact string, and unset
+ # simply means the client makes no calls (musicbrainz.py).
+ self._audio_enricher = AudioEnricher(self._media_cache)
+ self._musicbrainz_client = MusicBrainzClient(roster=self._roster)
+ musicbrainz_contact = await self._roster.musicbrainz_contact()
+ self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact)
log.info("Media cache opened: %s", media_cache_db)
# 5. Denylist
@@ -411,6 +430,7 @@ class NodeDaemon:
self._webrtc._ctx["bundle_store"] = self._bundle_store
self._webrtc._ctx["media_cache"] = self._media_cache
self._webrtc._ctx["tmdb_client"] = self._tmdb_client
+ self._webrtc._ctx["musicbrainz_client"] = self._musicbrainz_client
self._webrtc._ctx["sk_x25519_raw"] = sk_x_raw
self._webrtc._ctx["pk_x25519_raw"] = pk_x_raw
self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64
@@ -521,6 +541,7 @@ class NodeDaemon:
self._state["hub"] = hub
self._state["reload_fn"] = self._reload_config
self._state["enrich_video_root_fn"] = self._enrich_video_root_now
+ self._state["enrich_music_now_fn"] = self._enrich_music_now
# Rotating a key has to reach every transport holding a copy of it,
# and clearing the denylist has to reach the one the handshake
# consults — so both are published rather than reachable only
@@ -732,6 +753,9 @@ class NodeDaemon:
"tmdb_enabled": (
await self._roster.tmdb_enabled(group_cfg.id)
if self._roster else True),
+ "musicbrainz_enabled": (
+ await self._roster.musicbrainz_enabled(group_cfg.id)
+ if self._roster else True),
"chat_store": store,
}
groups_ctx[group_cfg.id] = new_ctx
@@ -964,6 +988,12 @@ class NodeDaemon:
# INDEX_DELTA update (_on_enriched below).
new_entries = delta.additions if delta is not None else list(idx.entries)
asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries))
+ # Music app (docs/musicbay.md §6): same shape, gated on the group's
+ # enabled_apps rather than a root (Music has no video_root analogue —
+ # see musicbay.md §2.1's note on why that scoping wasn't carried
+ # over). Tag/cover extraction is local and cheap either way; the gate
+ # exists to not do it at all for a group that never turned Music on.
+ asyncio.ensure_future(self._enrich_new_audio_entries(indexer, new_entries))
# A rename/move changes the very filename (or season folder) that
# §3.3/§3.4's title-parse read display_title/season/episode from,
@@ -976,11 +1006,15 @@ class NodeDaemon:
if delta is not None and delta.updates and previous is not None:
asyncio.ensure_future(
self._reenrich_renamed_video_entries(indexer, delta.updates, previous))
+ asyncio.ensure_future(
+ self._reenrich_renamed_audio_entries(indexer, delta.updates, previous))
- # Videos app: a file that leaves the index also loses its thumbnail
- # and file->tmdb mapping — the "real deletion obligation" docs/
- # mediacenter.md §2/§8 calls out explicitly rather than leaving
- # implicit. tmdb_meta rows are left alone (§2: shared across files).
+ # Videos/Music apps: a file that leaves the index also loses its
+ # thumbnail/cover and file->tmdb/file->mbid mapping — the "real
+ # deletion obligation" docs/mediacenter.md §2/§8 calls out
+ # explicitly rather than leaving implicit (docs/musicbay.md §6
+ # follows the same rule). tmdb_meta/mbid_meta rows are left alone
+ # (§2: shared across files).
if delta is not None and delta.deletions and self._media_cache:
for file_id in delta.deletions:
asyncio.ensure_future(self._media_cache.prune_file(file_id))
@@ -1110,6 +1144,71 @@ class NodeDaemon:
self._enriched_attempted.discard(entry.id)
await self._enrich_new_video_entries(indexer, updates)
+ async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
+ """
+ Music app (docs/musicbay.md §2.1, §6): fire (never await further)
+ tag/cover enrichment for unattempted audio entries, gated on the
+ group having "music" in its enabled_apps — there is no video_root
+ analogue for Music (musicbay.md §2.1 deliberately didn't add one:
+ tag reads are free/local, unlike ffprobe+ffmpeg thumbnailing, so the
+ cost this gate protects against is smaller, and most personal MP3
+ libraries want their whole shared tree available rather than one
+ scoped subfolder). `_enriched_attempted` is shared with the video
+ path — content-addressed ids never collide across the two.
+ """
+ if not self._audio_enricher or not self._roster:
+ return
+ enabled_apps = await self._roster.enabled_apps(indexer.group_id)
+ if "music" not in enabled_apps:
+ return
+ for entry in entries:
+ if entry.type != "audio" or entry.id in self._enriched_attempted:
+ continue
+ file_path = entry_abs_path(indexer.roots, entry)
+ if not file_path or not file_path.exists():
+ continue
+ self._enriched_attempted.add(entry.id)
+
+ async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
+ await self._on_enriched(_indexer, file_id, fields)
+
+ self._audio_enricher.spawn(entry, file_path, on_done)
+
+ async def _enrich_music_now(self, group_id: str) -> None:
+ """
+ Music app: sweep a group's existing index right after "music" is
+ added to its enabled_apps (ops.set_enabled_apps) — the ordinary path
+ above only ever looks at entries new since the last broadcast, so a
+ library that was already sitting there before Music was turned on
+ would otherwise never get enriched. Mirrors
+ `_enrich_video_root_now`, triggered from a different setting because
+ Music has no root of its own to key off.
+ """
+ indexer = self._state.get("indexers", {}).get(group_id)
+ if not indexer:
+ return
+ await self._enrich_new_audio_entries(indexer, list(indexer.index.entries))
+
+ async def _reenrich_renamed_audio_entries(
+ self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
+ ) -> None:
+ """
+ Music app equivalent of `_reenrich_renamed_video_entries` — a rename
+ can change the filename-parse fallback (title/track_no) even though
+ embedded tags, when present, are unaffected. Re-running the whole
+ pass on a rename is redundant work for a tagged file and a real fix
+ for an untagged one, and renames are rare enough not to need a
+ cheaper, tags-only special case.
+ """
+ for entry in updates:
+ if entry.type != "audio":
+ continue
+ old = previous.get_entry(entry.id)
+ if old is None or (old.name == entry.name and old.path == entry.path):
+ continue
+ self._enriched_attempted.discard(entry.id)
+ await self._enrich_new_audio_entries(indexer, updates)
+
async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None:
"""
Merge enrichment fields into the live index and re-trigger a
@@ -1174,6 +1273,9 @@ class NodeDaemon:
if self._tmdb_client:
await self._tmdb_client.close()
+ if self._musicbrainz_client:
+ await self._musicbrainz_client.close()
+
if self._media_cache:
await self._media_cache.close()
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.
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 6daae3f..47032b8 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -1,11 +1,17 @@
"""
-MeshBay Node — TMDB metadata and thumbnail cache for the Videos group app.
+MeshBay Node — TMDB/MusicBrainz metadata and thumbnail cache, shared by the
+Videos and Music group apps.
Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as
-`tmdb_enabled`/`tmdb_api_token` living in `group_settings` under the
-`group_id=""` sentinel (docs/mediacenter.md §5.5): TMDB is one operator's
-budget and credential, and a thumbnail is the same bytes regardless of which
-group happens to share the file.
+`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`/
+`musicbrainz_contact`, docs/musicbay.md §6) living in `group_settings` under
+the `group_id=""` sentinel (docs/mediacenter.md §5.5): the credential/budget
+is one operator's, and a thumbnail or cover image is the same bytes
+regardless of which group happens to share the file. The `file_mbid`/
+`mbid_meta` tables below are the Music app's equivalent of `file_tmdb`/
+`tmdb_meta`, sharing the same `thumbs` table for cover art (a MusicBrainz
+release's cover is cached under a synthetic `musicbrainz:{mbid}` file_id,
+the same trick `_fetch_and_cache_poster` uses for a TMDB poster_path).
Disposable and rebuildable, like the rest of the file index (§1, §2) — never
a second identity for a file. Every row here is keyed off a value the node
@@ -48,15 +54,28 @@ CREATE TABLE IF NOT EXISTS season_meta (
fetched_at REAL NOT NULL,
PRIMARY KEY (tmdb_id, season)
);
+CREATE TABLE IF NOT EXISTS file_mbid (
+ file_id TEXT PRIMARY KEY,
+ mbid TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS mbid_meta (
+ mbid TEXT PRIMARY KEY,
+ json TEXT NOT NULL,
+ fetched_at REAL NOT NULL
+);
"""
# TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not
# need re-checking on this schedule, only the metadata blob (§5.4, V3).
TMDB_META_TTL_SECS = 30 * 86400
+# Same default as TMDB (docs/musicbay.md §6) — MusicBrainz release data is
+# not expected to drift faster; revisit if that proves wrong in practice.
+MUSICBRAINZ_META_TTL_SECS = 30 * 86400
+
class MediaCache:
- """Async SQLite cache for TMDB lookups and generated thumbnails."""
+ """Async SQLite cache for TMDB/MusicBrainz lookups and generated thumbnails/cover art."""
def __init__(self, db_path: Path):
self._db_path = db_path
@@ -144,6 +163,44 @@ class MediaCache:
)
await self._db.commit()
+ # ── file -> musicbrainz release id mapping (Music app) ───────────────────
+
+ async def get_file_mbid(self, file_id: str) -> str | None:
+ async with self._db.execute(
+ "SELECT mbid FROM file_mbid WHERE file_id = ?", (file_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ return row[0] if row else None
+
+ async def set_file_mbid(self, file_id: str, mbid: str) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO file_mbid (file_id, mbid) VALUES (?, ?)",
+ (file_id, mbid),
+ )
+ await self._db.commit()
+
+ # ── musicbrainz release id -> metadata json (Music app) ───────────────────
+
+ async def get_mbid_meta(self, mbid: str) -> dict | None:
+ """Returns None on a miss or on an entry older than MUSICBRAINZ_META_TTL_SECS."""
+ async with self._db.execute(
+ "SELECT json, fetched_at FROM mbid_meta WHERE mbid = ?", (mbid,),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ raw_json, fetched_at = row
+ if time.time() - fetched_at > MUSICBRAINZ_META_TTL_SECS:
+ return None
+ return json.loads(raw_json)
+
+ async def set_mbid_meta(self, mbid: str, meta: dict) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO mbid_meta (mbid, json, fetched_at) VALUES (?, ?, ?)",
+ (mbid, json.dumps(meta), time.time()),
+ )
+ await self._db.commit()
+
# ── thumbnails ────────────────────────────────────────────────────────────
async def get_thumb(self, thumb_hash: str) -> bytes | None:
@@ -179,10 +236,12 @@ class MediaCache:
async def prune_file(self, file_id: str) -> None:
"""
Called when a file leaves the index (deletion, unshared root). Removes
- its thumbnail and its file->tmdb mapping. `tmdb_meta` rows are left
- alone — they're keyed by tmdb_id, not file_id, and other files (other
- episodes of the same show) may still reference the same entry.
+ its thumbnail and its file->tmdb/file->mbid mappings. `tmdb_meta`/
+ `mbid_meta` rows are left alone — they're keyed by tmdb_id/mbid, not
+ file_id, and other files (other episodes of the same show, other
+ tracks of the same release) may still reference the same entry.
"""
await self._db.execute("DELETE FROM thumbs WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,))
+ await self._db.execute("DELETE FROM file_mbid WHERE file_id = ?", (file_id,))
await self._db.commit()
diff --git a/packages/meshbay-node/src/meshbay_node/musicbrainz.py b/packages/meshbay-node/src/meshbay_node/musicbrainz.py
new file mode 100644
index 0000000..f3d9b4f
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/musicbrainz.py
@@ -0,0 +1,168 @@
+"""
+MusicBrainz (musicbrainz.org) + Cover Art Archive (coverartarchive.org)
+client for the Music group app.
+
+Called only by the node, never by a client (docs/musicbay.md §3): the node
+makes the one lookup per unique release, shared by every member, and self-
+paces against MusicBrainz's shared rate limit rather than letting several
+members' tile requests multiply it.
+
+Unlike tmdb.py, there is **no API key here** — MusicBrainz's read-only
+search/lookup endpoints and Cover Art Archive need no credential and no
+account, only:
+
+ 1. a descriptive `User-Agent` (application name/version + a contact),
+ which MusicBrainz's usage policy asks for — not a secret;
+ 2. a self-imposed ~1 request/second pace, since that's a courtesy limit
+ enforced by convention (and by MusicBrainz throttling abusive clients),
+ not a token bucket handed out by the server.
+
+Contact resolution order (docs/musicbay.md §3.2), same shape as tmdb.py's
+token resolution:
+
+ 1. an operator-supplied contact string (roster.py group_settings,
+ group_id="")
+ 2. the MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT environment variable
+ 3. none — MusicBrainz lookups are inert (callers get an empty result,
+ never an exception). Deliberately **not** falling back to a generic
+ User-Agent: sending an unidentified client to a service that polices
+ its User-Agent policy risks the node's IP being blocked, which is a
+ worse failure than "no music metadata yet".
+
+No literal contact value lives in this file, for the same reason tmdb.py
+carries no literal token — see docs/musicbay.md §3.2 on why a personal
+address must never land in source control.
+"""
+
+import asyncio
+import difflib
+import logging
+import os
+import re
+import time
+
+import httpx
+
+from meshbay_node.roster import Roster
+
+log = logging.getLogger(__name__)
+
+_BASE_URL = "https://musicbrainz.org/ws/2/"
+_COVER_ART_BASE = "https://coverartarchive.org/release/"
+_TIMEOUT = 10.0
+_DEFAULT_CONTACT_ENV = "MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT"
+_APP_NAME = "MeshBay-Node"
+
+# MusicBrainz's own stated courtesy limit for unauthenticated use. Enforced
+# here, not requested from the server — there is nothing to request.
+_MIN_INTERVAL_SECS = 1.0
+
+
+def _normalize(s: str) -> str:
+ s = s.lower()
+ s = re.sub(r"[^a-z0-9àâäéèêëïîôöùûüçñ ]+", " ", s)
+ return re.sub(r"\s+", " ", s).strip()
+
+
+def _best_match(query: str, results: list[dict], key: str) -> tuple[dict | None, float]:
+ """
+ Same "trust the search's own ranking" shape as tmdb.py's `_best_match`
+ (§3.3 of mediacenter.md found a locally-recomputed re-rank pick a
+ coincidentally closer-looking wrong result once — no reason to expect
+ MusicBrainz's own scored search to fare differently under the same
+ treatment). MusicBrainz already returns results ordered by its own
+ `score`; only the top one is considered, and the similarity ratio is a
+ confidence signal for the caller's fallback decision, not a re-rank.
+ """
+ if not results:
+ return None, 0.0
+ top = results[0]
+ val = top.get(key)
+ ratio = (difflib.SequenceMatcher(None, _normalize(query), _normalize(str(val))).ratio()
+ if val else 0.0)
+ return top, ratio
+
+
+class MusicBrainzClient:
+ """One instance per node, holding the resolved contact and an httpx client."""
+
+ def __init__(self, roster: Roster | None = None,
+ transport: httpx.AsyncBaseTransport | None = None):
+ self._roster = roster
+ # `transport` is a test-only seam (httpx.MockTransport) — production
+ # callers never pass it.
+ self._client = httpx.AsyncClient(timeout=_TIMEOUT, transport=transport)
+ self._rate_lock = asyncio.Lock()
+ self._last_request_monotonic: float | None = None
+
+ async def close(self) -> None:
+ await self._client.aclose()
+
+ async def _resolve_contact(self) -> str | None:
+ if self._roster is not None:
+ contact = await self._roster.musicbrainz_contact()
+ else:
+ contact = None
+ return contact or os.environ.get(_DEFAULT_CONTACT_ENV) or None
+
+ async def _pace(self) -> None:
+ """Serializes every call through this client to >= _MIN_INTERVAL_SECS apart."""
+ async with self._rate_lock:
+ now = time.monotonic()
+ if self._last_request_monotonic is not None:
+ wait = _MIN_INTERVAL_SECS - (now - self._last_request_monotonic)
+ if wait > 0:
+ await asyncio.sleep(wait)
+ self._last_request_monotonic = time.monotonic()
+
+ async def _get(self, url: str, params: dict) -> dict | None:
+ contact = await self._resolve_contact()
+ if not contact:
+ return None
+ await self._pace()
+ try:
+ resp = await self._client.get(
+ url, params={**params, "fmt": "json"},
+ headers={"User-Agent": f"{_APP_NAME}/1.0 ( {contact} )"},
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except httpx.HTTPError as e:
+ log.warning("MusicBrainz request failed (%s): %s", url, e)
+ return None
+
+ async def search_release(self, artist: str, album: str) -> tuple[dict | None, float]:
+ """Top MusicBrainz release match for an (artist, album) pair."""
+ query = f'artist:"{artist}" AND release:"{album}"'
+ data = await self._get(_BASE_URL + "release", {"query": query})
+ results = (data or {}).get("releases", [])
+ return _best_match(album, results, "title")
+
+ async def release_details(self, mbid: str) -> dict | None:
+ """Full release details, including recordings (tracklist)."""
+ return await self._get(_BASE_URL + f"release/{mbid}", {"inc": "recordings+artist-credits"})
+
+ async def fetch_cover_art(self, mbid: str) -> bytes | None:
+ """
+ The release's front cover, or None if Cover Art Archive has nothing
+ for it (a real, common outcome — most releases have no scan). Paced
+ the same as a metadata call: Cover Art Archive is served from the
+ same courtesy-limit policy.
+ """
+ contact = await self._resolve_contact()
+ if not contact:
+ return None
+ await self._pace()
+ try:
+ resp = await self._client.get(
+ f"{_COVER_ART_BASE}{mbid}/front-500",
+ headers={"User-Agent": f"{_APP_NAME}/1.0 ( {contact} )"},
+ follow_redirects=True,
+ )
+ if resp.status_code == 404:
+ return None
+ resp.raise_for_status()
+ return resp.content
+ except httpx.HTTPError as e:
+ log.warning("Cover Art Archive fetch failed (%s): %s", mbid, e)
+ return None
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index a232ba2..d439ec3 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -728,6 +728,14 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
set_by=state.get("node_user_id", ""))
ctx["enabled_apps"] = apps
log.info("Enabled apps for group %s: %s", group_id[:8], ",".join(sorted(apps)))
+ if "music" in apps:
+ # Music has no root of its own to key a sweep off (unlike
+ # set_video_root's enrich_video_root_fn) — turning the app on at all
+ # is the trigger, mirroring that same "sweep what's already there"
+ # need (docs/musicbay.md §2.1, daemon._enrich_music_now).
+ enrich_fn = state.get("enrich_music_now_fn")
+ if enrich_fn:
+ asyncio.ensure_future(enrich_fn(group_id))
return {"apps": apps, "group_id": group_id}
@@ -781,6 +789,40 @@ async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict:
return {"enabled": enabled, "group_id": group_id}
+# ── MusicBrainz config (Music app) ───────────────────────────────────────────
+
+async def set_musicbrainz_config(state: dict, contact: str | None = None) -> dict:
+ """
+ The node-wide User-Agent contact string MusicBrainz's usage policy asks
+ for (docs/musicbay.md §3.2). Unlike set_tmdb_config there is no token to
+ manage — MusicBrainz's read endpoints need no credential — so this is a
+ single field. `contact=""` explicitly clears a previously-set contact
+ (reverting to "no calls at all", never a generic/unidentified
+ User-Agent); `contact=None` leaves whatever was there unchanged.
+ """
+ roster = _roster(state)
+ await roster.set_musicbrainz_contact(contact, set_by=state.get("node_user_id", ""))
+ if contact is not None:
+ state["musicbrainz_contact_configured"] = bool(contact)
+ log.info("MusicBrainz config: contact_configured=%s", bool(contact))
+ return {"contact_configured": state.get("musicbrainz_contact_configured", False)}
+
+
+async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) -> dict:
+ """
+ Whether MusicBrainz lookups run for this group at all
+ (docs/musicbay.md §6) — per-group from the start, same reasoning as
+ set_tmdb_enabled: a real media-library group and a test/demo group on
+ one node need not share the decision to make outbound requests.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_musicbrainz_enabled(group_id, enabled, set_by=state.get("node_user_id", ""))
+ ctx["musicbrainz_enabled"] = enabled
+ log.info("MusicBrainz enabled for group %s: %s", group_id[:8], enabled)
+ return {"enabled": enabled, "group_id": group_id}
+
+
async def set_video_root(state: dict, group_id: str, path: str) -> dict:
"""
Which folder (possibly a subfolder of a shared root) is the Videos app's
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index b4efb7c..24424ab 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -665,6 +665,39 @@ class Roster:
await self.set_setting(group_id, self.SETTING_TMDB_ENABLED,
"1" if enabled else "0", set_by)
+ # The Music app's MusicBrainz contact string (docs/musicbay.md §3.2) —
+ # node-wide, under the same group_id="" sentinel as the TMDB token/
+ # language above, for the identical reason: one operator's User-Agent
+ # identity, not a per-group concern. Unlike TMDB there is no secret to
+ # store — this is the contact MusicBrainz's usage policy asks a client
+ # to identify itself with, not a credential. Unset means "no contact
+ # configured", which musicbrainz.py treats as "make no calls at all"
+ # (docs/musicbay.md §3.1) rather than sending an unidentified client.
+ SETTING_MUSICBRAINZ_CONTACT = "musicbrainz_contact"
+
+ async def musicbrainz_contact(self) -> str | None:
+ return await self.get_setting(
+ self.NODE_WIDE_GROUP_ID, self.SETTING_MUSICBRAINZ_CONTACT) or None
+
+ async def set_musicbrainz_contact(self, contact: str | None = None, set_by: str = "") -> None:
+ """`contact=""` clears it; `contact=None` leaves it unchanged (tmdb_config's shape)."""
+ if contact is not None:
+ await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_MUSICBRAINZ_CONTACT,
+ contact, set_by)
+
+ # Whether MusicBrainz lookups run for this group at all — per-group from
+ # the start (unlike tmdb_enabled, which started node-wide and moved
+ # per-group later once the lesson was already learned). Unset means on,
+ # same "absent means the old behaviour" discipline as everything else.
+ SETTING_MUSICBRAINZ_ENABLED = "musicbrainz_enabled"
+
+ async def musicbrainz_enabled(self, group_id: str) -> bool:
+ return (await self.get_setting(group_id, self.SETTING_MUSICBRAINZ_ENABLED, "1")) != "0"
+
+ async def set_musicbrainz_enabled(self, group_id: str, enabled: bool, set_by: str = "") -> None:
+ await self.set_setting(group_id, self.SETTING_MUSICBRAINZ_ENABLED,
+ "1" if enabled else "0", set_by)
+
# How often the indexer's reconciliation backstop runs, and how long it
# waits after the last change on a file before hashing it. Unset means
# the indexer's own defaults — an existing group's behaviour must not
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index c35e3ea..5da1b7e 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -69,6 +69,8 @@ from meshbay_common.adminop import (
OP_TMDB_ENABLED,
OP_VIDEO_ROOT,
OP_TMDB_OVERRIDE,
+ OP_MUSICBRAINZ_CONFIG,
+ OP_MUSICBRAINZ_ENABLED,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
OP_GROUP_ATTACH,
@@ -391,6 +393,12 @@ class WebRTCPeerSession:
self._spawn(self._do_tmdb_search_request(msg))
elif mtype == MNP.TMDB_OVERRIDE:
self._do_tmdb_override(msg)
+ elif mtype == MNP.MUSICBRAINZ_CONFIG:
+ self._do_musicbrainz_config(msg)
+ elif mtype == MNP.MUSICBRAINZ_ENABLED:
+ self._do_musicbrainz_enabled(msg)
+ elif mtype == MNP.MUSIC_META_REQ:
+ self._spawn(self._do_music_meta_request(msg))
elif mtype == MNP.MEMBER_UNPIN:
self._do_member_unpin(msg)
elif mtype == MNP.GEK_ROTATE:
@@ -656,6 +664,12 @@ class WebRTCPeerSession:
self._ctx.get("daemon_state", {}).get("tmdb_token_customized", False)),
"tmdb_language": str(
self._ctx.get("daemon_state", {}).get("tmdb_language") or ""),
+ # Music app (docs/musicbay.md §6) — same shape as the TMDB
+ # fields above. No language field: MusicBrainz search doesn't
+ # take one the way TMDB does.
+ "musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)),
+ "musicbrainz_contact_configured": bool(
+ self._ctx.get("daemon_state", {}).get("musicbrainz_contact_configured", False)),
# So a client that connects mid-scan shows the indexing state
# immediately, instead of waiting for the next periodic
# INDEX_PROGRESS push. Never a path or filename — see
@@ -1612,13 +1626,14 @@ class WebRTCPeerSession:
except Exception:
pass
- # Every "application" a group can show. Music, Photos join this set (and
- # apps.js's registry, client-side) when they land; nothing else about
+ # Every "application" a group can show. Photos joins this set (and
+ # apps.js's registry, client-side) when it lands; nothing else about
# this handler changes. DEFAULT_APPS (roster.py) deliberately does not
- # include "video" — it is the first app with outbound third-party
- # network calls (once TMDB is on), so an operator opts a group in
- # explicitly rather than getting it for free (docs/mediacenter.md §5.6).
- ALLOWED_APPS = frozenset({"chat", "files", "video"})
+ # include "video" or "music" — both can make outbound third-party
+ # network calls (TMDB, MusicBrainz) once enabled, so an operator opts a
+ # group in explicitly rather than getting it for free
+ # (docs/mediacenter.md §5.6, docs/musicbay.md §4.4).
+ ALLOWED_APPS = frozenset({"chat", "files", "video", "music"})
def _do_apps_enabled(self, msg: dict) -> None:
"""
@@ -1822,6 +1837,97 @@ class WebRTCPeerSession:
except Exception:
pass
+ def _do_musicbrainz_config(self, msg: dict) -> None:
+ """
+ Set (or clear) the node-wide MusicBrainz User-Agent contact string
+ (docs/musicbay.md §3.2). Unlike tmdb_config there is no token field —
+ MusicBrainz's read endpoints need no credential, only a descriptive
+ client identity. Signed like tmdb_config: this changes outbound
+ third-party network traffic the node did not have before the Music
+ app (§8) — an unsigned change would let any member alter egress the
+ operator never agreed to.
+ """
+ contact = msg.get("contact")
+ if contact is not None and not isinstance(contact, str):
+ self._send({"type": "error", "detail": "Invalid 'contact'"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ # Not a secret (unlike tmdb_config's token) — a contact address is
+ # meant to be visible to whoever receives it (MusicBrainz), but it is
+ # still not committed to the audit log's subject line as free text:
+ # the same "yes/no configured" shape as tmdb_config keeps the audit
+ # log itself free of a personal address.
+ subject = f"contact_configured={'yes' if contact else 'no'}"
+ self._issue_admin_challenge(
+ OP_MUSICBRAINZ_CONFIG, subject,
+ payload={"contact": contact}, group_id="")
+
+ async def _admin_exec_musicbrainz_config(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"musicbrainz_config:{pending['subject']}")
+ return
+ p = pending.get("payload") or {}
+ try:
+ result = await self._run_op(ops.set_musicbrainz_config, p.get("contact"))
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("musicbrainz_config", pending["subject"])
+
+ notice = {
+ "type": MNP.MUSICBRAINZ_CONFIG_ACK, "v": MNP_VERSION,
+ "contact_configured": result["contact_configured"],
+ }
+ for gctx in self._ctx.get("groups", {}).values():
+ for session in list(gctx.get("_peers", {}).values()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
+ def _do_musicbrainz_enabled(self, msg: dict) -> None:
+ """
+ Whether MusicBrainz lookups run for this group at all. Per-group
+ from the start (docs/musicbay.md §3.2/§6) — signed like
+ tmdb_enabled/video_root: it decides whether this group's members'
+ Music tab ever makes outbound MusicBrainz traffic.
+ """
+ enabled = msg.get("enabled")
+ if not isinstance(enabled, bool):
+ self._send({"type": "error", "detail": "Missing or invalid 'enabled'"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_MUSICBRAINZ_ENABLED, str(enabled))
+
+ async def _admin_exec_musicbrainz_enabled(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ enabled = pending["subject"] == "True"
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"musicbrainz_enabled:{pending['subject']}")
+ return
+ try:
+ await self._run_op(ops.set_musicbrainz_enabled, self._group_id or "", enabled)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("musicbrainz_enabled", pending["subject"])
+
+ notice = {"type": MNP.MUSICBRAINZ_ENABLED_ACK, "v": MNP_VERSION, "enabled": enabled}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
# Reconcile's backstop and the watchdog debounce (indexer.py
# DirectoryIndexer) — how hard the node works on the operator's own
# disk, not a member-facing permission. Signed for the same reason as
@@ -2427,6 +2533,100 @@ class WebRTCPeerSession:
await media_cache.put_thumb(thumb_hash, synthetic_id, content)
return thumb_hash
+ @staticmethod
+ async def _fetch_and_cache_cover(media_cache, musicbrainz_client, mbid: str | None) -> str | None:
+ """
+ Music app equivalent of `_fetch_and_cache_poster` — a release's
+ Cover Art Archive image, fetched once per mbid and cached under its
+ own blake3, addressed the same synthetic-id trick
+ (`musicbrainz:{mbid}`) so a second track of the same album never
+ re-downloads it. Most releases have no scan at all; that's a normal
+ outcome (None), not an error.
+ """
+ if not mbid:
+ return None
+ synthetic_id = f"musicbrainz:{mbid}"
+ cached_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id)
+ if cached_hash is not None:
+ return cached_hash
+ content = await musicbrainz_client.fetch_cover_art(mbid)
+ if content is None:
+ return None
+ thumb_hash = blake3.blake3(content).hexdigest()
+ await media_cache.put_thumb(thumb_hash, synthetic_id, content)
+ return thumb_hash
+
+ async def _do_music_meta_request(self, msg: dict) -> None:
+ """
+ docs/musicbay.md §4.3: MusicBrainz metadata for one path, resolved
+ from the group's index. Album-level (release), the direct analogue
+ of Videos' show-level TMDB caching: one search per (artist, album)
+ pair serves cover art and canonical naming to every track of the
+ same release, keyed off the `artist`/`album` fields enrich_audio.py
+ already populated at index time (from tags, or the filename-parse
+ fallback) — never re-parsed here.
+ """
+ path = msg.get("path")
+ log.debug("music_meta_req path=%r", path)
+ if not isinstance(path, str) or not path:
+ self._send({"type": "error", "detail": "Missing path"})
+ return
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ if not entry:
+ self._send({"type": "error", "detail": "File not found"})
+ return
+
+ media_cache = self._ctx.get("media_cache")
+ musicbrainz_client = self._ctx.get("musicbrainz_client")
+ # Same silent, no-error degradation as _do_media_meta_request: no
+ # client configured, MusicBrainz off for this group, or nothing to
+ # search with (no artist/album — an untagged, unparseable file) all
+ # look identical to the caller, which already has to handle "no
+ # match" as the ordinary case in flat mode.
+ if (media_cache is None or musicbrainz_client is None
+ or not ctx.get("musicbrainz_enabled", True)
+ or not entry.artist or not entry.album):
+ self._send({"type": MNP.MUSIC_META_RESP, "v": MNP_VERSION,
+ "path": path, "confidence": 0})
+ return
+
+ mbid = await media_cache.get_file_mbid(entry.id)
+ meta = await media_cache.get_mbid_meta(mbid) if mbid else None
+
+ if meta is None:
+ result, ratio = await musicbrainz_client.search_release(entry.artist, entry.album)
+ if result is None or ratio < 0.6:
+ self._send({"type": MNP.MUSIC_META_RESP, "v": MNP_VERSION,
+ "path": path, "confidence": 0})
+ return
+ mbid = result.get("id")
+ artist_credit = result.get("artist-credit") or []
+ meta = {
+ "artist": artist_credit[0].get("name") if artist_credit else entry.artist,
+ "album": result.get("title"),
+ "release_date": result.get("date"),
+ "confidence": ratio,
+ }
+ await media_cache.set_file_mbid(entry.id, mbid)
+ await media_cache.set_mbid_meta(mbid, meta)
+
+ cover_thumb_hash = await self._fetch_and_cache_cover(
+ media_cache, musicbrainz_client, mbid)
+ log.debug("music_meta_req path=%r: replying mbid=%s cover=%s",
+ path, mbid, cover_thumb_hash)
+
+ self._send({
+ "type": MNP.MUSIC_META_RESP, "v": MNP_VERSION, "path": path,
+ "mbid": mbid,
+ "artist": meta.get("artist"),
+ "album": meta.get("album"),
+ "title": entry.display_title,
+ "release_date": meta.get("release_date"),
+ "cover_thumb_hash": cover_thumb_hash,
+ "confidence": meta.get("confidence", 1.0),
+ })
+
async def _do_media_meta_request(self, msg: dict) -> None:
"""
docs/mediacenter.md §5.4: TMDB metadata for one path, resolved from
@@ -3264,6 +3464,12 @@ class WebRTCPeerSession:
elif pending["op"] == OP_TMDB_OVERRIDE:
self._spawn(
self._admin_exec_tmdb_override(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_MUSICBRAINZ_CONFIG:
+ self._spawn(
+ self._admin_exec_musicbrainz_config(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_MUSICBRAINZ_ENABLED:
+ self._spawn(
+ self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_ADD:
self._spawn(
self._admin_exec_root_add(pending, transcript, sig_bytes))