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_enrich_audio.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_enrich_audio.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_enrich_audio.py | 150 |
1 files changed, 150 insertions, 0 deletions
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 |