diff --git a/core/metadata/__init__.py b/core/metadata/__init__.py index e7142adf..7b3df829 100644 --- a/core/metadata/__init__.py +++ b/core/metadata/__init__.py @@ -1,5 +1,6 @@ """Metadata package public surface.""" +from core.metadata.engine import MetadataEngine, MetadataSourceFacade, get_metadata_engine from core.metadata.album_tracks import ( get_album_for_source, get_album_tracks_for_source, @@ -10,6 +11,11 @@ from core.metadata.album_tracks import ( from core.metadata.artist_image import get_artist_image_url from core.metadata.artwork import is_internal_image_host, normalize_image_url from core.metadata.cache import MetadataCache, get_metadata_cache +from core.metadata.constants import ( + METADATA_PROVIDER_SOURCES, + METADATA_SOURCE_LABELS, + METADATA_SOURCE_PRIORITY, +) from core.metadata.completion import ( check_album_completion, check_artist_discography_completion, @@ -20,9 +26,27 @@ from core.metadata.discography import ( get_artist_detail_discography, get_artist_discography, ) +from core.metadata.contracts import ( + MetadataLookupOutcome, + MetadataLookupRequest, + MetadataProviderStatus, + MetadataSearchOutcome, + MetadataSearchRequest, +) +from core.metadata.exceptions import ( + MetadataNotFound, + MetadataProviderError, + MetadataRateLimited, +) from core.metadata.lookup import MetadataLookupOptions +from core.metadata.models import ( + MetadataAlbum, + MetadataArtist, + MetadataPlaylist, + MetadataRecord, + MetadataTrack, +) from core.metadata.registry import ( - METADATA_SOURCE_PRIORITY, clear_cached_metadata_client, clear_cached_metadata_clients, clear_cached_profile_spotify_client, @@ -30,6 +54,7 @@ from core.metadata.registry import ( get_deezer_client, get_discogs_client, get_hydrabase_client, + get_enabled_metadata_sources, get_itunes_client, get_primary_client, get_primary_source, @@ -38,6 +63,7 @@ from core.metadata.registry import ( get_source_priority, get_spotify_client, is_hydrabase_enabled, + is_metadata_source_enabled, register_profile_spotify_credentials_provider, register_runtime_clients, ) @@ -56,11 +82,27 @@ from core.metadata.similar_artists import ( __all__ = [ "METADATA_SOURCE_PRIORITY", + "METADATA_SOURCE_LABELS", + "METADATA_PROVIDER_SOURCES", "METADATA_SOURCE_STATUS_TTL", + "MetadataAlbum", "MetadataCache", + "MetadataArtist", + "MetadataEngine", + "MetadataLookupOutcome", + "MetadataLookupRequest", "MetadataLookupOptions", "MetadataProvider", + "MetadataProviderError", + "MetadataProviderStatus", + "MetadataRateLimited", + "MetadataNotFound", + "MetadataRecord", "MetadataService", + "MetadataSearchOutcome", + "MetadataSearchRequest", + "MetadataSourceFacade", + "MetadataTrack", "check_album_completion", "check_artist_discography_completion", "check_single_completion", @@ -77,12 +119,14 @@ __all__ = [ "get_client_for_source", "get_deezer_client", "get_discogs_client", + "get_enabled_metadata_sources", "get_hydrabase_client", "get_itunes_client", "get_metadata_cache", "get_metadata_source_status", "get_metadata_service", "get_musicmap_similar_artists", + "get_metadata_engine", "get_primary_client", "get_primary_source", "get_spotify_client_for_profile", @@ -94,6 +138,7 @@ __all__ = [ "iter_artist_discography_completion_events", "iter_musicmap_similar_artist_events", "is_hydrabase_enabled", + "is_metadata_source_enabled", "is_internal_image_host", "register_profile_spotify_credentials_provider", "register_runtime_clients", diff --git a/core/metadata/album_tracks.py b/core/metadata/album_tracks.py index 37e728a4..1037ec0d 100644 --- a/core/metadata/album_tracks.py +++ b/core/metadata/album_tracks.py @@ -60,10 +60,14 @@ def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]: primary_source = metadata_registry.get_primary_source() source_chain = list(metadata_registry.get_source_priority(primary_source)) override = (options.source_override or '').strip().lower() + enabled_sources = tuple(source.strip().lower() for source in (options.enabled_sources or ()) if source and str(source).strip()) if override: source_chain = [override] + [source for source in source_chain if source != override] + if enabled_sources: + source_chain = [source for source in source_chain if source in enabled_sources] + if not options.allow_fallback: source_chain = source_chain[:1] @@ -176,11 +180,12 @@ def _normalize_context_artists(artists: Any) -> List[Dict[str, Any]]: for artist in artists: if isinstance(artist, dict): name = _extract_lookup_value(artist, 'name', 'artist_name', 'title', default='') or '' - artist_id = _extract_lookup_value(artist, 'id', 'artist_id', default='') or '' + artist_id = _extract_lookup_value(artist, 'source_id', 'id', 'artist_id', default='') or '' entry: Dict[str, Any] = {} if name: entry['name'] = str(name) if artist_id: + entry['source_id'] = str(artist_id) entry['id'] = str(artist_id) genres = _extract_lookup_value(artist, 'genres', default=None) if genres is not None: @@ -289,7 +294,7 @@ def _build_album_info_legacy(album_data: Any, album_id: str, or '' ) resolved_artist_id = str( - _extract_lookup_value(primary_artist, 'id', default='') + _extract_lookup_value(primary_artist, 'source_id', 'id', default='') or _extract_lookup_value(album_data, 'artist_id', default='') or '' ).strip() @@ -300,8 +305,10 @@ def _build_album_info_legacy(album_data: Any, album_id: str, if not image_url: image_url = _extract_lookup_value(album_data, 'image_url', 'thumb_url') + source_id = _extract_lookup_value(album_data, 'source_id', 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id return { - 'id': _extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id, + 'source_id': source_id, + 'id': source_id, 'name': _extract_lookup_value(album_data, 'name', 'title', default=album_name or album_id) or album_name or album_id, 'artist': resolved_artist_name or '', 'artist_name': resolved_artist_name or '', @@ -320,8 +327,10 @@ def _build_album_track_entry(track_item: Any, album_info: Dict[str, Any], source if isinstance(explicit_value, str): explicit_value = explicit_value.lower() == 'explicit' + source_id = _extract_lookup_value(track_item, 'source_id', 'id', 'track_id', 'trackId', default='') or '' return { - 'id': _extract_lookup_value(track_item, 'id', 'track_id', 'trackId', default='') or '', + 'source_id': source_id, + 'id': source_id, 'name': _extract_lookup_value(track_item, 'name', 'track_name', 'trackName', default='Unknown Track') or 'Unknown Track', 'artists': _normalize_track_artists(track_item), 'duration_ms': _extract_lookup_value(track_item, 'duration_ms', 'trackTimeMillis', default=0) or 0, @@ -439,7 +448,7 @@ def get_artist_albums_for_source( if not best: return albums - found_artist_id = _extract_lookup_value(best, 'id', 'artist_id') + found_artist_id = _extract_lookup_value(best, 'source_id', 'id', 'artist_id') if not found_artist_id: return albums @@ -519,9 +528,9 @@ def resolve_album_reference( for album in results: candidate_name = str(_extract_lookup_value(album, 'name', 'title', default='') or '').strip().lower() if candidate_name and candidate_name == str(search_title).strip().lower(): - return _extract_lookup_value(album, 'id', 'album_id', 'release_id'), source + return _extract_lookup_value(album, 'source_id', 'id', 'album_id', 'release_id'), source best = results[0] - return _extract_lookup_value(best, 'id', 'album_id', 'release_id'), source + return _extract_lookup_value(best, 'source_id', 'id', 'album_id', 'release_id'), source if not album_name and not artist_name: return None, None @@ -536,9 +545,9 @@ def resolve_album_reference( for album in results: candidate_name = str(_extract_lookup_value(album, 'name', 'title', default='') or '').strip().lower() if album_name and candidate_name == album_name.strip().lower(): - return _extract_lookup_value(album, 'id', 'album_id', 'release_id'), source + return _extract_lookup_value(album, 'source_id', 'id', 'album_id', 'release_id'), source best = results[0] - return _extract_lookup_value(best, 'id', 'album_id', 'release_id'), source + return _extract_lookup_value(best, 'source_id', 'id', 'album_id', 'release_id'), source except Exception as e: logger.debug("Error resolving album reference %s: %s", album_id, e) @@ -634,6 +643,7 @@ def get_artist_album_tracks( 'resolved_album_id': resolved_album_id, 'tracks': [], 'album': { + 'source_id': resolved_album_id, 'id': resolved_album_id, 'name': album_name or resolved_album_id, 'image_url': None, @@ -652,6 +662,7 @@ def get_artist_album_tracks( 'resolved_album_id': None, 'tracks': [], 'album': { + 'source_id': album_id, 'id': album_id, 'name': album_name or album_id, 'image_url': None, diff --git a/core/metadata/completion.py b/core/metadata/completion.py index 97baefb0..ed6b857b 100644 --- a/core/metadata/completion.py +++ b/core/metadata/completion.py @@ -81,7 +81,7 @@ def _resolve_completion_track_total(release: Dict[str, Any], source_chain: List[ if total_tracks: return int(total_tracks) - release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id') + release_id = _extract_lookup_value(release, 'source_id', 'id', 'album_id', 'release_id') if not release_id: return 0 @@ -111,7 +111,7 @@ def check_album_completion( source_chain = source_chain or _get_completion_source_chain(source_override) album_name = album_data.get('name', '') total_tracks = _resolve_completion_track_total(album_data, source_chain) - album_id = album_data.get('id', '') + album_id = album_data.get('source_id') or album_data.get('id', '') # If total_tracks is 0 (Discogs masters don't include track counts), # try to fetch the real count from the prioritized metadata sources. @@ -136,6 +136,7 @@ def check_album_completion( except Exception as db_error: logger.error(f"Database error for album '{album_name}': {db_error}") return { + "source_id": album_id, "id": album_id, "name": album_name, "status": "error", @@ -172,6 +173,7 @@ def check_album_completion( ) return { + "source_id": album_id, "id": album_id, "name": album_name, "status": status, @@ -186,7 +188,8 @@ def check_album_completion( except Exception as e: logger.error(f"Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}") return { - "id": album_data.get('id', ''), + "source_id": album_data.get('source_id') or album_data.get('id', ''), + "id": album_data.get('source_id') or album_data.get('id', ''), "name": album_data.get('name', 'Unknown'), "status": "error", "owned_tracks": 0, @@ -213,7 +216,7 @@ def check_single_completion( single_name = single_data.get('name', '') raw_total_tracks = single_data.get('total_tracks', 1) total_tracks = raw_total_tracks if raw_total_tracks is not None else 1 - single_id = single_data.get('id', '') + single_id = single_data.get('source_id') or single_data.get('id', '') album_type = single_data.get('album_type', 'single') formats = [] @@ -267,6 +270,7 @@ def check_single_completion( ) return { + "source_id": single_id, "id": single_id, "name": single_name, "status": status, @@ -316,6 +320,7 @@ def check_single_completion( ) return { + "source_id": single_id, "id": single_id, "name": single_name, "status": status, @@ -331,7 +336,8 @@ def check_single_completion( except Exception as e: logger.error(f"Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}") return { - "id": single_data.get('id', ''), + "source_id": single_data.get('source_id') or single_data.get('id', ''), + "id": single_data.get('source_id') or single_data.get('id', ''), "name": single_data.get('name', 'Unknown'), "status": "error", "owned_tracks": 0, @@ -411,7 +417,8 @@ def iter_artist_discography_completion_events( yield { 'type': 'error', 'container_type': 'albums', - 'id': album.get('id', ''), + 'source_id': album.get('source_id') or album.get('id', ''), + 'id': album.get('source_id') or album.get('id', ''), 'name': album.get('name', 'Unknown'), 'error': str(e), } @@ -436,7 +443,8 @@ def iter_artist_discography_completion_events( yield { 'type': 'error', 'container_type': 'singles', - 'id': single.get('id', ''), + 'source_id': single.get('source_id') or single.get('id', ''), + 'id': single.get('source_id') or single.get('id', ''), 'name': single.get('name', 'Unknown'), 'error': str(e), } diff --git a/core/metadata/constants.py b/core/metadata/constants.py new file mode 100644 index 00000000..4a21dcb2 --- /dev/null +++ b/core/metadata/constants.py @@ -0,0 +1,15 @@ +"""Shared metadata source constants.""" + +from __future__ import annotations + +METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase") + +METADATA_SOURCE_LABELS = { + "spotify": "Spotify", + "itunes": "iTunes", + "deezer": "Deezer", + "discogs": "Discogs", + "hydrabase": "Hydrabase", +} + +METADATA_PROVIDER_SOURCES = ("spotify", "deezer", "itunes", "discogs") diff --git a/core/metadata/contracts.py b/core/metadata/contracts.py new file mode 100644 index 00000000..275ff4ac --- /dev/null +++ b/core/metadata/contracts.py @@ -0,0 +1,71 @@ +"""Typed metadata engine contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Generic, Literal, Optional, Sequence, TypeVar + +T = TypeVar("T") + +MetadataEntityKind = Literal["artist", "album", "track"] + + +@dataclass(frozen=True) +class MetadataSearchRequest: + entity_kind: MetadataEntityKind + query: str + limit: int = 20 + source_override: Optional[str] = None + enabled_sources: Optional[Sequence[str]] = None + allow_fallback: bool = True + skip_cache: bool = False + max_pages: int = 0 + dedup_variants: bool = True + artist_source_ids: Optional[dict[str, str]] = None + + +@dataclass(frozen=True) +class MetadataLookupRequest: + entity_kind: MetadataEntityKind + entity_id: str + source_override: Optional[str] = None + enabled_sources: Optional[Sequence[str]] = None + allow_fallback: bool = True + skip_cache: bool = False + include_tracks: bool = True + limit: int = 50 + max_pages: int = 0 + + +@dataclass(frozen=True) +class MetadataSearchOutcome(Generic[T]): + items: list[T] = field(default_factory=list) + source: Optional[str] = None + attempted_sources: tuple[str, ...] = () + cache_hit: bool = False + status: str = "miss" + errors: tuple[str, ...] = () + raw_payload: Any = None + + +@dataclass(frozen=True) +class MetadataLookupOutcome(Generic[T]): + value: Optional[T] = None + source: Optional[str] = None + attempted_sources: tuple[str, ...] = () + cache_hit: bool = False + status: str = "miss" + errors: tuple[str, ...] = () + raw_payload: Any = None + + +@dataclass(frozen=True) +class MetadataProviderStatus: + provider: str + configured: bool + available: bool + authenticated: bool = False + rate_limited: bool = False + retry_after: Optional[int] = None + last_error: Optional[str] = None + details: dict[str, Any] = field(default_factory=dict) diff --git a/core/metadata/discography.py b/core/metadata/discography.py index 35f6df77..82e8bf6d 100644 --- a/core/metadata/discography.py +++ b/core/metadata/discography.py @@ -68,10 +68,14 @@ def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]: primary_source = metadata_registry.get_primary_source() source_chain = list(metadata_registry.get_source_priority(primary_source)) override = (options.source_override or '').strip().lower() + enabled_sources = tuple(source.strip().lower() for source in (options.enabled_sources or ()) if source and str(source).strip()) if override: source_chain = [override] + [source for source in source_chain if source != override] + if enabled_sources: + source_chain = [source for source in source_chain if source in enabled_sources] + if not options.allow_fallback: source_chain = source_chain[:1] @@ -142,6 +146,7 @@ def _build_discography_release_dict(release: Any, artist_id: str, return None artist_name = typed_album.artists[0] if typed_album.artists else '' return { + 'source_id': typed_album.id, 'id': typed_album.id, 'name': typed_album.name or typed_album.id, 'artist_name': artist_name, @@ -152,7 +157,7 @@ def _build_discography_release_dict(release: Any, artist_id: str, 'external_urls': typed_album.external_urls or {}, } - release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id') + release_id = _extract_lookup_value(release, 'source_id', 'id', 'album_id', 'release_id') if not release_id: return None @@ -160,6 +165,7 @@ def _build_discography_release_dict(release: Any, artist_id: str, release_date = _extract_lookup_value(release, 'release_date') return { + 'source_id': release_id, 'id': release_id, 'name': _extract_lookup_value(release, 'name', 'title', default=release_id), 'artist_name': _extract_release_artist_name(release), @@ -374,7 +380,7 @@ def get_artist_discography( if not release_data: continue - release_id = release_data['id'] + release_id = _extract_lookup_value(release_data, 'source_id', 'id') if release_id in seen_albums: continue seen_albums.add(release_id) @@ -427,6 +433,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any], release_year = None card = { + 'source_id': typed_album.id, 'id': typed_album.id, 'name': typed_album.name or typed_album.id, 'title': typed_album.name or typed_album.id, @@ -443,7 +450,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any], card['release_date'] = f"{release_year}-01-01" return card - release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id') + release_id = _extract_lookup_value(release, 'source_id', 'id', 'album_id', 'release_id') if not release_id: return None @@ -461,6 +468,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any], release_year = str(release_year) card = { + 'source_id': release_id, 'id': release_id, 'name': _extract_lookup_value(release, 'name', 'title', default=release_id), 'title': _extract_lookup_value(release, 'name', 'title', default=release_id), @@ -502,7 +510,7 @@ def get_artist_detail_discography( if not card: continue - release_id = card['id'] + release_id = _extract_lookup_value(card, 'source_id', 'id') if release_id in seen_ids: continue seen_ids.add(release_id) diff --git a/core/metadata/engine.py b/core/metadata/engine.py new file mode 100644 index 00000000..b81accd0 --- /dev/null +++ b/core/metadata/engine.py @@ -0,0 +1,1144 @@ +"""Typed metadata engine and compatibility facades.""" + +from __future__ import annotations + +import threading +from typing import Any, Callable, Optional, Sequence + +from core.metadata.cache import get_metadata_cache +from core.metadata.constants import METADATA_PROVIDER_SOURCES, METADATA_SOURCE_PRIORITY +from core.metadata.contracts import ( + MetadataEntityKind, + MetadataLookupOutcome, + MetadataLookupRequest, + MetadataProviderStatus, + MetadataSearchOutcome, + MetadataSearchRequest, +) +from core.metadata.exceptions import MetadataProviderError, MetadataRateLimited +from core.metadata.models import MetadataRecord, as_metadata_record +from core.metadata.normalize import ( + normalize_deezer_album, + normalize_deezer_artist, + normalize_deezer_track, + normalize_discogs_album, + normalize_discogs_artist, + normalize_discogs_track, + normalize_itunes_album, + normalize_itunes_artist, + normalize_itunes_track, + normalize_spotify_album, + normalize_spotify_artist, + normalize_spotify_track, +) +from core.metadata.providers import ( + DeezerMetadataAdapter, + DiscogsMetadataAdapter, + ITunesMetadataAdapter, + SpotifyMetadataAdapter, +) +from utils.logging_config import get_logger + +logger = get_logger("metadata.engine") + + +def _normalize_source(source: Optional[str]) -> str: + return (source or "").strip().lower() + + +def _dedupe(values: Sequence[str]) -> tuple[str, ...]: + seen = set() + ordered: list[str] = [] + for value in values: + normalized = _normalize_source(value) + if not normalized or normalized in seen: + continue + seen.add(normalized) + ordered.append(normalized) + return tuple(ordered) + + +def _normalize_enabled_sources(value: Any) -> tuple[str, ...]: + if value is None: + return METADATA_PROVIDER_SOURCES + if isinstance(value, str): + parts = [part.strip() for part in value.split(",")] + return _dedupe(parts) + try: + return _dedupe(list(value)) + except TypeError: + return METADATA_PROVIDER_SOURCES + + +def _read_global_enabled_sources() -> tuple[str, ...]: + try: + from config.settings import config_manager + + configured = config_manager.get("metadata.enabled_sources", None) + except Exception: + configured = None + enabled = _normalize_enabled_sources(configured) + return enabled or METADATA_PROVIDER_SOURCES + + +def _provider_source_chain(preferred_source: Optional[str]) -> tuple[str, ...]: + primary = _normalize_source(preferred_source) + ordered = [] + if primary in METADATA_SOURCE_PRIORITY: + ordered.append(primary) + for source in METADATA_SOURCE_PRIORITY: + if source not in ordered: + ordered.append(source) + return tuple(source for source in ordered if source in METADATA_PROVIDER_SOURCES) + + +def _record_from_mapping(raw: Any, **extra: Any) -> MetadataRecord: + if isinstance(raw, MetadataRecord): + payload = raw.copy() + elif isinstance(raw, dict): + payload = MetadataRecord(raw) + else: + payload = MetadataRecord({}) + payload.update(extra) + return payload + + +def _record_identity(value: Any) -> str: + if value is None: + return "" + if isinstance(value, dict): + source_id = value.get("source_id") or value.get("id") or value.get("artist_id") or value.get("album_id") + return str(source_id or "") + source_id = getattr(value, "source_id", None) or getattr(value, "id", None) + return str(source_id or "") + + +def _extract_image_url(raw: Any) -> Optional[str]: + if not isinstance(raw, dict): + return None + images = raw.get("images") or [] + if isinstance(images, list) and images: + first = images[0] + if isinstance(first, dict): + return first.get("url") or first.get("uri") + return ( + raw.get("image_url") + or raw.get("thumb_url") + or raw.get("cover_image") + or raw.get("picture_xl") + or raw.get("picture_big") + or raw.get("picture_medium") + or raw.get("artworkUrl600") + or raw.get("artworkUrl100") + ) + + +def _normalize_artist_artists(raw: Any) -> list[dict[str, Any]]: + artists: list[dict[str, Any]] = [] + if not raw: + return artists + + if isinstance(raw, dict): + raw = [raw] + elif isinstance(raw, (str, bytes)): + raw = [raw] + + try: + iterable = list(raw) + except TypeError: + iterable = [raw] + + for item in iterable: + if isinstance(item, dict): + entry: dict[str, Any] = {} + name = item.get("name") or item.get("artist_name") or item.get("title") + if name: + entry["name"] = str(name) + item_id = item.get("source_id") or item.get("id") or item.get("artist_id") + if item_id: + entry["id"] = str(item_id) + if item.get("genres"): + entry["genres"] = item.get("genres") + if entry: + artists.append(entry) + continue + + name = str(item).strip() + if name: + artists.append({"name": name}) + return artists + + +def _artist_payload(source: str, raw: Any) -> MetadataRecord: + model = { + "spotify": normalize_spotify_artist, + "deezer": normalize_deezer_artist, + "itunes": normalize_itunes_artist, + "discogs": normalize_discogs_artist, + }[source](raw or {}) + + payload = _record_from_mapping(raw or {}) + payload.update(model.to_dict()) + payload["source_id"] = _record_identity(model) + payload["id"] = payload["source_id"] + payload["name"] = payload.get("name") or model.name + payload["image_url"] = payload.get("image_url") or model.image_url + payload["images"] = payload.get("images") or ([{"url": model.image_url}] if model.image_url else []) + payload["genres"] = payload.get("genres") or list(model.genres) + payload["popularity"] = payload.get("popularity", model.popularity) + payload["followers"] = model.followers + payload["external_urls"] = payload.get("external_urls") or dict(model.external_urls or {}) + payload["source"] = source + payload["raw_data"] = raw + return payload + + +def _album_payload(source: str, raw: Any) -> MetadataRecord: + model = { + "spotify": normalize_spotify_album, + "deezer": normalize_deezer_album, + "itunes": normalize_itunes_album, + "discogs": normalize_discogs_album, + }[source](raw or {}) + + payload = _record_from_mapping(raw or {}) + payload.update(model.to_dict()) + payload["source_id"] = _record_identity(model) + payload["id"] = payload["source_id"] + payload["name"] = payload.get("name") or model.name + payload["image_url"] = payload.get("image_url") or model.image_url + payload["images"] = payload.get("images") or ([{"url": model.image_url}] if model.image_url else []) + + raw_artists = payload.get("artists") + if not raw_artists: + payload["artists"] = _normalize_artist_artists(raw.get("artists") if isinstance(raw, dict) else None) + elif isinstance(raw_artists, list) and raw_artists and isinstance(raw_artists[0], str): + payload["artists"] = _normalize_artist_artists(raw_artists) + + if not payload.get("artists") and model.artists: + payload["artists"] = _normalize_artist_artists(model.artists) + + payload["artist_name"] = payload.get("artist_name") or ( + payload["artists"][0]["name"] if payload.get("artists") else None + ) + payload["release_date"] = payload.get("release_date") or model.release_date + payload["total_tracks"] = payload.get("total_tracks") or model.total_tracks + payload["album_type"] = payload.get("album_type") or model.album_type + payload["external_urls"] = payload.get("external_urls") or dict(model.external_urls or {}) + payload["source"] = source + payload["raw_data"] = raw + return payload + + +def _track_payload(source: str, raw: Any) -> MetadataRecord: + raw_dict = raw if isinstance(raw, dict) else {} + model = { + "spotify": normalize_spotify_track, + "deezer": normalize_deezer_track, + "itunes": normalize_itunes_track, + "discogs": normalize_discogs_track, + }[source](raw_dict or {}) + + payload = _record_from_mapping(raw_dict or {}) + payload.update(model.to_dict()) + payload["source_id"] = _record_identity(model) + payload["id"] = payload["source_id"] + payload["name"] = payload.get("name") or model.name + payload["artists"] = payload.get("artists") or _normalize_artist_artists(raw_dict.get("artists")) + if not payload["artists"] and model.artists: + payload["artists"] = _normalize_artist_artists(model.artists) + payload["album_name"] = payload.get("album_name") or model.album + + album_raw = raw_dict.get("album") + if isinstance(album_raw, dict): + album_payload = _album_payload(source, album_raw) + payload["album"] = album_payload + else: + payload["album"] = _record_from_mapping( + { + "id": raw_dict.get("album_id") or raw_dict.get("collectionId") or raw_dict.get("release_id") or "", + "name": model.album, + "artists": payload["artists"], + "image_url": model.image_url, + "release_date": model.release_date, + "album_type": model.album_type, + "total_tracks": model.total_tracks, + } + ) + payload["duration_ms"] = payload.get("duration_ms") or model.duration_ms + payload["popularity"] = payload.get("popularity") or model.popularity + payload["preview_url"] = payload.get("preview_url") or model.preview_url + payload["external_urls"] = payload.get("external_urls") or dict(model.external_urls or {}) + payload["image_url"] = payload.get("image_url") or model.image_url + payload["release_date"] = payload.get("release_date") or model.release_date + payload["track_number"] = payload.get("track_number") or model.track_number + payload["disc_number"] = payload.get("disc_number") or model.disc_number + payload["album_type"] = payload.get("album_type") or model.album_type + payload["total_tracks"] = payload.get("total_tracks") or model.total_tracks + payload["source"] = source + if raw_dict.get("external_ids"): + payload["external_ids"] = raw_dict.get("external_ids") + if raw_dict.get("uri"): + payload["uri"] = raw_dict.get("uri") + payload["raw_data"] = raw + return payload + + +class MetadataSourceFacade: + """Compatibility facade for a single provider source.""" + + def __init__(self, engine: "MetadataEngine", source: str) -> None: + self._engine = engine + self.source = _normalize_source(source) + self.provider_name = self.source + + def reload_config(self) -> None: + self._engine.reload_config() + + def is_authenticated(self) -> bool: + status = self._engine.get_provider_status(self.source) + return bool(status.authenticated) + + def is_spotify_authenticated(self) -> bool: + return self.is_authenticated() + + def is_connected(self) -> bool: + return self.is_authenticated() + + def get_status(self) -> MetadataProviderStatus: + return self._engine.get_provider_status(self.source) + + def search_tracks(self, query: str, limit: int = 20, **kwargs) -> list[MetadataRecord]: + return self._engine.search_tracks( + query, + limit=limit, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + max_pages=int(kwargs.get("max_pages", 0) or 0), + ).items + + def search_artists(self, query: str, limit: int = 20, **kwargs) -> list[MetadataRecord]: + return self._engine.search_artists( + query, + limit=limit, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + max_pages=int(kwargs.get("max_pages", 0) or 0), + ).items + + def search_albums(self, query: str, limit: int = 20, **kwargs) -> list[MetadataRecord]: + return self._engine.search_albums( + query, + limit=limit, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + max_pages=int(kwargs.get("max_pages", 0) or 0), + ).items + + def get_track_details(self, track_id: str, **kwargs) -> Optional[MetadataRecord]: + return self._engine.get_track_details( + track_id, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + ).value + + def get_track_features(self, track_id: str, **kwargs) -> Optional[dict[str, Any]]: + return self._engine.get_track_features( + track_id, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + ).value + + def get_album(self, album_id: str, **kwargs) -> Optional[MetadataRecord]: + include_tracks = kwargs.get("include_tracks", True) + return self._engine.get_album( + album_id, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + include_tracks=bool(include_tracks), + max_pages=int(kwargs.get("max_pages", 0) or 0), + ).value + + def get_album_tracks(self, album_id: str, **kwargs) -> Optional[dict[str, Any]]: + return self._engine.get_album_tracks( + album_id, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + max_pages=int(kwargs.get("max_pages", 0) or 0), + limit=int(kwargs.get("limit", 50) or 50), + ).value + + def get_album_tracks_dict(self, album_id: str, **kwargs) -> Optional[dict[str, Any]]: + return self.get_album_tracks(album_id, **kwargs) + + def get_artist(self, artist_id: str, **kwargs) -> Optional[MetadataRecord]: + return self._engine.get_artist( + artist_id, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + ).value + + def get_artist_albums( + self, + artist_id: str, + album_type: str = "album,single", + limit: int = 50, + **kwargs, + ) -> list[MetadataRecord]: + return self._engine.get_artist_albums( + artist_id, + album_type=album_type, + limit=limit, + source_override=self.source, + allow_fallback=bool(kwargs.get("allow_fallback", False)), + skip_cache=bool(kwargs.get("skip_cache", False)), + max_pages=int(kwargs.get("max_pages", 0) or 0), + ).items + + def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]: + outcome = self._engine.get_artist_albums( + artist_id, + limit=5, + source_override=self.source, + allow_fallback=False, + ) + for album in outcome.items: + image_url = album.get("image_url") + if image_url: + return image_url + return None + + +class MetadataEngine: + """Typed metadata engine for provider orchestration and normalization.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._adapters: dict[str, Any] = {} + self._facades: dict[str, MetadataSourceFacade] = {} + + def reload_config(self) -> None: + with self._lock: + for adapter in self._adapters.values(): + try: + adapter.reload_config() + except Exception as exc: + logger.debug("Could not reload metadata adapter config: %s", exc) + + def _get_adapter(self, source: str): + source = _normalize_source(source) + if source not in METADATA_PROVIDER_SOURCES: + return None + + with self._lock: + adapter = self._adapters.get(source) + if adapter is not None: + return adapter + + if source == "spotify": + adapter = SpotifyMetadataAdapter() + elif source == "deezer": + adapter = DeezerMetadataAdapter() + elif source == "itunes": + adapter = ITunesMetadataAdapter() + elif source == "discogs": + adapter = DiscogsMetadataAdapter() + else: # pragma: no cover - defensive fallback + return None + + self._adapters[source] = adapter + return adapter + + def get_provider_status(self, source: str) -> MetadataProviderStatus: + adapter = self._get_adapter(source) + if adapter is None: + return MetadataProviderStatus( + provider=_normalize_source(source), + configured=False, + available=False, + ) + + try: + return adapter.get_status() + except Exception as exc: + logger.debug("Could not query status for %s: %s", source, exc) + return MetadataProviderStatus( + provider=_normalize_source(source), + configured=False, + available=False, + last_error=str(exc), + ) + + def get_source_facade(self, source: str) -> Optional[MetadataSourceFacade]: + source = _normalize_source(source) + if source not in METADATA_PROVIDER_SOURCES and source != "hydrabase": + return None + + with self._lock: + facade = self._facades.get(source) + if facade is not None: + return facade + if source not in METADATA_PROVIDER_SOURCES: + return None + facade = MetadataSourceFacade(self, source) + self._facades[source] = facade + return facade + + def _resolve_source_chain( + self, + *, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + ) -> tuple[str, ...]: + requested = _normalize_source(source_override) + global_enabled = _read_global_enabled_sources() + request_enabled = _normalize_enabled_sources(enabled_sources) if enabled_sources is not None else None + if request_enabled is not None and not request_enabled: + return () + + chain = list(_provider_source_chain(requested or None)) + if requested and requested not in chain: + chain.insert(0, requested) + + if request_enabled is not None: + allowed = set(request_enabled) + chain = [source for source in chain if source in allowed] + + allowed_global = set(global_enabled) + chain = [source for source in chain if source in allowed_global] + + if requested and requested in METADATA_PROVIDER_SOURCES and requested not in chain: + if requested in allowed_global and (request_enabled is None or requested in set(request_enabled)): + chain.insert(0, requested) + + if not allow_fallback: + chain = chain[:1] + + return _dedupe(chain) + + def _entity_kind_to_methods( + self, + source: str, + entity_kind: MetadataEntityKind, + ) -> tuple[Callable[..., Any], Callable[[Any], MetadataRecord]]: + adapter = self._get_adapter(source) + if adapter is None: + raise MetadataProviderError(source, entity_kind, f"Unknown provider: {source}") + + if entity_kind == "artist": + return adapter.get_artist_raw, lambda raw: _artist_payload(source, raw) + if entity_kind == "album": + return adapter.get_album_raw, lambda raw: _album_payload(source, raw) + if entity_kind == "track": + return adapter.get_track_raw, lambda raw: _track_payload(source, raw) + raise MetadataProviderError(source, entity_kind, f"Unsupported entity kind: {entity_kind}") + + def _search_source( + self, + source: str, + entity_kind: MetadataEntityKind, + query: str, + limit: int, + ) -> list[MetadataRecord]: + adapter = self._get_adapter(source) + if adapter is None: + return [] + + if entity_kind == "artist": + raw_results = adapter.search_artists_raw(query, limit=limit) + normalizer = lambda raw: _artist_payload(source, raw) + elif entity_kind == "album": + raw_results = adapter.search_albums_raw(query, limit=limit) + normalizer = lambda raw: _album_payload(source, raw) + elif entity_kind == "track": + raw_results = adapter.search_tracks_raw(query, limit=limit) + normalizer = lambda raw: _track_payload(source, raw) + else: + return [] + + if not raw_results: + return [] + + normalized = [normalizer(raw) for raw in raw_results if raw] + if normalized: + try: + entity_rows = [ + (source_id, item.get("raw_data") or item) + for item in normalized + if (source_id := _record_identity(item)) + ] + get_metadata_cache().store_entities_bulk( + source, + entity_kind, + entity_rows, + skip_if_exists=True, + ) + except Exception as exc: + logger.debug("Could not cache %s search results from %s: %s", entity_kind, source, exc) + return normalized + + def search_artists( + self, + query: str, + *, + limit: int = 20, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + max_pages: int = 0, + ) -> MetadataSearchOutcome[MetadataRecord]: + request = MetadataSearchRequest( + entity_kind="artist", + query=query, + limit=limit, + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + skip_cache=skip_cache, + max_pages=max_pages, + ) + return self._search(request) + + def search_albums( + self, + query: str, + *, + limit: int = 20, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + max_pages: int = 0, + ) -> MetadataSearchOutcome[MetadataRecord]: + request = MetadataSearchRequest( + entity_kind="album", + query=query, + limit=limit, + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + skip_cache=skip_cache, + max_pages=max_pages, + ) + return self._search(request) + + def search_tracks( + self, + query: str, + *, + limit: int = 20, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + max_pages: int = 0, + ) -> MetadataSearchOutcome[MetadataRecord]: + request = MetadataSearchRequest( + entity_kind="track", + query=query, + limit=limit, + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + skip_cache=skip_cache, + max_pages=max_pages, + ) + return self._search(request) + + def _search(self, request: MetadataSearchRequest) -> MetadataSearchOutcome[MetadataRecord]: + chain = self._resolve_source_chain( + source_override=request.source_override, + enabled_sources=request.enabled_sources, + allow_fallback=request.allow_fallback, + ) + errors: list[str] = [] + attempted: list[str] = [] + for source in chain: + attempted.append(source) + try: + results = self._search_source(source, request.entity_kind, request.query, request.limit) + except MetadataRateLimited as exc: + errors.append(f"{source}: rate limited") + logger.debug("Search rate limited on %s for %s: %s", source, request.query, exc) + continue + except MetadataProviderError as exc: + errors.append(f"{source}: {exc}") + logger.debug("Search failed on %s for %s: %s", source, request.query, exc) + continue + except Exception as exc: # pragma: no cover - defensive + errors.append(f"{source}: {exc}") + logger.debug("Search failed on %s for %s: %s", source, request.query, exc) + continue + + if results: + return MetadataSearchOutcome( + items=results[: request.limit], + source=source, + attempted_sources=tuple(attempted), + cache_hit=False, + status="ok", + errors=tuple(errors), + raw_payload=results, + ) + + return MetadataSearchOutcome( + items=[], + source=None, + attempted_sources=tuple(attempted), + cache_hit=False, + status="miss" if not errors else "error", + errors=tuple(errors), + raw_payload=None, + ) + + def get_artist( + self, + artist_id: str, + *, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + ) -> MetadataLookupOutcome[MetadataRecord]: + return self._lookup("artist", artist_id, source_override=source_override, enabled_sources=enabled_sources, allow_fallback=allow_fallback, skip_cache=skip_cache) + + def get_album( + self, + album_id: str, + *, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + include_tracks: bool = True, + max_pages: int = 0, + ) -> MetadataLookupOutcome[MetadataRecord]: + outcome = self._lookup( + "album", + album_id, + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + skip_cache=skip_cache, + ) + if outcome.value is None: + return outcome + if not include_tracks: + return outcome + + album = _record_from_mapping(outcome.value) + tracks = None + try: + tracks = self.get_album_tracks( + album_id, + source_override=outcome.source or source_override, + enabled_sources=enabled_sources, + allow_fallback=False, + skip_cache=skip_cache, + max_pages=max_pages, + ).value + except Exception as exc: + logger.debug("Could not fetch embedded tracks for album %s: %s", album_id, exc) + if tracks and isinstance(tracks, dict): + album["tracks"] = tracks + return MetadataLookupOutcome( + value=album, + source=outcome.source, + attempted_sources=outcome.attempted_sources, + cache_hit=outcome.cache_hit, + status=outcome.status, + errors=outcome.errors, + raw_payload=outcome.raw_payload, + ) + + def get_track_details( + self, + track_id: str, + *, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + ) -> MetadataLookupOutcome[MetadataRecord]: + return self._lookup("track", track_id, source_override=source_override, enabled_sources=enabled_sources, allow_fallback=allow_fallback, skip_cache=skip_cache) + + def get_track_features( + self, + track_id: str, + *, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + ) -> MetadataLookupOutcome[dict[str, Any]]: + chain = self._resolve_source_chain( + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + ) + attempted: list[str] = [] + errors: list[str] = [] + for source in chain: + if source != "spotify": + continue + attempted.append(source) + adapter = self._get_adapter(source) + if adapter is None or not hasattr(adapter, "get_track_features_raw"): + continue + try: + raw = adapter.get_track_features_raw(track_id) + if raw: + return MetadataLookupOutcome( + value=raw, + source=source, + attempted_sources=tuple(attempted), + cache_hit=False, + status="ok", + errors=tuple(errors), + raw_payload=raw, + ) + except Exception as exc: + errors.append(f"{source}: {exc}") + logger.debug("Track features lookup failed on %s for %s: %s", source, track_id, exc) + return MetadataLookupOutcome( + value=None, + source=None, + attempted_sources=tuple(attempted), + cache_hit=False, + status="miss" if not errors else "error", + errors=tuple(errors), + raw_payload=None, + ) + + def get_album_tracks( + self, + album_id: str, + *, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + max_pages: int = 0, + limit: int = 50, + ) -> MetadataLookupOutcome[dict[str, Any]]: + chain = self._resolve_source_chain( + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + ) + attempted: list[str] = [] + errors: list[str] = [] + cache = get_metadata_cache() + + for source in chain: + attempted.append(source) + adapter = self._get_adapter(source) + if adapter is None: + continue + + album_raw = None + tracks_raw = None + cache_hit = False + + if not skip_cache: + try: + album_raw = cache.get_entity(source, "album", album_id) + cache_hit = album_raw is not None + except Exception as exc: + logger.debug("Album cache lookup failed for %s/%s: %s", source, album_id, exc) + + if album_raw is None: + try: + album_raw = adapter.get_album_raw(album_id) + except MetadataRateLimited as exc: + errors.append(f"{source}: rate limited") + logger.debug("Album lookup rate limited on %s for %s: %s", source, album_id, exc) + continue + except MetadataProviderError as exc: + errors.append(f"{source}: {exc}") + logger.debug("Album lookup failed on %s for %s: %s", source, album_id, exc) + continue + except Exception as exc: + errors.append(f"{source}: {exc}") + logger.debug("Album lookup failed on %s for %s: %s", source, album_id, exc) + continue + + if album_raw: + try: + cache.store_entity(source, "album", album_id, album_raw) + except Exception as exc: + logger.debug("Could not cache album %s from %s: %s", album_id, source, exc) + + if album_raw is None: + continue + + album_payload = _album_payload(source, album_raw) + embedded_tracks = album_raw.get("tracks") if isinstance(album_raw, dict) else None + if isinstance(embedded_tracks, dict): + items = embedded_tracks.get("items") or embedded_tracks.get("data") or [] + elif isinstance(embedded_tracks, list): + items = embedded_tracks + else: + items = [] + + if not items: + try: + tracks_raw = adapter.get_album_tracks_raw(album_id, limit=limit, max_pages=max_pages) + except MetadataRateLimited as exc: + errors.append(f"{source}: rate limited") + logger.debug("Tracklist lookup rate limited on %s for %s: %s", source, album_id, exc) + continue + except MetadataProviderError as exc: + errors.append(f"{source}: {exc}") + logger.debug("Tracklist lookup failed on %s for %s: %s", source, album_id, exc) + continue + except Exception as exc: + errors.append(f"{source}: {exc}") + logger.debug("Tracklist lookup failed on %s for %s: %s", source, album_id, exc) + continue + if tracks_raw: + items = tracks_raw.get("items") or tracks_raw.get("data") or [] + album_from_tracks = tracks_raw.get("album") if isinstance(tracks_raw, dict) else None + if album_from_tracks and not album_payload.get("image_url"): + album_payload = _album_payload(source, album_from_tracks) + + normalized_tracks = [_track_payload(source, item) for item in items if item] + if not normalized_tracks: + continue + + tracks_payload = { + "items": normalized_tracks, + "tracks": normalized_tracks, + "album": album_payload, + "total": len(normalized_tracks), + "limit": len(normalized_tracks), + "next": None, + "source": source, + "success": True, + } + album_payload["tracks"] = tracks_payload + return MetadataLookupOutcome( + value=tracks_payload, + source=source, + attempted_sources=tuple(attempted), + cache_hit=cache_hit, + status="ok", + errors=tuple(errors), + raw_payload=tracks_raw or album_raw, + ) + + return MetadataLookupOutcome( + value=None, + source=None, + attempted_sources=tuple(attempted), + cache_hit=False, + status="miss" if not errors else "error", + errors=tuple(errors), + raw_payload=None, + ) + + def get_artist_albums( + self, + artist_id: str, + *, + album_type: str = "album,single", + limit: int = 50, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + max_pages: int = 0, + ) -> MetadataSearchOutcome[MetadataRecord]: + chain = self._resolve_source_chain( + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + ) + attempted: list[str] = [] + errors: list[str] = [] + for source in chain: + attempted.append(source) + adapter = self._get_adapter(source) + if adapter is None or not hasattr(adapter, "get_artist_albums_raw"): + continue + try: + raw_albums = adapter.get_artist_albums_raw( + artist_id, + album_type=album_type, + limit=limit, + max_pages=max_pages, + ) + except MetadataRateLimited as exc: + errors.append(f"{source}: rate limited") + logger.debug("Artist albums lookup rate limited on %s for %s: %s", source, artist_id, exc) + continue + except MetadataProviderError as exc: + errors.append(f"{source}: {exc}") + logger.debug("Artist albums lookup failed on %s for %s: %s", source, artist_id, exc) + continue + except Exception as exc: + errors.append(f"{source}: {exc}") + logger.debug("Artist albums lookup failed on %s for %s: %s", source, artist_id, exc) + continue + + if not raw_albums: + continue + + normalized = [_album_payload(source, raw) for raw in raw_albums if raw] + if normalized: + try: + entity_rows = [ + (source_id, item.get("raw_data") or item) + for item in normalized + if (source_id := _record_identity(item)) + ] + get_metadata_cache().store_entities_bulk( + source, + "album", + entity_rows, + skip_if_exists=True, + ) + except Exception as exc: + logger.debug("Could not cache artist albums from %s: %s", source, exc) + return MetadataSearchOutcome( + items=normalized[:limit], + source=source, + attempted_sources=tuple(attempted), + cache_hit=False, + status="ok", + errors=tuple(errors), + raw_payload=raw_albums, + ) + + return MetadataSearchOutcome( + items=[], + source=None, + attempted_sources=tuple(attempted), + cache_hit=False, + status="miss" if not errors else "error", + errors=tuple(errors), + raw_payload=None, + ) + + def _lookup( + self, + entity_kind: MetadataEntityKind, + entity_id: str, + *, + source_override: Optional[str] = None, + enabled_sources: Optional[Sequence[str]] = None, + allow_fallback: bool = True, + skip_cache: bool = False, + ) -> MetadataLookupOutcome[MetadataRecord]: + chain = self._resolve_source_chain( + source_override=source_override, + enabled_sources=enabled_sources, + allow_fallback=allow_fallback, + ) + attempted: list[str] = [] + errors: list[str] = [] + cache = get_metadata_cache() + + for source in chain: + attempted.append(source) + try: + raw_cache = None + cache_hit = False + if not skip_cache and entity_kind in {"artist", "album", "track"}: + try: + raw_cache = cache.get_entity(source, entity_kind, entity_id) + cache_hit = raw_cache is not None + except Exception as exc: + logger.debug("Cache lookup failed for %s/%s/%s: %s", source, entity_kind, entity_id, exc) + + if raw_cache is not None: + payload = self._lookup_payload_for_source(source, entity_kind, raw_cache) + return MetadataLookupOutcome( + value=payload, + source=source, + attempted_sources=tuple(attempted), + cache_hit=cache_hit, + status="ok", + errors=tuple(errors), + raw_payload=raw_cache, + ) + + get_raw, payload_builder = self._entity_kind_to_methods(source, entity_kind) + raw = get_raw(entity_id) + if not raw: + continue + + payload = payload_builder(raw) + try: + cache.store_entity(source, entity_kind, _record_identity(payload) or entity_id, raw) + except Exception as exc: + logger.debug("Could not cache %s/%s/%s: %s", source, entity_kind, entity_id, exc) + + return MetadataLookupOutcome( + value=payload, + source=source, + attempted_sources=tuple(attempted), + cache_hit=False, + status="ok", + errors=tuple(errors), + raw_payload=raw, + ) + except MetadataRateLimited as exc: + errors.append(f"{source}: rate limited") + logger.debug("%s lookup rate limited on %s for %s: %s", entity_kind, source, entity_id, exc) + continue + except MetadataProviderError as exc: + errors.append(f"{source}: {exc}") + logger.debug("%s lookup failed on %s for %s: %s", entity_kind, source, entity_id, exc) + continue + except Exception as exc: + errors.append(f"{source}: {exc}") + logger.debug("%s lookup failed on %s for %s: %s", entity_kind, source, entity_id, exc) + continue + + return MetadataLookupOutcome( + value=None, + source=None, + attempted_sources=tuple(attempted), + cache_hit=False, + status="miss" if not errors else "error", + errors=tuple(errors), + raw_payload=None, + ) + + def _lookup_payload_for_source(self, source: str, entity_kind: MetadataEntityKind, raw: Any) -> MetadataRecord: + if entity_kind == "artist": + return _artist_payload(source, raw) + if entity_kind == "album": + return _album_payload(source, raw) + if entity_kind == "track": + return _track_payload(source, raw) + raise MetadataProviderError(source, entity_kind, f"Unsupported entity kind: {entity_kind}") + + +_ENGINE_INSTANCE: Optional[MetadataEngine] = None +_ENGINE_LOCK = threading.RLock() + + +def get_metadata_engine() -> MetadataEngine: + global _ENGINE_INSTANCE + with _ENGINE_LOCK: + if _ENGINE_INSTANCE is None: + _ENGINE_INSTANCE = MetadataEngine() + return _ENGINE_INSTANCE + + +def clear_metadata_engine_cache() -> None: + global _ENGINE_INSTANCE + with _ENGINE_LOCK: + _ENGINE_INSTANCE = None diff --git a/core/metadata/exceptions.py b/core/metadata/exceptions.py new file mode 100644 index 00000000..85187b23 --- /dev/null +++ b/core/metadata/exceptions.py @@ -0,0 +1,34 @@ +"""Metadata engine and provider exceptions.""" + +from __future__ import annotations + +from typing import Any, Optional + + +class MetadataProviderError(RuntimeError): + """Base error for metadata provider failures.""" + + def __init__( + self, + provider: str, + operation: str, + message: str, + *, + status_code: Optional[int] = None, + retry_after: Optional[int] = None, + payload: Any = None, + ) -> None: + super().__init__(message) + self.provider = provider + self.operation = operation + self.status_code = status_code + self.retry_after = retry_after + self.payload = payload + + +class MetadataNotFound(MetadataProviderError): + """Raised when a provider cannot resolve the requested entity.""" + + +class MetadataRateLimited(MetadataProviderError): + """Raised when a provider asks us to back off.""" diff --git a/core/metadata/lookup.py b/core/metadata/lookup.py index d9cb6a67..f1c76a87 100644 --- a/core/metadata/lookup.py +++ b/core/metadata/lookup.py @@ -13,10 +13,10 @@ class MetadataLookupOptions: """Generic metadata lookup policy shared by metadata services.""" source_override: Optional[str] = None + enabled_sources: Optional[tuple[str, ...]] = None allow_fallback: bool = True skip_cache: bool = False max_pages: int = 0 limit: int = 50 artist_source_ids: Optional[Dict[str, str]] = None dedup_variants: bool = True - diff --git a/core/metadata/models.py b/core/metadata/models.py new file mode 100644 index 00000000..d357ffda --- /dev/null +++ b/core/metadata/models.py @@ -0,0 +1,202 @@ +"""Canonical typed metadata entities. + +The metadata layer treats ``source_id`` as the authoritative external +identifier. ``id`` stays as a compatibility alias for older callers. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + + +class MetadataRecord(dict): + """Dictionary payload with attribute access for compatibility. + + ``source_id`` is the primary external identifier. ``id`` mirrors it + so older call sites keep working while new code can depend on the + explicit source-scoped field. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._sync_identity_aliases() + + def __getattr__(self, item: str) -> Any: + try: + return self[item] + except KeyError as exc: # pragma: no cover - standard attribute fallback + raise AttributeError(item) from exc + + def __setattr__(self, key: str, value: Any) -> None: + self[key] = value + + def __setitem__(self, key: str, value: Any) -> None: + if key == "id": + source_id = dict.get(self, "source_id") + if source_id not in (None, ""): + value = source_id + dict.__setitem__(self, key, value) + self._sync_identity_aliases() + + def update(self, *args, **kwargs) -> None: # pragma: no cover - thin dict wrapper + dict.update(self, *args, **kwargs) + self._sync_identity_aliases() + + def _sync_identity_aliases(self) -> None: + source_id = dict.get(self, "source_id") + entity_id = dict.get(self, "id") + + if source_id not in (None, ""): + dict.__setitem__(self, "source_id", source_id) + dict.__setitem__(self, "id", source_id) + return + + if entity_id not in (None, ""): + dict.__setitem__(self, "source_id", entity_id) + dict.__setitem__(self, "id", entity_id) + + def copy(self): # pragma: no cover - dict compatibility helper + return MetadataRecord(super().copy()) + + +def as_metadata_record(data: Optional[dict[str, Any]] = None, **extra: Any) -> MetadataRecord: + payload = MetadataRecord(data or {}) + payload.update(extra) + return payload + + +@dataclass(frozen=True) +class MetadataTrack: + id: str + name: str + artists: list[str] + album: str + duration_ms: int + popularity: int + preview_url: Optional[str] = None + external_urls: Optional[dict[str, str]] = None + image_url: Optional[str] = None + release_date: Optional[str] = None + track_number: Optional[int] = None + disc_number: Optional[int] = None + album_type: Optional[str] = None + total_tracks: Optional[int] = None + source: Optional[str] = None + source_id: Optional[str] = None + raw_data: Optional[dict[str, Any]] = None + + def to_dict(self) -> dict[str, Any]: + source_id = self.source_id or self.id or "" + compat_id = source_id or self.id or "" + data = { + "source_id": source_id, + "id": compat_id, + "name": self.name, + "artists": list(self.artists), + "album": self.album, + "duration_ms": self.duration_ms, + "popularity": self.popularity, + "preview_url": self.preview_url, + "external_urls": dict(self.external_urls or {}), + "image_url": self.image_url, + "release_date": self.release_date, + "track_number": self.track_number, + "disc_number": self.disc_number, + "album_type": self.album_type, + "total_tracks": self.total_tracks, + } + if self.source: + data["source"] = self.source + if self.raw_data is not None: + data["raw_data"] = self.raw_data + return data + + def to_record(self) -> MetadataRecord: + return as_metadata_record(self.to_dict()) + + +@dataclass(frozen=True) +class MetadataArtist: + id: str + name: str + popularity: int + genres: list[str] + followers: int + image_url: Optional[str] = None + external_urls: Optional[dict[str, str]] = None + source: Optional[str] = None + source_id: Optional[str] = None + raw_data: Optional[dict[str, Any]] = None + + def to_dict(self) -> dict[str, Any]: + source_id = self.source_id or self.id or "" + compat_id = source_id or self.id or "" + data = { + "source_id": source_id, + "id": compat_id, + "name": self.name, + "popularity": self.popularity, + "genres": list(self.genres), + "followers": self.followers, + "image_url": self.image_url, + "external_urls": dict(self.external_urls or {}), + } + if self.source: + data["source"] = self.source + if self.raw_data is not None: + data["raw_data"] = self.raw_data + return data + + def to_record(self) -> MetadataRecord: + return as_metadata_record(self.to_dict()) + + +@dataclass(frozen=True) +class MetadataAlbum: + id: str + name: str + artists: list[str] + release_date: str + total_tracks: int + album_type: str + image_url: Optional[str] = None + external_urls: Optional[dict[str, str]] = None + source: Optional[str] = None + source_id: Optional[str] = None + raw_data: Optional[dict[str, Any]] = None + + def to_dict(self) -> dict[str, Any]: + source_id = self.source_id or self.id or "" + compat_id = source_id or self.id or "" + data = { + "source_id": source_id, + "id": compat_id, + "name": self.name, + "artists": list(self.artists), + "release_date": self.release_date, + "total_tracks": self.total_tracks, + "album_type": self.album_type, + "image_url": self.image_url, + "external_urls": dict(self.external_urls or {}), + } + if self.source: + data["source"] = self.source + if self.raw_data is not None: + data["raw_data"] = self.raw_data + return data + + def to_record(self) -> MetadataRecord: + return as_metadata_record(self.to_dict()) + + +@dataclass(frozen=True) +class MetadataPlaylist: + id: str + name: str + description: Optional[str] + owner: str + public: bool + collaborative: bool + tracks: list[MetadataTrack] = field(default_factory=list) + total_tracks: int = 0 diff --git a/core/metadata/normalize.py b/core/metadata/normalize.py new file mode 100644 index 00000000..4cb670c5 --- /dev/null +++ b/core/metadata/normalize.py @@ -0,0 +1,454 @@ +"""Normalization helpers for provider payloads.""" + +from __future__ import annotations + +import re +from typing import Any, Iterable, Optional + +from core.metadata.models import MetadataAlbum, MetadataArtist, MetadataTrack + + +def _clean_itunes_album_name(album_name: str) -> str: + if not album_name: + return album_name + + for suffix in (" - Single", " - EP"): + if album_name.endswith(suffix): + return album_name[: -len(suffix)] + return album_name + + +def _as_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, (str, bytes)): + return [value] + if isinstance(value, dict): + return [value] + try: + return list(value) + except TypeError: + return [value] + + +def _extract_image_url(raw: Any, *keys: str) -> Optional[str]: + for key in keys: + if isinstance(raw, dict) and raw.get(key): + return raw.get(key) + return None + + +def _pick_first_image(images: Any) -> Optional[str]: + images = _as_list(images) + if not images: + return None + first = images[0] + if isinstance(first, dict): + return first.get("url") or first.get("uri") + return None + + +def _first_non_empty(*values: Any) -> Any: + for value in values: + if value not in (None, "", [], {}): + return value + return None + + +def _normalize_external_urls(raw: Any, provider: str, fallback_key: str = "link") -> dict[str, str]: + if isinstance(raw, dict): + external_urls = raw.get("external_urls") + if isinstance(external_urls, dict) and external_urls: + return dict(external_urls) + url = raw.get(fallback_key) + if url: + return {provider: str(url)} + return {} + + +def _normalize_artists(raw_artists: Any) -> list[str]: + artists: list[str] = [] + for artist in _as_list(raw_artists): + if isinstance(artist, dict): + name = artist.get("name") or artist.get("artist_name") or artist.get("title") + else: + name = artist + if name: + artists.append(str(name)) + return artists or ["Unknown Artist"] + + +def _infer_album_type_from_track_count(track_count: int) -> str: + if track_count <= 3: + return "single" + if track_count <= 6: + return "ep" + return "album" + + +def _normalize_itunes_album_type(collection_type: str, track_count: int) -> str: + collection_type = (collection_type or "").lower() + if "compilation" in collection_type: + return "compilation" + if track_count <= 3: + return "single" + if track_count <= 6: + return "ep" + return "album" + + +def _normalize_discogs_album_type(raw: dict[str, Any]) -> str: + formats = raw.get("formats", []) or [] + format_name = str(formats[0].get("name", "")).lower() if formats else "" + descriptions = [str(desc).lower() for desc in (formats[0].get("descriptions", []) if formats else [])] + raw_format = raw.get("format") or "" + if isinstance(raw_format, list): + format_str = ", ".join(raw_format).lower() + else: + format_str = str(raw_format).lower() + + if "single" in descriptions or "single" in format_name or "single" in format_str: + return "single" + if "ep" in descriptions or ", ep" in format_str or format_str.endswith("ep"): + return "ep" + if "compilation" in descriptions or "compilation" in format_str: + return "compilation" + return "album" + + +def normalize_spotify_artist(raw: dict[str, Any]) -> MetadataArtist: + images = _as_list(raw.get("images")) + image_url = _pick_first_image(images) + return MetadataArtist( + id=str(raw.get("id", "")), + name=str(raw.get("name", "")), + popularity=int(raw.get("popularity", 0) or 0), + genres=list(raw.get("genres") or []), + followers=int(_first_non_empty((raw.get("followers") or {}).get("total"), 0) or 0), + image_url=image_url, + external_urls=dict(raw.get("external_urls") or {}), + source="spotify", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_spotify_album(raw: dict[str, Any]) -> MetadataAlbum: + images = _as_list(raw.get("images")) + image_url = _pick_first_image(images) + artists = _normalize_artists(raw.get("artists")) + return MetadataAlbum( + id=str(raw.get("id", "")), + name=str(raw.get("name", "")), + artists=artists, + release_date=str(raw.get("release_date", "") or ""), + total_tracks=int(raw.get("total_tracks", 0) or 0), + album_type=str(raw.get("album_type", "album") or "album"), + image_url=image_url, + external_urls=dict(raw.get("external_urls") or {}), + source="spotify", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_spotify_track(raw: dict[str, Any]) -> MetadataTrack: + album = raw.get("album") or {} + album_name = str(album.get("name", "") or "") + artists = _normalize_artists(raw.get("artists")) + image_url = _pick_first_image(album.get("images")) + return MetadataTrack( + id=str(raw.get("id", "")), + name=str(raw.get("name", "")), + artists=artists, + album=album_name, + duration_ms=int(raw.get("duration_ms", 0) or 0), + popularity=int(raw.get("popularity", 0) or 0), + preview_url=raw.get("preview_url"), + external_urls=dict(raw.get("external_urls") or {}), + image_url=image_url, + release_date=str(album.get("release_date", "") or raw.get("release_date", "") or ""), + track_number=raw.get("track_number"), + disc_number=raw.get("disc_number"), + album_type=str(album.get("album_type", "") or raw.get("album_type", "") or "") or None, + total_tracks=int(album.get("total_tracks", 0) or 0) or None, + source="spotify", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_deezer_artist(raw: dict[str, Any]) -> MetadataArtist: + image_url = _first_non_empty(raw.get("picture_xl"), raw.get("picture_big"), raw.get("picture_medium")) + return MetadataArtist( + id=str(raw.get("id", "")), + name=str(raw.get("name", "")), + popularity=int(raw.get("rank", 0) or 0), + genres=[], + followers=int(raw.get("nb_fan", 0) or 0), + image_url=image_url, + external_urls=_normalize_external_urls(raw, "deezer"), + source="deezer", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_deezer_album(raw: dict[str, Any]) -> MetadataAlbum: + image_url = _first_non_empty(raw.get("cover_xl"), raw.get("cover_big"), raw.get("cover_medium")) + artist = raw.get("artist") or {} + artists = _normalize_artists([artist] if artist else []) + record_type = str(raw.get("record_type", "album") or "album").lower() + if record_type == "single": + album_type = "single" + elif record_type == "ep": + album_type = "ep" + elif record_type == "compile": + album_type = "compilation" + else: + album_type = _infer_album_type_from_track_count(int(raw.get("nb_tracks", 0) or 0)) + return MetadataAlbum( + id=str(raw.get("id", "")), + name=str(raw.get("title", "")), + artists=artists, + release_date=str(raw.get("release_date", "") or ""), + total_tracks=int(raw.get("nb_tracks", 0) or 0), + album_type=album_type, + image_url=image_url, + external_urls=_normalize_external_urls(raw, "deezer"), + source="deezer", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_deezer_track(raw: dict[str, Any]) -> MetadataTrack: + album = raw.get("album") or {} + artist = raw.get("artist") or {} + contributors = raw.get("contributors") or [] + artists = _normalize_artists(contributors if len(_as_list(contributors)) > 1 else [artist] if artist else []) + image_url = _first_non_empty(album.get("cover_xl"), album.get("cover_big"), album.get("cover_medium")) + album_type = raw.get("type") or album.get("type") + if not album_type: + nb_tracks = int(album.get("nb_tracks", 0) or 0) + album_type = _infer_album_type_from_track_count(nb_tracks) + return MetadataTrack( + id=str(raw.get("id", "")), + name=str(raw.get("title", "")), + artists=artists, + album=str(album.get("title", "") or ""), + duration_ms=int(raw.get("duration", 0) or 0) * 1000, + popularity=int(raw.get("rank", 0) or 0), + preview_url=raw.get("preview"), + external_urls=_normalize_external_urls(raw, "deezer"), + image_url=image_url, + release_date=str(raw.get("release_date", "") or album.get("release_date", "") or ""), + track_number=raw.get("track_position"), + disc_number=raw.get("disk_number", 1) or 1, + album_type=str(album_type or "album"), + total_tracks=int(album.get("nb_tracks", 0) or 0) or None, + source="deezer", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_itunes_artist(raw: dict[str, Any]) -> MetadataArtist: + image_url = raw.get("artworkUrl100") + if image_url: + image_url = image_url.replace("100x100bb", "3000x3000bb") + return MetadataArtist( + id=str(raw.get("artistId", "")), + name=str(raw.get("artistName", "")), + popularity=0, + genres=[raw["primaryGenreName"]] if raw.get("primaryGenreName") else [], + followers=0, + image_url=image_url, + external_urls=_normalize_external_urls(raw, "itunes", "artistViewUrl"), + source="itunes", + source_id=str(raw.get("artistId", "")), + raw_data=raw, + ) + + +def normalize_itunes_album(raw: dict[str, Any]) -> MetadataAlbum: + artwork = raw.get("artworkUrl100") + image_url = artwork.replace("100x100bb", "3000x3000bb") if artwork else None + track_count = int(raw.get("trackCount", 0) or 0) + return MetadataAlbum( + id=str(raw.get("collectionId", "")), + name=_clean_itunes_album_name(str(raw.get("collectionName", "") or "")), + artists=[str(raw.get("artistName", "Unknown Artist") or "Unknown Artist")], + release_date=str(raw.get("releaseDate", "") or ""), + total_tracks=track_count, + album_type=_normalize_itunes_album_type(str(raw.get("collectionType", "") or ""), track_count), + image_url=image_url, + external_urls=_normalize_external_urls(raw, "itunes", "collectionViewUrl"), + source="itunes", + source_id=str(raw.get("collectionId", "")), + raw_data=raw, + ) + + +def normalize_itunes_track(raw: dict[str, Any], *, clean_artist_name: Optional[str] = None) -> MetadataTrack: + artwork = raw.get("artworkUrl100") + image_url = artwork.replace("100x100bb", "3000x3000bb") if artwork else None + track_count = int(raw.get("trackCount", 0) or 0) + if clean_artist_name: + artists = [clean_artist_name] + else: + artists = [str(raw.get("artistName", "Unknown Artist") or "Unknown Artist")] + return MetadataTrack( + id=str(raw.get("trackId", "")), + name=str(raw.get("trackName", "")), + artists=artists, + album=_clean_itunes_album_name(str(raw.get("collectionName", "") or "")), + duration_ms=int(raw.get("trackTimeMillis", 0) or 0), + popularity=0, + preview_url=raw.get("previewUrl"), + external_urls=_normalize_external_urls(raw, "itunes", "trackViewUrl"), + image_url=image_url, + release_date=str(raw.get("releaseDate", "") or "").split("T")[0] if raw.get("releaseDate") else None, + track_number=raw.get("trackNumber"), + disc_number=raw.get("discNumber", 1) or 1, + album_type=_normalize_itunes_album_type(str(raw.get("collectionType", "") or ""), track_count), + total_tracks=track_count or None, + source="itunes", + source_id=str(raw.get("trackId", "")), + raw_data=raw, + ) + + +def normalize_discogs_artist(raw: dict[str, Any]) -> MetadataArtist: + images = _as_list(raw.get("images")) + image_url = _pick_first_image(images) + if not image_url: + image_url = raw.get("cover_image") or raw.get("thumb") + if image_url and "spacer.gif" in str(image_url): + image_url = None + external_urls = {} + if raw.get("uri"): + uri = str(raw["uri"]) + external_urls["discogs"] = f"https://www.discogs.com{uri}" if uri.startswith("/") else uri + elif raw.get("resource_url"): + external_urls["discogs_api"] = str(raw["resource_url"]) + return MetadataArtist( + id=str(raw.get("id", "")), + name=str(raw.get("name", raw.get("title", "")) or ""), + popularity=0, + genres=[], + followers=0, + image_url=image_url, + external_urls=external_urls, + source="discogs", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_discogs_album(raw: dict[str, Any]) -> MetadataAlbum: + title = str(raw.get("title", "") or "") + artists = [] + if raw.get("artists"): + artists = [str(a.get("name", "") or "") for a in raw["artists"] if a.get("name")] + elif raw.get("artist"): + artists = [str(raw.get("artist") or "")] + elif " - " in title: + artists = [title.split(" - ", 1)[0].strip()] + title = title.split(" - ", 1)[1].strip() + if not artists: + artists = ["Unknown Artist"] + images = _as_list(raw.get("images")) + image_url = _pick_first_image(images) + if not image_url: + image_url = raw.get("cover_image") or raw.get("thumb") + if image_url and "spacer.gif" in str(image_url): + image_url = None + external_urls = {} + if raw.get("uri"): + uri = str(raw["uri"]) + external_urls["discogs"] = f"https://www.discogs.com{uri}" if uri.startswith("/") else uri + elif raw.get("resource_url"): + external_urls["discogs_api"] = str(raw["resource_url"]) + total_tracks = int(_first_non_empty(len(raw.get("tracklist", []) or []), raw.get("format_quantity", 0), 0) or 0) + release_date = str(_first_non_empty(raw.get("year"), raw.get("released"), "") or "") + return MetadataAlbum( + id=str(raw.get("id", "")), + name=title, + artists=artists, + release_date=release_date, + total_tracks=total_tracks, + album_type=_normalize_discogs_album_type(raw), + image_url=image_url, + external_urls=external_urls, + source="discogs", + source_id=str(raw.get("id", "")), + raw_data=raw, + ) + + +def normalize_discogs_track(raw: dict[str, Any], release_raw: Optional[dict[str, Any]] = None) -> MetadataTrack: + release = release_raw or {} + position = str(raw.get("position", "") or "") + track_number = None + disc_number = 1 + if position: + if "-" in position and position.replace("-", "").isdigit(): + parts = position.split("-") + disc_number = int(parts[0]) + track_number = int(parts[1]) + elif position.isdigit(): + track_number = int(position) + else: + digits = "".join(c for c in position if c.isdigit()) + if digits: + track_number = int(digits) + + duration_ms = 0 + dur_str = str(raw.get("duration", "") or "") + if ":" in dur_str: + parts = dur_str.split(":") + try: + duration_ms = (int(parts[0]) * 60 + int(parts[1])) * 1000 + except (ValueError, IndexError): + duration_ms = 0 + + track_artists = [] + if raw.get("artists"): + track_artists = [a.get("name", "") for a in raw["artists"] if a.get("name")] + if not track_artists and release.get("artists"): + track_artists = [a.get("name", "") for a in release["artists"] if a.get("name")] + if not track_artists: + track_artists = ["Unknown Artist"] + + image_url = None + images = release.get("images", []) + if images: + primary = next((img for img in images if img.get("type") == "primary"), None) + image_url = (primary or images[0]).get("uri") + external_urls = {} + if release.get("uri"): + uri = str(release["uri"]) + external_urls["discogs"] = f"https://www.discogs.com{uri}" if uri.startswith("/") else uri + + return MetadataTrack( + id=f"{release.get('id', '')}_t{track_number or 0}", + name=str(raw.get("title", "") or ""), + artists=track_artists, + album=str(release.get("title", "") or ""), + duration_ms=duration_ms, + popularity=int(_first_non_empty(release.get("community", {}).get("have"), 0) or 0), + preview_url=None, + external_urls=external_urls, + image_url=image_url, + release_date=str(release.get("year", "") or "") if release.get("year") else None, + track_number=track_number, + disc_number=disc_number, + album_type="album", + total_tracks=int(len(release.get("tracklist", []) or [])) or None, + source="discogs", + source_id=f"{release.get('id', '')}_t{track_number or 0}", + raw_data=raw, + ) diff --git a/core/metadata/providers/__init__.py b/core/metadata/providers/__init__.py new file mode 100644 index 00000000..ff2c265d --- /dev/null +++ b/core/metadata/providers/__init__.py @@ -0,0 +1,13 @@ +"""Metadata provider adapters.""" + +from .spotify import SpotifyMetadataAdapter +from .deezer import DeezerMetadataAdapter +from .itunes import ITunesMetadataAdapter +from .discogs import DiscogsMetadataAdapter + +__all__ = [ + "SpotifyMetadataAdapter", + "DeezerMetadataAdapter", + "ITunesMetadataAdapter", + "DiscogsMetadataAdapter", +] diff --git a/core/metadata/providers/base.py b/core/metadata/providers/base.py new file mode 100644 index 00000000..4711c2b1 --- /dev/null +++ b/core/metadata/providers/base.py @@ -0,0 +1,167 @@ +"""Shared transport helpers for metadata providers.""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Optional + +import requests + +from core.metadata.exceptions import MetadataProviderError, MetadataRateLimited +from core.metadata.contracts import MetadataProviderStatus + + +class BaseMetadataAdapter: + """Shared HTTP transport helpers for provider adapters.""" + + provider_name: str = "unknown" + min_api_interval: float = 0.0 + timeout: float = 15.0 + max_retries: int = 2 + retry_backoff: float = 0.5 + + def __init__(self) -> None: + self.session = requests.Session() + self._lock = threading.RLock() + self._next_request_at = 0.0 + self._rate_limited_until = 0.0 + self._last_error: Optional[str] = None + + def is_available(self) -> bool: + return True + + def is_authenticated(self) -> bool: + return self.is_available() + + def reload_config(self) -> None: + """Refresh adapter-local configuration state.""" + + def _set_last_error(self, message: Optional[str]) -> None: + self._last_error = message + + def _throttle(self) -> None: + if self.min_api_interval <= 0: + return + with self._lock: + now = time.time() + if now < self._next_request_at: + time.sleep(self._next_request_at - now) + self._next_request_at = time.time() + self.min_api_interval + + def _request_json( + self, + method: str, + url: str, + *, + params: Optional[dict[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + timeout: Optional[float] = None, + data: Any = None, + ) -> Any: + last_error: Optional[Exception] = None + for attempt in range(self.max_retries + 1): + self._throttle() + try: + response = self.session.request( + method, + url, + params=params, + headers=headers, + timeout=timeout or self.timeout, + data=data, + ) + except Exception as exc: + last_error = exc + self._set_last_error(str(exc)) + if attempt < self.max_retries: + time.sleep(self.retry_backoff * (attempt + 1)) + continue + raise MetadataProviderError(self.provider_name, method.lower(), str(exc)) from exc + + if response.status_code == 429: + retry_after = self._parse_retry_after(response.headers.get("Retry-After")) + self._rate_limited_until = time.time() + float(retry_after or 60) + message = f"{self.provider_name} rate limited" + self._set_last_error(message) + raise MetadataRateLimited( + self.provider_name, + method.lower(), + message, + status_code=429, + retry_after=retry_after, + payload=response.text, + ) + + if response.status_code in (404, 204): + return None + + if response.status_code >= 500 and attempt < self.max_retries: + last_error = MetadataProviderError( + self.provider_name, + method.lower(), + f"{self.provider_name} returned HTTP {response.status_code}", + status_code=response.status_code, + payload=response.text, + ) + time.sleep(self.retry_backoff * (attempt + 1)) + continue + + if response.status_code >= 400: + message = f"{self.provider_name} returned HTTP {response.status_code}" + self._set_last_error(message) + raise MetadataProviderError( + self.provider_name, + method.lower(), + message, + status_code=response.status_code, + payload=response.text, + ) + + if not response.content: + return None + + try: + return response.json() + except Exception as exc: + last_error = exc + self._set_last_error(str(exc)) + if attempt < self.max_retries: + time.sleep(self.retry_backoff * (attempt + 1)) + continue + raise MetadataProviderError( + self.provider_name, + method.lower(), + f"{self.provider_name} returned invalid JSON", + status_code=response.status_code, + payload=response.text, + ) from exc + + if last_error is not None: + raise MetadataProviderError(self.provider_name, method.lower(), str(last_error)) from last_error + return None + + @staticmethod + def _parse_retry_after(value: Optional[str]) -> Optional[int]: + if not value: + return None + try: + return max(0, int(float(value))) + except (TypeError, ValueError): + return None + + def get_status(self) -> MetadataProviderStatus: + retry_after = None + rate_limited = False + if self._rate_limited_until > time.time(): + rate_limited = True + retry_after = int(self._rate_limited_until - time.time()) + return MetadataProviderStatus( + provider=self.provider_name, + configured=self.is_authenticated(), + available=self.is_available(), + authenticated=self.is_authenticated(), + rate_limited=rate_limited, + retry_after=retry_after, + last_error=self._last_error, + ) diff --git a/core/metadata/providers/deezer.py b/core/metadata/providers/deezer.py new file mode 100644 index 00000000..4b953e29 --- /dev/null +++ b/core/metadata/providers/deezer.py @@ -0,0 +1,100 @@ +"""Deezer metadata adapter.""" + +from __future__ import annotations + +from typing import Any, Optional + +from core.metadata.providers.base import BaseMetadataAdapter + + +class DeezerMetadataAdapter(BaseMetadataAdapter): + provider_name = "deezer" + min_api_interval = 1.0 + timeout = 15.0 + + BASE_URL = "https://api.deezer.com" + + def __init__(self) -> None: + super().__init__() + self._access_token: Optional[str] = None + + def reload_config(self) -> None: + self._access_token = None + + def _read_access_token(self) -> Optional[str]: + try: + from config.settings import config_manager + + token = config_manager.get("deezer.access_token", None) + except Exception: + token = None + return (str(token).strip() or None) if token else None + + def _request_api(self, endpoint: str, params: Optional[dict[str, Any]] = None, timeout: int = 15) -> Optional[dict[str, Any]]: + url = f"{self.BASE_URL}/{endpoint.lstrip('/')}" + params = dict(params or {}) + token = self._read_access_token() + if token and "access_token" not in params: + params["access_token"] = token + payload = self._request_json("GET", url, params=params, timeout=timeout) + return payload if isinstance(payload, dict) else None + + def is_authenticated(self) -> bool: + return True + + def search_tracks_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]: + payload = self._request_api("search/track", {"q": query, "limit": min(limit, 100)}) + return list(payload.get("data") or []) if payload else [] + + def search_artists_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]: + payload = self._request_api("search/artist", {"q": query, "limit": min(limit, 100)}) + return list(payload.get("data") or []) if payload else [] + + def search_albums_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]: + payload = self._request_api("search/album", {"q": query, "limit": min(limit, 100)}) + return list(payload.get("data") or []) if payload else [] + + def get_track_raw(self, track_id: str) -> Optional[dict[str, Any]]: + return self._request_api(f"track/{track_id}") + + def get_album_raw(self, album_id: str) -> Optional[dict[str, Any]]: + return self._request_api(f"album/{album_id}") + + def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]: + return self._request_api(f"artist/{artist_id}") + + def get_album_tracks_raw(self, album_id: str, limit: int = 500, max_pages: int = 0) -> Optional[dict[str, Any]]: + data = self._request_api(f"album/{album_id}/tracks", {"limit": min(limit, 500)}) + if not data or not data.get("data"): + album_data = self._request_api(f"album/{album_id}") + if album_data and album_data.get("tracks") and album_data["tracks"].get("data"): + data = album_data["tracks"] + else: + return None + + album_info = self._request_api(f"album/{album_id}") or {} + items = list(data.get("data") or []) + return { + "items": items, + "total": len(items), + "limit": len(items), + "next": None, + "album": album_info, + } + + def get_artist_albums_raw(self, artist_id: str, album_type: str = "album,single", limit: int = 200, max_pages: int = 0) -> list[dict[str, Any]]: + albums: list[dict[str, Any]] = [] + offset = 0 + page_size = 100 + while offset < limit: + fetch_limit = min(page_size, limit - offset) + data = self._request_api(f"artist/{artist_id}/albums", {"limit": fetch_limit, "index": offset}) + if not data or not data.get("data"): + break + albums.extend(list(data.get("data") or [])) + if len(data.get("data") or []) < fetch_limit: + break + offset += len(data.get("data") or []) + if max_pages and offset >= max_pages * page_size: + break + return albums[:limit] diff --git a/core/metadata/providers/discogs.py b/core/metadata/providers/discogs.py new file mode 100644 index 00000000..d02fd16f --- /dev/null +++ b/core/metadata/providers/discogs.py @@ -0,0 +1,174 @@ +"""Discogs metadata adapter.""" + +from __future__ import annotations + +import re +from typing import Any, Optional + +from core.metadata.providers.base import BaseMetadataAdapter + + +class DiscogsMetadataAdapter(BaseMetadataAdapter): + provider_name = "discogs" + min_api_interval = 2.5 + timeout = 15.0 + + BASE_URL = "https://api.discogs.com" + + def __init__(self, token: Optional[str] = None) -> None: + super().__init__() + self.token = token + self.reload_config() + + def reload_config(self) -> None: + if self.token: + self.session.headers["Authorization"] = f"Discogs token={self.token}" + else: + self.session.headers.pop("Authorization", None) + + def _read_token(self) -> Optional[str]: + if self.token is not None: + return self.token or None + try: + from config.settings import config_manager + + token = config_manager.get("discogs.token", "") + except Exception: + token = "" + return (str(token).strip() or None) if token else None + + def _sync_auth_state(self) -> None: + token = self._read_token() + self.token = token + if token: + self.session.headers["Authorization"] = f"Discogs token={token}" + self.min_api_interval = 1.0 + else: + self.session.headers.pop("Authorization", None) + self.min_api_interval = 2.5 + + def is_available(self) -> bool: + return True + + def is_authenticated(self) -> bool: + return bool(self._read_token()) + + def _request_api(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]: + self._sync_auth_state() + url = f"{self.BASE_URL}/{endpoint.lstrip('/')}" + payload = self._request_json("GET", url, params=params, timeout=self.timeout) + return payload if isinstance(payload, dict) else None + + @staticmethod + def _normalize_name(name: str) -> str: + name = (name or "").lower().strip() + name = re.sub(r"\s*\(.*?\)\s*", " ", name) + name = re.sub(r"[^\w\s]", "", name) + name = re.sub(r"\s+", " ", name).strip() + return name + + def search_artists_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]: + data = self._request_api( + "/database/search", + {"q": query, "type": "artist", "per_page": min(limit, 50)}, + ) + if not data: + return [] + return list(data.get("results") or [])[:limit] + + def search_albums_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]: + data = self._request_api( + "/database/search", + {"q": query, "type": "release", "per_page": min(limit, 50)}, + ) + if not data: + return [] + return list(data.get("results") or [])[: limit * 2] + + def search_tracks_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]: + return [] + + def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]: + return self._request_api(f"/artists/{artist_id}") + + def get_album_raw(self, release_id: str) -> Optional[dict[str, Any]]: + data = self._request_api(f"/masters/{release_id}") + if not data or not data.get("title"): + data = self._request_api(f"/releases/{release_id}") + return data + + def get_artist_albums_raw(self, artist_id: str, album_type: str = "album,single", limit: int = 50, max_pages: int = 0) -> list[dict[str, Any]]: + artist_data = self._request_api(f"/artists/{artist_id}") + artist_name = str((artist_data or {}).get("name", "") or "").lower() + data = self._request_api( + f"/artists/{artist_id}/releases", + {"sort": "year", "sort_order": "desc", "per_page": min(limit * 3, 200)}, + ) + if not data: + return [] + + masters: list[dict[str, Any]] = [] + releases_no_master: list[dict[str, Any]] = [] + master_titles = set() + for item in data.get("releases") or []: + role = str(item.get("role", "Main") or "Main").lower() + if role not in ("main", ""): + continue + release_artist = str(item.get("artist", "") or "") + if artist_name and release_artist: + primary = re.split(r"\s+(?:feat\.?|ft\.?|featuring)\s+", release_artist, flags=re.IGNORECASE)[0] + primary = re.split(r"\s*[&,]\s*", primary)[0].strip() + if self._normalize_name(primary) != self._normalize_name(artist_name): + continue + if item.get("type") == "master": + masters.append(item) + master_titles.add(str(item.get("title", "")).lower()) + else: + releases_no_master.append(item) + + ordered = masters + [r for r in releases_no_master if str(r.get("title", "")).lower() not in master_titles] + seen_titles = set() + allowed_types = {part.strip() for part in (album_type or "album,single").split(",") if part.strip()} + albums: list[dict[str, Any]] = [] + for item in ordered: + title = str(item.get("title", "") or "").lower().strip() + if title in seen_titles: + continue + seen_titles.add(title) + album_type_value = "album" + formats = item.get("formats", []) or [] + if formats: + fmt = formats[0] + descriptions = [str(desc).lower() for desc in (fmt.get("descriptions", []) or [])] + format_name = str(fmt.get("name", "") or "").lower() + raw_format = item.get("format") or "" + if isinstance(raw_format, list): + format_str = ", ".join(raw_format).lower() + else: + format_str = str(raw_format).lower() + if "single" in descriptions or "single" in format_name or "single" in format_str: + album_type_value = "single" + elif "ep" in descriptions or ", ep" in format_str or format_str.endswith("ep"): + album_type_value = "ep" + elif "compilation" in descriptions or "compilation" in format_str: + album_type_value = "compilation" + if album_type_value in allowed_types or (album_type_value == "ep" and "single" in allowed_types): + albums.append(item) + if len(albums) >= limit: + break + return albums + + def get_album_tracks_raw(self, release_id: str, limit: int = 50, max_pages: int = 0) -> Optional[dict[str, Any]]: + data = self._request_api(f"/masters/{release_id}") + if not data or not data.get("tracklist"): + data = self._request_api(f"/releases/{release_id}") + if not data or not data.get("tracklist"): + return None + track_items = [t for t in data.get("tracklist") or [] if t.get("type_", "") in ("track", "") or not t.get("type_")] + return { + "items": track_items, + "total": len(track_items), + "limit": len(track_items), + "next": None, + "album": data, + } diff --git a/core/metadata/providers/itunes.py b/core/metadata/providers/itunes.py new file mode 100644 index 00000000..cbb1501d --- /dev/null +++ b/core/metadata/providers/itunes.py @@ -0,0 +1,193 @@ +"""iTunes metadata adapter.""" + +from __future__ import annotations + +import re +from typing import Any, Optional + +from core.metadata.providers.base import BaseMetadataAdapter + + +class ITunesMetadataAdapter(BaseMetadataAdapter): + provider_name = "itunes" + min_api_interval = 3.0 + timeout = 30.0 + + SEARCH_URL = "https://itunes.apple.com/search" + LOOKUP_URL = "https://itunes.apple.com/lookup" + FALLBACK_COUNTRIES = ["US", "GB", "FR", "DE", "JP", "AU", "CA", "BR", "KR", "SE"] + + def __init__(self) -> None: + super().__init__() + self._fixed_country: Optional[str] = None + self.reload_config() + + def reload_config(self) -> None: + try: + from config.settings import config_manager + + country = config_manager.get("itunes.country", "US") + except Exception: + country = "US" + if self._fixed_country: + country = self._fixed_country + self._country = (country or "US").upper() + + @property + def country(self) -> str: + try: + from config.settings import config_manager + + if self._fixed_country: + return self._fixed_country + country = config_manager.get("itunes.country", self._country or "US") or "US" + return str(country).upper() + except Exception: + return self._country or "US" + + def is_authenticated(self) -> bool: + return True + + def _search_raw(self, term: str, entity: str, limit: int = 50) -> list[dict[str, Any]]: + payload = self._request_json( + "GET", + self.SEARCH_URL, + params={ + "term": term, + "country": self.country, + "media": "music", + "entity": entity, + "limit": min(limit, 200), + "explicit": "Yes", + }, + timeout=self.timeout, + ) + if not payload: + return [] + results = list(payload.get("results") or []) + if entity == "song": + return [item for item in results if item.get("wrapperType") == "track" and item.get("kind") == "song"] + if entity == "album": + return [item for item in results if item.get("wrapperType") == "collection"] + if entity == "musicArtist": + return [item for item in results if item.get("wrapperType") == "artist"] + return results + + def _lookup_raw(self, **params) -> list[dict[str, Any]]: + params = dict(params or {}) + params["country"] = self.country + payload = self._request_json("GET", self.LOOKUP_URL, params=params, timeout=self.timeout) + if not payload: + return [] + results = list(payload.get("results") or []) + if results: + return results + + if "id" in params: + for fallback in self.FALLBACK_COUNTRIES: + if fallback == self.country: + continue + fallback_params = dict(params) + fallback_params["country"] = fallback + try: + payload = self._request_json("GET", self.LOOKUP_URL, params=fallback_params, timeout=15) + except Exception: + continue + if payload and payload.get("results"): + return list(payload.get("results") or []) + return [] + + def search_tracks_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]: + return self._search_raw(query, "song", limit) + + def search_artists_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]: + return self._search_raw(query, "musicArtist", limit) + + def search_albums_raw(self, query: str, limit: int = 20) -> list[dict[str, Any]]: + return self._search_raw(query, "album", limit * 2) + + def get_track_raw(self, track_id: str) -> Optional[dict[str, Any]]: + results = self._lookup_raw(id=track_id) + for item in results: + if item.get("wrapperType") == "track": + return item + return None + + def get_album_raw(self, album_id: str) -> Optional[dict[str, Any]]: + results = self._lookup_raw(id=album_id) + for item in results: + if item.get("wrapperType") == "collection": + return item + return None + + def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]: + results = self._lookup_raw(id=artist_id) + for item in results: + if item.get("wrapperType") == "artist": + return item + return None + + def get_album_tracks_raw(self, album_id: str, limit: int = 50, max_pages: int = 0) -> Optional[dict[str, Any]]: + results = self._lookup_raw(id=album_id, entity="song", limit=min(limit, 200)) + if not results: + return None + + album_raw = None + tracks: list[dict[str, Any]] = [] + for item in results: + if item.get("wrapperType") == "collection" and album_raw is None: + album_raw = item + elif item.get("wrapperType") == "track" and item.get("kind") == "song": + tracks.append(item) + + if not tracks: + return None + + return { + "items": tracks, + "total": len(tracks), + "limit": len(tracks), + "next": None, + "album": album_raw, + } + + def get_artist_albums_raw(self, artist_id: str, album_type: str = "album,single", limit: int = 200, max_pages: int = 0) -> list[dict[str, Any]]: + results = self._lookup_raw(id=artist_id, entity="album", limit=min(limit, 200)) + if not results: + return [] + + seen: dict[str, dict[str, Any]] = {} + + def _normalize_album_name(name: str) -> str: + normalized = (name or "").lower().strip() + normalized = re.sub( + r"\s*[\(\[]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version|bonus|special|standard).*?[\)\]]", + "", + normalized, + flags=re.IGNORECASE, + ) + normalized = re.sub(r"\s*[-–—]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version).*?$", "", normalized, flags=re.IGNORECASE) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + for album_data in results: + if album_data.get("wrapperType") != "collection": + continue + normalized_name = _normalize_album_name(str(album_data.get("collectionName", "") or "")) + current = seen.get(normalized_name) + is_explicit = album_data.get("collectionExplicitness") == "explicit" + if current is None: + seen[normalized_name] = {"data": album_data, "is_explicit": is_explicit} + continue + if is_explicit and not current["is_explicit"]: + seen[normalized_name] = {"data": album_data, "is_explicit": True} + + albums = [item["data"] for item in seen.values()] + return albums[:limit] + + def get_artist_image_from_albums_raw(self, artist_id: str) -> Optional[str]: + results = self._lookup_raw(id=artist_id, entity="album", limit=1) + for item in results: + if item.get("wrapperType") == "collection" and item.get("artworkUrl100"): + return str(item["artworkUrl100"]).replace("100x100bb", "600x600bb") + return None diff --git a/core/metadata/providers/spotify.py b/core/metadata/providers/spotify.py new file mode 100644 index 00000000..49737bb7 --- /dev/null +++ b/core/metadata/providers/spotify.py @@ -0,0 +1,213 @@ +"""Spotify metadata adapter.""" + +from __future__ import annotations + +import base64 +import time +from typing import Any, Optional + +from core.metadata.exceptions import MetadataProviderError, MetadataRateLimited +from core.metadata.providers.base import BaseMetadataAdapter + + +class SpotifyMetadataAdapter(BaseMetadataAdapter): + provider_name = "spotify" + min_api_interval = 0.35 + timeout = 15.0 + + TOKEN_URL = "https://accounts.spotify.com/api/token" + BASE_URL = "https://api.spotify.com/v1" + + def __init__(self) -> None: + super().__init__() + self._credential_fingerprint: Optional[tuple[str, str]] = None + self._access_token: Optional[str] = None + self._token_expires_at = 0.0 + + def _read_credentials(self) -> tuple[Optional[str], Optional[str]]: + try: + from config.settings import config_manager + + config = config_manager.get("spotify", {}) or {} + except Exception: + config = {} + client_id = (config.get("client_id") or "").strip() or None + client_secret = (config.get("client_secret") or "").strip() or None + return client_id, client_secret + + def is_available(self) -> bool: + client_id, client_secret = self._read_credentials() + return bool(client_id and client_secret) + + def is_authenticated(self) -> bool: + return self.is_available() + + def reload_config(self) -> None: + self._credential_fingerprint = None + self._access_token = None + self._token_expires_at = 0.0 + + def _get_access_token(self) -> Optional[str]: + client_id, client_secret = self._read_credentials() + if not client_id or not client_secret: + return None + + fingerprint = (client_id, client_secret) + if fingerprint != self._credential_fingerprint: + self._credential_fingerprint = fingerprint + self._access_token = None + self._token_expires_at = 0.0 + + now = time.time() + if self._access_token and now < self._token_expires_at: + return self._access_token + + basic = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii") + headers = { + "Authorization": f"Basic {basic}", + "Content-Type": "application/x-www-form-urlencoded", + } + response = self.session.post( + self.TOKEN_URL, + headers=headers, + data={"grant_type": "client_credentials"}, + timeout=self.timeout, + ) + if response.status_code == 429: + retry_after = self._parse_retry_after(response.headers.get("Retry-After")) + raise MetadataRateLimited( + self.provider_name, + "token", + "Spotify token endpoint rate limited", + status_code=429, + retry_after=retry_after, + payload=response.text, + ) + if response.status_code >= 400: + raise MetadataProviderError( + self.provider_name, + "token", + f"Spotify token request failed with HTTP {response.status_code}", + status_code=response.status_code, + payload=response.text, + ) + + payload = response.json() + token = payload.get("access_token") + if not token: + raise MetadataProviderError( + self.provider_name, + "token", + "Spotify token response did not include an access token", + status_code=response.status_code, + payload=payload, + ) + + expires_in = int(payload.get("expires_in", 3600) or 3600) + self._access_token = str(token) + self._token_expires_at = now + max(60, expires_in - 60) + return self._access_token + + def _auth_headers(self) -> dict[str, str]: + token = self._get_access_token() + if not token: + return {} + return {"Authorization": f"Bearer {token}"} + + def _request_api( + self, + method: str, + path_or_url: str, + *, + params: Optional[dict[str, Any]] = None, + ) -> Any: + url = path_or_url if path_or_url.startswith("http") else f"{self.BASE_URL}/{path_or_url.lstrip('/')}" + return self._request_json(method, url, params=params, headers=self._auth_headers()) + + def search_tracks_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]: + payload = self._request_api( + "GET", + "search", + params={"q": query, "type": "track", "limit": min(limit, 50)}, + ) + if not payload: + return [] + return list((payload.get("tracks") or {}).get("items") or []) + + def search_artists_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]: + search_query = f"artist:{query}" if len((query or "").strip()) <= 4 else query + payload = self._request_api( + "GET", + "search", + params={"q": search_query, "type": "artist", "limit": min(limit, 50)}, + ) + if not payload: + return [] + return list((payload.get("artists") or {}).get("items") or []) + + def search_albums_raw(self, query: str, limit: int = 10) -> list[dict[str, Any]]: + payload = self._request_api( + "GET", + "search", + params={"q": query, "type": "album", "limit": min(limit, 50)}, + ) + if not payload: + return [] + return list((payload.get("albums") or {}).get("items") or []) + + def get_track_raw(self, track_id: str) -> Optional[dict[str, Any]]: + return self._request_api("GET", f"tracks/{track_id}") + + def get_album_raw(self, album_id: str) -> Optional[dict[str, Any]]: + return self._request_api("GET", f"albums/{album_id}") + + def get_artist_raw(self, artist_id: str) -> Optional[dict[str, Any]]: + return self._request_api("GET", f"artists/{artist_id}") + + def get_track_features_raw(self, track_id: str) -> Optional[dict[str, Any]]: + return self._request_api("GET", f"audio-features/{track_id}") + + def get_album_tracks_raw(self, album_id: str, limit: int = 50, max_pages: int = 0) -> Optional[dict[str, Any]]: + first_page = self._request_api("GET", f"albums/{album_id}/tracks", params={"limit": min(limit, 50)}) + if not first_page or not first_page.get("items"): + return None + + all_tracks = list(first_page.get("items") or []) + page_count = 1 + next_page = first_page + while next_page.get("next") and (max_pages <= 0 or page_count < max_pages): + next_page = self._request_api("GET", next_page["next"]) + page_count += 1 + if next_page and next_page.get("items"): + all_tracks.extend(next_page.get("items") or []) + + result = dict(first_page) + result["items"] = all_tracks + result["next"] = None + result["limit"] = len(all_tracks) + return result + + def get_artist_albums_raw( + self, + artist_id: str, + album_type: str = "album,single", + limit: int = 10, + max_pages: int = 0, + ) -> list[dict[str, Any]]: + results = self._request_api( + "GET", + f"artists/{artist_id}/albums", + params={"include_groups": album_type, "limit": min(limit, 50)}, + ) + if not results: + return [] + + albums = list(results.get("items") or []) + page_count = 1 + next_page = results + while next_page.get("next") and (max_pages <= 0 or page_count < max_pages): + next_page = self._request_api("GET", next_page["next"]) + page_count += 1 + if next_page and next_page.get("items"): + albums.extend(next_page.get("items") or []) + return albums diff --git a/core/metadata/registry.py b/core/metadata/registry.py index d2dc86f7..d2451d2a 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -12,21 +12,18 @@ import hashlib import time from typing import Any, Callable, Dict, Optional +from core.metadata.constants import ( + METADATA_PROVIDER_SOURCES, + METADATA_SOURCE_LABELS, + METADATA_SOURCE_PRIORITY, +) +from core.metadata.engine import clear_metadata_engine_cache, get_metadata_engine from utils.logging_config import get_logger logger = get_logger("metadata.registry") MetadataClientFactory = Callable[[], Any] -METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase") -METADATA_SOURCE_LABELS = { - "spotify": "Spotify", - "itunes": "iTunes", - "deezer": "Deezer", - "discogs": "Discogs", - "hydrabase": "Hydrabase", -} - _UNSET = object() _client_cache_lock = threading.RLock() _client_cache: Dict[str, Any] = {} @@ -83,12 +80,21 @@ def clear_cached_metadata_clients() -> None: """ with _client_cache_lock: _client_cache.clear() + try: + clear_metadata_engine_cache() + except Exception: + pass def clear_cached_metadata_client(cache_key: str) -> None: """Clear one lazily-created client singleton by cache key.""" with _client_cache_lock: _client_cache.pop(cache_key, None) + if cache_key == "metadata_engine": + try: + clear_metadata_engine_cache() + except Exception: + pass def clear_cached_profile_spotify_client(profile_id: int) -> None: @@ -108,6 +114,40 @@ def _get_config_value(key: str, default: Any = None) -> Any: return default +def _normalize_source(source: Optional[str]) -> str: + return (source or "").strip().lower() + + +def get_enabled_metadata_sources() -> tuple[str, ...]: + """Return globally-enabled metadata provider sources.""" + configured = _get_config_value("metadata.enabled_sources", None) + if configured is None: + return METADATA_SOURCE_PRIORITY + if isinstance(configured, str): + enabled = tuple( + source.strip().lower() + for source in configured.split(",") + if source.strip() + ) + return enabled + try: + enabled = tuple( + source.strip().lower() + for source in configured + if str(source).strip() + ) + return enabled + except TypeError: + return METADATA_SOURCE_PRIORITY + + +def is_metadata_source_enabled(source: str) -> bool: + enabled = get_enabled_metadata_sources() + if not enabled: + return False + return _normalize_source(source) in enabled + + def _get_spotify_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory: if client_factory is not None: return client_factory @@ -289,16 +329,32 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str: """Return configured primary metadata source.""" source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" + source = _normalize_source(source) - if source == "spotify": - try: - spotify = get_spotify_client(client_factory=spotify_client_factory) - if not spotify or not spotify.is_spotify_authenticated(): - return "deezer" - except Exception: - return "deezer" + candidates = get_source_priority(source) + enabled_sources = set(get_enabled_metadata_sources()) + for candidate in candidates: + if enabled_sources and candidate not in enabled_sources: + continue + if candidate == "spotify": + try: + spotify = get_spotify_client(client_factory=spotify_client_factory) + if spotify and spotify.is_spotify_authenticated(): + return candidate + except Exception: + continue + continue + if candidate == "hydrabase": + if is_hydrabase_enabled(): + return candidate + continue + client = get_client_for_source(candidate) + if client is not None: + return candidate - return source + if enabled_sources and source in enabled_sources: + return source + return get_source_priority(source)[0] def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str: @@ -333,13 +389,29 @@ def get_primary_client( discogs_client_factory: Optional[MetadataClientFactory] = None, ): """Return client for configured primary source.""" - return get_client_for_source( - get_primary_source(spotify_client_factory=spotify_client_factory), - spotify_client_factory=spotify_client_factory, - itunes_client_factory=itunes_client_factory, - deezer_client_factory=deezer_client_factory, - discogs_client_factory=discogs_client_factory, - ) + primary_source = get_primary_source(spotify_client_factory=spotify_client_factory) + if primary_source == "spotify": + try: + client = get_spotify_client(client_factory=spotify_client_factory) + if client and client.is_spotify_authenticated(): + return client + except Exception: + return None + return None + + if primary_source == "deezer": + return get_deezer_client(client_factory=deezer_client_factory) + + if primary_source == "itunes": + return get_itunes_client(client_factory=itunes_client_factory) + + if primary_source == "discogs": + return get_discogs_client(client_factory=discogs_client_factory) + + if primary_source == "hydrabase": + return get_hydrabase_client(allow_fallback=False) + + return None def get_primary_source_status( @@ -350,26 +422,20 @@ def get_primary_source_status( discogs_client_factory: Optional[MetadataClientFactory] = None, ) -> Dict[str, Any]: """Return a generic status snapshot for the active primary metadata source.""" - source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" + source = get_primary_source(spotify_client_factory=spotify_client_factory) started = time.time() connected = False try: - client = get_client_for_source( - source, - spotify_client_factory=spotify_client_factory, - itunes_client_factory=itunes_client_factory, - deezer_client_factory=deezer_client_factory, - discogs_client_factory=discogs_client_factory, - ) - if source == "spotify": - connected = bool(client and client.is_spotify_authenticated()) - elif source == "hydrabase": + if source == "hydrabase": + client = get_hydrabase_client(allow_fallback=False) connected = bool(client and (client.is_connected() if hasattr(client, "is_connected") else client.is_authenticated())) - elif client is not None and hasattr(client, "is_authenticated"): - connected = bool(client.is_authenticated()) + elif source == "spotify": + client = get_spotify_client(client_factory=spotify_client_factory) + connected = bool(client and client.is_spotify_authenticated()) else: - connected = client is not None + status = get_metadata_engine().get_provider_status(source) + connected = bool(status.authenticated or status.available) except Exception: connected = False @@ -389,25 +455,30 @@ def get_client_for_source( discogs_client_factory: Optional[MetadataClientFactory] = None, ): """Return exact client for a source, or None if unavailable.""" - if source == "spotify": - try: - client = get_spotify_client(client_factory=spotify_client_factory) - if client and client.is_spotify_authenticated(): - return client - except Exception as e: - logger.debug("spotify client get_for_source: %s", e) - return None - - if source == "deezer": - return get_deezer_client(client_factory=deezer_client_factory) - - if source == "discogs": - return get_discogs_client(client_factory=discogs_client_factory) + source = _normalize_source(source) if source == "hydrabase": + if not is_metadata_source_enabled(source): + return None return get_hydrabase_client(allow_fallback=False) - if source == "itunes": - return get_itunes_client(client_factory=itunes_client_factory) + if source not in METADATA_PROVIDER_SOURCES: + return None - return None + if not is_metadata_source_enabled(source): + return None + + engine = get_metadata_engine() + facade = engine.get_source_facade(source) + if facade is None: + return None + + if source == "spotify": + try: + status = engine.get_provider_status(source) + if not status.authenticated: + return None + except Exception: + return None + + return facade diff --git a/core/metadata/service.py b/core/metadata/service.py index 8ee5f2ee..788d1804 100644 --- a/core/metadata/service.py +++ b/core/metadata/service.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional, Literal +from core.metadata.engine import get_metadata_engine from core.metadata.registry import ( get_client_for_source, get_primary_source, @@ -62,60 +63,148 @@ class MetadataService: def _get_client(self): provider = self.get_active_provider() if provider == "spotify": - if not self.spotify or not self.spotify.is_spotify_authenticated(): - logger.warning( - "Spotify requested but not authenticated, falling back to %s", - self._fallback_source, - ) - return self.itunes - return self.spotify + spotify_client = get_client_for_source("spotify") + return spotify_client or self.itunes + return get_client_for_source(provider) + + def _get_metadata_client(self, provider: str): + provider = (provider or "").strip().lower() + if provider == "hydrabase": + return get_client_for_source(provider) + return get_client_for_source(provider) + + def _get_spotify_fallback_client(self): + if self.spotify and self.spotify.is_spotify_authenticated(): + return None return self.itunes def search_tracks(self, query: str, limit: int = 20) -> List: - client = self._get_client() provider = self.get_active_provider() logger.debug("Searching tracks with %s: %r", provider, query) - return client.search_tracks(query, limit) + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "search_tracks"): + return list(fallback_client.search_tracks(query, limit=limit)) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "search_tracks"): + return list(client.search_tracks(query, limit=limit)) + return [] + outcome = get_metadata_engine().search_tracks(query, limit=limit, source_override=provider, allow_fallback=True) + return list(outcome.items) def search_artists(self, query: str, limit: int = 20) -> List: - client = self._get_client() provider = self.get_active_provider() logger.debug("Searching artists with %s: %r", provider, query) - return client.search_artists(query, limit) + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "search_artists"): + return list(fallback_client.search_artists(query, limit=limit)) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "search_artists"): + return list(client.search_artists(query, limit=limit)) + return [] + outcome = get_metadata_engine().search_artists(query, limit=limit, source_override=provider, allow_fallback=True) + return list(outcome.items) def search_albums(self, query: str, limit: int = 20) -> List: - client = self._get_client() provider = self.get_active_provider() logger.debug("Searching albums with %s: %r", provider, query) - return client.search_albums(query, limit) + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "search_albums"): + return list(fallback_client.search_albums(query, limit=limit)) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "search_albums"): + return list(client.search_albums(query, limit=limit)) + return [] + outcome = get_metadata_engine().search_albums(query, limit=limit, source_override=provider, allow_fallback=True) + return list(outcome.items) def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]: - client = self._get_client() - return client.get_track_details(track_id) + provider = self.get_active_provider() + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "get_track_details"): + return fallback_client.get_track_details(track_id) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "get_track_details"): + return client.get_track_details(track_id) + return None + outcome = get_metadata_engine().get_track_details(track_id, source_override=provider, allow_fallback=True) + return outcome.value def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: - client = self._get_client() - return client.get_album(album_id) + provider = self.get_active_provider() + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "get_album"): + return fallback_client.get_album(album_id) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "get_album"): + return client.get_album(album_id) + return None + outcome = get_metadata_engine().get_album(album_id, source_override=provider, allow_fallback=True) + return outcome.value def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]: - client = self._get_client() provider = self.get_active_provider() logger.debug("Fetching album tracks with %s: %s", provider, album_id) - return client.get_album_tracks(album_id) + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "get_album_tracks"): + return fallback_client.get_album_tracks(album_id) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "get_album_tracks"): + return client.get_album_tracks(album_id) + return None + outcome = get_metadata_engine().get_album_tracks(album_id, source_override=provider, allow_fallback=True) + return outcome.value def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: - client = self._get_client() - return client.get_artist(artist_id) + provider = self.get_active_provider() + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "get_artist"): + return fallback_client.get_artist(artist_id) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "get_artist"): + return client.get_artist(artist_id) + return None + outcome = get_metadata_engine().get_artist(artist_id, source_override=provider, allow_fallback=True) + return outcome.value def get_artist_albums(self, artist_id: str, album_type: str = "album,single", limit: int = 50) -> List: - client = self._get_client() provider = self.get_active_provider() logger.debug("Fetching artist albums with %s: %s", provider, artist_id) - return client.get_artist_albums(artist_id, album_type, limit) + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "get_artist_albums"): + return list(fallback_client.get_artist_albums(artist_id, album_type=album_type, limit=limit)) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "get_artist_albums"): + return list(client.get_artist_albums(artist_id, album_type=album_type, limit=limit)) + return [] + outcome = get_metadata_engine().get_artist_albums( + artist_id, + album_type=album_type, + limit=limit, + source_override=provider, + allow_fallback=True, + ) + return list(outcome.items) def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]: - client = self._get_client() - return client.get_track_features(track_id) + provider = self.get_active_provider() + fallback_client = self._get_spotify_fallback_client() if provider == "spotify" else None + if fallback_client and hasattr(fallback_client, "get_track_features"): + return fallback_client.get_track_features(track_id) + if provider == "hydrabase": + client = self._get_metadata_client(provider) + if client and hasattr(client, "get_track_features"): + return client.get_track_features(track_id) + return None + outcome = get_metadata_engine().get_track_features(track_id, source_override=provider, allow_fallback=True) + return outcome.value def get_user_playlists(self) -> List: if self.spotify and self.spotify.is_spotify_authenticated(): @@ -155,6 +244,7 @@ class MetadataService: logger.info("Reloading metadata service configuration") if self.spotify and hasattr(self.spotify, "reload_config"): self.spotify.reload_config() + get_metadata_engine().reload_config() new_source = get_primary_source() self._fallback_source = new_source try: diff --git a/core/metadata/similar_artists.py b/core/metadata/similar_artists.py index 95d70381..10bab356 100644 --- a/core/metadata/similar_artists.py +++ b/core/metadata/similar_artists.py @@ -29,10 +29,14 @@ def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]: primary_source = metadata_registry.get_primary_source() source_chain = list(metadata_registry.get_source_priority(primary_source)) override = (options.source_override or '').strip().lower() + enabled_sources = tuple(source.strip().lower() for source in (options.enabled_sources or ()) if source and str(source).strip()) if override: source_chain = [override] + [source for source in source_chain if source != override] + if enabled_sources: + source_chain = [source for source in source_chain if source in enabled_sources] + if not options.allow_fallback: source_chain = source_chain[:1] @@ -82,7 +86,7 @@ def _fetch_musicmap_similar_artist_names(artist_name: str) -> List[str]: def _build_similar_artist_payload(artist_data: Any, source: str) -> Optional[Dict[str, Any]]: - artist_id = _extract_lookup_value(artist_data, 'id', 'artist_id', 'spotify_id', 'itunes_id', 'deezer_id') + artist_id = _extract_lookup_value(artist_data, 'source_id', 'id', 'artist_id', 'spotify_id', 'itunes_id', 'deezer_id') if not artist_id: return None @@ -113,6 +117,7 @@ def _build_similar_artist_payload(artist_data: Any, source: str) -> Optional[Dic popularity = 0 return { + 'source_id': str(artist_id), 'id': str(artist_id), 'name': str(name or artist_id), 'image_url': _extract_artist_image_url(artist_data), @@ -132,7 +137,7 @@ def _resolve_musicmap_artist_source_ids(artist_name: str, source_chain: List[str continue search_results = _search_artists_for_source(source, client, artist_name, limit=1) - searched_source_ids[source] = _extract_lookup_value(search_results[0], 'id', 'artist_id') if search_results else None + searched_source_ids[source] = _extract_lookup_value(search_results[0], 'source_id', 'id', 'artist_id') if search_results else None return searched_source_ids @@ -165,7 +170,7 @@ def _match_musicmap_similar_artist( if matched_name and matched_name == searched_name: continue - matched_id = _extract_lookup_value(matched_artist, 'id', 'artist_id') + matched_id = _extract_lookup_value(matched_artist, 'source_id', 'id', 'artist_id') if not matched_id: continue @@ -244,7 +249,7 @@ def iter_musicmap_similar_artist_events( if not payload: continue - payload_id = str(payload.get('id') or '') + payload_id = str(payload.get('source_id') or payload.get('id') or '') if payload_id in seen_ids: continue diff --git a/core/metadata_service.py b/core/metadata_service.py index 5391a80b..5b78b023 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -15,6 +15,7 @@ from core.metadata.album_tracks import ( get_artist_albums_for_source, resolve_album_reference, ) +from core.metadata.engine import MetadataEngine, MetadataSourceFacade, get_metadata_engine from core.metadata.artist_image import get_artist_image_url from core.metadata.cache import MetadataCache, get_metadata_cache from core.metadata.completion import ( @@ -45,6 +46,7 @@ from core.metadata.registry import ( get_client_for_source, get_deezer_client, get_discogs_client, + get_enabled_metadata_sources, get_hydrabase_client, get_itunes_client, get_primary_client, @@ -54,6 +56,7 @@ from core.metadata.registry import ( get_source_priority, get_spotify_client, is_hydrabase_enabled, + is_metadata_source_enabled, register_profile_spotify_credentials_provider, register_runtime_clients, ) @@ -76,9 +79,11 @@ except Exception: # pragma: no cover - optional dependency fallback __all__ = [ "METADATA_SOURCE_PRIORITY", "MetadataCache", + "MetadataEngine", "MetadataLookupOptions", "MetadataProvider", "MetadataService", + "MetadataSourceFacade", "SpotifyClient", "iTunesClient", "_build_artist_detail_release_card", @@ -106,9 +111,11 @@ __all__ = [ "get_client_for_source", "get_deezer_client", "get_discogs_client", + "get_enabled_metadata_sources", "get_hydrabase_client", "get_itunes_client", "get_metadata_cache", + "get_metadata_engine", "get_metadata_service", "get_musicmap_similar_artists", "get_primary_client", @@ -120,6 +127,7 @@ __all__ = [ "iter_artist_discography_completion_events", "iter_musicmap_similar_artist_events", "is_hydrabase_enabled", + "is_metadata_source_enabled", "register_profile_spotify_credentials_provider", "register_runtime_clients", "requests", diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index c90e95bd..15c1c383 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1725,12 +1725,11 @@ class WatchlistScanner: def _match_to_itunes(self, artist_name: str) -> Optional[str]: """Match artist name to iTunes ID using fuzzy name comparison.""" try: - if hasattr(self, '_metadata_service') and self._metadata_service: - results = self._metadata_service.itunes.search_artists(artist_name, limit=5) - else: - logger.warning("Cannot match to iTunes - MetadataService not available") + client = get_client_for_source('itunes') + if not client or not hasattr(client, 'search_artists'): + logger.warning("Cannot match to iTunes - provider unavailable") return None - + results = client.search_artists(artist_name, limit=5) return self._best_artist_match(results, artist_name) except Exception as e: logger.warning(f"Could not match {artist_name} to iTunes: {e}") @@ -1739,17 +1738,9 @@ class WatchlistScanner: def _match_to_deezer(self, artist_name: str) -> Optional[str]: """Match artist name to Deezer ID using fuzzy name comparison.""" try: - # Try MetadataService fallback client (if it's Deezer) - if hasattr(self, '_metadata_service') and self._metadata_service: - client = self._metadata_service.itunes # Named 'itunes' but may be DeezerClient - from core.deezer_client import DeezerClient - if isinstance(client, DeezerClient): - results = client.search_artists(artist_name, limit=5) - return self._best_artist_match(results, artist_name) - - # Fallback: use cached Deezer client - from core.metadata.registry import get_deezer_client - client = get_deezer_client() + client = get_client_for_source('deezer') + if not client or not hasattr(client, 'search_artists'): + return None results = client.search_artists(artist_name, limit=5) return self._best_artist_match(results, artist_name) except Exception as e: @@ -1759,8 +1750,9 @@ class WatchlistScanner: def _match_to_discogs(self, artist_name: str) -> Optional[str]: """Match artist name to Discogs ID using fuzzy name comparison.""" try: - from core.metadata.registry import get_discogs_client - client = get_discogs_client() + client = get_client_for_source('discogs') + if not client or not hasattr(client, 'search_artists'): + return None results = client.search_artists(artist_name, limit=5) return self._best_artist_match(results, artist_name) except Exception as e: diff --git a/tests/metadata/test_metadata_engine.py b/tests/metadata/test_metadata_engine.py new file mode 100644 index 00000000..10ea9928 --- /dev/null +++ b/tests/metadata/test_metadata_engine.py @@ -0,0 +1,141 @@ +import sys +import types +from types import SimpleNamespace + + +if "spotipy" not in sys.modules: + spotipy = types.ModuleType("spotipy") + oauth2 = types.ModuleType("spotipy.oauth2") + + class _DummySpotify: + def __init__(self, *args, **kwargs): + pass + + class _DummyOAuth: + def __init__(self, *args, **kwargs): + pass + + spotipy.Spotify = _DummySpotify + oauth2.SpotifyOAuth = _DummyOAuth + oauth2.SpotifyClientCredentials = _DummyOAuth + spotipy.oauth2 = oauth2 + sys.modules["spotipy"] = spotipy + sys.modules["spotipy.oauth2"] = oauth2 + +if "config.settings" not in sys.modules: + config_pkg = types.ModuleType("config") + settings_mod = types.ModuleType("config.settings") + + class _DummyConfigManager: + def get(self, key, default=None): + return default + + def get_active_media_server(self): + return "primary" + + settings_mod.config_manager = _DummyConfigManager() + config_pkg.settings = settings_mod + sys.modules["config"] = config_pkg + sys.modules["config.settings"] = settings_mod + +from core.metadata.engine import MetadataEngine, MetadataSourceFacade +from core.metadata.models import MetadataArtist, MetadataRecord +from core.metadata.registry import get_client_for_source +from core.metadata.service import MetadataService + + +def test_get_client_for_source_returns_source_facade(): + client = get_client_for_source("itunes") + + assert isinstance(client, MetadataSourceFacade) + assert client.source == "itunes" + assert client.is_connected() is True + assert hasattr(client, "_get_artist_image_from_albums") + + +def test_metadata_service_searches_via_engine(monkeypatch): + calls = [] + + class _FakeOutcome: + def __init__(self, items): + self.items = items + + class _FakeEngine: + def search_artists(self, query, **kwargs): + calls.append(("artists", query, dict(kwargs))) + return _FakeOutcome([SimpleNamespace(id="artist-1", name="Artist One")]) + + def search_tracks(self, query, **kwargs): + calls.append(("tracks", query, dict(kwargs))) + return _FakeOutcome([]) + + def search_albums(self, query, **kwargs): + calls.append(("albums", query, dict(kwargs))) + return _FakeOutcome([]) + + monkeypatch.setattr("core.metadata.service.get_spotify_client", lambda *args, **kwargs: None) + monkeypatch.setattr("core.metadata.service.get_primary_source", lambda *args, **kwargs: "deezer") + monkeypatch.setattr("core.metadata.service.get_client_for_source", lambda source: SimpleNamespace(source=source)) + monkeypatch.setattr("core.metadata.service.get_metadata_engine", lambda: _FakeEngine()) + + service = MetadataService() + results = service.search_artists("Artist One", limit=5) + + assert [result.name for result in results] == ["Artist One"] + assert calls[0][0] == "artists" + assert calls[0][1] == "Artist One" + assert calls[0][2]["limit"] == 5 + assert calls[0][2]["source_override"] == "deezer" + + +def test_metadata_record_mirrors_source_id_and_id(): + record = MetadataRecord({"id": "provider-1"}) + assert record["source_id"] == "provider-1" + assert record["id"] == "provider-1" + + record = MetadataRecord({"source_id": "provider-2"}) + assert record["source_id"] == "provider-2" + assert record["id"] == "provider-2" + + record["id"] = "different-id" + assert record["source_id"] == "provider-2" + assert record["id"] == "provider-2" + + +def test_metadata_artist_to_dict_prefers_source_id(): + artist = MetadataArtist( + id="legacy-id", + name="Artist One", + popularity=0, + genres=[], + followers=0, + source_id="provider-id", + ) + + payload = artist.to_dict() + + assert payload["source_id"] == "provider-id" + assert payload["id"] == "provider-id" + + +def test_metadata_engine_caches_search_results_by_source_id(monkeypatch): + stored_rows = [] + + class _FakeAdapter: + def search_artists_raw(self, query, limit=20): + return [{"id": "provider-1", "name": "Artist One"}] + + class _FakeCache: + def store_entities_bulk(self, source, entity_kind, rows, skip_if_exists=True): + stored_rows.append((source, entity_kind, rows, skip_if_exists)) + + engine = MetadataEngine() + monkeypatch.setattr(engine, "_get_adapter", lambda source: _FakeAdapter()) + monkeypatch.setattr("core.metadata.engine.get_metadata_cache", lambda: _FakeCache()) + + outcome = engine.search_artists("Artist One", source_override="deezer", allow_fallback=False) + + assert outcome.items[0]["source_id"] == "provider-1" + assert stored_rows[0][0] == "deezer" + assert stored_rows[0][1] == "artist" + assert stored_rows[0][2][0][0] == "provider-1"