Initial public release
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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
|
||||
Reference in New Issue
Block a user