Centralize metadata source selection in core/metadata_service.py

All metadata source decisions now flow through get_primary_source() and
get_primary_client() in core/metadata_service.py. Previously 6 different
files reimplemented this logic with inconsistent defaults ('itunes' vs
'deezer') and auth checks, causing bugs when any one was missed.

Changes:
- metadata_service.py: Added canonical get_primary_source/get_primary_client
- web_server.py: _get_metadata_fallback_source() and _get_active_discovery_source()
  are now thin wrappers delegating to metadata_service
- seasonal_discovery.py: _get_source() delegates to metadata_service
- personalized_playlists.py: _get_active_source() delegates to metadata_service
- spotify_client.py: Fixed _fallback_source default from 'itunes' to 'deezer'
- watchlist_scanner.py: _get_fallback_metadata_client() delegates to metadata_service

Future changes to source selection only need to update one file.
This commit is contained in:
Broque Thomas 2026-04-10 12:34:25 -07:00
parent 52a5d93018
commit 498c22e7c3
6 changed files with 110 additions and 65 deletions

View file

@ -1,9 +1,10 @@
"""
Metadata Service - Hot-swappable Spotify/iTunes/Deezer provider
Metadata Service - Centralized metadata source selection
Automatically uses Spotify when authenticated, falls back to the configured
fallback source (iTunes or Deezer) when not.
Provides unified interface for all metadata operations.
ALL metadata source decisions flow through this module. Other files import
get_primary_source() and get_primary_client() instead of reimplementing
the logic. This prevents bugs where different files have different defaults
or auth checks.
"""
from typing import List, Optional, Dict, Any, Literal
@ -16,37 +17,106 @@ logger = get_logger("metadata_service")
MetadataProvider = Literal["spotify", "itunes", "auto"]
def _get_configured_fallback_source():
"""Get the configured metadata fallback source ('itunes' or 'deezer')."""
# =============================================================================
# CANONICAL SOURCE SELECTION — all code should use these two functions
# =============================================================================
def get_primary_source() -> str:
"""Get the user's configured primary metadata source.
Returns 'spotify', 'deezer', 'itunes', 'discogs', or 'hydrabase'.
If the user selected Spotify but it's not authenticated, falls back to 'deezer'.
This is THE single source of truth for "which metadata source should I use?"
All other modules should import this function instead of reading config directly.
"""
try:
from config.settings import config_manager
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
except Exception:
return 'itunes'
return 'deezer'
# Validate Spotify selection — can't use it if not authenticated
if source == 'spotify':
try:
import importlib
ws = importlib.import_module('web_server')
sc = getattr(ws, 'spotify_client', None)
if not sc or not sc.is_spotify_authenticated():
return 'deezer'
except Exception:
return 'deezer'
return source
def _create_fallback_client():
"""Create the configured fallback metadata client."""
source = _get_configured_fallback_source()
def get_primary_client():
"""Get the client object for the user's configured primary metadata source.
Returns a SpotifyClient, DeezerClient, iTunesClient, DiscogsClient,
or HydrabaseClient instance.
This is THE single source of truth for "which client should I call?"
"""
source = get_primary_source()
if source == 'spotify':
try:
import importlib
ws = importlib.import_module('web_server')
sc = getattr(ws, 'spotify_client', None)
if sc and sc.is_spotify_authenticated():
return sc
except Exception:
pass
# Spotify selected but unavailable — fall back to Deezer
from core.deezer_client import DeezerClient
return DeezerClient()
if source == 'deezer':
from core.deezer_client import DeezerClient
return DeezerClient()
if source == 'discogs':
try:
from config.settings import config_manager
token = config_manager.get('discogs.token', '')
if token:
from core.discogs_client import DiscogsClient
return DiscogsClient(token=token)
except Exception:
pass
return iTunesClient()
if source == 'hydrabase':
try:
from core.hydrabase_client import HydrabaseClient
# Hydrabase client is managed globally — try to import the running instance
import importlib
ws_module = importlib.import_module('web_server')
client = getattr(ws_module, 'hydrabase_client', None)
ws = importlib.import_module('web_server')
client = getattr(ws, 'hydrabase_client', None)
if client and client.is_connected():
return client
except Exception:
pass
# Hydrabase not available — fall back to iTunes
return iTunesClient()
# Default: iTunes
return iTunesClient()
# =============================================================================
# LEGACY ALIASES — kept for backward compatibility, delegate to canonical funcs
# =============================================================================
def _get_configured_fallback_source():
"""Legacy alias for get_primary_source(). Use get_primary_source() instead."""
return get_primary_source()
def _create_fallback_client():
"""Legacy alias for get_primary_client(). Use get_primary_client() instead."""
return get_primary_client()
class MetadataService:
"""
Unified metadata service that seamlessly switches between Spotify and
@ -94,10 +164,8 @@ class MetadataService:
return "spotify"
elif self.preferred_provider == "itunes":
return self._fallback_source
else: # auto
# Use is_spotify_authenticated() to check actual Spotify auth status
# (is_authenticated() always returns True due to fallback)
return "spotify" if self.spotify.is_spotify_authenticated() else self._fallback_source
else: # auto — use the centralized source selection
return get_primary_source()
def _get_client(self):
"""Get the appropriate client based on provider selection"""

View file

@ -101,17 +101,9 @@ class PersonalizedPlaylistsService:
self.spotify_client = spotify_client
def _get_active_source(self) -> str:
"""
Determine which music source is active respects user's configured primary source.
"""
try:
from config.settings import config_manager
source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
if source == 'spotify' and not (self.spotify_client and hasattr(self.spotify_client, 'is_spotify_authenticated') and self.spotify_client.is_spotify_authenticated()):
return 'deezer'
return source
except Exception:
return 'deezer'
"""Determine which music source is active — delegates to centralized metadata_service."""
from core.metadata_service import get_primary_source
return get_primary_source()
def _build_track_dict(self, row, source: str) -> Dict:
"""Build a standardized track dictionary from a database row."""

View file

@ -96,16 +96,9 @@ class SeasonalDiscoveryService:
self._ensure_database_schema()
def _get_source(self):
"""Determine active music source — respects user's configured primary source"""
try:
from config.settings import config_manager
source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
# If user selected spotify, verify it's actually authenticated
if source == 'spotify' and not (self.spotify_client and self.spotify_client.is_spotify_authenticated()):
return 'deezer'
return source
except Exception:
return 'deezer'
"""Determine active music source — delegates to centralized metadata_service."""
from core.metadata_service import get_primary_source
return get_primary_source()
def _ensure_database_schema(self):
"""Create seasonal content tables if they don't exist"""

