mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
473 lines
16 KiB
473 lines
16 KiB
"""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
|