Move metadata API into package

- add package-owned metadata API, cache, registry, and lookup modules
- keep legacy metadata_service and metadata_cache paths as explicit shims
- update metadata call sites and tests to use package-owned helpers
This commit is contained in:
Antti Kettunen 2026-04-29 08:10:18 +03:00
parent e504099439
commit a759f778b6
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
17 changed files with 2668 additions and 2305 deletions

View file

@ -6,7 +6,7 @@ from typing import Dict, List, Optional, Any
from functools import wraps from functools import wraps
from dataclasses import dataclass from dataclasses import dataclass
from utils.logging_config import get_logger from utils.logging_config import get_logger
from core.metadata_cache import get_metadata_cache from core.metadata.cache import get_metadata_cache
logger = get_logger("deezer_client") logger = get_logger("deezer_client")

View file

@ -411,7 +411,7 @@ class DeezerDownloadClient:
album_ids.add(str(aid)) album_ids.add(str(aid))
album_release_dates = {} album_release_dates = {}
try: try:
from core.metadata_cache import get_metadata_cache from core.metadata.cache import get_metadata_cache
cache = get_metadata_cache() cache = get_metadata_cache()
except Exception: except Exception:
cache = None cache = None

View file

@ -12,7 +12,7 @@ import re
import time import time
import threading import threading
import requests import requests
from core.metadata_cache import get_metadata_cache from core.metadata.cache import get_metadata_cache
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps from functools import wraps

View file

@ -5,7 +5,7 @@ import threading
from functools import wraps from functools import wraps
from dataclasses import dataclass from dataclasses import dataclass
from utils.logging_config import get_logger from utils.logging_config import get_logger
from core.metadata_cache import get_metadata_cache from core.metadata.cache import get_metadata_cache
logger = get_logger("itunes_client") logger = get_logger("itunes_client")

View file

@ -1,2 +1,76 @@
"""Metadata helper package.""" """Metadata package public surface."""
from core.metadata.api import (
MetadataProvider,
MetadataService,
check_album_completion,
check_artist_discography_completion,
check_single_completion,
clear_cached_metadata_clients,
get_album_for_source,
get_album_tracks_for_source,
get_artist_album_tracks,
get_artist_albums_for_source,
get_artist_detail_discography,
get_artist_discography,
get_artist_image_url,
get_client_for_source,
get_deezer_client,
get_discogs_client,
get_hydrabase_client,
get_itunes_client,
get_metadata_service,
get_musicmap_similar_artists,
get_primary_client,
get_primary_source,
get_spotify_client,
get_source_priority,
iter_artist_discography_completion_events,
iter_musicmap_similar_artist_events,
is_hydrabase_enabled,
resolve_album_reference,
)
from core.metadata.cache import MetadataCache, get_metadata_cache
from core.metadata.lookup import MetadataLookupOptions
from core.metadata.registry import (
METADATA_SOURCE_PRIORITY,
get_registered_runtime_client,
register_runtime_clients,
)
__all__ = [
"METADATA_SOURCE_PRIORITY",
"MetadataCache",
"MetadataLookupOptions",
"MetadataProvider",
"MetadataService",
"check_album_completion",
"check_artist_discography_completion",
"check_single_completion",
"clear_cached_metadata_clients",
"get_album_for_source",
"get_album_tracks_for_source",
"get_artist_album_tracks",
"get_artist_albums_for_source",
"get_artist_detail_discography",
"get_artist_discography",
"get_artist_image_url",
"get_client_for_source",
"get_deezer_client",
"get_discogs_client",
"get_hydrabase_client",
"get_itunes_client",
"get_metadata_cache",
"get_metadata_service",
"get_musicmap_similar_artists",
"get_primary_client",
"get_primary_source",
"get_registered_runtime_client",
"get_spotify_client",
"get_source_priority",
"iter_artist_discography_completion_events",
"iter_musicmap_similar_artist_events",
"is_hydrabase_enabled",
"register_runtime_clients",
"resolve_album_reference",
]