View file

@ -506,11 +506,11 @@ class SpotifyClient:
@property
def _fallback_source(self) -> str:
"""Get configured metadata fallback source ('itunes', 'deezer', or 'discogs')"""
"""Get configured primary metadata source for internal fallback routing."""
try:
return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
except Exception:
return 'itunes'
return 'deezer'
@property
def _fallback(self):

View file

@ -30,18 +30,9 @@ ITUNES_BASE_DELAY = 1.0 # Base delay in seconds for exponential backoff
def _get_fallback_metadata_client():
"""Get the configured metadata fallback client (iTunes or Deezer)."""
try:
from config.settings import config_manager
source = config_manager.get('metadata.fallback_source', 'itunes') or 'itunes'
if source == 'deezer':
from core.deezer_client import DeezerClient
return DeezerClient(), 'deezer'
from core.itunes_client import iTunesClient
return iTunesClient(), 'itunes'
except Exception:
from core.itunes_client import iTunesClient
return iTunesClient(), 'itunes'
"""Get the configured metadata client — delegates to centralized metadata_service."""
from core.metadata_service import get_primary_source, get_primary_client
return get_primary_client(), get_primary_source()
def itunes_api_call_with_retry(func, *args, max_retries=ITUNES_MAX_RETRIES, **kwargs):

View file

@ -33369,11 +33369,12 @@ def _get_deezer_client():
def _get_metadata_fallback_source():
"""Get the configured primary metadata source.
Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'."""
try:
return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
except Exception:
return 'deezer'
Returns 'spotify', 'itunes', 'deezer', 'discogs', or 'hydrabase'.
NOTE: This is a thin wrapper canonical logic lives in core.metadata_service.get_primary_source().
Kept as a local function because 70+ callers reference it by name."""
from core.metadata_service import get_primary_source
return get_primary_source()
def _get_metadata_fallback_client():
"""Get the active metadata client based on settings.
@ -40240,11 +40241,11 @@ def _get_active_discovery_source():
Determine which music source is active for discovery.
Returns the user's configured primary metadata source.
If the selected source requires auth and isn't available, falls back.
NOTE: Thin wrapper canonical logic lives in core.metadata_service.get_primary_source().
"""
source = _get_metadata_fallback_source()
if source == 'spotify' and not (spotify_client and spotify_client.is_spotify_authenticated()):
return 'deezer'
return source
from core.metadata_service import get_primary_source
return get_primary_source()
@app.route('/api/discover/hero', methods=['GET'])