soulsync/core/metadata/engine.py
Antti Kettunen 896b1b1012
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
2026-05-09 11:44:47 +03:00

1144 lines
42 KiB
Python

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