2003
core/metadata/api.py Normal file

File diff suppressed because it is too large Load diff

22
core/metadata/lookup.py Normal file
View file

@ -0,0 +1,22 @@
"""Shared metadata lookup policy objects."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
__all__ = ["MetadataLookupOptions"]
@dataclass(frozen=True)
class MetadataLookupOptions:
"""Generic metadata lookup policy shared by metadata services."""
source_override: Optional[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

274
core/metadata/registry.py Normal file
View file

@ -0,0 +1,274 @@
"""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
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")
_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
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 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 _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_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 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 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:
pass
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."""
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
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"
return source
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,
):
"""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,
)
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,
):
"""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:
pass
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)
return None

174
core/metadata/service.py Normal file
View file

@ -0,0 +1,174 @@
"""Compatibility metadata service facade.
The modern lookup code prefers standalone functions and shared registry
helpers, but the legacy `MetadataService` wrapper remains available for
call sites that still expect an object.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Literal
from core.metadata.registry import (
get_client_for_source,
get_primary_source,
get_spotify_client,
)
from utils.logging_config import get_logger
logger = get_logger("metadata_service")
MetadataProvider = Literal["spotify", "itunes", "auto"]
class MetadataService:
"""
Unified metadata service that seamlessly switches between Spotify and
the configured fallback source.
"""
def __init__(self, preferred_provider: MetadataProvider = "auto"):
self.preferred_provider = preferred_provider
try:
self.spotify = get_spotify_client()
except Exception:
self.spotify = None
self._fallback_source = get_primary_source()
try:
self.itunes = get_client_for_source(self._fallback_source)
except Exception:
self.itunes = None
self._log_initialization()
def _log_initialization(self):
spotify_status = "Authenticated" if self.spotify and self.spotify.is_spotify_authenticated() else "Not authenticated"
fallback_status = "Available" if self.itunes and getattr(self.itunes, "is_authenticated", lambda: False)() else "Not available"
logger.info(
"MetadataService initialized - Spotify: %s, %s: %s",
spotify_status,
self._fallback_source.capitalize(),
fallback_status,
)
logger.info("Preferred provider: %s", self.preferred_provider)
def get_active_provider(self) -> str:
if self.preferred_provider == "spotify":
return "spotify"
if self.preferred_provider == "itunes":
return self._fallback_source
return get_primary_source()
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
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)
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)
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)
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
client = self._get_client()
return client.get_track_details(track_id)
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
client = self._get_client()
return client.get_album(album_id)
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)
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
client = self._get_client()
return client.get_artist(artist_id)
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)
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
client = self._get_client()
return client.get_track_features(track_id)
def get_user_playlists(self) -> List:
if self.spotify and self.spotify.is_spotify_authenticated():
return self.spotify.get_user_playlists()
logger.warning("User playlists only available with Spotify authentication")
return []
def get_saved_tracks(self) -> List:
if self.spotify and self.spotify.is_spotify_authenticated():
return self.spotify.get_saved_tracks()
logger.warning("Saved tracks only available with Spotify authentication")
return []
def get_saved_tracks_count(self) -> int:
if self.spotify and self.spotify.is_spotify_authenticated():
return self.spotify.get_saved_tracks_count()
return 0
def is_authenticated(self) -> bool:
return bool(self.spotify and self.spotify.is_spotify_authenticated()) or bool(
self.itunes and getattr(self.itunes, "is_authenticated", lambda: False)()
)
def get_provider_info(self) -> Dict[str, Any]:
spotify_authenticated = bool(self.spotify and self.spotify.is_spotify_authenticated())
itunes_available = bool(self.itunes and getattr(self.itunes, "is_authenticated", lambda: False)())
return {
"active_provider": self.get_active_provider(),
"spotify_authenticated": spotify_authenticated,
"itunes_available": itunes_available,
"fallback_source": self._fallback_source,
"preferred_provider": self.preferred_provider,
"can_access_user_data": spotify_authenticated,
}
def reload_config(self):
logger.info("Reloading metadata service configuration")
if self.spotify and hasattr(self.spotify, "reload_config"):
self.spotify.reload_config()
new_source = get_primary_source()
self._fallback_source = new_source
try:
self.itunes = get_client_for_source(new_source)
except Exception:
self.itunes = None
self._log_initialization()
_metadata_service_instance: Optional[MetadataService] = None
def get_metadata_service() -> MetadataService:
global _metadata_service_instance
if _metadata_service_instance is None:
_metadata_service_instance = MetadataService()
return _metadata_service_instance

