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 --- .../meshbay-common/src/meshbay_common/__init__.py | 9 +- .../meshbay-common/src/meshbay_common/adminop.py | 9 + .../meshbay-common/src/meshbay_common/protocol.py | 13 ++ packages/meshbay-node/pyproject.toml | 1 + packages/meshbay-node/src/meshbay_node/daemon.py | 112 ++++++++++- .../src/meshbay_node/indexer/enrich_audio.py | 197 +++++++++++++++++++ .../src/meshbay_node/indexer/title_parse.py | 41 ++++ .../meshbay-node/src/meshbay_node/media_cache.py | 77 +++++++- .../meshbay-node/src/meshbay_node/musicbrainz.py | 168 ++++++++++++++++ packages/meshbay-node/src/meshbay_node/ops.py | 42 ++++ packages/meshbay-node/src/meshbay_node/roster.py | 33 ++++ .../src/meshbay_node/transport/webrtc_server.py | 218 ++++++++++++++++++++- packages/meshbay-node/tests/test_enrich_audio.py | 150 ++++++++++++++ packages/meshbay-node/tests/test_media_cache.py | 53 ++++- packages/meshbay-node/tests/test_musicbrainz.py | 163 +++++++++++++++ .../tests/test_musicbrainz_config_policy.py | 179 +++++++++++++++++ .../tests/test_musicbrainz_enabled_policy.py | 126 ++++++++++++ 17 files changed, 1568 insertions(+), 23 deletions(-) create mode 100644 packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py create mode 100644 packages/meshbay-node/src/meshbay_node/musicbrainz.py create mode 100644 packages/meshbay-node/tests/test_enrich_audio.py create mode 100644 packages/meshbay-node/tests/test_musicbrainz.py create mode 100644 packages/meshbay-node/tests/test_musicbrainz_config_policy.py create mode 100644 packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 53c1058..3ed1ed2 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -24,5 +24,12 @@ __version__ = "0.7.0" # for one, not all of them). `tmdb_config`/`tmdb_config_ack` keep their name # but now only cover the token/language, which stay node-wide. Additive: an # older client simply never sends/handles the new pair. -MNP_VERSION = "0.7" +# 0.8: added `artist`/`album`/`track_no` to `IndexEntry`, and +# `musicbrainz_config`/`_ack`, `musicbrainz_enabled`/`_ack`, +# `music_meta_req`/`_resp`, for the Music group app (docs/musicbay.md). +# Same shape as 0.5-0.7's Videos additions, and reuses `duration`/ +# `thumb_hash`/`display_title` rather than declaring new ones. Additive: +# an older client simply doesn't render the new fields or send the new +# messages. +MNP_VERSION = "0.8" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index df806a9..f0a79fe 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -83,6 +83,15 @@ OP_VIDEO_ROOT = "video_root" # (media_cache is shared, not per-viewer), so an unsigned override would let # any member vandalize another show's metadata. OP_TMDB_OVERRIDE = "tmdb_override" +# Music app (docs/musicbay.md §6) — same shape as OP_TMDB_CONFIG, minus a +# secret: MusicBrainz needs no API key, only a rate-limited, self-identifying +# client, so this only ever carries the User-Agent contact string, node-wide. +OP_MUSICBRAINZ_CONFIG = "musicbrainz_config" +# Whether the node calls MusicBrainz *at all* for this group — per-group from +# the start (unlike TMDB, which started node-wide and was split later once +# the lesson was already learned once). Signed for the same reason as +# tmdb_enabled. +OP_MUSICBRAINZ_ENABLED = "musicbrainz_enabled" OP_ROOT_ADD = "root_add" OP_ROOT_REMOVE = "root_remove" OP_GROUP_ATTACH = "group_attach" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 32dc373..3b5d428 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -104,6 +104,15 @@ class MNP: TMDB_SEARCH_RESP = "tmdb_search_resp" # node → client: candidate list (id, title, year, poster) TMDB_OVERRIDE = "tmdb_override" # operator → node: replace a show/movie's TMDB match TMDB_OVERRIDE_ACK = "tmdb_override_ack" + # Music app (docs/musicbay.md) — same shape as the TMDB pair above, minus + # a credential: MusicBrainz read lookups need no API key, only a + # rate-limited, self-identifying client (§3 there). + MUSICBRAINZ_CONFIG = "musicbrainz_config" # operator → node: contact string + MUSICBRAINZ_CONFIG_ACK = "musicbrainz_config_ack" # node → everyone: new config (no secret) + MUSICBRAINZ_ENABLED = "musicbrainz_enabled" # operator → node: enable/disable + MUSICBRAINZ_ENABLED_ACK = "musicbrainz_enabled_ack" # node → this group: new enabled state + MUSIC_META_REQ = "music_meta_req" # client → node: metadata for a path + MUSIC_META_RESP = "music_meta_resp" # node → client: metadata (or none) # Device linking. A new device files a request bound to a code it displays; # an already-pinned device of the same account approves it. Neither the hub # nor the node can produce the countersignature. @@ -160,6 +169,9 @@ class IndexEntry: display_title: str | None = None # parsed or cleaned-filename title, Videos app season: int | None = None # parsed season number, Videos app episode: int | None = None # parsed episode number, Videos app + artist: str | None = None # tag or parsed, Music app + album: str | None = None # tag or parsed, Music app + track_no: int | None = None # tag or parsed, Music app def index_entry_wire(e: IndexEntry) -> dict: @@ -178,6 +190,7 @@ def index_entry_wire(e: IndexEntry) -> dict: "width": e.width, "height": e.height, "display_title": e.display_title, "season": e.season, "episode": e.episode, + "artist": e.artist, "album": e.album, "track_no": e.track_no, } diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml index 58f6f7f..f8747d2 100644 --- a/packages/meshbay-node/pyproject.toml +++ b/packages/meshbay-node/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "aiortc>=1.9", # WebRTC DataChannel for browser P2P (Phase 9) "aiosqlite>=0.20", # async SQLite for chat, audit, bundle stores "guessit>=4.4", # filename parsing for the Videos app + "mutagen>=1.47", # ID3/Vorbis tag + embedded cover reading for the Music app ] [project.optional-dependencies] 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)) - - # 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). + asyncio.ensure_future( + self._reenrich_renamed_audio_entries(indexer, delta.updates, previous)) + + # 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)) diff --git a/packages/meshbay-node/tests/test_enrich_audio.py b/packages/meshbay-node/tests/test_enrich_audio.py new file mode 100644 index 0000000..62d0a0a --- /dev/null +++ b/packages/meshbay-node/tests/test_enrich_audio.py @@ -0,0 +1,150 @@ +"""Tests for indexer/enrich_audio.py — tag/cover extraction and the end-to-end pool.""" + +import asyncio +import shutil +import subprocess +from pathlib import Path + +import pytest + +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer.enrich_audio import ( + AudioEnricher, _artist_album_from_ancestors, _extract_cover, +) +from meshbay_node.indexer.title_parse import parse_track_filename +from meshbay_node.media_cache import MediaCache + +_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") + + +# ── pure helpers, no ffmpeg/mutagen file needed ────────────────────────────── + +def test_parse_track_filename_splits_leading_track_number(): + parsed = parse_track_filename("01 - Venus As A Boy (Edited Lp Version).mp3") + assert parsed.track_no == 1 + assert parsed.title == "Venus As A Boy (Edited Lp Version)" + + +def test_parse_track_filename_handles_dot_separated(): + parsed = parse_track_filename("03. Human Behaviour.mp3") + assert parsed.track_no == 3 + assert parsed.title == "Human Behaviour" + + +def test_parse_track_filename_handles_underscore_separated(): + parsed = parse_track_filename("12_Some_Title.mp3") + assert parsed.track_no == 12 + assert parsed.title == "Some Title" + + +def test_parse_track_filename_no_prefix_leaves_track_no_none(): + parsed = parse_track_filename("Some Title.mp3") + assert parsed.track_no is None + assert parsed.title == "Some Title" + + +def test_parse_track_filename_does_not_mistake_a_leading_year_for_a_track_number(): + parsed = parse_track_filename("1999 - Some Title.mp3") + assert parsed.track_no is None, "a 4-digit prefix is capped out, not read as track 199" + + +def test_artist_album_from_ancestors_reads_artist_album_track_layout(tmp_path): + folder = tmp_path / "Some Artist" / "Some Album" + folder.mkdir(parents=True) + track = folder / "01 - A Track.mp3" + track.touch() + + artist, album = _artist_album_from_ancestors(track) + + assert artist == "Some Artist" + assert album == "Some Album" + + +# ── end-to-end against a real (tiny, synthetic) MP3 file ──────────────────── + +pytestmark_ffmpeg = pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed") + + +def _make_clip(path: Path, *, title=None, artist=None, album=None, track=None) -> None: + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-c:a", "libmp3lame", "-b:a", "64k", + *(["-metadata", f"title={title}"] if title else []), + *(["-metadata", f"artist={artist}"] if artist else []), + *(["-metadata", f"album={album}"] if album else []), + *(["-metadata", f"track={track}"] if track else []), + str(path)], + check=True, capture_output=True, + ) + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_prefers_tags_over_filename_parse(tmp_path, media_cache): + clip = tmp_path / "99 - wrong title.mp3" + _make_clip(clip, title="Real Title", artist="Real Artist", album="Real Album", track=3) + entry = IndexEntry(id="fileid1", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="audio", added_at=0) + + enricher = AudioEnricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done) + file_id, fields = await asyncio.wait_for(done, timeout=30) + + assert file_id == "fileid1" + assert fields["display_title"] == "Real Title" + assert fields["artist"] == "Real Artist" + assert fields["album"] == "Real Album" + assert fields["track_no"] == 3 + assert fields["duration"] == 1 + assert fields.get("thumb_hash") is None, "no embedded cover was written in this clip" + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_falls_back_to_filename_and_folder_when_tags_absent(tmp_path, media_cache): + folder = tmp_path / "Folder Artist" / "Folder Album" + folder.mkdir(parents=True) + clip = folder / "05 - Filename Title.mp3" + _make_clip(clip) # no metadata tags at all + entry = IndexEntry(id="fileid2", name=clip.name, + path=str(clip.relative_to(tmp_path)), + size=clip.stat().st_size, type="audio", added_at=0) + + enricher = AudioEnricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done) + _, fields = await asyncio.wait_for(done, timeout=30) + + assert fields["display_title"] == "Filename Title" + assert fields["track_no"] == 5 + assert fields["artist"] == "Folder Artist" + assert fields["album"] == "Folder Album" + + +@pytestmark_ffmpeg +def test_extract_cover_returns_none_when_no_apic_frame(tmp_path): + from mutagen import File as MutagenFile + + clip = tmp_path / "plain.mp3" + _make_clip(clip) + + mf = MutagenFile(str(clip)) + assert _extract_cover(mf) is None diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py index b66c448..e70ef43 100644 --- a/packages/meshbay-node/tests/test_media_cache.py +++ b/packages/meshbay-node/tests/test_media_cache.py @@ -1,10 +1,10 @@ -"""Tests for media_cache.py — TMDB/thumbnail cache and its pruning obligation.""" +"""Tests for media_cache.py — TMDB/MusicBrainz/thumbnail cache and its pruning obligation.""" import time import pytest -from meshbay_node.media_cache import MediaCache, TMDB_META_TTL_SECS +from meshbay_node.media_cache import MediaCache, TMDB_META_TTL_SECS, MUSICBRAINZ_META_TTL_SECS @pytest.fixture @@ -69,3 +69,52 @@ async def test_prune_file_removes_thumb_and_mapping_but_not_shared_meta(cache): # ep2's own mapping and the shared show metadata both survive assert await cache.get_file_tmdb("ep2") == ("555", "tv") assert await cache.get_tmdb_meta("555", "tv") == {"name": "A Show"} + + +# ── Music app (docs/musicbay.md §6) — file_mbid/mbid_meta ──────────────────── + +@pytest.mark.asyncio +async def test_file_mbid_round_trip(cache): + assert await cache.get_file_mbid("file1") is None + + await cache.set_file_mbid("file1", "release-mbid-1") + + assert await cache.get_file_mbid("file1") == "release-mbid-1" + + +@pytest.mark.asyncio +async def test_mbid_meta_round_trip(cache): + assert await cache.get_mbid_meta("release-mbid-1") is None + + await cache.set_mbid_meta("release-mbid-1", {"artist": "Some Artist", "album": "An Album"}) + + meta = await cache.get_mbid_meta("release-mbid-1") + assert meta == {"artist": "Some Artist", "album": "An Album"} + + +@pytest.mark.asyncio +async def test_mbid_meta_expires_after_ttl(cache): + await cache._db.execute( + "INSERT INTO mbid_meta (mbid, json, fetched_at) VALUES (?, ?, ?)", + ("old-mbid", '{"album": "Old"}', time.time() - MUSICBRAINZ_META_TTL_SECS - 1), + ) + await cache._db.commit() + + assert await cache.get_mbid_meta("old-mbid") is None + + +@pytest.mark.asyncio +async def test_prune_file_removes_mbid_mapping_but_not_shared_meta(cache): + # Two tracks of the same release share one mbid_meta row. + await cache.set_file_mbid("track1", "release-mbid-1") + await cache.set_file_mbid("track2", "release-mbid-1") + await cache.set_mbid_meta("release-mbid-1", {"album": "An Album"}) + await cache.put_thumb("cover-hash", "track1", b"cover-bytes") + + await cache.prune_file("track1") + + assert await cache.get_file_mbid("track1") is None + assert await cache.get_thumb("cover-hash") is None + # track2's own mapping and the shared release metadata both survive + assert await cache.get_file_mbid("track2") == "release-mbid-1" + assert await cache.get_mbid_meta("release-mbid-1") == {"album": "An Album"} diff --git a/packages/meshbay-node/tests/test_musicbrainz.py b/packages/meshbay-node/tests/test_musicbrainz.py new file mode 100644 index 0000000..482395d --- /dev/null +++ b/packages/meshbay-node/tests/test_musicbrainz.py @@ -0,0 +1,163 @@ +"""Tests for musicbrainz.py against a mocked httpx transport — no live network in CI.""" + +import time + +import httpx +import pytest + +from meshbay_node.musicbrainz import MusicBrainzClient, _MIN_INTERVAL_SECS + + +class FakeRoster: + def __init__(self, contact: str | None = "operator@example.invalid"): + self._contact = contact + + async def musicbrainz_contact(self): + return self._contact + + +def _handler(response_map): + def handle(request: httpx.Request) -> httpx.Response: + path = request.url.path + for prefix, body in response_map.items(): + if path.endswith(prefix): + return httpx.Response(200, json=body) + return httpx.Response(404, json={}) + return handle + + +@pytest.mark.asyncio +async def test_search_release_returns_top_result_and_confidence(): + body = {"releases": [{"id": "abc-123", "title": "The Great Album", + "artist-credit": [{"name": "Some Artist"}]}]} + client = MusicBrainzClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"release": body})), + ) + result, ratio = await client.search_release("Some Artist", "The Great Album") + + assert result is not None + assert result["id"] == "abc-123" + assert ratio > 0.9 + await client.close() + + +@pytest.mark.asyncio +async def test_no_results_returns_none_and_zero_confidence(): + client = MusicBrainzClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"release": {"releases": []}})), + ) + result, ratio = await client.search_release("Nobody", "Nonexistent Obscure Album") + + assert result is None + assert ratio == 0.0 + await client.close() + + +@pytest.mark.asyncio +async def test_no_contact_configured_makes_no_request(monkeypatch): + monkeypatch.delenv("MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT", raising=False) + calls = [] + + def handle(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response(200, json={"releases": []}) + + client = MusicBrainzClient( + roster=FakeRoster(contact=None), + transport=httpx.MockTransport(handle), + ) + result, ratio = await client.search_release("Anyone", "Anything") + + assert result is None + assert calls == [], "an unidentified client must never be sent — see musicbay.md §3.1" + await client.close() + + +@pytest.mark.asyncio +async def test_the_configured_contact_is_sent_as_user_agent(): + captured = {} + + def handle(request: httpx.Request) -> httpx.Response: + captured["ua"] = request.headers.get("user-agent") + return httpx.Response(200, json={"releases": []}) + + client = MusicBrainzClient( + roster=FakeRoster(contact="operator@example.invalid"), + transport=httpx.MockTransport(handle), + ) + await client.search_release("Anyone", "Anything") + + assert "operator@example.invalid" in captured["ua"] + await client.close() + + +@pytest.mark.asyncio +async def test_http_error_returns_none_gracefully(): + def handle(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"error": "server error"}) + + client = MusicBrainzClient( + roster=FakeRoster(), + transport=httpx.MockTransport(handle), + ) + result, ratio = await client.search_release("Anyone", "Anything") + + assert result is None + assert ratio == 0.0 + await client.close() + + +@pytest.mark.asyncio +async def test_cover_art_missing_returns_none_not_an_error(): + def handle(request: httpx.Request) -> httpx.Response: + return httpx.Response(404) + + client = MusicBrainzClient( + roster=FakeRoster(), + transport=httpx.MockTransport(handle), + ) + content = await client.fetch_cover_art("abc-123") + + assert content is None + await client.close() + + +@pytest.mark.asyncio +async def test_cover_art_found_returns_bytes(): + def handle(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"\xff\xd8fake-jpeg-bytes") + + client = MusicBrainzClient( + roster=FakeRoster(), + transport=httpx.MockTransport(handle), + ) + content = await client.fetch_cover_art("abc-123") + + assert content == b"\xff\xd8fake-jpeg-bytes" + await client.close() + + +@pytest.mark.asyncio +async def test_calls_are_paced_at_least_min_interval_apart(): + """ + docs/musicbay.md §3.2: the ~1 req/s courtesy limit is this node's own + job, not something the server hands out — verified by timing two calls + back to back rather than mocking the clock, so a change to the pacing + implementation that still meets the contract doesn't break this test. + """ + def handle(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"releases": []}) + + client = MusicBrainzClient( + roster=FakeRoster(), + transport=httpx.MockTransport(handle), + ) + start = time.monotonic() + await client.search_release("A", "One") + await client.search_release("B", "Two") + elapsed = time.monotonic() - start + + assert elapsed >= _MIN_INTERVAL_SECS * 0.9 + await client.close() diff --git a/packages/meshbay-node/tests/test_musicbrainz_config_policy.py b/packages/meshbay-node/tests/test_musicbrainz_config_policy.py new file mode 100644 index 0000000..3590f01 --- /dev/null +++ b/packages/meshbay-node/tests/test_musicbrainz_config_policy.py @@ -0,0 +1,179 @@ +""" +The operator's MusicBrainz User-Agent contact string — docs/musicbay.md +§3.2/§6. Same shape as test_tmdb_config_policy.py: a signed operator +instruction, node-wide (group_id="") rather than per-group, stored via +roster.py's group_settings table. + +Unlike TMDB's token, a contact string is not a secret — MusicBrainz's usage +policy expects it to be visible to the service it's sent to — but the +subject signed/audited still only ever says whether one was configured +(never the address itself), the same "yes/no" shape as tmdb_config's +subject, to keep a personal contact out of the audit log as free text. +""" + +from pathlib import Path + +import pytest + +from meshbay_common.adminop import OP_MUSICBRAINZ_CONFIG +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = None + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _fake_challenge(issued: list): + return lambda op, subject, payload=None, group_id=None: issued.append( + (op, subject, payload, group_id)) + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_non_string_contact_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_musicbrainz_config({"contact": 12345}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", operator="the-operator") + session._has_admin_authority = lambda: False + + session._do_musicbrainz_config({"contact": "https://example.invalid/contact"}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Who may change it, and what gets signed ───────────────────────────────── + +async def test_changing_it_needs_a_signature(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_musicbrainz_config({}) + + assert len(issued) == 1 + op, subject, payload, group_id = issued[0] + assert op == OP_MUSICBRAINZ_CONFIG + assert group_id == "", "node-wide, like tmdb_config — not tied to self._group_id" + + +async def test_the_contact_itself_never_appears_in_the_signed_subject(tmp_path): + """ + Not a secret the way a TMDB token is, but still kept out of the audited + subject line as free text — same "yes/no configured" shape. + """ + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + contact = "operator@example.invalid" + session._do_musicbrainz_config({"contact": contact}) + + _, subject, payload, _ = issued[0] + assert contact not in subject + assert payload["contact"] == contact, "the real value still has to reach the exec step somehow" + + +async def test_subject_reflects_whether_a_contact_was_supplied(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_musicbrainz_config({"contact": "x"}) + + _, subject, _, _ = issued[0] + assert subject == "contact_configured=yes" + + +async def test_subject_says_no_contact_when_none_given(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_musicbrainz_config({}) + + _, subject, _, _ = issued[0] + assert subject == "contact_configured=no" + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.musicbrainz_contact() is None, \ + "absent must mean 'no contact configured' — no shipped default to fall back to" + await roster.set_musicbrainz_contact("operator@example.invalid", set_by="op") + assert await roster.musicbrainz_contact() == "operator@example.invalid" + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.musicbrainz_contact() == "operator@example.invalid" + finally: + await reopened.close() + + +async def test_clearing_the_contact_reverts_to_unconfigured(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_musicbrainz_contact("a-contact", set_by="op") + assert await roster.musicbrainz_contact() == "a-contact" + + await roster.set_musicbrainz_contact("", set_by="op") + assert await roster.musicbrainz_contact() is None, \ + "an explicit empty string clears the contact" + finally: + await roster.close() + + +async def test_omitting_the_contact_leaves_it_unchanged(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_musicbrainz_contact("a-contact", set_by="op") + await roster.set_musicbrainz_contact(None, set_by="op") + assert await roster.musicbrainz_contact() == "a-contact" + finally: + await roster.close() diff --git a/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py new file mode 100644 index 0000000..e86a3f3 --- /dev/null +++ b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py @@ -0,0 +1,126 @@ +""" +Whether MusicBrainz lookups run *at all* for a group — docs/musicbay.md +§3.2/§6. Per-group from the start (unlike tmdb_enabled, which started +node-wide and moved per-group later once the lesson was already learned). +Same shape as test_tmdb_enabled_policy.py: a signed operator instruction, +scoped to self._group_id (not passed explicitly on the wire), stored via +roster.py's group_settings table under the real group_id. + +The contact string stays node-wide — see test_musicbrainz_config_policy.py. +""" + +from pathlib import Path + +import pytest + +from meshbay_common.adminop import OP_MUSICBRAINZ_ENABLED +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = "g" * 32 + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_missing_enabled_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_musicbrainz_enabled({}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_non_bool_enabled_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_musicbrainz_enabled({"enabled": "yes"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", operator="the-operator") + session._has_admin_authority = lambda: False + + session._do_musicbrainz_enabled({"enabled": False}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Accepted cases ─────────────────────────────────────────────────────────── + +async def test_a_valid_request_is_signed_against_this_groups_id(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_musicbrainz_enabled({"enabled": True}) + + assert issued == [(OP_MUSICBRAINZ_ENABLED, "True")] + + +async def test_disabling_is_signed_too(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_musicbrainz_enabled({"enabled": False}) + + assert issued == [(OP_MUSICBRAINZ_ENABLED, "False")] + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.musicbrainz_enabled("g1") is True, "absent must mean on" + await roster.set_musicbrainz_enabled("g1", False, set_by="op") + assert await roster.musicbrainz_enabled("g1") is False + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.musicbrainz_enabled("g1") is False + assert await reopened.musicbrainz_enabled("g2") is True, \ + "one group's setting must not answer for another" + finally: + await reopened.close() -- cgit v1.2.3