Move metadata status cache into core/metadata

- move metadata-source and Spotify status caching out of web_server.py
- keep the public /status payload unchanged while shrinking server-side glue
- centralize invalidation and TTL handling in core/metadata/status.py
This commit is contained in:
Antti Kettunen 2026-05-02 11:34:45 +03:00
parent 3c7187fb32
commit cc13fb8f01
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
3 changed files with 150 additions and 72 deletions

View file

@ -40,6 +40,15 @@ from core.metadata.registry import (
register_profile_spotify_credentials_provider, register_profile_spotify_credentials_provider,
register_runtime_clients, register_runtime_clients,
) )
from core.metadata.status import (
METADATA_SOURCE_STATUS_TTL,
SPOTIFY_STATUS_TTL_ACTIVE,
SPOTIFY_STATUS_TTL_IDLE,
get_metadata_source_status,
get_spotify_status,
get_status_snapshot,
invalidate_metadata_status_caches,
)
from core.metadata.service import MetadataProvider, MetadataService, get_metadata_service from core.metadata.service import MetadataProvider, MetadataService, get_metadata_service
from core.metadata.similar_artists import ( from core.metadata.similar_artists import (
get_musicmap_similar_artists, get_musicmap_similar_artists,
@ -48,6 +57,7 @@ from core.metadata.similar_artists import (
__all__ = [ __all__ = [
"METADATA_SOURCE_PRIORITY", "METADATA_SOURCE_PRIORITY",
"METADATA_SOURCE_STATUS_TTL",
"MetadataCache", "MetadataCache",
"MetadataLookupOptions", "MetadataLookupOptions",
"MetadataProvider", "MetadataProvider",
@ -71,6 +81,7 @@ __all__ = [
"get_hydrabase_client", "get_hydrabase_client",
"get_itunes_client", "get_itunes_client",
"get_metadata_cache", "get_metadata_cache",
"get_metadata_source_status",
"get_metadata_service", "get_metadata_service",
"get_musicmap_similar_artists", "get_musicmap_similar_artists",
"get_primary_client", "get_primary_client",
@ -78,11 +89,16 @@ __all__ = [
"get_spotify_client_for_profile", "get_spotify_client_for_profile",
"get_registered_runtime_client", "get_registered_runtime_client",
"get_spotify_client", "get_spotify_client",
"get_spotify_status",
"get_source_priority", "get_source_priority",
"get_status_snapshot",
"iter_artist_discography_completion_events", "iter_artist_discography_completion_events",
"iter_musicmap_similar_artist_events", "iter_musicmap_similar_artist_events",
"is_hydrabase_enabled", "is_hydrabase_enabled",
"register_profile_spotify_credentials_provider", "register_profile_spotify_credentials_provider",
"register_runtime_clients", "register_runtime_clients",
"resolve_album_reference", "resolve_album_reference",
"SPOTIFY_STATUS_TTL_ACTIVE",
"SPOTIFY_STATUS_TTL_IDLE",
"invalidate_metadata_status_caches",
] ]

115
core/metadata/status.py Normal file
View file

@ -0,0 +1,115 @@
"""Cached metadata-provider and Spotify status snapshots."""
from __future__ import annotations
import threading
import time
from typing import Any, Dict, Optional
from config.settings import config_manager
from utils.logging_config import get_logger
from core.metadata.registry import get_primary_source_status
logger = get_logger("metadata.status")
METADATA_SOURCE_STATUS_TTL = 120
SPOTIFY_STATUS_TTL_ACTIVE = 15
SPOTIFY_STATUS_TTL_IDLE = 300
_status_lock = threading.RLock()
_metadata_source_status_cache: Dict[str, Any] = {
"source": "deezer",
"connected": False,
"response_time": 0,
}
_metadata_source_status_timestamp = 0.0
_spotify_status_cache: Dict[str, Any] = {
"connected": False,
"authenticated": False,
"rate_limited": False,
"rate_limit": None,
"post_ban_cooldown": None,
}
_spotify_status_timestamp = 0.0
def _get_config_value(key: str, default: Any = None) -> Any:
try:
return config_manager.get(key, default)
except Exception:
return default
def invalidate_metadata_status_caches() -> None:
"""Mark the cached metadata-source and Spotify status snapshots stale."""
global _metadata_source_status_timestamp, _spotify_status_timestamp
with _status_lock:
_metadata_source_status_timestamp = 0.0
_spotify_status_timestamp = 0.0
def get_metadata_source_status() -> Dict[str, Any]:
"""Return a cached snapshot for the active primary metadata source."""
global _metadata_source_status_timestamp
current_time = time.time()
with _status_lock:
if _metadata_source_status_timestamp and current_time - _metadata_source_status_timestamp <= METADATA_SOURCE_STATUS_TTL:
return dict(_metadata_source_status_cache)
try:
status_data = get_primary_source_status()
except Exception as exc:
logger.debug("Metadata source status refresh failed: %s", exc)
status_data = None
if status_data:
with _status_lock:
_metadata_source_status_cache.update(status_data)
_metadata_source_status_timestamp = current_time
with _status_lock:
return dict(_metadata_source_status_cache)
def get_spotify_status(spotify_client: Optional[Any] = None) -> Dict[str, Any]:
"""Return a cached Spotify-specific status snapshot."""
global _spotify_status_timestamp
current_time = time.time()
configured_source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
ttl = SPOTIFY_STATUS_TTL_ACTIVE if configured_source == "spotify" else SPOTIFY_STATUS_TTL_IDLE
with _status_lock:
if _spotify_status_timestamp and current_time - _spotify_status_timestamp <= ttl:
return dict(_spotify_status_cache)
try:
is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False
rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None
cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0
authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False
with _status_lock:
_spotify_status_cache.update({
"connected": authenticated,
"authenticated": authenticated,
"rate_limited": is_rate_limited,
"rate_limit": rate_limit_info,
"post_ban_cooldown": cooldown_remaining if cooldown_remaining > 0 else None,
})
_spotify_status_timestamp = current_time
except Exception as exc:
logger.debug("Spotify status refresh failed: %s", exc)
with _status_lock:
return dict(_spotify_status_cache)
def get_status_snapshot(spotify_client: Optional[Any] = None) -> Dict[str, Any]:
"""Return the combined metadata-provider status snapshot."""
return {
"metadata_source": get_metadata_source_status(),
"spotify": get_spotify_status(spotify_client=spotify_client),
}

View file

@ -103,6 +103,10 @@ from core.metadata.registry import (
get_spotify_disconnect_source, get_spotify_disconnect_source,
register_runtime_clients, register_runtime_clients,
) )
from core.metadata.status import (
get_status_snapshot as get_metadata_status_snapshot,
invalidate_metadata_status_caches,
)
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,
@ -811,34 +815,14 @@ _idle_since = {}
_IDLE_GRACE_SECONDS = 5 _IDLE_GRACE_SECONDS = 5
_status_cache = { _status_cache = {
'metadata_source': {'connected': False, 'response_time': 0, 'source': 'itunes'},
'media_server': {'connected': False, 'response_time': 0, 'type': None}, 'media_server': {'connected': False, 'response_time': 0, 'type': None},
'soulseek': {'connected': False, 'response_time': 0}, 'soulseek': {'connected': False, 'response_time': 0},
} }
_status_cache_timestamps: dict[str, float] = { _status_cache_timestamps: dict[str, float] = {
'metadata_source': 0,
'media_server': 0, 'media_server': 0,
'soulseek': 0, 'soulseek': 0,
} }
STATUS_CACHE_TTL = 120 STATUS_CACHE_TTL = 120
SPOTIFY_STATUS_TTL_ACTIVE = 15
SPOTIFY_STATUS_TTL_IDLE = 300
_spotify_status_cache = {
'connected': False,
'authenticated': False,
'rate_limited': False,
'rate_limit': None,
'post_ban_cooldown': None,
}
_spotify_status_timestamp = 0.0
def _invalidate_metadata_status_caches():
"""Mark the metadata-source and Spotify status snapshots stale."""
global _spotify_status_timestamp
_status_cache_timestamps['metadata_source'] = 0
_spotify_status_timestamp = 0
dev_mode_enabled = False dev_mode_enabled = False
_hydrabase_ws = None _hydrabase_ws = None
@ -3464,11 +3448,7 @@ def get_status():
current_time = time.time() current_time = time.time()
active_server = config_manager.get_active_media_server() active_server = config_manager.get_active_media_server()
# Test primary metadata provider and Spotify separately metadata_status = get_metadata_status_snapshot(spotify_client=spotify_client)
if current_time - _status_cache_timestamps['metadata_source'] > STATUS_CACHE_TTL:
_status_cache['metadata_source'] = metadata_registry.get_primary_source_status()
_status_cache_timestamps['metadata_source'] = current_time
# else: use cached value
# Test media server - use EXISTING instances (they have internal caching) # Test media server - use EXISTING instances (they have internal caching)
# Media server clients already cache connection checks internally # Media server clients already cache connection checks internally
@ -3542,8 +3522,8 @@ def get_status():
active_dl_count += 1 active_dl_count += 1
status_data = { status_data = {
'metadata_source': _status_cache['metadata_source'], 'metadata_source': metadata_status['metadata_source'],
'spotify': _build_spotify_status_payload(), 'spotify': metadata_status['spotify'],
'media_server': _status_cache['media_server'], 'media_server': _status_cache['media_server'],
'soulseek': _status_cache['soulseek'], 'soulseek': _status_cache['soulseek'],
'active_media_server': active_server, 'active_media_server': active_server,
@ -4173,7 +4153,7 @@ def handle_settings():
if tidal_enrichment_worker: if tidal_enrichment_worker:
tidal_enrichment_worker.client = tidal_client tidal_enrichment_worker.client = tidal_client
# Invalidate status cache so next poll reflects new settings (e.g. fallback source change) # Invalidate status cache so next poll reflects new settings (e.g. fallback source change)
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
logger.info("Service clients re-initialized with new settings.") logger.info("Service clients re-initialized with new settings.")
return jsonify({"success": True, "message": "Settings saved successfully."}) return jsonify({"success": True, "message": "Settings saved successfully."})
except Exception as e: except Exception as e:
@ -4790,7 +4770,7 @@ def test_connection_endpoint():
if success: if success:
current_time = time.time() current_time = time.time()
if service == 'spotify': if service == 'spotify':
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
logger.info("Updated Spotify status cache after successful test") logger.info("Updated Spotify status cache after successful test")
elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']:
_status_cache['media_server']['connected'] = True _status_cache['media_server']['connected'] = True
@ -4954,7 +4934,7 @@ def test_dashboard_connection_endpoint():
if success: if success:
current_time = time.time() current_time = time.time()
if service == 'spotify': if service == 'spotify':
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
logger.info("Updated Spotify status cache after successful dashboard test") logger.info("Updated Spotify status cache after successful dashboard test")
elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']:
_status_cache['media_server']['connected'] = True _status_cache['media_server']['connected'] = True
@ -5833,7 +5813,7 @@ def spotify_callback():
return _spotify_auth_result_page("Your personal Spotify account is now connected. You can close this window.", authenticated=True) return _spotify_auth_result_page("Your personal Spotify account is now connected. You can close this window.", authenticated=True)
if profile_client: if profile_client:
profile_client._invalidate_auth_cache() profile_client._invalidate_auth_cache()
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
add_activity_item("", "Spotify Auth Warning", f"Profile {profile_id_from_state} completed OAuth but Spotify did not confirm an authenticated session", "Now") add_activity_item("", "Spotify Auth Warning", f"Profile {profile_id_from_state} completed OAuth but Spotify did not confirm an authenticated session", "Now")
return _spotify_auth_result_page( return _spotify_auth_result_page(
"Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session for this profile. You can close this window and try Authenticate again.", "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session for this profile. You can close this window and try Authenticate again.",
@ -5869,7 +5849,7 @@ def spotify_callback():
_clear_rate_limit() _clear_rate_limit()
spotify_client._invalidate_auth_cache() spotify_client._invalidate_auth_cache()
# Invalidate status cache so next poll picks up the new connection # Invalidate status cache so next poll picks up the new connection
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
# Refresh enrichment worker's client so it picks up new auth # Refresh enrichment worker's client so it picks up new auth
if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'):
spotify_enrichment_worker.client.reload_config() spotify_enrichment_worker.client.reload_config()
@ -5879,7 +5859,7 @@ def spotify_callback():
else: else:
logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed") logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed")
spotify_client._invalidate_auth_cache() spotify_client._invalidate_auth_cache()
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now")
return _spotify_auth_result_page( return _spotify_auth_result_page(
"Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.", "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.",
@ -5908,7 +5888,7 @@ def spotify_disconnect():
source_label = get_metadata_source_label(active_source) source_label = get_metadata_source_label(active_source)
if configured_source == 'spotify': if configured_source == 'spotify':
config_manager.set('metadata.fallback_source', active_source) config_manager.set('metadata.fallback_source', active_source)
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now") add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now")
return jsonify({ return jsonify({
'success': True, 'success': True,
@ -32043,7 +32023,7 @@ def start_oauth_callback_servers():
_clear_rate_limit() _clear_rate_limit()
spotify_client._invalidate_auth_cache() spotify_client._invalidate_auth_cache()
# Invalidate status cache so next poll picks up the new connection # Invalidate status cache so next poll picks up the new connection
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
# Refresh enrichment worker's client so it picks up new auth # Refresh enrichment worker's client so it picks up new auth
if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'):
spotify_enrichment_worker.client.reload_config() spotify_enrichment_worker.client.reload_config()
@ -32056,7 +32036,7 @@ def start_oauth_callback_servers():
else: else:
_oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed") _oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed")
spotify_client._invalidate_auth_cache() spotify_client._invalidate_auth_cache()
_invalidate_metadata_status_caches() invalidate_metadata_status_caches()
add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now")
self.send_response(200) self.send_response(200)
self.send_header('Content-type', 'text/html') self.send_header('Content-type', 'text/html')
@ -34020,8 +34000,7 @@ def _build_status_payload():
download_mode = config_manager.get('download_source.mode', 'hybrid') download_mode = config_manager.get('download_source.mode', 'hybrid')
soulseek_data = dict(_status_cache.get('soulseek', {})) soulseek_data = dict(_status_cache.get('soulseek', {}))
soulseek_data['source'] = download_mode soulseek_data['source'] = download_mode
metadata_source_data = dict(_status_cache.get('metadata_source', {})) metadata_status = get_metadata_status_snapshot(spotify_client=spotify_client)
spotify_data = _build_spotify_status_payload()
# Count active downloads for nav badge # Count active downloads for nav badge
active_dl_count = 0 active_dl_count = 0
@ -34034,8 +34013,8 @@ def _build_status_payload():
pass pass
return { return {
'metadata_source': metadata_source_data, 'metadata_source': metadata_status['metadata_source'],
'spotify': spotify_data, 'spotify': metadata_status['spotify'],
'media_server': _status_cache.get('media_server', {}), 'media_server': _status_cache.get('media_server', {}),
'soulseek': soulseek_data, 'soulseek': soulseek_data,
'active_media_server': config_manager.get_active_media_server(), 'active_media_server': config_manager.get_active_media_server(),
@ -34043,38 +34022,6 @@ def _build_status_payload():
'active_downloads': active_dl_count, 'active_downloads': active_dl_count,
} }
def _build_spotify_status_payload():
"""Build a Spotify-specific status snapshot for auth and rate-limit state."""
import time
global _spotify_status_timestamp
current_time = time.time()
configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
ttl = SPOTIFY_STATUS_TTL_ACTIVE if configured_source == 'spotify' else SPOTIFY_STATUS_TTL_IDLE
if _spotify_status_timestamp and current_time - _spotify_status_timestamp <= ttl:
return dict(_spotify_status_cache)
try:
# Always include fresh rate limit info because it can change independently of cache TTL.
is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False
rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None
cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0
authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False
_spotify_status_cache.update({
'connected': authenticated,
'authenticated': authenticated,
'rate_limited': is_rate_limited,
'rate_limit': rate_limit_info,
'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None,
})
_spotify_status_timestamp = current_time
except Exception:
pass
return dict(_spotify_status_cache)
def _build_watchlist_count_payload(profile_id=1): def _build_watchlist_count_payload(profile_id=1):
"""Build the same payload used by GET /api/watchlist/count.""" """Build the same payload used by GET /api/watchlist/count."""
try: try: