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
pull/467/head
Antti Kettunen 3 weeks ago
parent 3c7187fb32
commit cc13fb8f01
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

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

@ -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),
}

@ -103,6 +103,10 @@ from core.metadata.registry import (
get_spotify_disconnect_source,
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 (
get_import_clean_album,
get_import_clean_title,
@ -811,34 +815,14 @@ _idle_since = {}
_IDLE_GRACE_SECONDS = 5
_status_cache = {
'metadata_source': {'connected': False, 'response_time': 0, 'source': 'itunes'},
'media_server': {'connected': False, 'response_time': 0, 'type': None},
'soulseek': {'connected': False, 'response_time': 0},
}
_status_cache_timestamps: dict[str, float] = {
'metadata_source': 0,
'media_server': 0,
'soulseek': 0,
}
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
_hydrabase_ws = None
@ -3464,11 +3448,7 @@ def get_status():
current_time = time.time()
active_server = config_manager.get_active_media_server()
# Test primary metadata provider and Spotify separately
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
metadata_status = get_metadata_status_snapshot(spotify_client=spotify_client)
# Test media server - use EXISTING instances (they have internal caching)
# Media server clients already cache connection checks internally
@ -3542,8 +3522,8 @@ def get_status():
active_dl_count += 1
status_data = {
'metadata_source': _status_cache['metadata_source'],
'spotify': _build_spotify_status_payload(),
'metadata_source': metadata_status['metadata_source'],
'spotify': metadata_status['spotify'],
'media_server': _status_cache['media_server'],
'soulseek': _status_cache['soulseek'],
'active_media_server': active_server,
@ -4173,7 +4153,7 @@ def handle_settings():
if tidal_enrichment_worker:
tidal_enrichment_worker.client = tidal_client
# 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.")
return jsonify({"success": True, "message": "Settings saved successfully."})
except Exception as e:
@ -4790,7 +4770,7 @@ def test_connection_endpoint():
if success:
current_time = time.time()
if service == 'spotify':
_invalidate_metadata_status_caches()
invalidate_metadata_status_caches()
logger.info("Updated Spotify status cache after successful test")
elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']:
_status_cache['media_server']['connected'] = True
@ -4954,7 +4934,7 @@ def test_dashboard_connection_endpoint():
if success:
current_time = time.time()
if service == 'spotify':
_invalidate_metadata_status_caches()
invalidate_metadata_status_caches()
logger.info("Updated Spotify status cache after successful dashboard test")
elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']:
_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)
if profile_client:
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")
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.",
@ -5869,7 +5849,7 @@ def spotify_callback():
_clear_rate_limit()
spotify_client._invalidate_auth_cache()
# 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
if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'):
spotify_enrichment_worker.client.reload_config()
@ -5879,7 +5859,7 @@ def spotify_callback():
else:
logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed")
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")
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.",
@ -5908,7 +5888,7 @@ def spotify_disconnect():
source_label = get_metadata_source_label(active_source)
if configured_source == 'spotify':
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")
return jsonify({
'success': True,
@ -32043,7 +32023,7 @@ def start_oauth_callback_servers():
_clear_rate_limit()
spotify_client._invalidate_auth_cache()
# 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
if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'):
spotify_enrichment_worker.client.reload_config()
@ -32056,7 +32036,7 @@ def start_oauth_callback_servers():
else:
_oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed")
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")
self.send_response(200)
self.send_header('Content-type', 'text/html')
@ -34020,8 +34000,7 @@ def _build_status_payload():
download_mode = config_manager.get('download_source.mode', 'hybrid')
soulseek_data = dict(_status_cache.get('soulseek', {}))
soulseek_data['source'] = download_mode
metadata_source_data = dict(_status_cache.get('metadata_source', {}))
spotify_data = _build_spotify_status_payload()
metadata_status = get_metadata_status_snapshot(spotify_client=spotify_client)
# Count active downloads for nav badge
active_dl_count = 0
@ -34034,8 +34013,8 @@ def _build_status_payload():
pass
return {
'metadata_source': metadata_source_data,
'spotify': spotify_data,
'metadata_source': metadata_status['metadata_source'],
'spotify': metadata_status['spotify'],
'media_server': _status_cache.get('media_server', {}),
'soulseek': soulseek_data,
'active_media_server': config_manager.get_active_media_server(),
@ -34043,38 +34022,6 @@ def _build_status_payload():
'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):
"""Build the same payload used by GET /api/watchlist/count."""
try:

Loading…
Cancel
Save