"""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, _clean_tag, _extract_cover, _split_top_level_folder, ) from meshbay_node.indexer.title_parse import parse_track_filename, strip_track_prefix 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, tmp_path) assert artist == "Some Artist" assert album == "Some Album" def test_artist_album_from_ancestors_refuses_to_name_the_root_as_artist(tmp_path): """ The regression this whole revision exists for: a flat `Artist/track.mp3` layout (no album subfolder) used to read the *root's own directory name* as the artist, because the walk always climbed two levels with no idea where the root was. Measured live: 289 tracks across 41 real, unrelated artists collapsed into one fake "artist" this way — the single biggest bucket in the whole library. """ folder = tmp_path / "Ben Harper" folder.mkdir(parents=True) track = folder / "Ashes.mp3" track.touch() artist, album = _artist_album_from_ancestors(track, tmp_path) assert artist == "Ben Harper" assert album is None, "no album folder exists — must not invent one, or swap artist/album" def test_artist_album_from_ancestors_file_directly_in_root_has_no_context(tmp_path): track = tmp_path / "loose_track.mp3" track.touch() artist, album = _artist_album_from_ancestors(track, tmp_path) assert (artist, album) == (None, None) def test_artist_album_from_ancestors_without_a_root_keeps_the_old_two_level_behaviour(tmp_path): """No root resolved (should not happen in practice, but must degrade safely).""" 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, None) assert artist == "Some Artist" assert album == "Some Album" def test_split_top_level_folder_splits_artist_dash_album(): artist, album = _split_top_level_folder("GHOST DOG - Soundtrack") assert artist == "GHOST DOG" assert album == "Soundtrack" def test_split_top_level_folder_with_no_separator_is_artist_only(): artist, album = _split_top_level_folder("Ben Harper") assert artist == "Ben Harper" assert album is None def test_split_top_level_folder_cleans_rip_tag_noise(): artist, album = _split_top_level_folder( "Sinsemilia - Premiere Recolte [MP3 320kbps Album]") assert artist == "Sinsemilia" assert album == "Premiere Recolte" def test_clean_tag_filters_known_placeholders(): assert _clean_tag("No Artist") is None assert _clean_tag("unknown artist") is None assert _clean_tag("Nouvel artiste (334)") is None assert _clean_tag("Nouveau titre (12)") is None assert _clean_tag("") is None assert _clean_tag(None) is None def test_clean_tag_keeps_various_artists_as_a_real_credit(): """A real, meaningful compilation credit — not a placeholder to blank out.""" assert _clean_tag("Various Artists") == "Various Artists" def test_clean_tag_keeps_a_real_value(): assert _clean_tag("Björk") == "Björk" def test_strip_track_prefix_removes_a_leaked_filename_number(): assert strip_track_prefix("01 - Venus As A Boy (Edited Lp Version)") == \ "Venus As A Boy (Edited Lp Version)" def test_strip_track_prefix_is_a_noop_on_a_clean_title(): assert strip_track_prefix("Venus As A Boy") == "Venus As A Boy" # ── 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, tmp_path) _, 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 @pytest.mark.asyncio async def test_enricher_falls_back_to_artist_only_for_a_flat_top_level_dir(tmp_path, media_cache): """The real-world regression case, end to end through the whole pool.""" folder = tmp_path / "Ben Harper" folder.mkdir(parents=True) clip = folder / "Ashes.mp3" _make_clip(clip) # no metadata tags at all entry = IndexEntry(id="fileid3", 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, tmp_path) _, fields = await asyncio.wait_for(done, timeout=30) assert fields["artist"] == "Ben Harper" assert fields["album"] is None assert fields["artist"] != tmp_path.name, \ "must never fall back to the shared root's own directory name" @pytestmark_ffmpeg @pytest.mark.asyncio async def test_enricher_uses_a_sibling_cover_file_when_no_embedded_art(tmp_path, media_cache): folder = tmp_path / "Some Artist" / "Some Album" folder.mkdir(parents=True) clip = folder / "01 - A Track.mp3" _make_clip(clip) (folder / "Folder.jpg").write_bytes(b"\xff\xd8\xff\xe0fake-jpeg-bytes") entry = IndexEntry(id="fileid4", 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, tmp_path) _, fields = await asyncio.wait_for(done, timeout=30) assert fields.get("thumb_hash"), "a Folder.jpg beside the track must be picked up as its cover" stored = await media_cache.get_thumb(fields["thumb_hash"]) assert stored == b"\xff\xd8\xff\xe0fake-jpeg-bytes" @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