diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 17:12:36 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 17:12:36 +0200 |
| commit | 941d1a135dd7b03834576855e8e9fdaa24c4e406 (patch) | |
| tree | 5d7c27d45a3f1e77320e089f6a4522b8383cbd45 /packages/meshbay-node/tests/test_musicbrainz.py | |
| parent | 16bc07acf053d7d14f8182f5523da1d179154a15 (diff) | |
| download | meshbay-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/tests/test_musicbrainz.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_musicbrainz.py | 163 |
1 files changed, 163 insertions, 0 deletions
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() |