mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
771 lines
27 KiB
771 lines
27 KiB
from collections import OrderedDict
|
|
import types
|
|
import sqlite3
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from core.metadata import enrichment as me
|
|
from core.metadata import artwork as ma
|
|
from core.metadata import source as ms
|
|
from core.metadata import album_mbid_cache as _album_mbid_cache
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_persistent_album_mbid_cache(monkeypatch):
|
|
"""The MB release lookup in `core/metadata/source.py` consults the
|
|
persistent album-MBID cache (`core/metadata/album_mbid_cache.py`)
|
|
before calling MusicBrainz. Tests in this file pin per-test MB
|
|
call counts and in-memory cache state — they shouldn't get
|
|
bypassed by leftover persistent rows from other tests sharing the
|
|
same SQLite database.
|
|
|
|
Easiest fix: monkeypatch the persistent cache to be a no-op for
|
|
these tests. They focus on the in-memory layer + MB call shape;
|
|
the persistent layer has its own dedicated tests at
|
|
`tests/metadata/test_album_mbid_cache.py`.
|
|
"""
|
|
monkeypatch.setattr(_album_mbid_cache, 'lookup', lambda *a, **kw: None)
|
|
monkeypatch.setattr(_album_mbid_cache, 'record', lambda *a, **kw: False)
|
|
|
|
|
|
class _Config:
|
|
def __init__(self, values=None):
|
|
self.values = values or {}
|
|
|
|
def get(self, key, default=None):
|
|
return self.values.get(key, default)
|
|
|
|
|
|
class _FakeTag:
|
|
def __init__(self, kind, **kwargs):
|
|
self.kind = kind
|
|
self.kwargs = kwargs
|
|
|
|
|
|
class _FakeID3Tags:
|
|
def __init__(self):
|
|
self.added = []
|
|
|
|
def add(self, frame):
|
|
self.added.append(frame)
|
|
|
|
def clear(self):
|
|
self.added.clear()
|
|
|
|
def getall(self, _key):
|
|
return []
|
|
|
|
def keys(self):
|
|
return [frame.kind for frame in self.added]
|
|
|
|
def __len__(self):
|
|
return len(self.added)
|
|
|
|
|
|
class _FakeAudio:
|
|
def __init__(self):
|
|
self.tags = _FakeID3Tags()
|
|
self.save_calls = []
|
|
self.clear_pictures_calls = 0
|
|
|
|
def clear_pictures(self):
|
|
self.clear_pictures_calls += 1
|
|
|
|
def add_tags(self):
|
|
self.tags = _FakeID3Tags()
|
|
|
|
def save(self, **kwargs):
|
|
self.save_calls.append(kwargs)
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload, content_type="image/jpeg"):
|
|
self._payload = payload
|
|
self._content_type = content_type
|
|
|
|
def read(self):
|
|
return self._payload
|
|
|
|
def info(self):
|
|
return types.SimpleNamespace(get_content_type=lambda: self._content_type)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
|
|
class _FileDB:
|
|
def __init__(self, db_path):
|
|
self.db_path = db_path
|
|
|
|
def _get_connection(self):
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _fake_symbols(audio):
|
|
def _tag_factory(kind):
|
|
return lambda **kwargs: _FakeTag(kind, **kwargs)
|
|
|
|
return types.SimpleNamespace(
|
|
File=lambda _path: audio,
|
|
APEv2=type("FakeAPEv2", (), {}),
|
|
APENoHeaderError=Exception,
|
|
FLAC=type("FakeFLAC", (), {}),
|
|
Picture=type("FakePicture", (), {"__init__": lambda self: None}),
|
|
ID3=_FakeID3Tags,
|
|
APIC=_tag_factory("APIC"),
|
|
TBPM=_tag_factory("TBPM"),
|
|
TCOP=_tag_factory("TCOP"),
|
|
TDOR=_tag_factory("TDOR"),
|
|
TDRC=_tag_factory("TDRC"),
|
|
TCON=_tag_factory("TCON"),
|
|
TIT2=_tag_factory("TIT2"),
|
|
TALB=_tag_factory("TALB"),
|
|
TPE1=_tag_factory("TPE1"),
|
|
TPE2=_tag_factory("TPE2"),
|
|
TPOS=_tag_factory("TPOS"),
|
|
TPUB=_tag_factory("TPUB"),
|
|
TRCK=_tag_factory("TRCK"),
|
|
TSRC=_tag_factory("TSRC"),
|
|
TXXX=_tag_factory("TXXX"),
|
|
UFID=_tag_factory("UFID"),
|
|
TMED=_tag_factory("TMED"),
|
|
MP4=type("FakeMP4", (), {}),
|
|
MP4Cover=types.SimpleNamespace(FORMAT_JPEG=1, FORMAT_PNG=2, __call__=lambda *args, **kwargs: ("cover", args, kwargs)),
|
|
MP4FreeForm=lambda data: ("freeform", data),
|
|
OggVorbis=type("FakeOggVorbis", (), {}),
|
|
OggOpus=None,
|
|
)
|
|
|
|
|
|
def test_extract_source_metadata_keeps_neutral_fields_and_skips_itunes_fallback_for_non_itunes_sources(monkeypatch):
|
|
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config({"file_organization.collab_artist_mode": "first"}))
|
|
monkeypatch.setattr(ms, "get_itunes_client", lambda: (_ for _ in ()).throw(AssertionError("itunes fallback should not run for non-itunes sources")))
|
|
|
|
context = {
|
|
"source": "spotify",
|
|
"artist": {"name": "Artist One & Artist Two", "id": "123", "genres": ["rock", "indie"]},
|
|
"album": {
|
|
"name": "Album One",
|
|
"total_tracks": 12,
|
|
"release_date": "2024-01-02",
|
|
"images": [{"url": "https://img.example/album.jpg"}],
|
|
},
|
|
"track_info": {
|
|
"artists": [{"name": "Artist One"}],
|
|
"_source": "spotify",
|
|
"track_number": 3,
|
|
"disc_number": 2,
|
|
"total_tracks": 12,
|
|
},
|
|
"original_search_result": {
|
|
"title": "Song One",
|
|
"artists": [{"name": "Artist One"}],
|
|
"clean_title": "Song One",
|
|
"clean_album": "Album One",
|
|
"clean_artist": "Artist One",
|
|
"disc_number": 2,
|
|
"duration_ms": 180000,
|
|
},
|
|
}
|
|
|
|
metadata = me.extract_source_metadata(
|
|
context,
|
|
context["artist"],
|
|
{"is_album": True, "album_name": "Album One", "track_number": 3, "disc_number": 2, "album_image_url": "https://img.example/album.jpg"},
|
|
)
|
|
|
|
assert metadata["source"] == "spotify"
|
|
assert metadata["title"] == "Song One"
|
|
assert metadata["artist"] == "Artist One"
|
|
assert metadata["album_artist"] == "Artist One & Artist Two"
|
|
assert metadata["album"] == "Album One"
|
|
assert metadata["track_number"] == 3
|
|
assert metadata["total_tracks"] == 12
|
|
assert metadata["disc_number"] == 2
|
|
assert metadata["date"] == "2024-01-02"
|
|
assert metadata["album_art_url"] == "https://img.example/album.jpg"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw_release_date", "expected_tag_date"),
|
|
[
|
|
("2024-05-06", "2024-05-06"),
|
|
("2024-05", "2024-05"),
|
|
("2024", "2024"),
|
|
("2024-05-06T07:08:09Z", "2024-05-06"),
|
|
],
|
|
)
|
|
def test_extract_source_metadata_preserves_available_release_date_precision(monkeypatch, raw_release_date, expected_tag_date):
|
|
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config({"file_organization.collab_artist_mode": "first"}))
|
|
|
|
context = {
|
|
"source": "spotify",
|
|
"artist": {"name": "Artist One", "id": "123", "genres": []},
|
|
"album": {
|
|
"name": "Album One",
|
|
"total_tracks": 12,
|
|
"release_date": raw_release_date,
|
|
},
|
|
"track_info": {
|
|
"artists": [{"name": "Artist One"}],
|
|
"_source": "spotify",
|
|
"track_number": 3,
|
|
"disc_number": 1,
|
|
},
|
|
"original_search_result": {
|
|
"title": "Song One",
|
|
"artists": [{"name": "Artist One"}],
|
|
"clean_title": "Song One",
|
|
"clean_album": "Album One",
|
|
"clean_artist": "Artist One",
|
|
},
|
|
}
|
|
|
|
metadata = me.extract_source_metadata(
|
|
context,
|
|
context["artist"],
|
|
{"is_album": True, "album_name": "Album One", "track_number": 3, "disc_number": 1},
|
|
)
|
|
|
|
assert metadata["date"] == expected_tag_date
|
|
|
|
|
|
def test_embed_source_ids_uses_current_source_ids_and_legacy_fallback(monkeypatch):
|
|
audio = _FakeAudio()
|
|
symbols = _fake_symbols(audio)
|
|
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config())
|
|
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
|
|
monkeypatch.setattr(ms, "get_database", lambda: None)
|
|
|
|
current_metadata = {
|
|
"source": "deezer",
|
|
"source_track_id": "dz-track",
|
|
"source_artist_id": "dz-artist",
|
|
"source_album_id": "dz-album",
|
|
"title": "Song One",
|
|
"artist": "Artist One",
|
|
"album_artist": "Artist One",
|
|
"album": "Album One",
|
|
}
|
|
me.embed_source_ids(audio, current_metadata, context={"track_info": {}, "original_search_result": {}})
|
|
|
|
current_descs = [frame.kwargs.get("desc") for frame in audio.tags.added if frame.kind == "TXXX"]
|
|
assert "DEEZER_TRACK_ID" in current_descs
|
|
assert "DEEZER_ARTIST_ID" in current_descs
|
|
|
|
audio = _FakeAudio()
|
|
symbols = _fake_symbols(audio)
|
|
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
|
|
|
|
legacy_metadata = {
|
|
"source": "",
|
|
"spotify_track_id": "sp-track",
|
|
"spotify_artist_id": "sp-artist",
|
|
"spotify_album_id": "sp-album",
|
|
"itunes_track_id": "it-track",
|
|
"itunes_artist_id": "it-artist",
|
|
"itunes_album_id": "it-album",
|
|
"title": "Song One",
|
|
"artist": "Artist One",
|
|
"album_artist": "Artist One",
|
|
"album": "Album One",
|
|
}
|
|
me.embed_source_ids(audio, legacy_metadata, context={"track_info": {}, "original_search_result": {}})
|
|
|
|
legacy_descs = [frame.kwargs.get("desc") for frame in audio.tags.added if frame.kind == "TXXX"]
|
|
assert "SPOTIFY_TRACK_ID" in legacy_descs
|
|
assert "SPOTIFY_ARTIST_ID" in legacy_descs
|
|
assert "SPOTIFY_ALBUM_ID" in legacy_descs
|
|
assert "ITUNES_TRACK_ID" in legacy_descs
|
|
assert "ITUNES_ARTIST_ID" in legacy_descs
|
|
assert "ITUNES_ALBUM_ID" in legacy_descs
|
|
|
|
|
|
def test_embed_source_ids_skips_disabled_source_specific_tags(monkeypatch):
|
|
audio = _FakeAudio()
|
|
symbols = _fake_symbols(audio)
|
|
|
|
monkeypatch.setattr(
|
|
ms,
|
|
"get_config_manager",
|
|
lambda: _Config({"deezer.embed_tags": False}),
|
|
)
|
|
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
|
|
monkeypatch.setattr(ms, "get_database", lambda: None)
|
|
|
|
metadata = {
|
|
"source": "deezer",
|
|
"source_track_id": "dz-track",
|
|
"source_artist_id": "dz-artist",
|
|
"source_album_id": "dz-album",
|
|
"title": "Song One",
|
|
"artist": "Artist One",
|
|
"album_artist": "Artist One",
|
|
"album": "Album One",
|
|
}
|
|
|
|
me.embed_source_ids(audio, metadata, context={"track_info": {}, "original_search_result": {}})
|
|
|
|
assert audio.tags.added == []
|
|
|
|
|
|
def test_embed_source_ids_writes_musicbrainz_release_year_and_updates_album_year(tmp_path, monkeypatch):
|
|
db_path = tmp_path / "music.db"
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)")
|
|
conn.execute("CREATE TABLE albums (id TEXT PRIMARY KEY, artist_id TEXT, title TEXT, year INTEGER)")
|
|
conn.execute("INSERT INTO artists (id, name) VALUES (?, ?)", ("artist-1", "Artist One"))
|
|
conn.execute(
|
|
"INSERT INTO albums (id, artist_id, title, year) VALUES (?, ?, ?, ?)",
|
|
("album-1", "artist-1", "Album One", None),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
class _FakeMBClient:
|
|
def get_recording(self, mbid, includes=None):
|
|
return {
|
|
"isrcs": ["ISRC-123"],
|
|
"genres": [{"name": "Post Rock", "count": 10}],
|
|
}
|
|
|
|
def get_release(self, mbid, includes=None):
|
|
return {
|
|
"release-group": {
|
|
"id": "rg-1",
|
|
"primary-type": "album",
|
|
"first-release-date": "2021-09-17",
|
|
},
|
|
"artist-credit": [{"artist": {"id": "artist-mb-1"}}],
|
|
"status": "Official",
|
|
"country": "US",
|
|
"barcode": "1234567890",
|
|
"media": [{"format": "CD", "tracks": [{"position": 1, "id": "reltrack-1", "recording": {"id": "rec-1"}}]}],
|
|
"label-info": [{"catalog-number": "CAT-1"}],
|
|
"text-representation": {"script": "Latn"},
|
|
"asin": "ASIN1",
|
|
}
|
|
|
|
class _FakeMBService:
|
|
def __init__(self):
|
|
self.mb_client = _FakeMBClient()
|
|
|
|
def match_recording(self, title, artist):
|
|
return {"mbid": "rec-mbid"}
|
|
|
|
def match_artist(self, artist):
|
|
return {"mbid": "artist-mbid"}
|
|
|
|
def match_release(self, album, artist):
|
|
return {"mbid": "release-mbid"}
|
|
|
|
audio = _FakeAudio()
|
|
symbols = _fake_symbols(audio)
|
|
runtime = types.SimpleNamespace(mb_worker=types.SimpleNamespace(mb_service=_FakeMBService()))
|
|
|
|
monkeypatch.setattr(
|
|
ms,
|
|
"get_config_manager",
|
|
lambda: _Config(
|
|
{
|
|
"metadata_enhancement.enabled": True,
|
|
"metadata_enhancement.embed_album_art": False,
|
|
"metadata_enhancement.tags.write_multi_artist": False,
|
|
"musicbrainz.embed_tags": True,
|
|
}
|
|
),
|
|
)
|
|
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
|
|
monkeypatch.setattr(ms, "get_database", lambda: _FileDB(str(db_path)))
|
|
|
|
metadata = {
|
|
"source": "musicbrainz",
|
|
"title": "Song One",
|
|
"artist": "Artist One",
|
|
"album_artist": "Artist One",
|
|
"album": "Album One",
|
|
"track_number": 1,
|
|
"total_tracks": 12,
|
|
"disc_number": 1,
|
|
}
|
|
|
|
me.embed_source_ids(audio, metadata, context={"track_info": {}, "original_search_result": {}}, runtime=runtime)
|
|
|
|
assert metadata["musicbrainz_release_id"] == "release-mbid"
|
|
assert metadata["date"] == "2021"
|
|
assert any(frame.kind == "TDRC" for frame in audio.tags.added)
|
|
assert any(frame.kind == "TSRC" for frame in audio.tags.added)
|
|
|
|
check = sqlite3.connect(db_path)
|
|
check.row_factory = sqlite3.Row
|
|
row = check.execute(
|
|
"""
|
|
SELECT albums.year
|
|
FROM albums
|
|
JOIN artists ON artists.id = albums.artist_id
|
|
WHERE albums.title = ? AND artists.name = ?
|
|
""",
|
|
("Album One", "Artist One"),
|
|
).fetchone()
|
|
check.close()
|
|
|
|
assert row["year"] == 2021
|
|
|
|
|
|
def test_musicbrainz_release_lookup_failure_does_not_poison_cache(monkeypatch):
|
|
class _FakeMBClient:
|
|
def get_release(self, mbid, includes=None):
|
|
return {}
|
|
|
|
def get_artist(self, mbid, includes=None):
|
|
return {}
|
|
|
|
class _FakeMBService:
|
|
def __init__(self):
|
|
self.release_calls = 0
|
|
self.mb_client = _FakeMBClient()
|
|
|
|
def match_recording(self, title, artist):
|
|
return None
|
|
|
|
def match_artist(self, artist):
|
|
return {"mbid": "artist-mbid"}
|
|
|
|
def match_release(self, album, artist):
|
|
self.release_calls += 1
|
|
if self.release_calls == 1:
|
|
raise requests.RequestException("temporary MusicBrainz outage")
|
|
return {"mbid": "release-mbid"}
|
|
|
|
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config({"musicbrainz.embed_tags": True}))
|
|
monkeypatch.setattr(ms, "mb_release_cache", {})
|
|
monkeypatch.setattr(ms, "mb_release_detail_cache", {})
|
|
|
|
service = _FakeMBService()
|
|
runtime = types.SimpleNamespace(mb_worker=types.SimpleNamespace(mb_service=service))
|
|
pp = {
|
|
"id_tags": {},
|
|
"track_title": "Song One",
|
|
"artist_name": "Artist One",
|
|
"batch_artist_name": "Artist One",
|
|
"metadata": {"album": "Album One"},
|
|
"recording_mbid": None,
|
|
"artist_mbid": None,
|
|
"release_mbid": "",
|
|
"mb_genres": [],
|
|
"isrc": None,
|
|
"deezer_bpm": None,
|
|
"deezer_isrc": None,
|
|
"audiodb_mood": None,
|
|
"audiodb_style": None,
|
|
"audiodb_genre": None,
|
|
"tidal_isrc": None,
|
|
"tidal_copyright": None,
|
|
"qobuz_isrc": None,
|
|
"qobuz_copyright": None,
|
|
"qobuz_label": None,
|
|
"lastfm_tags": [],
|
|
"lastfm_url": None,
|
|
"genius_url": None,
|
|
"release_year": None,
|
|
}
|
|
metadata = {"album": "Album One", "artist": "Artist One"}
|
|
|
|
ms._process_musicbrainz_source(pp, metadata, _Config({"musicbrainz.embed_tags": True}), runtime, "Song One", "Artist One")
|
|
assert service.release_calls == 1
|
|
assert ms.mb_release_cache == {}
|
|
|
|
poisoned_norm_key = (ms.normalize_album_cache_key("Album One"), "artist one")
|
|
poisoned_exact_key = ("album one", "artist one")
|
|
ms.mb_release_cache[poisoned_norm_key] = ""
|
|
ms.mb_release_cache[poisoned_exact_key] = ""
|
|
|
|
ms._process_musicbrainz_source(pp, metadata, _Config({"musicbrainz.embed_tags": True}), runtime, "Song One", "Artist One")
|
|
assert service.release_calls == 2
|
|
|
|
|
|
def test_source_processors_do_not_swallow_programmer_errors():
|
|
class _BoomClient:
|
|
def search_track(self, artist_name, track_title):
|
|
raise ValueError("boom")
|
|
|
|
runtime = types.SimpleNamespace(deezer_worker=types.SimpleNamespace(client=_BoomClient()))
|
|
pp = {
|
|
"id_tags": {},
|
|
"batch_artist_name": "Artist One",
|
|
"release_year": None,
|
|
}
|
|
metadata = {"album": "Album One"}
|
|
|
|
with pytest.raises(ValueError):
|
|
ms._process_deezer_source(pp, metadata, _Config({"deezer.embed_tags": True}), runtime, "Song One", "Artist One")
|
|
|
|
|
|
def test_musicbrainz_caches_evict_oldest_entries():
|
|
release_cache = OrderedDict()
|
|
detail_cache = OrderedDict()
|
|
|
|
ms._bounded_cache_set(release_cache, ("album-1", "artist"), "release-1", 2)
|
|
ms._bounded_cache_set(release_cache, ("album-2", "artist"), "release-2", 2)
|
|
assert list(release_cache.keys()) == [("album-1", "artist"), ("album-2", "artist")]
|
|
|
|
assert ms._bounded_cache_get(release_cache, ("album-1", "artist")) == "release-1"
|
|
ms._bounded_cache_set(release_cache, ("album-3", "artist"), "release-3", 2)
|
|
assert list(release_cache.keys()) == [("album-1", "artist"), ("album-3", "artist")]
|
|
|
|
ms._bounded_cache_set(detail_cache, "release-1", {"title": "One"}, 1)
|
|
ms._bounded_cache_set(detail_cache, "release-2", {"title": "Two"}, 1)
|
|
assert list(detail_cache.keys()) == ["release-2"]
|
|
|
|
|
|
def test_enhance_file_metadata_forwards_runtime_to_source_embedding(monkeypatch):
|
|
audio = _FakeAudio()
|
|
symbols = _fake_symbols(audio)
|
|
seen = {}
|
|
runtime = types.SimpleNamespace(marker="runtime")
|
|
|
|
monkeypatch.setattr(me, "get_config_manager", lambda: _Config(
|
|
{
|
|
"metadata_enhancement.enabled": True,
|
|
"metadata_enhancement.embed_album_art": False,
|
|
"metadata_enhancement.tags.write_multi_artist": False,
|
|
}
|
|
))
|
|
monkeypatch.setattr(me, "get_mutagen_symbols", lambda: symbols)
|
|
monkeypatch.setattr(me, "strip_all_non_audio_tags", lambda file_path: {"apev2_stripped": False, "apev2_tag_count": 0})
|
|
monkeypatch.setattr(
|
|
me,
|
|
"extract_source_metadata",
|
|
lambda context, artist, album_info: {
|
|
"source": "deezer",
|
|
"source_track_id": "dz-track",
|
|
"source_artist_id": "dz-artist",
|
|
"source_album_id": "dz-album",
|
|
"title": "Song One",
|
|
"artist": "Artist One",
|
|
"album_artist": "Artist One",
|
|
"album": "Album One",
|
|
"track_number": 3,
|
|
"total_tracks": 12,
|
|
"disc_number": 2,
|
|
"date": "2024",
|
|
"genre": "Rock",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
me,
|
|
"embed_source_ids",
|
|
lambda audio_file, metadata, context, runtime=None: seen.setdefault("runtime", runtime),
|
|
)
|
|
monkeypatch.setattr(me, "embed_album_art_metadata", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(me, "verify_metadata_written", lambda file_path: True)
|
|
|
|
result = me.enhance_file_metadata(
|
|
"song.flac",
|
|
{"_audio_quality": ""},
|
|
{"name": "Artist One"},
|
|
{},
|
|
runtime=runtime,
|
|
)
|
|
|
|
assert result is True
|
|
assert seen["runtime"] is runtime
|
|
|
|
|
|
def test_enhance_file_metadata_writes_tags_and_propagates_release_id(monkeypatch):
|
|
audio = _FakeAudio()
|
|
symbols = _fake_symbols(audio)
|
|
strip_calls = []
|
|
verify_calls = []
|
|
|
|
monkeypatch.setattr(me, "get_config_manager", lambda: _Config(
|
|
{
|
|
"metadata_enhancement.enabled": True,
|
|
"metadata_enhancement.embed_album_art": False,
|
|
"metadata_enhancement.tags.write_multi_artist": False,
|
|
}
|
|
))
|
|
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config())
|
|
monkeypatch.setattr(me, "get_mutagen_symbols", lambda: symbols)
|
|
monkeypatch.setattr(ms, "get_mutagen_symbols", lambda: symbols)
|
|
monkeypatch.setattr(ms, "get_database", lambda: None)
|
|
monkeypatch.setattr(me, "strip_all_non_audio_tags", lambda file_path: strip_calls.append(file_path) or {"apev2_stripped": False, "apev2_tag_count": 0})
|
|
monkeypatch.setattr(
|
|
me,
|
|
"extract_source_metadata",
|
|
lambda context, artist, album_info: {
|
|
"source": "deezer",
|
|
"source_track_id": "dz-track",
|
|
"source_artist_id": "dz-artist",
|
|
"source_album_id": "dz-album",
|
|
"title": "Song One",
|
|
"artist": "Artist One",
|
|
"album_artist": "Artist One",
|
|
"album": "Album One",
|
|
"track_number": 3,
|
|
"total_tracks": 12,
|
|
"disc_number": 2,
|
|
"date": "2024",
|
|
"genre": "Rock",
|
|
"musicbrainz_release_id": "mb-release-1",
|
|
},
|
|
)
|
|
monkeypatch.setattr(me, "embed_album_art_metadata", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(me, "verify_metadata_written", lambda file_path: verify_calls.append(file_path) or True)
|
|
|
|
album_info = {}
|
|
result = me.enhance_file_metadata(
|
|
"song.flac",
|
|
{"_audio_quality": ""},
|
|
{"name": "Artist One"},
|
|
album_info,
|
|
)
|
|
|
|
assert result is True
|
|
assert strip_calls == ["song.flac"]
|
|
assert verify_calls == ["song.flac"]
|
|
assert audio.clear_pictures_calls == 1
|
|
assert len(audio.save_calls) == 2
|
|
assert album_info["musicbrainz_release_id"] == "mb-release-1"
|
|
assert any(frame.kind == "TIT2" for frame in audio.tags.added)
|
|
assert any(frame.kind == "TPE1" for frame in audio.tags.added)
|
|
assert any(frame.kind == "TXXX" and frame.kwargs.get("desc") == "DEEZER_TRACK_ID" for frame in audio.tags.added)
|
|
|
|
|
|
def test_download_cover_art_uses_album_context_image_url(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(ma, "get_config_manager", lambda: _Config(
|
|
{
|
|
"metadata_enhancement.cover_art_download": True,
|
|
"metadata_enhancement.prefer_caa_art": False,
|
|
}
|
|
))
|
|
|
|
monkeypatch.setattr(ma.urllib.request, "urlopen", lambda *args, **kwargs: _FakeResponse(b"cover-bytes"))
|
|
|
|
target_dir = tmp_path / "Album One"
|
|
target_dir.mkdir()
|
|
|
|
ma.download_cover_art(
|
|
{},
|
|
str(target_dir),
|
|
{"album": {"image_url": "https://img.example/album.jpg"}},
|
|
)
|
|
|
|
cover_path = target_dir / "cover.jpg"
|
|
assert cover_path.exists()
|
|
assert cover_path.read_bytes() == b"cover-bytes"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MusicBrainz genre fallback chain (recording → release → artist)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_mb_genre_test(monkeypatch, *, recording_genres, release_genres, artist_genres):
|
|
"""Helper: assemble a fake MB stack with configurable genres at each tier."""
|
|
class _FakeMBClient:
|
|
def __init__(self):
|
|
self.artist_calls = 0
|
|
self.release_calls = 0
|
|
|
|
def get_recording(self, mbid, includes=None):
|
|
return {"isrcs": [], "genres": list(recording_genres)}
|
|
|
|
def get_release(self, mbid, includes=None):
|
|
self.release_calls += 1
|
|
return {"genres": list(release_genres), "media": []}
|
|
|
|
def get_artist(self, mbid, includes=None):
|
|
self.artist_calls += 1
|
|
return {"genres": list(artist_genres)}
|
|
|
|
class _FakeMBService:
|
|
def __init__(self):
|
|
self.mb_client = _FakeMBClient()
|
|
|
|
def match_recording(self, t, a):
|
|
return {"mbid": "rec-mbid"}
|
|
|
|
def match_artist(self, a):
|
|
return {"mbid": "artist-mbid"}
|
|
|
|
def match_release(self, album, artist):
|
|
return {"mbid": "release-mbid"}
|
|
|
|
service = _FakeMBService()
|
|
monkeypatch.setattr(ms, "get_config_manager", lambda: _Config({"musicbrainz.embed_tags": True}))
|
|
monkeypatch.setattr(ms, "mb_release_cache", {})
|
|
monkeypatch.setattr(ms, "mb_release_detail_cache", {})
|
|
|
|
runtime = types.SimpleNamespace(mb_worker=types.SimpleNamespace(mb_service=service))
|
|
pp = {
|
|
"id_tags": {}, "track_title": "T", "artist_name": "A", "batch_artist_name": "A",
|
|
"metadata": {"album": "Alb"}, "recording_mbid": None, "artist_mbid": None,
|
|
"release_mbid": "", "mb_genres": [], "isrc": None,
|
|
"deezer_bpm": None, "deezer_isrc": None,
|
|
"audiodb_mood": None, "audiodb_style": None, "audiodb_genre": None,
|
|
"tidal_isrc": None, "tidal_copyright": None,
|
|
"qobuz_isrc": None, "qobuz_copyright": None, "qobuz_label": None,
|
|
"lastfm_tags": [], "lastfm_url": None, "genius_url": None, "release_year": None,
|
|
}
|
|
return pp, service, runtime
|
|
|
|
|
|
def test_mb_genre_recording_used_when_present(monkeypatch):
|
|
pp, service, runtime = _build_mb_genre_test(
|
|
monkeypatch,
|
|
recording_genres=[{"name": "Rock", "count": 5}],
|
|
release_genres=[{"name": "Pop", "count": 10}],
|
|
artist_genres=[{"name": "Jazz", "count": 20}],
|
|
)
|
|
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
|
|
runtime, "T", "A")
|
|
assert pp["mb_genres"] == ["Rock"]
|
|
# Release/artist genre lookups not consulted because recording had genres
|
|
assert service.mb_client.artist_calls == 0
|
|
|
|
|
|
def test_mb_genre_falls_back_to_release_when_recording_empty(monkeypatch):
|
|
pp, service, runtime = _build_mb_genre_test(
|
|
monkeypatch,
|
|
recording_genres=[],
|
|
release_genres=[{"name": "Pop", "count": 10}, {"name": "Indie", "count": 3}],
|
|
artist_genres=[{"name": "Jazz", "count": 20}],
|
|
)
|
|
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
|
|
runtime, "T", "A")
|
|
# Sorted by count desc: Pop (10) before Indie (3)
|
|
assert pp["mb_genres"] == ["Pop", "Indie"]
|
|
# Artist not consulted because release had genres
|
|
assert service.mb_client.artist_calls == 0
|
|
|
|
|
|
def test_mb_genre_falls_back_to_artist_when_recording_and_release_empty(monkeypatch):
|
|
pp, service, runtime = _build_mb_genre_test(
|
|
monkeypatch,
|
|
recording_genres=[],
|
|
release_genres=[],
|
|
artist_genres=[{"name": "Jazz", "count": 20}, {"name": "Fusion", "count": 5}],
|
|
)
|
|
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
|
|
runtime, "T", "A")
|
|
assert pp["mb_genres"] == ["Jazz", "Fusion"]
|
|
assert service.mb_client.artist_calls == 1
|
|
|
|
|
|
def test_mb_genre_all_empty_returns_empty(monkeypatch):
|
|
pp, service, runtime = _build_mb_genre_test(
|
|
monkeypatch,
|
|
recording_genres=[],
|
|
release_genres=[],
|
|
artist_genres=[],
|
|
)
|
|
ms._process_musicbrainz_source(pp, {"album": "Alb"}, _Config({"musicbrainz.embed_tags": True}),
|
|
runtime, "T", "A")
|
|
assert pp["mb_genres"] == []
|