Merge pull request #274 from kettui/feat/metadata-client-caching
Add caching for metadata clients
This commit is contained in:
commit
8977120ba8
7 changed files with 280 additions and 125 deletions
|
|
@ -61,6 +61,9 @@ main.py
|
||||||
ui/
|
ui/
|
||||||
requirements.txt
|
requirements.txt
|
||||||
|
|
||||||
|
# Dev-specific files
|
||||||
|
requirements-dev.txt
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store?
|
.DS_Store?
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,13 @@ python web_server.py
|
||||||
# Open http://localhost:8008
|
# Open http://localhost:8008
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For local development and tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup Guide
|
## Setup Guide
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ the logic. This prevents bugs where different files have different defaults
|
||||||
or auth checks.
|
or auth checks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
from typing import List, Optional, Dict, Any, Literal
|
from typing import List, Optional, Dict, Any, Literal
|
||||||
from core.spotify_client import SpotifyClient
|
from core.spotify_client import SpotifyClient
|
||||||
from core.itunes_client import iTunesClient
|
from core.itunes_client import iTunesClient
|
||||||
|
|
@ -16,6 +17,9 @@ logger = get_logger("metadata_service")
|
||||||
|
|
||||||
MetadataProvider = Literal["spotify", "itunes", "auto"]
|
MetadataProvider = Literal["spotify", "itunes", "auto"]
|
||||||
|
|
||||||
|
_client_cache_lock = threading.RLock()
|
||||||
|
_client_cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CANONICAL SOURCE SELECTION — all code should use these two functions
|
# CANONICAL SOURCE SELECTION — all code should use these two functions
|
||||||
|
|
@ -58,8 +62,89 @@ def get_primary_client():
|
||||||
|
|
||||||
This is THE single source of truth for "which client should I call?"
|
This is THE single source of truth for "which client should I call?"
|
||||||
"""
|
"""
|
||||||
source = get_primary_source()
|
return _get_client_for_source(get_primary_source())
|
||||||
|
|
||||||
|
|
||||||
|
def get_deezer_client():
|
||||||
|
"""Get cached Deezer client.
|
||||||
|
|
||||||
|
Deezer client is safe to reuse across requests because it owns no
|
||||||
|
request-specific state beyond the current access token.
|
||||||
|
"""
|
||||||
|
from core.deezer_client import DeezerClient
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
current_token = config_manager.get('deezer.access_token', None)
|
||||||
|
except Exception:
|
||||||
|
current_token = None
|
||||||
|
|
||||||
|
cache_key = f"deezer::{current_token or ''}"
|
||||||
|
with _client_cache_lock:
|
||||||
|
client = _client_cache.get(cache_key)
|
||||||
|
if client is None:
|
||||||
|
client = DeezerClient()
|
||||||
|
_client_cache[cache_key] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def get_itunes_client():
|
||||||
|
"""Get cached iTunes client."""
|
||||||
|
with _client_cache_lock:
|
||||||
|
client = _client_cache.get("itunes")
|
||||||
|
if client is None:
|
||||||
|
client = iTunesClient()
|
||||||
|
_client_cache["itunes"] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def get_discogs_client(token: Optional[str] = None):
|
||||||
|
"""Get cached Discogs client.
|
||||||
|
|
||||||
|
Discogs auth changes are token-driven, so the cache key tracks the
|
||||||
|
current configured token.
|
||||||
|
"""
|
||||||
|
if token is None:
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
current_token = config_manager.get('discogs.token', '') or ''
|
||||||
|
except Exception:
|
||||||
|
current_token = ''
|
||||||
|
else:
|
||||||
|
current_token = token or ''
|
||||||
|
|
||||||
|
cache_key = f"discogs::{current_token}"
|
||||||
|
with _client_cache_lock:
|
||||||
|
client = _client_cache.get(cache_key)
|
||||||
|
if client is None:
|
||||||
|
from core.discogs_client import DiscogsClient
|
||||||
|
client = DiscogsClient(token=current_token or None)
|
||||||
|
_client_cache[cache_key] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def get_hydrabase_client():
|
||||||
|
"""Return current Hydrabase client if connected, else iTunes fallback."""
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
ws = importlib.import_module('web_server')
|
||||||
|
client = getattr(ws, 'hydrabase_client', None)
|
||||||
|
if client and client.is_connected():
|
||||||
|
return client
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return get_itunes_client()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cached_metadata_clients():
|
||||||
|
"""Clear cached metadata clients.
|
||||||
|
|
||||||
|
Useful for tests and config reload flows.
|
||||||
|
"""
|
||||||
|
with _client_cache_lock:
|
||||||
|
_client_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_for_source(source: str):
|
||||||
if source == 'spotify':
|
if source == 'spotify':
|
||||||
try:
|
try:
|
||||||
import importlib
|
import importlib
|
||||||
|
|
@ -69,38 +154,18 @@ def get_primary_client():
|
||||||
return sc
|
return sc
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Spotify selected but unavailable — fall back to Deezer
|
return get_deezer_client()
|
||||||
from core.deezer_client import DeezerClient
|
|
||||||
return DeezerClient()
|
|
||||||
|
|
||||||
if source == 'deezer':
|
if source == 'deezer':
|
||||||
from core.deezer_client import DeezerClient
|
return get_deezer_client()
|
||||||
return DeezerClient()
|
|
||||||
|
|
||||||
if source == 'discogs':
|
if source == 'discogs':
|
||||||
try:
|
return get_discogs_client()
|
||||||
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':
|
if source == 'hydrabase':
|
||||||
try:
|
return get_hydrabase_client()
|
||||||
import importlib
|
|
||||||
ws = importlib.import_module('web_server')
|
|
||||||
client = getattr(ws, 'hydrabase_client', None)
|
|
||||||
if client and client.is_connected():
|
|
||||||
return client
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return iTunesClient()
|
|
||||||
|
|
||||||
# Default: iTunes
|
return get_itunes_client()
|
||||||
return iTunesClient()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -141,7 +206,7 @@ class MetadataService:
|
||||||
self.preferred_provider = preferred_provider
|
self.preferred_provider = preferred_provider
|
||||||
self.spotify = SpotifyClient()
|
self.spotify = SpotifyClient()
|
||||||
self._fallback_source = get_primary_source()
|
self._fallback_source = get_primary_source()
|
||||||
self.itunes = get_primary_client() # May be iTunesClient or DeezerClient
|
self.itunes = _get_client_for_source(self._fallback_source)
|
||||||
|
|
||||||
self._log_initialization()
|
self._log_initialization()
|
||||||
|
|
||||||
|
|
@ -311,13 +376,9 @@ class MetadataService:
|
||||||
"""Reload configuration for both clients"""
|
"""Reload configuration for both clients"""
|
||||||
logger.info("Reloading metadata service configuration")
|
logger.info("Reloading metadata service configuration")
|
||||||
self.spotify.reload_config()
|
self.spotify.reload_config()
|
||||||
# Re-create fallback client in case the setting changed
|
|
||||||
new_source = get_primary_source()
|
new_source = get_primary_source()
|
||||||
if new_source != self._fallback_source:
|
self._fallback_source = new_source
|
||||||
self._fallback_source = new_source
|
self.itunes = _get_client_for_source(new_source)
|
||||||
self.itunes = get_primary_client()
|
|
||||||
elif hasattr(self.itunes, 'reload_config'):
|
|
||||||
self.itunes.reload_config()
|
|
||||||
self._log_initialization()
|
self._log_initialization()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1209,9 +1209,9 @@ class WatchlistScanner:
|
||||||
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)
|
||||||
|
|
||||||
# Fallback: create a fresh Deezer client
|
# Fallback: use cached Deezer client
|
||||||
from core.deezer_client import DeezerClient
|
from core.metadata_service import get_deezer_client
|
||||||
client = DeezerClient()
|
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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1221,8 +1221,8 @@ 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.discogs_client import DiscogsClient
|
from core.metadata_service import get_discogs_client
|
||||||
client = DiscogsClient()
|
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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
7
requirements-dev.txt
Normal file
7
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# SoulSync development requirements
|
||||||
|
# Runtime web dependencies + test runner
|
||||||
|
|
||||||
|
-r requirements-webui.txt
|
||||||
|
|
||||||
|
# Test runner
|
||||||
|
pytest>=9.0.0
|
||||||
107
tests/test_metadata_service_cache.py
Normal file
107
tests/test_metadata_service_cache.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
if "spotipy" not in sys.modules:
|
||||||
|
spotipy = types.ModuleType("spotipy")
|
||||||
|
|
||||||
|
class _DummySpotify:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||||
|
|
||||||
|
class _DummyOAuth:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
spotipy.Spotify = _DummySpotify
|
||||||
|
oauth2.SpotifyOAuth = _DummyOAuth
|
||||||
|
oauth2.SpotifyClientCredentials = _DummyOAuth
|
||||||
|
spotipy.oauth2 = oauth2
|
||||||
|
sys.modules["spotipy"] = spotipy
|
||||||
|
sys.modules["spotipy.oauth2"] = oauth2
|
||||||
|
|
||||||
|
if "config.settings" not in sys.modules:
|
||||||
|
config_pkg = types.ModuleType("config")
|
||||||
|
settings_mod = types.ModuleType("config.settings")
|
||||||
|
|
||||||
|
class _DummyConfigManager:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
settings_mod.config_manager = _DummyConfigManager()
|
||||||
|
config_pkg.settings = settings_mod
|
||||||
|
sys.modules["config"] = config_pkg
|
||||||
|
sys.modules["config.settings"] = settings_mod
|
||||||
|
|
||||||
|
from core import metadata_service
|
||||||
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_metadata_client_cache():
|
||||||
|
metadata_service.clear_cached_metadata_clients()
|
||||||
|
yield
|
||||||
|
metadata_service.clear_cached_metadata_clients()
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_client_is_cached_for_same_source(monkeypatch):
|
||||||
|
calls = {"deezer": 0}
|
||||||
|
|
||||||
|
class FakeDeezerClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["deezer"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: "deezer")
|
||||||
|
monkeypatch.setattr("core.deezer_client.DeezerClient", FakeDeezerClient)
|
||||||
|
|
||||||
|
first = metadata_service.get_primary_client()
|
||||||
|
second = metadata_service.get_primary_client()
|
||||||
|
|
||||||
|
assert first is second
|
||||||
|
assert calls["deezer"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_client_switches_cache_by_source(monkeypatch):
|
||||||
|
calls = {"deezer": 0, "itunes": 0}
|
||||||
|
sources = iter(["deezer", "itunes"])
|
||||||
|
|
||||||
|
class FakeDeezerClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["deezer"] += 1
|
||||||
|
|
||||||
|
class FakeITunesClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["itunes"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_service, "get_primary_source", lambda: next(sources))
|
||||||
|
monkeypatch.setattr("core.deezer_client.DeezerClient", FakeDeezerClient)
|
||||||
|
monkeypatch.setattr(metadata_service, "iTunesClient", FakeITunesClient)
|
||||||
|
|
||||||
|
deezer_client = metadata_service.get_primary_client()
|
||||||
|
itunes_client = metadata_service.get_primary_client()
|
||||||
|
|
||||||
|
assert deezer_client is not itunes_client
|
||||||
|
assert calls["deezer"] == 1
|
||||||
|
assert calls["itunes"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_deezer_client_cache_tracks_token(monkeypatch):
|
||||||
|
tokens = iter(["token-a", "token-b"])
|
||||||
|
calls = {"deezer": 0}
|
||||||
|
|
||||||
|
class FakeDeezerClient:
|
||||||
|
def __init__(self):
|
||||||
|
calls["deezer"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr("core.deezer_client.DeezerClient", FakeDeezerClient)
|
||||||
|
monkeypatch.setattr(config_manager, "get", lambda key, default=None: next(tokens) if key == "deezer.access_token" else default)
|
||||||
|
|
||||||
|
first = metadata_service.get_deezer_client()
|
||||||
|
second = metadata_service.get_deezer_client()
|
||||||
|
|
||||||
|
assert first is not second
|
||||||
|
assert calls["deezer"] == 2
|
||||||
142
web_server.py
142
web_server.py
|
|
@ -702,8 +702,7 @@ def _register_automation_handlers():
|
||||||
|
|
||||||
elif source == 'deezer':
|
elif source == 'deezer':
|
||||||
try:
|
try:
|
||||||
from core.deezer_client import DeezerClient
|
deezer = _get_deezer_client()
|
||||||
deezer = DeezerClient()
|
|
||||||
playlist_data = deezer.get_playlist(source_id)
|
playlist_data = deezer.get_playlist(source_id)
|
||||||
if playlist_data and playlist_data.get('tracks'):
|
if playlist_data and playlist_data.get('tracks'):
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
@ -7125,12 +7124,8 @@ def deezer_callback():
|
||||||
config_manager.set('deezer.access_token', access_token)
|
config_manager.set('deezer.access_token', access_token)
|
||||||
|
|
||||||
# Reload the global deezer client to pick up the token
|
# Reload the global deezer client to pick up the token
|
||||||
global deezer_client
|
deezer_client = _get_deezer_client()
|
||||||
if deezer_client:
|
deezer_client.reload_config()
|
||||||
deezer_client.reload_config()
|
|
||||||
else:
|
|
||||||
from core.deezer_client import DeezerClient
|
|
||||||
deezer_client = DeezerClient()
|
|
||||||
|
|
||||||
add_activity_item("✅", "Deezer Auth Complete", "Deezer account connected via OAuth", "Now")
|
add_activity_item("✅", "Deezer Auth Complete", "Deezer account connected via OAuth", "Now")
|
||||||
logger.info("Deezer OAuth authentication successful")
|
logger.info("Deezer OAuth authentication successful")
|
||||||
|
|
@ -8024,15 +8019,13 @@ def enhanced_search_source(source_name):
|
||||||
else:
|
else:
|
||||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||||
elif source_name == 'itunes':
|
elif source_name == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
elif source_name == 'deezer':
|
elif source_name == 'deezer':
|
||||||
client = _get_deezer_client()
|
client = _get_deezer_client()
|
||||||
elif source_name == 'discogs':
|
elif source_name == 'discogs':
|
||||||
token = config_manager.get('discogs.token', '')
|
token = config_manager.get('discogs.token', '')
|
||||||
if token:
|
if token:
|
||||||
from core.discogs_client import DiscogsClient
|
client = _get_discogs_client(token)
|
||||||
client = DiscogsClient(token=token)
|
|
||||||
else:
|
else:
|
||||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||||
elif source_name == 'hydrabase':
|
elif source_name == 'hydrabase':
|
||||||
|
|
@ -10208,8 +10201,7 @@ def get_artist_image(artist_id):
|
||||||
source_override = request.args.get('source', '')
|
source_override = request.args.get('source', '')
|
||||||
|
|
||||||
if source_override == 'itunes':
|
if source_override == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
image_url = client._get_artist_image_from_albums(artist_id)
|
image_url = client._get_artist_image_from_albums(artist_id)
|
||||||
return jsonify({"success": True, "image_url": image_url})
|
return jsonify({"success": True, "image_url": image_url})
|
||||||
elif source_override == 'deezer':
|
elif source_override == 'deezer':
|
||||||
|
|
@ -10217,8 +10209,7 @@ def get_artist_image(artist_id):
|
||||||
image_url = client._get_artist_image_from_albums(artist_id)
|
image_url = client._get_artist_image_from_albums(artist_id)
|
||||||
return jsonify({"success": True, "image_url": image_url})
|
return jsonify({"success": True, "image_url": image_url})
|
||||||
elif source_override == 'discogs':
|
elif source_override == 'discogs':
|
||||||
from core.discogs_client import DiscogsClient
|
client = _get_discogs_client()
|
||||||
client = DiscogsClient()
|
|
||||||
image_url = client._get_artist_image_from_albums(artist_id)
|
image_url = client._get_artist_image_from_albums(artist_id)
|
||||||
return jsonify({"success": True, "image_url": image_url})
|
return jsonify({"success": True, "image_url": image_url})
|
||||||
elif source_override == 'hydrabase':
|
elif source_override == 'hydrabase':
|
||||||
|
|
@ -10228,8 +10219,7 @@ def get_artist_image(artist_id):
|
||||||
client = _get_deezer_client()
|
client = _get_deezer_client()
|
||||||
image_url = client._get_artist_image_from_albums(artist_id)
|
image_url = client._get_artist_image_from_albums(artist_id)
|
||||||
elif plugin == 'itunes' or artist_id.isdigit():
|
elif plugin == 'itunes' or artist_id.isdigit():
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
image_url = client._get_artist_image_from_albums(artist_id)
|
image_url = client._get_artist_image_from_albums(artist_id)
|
||||||
else:
|
else:
|
||||||
image_url = None
|
image_url = None
|
||||||
|
|
@ -10283,8 +10273,7 @@ def get_artist_discography(artist_id):
|
||||||
if albums:
|
if albums:
|
||||||
active_source = 'spotify'
|
active_source = 'spotify'
|
||||||
elif source_override == 'itunes':
|
elif source_override == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
itunes_cl = _get_itunes_client()
|
||||||
itunes_cl = iTunesClient()
|
|
||||||
albums = itunes_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
albums = itunes_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
||||||
if albums:
|
if albums:
|
||||||
active_source = 'itunes'
|
active_source = 'itunes'
|
||||||
|
|
@ -10294,8 +10283,7 @@ def get_artist_discography(artist_id):
|
||||||
if albums:
|
if albums:
|
||||||
active_source = 'deezer'
|
active_source = 'deezer'
|
||||||
elif source_override == 'discogs':
|
elif source_override == 'discogs':
|
||||||
from core.discogs_client import DiscogsClient
|
discogs_cl = _get_discogs_client()
|
||||||
discogs_cl = DiscogsClient()
|
|
||||||
albums = discogs_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
albums = discogs_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
||||||
if albums:
|
if albums:
|
||||||
active_source = 'discogs'
|
active_source = 'discogs'
|
||||||
|
|
@ -10304,8 +10292,7 @@ def get_artist_discography(artist_id):
|
||||||
if plugin == 'deezer':
|
if plugin == 'deezer':
|
||||||
hb_cl = _get_deezer_client()
|
hb_cl = _get_deezer_client()
|
||||||
elif plugin == 'itunes' or artist_id.isdigit():
|
elif plugin == 'itunes' or artist_id.isdigit():
|
||||||
from core.itunes_client import iTunesClient
|
hb_cl = _get_itunes_client()
|
||||||
hb_cl = iTunesClient()
|
|
||||||
else:
|
else:
|
||||||
hb_cl = spotify_client
|
hb_cl = spotify_client
|
||||||
albums = hb_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
albums = hb_cl.get_artist_albums(artist_id, album_type='album,single', limit=50)
|
||||||
|
|
@ -10315,20 +10302,17 @@ def get_artist_discography(artist_id):
|
||||||
# If direct ID lookup failed but we have artist name, search by name
|
# If direct ID lookup failed but we have artist name, search by name
|
||||||
if not albums and artist_name:
|
if not albums and artist_name:
|
||||||
if source_override == 'itunes':
|
if source_override == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
cl = _get_itunes_client()
|
||||||
cl = iTunesClient()
|
|
||||||
elif source_override == 'hydrabase':
|
elif source_override == 'hydrabase':
|
||||||
plugin = request.args.get('plugin', '').lower()
|
plugin = request.args.get('plugin', '').lower()
|
||||||
if plugin == 'deezer':
|
if plugin == 'deezer':
|
||||||
cl = _get_deezer_client()
|
cl = _get_deezer_client()
|
||||||
else:
|
else:
|
||||||
from core.itunes_client import iTunesClient
|
cl = _get_itunes_client()
|
||||||
cl = iTunesClient()
|
|
||||||
elif source_override == 'deezer':
|
elif source_override == 'deezer':
|
||||||
cl = _get_deezer_client()
|
cl = _get_deezer_client()
|
||||||
elif source_override == 'discogs':
|
elif source_override == 'discogs':
|
||||||
from core.discogs_client import DiscogsClient
|
cl = _get_discogs_client()
|
||||||
cl = DiscogsClient()
|
|
||||||
elif source_override == 'spotify' and spotify_available:
|
elif source_override == 'spotify' and spotify_available:
|
||||||
cl = spotify_client
|
cl = spotify_client
|
||||||
else:
|
else:
|
||||||
|
|
@ -10690,20 +10674,17 @@ def get_artist_album_tracks(artist_id, album_id):
|
||||||
source_override = request.args.get('source', '')
|
source_override = request.args.get('source', '')
|
||||||
client = spotify_client
|
client = spotify_client
|
||||||
if source_override == 'itunes':
|
if source_override == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
elif source_override == 'hydrabase':
|
elif source_override == 'hydrabase':
|
||||||
plugin = request.args.get('plugin', '').lower()
|
plugin = request.args.get('plugin', '').lower()
|
||||||
if plugin == 'deezer':
|
if plugin == 'deezer':
|
||||||
client = _get_deezer_client()
|
client = _get_deezer_client()
|
||||||
elif plugin == 'itunes' or album_id.isdigit():
|
elif plugin == 'itunes' or album_id.isdigit():
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
elif source_override == 'deezer':
|
elif source_override == 'deezer':
|
||||||
client = _get_deezer_client()
|
client = _get_deezer_client()
|
||||||
elif source_override == 'discogs':
|
elif source_override == 'discogs':
|
||||||
from core.discogs_client import DiscogsClient
|
client = _get_discogs_client()
|
||||||
client = DiscogsClient()
|
|
||||||
|
|
||||||
print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id} (source: {source_override or 'auto'})")
|
print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id} (source: {source_override or 'auto'})")
|
||||||
|
|
||||||
|
|
@ -10806,8 +10787,7 @@ def download_discography(artist_id):
|
||||||
else:
|
else:
|
||||||
fallback_src = _get_metadata_fallback_source()
|
fallback_src = _get_metadata_fallback_source()
|
||||||
if fallback_src == 'itunes':
|
if fallback_src == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
elif fallback_src == 'deezer':
|
elif fallback_src == 'deezer':
|
||||||
client = _get_deezer_client()
|
client = _get_deezer_client()
|
||||||
|
|
||||||
|
|
@ -14139,8 +14119,7 @@ def redownload_search_metadata(track_id):
|
||||||
if spotify_client and spotify_client.is_authenticated():
|
if spotify_client and spotify_client.is_authenticated():
|
||||||
sources_to_search.append(('spotify', spotify_client))
|
sources_to_search.append(('spotify', spotify_client))
|
||||||
try:
|
try:
|
||||||
from core.itunes_client import iTunesClient
|
sources_to_search.append(('itunes', _get_itunes_client()))
|
||||||
sources_to_search.append(('itunes', iTunesClient()))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"iTunes client not available for redownload search: {e}")
|
logger.debug(f"iTunes client not available for redownload search: {e}")
|
||||||
try:
|
try:
|
||||||
|
|
@ -14387,8 +14366,7 @@ def redownload_start(track_id):
|
||||||
if full_track_details and full_track_details.get('album', {}).get('id'):
|
if full_track_details and full_track_details.get('album', {}).get('id'):
|
||||||
full_album_data = spotify_client.get_album(full_track_details['album']['id'])
|
full_album_data = spotify_client.get_album(full_track_details['album']['id'])
|
||||||
elif meta_source == 'itunes':
|
elif meta_source == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
_it = _get_itunes_client()
|
||||||
_it = iTunesClient()
|
|
||||||
results = _it._lookup(id=meta_id, entity='song')
|
results = _it._lookup(id=meta_id, entity='song')
|
||||||
if results:
|
if results:
|
||||||
for r in results:
|
for r in results:
|
||||||
|
|
@ -17544,8 +17522,7 @@ def _apply_path_template(template: str, context: dict) -> str:
|
||||||
itunes_artist_id = context.get('_itunes_artist_id')
|
itunes_artist_id = context.get('_itunes_artist_id')
|
||||||
if itunes_artist_id and (',' in album_artist_value or ' & ' in album_artist_value):
|
if itunes_artist_id and (',' in album_artist_value or ' & ' in album_artist_value):
|
||||||
try:
|
try:
|
||||||
from core.itunes_client import iTunesClient
|
resolved = _get_itunes_client().resolve_primary_artist(itunes_artist_id)
|
||||||
resolved = iTunesClient().resolve_primary_artist(itunes_artist_id)
|
|
||||||
if resolved and resolved != album_artist_value:
|
if resolved and resolved != album_artist_value:
|
||||||
album_artist_value = resolved
|
album_artist_value = resolved
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -18050,8 +18027,7 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) ->
|
||||||
_src = original_search.get('_source') or _track_info_ctx.get('_source', '')
|
_src = original_search.get('_source') or _track_info_ctx.get('_source', '')
|
||||||
if _aid.isdigit() and _src != 'deezer':
|
if _aid.isdigit() and _src != 'deezer':
|
||||||
try:
|
try:
|
||||||
from core.itunes_client import iTunesClient
|
resolved = _get_itunes_client().resolve_primary_artist(_aid)
|
||||||
resolved = iTunesClient().resolve_primary_artist(_aid)
|
|
||||||
if resolved and resolved != _raw_album_artist:
|
if resolved and resolved != _raw_album_artist:
|
||||||
_raw_album_artist = resolved
|
_raw_album_artist = resolved
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -31234,23 +31210,20 @@ def get_album_tracks(album_id):
|
||||||
# Use explicit source client when overridden (prevents numeric ID misrouting)
|
# Use explicit source client when overridden (prevents numeric ID misrouting)
|
||||||
client = spotify_client
|
client = spotify_client
|
||||||
if source_override == 'itunes':
|
if source_override == 'itunes':
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
elif source_override == 'hydrabase':
|
elif source_override == 'hydrabase':
|
||||||
# Hydrabase IDs originate from whichever plugin the peer runs.
|
# Hydrabase IDs originate from whichever plugin the peer runs.
|
||||||
# 'plugin' param is authoritative; fall back to ID format detection.
|
# 'plugin' param is authoritative; fall back to ID format detection.
|
||||||
plugin = request.args.get('plugin', '').lower()
|
plugin = request.args.get('plugin', '').lower()
|
||||||
if plugin == 'itunes' or (not plugin and album_id.isdigit()):
|
if plugin == 'itunes' or (not plugin and album_id.isdigit()):
|
||||||
from core.itunes_client import iTunesClient
|
client = _get_itunes_client()
|
||||||
client = iTunesClient()
|
|
||||||
elif plugin == 'deezer':
|
elif plugin == 'deezer':
|
||||||
client = _get_deezer_client()
|
client = _get_deezer_client()
|
||||||
# else: spotify (default)
|
# else: spotify (default)
|
||||||
elif source_override == 'deezer':
|
elif source_override == 'deezer':
|
||||||
client = _get_deezer_client()
|
client = _get_deezer_client()
|
||||||
elif source_override == 'discogs':
|
elif source_override == 'discogs':
|
||||||
from core.discogs_client import DiscogsClient
|
client = _get_discogs_client()
|
||||||
client = DiscogsClient()
|
|
||||||
|
|
||||||
album_data = client.get_album(album_id)
|
album_data = client.get_album(album_id)
|
||||||
if not album_data:
|
if not album_data:
|
||||||
|
|
@ -31665,8 +31638,7 @@ def get_discover_album(source, album_id):
|
||||||
fallback_client = _get_deezer_client()
|
fallback_client = _get_deezer_client()
|
||||||
fallback_source = 'deezer'
|
fallback_source = 'deezer'
|
||||||
else:
|
else:
|
||||||
from core.itunes_client import iTunesClient
|
fallback_client = _get_itunes_client()
|
||||||
fallback_client = iTunesClient()
|
|
||||||
fallback_source = 'itunes'
|
fallback_source = 'itunes'
|
||||||
|
|
||||||
album_data = fallback_client.get_album(album_id)
|
album_data = fallback_client.get_album(album_id)
|
||||||
|
|
@ -33613,19 +33585,20 @@ def cancel_tidal_sync(playlist_id):
|
||||||
deezer_discovery_states = {} # Key: playlist_id, Value: discovery state
|
deezer_discovery_states = {} # Key: playlist_id, Value: discovery state
|
||||||
deezer_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="deezer_discovery")
|
deezer_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="deezer_discovery")
|
||||||
|
|
||||||
# Lazy-initialized global DeezerClient instance
|
|
||||||
_deezer_client_instance = None
|
|
||||||
_deezer_client_lock = threading.Lock()
|
|
||||||
|
|
||||||
def _get_deezer_client():
|
def _get_deezer_client():
|
||||||
"""Get or create the global DeezerClient instance (thread-safe)."""
|
"""Get cached Deezer client."""
|
||||||
global _deezer_client_instance
|
from core.metadata_service import get_deezer_client
|
||||||
if _deezer_client_instance is None:
|
return get_deezer_client()
|
||||||
with _deezer_client_lock:
|
|
||||||
if _deezer_client_instance is None:
|
def _get_itunes_client():
|
||||||
from core.deezer_client import DeezerClient
|
"""Get cached iTunes client."""
|
||||||
_deezer_client_instance = DeezerClient()
|
from core.metadata_service import get_itunes_client
|
||||||
return _deezer_client_instance
|
return get_itunes_client()
|
||||||
|
|
||||||
|
def _get_discogs_client(token=None):
|
||||||
|
"""Get cached Discogs client."""
|
||||||
|
from core.metadata_service import get_discogs_client
|
||||||
|
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.
|
||||||
|
|
@ -33650,17 +33623,13 @@ def _get_metadata_fallback_client():
|
||||||
if source == 'discogs':
|
if source == 'discogs':
|
||||||
token = config_manager.get('discogs.token', '')
|
token = config_manager.get('discogs.token', '')
|
||||||
if token:
|
if token:
|
||||||
from core.discogs_client import DiscogsClient
|
return _get_discogs_client(token)
|
||||||
return DiscogsClient(token=token)
|
return _get_itunes_client()
|
||||||
from core.itunes_client import iTunesClient
|
|
||||||
return iTunesClient()
|
|
||||||
if source == 'hydrabase':
|
if source == 'hydrabase':
|
||||||
if hydrabase_client and hydrabase_client.is_connected():
|
if hydrabase_client and hydrabase_client.is_connected():
|
||||||
return hydrabase_client
|
return hydrabase_client
|
||||||
from core.itunes_client import iTunesClient
|
return _get_itunes_client()
|
||||||
return iTunesClient()
|
return _get_itunes_client()
|
||||||
from core.itunes_client import iTunesClient
|
|
||||||
return iTunesClient()
|
|
||||||
|
|
||||||
@app.route('/api/deezer/arl-status', methods=['GET'])
|
@app.route('/api/deezer/arl-status', methods=['GET'])
|
||||||
def get_deezer_arl_status():
|
def get_deezer_arl_status():
|
||||||
|
|
@ -38458,8 +38427,7 @@ def add_to_watchlist():
|
||||||
try:
|
try:
|
||||||
if source == 'discogs':
|
if source == 'discogs':
|
||||||
# Discogs: fetch artist image from API
|
# Discogs: fetch artist image from API
|
||||||
from core.discogs_client import DiscogsClient
|
dc = _get_discogs_client()
|
||||||
dc = DiscogsClient()
|
|
||||||
dc_data = dc.get_artist(artist_id)
|
dc_data = dc.get_artist(artist_id)
|
||||||
if dc_data:
|
if dc_data:
|
||||||
image_url = dc_data.get('image_url')
|
image_url = dc_data.get('image_url')
|
||||||
|
|
@ -42207,17 +42175,15 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
|
||||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||||
search_clients['spotify'] = spotify_client
|
search_clients['spotify'] = spotify_client
|
||||||
try:
|
try:
|
||||||
from core.itunes_client import iTunesClient
|
search_clients['itunes'] = _get_itunes_client()
|
||||||
search_clients['itunes'] = iTunesClient()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
search_clients['deezer'] = DeezerClient()
|
search_clients['deezer'] = _get_deezer_client()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
from core.discogs_client import DiscogsClient
|
dc = _get_discogs_client()
|
||||||
dc = DiscogsClient()
|
|
||||||
# Only use Discogs if token is configured
|
# Only use Discogs if token is configured
|
||||||
from config.settings import config_manager as _cm
|
from config.settings import config_manager as _cm
|
||||||
if _cm.get('discogs.token', ''):
|
if _cm.get('discogs.token', ''):
|
||||||
|
|
@ -43230,8 +43196,7 @@ def get_artist_map_explore():
|
||||||
center_genres = sa.genres if hasattr(sa, 'genres') else []
|
center_genres = sa.genres if hasattr(sa, 'genres') else []
|
||||||
artist_found = True
|
artist_found = True
|
||||||
if not artist_found:
|
if not artist_found:
|
||||||
from core.itunes_client import iTunesClient
|
ic = _get_itunes_client()
|
||||||
ic = iTunesClient()
|
|
||||||
results = ic.search_artists(artist_name, limit=1)
|
results = ic.search_artists(artist_name, limit=1)
|
||||||
if results and len(results) > 0:
|
if results and len(results) > 0:
|
||||||
ia = results[0]
|
ia = results[0]
|
||||||
|
|
@ -48902,15 +48867,20 @@ def start_oauth_callback_servers():
|
||||||
def get_spotify_artist_discography(artist_name):
|
def get_spotify_artist_discography(artist_name):
|
||||||
"""Get complete artist discography from Spotify using proper matching"""
|
"""Get complete artist discography from Spotify using proper matching"""
|
||||||
try:
|
try:
|
||||||
from core.spotify_client import SpotifyClient
|
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
|
||||||
print(f"🎵 Searching Spotify for artist: {artist_name}")
|
print(f"🎵 Searching Spotify for artist: {artist_name}")
|
||||||
|
|
||||||
# Initialize clients
|
# Reuse cached profile-aware Spotify client
|
||||||
spotify_client = SpotifyClient()
|
spotify_client = get_spotify_client_for_profile()
|
||||||
matching_engine = MusicMatchingEngine()
|
matching_engine = MusicMatchingEngine()
|
||||||
|
|
||||||
|
if not spotify_client:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'Spotify client unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
# Search for multiple potential matches (not just 1)
|
# Search for multiple potential matches (not just 1)
|
||||||
artists = spotify_client.search_artists(artist_name, limit=5)
|
artists = spotify_client.search_artists(artist_name, limit=5)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue