Add typed metadata engine baseline

- introduce the in-process metadata engine and fresh provider adapters
- normalize external identity through source_id while keeping id as a compatibility alias
- route metadata callers through the new seam and add regression coverage
This commit is contained in:
Antti Kettunen 2026-05-05 06:57:42 +03:00
parent 2da1e8b2d9
commit 896b1b1012
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
23 changed files with 3283 additions and 124 deletions

View file

@ -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",

View file

@ -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,

View file

@ -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),
}

View file

@ -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")

View file

@ -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)

View file

@ -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)

1144
core/metadata/engine.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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."""

View file

@ -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

202
core/metadata/models.py Normal file
View file

@ -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

454
core/metadata/normalize.py Normal file
View file

@ -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,
)

View file

@ -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",
]

View file

@ -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,
)

View file

@ -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]

View file

@ -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,
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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",

View file

@ -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:

View file

@ -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"