View file

@ -23,7 +23,7 @@ from core.imports.context import (
get_source_tag_names, get_source_tag_names,
normalize_import_context, normalize_import_context,
) )
from core.metadata_service import get_itunes_client from core.metadata.registry import get_itunes_client
from database.music_database import get_database from database.music_database import get_database
from core.metadata.common import ( from core.metadata.common import (
get_config_manager, get_config_manager,

File diff suppressed because it is too large Load diff

View file

@ -196,7 +196,7 @@ class RepairWorker:
def metadata_cache(self): def metadata_cache(self):
if self._metadata_cache is None: if self._metadata_cache is None:
try: try:
from core.metadata_cache import get_metadata_cache from core.metadata.cache import get_metadata_cache
self._metadata_cache = get_metadata_cache() self._metadata_cache = get_metadata_cache()
except Exception as e: except Exception as e:
logger.error("Failed to get metadata cache: %s", e) logger.error("Failed to get metadata cache: %s", e)

View file

@ -8,7 +8,7 @@ from functools import wraps
from dataclasses import dataclass from dataclasses import dataclass
from utils.logging_config import get_logger from utils.logging_config import get_logger
from config.settings import config_manager from config.settings import config_manager
from core.metadata_cache import get_metadata_cache from core.metadata.cache import get_metadata_cache
logger = get_logger("spotify_client") logger = get_logger("spotify_client")

View file

@ -358,7 +358,7 @@ class WatchlistScanner:
def metadata_service(self): def metadata_service(self):
"""Get or create MetadataService instance (lazy loading)""" """Get or create MetadataService instance (lazy loading)"""
if self._metadata_service is None: if self._metadata_service is None:
from core.metadata_service import MetadataService from core.metadata.service import MetadataService
self._metadata_service = MetadataService() self._metadata_service = MetadataService()
return self._metadata_service return self._metadata_service
@ -1642,7 +1642,7 @@ class WatchlistScanner:
return self._best_artist_match(results, artist_name) return self._best_artist_match(results, artist_name)
# Fallback: use cached Deezer client # Fallback: use cached Deezer client
from core.metadata_service import get_deezer_client from core.metadata.registry import get_deezer_client
client = get_deezer_client() client = get_deezer_client()
results = client.search_artists(artist_name, limit=5) results = client.search_artists(artist_name, limit=5)
return self._best_artist_match(results, artist_name) return self._best_artist_match(results, artist_name)
@ -1653,7 +1653,7 @@ class WatchlistScanner:
def _match_to_discogs(self, artist_name: str) -> Optional[str]: def _match_to_discogs(self, artist_name: str) -> Optional[str]:
"""Match artist name to Discogs ID using fuzzy name comparison.""" """Match artist name to Discogs ID using fuzzy name comparison."""
try: try:
from core.metadata_service import get_discogs_client from core.metadata.registry import get_discogs_client
client = get_discogs_client() client = get_discogs_client()
results = client.search_artists(artist_name, limit=5) results = client.search_artists(artist_name, limit=5)
return self._best_artist_match(results, artist_name) return self._best_artist_match(results, artist_name)

View file

@ -14,7 +14,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from core.metadata_cache import MetadataCache from core.metadata.cache import MetadataCache
@pytest.fixture @pytest.fixture

View file

@ -96,7 +96,8 @@ from core.tidal_client import TidalClient # Added import for Tidal
from core.matching_engine import MusicMatchingEngine from core.matching_engine import MusicMatchingEngine
from core.database_update_worker import DatabaseUpdateWorker from core.database_update_worker import DatabaseUpdateWorker
from core.web_scan_manager import WebScanManager from core.web_scan_manager import WebScanManager
from core.metadata_cache import get_metadata_cache from core.metadata.cache import get_metadata_cache
from core.metadata.registry import register_runtime_clients as register_metadata_runtime_clients
from core.imports.context import ( from core.imports.context import (
get_import_clean_album, get_import_clean_album,
get_import_clean_title, get_import_clean_title,
@ -6039,7 +6040,7 @@ _comparison_lock = threading.Lock()
def _is_hydrabase_active(): def _is_hydrabase_active():
"""Check if Hydrabase is connected and enabled for metadata use.""" """Check if Hydrabase is connected and enabled for metadata use."""
try: try:
from core.metadata_service import is_hydrabase_enabled from core.metadata.registry import is_hydrabase_enabled
return is_hydrabase_enabled() return is_hydrabase_enabled()
except Exception: except Exception:
return False return False
@ -10274,7 +10275,8 @@ def get_artist_detail(artist_id):
# Get source-priority discography for proper categorization and missing releases # Get source-priority discography for proper categorization and missing releases
artist_detail_discography = None artist_detail_discography = None
try: try:
from core.metadata_service import MetadataLookupOptions, get_artist_detail_discography as _get_artist_detail_discography from core.metadata.lookup import MetadataLookupOptions
from core.metadata_service import get_artist_detail_discography as _get_artist_detail_discography
artist_source_ids = { artist_source_ids = {
'spotify': artist_info.get('spotify_artist_id'), 'spotify': artist_info.get('spotify_artist_id'),
@ -10510,7 +10512,8 @@ def get_artist_discography(artist_id):
else: else:
effective_override_source = 'spotify' effective_override_source = 'spotify'
from core.metadata_service import MetadataLookupOptions, get_artist_discography as _get_artist_discography from core.metadata.lookup import MetadataLookupOptions
from core.metadata_service import get_artist_discography as _get_artist_discography
discography = _get_artist_discography( discography = _get_artist_discography(
artist_id, artist_id,
@ -25122,38 +25125,38 @@ deezer_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix
def _get_deezer_client(): def _get_deezer_client():
"""Get cached Deezer client.""" """Get cached Deezer client."""
from core.metadata_service import get_deezer_client from core.metadata.registry import get_deezer_client
return get_deezer_client() return get_deezer_client()
def _get_itunes_client(): def _get_itunes_client():
"""Get cached iTunes client.""" """Get cached iTunes client."""
from core.metadata_service import get_itunes_client from core.metadata.registry import get_itunes_client
return get_itunes_client() return get_itunes_client()
def _get_discogs_client(token=None): def _get_discogs_client(token=None):
"""Get cached Discogs client.""" """Get cached Discogs client."""
from core.metadata_service import get_discogs_client from core.metadata.registry import get_discogs_client
return get_discogs_client(token) return get_discogs_client(token)
def _get_metadata_fallback_source(): def _get_metadata_fallback_source():
"""Get the configured primary metadata source. """Get the configured primary metadata source.
Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'. Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'.
NOTE: This is a thin wrapper canonical logic lives in core.metadata_service.get_primary_source(). NOTE: This is a thin wrapper canonical logic lives in core.metadata.registry.get_primary_source().
Kept as a local function because 70+ callers reference it by name.""" Kept as a local function because 70+ callers reference it by name."""
from core.metadata_service import get_primary_source from core.metadata.registry import get_primary_source
return get_primary_source() return get_primary_source()
def _get_metadata_fallback_client(): def _get_metadata_fallback_client():
"""Get the active metadata client based on settings. """Get the active metadata client based on settings.
Returns a SpotifyClient, iTunesClient, DeezerClient, DiscogsClient, or HydrabaseClient instance.""" Returns a SpotifyClient, iTunesClient, DeezerClient, DiscogsClient, or HydrabaseClient instance."""
source = _get_metadata_fallback_source() source = _get_metadata_fallback_source()
from core.metadata.registry import get_client_for_source
client = get_client_for_source(source)
if client is not None:
return client
if source == 'spotify': if source == 'spotify':
if spotify_client and spotify_client.is_spotify_authenticated():
return spotify_client
# Spotify selected but not authed — fall back to deezer
return _get_deezer_client()
if source == 'deezer':
return _get_deezer_client() return _get_deezer_client()
if source == 'discogs': if source == 'discogs':
token = config_manager.get('discogs.token', '') token = config_manager.get('discogs.token', '')
@ -29094,7 +29097,7 @@ def start_watchlist_scan():
"""Start a watchlist scan for new releases""" """Start a watchlist scan for new releases"""
try: try:
# Check if MetadataService can provide a working client (Spotify OR fallback) # Check if MetadataService can provide a working client (Spotify OR fallback)
from core.metadata_service import MetadataService from core.metadata.service import MetadataService
metadata_service = MetadataService() metadata_service = MetadataService()
# Get active provider - will be spotify or the configured fallback # Get active provider - will be spotify or the configured fallback
@ -29581,7 +29584,7 @@ def watchlist_artist_config(artist_id):
'preferred_metadata_source': result[17] if len(result) > 17 else None, 'preferred_metadata_source': result[17] if len(result) > 17 else None,
} }
from core.metadata_service import get_primary_source from core.metadata.registry import get_primary_source
return jsonify({ return jsonify({
"success": True, "success": True,
"config": config, "config": config,
@ -30332,7 +30335,7 @@ def _get_active_discovery_source():
NOTE: Thin wrapper canonical logic lives in core.metadata_service.get_primary_source(). NOTE: Thin wrapper canonical logic lives in core.metadata_service.get_primary_source().
""" """
from core.metadata_service import get_primary_source from core.metadata.registry import get_primary_source
return get_primary_source() return get_primary_source()
@ -36121,7 +36124,6 @@ def enrich_beatport_tracks():
uncached_tracks = [] uncached_tracks = []
uncached_indices = [] uncached_indices = []
from core.metadata_cache import get_metadata_cache
mcache = get_metadata_cache() mcache = get_metadata_cache()
for i, track in enumerate(tracks): for i, track in enumerate(tracks):
@ -39694,6 +39696,12 @@ except Exception as e:
hydrabase_worker = None hydrabase_worker = None
hydrabase_client = None hydrabase_client = None
register_metadata_runtime_clients(
spotify_client=spotify_client,
hydrabase_client=hydrabase_client,
dev_mode_enabled_provider=lambda: dev_mode_enabled,
)
# --- Hydrabase Auto-Reconnect --- # --- Hydrabase Auto-Reconnect ---
try: try:
_hydra_cfg = config_manager.get_hydrabase_config() _hydra_cfg = config_manager.get_hydrabase_config()
@ -40212,7 +40220,6 @@ def repair_findings_counts():
def repair_cache_health(): def repair_cache_health():
"""Get metadata cache health stats for the repair dashboard""" """Get metadata cache health stats for the repair dashboard"""
try: try:
from core.metadata_cache import get_metadata_cache
cache = get_metadata_cache() cache = get_metadata_cache()
return jsonify(cache.get_health_stats()), 200 return jsonify(cache.get_health_stats()), 200
except Exception as e: except Exception as e:
@ -40551,7 +40558,7 @@ def import_search_albums():
return jsonify({'success': False, 'error': 'Missing query parameter'}), 400 return jsonify({'success': False, 'error': 'Missing query parameter'}), 400
limit = min(int(request.args.get('limit', 12)), 50) limit = min(int(request.args.get('limit', 12)), 50)
from core.metadata_service import get_primary_source from core.metadata.registry import get_primary_source
if get_primary_source() == 'hydrabase' and hydrabase_worker and dev_mode_enabled: if get_primary_source() == 'hydrabase' and hydrabase_worker and dev_mode_enabled:
hydrabase_worker.enqueue(query, 'albums') hydrabase_worker.enqueue(query, 'albums')