From cc95cfcdf2cde85fc88246121f84d88df607a9a0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:32:28 -0700 Subject: [PATCH] Wire Discogs as fully featured fallback metadata source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SpotifyClient: add _discogs lazy-load property, route _fallback to DiscogsClient when configured (requires token, falls back to iTunes) - web_server: _get_metadata_fallback_client returns DiscogsClient when selected and token present - Enhanced search: Discogs added as source tab with NDJSON streaming, only available when token configured - Alternate sources list includes Discogs when token is set - Frontend: source labels, tab styling, fetch list all include Discogs - Consistent with iTunes/Deezer pattern — same interfaces, same routing --- core/spotify_client.py | 18 +++++++++++++++++- web_server.py | 25 +++++++++++++++++++++---- webui/static/script.js | 9 +++++---- webui/static/style.css | 1 + 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/core/spotify_client.py b/core/spotify_client.py index 40d359f8..65195506 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -417,6 +417,7 @@ class SpotifyClient: self.user_id: Optional[str] = None self._itunes_client = None # Lazy-loaded iTunes fallback self._deezer_client = None # Lazy-loaded Deezer fallback + self._discogs_client = None # Lazy-loaded Discogs fallback self._auth_cache_lock = threading.Lock() self._auth_cached_result: Optional[bool] = None self._auth_cache_time: float = 0 @@ -454,9 +455,18 @@ class SpotifyClient: logger.info("Deezer fallback client initialized") return self._deezer_client + @property + def _discogs(self): + """Lazy-load Discogs client for metadata fallback""" + if self._discogs_client is None: + from core.discogs_client import DiscogsClient + self._discogs_client = DiscogsClient() + logger.info("Discogs fallback client initialized") + return self._discogs_client + @property def _fallback_source(self) -> str: - """Get configured metadata fallback source ('itunes' or 'deezer')""" + """Get configured metadata fallback source ('itunes', 'deezer', or 'discogs')""" try: return config_manager.get('metadata.fallback_source', 'itunes') or 'itunes' except Exception: @@ -467,6 +477,12 @@ class SpotifyClient: """Get the active fallback metadata client based on settings""" if self._fallback_source == 'deezer': return self._deezer + if self._fallback_source == 'discogs': + # Only use Discogs if token is configured + token = config_manager.get('discogs.token', '') + if token: + return self._discogs + return self._itunes # Fall back to iTunes if no Discogs token return self._itunes def reload_config(self): diff --git a/web_server.py b/web_server.py index 6dbc7160..1f45f4fc 100644 --- a/web_server.py +++ b/web_server.py @@ -7362,6 +7362,7 @@ def enhanced_search(): # Determine which alternate sources are available (for frontend to fetch async) spotify_available = bool(spotify_client and spotify_client.is_spotify_authenticated()) hydrabase_available = bool(hydrabase_client and hydrabase_client.is_connected()) + discogs_available = bool(config_manager.get('discogs.token', '')) alternate_sources = [] if primary_source != 'spotify' and spotify_available: alternate_sources.append('spotify') @@ -7369,6 +7370,8 @@ def enhanced_search(): alternate_sources.append('itunes') if primary_source != 'deezer': alternate_sources.append('deezer') + if primary_source != 'discogs' and discogs_available: + alternate_sources.append('discogs') if primary_source != 'hydrabase' and hydrabase_available: alternate_sources.append('hydrabase') @@ -7457,7 +7460,7 @@ def enhanced_search_source(source_name): This prevents slow sources (iTunes with 3s rate limit) from blocking the UI. Falls back to single JSON response if streaming not supported. """ - if source_name not in ('spotify', 'itunes', 'deezer', 'hydrabase'): + if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'): return jsonify({"error": f"Unknown source: {source_name}"}), 400 data = request.get_json() @@ -7477,6 +7480,13 @@ def enhanced_search_source(source_name): client = iTunesClient() 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) + else: + return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) elif source_name == 'hydrabase': if hydrabase_client and hydrabase_client.is_connected(): client = hydrabase_client @@ -31896,7 +31906,7 @@ def _get_deezer_client(): return _deezer_client_instance def _get_metadata_fallback_source(): - """Get the configured metadata fallback source ('itunes', 'deezer', or 'hydrabase').""" + """Get the configured metadata fallback source ('itunes', 'deezer', 'discogs', or 'hydrabase').""" try: return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' except Exception: @@ -31904,14 +31914,21 @@ def _get_metadata_fallback_source(): def _get_metadata_fallback_client(): """Get the active metadata fallback client based on settings. - Returns an iTunesClient, DeezerClient, or HydrabaseClient instance with identical interfaces.""" + Returns an iTunesClient, DeezerClient, DiscogsClient, or HydrabaseClient instance with identical interfaces.""" source = _get_metadata_fallback_source() if source == 'deezer': return _get_deezer_client() + if source == 'discogs': + token = config_manager.get('discogs.token', '') + if token: + from core.discogs_client import DiscogsClient + return DiscogsClient(token=token) + # No token — fall back to iTunes + from core.itunes_client import iTunesClient + return iTunesClient() if source == 'hydrabase': if hydrabase_client and hydrabase_client.is_connected(): return hydrabase_client - # Hydrabase not connected — fall back to iTunes from core.itunes_client import iTunesClient return iTunesClient() from core.itunes_client import iTunesClient diff --git a/webui/static/script.js b/webui/static/script.js index dd374eeb..312ff433 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -7922,6 +7922,7 @@ function initializeSearchModeToggle() { spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, + discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, }; @@ -8047,7 +8048,7 @@ function initializeSearchModeToggle() { // Fire ALL source fetches immediately in parallel with the primary endpoint. // Don't guess which is primary — the main endpoint response will tell us. // If an alternate duplicates the primary, it just overwrites with same data. - for (const srcName of ['spotify', 'itunes', 'deezer', 'hydrabase']) { + for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) { _fetchAlternateSource(srcName, query); } @@ -17120,7 +17121,7 @@ function _gsRender(data) { return; } - const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', hydrabase: 'Hydrabase' }; + const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || ''; let h = ''; @@ -17217,7 +17218,7 @@ function _gsRenderTabs() { if (!el) return; const sources = Object.keys(_gsState.sources); if (sources.length < 2) { el.style.display = 'none'; return; } - const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', hydrabase: 'Hydrabase' }; + const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; el.style.display = 'flex'; el.innerHTML = sources.map(s => { const d = _gsState.sources[s]; @@ -44811,7 +44812,7 @@ function _renderRedownloadStep1(overlay, track, data) { const bestSource = data.best_match?.source || sources[0]; const sourceIcons = { spotify: '🟢', itunes: '🍎', deezer: '🟣', hydrabase: '🔷' }; - const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', hydrabase: 'Hydrabase' }; + const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; // Build columns — one per source, side by side const columnsHtml = sources.map(source => { diff --git a/webui/static/style.css b/webui/static/style.css index ec9b4fb3..acfb847a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -32143,6 +32143,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .enh-source-tab.enh-tab-spotify.active { background: rgba(29, 185, 84, 0.2); color: #1db954; } .enh-source-tab.enh-tab-itunes.active { background: rgba(252, 60, 68, 0.2); color: #fc3c44; } .enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; } +.enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; } .enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; } .enh-dropdown-section {