From 6b6c866d03ffab8e5f4aaa666b0c4beb116849ac Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 11 Apr 2026 12:41:56 +0300 Subject: [PATCH 01/20] Add separate requirements file for dev dependencies --- .dockerignore | 5 ++++- README.md | 7 +++++++ requirements-dev.txt | 7 +++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 requirements-dev.txt diff --git a/.dockerignore b/.dockerignore index de93ff60..44215b24 100644 --- a/.dockerignore +++ b/.dockerignore @@ -61,6 +61,9 @@ main.py ui/ requirements.txt +# Dev-specific files +requirements-dev.txt + # OS generated files .DS_Store .DS_Store? @@ -68,4 +71,4 @@ requirements.txt .Spotlight-V100 .Trashes ehthumbs.db -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/README.md b/README.md index 14de99dc..27f21688 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,13 @@ python web_server.py # Open http://localhost:8008 ``` +For local development and tests: + +```bash +pip install -r requirements-dev.txt +pytest +``` + --- ## Setup Guide diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..5aee5232 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +# SoulSync development requirements +# Runtime web dependencies + test runner + +-r requirements-webui.txt + +# Test runner +pytest>=9.0.0 From fd6335a66e002ba9979039a34263ac54275426ca Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 11 Apr 2026 13:55:10 +0300 Subject: [PATCH 02/20] Add / improve metadata client caching Clients are for the most part being initialized per-request, which leads to a lot of redundant client initialization, as well as noise on the logs, since each client initialization emits a row on the logs, eg. 'Deezer client initialized' --- core/metadata_service.py | 127 +++++++++++++++++++------- core/watchlist_scanner.py | 12 +-- tests/test_metadata_service_cache.py | 107 ++++++++++++++++++++++ web_server.py | 131 ++++++++++----------------- 4 files changed, 255 insertions(+), 122 deletions(-) create mode 100644 tests/test_metadata_service_cache.py diff --git a/core/metadata_service.py b/core/metadata_service.py index 9173f809..829fd210 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -7,6 +7,7 @@ the logic. This prevents bugs where different files have different defaults or auth checks. """ +import threading from typing import List, Optional, Dict, Any, Literal from core.spotify_client import SpotifyClient from core.itunes_client import iTunesClient @@ -16,6 +17,9 @@ logger = get_logger("metadata_service") 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 @@ -58,8 +62,89 @@ def get_primary_client(): 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': try: import importlib @@ -69,38 +154,18 @@ def get_primary_client(): return sc except Exception: pass - # Spotify selected but unavailable — fall back to Deezer - from core.deezer_client import DeezerClient - return DeezerClient() + return get_deezer_client() if source == 'deezer': - from core.deezer_client import DeezerClient - return DeezerClient() + return get_deezer_client() 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() + return get_discogs_client() if source == 'hydrabase': - 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 iTunesClient() + return get_hydrabase_client() - # Default: iTunes - return iTunesClient() + return get_itunes_client() # ============================================================================= @@ -141,7 +206,7 @@ class MetadataService: self.preferred_provider = preferred_provider self.spotify = SpotifyClient() 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() @@ -311,13 +376,9 @@ class MetadataService: """Reload configuration for both clients""" logger.info("Reloading metadata service configuration") self.spotify.reload_config() - # Re-create fallback client in case the setting changed new_source = get_primary_source() - if new_source != self._fallback_source: - self._fallback_source = new_source - self.itunes = get_primary_client() - elif hasattr(self.itunes, 'reload_config'): - self.itunes.reload_config() + self._fallback_source = new_source + self.itunes = _get_client_for_source(new_source) self._log_initialization() diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index bb57e6e5..23f253c2 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1169,9 +1169,9 @@ class WatchlistScanner: results = client.search_artists(artist_name, limit=5) return self._best_artist_match(results, artist_name) - # Fallback: create a fresh Deezer client - from core.deezer_client import DeezerClient - client = DeezerClient() + # Fallback: use cached Deezer client + from core.metadata_service import get_deezer_client + client = get_deezer_client() results = client.search_artists(artist_name, limit=5) return self._best_artist_match(results, artist_name) except Exception as e: @@ -1181,8 +1181,8 @@ class WatchlistScanner: def _match_to_discogs(self, artist_name: str) -> Optional[str]: """Match artist name to Discogs ID using fuzzy name comparison.""" try: - from core.discogs_client import DiscogsClient - client = DiscogsClient() + from core.metadata_service import get_discogs_client + client = get_discogs_client() results = client.search_artists(artist_name, limit=5) return self._best_artist_match(results, artist_name) except Exception as e: @@ -3266,4 +3266,4 @@ def get_watchlist_scanner(spotify_client: SpotifyClient) -> WatchlistScanner: global _watchlist_scanner_instance if _watchlist_scanner_instance is None: _watchlist_scanner_instance = WatchlistScanner(spotify_client) - return _watchlist_scanner_instance \ No newline at end of file + return _watchlist_scanner_instance diff --git a/tests/test_metadata_service_cache.py b/tests/test_metadata_service_cache.py new file mode 100644 index 00000000..e6abecd4 --- /dev/null +++ b/tests/test_metadata_service_cache.py @@ -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 diff --git a/web_server.py b/web_server.py index 7165ee3d..4485274c 100644 --- a/web_server.py +++ b/web_server.py @@ -701,8 +701,7 @@ def _register_automation_handlers(): elif source == 'deezer': try: - from core.deezer_client import DeezerClient - deezer = DeezerClient() + deezer = _get_deezer_client() playlist_data = deezer.get_playlist(source_id) if playlist_data and playlist_data.get('tracks'): tracks = [] @@ -7109,12 +7108,8 @@ def deezer_callback(): config_manager.set('deezer.access_token', access_token) # Reload the global deezer client to pick up the token - global deezer_client - if deezer_client: - deezer_client.reload_config() - else: - from core.deezer_client import DeezerClient - deezer_client = DeezerClient() + deezer_client = _get_deezer_client() + deezer_client.reload_config() add_activity_item("✅", "Deezer Auth Complete", "Deezer account connected via OAuth", "Now") logger.info("Deezer OAuth authentication successful") @@ -8008,15 +8003,13 @@ def enhanced_search_source(source_name): else: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) elif source_name == 'itunes': - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() elif source_name == 'deezer': client = _get_deezer_client() elif source_name == 'discogs': token = config_manager.get('discogs.token', '') if token: - from core.discogs_client import DiscogsClient - client = DiscogsClient(token=token) + client = _get_discogs_client(token) else: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) elif source_name == 'hydrabase': @@ -10181,8 +10174,7 @@ def get_artist_image(artist_id): source_override = request.args.get('source', '') if source_override == 'itunes': - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() image_url = client._get_artist_image_from_albums(artist_id) return jsonify({"success": True, "image_url": image_url}) elif source_override == 'deezer': @@ -10190,8 +10182,7 @@ def get_artist_image(artist_id): image_url = client._get_artist_image_from_albums(artist_id) return jsonify({"success": True, "image_url": image_url}) elif source_override == 'discogs': - from core.discogs_client import DiscogsClient - client = DiscogsClient() + client = _get_discogs_client() image_url = client._get_artist_image_from_albums(artist_id) return jsonify({"success": True, "image_url": image_url}) elif source_override == 'hydrabase': @@ -10201,8 +10192,7 @@ def get_artist_image(artist_id): client = _get_deezer_client() image_url = client._get_artist_image_from_albums(artist_id) elif plugin == 'itunes' or artist_id.isdigit(): - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() image_url = client._get_artist_image_from_albums(artist_id) else: image_url = None @@ -10256,8 +10246,7 @@ def get_artist_discography(artist_id): if albums: active_source = 'spotify' elif source_override == 'itunes': - from core.itunes_client import iTunesClient - itunes_cl = iTunesClient() + itunes_cl = _get_itunes_client() albums = itunes_cl.get_artist_albums(artist_id, album_type='album,single', limit=50) if albums: active_source = 'itunes' @@ -10267,8 +10256,7 @@ def get_artist_discography(artist_id): if albums: active_source = 'deezer' elif source_override == 'discogs': - from core.discogs_client import DiscogsClient - discogs_cl = DiscogsClient() + discogs_cl = _get_discogs_client() albums = discogs_cl.get_artist_albums(artist_id, album_type='album,single', limit=50) if albums: active_source = 'discogs' @@ -10277,8 +10265,7 @@ def get_artist_discography(artist_id): if plugin == 'deezer': hb_cl = _get_deezer_client() elif plugin == 'itunes' or artist_id.isdigit(): - from core.itunes_client import iTunesClient - hb_cl = iTunesClient() + hb_cl = _get_itunes_client() else: hb_cl = spotify_client albums = hb_cl.get_artist_albums(artist_id, album_type='album,single', limit=50) @@ -10288,20 +10275,17 @@ def get_artist_discography(artist_id): # If direct ID lookup failed but we have artist name, search by name if not albums and artist_name: if source_override == 'itunes': - from core.itunes_client import iTunesClient - cl = iTunesClient() + cl = _get_itunes_client() elif source_override == 'hydrabase': plugin = request.args.get('plugin', '').lower() if plugin == 'deezer': cl = _get_deezer_client() else: - from core.itunes_client import iTunesClient - cl = iTunesClient() + cl = _get_itunes_client() elif source_override == 'deezer': cl = _get_deezer_client() elif source_override == 'discogs': - from core.discogs_client import DiscogsClient - cl = DiscogsClient() + cl = _get_discogs_client() elif source_override == 'spotify' and spotify_available: cl = spotify_client else: @@ -10663,20 +10647,17 @@ def get_artist_album_tracks(artist_id, album_id): source_override = request.args.get('source', '') client = spotify_client if source_override == 'itunes': - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() elif source_override == 'hydrabase': plugin = request.args.get('plugin', '').lower() if plugin == 'deezer': client = _get_deezer_client() elif plugin == 'itunes' or album_id.isdigit(): - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() elif source_override == 'deezer': client = _get_deezer_client() elif source_override == 'discogs': - from core.discogs_client import DiscogsClient - client = DiscogsClient() + client = _get_discogs_client() print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id} (source: {source_override or 'auto'})") @@ -10779,8 +10760,7 @@ def download_discography(artist_id): else: fallback_src = _get_metadata_fallback_source() if fallback_src == 'itunes': - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() elif fallback_src == 'deezer': client = _get_deezer_client() @@ -14112,8 +14092,7 @@ def redownload_search_metadata(track_id): if spotify_client and spotify_client.is_authenticated(): sources_to_search.append(('spotify', spotify_client)) try: - from core.itunes_client import iTunesClient - sources_to_search.append(('itunes', iTunesClient())) + sources_to_search.append(('itunes', _get_itunes_client())) except Exception as e: logger.debug(f"iTunes client not available for redownload search: {e}") try: @@ -14360,8 +14339,7 @@ def redownload_start(track_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']) elif meta_source == 'itunes': - from core.itunes_client import iTunesClient - _it = iTunesClient() + _it = _get_itunes_client() results = _it._lookup(id=meta_id, entity='song') if results: for r in results: @@ -17517,8 +17495,7 @@ def _apply_path_template(template: str, context: dict) -> str: itunes_artist_id = context.get('_itunes_artist_id') if itunes_artist_id and (',' in album_artist_value or ' & ' in album_artist_value): try: - from core.itunes_client import iTunesClient - resolved = iTunesClient().resolve_primary_artist(itunes_artist_id) + resolved = _get_itunes_client().resolve_primary_artist(itunes_artist_id) if resolved and resolved != album_artist_value: album_artist_value = resolved except Exception: @@ -18023,8 +18000,7 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> _src = original_search.get('_source') or _track_info_ctx.get('_source', '') if _aid.isdigit() and _src != 'deezer': try: - from core.itunes_client import iTunesClient - resolved = iTunesClient().resolve_primary_artist(_aid) + resolved = _get_itunes_client().resolve_primary_artist(_aid) if resolved and resolved != _raw_album_artist: _raw_album_artist = resolved except Exception: @@ -31177,23 +31153,20 @@ def get_album_tracks(album_id): # Use explicit source client when overridden (prevents numeric ID misrouting) client = spotify_client if source_override == 'itunes': - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() elif source_override == 'hydrabase': # Hydrabase IDs originate from whichever plugin the peer runs. # 'plugin' param is authoritative; fall back to ID format detection. plugin = request.args.get('plugin', '').lower() if plugin == 'itunes' or (not plugin and album_id.isdigit()): - from core.itunes_client import iTunesClient - client = iTunesClient() + client = _get_itunes_client() elif plugin == 'deezer': client = _get_deezer_client() # else: spotify (default) elif source_override == 'deezer': client = _get_deezer_client() elif source_override == 'discogs': - from core.discogs_client import DiscogsClient - client = DiscogsClient() + client = _get_discogs_client() album_data = client.get_album(album_id) if not album_data: @@ -31608,8 +31581,7 @@ def get_discover_album(source, album_id): fallback_client = _get_deezer_client() fallback_source = 'deezer' else: - from core.itunes_client import iTunesClient - fallback_client = iTunesClient() + fallback_client = _get_itunes_client() fallback_source = 'itunes' album_data = fallback_client.get_album(album_id) @@ -33556,19 +33528,20 @@ def cancel_tidal_sync(playlist_id): deezer_discovery_states = {} # Key: playlist_id, Value: discovery state 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(): - """Get or create the global DeezerClient instance (thread-safe).""" - global _deezer_client_instance - if _deezer_client_instance is None: - with _deezer_client_lock: - if _deezer_client_instance is None: - from core.deezer_client import DeezerClient - _deezer_client_instance = DeezerClient() - return _deezer_client_instance + """Get cached Deezer client.""" + from core.metadata_service import get_deezer_client + return get_deezer_client() + +def _get_itunes_client(): + """Get cached iTunes client.""" + from core.metadata_service import get_itunes_client + 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(): """Get the configured primary metadata source. @@ -33593,17 +33566,13 @@ def _get_metadata_fallback_client(): if source == 'discogs': token = config_manager.get('discogs.token', '') if token: - from core.discogs_client import DiscogsClient - return DiscogsClient(token=token) - from core.itunes_client import iTunesClient - return iTunesClient() + return _get_discogs_client(token) + return _get_itunes_client() if source == 'hydrabase': if hydrabase_client and hydrabase_client.is_connected(): return hydrabase_client - from core.itunes_client import iTunesClient - return iTunesClient() - from core.itunes_client import iTunesClient - return iTunesClient() + return _get_itunes_client() + return _get_itunes_client() @app.route('/api/deezer/arl-status', methods=['GET']) def get_deezer_arl_status(): @@ -38401,8 +38370,7 @@ def add_to_watchlist(): try: if source == 'discogs': # Discogs: fetch artist image from API - from core.discogs_client import DiscogsClient - dc = DiscogsClient() + dc = _get_discogs_client() dc_data = dc.get_artist(artist_id) if dc_data: image_url = dc_data.get('image_url') @@ -42150,17 +42118,15 @@ def _match_liked_artists_to_all_sources(database, profile_id: int): if spotify_client and spotify_client.is_spotify_authenticated(): search_clients['spotify'] = spotify_client try: - from core.itunes_client import iTunesClient - search_clients['itunes'] = iTunesClient() + search_clients['itunes'] = _get_itunes_client() except Exception: pass try: - search_clients['deezer'] = DeezerClient() + search_clients['deezer'] = _get_deezer_client() except Exception: pass try: - from core.discogs_client import DiscogsClient - dc = DiscogsClient() + dc = _get_discogs_client() # Only use Discogs if token is configured from config.settings import config_manager as _cm if _cm.get('discogs.token', ''): @@ -43173,8 +43139,7 @@ def get_artist_map_explore(): center_genres = sa.genres if hasattr(sa, 'genres') else [] artist_found = True if not artist_found: - from core.itunes_client import iTunesClient - ic = iTunesClient() + ic = _get_itunes_client() results = ic.search_artists(artist_name, limit=1) if results and len(results) > 0: ia = results[0] From 45f608bf1277a8d1459dfe85060ba051763c6ce7 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 11 Apr 2026 12:53:49 +0300 Subject: [PATCH 03/20] Utilize cached spotify client in get_spotify_artist_discography --- web_server.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web_server.py b/web_server.py index 4485274c..f602e6ab 100644 --- a/web_server.py +++ b/web_server.py @@ -48810,15 +48810,20 @@ def start_oauth_callback_servers(): def get_spotify_artist_discography(artist_name): """Get complete artist discography from Spotify using proper matching""" try: - from core.spotify_client import SpotifyClient from core.matching_engine import MusicMatchingEngine print(f"🎵 Searching Spotify for artist: {artist_name}") - # Initialize clients - spotify_client = SpotifyClient() + # Reuse cached profile-aware Spotify client + spotify_client = get_spotify_client_for_profile() matching_engine = MusicMatchingEngine() + if not spotify_client: + return { + 'success': False, + 'error': 'Spotify client unavailable' + } + # Search for multiple potential matches (not just 1) artists = spotify_client.search_artists(artist_name, limit=5) From b3d3c017ed8679605b1843cb965dd0fa2c169344 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:39:16 -0700 Subject: [PATCH 04/20] Use multi-stage Docker build to reduce image size Builder stage compiles Python dependencies with gcc/build tools. Runtime stage only includes curl, gosu, ffmpeg, and libchromaprint-tools. Build tools are not shipped in the final image, reducing size and attack surface. Inspired by kettui's PR #273. --- Dockerfile | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 97437292..5d3a2974 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,41 @@ # SoulSync WebUI Dockerfile # Multi-architecture support for AMD64 and ARM64 +# Stage 1: Builder — install Python dependencies with compilation tools +FROM python:3.11-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev \ + libffi-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create virtualenv and install dependencies +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements-webui.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements-webui.txt + +# Stage 2: Runtime — only runtime dependencies, no build tools FROM python:3.11-slim # Build-time commit SHA for update detection ARG COMMIT_SHA="" ENV SOULSYNC_COMMIT_SHA=${COMMIT_SHA} +# Copy pre-built virtualenv from builder +COPY --from=builder /opt/venv /opt/venv +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="/opt/venv/bin:$PATH" + # Set working directory WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - libc6-dev \ - libffi-dev \ - libssl-dev \ +# Install runtime-only system dependencies (no gcc/build tools) +RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gosu \ ffmpeg \ @@ -25,11 +45,6 @@ RUN apt-get update && apt-get install -y \ # Create non-root user for security RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync -# Copy requirements and install Python dependencies -COPY requirements-webui.txt . -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir -r requirements-webui.txt - # Copy application code COPY . . @@ -75,4 +90,4 @@ ENV UMASK=022 # Set entrypoint and default command ENTRYPOINT ["/entrypoint.sh"] -CMD ["python", "web_server.py"] \ No newline at end of file +CMD ["python", "web_server.py"] From c461f0071d9a9f2065c8718569e0eb67cec68627 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:54:23 -0700 Subject: [PATCH 05/20] Skip empty search results when navigating back from artist detail page --- webui/static/script.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/webui/static/script.js b/webui/static/script.js index 32806aae..18bb0320 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -34383,7 +34383,15 @@ function initializeArtistsPage() { } if (detailBackButton) { - detailBackButton.addEventListener('click', () => showArtistsResultsState()); + detailBackButton.addEventListener('click', () => { + // If there are no search results (user navigated directly to artist), + // go straight to the main search view instead of showing an empty results page + if (!artistsPageState.searchResults || artistsPageState.searchResults.length === 0) { + showArtistsSearchState(); + } else { + showArtistsResultsState(); + } + }); } // Initialize tabs (only need to do this once) From 805f72c5fd3280929ae4948c324d2ca0aabcc890 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:56:56 -0700 Subject: [PATCH 06/20] Add acappella to live/commentary cleaner patterns --- core/repair_jobs/live_commentary_cleaner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/repair_jobs/live_commentary_cleaner.py b/core/repair_jobs/live_commentary_cleaner.py index 248e7156..5049687b 100644 --- a/core/repair_jobs/live_commentary_cleaner.py +++ b/core/repair_jobs/live_commentary_cleaner.py @@ -31,6 +31,9 @@ _CONTENT_PATTERNS = [ (r'\bspoken\s*word\b', 'spoken_word'), (r'\bnarrat(?:ion|ed)\b', 'spoken_word'), (r'\bintroduction\b', 'spoken_word'), + # Acappella + (r'\ba\s*cappella\b', 'acappella'), + (r'\bacappella\b', 'acappella'), ] From 1e078192f06714320800ed393f945b3dfb5cceab Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:12:28 -0700 Subject: [PATCH 07/20] Add track_artist column for per-track artist on compilations/DJ mixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: data collection only — no behavior changes. Adds nullable track_artist column to tracks table. During database updates (incremental, full refresh, deep scan), extracts per-track artist from the media server when it differs from the album artist: - Plex: originalTitle field - Jellyfin/Emby: ArtistItems[0] vs AlbumArtists[0] - Navidrome: artist attribute vs album artist name NULL for normal albums (track artist = album artist). Populated only when the media server reports a different per-track artist. UPDATE uses COALESCE to never overwrite existing data with NULL. --- database/music_database.py | 50 ++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 23444547..83b546dc 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -565,6 +565,16 @@ class MusicDatabase: except Exception: pass + # Migration: add track_artist column for per-track artist on compilations/DJ mixes + try: + cursor.execute("SELECT track_artist FROM tracks LIMIT 1") + except Exception: + try: + cursor.execute("ALTER TABLE tracks ADD COLUMN track_artist TEXT") + logger.info("Added track_artist column to tracks table") + except Exception: + pass + # One-time migration: purge discovery cache entries that lack track_number. # Prior versions cached discovery results without track_number/disc_number/release_date, # causing incorrect file organization (all tracks as "01", missing album year). @@ -4598,7 +4608,36 @@ class MusicDatabase: bitrate = track_obj.bitRate if file_path is None and hasattr(track_obj, 'suffix') and track_obj.suffix: file_path = f"{track_obj.title}.{track_obj.suffix}" - + + # Extract per-track artist for compilations/DJ mixes. + # Only stored when it differs from the album artist. + track_artist = None + # Plex: originalTitle holds the per-track artist on compilation albums + plex_original = getattr(track_obj, 'originalTitle', None) + if plex_original and plex_original.strip(): + track_artist = plex_original.strip() + # Jellyfin/Emby: ArtistItems[0] is the track artist, may differ from album artist + if not track_artist and hasattr(track_obj, '_data'): + raw = getattr(track_obj, '_data', {}) or {} + artist_items = raw.get('ArtistItems', []) + if artist_items: + jf_track_artist = artist_items[0].get('Name', '') + album_artists = raw.get('AlbumArtists', []) + jf_album_artist = album_artists[0].get('Name', '') if album_artists else '' + if jf_track_artist and jf_track_artist != jf_album_artist: + track_artist = jf_track_artist + # Navidrome/Subsonic: artist attribute is per-track + if not track_artist and hasattr(track_obj, 'artist') and isinstance(getattr(track_obj, 'artist', None), str): + nav_artist = getattr(track_obj, 'artist', '').strip() + # Compare against album artist name to only store when different + try: + artist_row = cursor.execute("SELECT name FROM artists WHERE id = ?", (artist_id,)).fetchone() + album_artist_name = artist_row[0] if artist_row else '' + if nav_artist and nav_artist.lower() != album_artist_name.lower(): + track_artist = nav_artist + except Exception: + pass + # Check if track already exists — UPDATE to preserve enrichment columns, # INSERT only for genuinely new tracks cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,)) @@ -4607,9 +4646,9 @@ class MusicDatabase: if is_new_track: cursor.execute(""" INSERT INTO tracks - (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source)) + (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist)) else: # Update server-provided fields only — preserves spotify_track_id, deezer_id, # isrc, bpm, musicbrainz IDs, and all other enrichment data @@ -4617,9 +4656,10 @@ class MusicDatabase: UPDATE tracks SET album_id = ?, artist_id = ?, title = ?, track_number = ?, duration = ?, file_path = ?, bitrate = ?, server_source = ?, + track_artist = COALESCE(?, track_artist), updated_at = CURRENT_TIMESTAMP WHERE id = ? - """, (album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_id)) + """, (album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, track_id)) conn.commit() From d39db04ac033e97e7d2c72e0d4df8016642bceb2 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:40:41 -0700 Subject: [PATCH 08/20] Standardize Discogs worker hover tooltip to match other enrichment buttons --- webui/static/style.css | 52 +++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/webui/static/style.css b/webui/static/style.css index d5b371d9..14d45f8d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34972,38 +34972,54 @@ body.helper-mode-active #dashboard-activity-feed:hover { /* Tooltip */ .discogs-tooltip { - display: none; position: absolute; - bottom: calc(100% + 12px); left: 50%; - transform: translateX(-50%); - z-index: 100; + top: calc(100% + 12px); + transform: translateX(-50%) translateY(-5px); + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); pointer-events: none; } -.discogs-button-container:hover .discogs-tooltip { display: block; } +.discogs-button:hover+.discogs-tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} .discogs-tooltip-content { - background: rgba(14, 14, 20, 0.97); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 12px 16px; - min-width: 200px; - backdrop-filter: blur(20px); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + min-width: 260px; + background: linear-gradient(135deg, rgba(30, 30, 30, 0.98) 0%, rgba(20, 20, 20, 0.99) 100%); + backdrop-filter: blur(40px) saturate(1.6); + -webkit-backdrop-filter: blur(40px) saturate(1.6); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 16px; + padding: 16px 18px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1); } .discogs-tooltip-header { + font-family: 'SF Pro Display', -apple-system, sans-serif; font-size: 13px; - font-weight: 700; - color: rgba(255, 255, 255, 0.9); - margin-bottom: 8px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + letter-spacing: -0.2px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); } .discogs-tooltip-body { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - line-height: 1.6; + display: flex; + flex-direction: column; + gap: 8px; +} + +#discogs-tooltip-status { + color: rgb(var(--accent-light-rgb)); + font-weight: 600; } .deezer-button-container { From f5ac7c9261c734c63a729acb8a3203ded77c7320 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:13:07 -0700 Subject: [PATCH 09/20] Include track_artist in track search queries for collab/feature matching --- database/music_database.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 83b546dc..1f5de289 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -4877,9 +4877,11 @@ class MusicDatabase: params.append(f"%{self._normalize_for_comparison(title)}%") if artist: - where_conditions.append("unidecode_lower(artists.name) LIKE ?") - params.append(f"%{self._normalize_for_comparison(artist)}%") - + norm_artist = f"%{self._normalize_for_comparison(artist)}%" + where_conditions.append("(unidecode_lower(artists.name) LIKE ? OR unidecode_lower(COALESCE(tracks.track_artist, '')) LIKE ?)") + params.append(norm_artist) + params.append(norm_artist) + # Add server filter if specified if server_source: where_conditions.append("tracks.server_source = ?") @@ -4925,8 +4927,8 @@ class MusicDatabase: params = [] for term in search_terms[:5]: # Limit to 5 terms to avoid too broad search - like_conditions.append("(unidecode_lower(tracks.title) LIKE ? OR unidecode_lower(artists.name) LIKE ?)") - params.extend([f"%{term}%", f"%{term}%"]) + like_conditions.append("(unidecode_lower(tracks.title) LIKE ? OR unidecode_lower(artists.name) LIKE ? OR unidecode_lower(COALESCE(tracks.track_artist, '')) LIKE ?)") + params.extend([f"%{term}%", f"%{term}%", f"%{term}%"]) if not like_conditions: return [] From 7fb4cf6b0ecd3a3558d9a79495600827636b62ca Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:31:11 -0700 Subject: [PATCH 10/20] Use per-track artist in tag writer for compilations and DJ mixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag preview and writer now use track_artist (per-track artist) for the Artist tag when available, falling back to artist_name (album artist) when NULL. Album Artist tag always uses artist_name. Fixes #277 — DJ mix albums no longer overwrite per-track artists (Technique, Gouryella) with the album artist (Tiësto). --- core/tag_writer.py | 8 ++++++-- web_server.py | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/core/tag_writer.py b/core/tag_writer.py index 71bf3b86..09bf611a 100644 --- a/core/tag_writer.py +++ b/core/tag_writer.py @@ -143,6 +143,10 @@ def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[D file_val = file_tags.get(file_key) db_val = db_data.get(db_key) + # Special: use per-track artist for Artist field when available (DJ mixes, compilations) + if file_key == 'artist' and db_data.get('track_artist'): + db_val = db_data['track_artist'] + # Normalize for comparison file_str = _normalize_for_compare(file_val) db_str = _normalize_for_compare(db_val) @@ -230,9 +234,9 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any], # Build metadata dict from DB data title = db_data.get('title') - artist = db_data.get('artist_name') + artist = db_data.get('track_artist') or db_data.get('artist_name') # Per-track artist for compilations/DJ mixes album = db_data.get('album_title') - album_artist = db_data.get('artist_name') # Use artist name as album artist + album_artist = db_data.get('artist_name') # Album artist stays as the album-level artist year = db_data.get('year') genres = db_data.get('genres') track_num = db_data.get('track_number') diff --git a/web_server.py b/web_server.py index d81d1984..260a4f0e 100644 --- a/web_server.py +++ b/web_server.py @@ -11944,6 +11944,7 @@ def get_track_tag_preview(track_id): db_data = { 'title': track_data.get('title'), 'artist_name': track_data.get('artist_name'), + 'track_artist': track_data.get('track_artist'), 'album_title': track_data.get('album_title'), 'year': track_data.get('year'), 'genres': album_genres, From a3ab5adcbae922e91b2ffd6448de60d530225f85 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:47:54 -0700 Subject: [PATCH 11/20] Backfill MusicBrainz recording ID from Navidrome during database scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navidrome provides musicBrainzId on tracks — now captured during database updates so the MusicBrainz enrichment worker can skip tracks that already have an MBID. Uses COALESCE on UPDATE to never overwrite existing enrichment data with NULL (safe for Plex/Jellyfin which don't provide this field). Inspired by PR #279 — fixed data loss bug in the original where unconditional UPDATE would erase existing MBIDs. --- core/navidrome_client.py | 1 + database/music_database.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/core/navidrome_client.py b/core/navidrome_client.py index b366a5f4..81b49f67 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -120,6 +120,7 @@ class NavidromeTrack: self._album_id = navidrome_data.get('albumId', '') self._artist_id = navidrome_data.get('artistId', '') + self.musicBrainzId = navidrome_data.get('musicBrainzId') def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: if not date_str: diff --git a/database/music_database.py b/database/music_database.py index 1f5de289..2c4ab73d 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -4638,6 +4638,9 @@ class MusicDatabase: except Exception: pass + # Extract MusicBrainz recording ID from server if available (Navidrome provides this) + mbid = getattr(track_obj, 'musicBrainzId', None) or None + # Check if track already exists — UPDATE to preserve enrichment columns, # INSERT only for genuinely new tracks cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,)) @@ -4646,20 +4649,21 @@ class MusicDatabase: if is_new_track: cursor.execute(""" INSERT INTO tracks - (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist)) + (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, musicbrainz_recording_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, mbid)) else: # Update server-provided fields only — preserves spotify_track_id, deezer_id, - # isrc, bpm, musicbrainz IDs, and all other enrichment data + # isrc, bpm, and all other enrichment data cursor.execute(""" UPDATE tracks SET album_id = ?, artist_id = ?, title = ?, track_number = ?, duration = ?, file_path = ?, bitrate = ?, server_source = ?, track_artist = COALESCE(?, track_artist), + musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id), updated_at = CURRENT_TIMESTAMP WHERE id = ? - """, (album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, track_id)) + """, (album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, track_artist, mbid, track_id)) conn.commit() From faba4d58470ebd45cf501787a903ebf003e31580 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:41:03 -0700 Subject: [PATCH 12/20] Add missing pages to profile page access and home page options Profile creation was missing Listening Stats, Playlist Explorer, and Issues from the page access checkboxes. Home page dropdown was missing Stats, Playlist Explorer, and Help & Docs. Both admin and self-edit pageLabels dicts updated to match. --- webui/index.html | 6 ++++++ webui/static/script.js | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/webui/index.html b/webui/index.html index f6267fc4..da6512e4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -94,7 +94,10 @@ + + +
@@ -105,8 +108,11 @@ + + +