"""Metadata client registry and source selection. Owns shared metadata client singletons, runtime client registration, and canonical source selection. Package-internal code should use this module instead of importing `web_server`. """ from __future__ import annotations import threading import hashlib import time from typing import Any, Callable, Dict, Optional from utils.logging_config import get_logger logger = get_logger("metadata.registry") MetadataClientFactory = Callable[[], Any] METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase", "musicbrainz") METADATA_SOURCE_LABELS = { "spotify": "Spotify", "itunes": "iTunes", "deezer": "Deezer", "discogs": "Discogs", "hydrabase": "Hydrabase", "musicbrainz": "MusicBrainz", } _UNSET = object() _client_cache_lock = threading.RLock() _client_cache: Dict[str, Any] = {} _runtime_clients_lock = threading.RLock() _runtime_clients: Dict[str, Any] = { "spotify": None, "hydrabase": None, } _dev_mode_enabled_provider: Callable[[], bool] = lambda: False _profile_spotify_credentials_provider: Callable[[int], Any] = lambda profile_id: None def register_runtime_clients( *, spotify_client: Any = _UNSET, hydrabase_client: Any = _UNSET, dev_mode_enabled_provider: Optional[Callable[[], bool]] = _UNSET, ) -> None: """Register app-owned runtime clients. `None` is a valid value and clears the registered client. Omitted arguments leave the current registration unchanged. """ global _dev_mode_enabled_provider with _runtime_clients_lock: if spotify_client is not _UNSET: _runtime_clients["spotify"] = spotify_client if hydrabase_client is not _UNSET: _runtime_clients["hydrabase"] = hydrabase_client if dev_mode_enabled_provider is not _UNSET: _dev_mode_enabled_provider = dev_mode_enabled_provider or (lambda: False) def register_profile_spotify_credentials_provider( provider: Optional[Callable[[int], Any]] = _UNSET, ) -> None: """Register a callable that returns per-profile Spotify credentials.""" global _profile_spotify_credentials_provider with _runtime_clients_lock: if provider is not _UNSET: _profile_spotify_credentials_provider = provider or (lambda profile_id: None) def get_registered_runtime_client(name: str) -> Any: with _runtime_clients_lock: return _runtime_clients.get(name) def clear_cached_metadata_clients() -> None: """Clear lazily-created client singletons. Runtime clients registered by the host app stay in place. """ with _client_cache_lock: _client_cache.clear() 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) def clear_cached_profile_spotify_client(profile_id: int) -> None: """Clear any cached Spotify client for a specific profile.""" prefix = f"spotify_profile::{profile_id}::" with _client_cache_lock: for key in [key for key in _client_cache if key.startswith(prefix)]: _client_cache.pop(key, None) def _get_config_value(key: str, default: Any = None) -> Any: try: from config.settings import config_manager return config_manager.get(key, default) except Exception: return default def _get_spotify_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory: if client_factory is not None: return client_factory from core.spotify_client import SpotifyClient return SpotifyClient def _get_itunes_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory: if client_factory is not None: return client_factory from core.itunes_client import iTunesClient return iTunesClient def _get_deezer_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory: if client_factory is not None: return client_factory from core.deezer_client import DeezerClient return DeezerClient def _get_discogs_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory: if client_factory is not None: return client_factory from core.discogs_client import DiscogsClient return DiscogsClient def _get_amazon_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory: if client_factory is not None: return client_factory from core.amazon_client import AmazonClient return AmazonClient def _get_musicbrainz_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory: if client_factory is not None: return client_factory from core.musicbrainz_search import MusicBrainzSearchClient return MusicBrainzSearchClient def get_spotify_client(client_factory: Optional[MetadataClientFactory] = None): """Get shared Spotify client. Prefers the app-registered runtime client. Falls back to a lazily cached singleton if no runtime client was registered. """ runtime_client = get_registered_runtime_client("spotify") if runtime_client is not None: return runtime_client cache_key = "spotify" factory = _get_spotify_factory(client_factory) with _client_cache_lock: client = _client_cache.get(cache_key) if client is None: client = factory() _client_cache[cache_key] = client return client def _build_profile_spotify_cache_key(profile_id: int, creds: Dict[str, Any]) -> str: fingerprint = hashlib.sha256( f"{profile_id}:{creds.get('client_id', '')}:{creds.get('client_secret', '')}:{creds.get('redirect_uri', '')}".encode( "utf-8" ) ).hexdigest() return f"spotify_profile::{profile_id}::{fingerprint}" def get_spotify_client_for_profile(profile_id: Optional[int] = None): """Get a profile-specific Spotify client or fall back to the global one.""" if profile_id is None or profile_id == 1: return get_spotify_client() try: creds = _profile_spotify_credentials_provider(profile_id) if not creds or not creds.get("client_id"): return get_spotify_client() except Exception: return get_spotify_client() cache_key = _build_profile_spotify_cache_key(profile_id, creds) with _client_cache_lock: client = _client_cache.get(cache_key) if client is not None and getattr(client, "sp", None) is not None: return client try: from core.spotify_client import SpotifyClient from spotipy.oauth2 import SpotifyOAuth import spotipy auth_manager = SpotifyOAuth( client_id=creds["client_id"], client_secret=creds["client_secret"], redirect_uri=creds.get("redirect_uri", "http://127.0.0.1:8888/callback"), scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read", cache_path=f"config/.spotify_cache_profile_{profile_id}", state=f"profile_{profile_id}", ) profile_client = SpotifyClient() profile_client.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15) profile_client.user_id = None with _client_cache_lock: _client_cache[cache_key] = profile_client logger.info("Created per-profile Spotify client for profile %s", profile_id) return profile_client except Exception as e: logger.error("Failed to create per-profile Spotify client for profile %s: %s", profile_id, e) return get_spotify_client() def get_deezer_client(client_factory: Optional[MetadataClientFactory] = None): """Get cached Deezer client keyed by current access token.""" current_token = _get_config_value("deezer.access_token", None) cache_key = f"deezer::{current_token or ''}" factory = _get_deezer_factory(client_factory) with _client_cache_lock: client = _client_cache.get(cache_key) if client is None: client = factory() _client_cache[cache_key] = client return client def get_itunes_client(client_factory: Optional[MetadataClientFactory] = None): """Get cached iTunes client.""" cache_key = "itunes" factory = _get_itunes_factory(client_factory) with _client_cache_lock: client = _client_cache.get(cache_key) if client is None: client = factory() _client_cache[cache_key] = client return client def get_discogs_client( token: Optional[str] = None, client_factory: Optional[MetadataClientFactory] = None, ): """Get cached Discogs client keyed by token.""" if token is None: current_token = _get_config_value("discogs.token", "") or "" else: current_token = token or "" cache_key = f"discogs::{current_token}" factory = _get_discogs_factory(client_factory) with _client_cache_lock: client = _client_cache.get(cache_key) if client is None: client = factory(token=current_token or None) # type: ignore[misc] _client_cache[cache_key] = client return client def get_amazon_client(client_factory: Optional[MetadataClientFactory] = None): """Get cached Amazon Music client.""" cache_key = "amazon" factory = _get_amazon_factory(client_factory) with _client_cache_lock: client = _client_cache.get(cache_key) if client is None: client = factory() _client_cache[cache_key] = client return client def get_musicbrainz_client(client_factory: Optional[MetadataClientFactory] = None): """Get cached MusicBrainz primary source client.""" cache_key = "musicbrainz" factory = _get_musicbrainz_factory(client_factory) with _client_cache_lock: client = _client_cache.get(cache_key) if client is None: client = factory() _client_cache[cache_key] = client return client def is_hydrabase_enabled() -> bool: """Return True when Hydrabase is connected and app-enabled.""" try: client = get_registered_runtime_client("hydrabase") if not client or not client.is_connected(): return False return bool(_dev_mode_enabled_provider()) except Exception: return False def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = True): """Return registered Hydrabase client or iTunes fallback.""" try: client = get_registered_runtime_client("hydrabase") if client and client.is_connected(): if not require_enabled or bool(_dev_mode_enabled_provider()): return client except Exception as e: logger.debug("hydrabase client lookup: %s", e) if allow_fallback: return get_itunes_client() return None def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str: """Return configured primary metadata source.""" _default = METADATA_SOURCE_PRIORITY[0] source = _get_config_value("metadata.fallback_source", _default) or _default if source == "spotify": try: spotify = get_spotify_client(client_factory=spotify_client_factory) if not spotify or not spotify.is_spotify_authenticated(): return _default except Exception: return _default return source def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str: """Return the active metadata source after Spotify is disconnected.""" _default = METADATA_SOURCE_PRIORITY[0] source = configured_source if configured_source is not None else _get_config_value("metadata.fallback_source", _default) source = source or _default return _default if source == "spotify" else source def get_metadata_source_label(source: str) -> str: """Return a human-readable label for a metadata source.""" return METADATA_SOURCE_LABELS.get(source, "Unmapped") def get_source_priority(preferred_source: str): """Return source priority with preferred source first.""" ordered = [] if preferred_source in METADATA_SOURCE_PRIORITY: ordered.append(preferred_source) for source in METADATA_SOURCE_PRIORITY: if source not in ordered: ordered.append(source) return ordered def get_primary_client( *, spotify_client_factory: Optional[MetadataClientFactory] = None, itunes_client_factory: Optional[MetadataClientFactory] = None, deezer_client_factory: Optional[MetadataClientFactory] = None, discogs_client_factory: Optional[MetadataClientFactory] = None, amazon_client_factory: Optional[MetadataClientFactory] = None, musicbrainz_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, amazon_client_factory=amazon_client_factory, musicbrainz_client_factory=musicbrainz_client_factory, ) def get_primary_source_status( *, spotify_client_factory: Optional[MetadataClientFactory] = None, itunes_client_factory: Optional[MetadataClientFactory] = None, deezer_client_factory: Optional[MetadataClientFactory] = None, discogs_client_factory: Optional[MetadataClientFactory] = None, amazon_client_factory: Optional[MetadataClientFactory] = None, musicbrainz_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" 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, amazon_client_factory=amazon_client_factory, musicbrainz_client_factory=musicbrainz_client_factory, ) if source == "spotify": connected = bool(client and client.is_spotify_authenticated()) elif source == "hydrabase": 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()) else: connected = client is not None except Exception: connected = False return { "source": source, "connected": connected, "response_time": round((time.time() - started) * 1000, 1), } def get_client_for_source( source: str, *, spotify_client_factory: Optional[MetadataClientFactory] = None, itunes_client_factory: Optional[MetadataClientFactory] = None, deezer_client_factory: Optional[MetadataClientFactory] = None, discogs_client_factory: Optional[MetadataClientFactory] = None, amazon_client_factory: Optional[MetadataClientFactory] = None, musicbrainz_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) if source == "hydrabase": return get_hydrabase_client(allow_fallback=False) if source == "itunes": return get_itunes_client(client_factory=itunes_client_factory) if source == "amazon": return get_amazon_client(client_factory=amazon_client_factory) if source == "musicbrainz": return get_musicbrainz_client(client_factory=musicbrainz_client_factory) return None