import asyncio import logging from typing import Any import httpx import msgspec from models.search import SearchResult from core.exceptions import ExternalServiceError from services.preferences_service import PreferencesService from infrastructure.cache.memory_cache import CacheInterface from infrastructure.cache.cache_keys import ( mb_artist_search_key, mb_artist_detail_key, MB_ARTISTS_BY_TAG_PREFIX, MB_ARTIST_RELS_PREFIX, ) from infrastructure.queue.priority_queue import RequestPriority from infrastructure.resilience.retry import CircuitOpenError from repositories.musicbrainz_base import ( mb_api_get, mb_deduplicator, dedupe_by_id, get_score, build_musicbrainz_tag_query, ) from infrastructure.degradation import try_get_degradation_context from infrastructure.integration_result import IntegrationResult logger = logging.getLogger(__name__) def _record_mb_degradation(msg: str) -> None: ctx = try_get_degradation_context() if ctx: ctx.record(IntegrationResult.error(source="musicbrainz", msg=msg)) class _ArtistSearchPayload(msgspec.Struct): artists: list[dict[str, Any]] = msgspec.field(default_factory=list) class _ArtistReleaseGroupsPayload(msgspec.Struct): release_groups: list[dict[str, Any]] = msgspec.field(name="release-groups", default_factory=list) release_group_count: int = msgspec.field(name="release-group-count", default=0) FILTERED_ARTIST_MBIDS = { "89ad4ac3-39f7-470e-963a-56509c546377", # Various Artists "41ece0f7-91f6-4c87-982c-3a39c5a02586", # /v/ "125ec42a-7229-4250-afc5-e057484327fe", # [Unknown] } FILTERED_ARTIST_NAMES = { "various artists", "[unknown]", "/v/", } class MusicBrainzArtistMixin: _cache: CacheInterface _preferences_service: PreferencesService def _map_artist_to_result(self, artist: dict[str, Any]) -> SearchResult | None: artist_id = artist.get("id", "") if artist_id in FILTERED_ARTIST_MBIDS: return None name = artist.get("name", "Unknown Artist") if name.lower() in FILTERED_ARTIST_NAMES: return None return SearchResult( type="artist", title=name, musicbrainz_id=artist_id, in_library=False, disambiguation=artist.get("disambiguation") or None, type_info=artist.get("type") or None, score=get_score(artist), ) async def search_artists( self, query: str, limit: int = 10, offset: int = 0 ) -> list[SearchResult]: cache_key = mb_artist_search_key(query, limit, offset) cached = await self._cache.get(cache_key) if cached is not None: return cached try: search_query = f'artist:"{query}"^3 OR artistaccent:"{query}"^3 OR alias:"{query}"^2 OR {query}' result = await mb_api_get( "/artist", params={ "query": search_query, "limit": min(100, max(limit * 2, 25)), "offset": offset, }, priority=RequestPriority.USER_INITIATED, decode_type=_ArtistSearchPayload, ) artists = result.artists artists = dedupe_by_id(artists) results = [] for a in artists: mapped = self._map_artist_to_result(a) if mapped: results.append(mapped) if len(results) >= limit: break advanced_settings = self._preferences_service.get_advanced_settings() await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search) return results except Exception as e: # noqa: BLE001 logger.error(f"MusicBrainz artist search failed: {e}") _record_mb_degradation(f"artist search failed: {e}") return [] async def search_artists_by_tag( self, tag: str, limit: int = 50, offset: int = 0 ) -> list[SearchResult]: cache_key = f"{MB_ARTISTS_BY_TAG_PREFIX}{tag.lower()}:{limit}:{offset}" cached = await self._cache.get(cache_key) if cached is not None: return cached try: result = await mb_api_get( "/artist", params={ "query": build_musicbrainz_tag_query(tag), "limit": min(100, limit), "offset": offset, }, priority=RequestPriority.BACKGROUND_SYNC, decode_type=_ArtistSearchPayload, ) artists = result.artists artists = dedupe_by_id(artists) results = [r for a in artists[:limit] if (r := self._map_artist_to_result(a)) is not None] advanced_settings = self._preferences_service.get_advanced_settings() await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search * 2) return results except Exception as e: # noqa: BLE001 logger.error(f"MusicBrainz artist tag search failed for '{tag}': {e}") _record_mb_degradation(f"artist tag search failed: {e}") return [] async def get_artist_by_id(self, mbid: str) -> dict | None: cache_key = mb_artist_detail_key(mbid) cached = await self._cache.get(cache_key) if cached is not None: return cached dedupe_key = f"mb:artist:{mbid}" return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_artist_by_id(mbid, cache_key)) async def get_artist_relations(self, mbid: str) -> dict | None: detail_key = mb_artist_detail_key(mbid) cached = await self._cache.get(detail_key) if cached is not None: return cached rels_key = f"{MB_ARTIST_RELS_PREFIX}{mbid}" cached_rels = await self._cache.get(rels_key) if cached_rels is not None: return cached_rels dedupe_key = f"{MB_ARTIST_RELS_PREFIX}{mbid}" return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_artist_relations(mbid, rels_key)) async def _fetch_artist_relations(self, mbid: str, cache_key: str) -> dict | None: try: result = await mb_api_get( f"/artist/{mbid}", params={"inc": "url-rels"}, priority=RequestPriority.IMAGE_FETCH, ) if not result: return None await self._cache.set(cache_key, result, ttl_seconds=86400) return result except (CircuitOpenError, httpx.HTTPError, ExternalServiceError): raise except Exception as e: # noqa: BLE001 logger.error(f"Failed to fetch artist relations {mbid}: {e}") _record_mb_degradation(f"artist relations failed: {e}") return None async def _fetch_artist_by_id(self, mbid: str, cache_key: str) -> dict | None: try: limit = 50 artist_result, browse_result = await asyncio.gather( mb_api_get( f"/artist/{mbid}", params={"inc": "tags+aliases+url-rels"}, priority=RequestPriority.USER_INITIATED, ), mb_api_get( "/release-group", params={"artist": mbid, "limit": limit, "offset": 0}, priority=RequestPriority.USER_INITIATED, decode_type=_ArtistReleaseGroupsPayload, ), ) if not artist_result: return None all_release_groups = browse_result.release_groups total_count = int(browse_result.release_group_count) if all_release_groups: artist_result["release-group-list"] = all_release_groups artist_result["release-group-count"] = total_count await self._cache.set(cache_key, artist_result, ttl_seconds=21600) from core.task_registry import TaskRegistry registry = TaskRegistry.get_instance() if not registry.is_running("mb-release-group-warmup"): _rg_task = asyncio.create_task(self._warm_release_group_cache(all_release_groups[:6])) try: registry.register("mb-release-group-warmup", _rg_task) except RuntimeError: pass return artist_result except Exception as e: # noqa: BLE001 logger.error(f"Failed to fetch artist {mbid}: {e}") _record_mb_degradation(f"artist fetch failed: {e}") return None async def _warm_release_group_cache(self, release_groups: list[dict[str, Any]]) -> None: for rg in release_groups: rg_id = rg.get("id") if not rg_id: continue try: await self.get_release_group_by_id(rg_id, priority=RequestPriority.BACKGROUND_SYNC) except (CircuitOpenError, ExternalServiceError, httpx.HTTPError) as exc: logger.debug("Failed to warm release group cache for %s: %s", rg_id, exc) async def get_artist_release_groups( self, artist_mbid: str, offset: int = 0, limit: int = 50 ) -> tuple[list[dict[str, Any]], int]: try: result = await mb_api_get( "/release-group", params={"artist": artist_mbid, "limit": limit, "offset": offset}, priority=RequestPriority.BACKGROUND_SYNC, decode_type=_ArtistReleaseGroupsPayload, ) release_groups = result.release_groups total_count = int(result.release_group_count) return release_groups, total_count except Exception as e: # noqa: BLE001 logger.error(f"Failed to fetch release groups for artist {artist_mbid} at offset {offset}: {e}") _record_mb_degradation(f"release groups failed: {e}") return [], 0 async def get_release_groups_by_artist( self, artist_mbid: str, limit: int = 10 ) -> list[dict[str, Any]]: release_groups, _ = await self.get_artist_release_groups(artist_mbid, offset=0, limit=limit) return release_groups