diff --git a/web_server.py b/web_server.py index 37f87f9a..fc635658 100644 --- a/web_server.py +++ b/web_server.py @@ -4384,6 +4384,70 @@ def _find_downloaded_file(download_path, track_data): # --- Refactored Logic from GUI Threads --- # This logic is extracted from the database update worker to be used directly by Flask. + +# ── Settings Connection Status Registry ── +# Maps each service shown in Settings → Connections to its config requirements. +# Used by _is_service_configured() to drive the green/yellow header gradient. +# +# Registry entry shapes: +# {'required': [keys]} — green if all keys populated in config_manager.get(service) +# {'always': True} — always green (no credentials required, e.g. default-storefront iTunes) +# {'custom': callable} — callable(service_name) -> bool, for services with non-field checks (e.g. token file) +# {'any_of': [[keys_a], [keys_b]]} — green if any one group's keys are all populated (e.g. Qobuz: email+password OR token) +SERVICE_CONFIG_REGISTRY = { + 'spotify': {'required': ['client_id', 'client_secret']}, + 'itunes': {'always': True}, # default storefront works anon + 'deezer': {'always': True}, # anon search works, premium ARL is optional + 'discogs': {'required': ['token']}, + 'tidal': {'custom': lambda _svc: _tidal_has_auth_token()}, + 'qobuz': {'any_of': [['email', 'password'], ['token'], ['user_auth_token']]}, + 'lastfm': {'required': ['api_key']}, + 'genius': {'required': ['access_token']}, + 'acoustid': {'required': ['api_key']}, + 'listenbrainz': {'required': ['token']}, + 'hydrabase': {'required': ['url', 'api_key']}, +} + + +def _tidal_has_auth_token() -> bool: + """Check if Tidal has a cached OAuth token. Tidal uses a token file, not config fields.""" + try: + return bool(tidal_client and tidal_client.is_authenticated()) + except Exception: + return False + + +def _is_service_configured(service: str) -> bool: + """Return True if the user has provided the required credentials for this service. + Drives the green/yellow header gradient on the Connections tab. + """ + entry = SERVICE_CONFIG_REGISTRY.get(service) + if not entry: + return False + + if entry.get('always'): + return True + + if 'custom' in entry: + try: + return bool(entry['custom'](service)) + except Exception: + return False + + service_config = config_manager.get(service, {}) or {} + + if 'required' in entry: + return all(bool(service_config.get(key)) for key in entry['required']) + + if 'any_of' in entry: + for key_group in entry['any_of']: + if all(bool(service_config.get(key)) for key in key_group): + return True + return False + + return False + + def run_service_test(service, test_config): """ Performs the actual connection test for a given service. @@ -4653,6 +4717,65 @@ def run_service_test(service, test_config): return False, f"Lidarr returned HTTP {resp.status_code}" except Exception as e: return False, f"Lidarr connection error: {str(e)}" + elif service == "itunes": + # Public API — just confirm we can reach it with a cheap search + try: + storefront = config_manager.get('itunes.storefront', 'US') or 'US' + resp = requests.get( + 'https://itunes.apple.com/search', + params={'term': 'beatles', 'limit': 1, 'country': storefront, 'media': 'music'}, + timeout=5, + ) + if resp.ok and resp.json().get('resultCount', 0) >= 0: + return True, f"iTunes Search API reachable (storefront: {storefront})" + return False, f"iTunes returned HTTP {resp.status_code}" + except Exception as e: + return False, f"iTunes connection error: {str(e)}" + elif service == "deezer": + # Public API — anon search works without credentials + try: + resp = requests.get( + 'https://api.deezer.com/search/artist', + params={'q': 'beatles', 'limit': 1}, + timeout=5, + ) + if resp.ok and isinstance(resp.json(), dict): + return True, "Deezer Public API reachable" + return False, f"Deezer returned HTTP {resp.status_code}" + except Exception as e: + return False, f"Deezer connection error: {str(e)}" + elif service == "discogs": + token = test_config.get('token', '') or config_manager.get('discogs.token', '') + if not token: + return False, "Missing Discogs personal token." + try: + resp = requests.get( + 'https://api.discogs.com/database/search', + params={'q': 'beatles', 'per_page': 1}, + headers={'Authorization': f'Discogs token={token}', 'User-Agent': 'SoulSync/1.0'}, + timeout=10, + ) + if resp.ok: + return True, "Discogs API reachable with provided token" + if resp.status_code == 401: + return False, "Discogs token rejected (HTTP 401)" + return False, f"Discogs returned HTTP {resp.status_code}" + except Exception as e: + return False, f"Discogs connection error: {str(e)}" + elif service == "qobuz": + try: + if qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated(): + return True, "Qobuz client authenticated" + return False, "Qobuz not authenticated. Provide email/password or user auth token." + except Exception as e: + return False, f"Qobuz connection error: {str(e)}" + elif service == "hydrabase": + try: + if hydrabase_client and hydrabase_client.is_connected(): + return True, "Hydrabase connected" + return False, "Hydrabase not connected. Configure URL + API key and click Connect." + except Exception as e: + return False, f"Hydrabase connection error: {str(e)}" return False, "Unknown service." except AttributeError as e: # This specifically catches the error you reported for Jellyfin @@ -7209,6 +7332,120 @@ def test_connection_endpoint(): return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""}) + +@app.route('/api/settings/config-status', methods=['GET']) +def settings_config_status_endpoint(): + """Return per-service config state for the Settings → Connections page. + Drives the green/yellow header gradient. No API calls — just config reads. + """ + try: + return jsonify({ + service: {'configured': _is_service_configured(service)} + for service in SERVICE_CONFIG_REGISTRY + }) + except Exception as e: + logger.error(f"config-status error: {e}") + return jsonify({"error": str(e)}), 500 + + +# ── Per-service verify cache ── +# Stores the last verify result per service for 5 minutes to prevent +# hammering external APIs when the user rapidly expands/collapses cards. +_settings_verify_cache = {} # service -> {'success': bool, 'message': str, 'error': str, 'ts': float} +_settings_verify_cache_lock = threading.Lock() +_SETTINGS_VERIFY_TTL_SECONDS = 300 + + +def _get_cached_verify_result(service: str): + with _settings_verify_cache_lock: + entry = _settings_verify_cache.get(service) + if entry and (time.time() - entry['ts']) < _SETTINGS_VERIFY_TTL_SECONDS: + return entry + return None + + +def _store_verify_result(service: str, success: bool, message: str): + with _settings_verify_cache_lock: + _settings_verify_cache[service] = { + 'success': bool(success), + 'message': message or '', + 'error': '' if success else (message or 'Unknown error'), + 'ts': time.time(), + } + + +def _run_single_verify(service: str): + """Run verify for one service, reading its current saved config. Returns cached + result if recent, else executes run_service_test and caches the outcome. + """ + if service not in SERVICE_CONFIG_REGISTRY: + return {'success': False, 'error': f'Unknown service: {service}', 'cached': False} + + cached = _get_cached_verify_result(service) + if cached: + return { + 'success': cached['success'], + 'error': cached.get('error', ''), + 'message': cached.get('message', ''), + 'cached': True, + } + + try: + saved_config = config_manager.get(service, {}) or {} + success, message = run_service_test(service, saved_config) + _store_verify_result(service, success, message) + return { + 'success': bool(success), + 'error': '' if success else (message or 'Verification failed'), + 'message': message or '', + 'cached': False, + } + except Exception as e: + logger.error(f"verify error for {service}: {e}") + _store_verify_result(service, False, str(e)) + return {'success': False, 'error': str(e), 'cached': False} + + +@app.route('/api/settings/verify', methods=['POST']) +def settings_verify_endpoint(): + """Run connection verification for one or more services. + + Body: {"services": ["spotify", "deezer"]} — which services to check + Query: ?force=true — bust cache and re-run + Returns {service: {success, error, message, cached}} per requested service. + Concurrency capped at 3 to avoid rate-limiting ourselves on Expand All. + """ + try: + data = request.get_json(silent=True) or {} + services = data.get('services') or [] + if isinstance(services, str): + services = [services] + if not services: + return jsonify({'error': 'No services specified'}), 400 + + force = (request.args.get('force') or '').strip().lower() in ('1', 'true', 'yes') + if force: + with _settings_verify_cache_lock: + for svc in services: + _settings_verify_cache.pop(svc, None) + + from concurrent.futures import ThreadPoolExecutor, as_completed + results = {} + with ThreadPoolExecutor(max_workers=3) as pool: + futures = {pool.submit(_run_single_verify, svc): svc for svc in services} + for fut in as_completed(futures): + svc = futures[fut] + try: + results[svc] = fut.result() + except Exception as e: + results[svc] = {'success': False, 'error': str(e), 'cached': False} + + return jsonify(results) + except Exception as e: + logger.error(f"settings/verify error: {e}") + return jsonify({'error': str(e)}), 500 + + @app.route('/api/test-dashboard-connection', methods=['POST']) def test_dashboard_connection_endpoint(): """Test connection from dashboard - creates specific dashboard activity items""" diff --git a/webui/index.html b/webui/index.html index 9fa2bc49..e2dcc2af 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3871,7 +3871,7 @@ -
+

Spotify

@@ -3914,7 +3914,7 @@
-
+

iTunes / Apple Music

@@ -3949,7 +3949,7 @@
-
+

Deezer (Favorites & Playlists)

@@ -3995,7 +3995,7 @@
-
+

Discogs

@@ -4017,7 +4017,7 @@
-
+

Tidal (Playlists & Metadata)

@@ -4052,7 +4052,7 @@
-
+

Qobuz (Metadata & Enrichment)

@@ -4109,7 +4109,7 @@
-
+

Last.fm

@@ -4146,7 +4146,7 @@
-
+

Genius

@@ -4168,7 +4168,7 @@
-
+

AcoustID Verification

@@ -4205,7 +4205,7 @@
-
+

ListenBrainz

@@ -4238,7 +4238,7 @@
-
+

Hydrabase

diff --git a/webui/static/script.js b/webui/static/script.js index 5b997db3..e360ad0c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -5808,17 +5808,195 @@ function switchSettingsTab(tab) { } else { _logViewerStop(); } + // Refresh the green/yellow header gradient when arriving on Connections + if (tab === 'connections') { + try { applyServiceStatusGradients(); } catch (e) { } + } +} + +// ── Settings → Connections: per-service status gradient + verify wiring ── +// Gradient shows green when the user has filled in credentials, yellow when empty. +// It's based purely on config presence (cheap, no API calls). The verify layer — +// which runs on expand / Expand All — surfaces whether those credentials actually +// work, via an inline warning bar inside the expanded panel. + +let _stgServiceStatusState = {}; // service -> {configured: bool} +let _stgServiceVerifyInFlight = {}; // service -> true while a verify call is running + +async function applyServiceStatusGradients() { + try { + const resp = await fetch('/api/settings/config-status'); + if (!resp.ok) return; + const data = await resp.json(); + _stgServiceStatusState = data || {}; + document.querySelectorAll('#settings-page .stg-service[data-service]').forEach(card => { + const service = card.getAttribute('data-service'); + const header = card.querySelector('.stg-service-header'); + if (!service || !header) return; + const configured = !!(data[service] && data[service].configured); + header.classList.toggle('status-configured', configured); + header.classList.toggle('status-missing', !configured); + // Ensure the header has a spinner placeholder for the verify-checking state + if (!header.querySelector('.stg-service-verify-spinner')) { + const spinner = document.createElement('span'); + spinner.className = 'stg-service-verify-spinner'; + // Insert before the chevron on the right + const chevron = header.querySelector('.stg-service-chevron'); + if (chevron) header.insertBefore(spinner, chevron); + else header.appendChild(spinner); + } + }); + } catch (e) { + console.warn('[Settings Status] Failed to apply gradients:', e); + } +} + +function _stgSetCheckingState(service, isChecking) { + const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); + if (!card) return; + const header = card.querySelector('.stg-service-header'); + const body = card.querySelector('.stg-service-body'); + if (header) { + header.classList.toggle('status-checking', !!isChecking); + // Lazy-create the spinner element so it's there even if + // applyServiceStatusGradients() hasn't run yet. + if (!header.querySelector('.stg-service-verify-spinner')) { + const spinner = document.createElement('span'); + spinner.className = 'stg-service-verify-spinner'; + const chevron = header.querySelector('.stg-service-chevron'); + if (chevron) header.insertBefore(spinner, chevron); + else header.appendChild(spinner); + } + } + if (!body) return; + const existing = body.querySelector('.stg-service-verify-status'); + if (isChecking) { + if (!existing) { + const status = document.createElement('div'); + status.className = 'stg-service-verify-status'; + status.textContent = 'Testing connection…'; + body.insertBefore(status, body.firstChild); + } + } else if (existing) { + existing.remove(); + } +} + +function _stgShowVerifyWarning(service, message) { + const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); + if (!card) return; + const body = card.querySelector('.stg-service-body'); + if (!body) return; + const existing = body.querySelector('.stg-service-warning'); + if (existing) existing.remove(); + const warning = document.createElement('div'); + warning.className = 'stg-service-warning'; + warning.innerHTML = ` + + + `; + warning.querySelector('.stg-service-warning-text').textContent = + message || 'Connection test failed.'; + body.insertBefore(warning, body.firstChild); +} + +function _stgClearVerifyWarning(service) { + const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); + if (!card) return; + const existing = card.querySelector('.stg-service-warning'); + if (existing) existing.remove(); +} + +async function _stgRefreshAfterSave() { + // Called after a successful settings save. Cheap gradient refresh always, + // plus re-verify any cards the user currently has expanded (so they see + // immediate feedback on credentials they just edited). Collapsed cards + // keep their cached verify result until the user expands them. + try { + await applyServiceStatusGradients(); + const expandedServices = Array.from( + document.querySelectorAll('#settings-page .stg-service.expanded[data-service]') + ) + .map(card => card.getAttribute('data-service')) + .filter(Boolean); + if (expandedServices.length > 0) { + _stgVerifyServices(expandedServices, { force: true }); + } + } catch (e) { + console.warn('[Settings Status] Post-save refresh failed:', e); + } +} + +async function _stgVerifyServices(services, { force = false } = {}) { + if (!services || !services.length) return {}; + // Mark all as checking immediately so the user sees spinners/status lines + services.forEach(svc => { + _stgServiceVerifyInFlight[svc] = true; + _stgSetCheckingState(svc, true); + _stgClearVerifyWarning(svc); + }); + try { + const url = '/api/settings/verify' + (force ? '?force=true' : ''); + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ services }) + }); + const data = await resp.json(); + services.forEach(svc => { + _stgServiceVerifyInFlight[svc] = false; + _stgSetCheckingState(svc, false); + const result = data[svc]; + if (result && result.success === false) { + _stgShowVerifyWarning(svc, result.error || result.message || ''); + } + }); + return data; + } catch (e) { + console.warn('[Settings Verify] Network error:', e); + services.forEach(svc => { + _stgServiceVerifyInFlight[svc] = false; + _stgSetCheckingState(svc, false); + _stgShowVerifyWarning(svc, 'Unable to reach the verification endpoint.'); + }); + return {}; + } } function toggleStgService(el) { const service = el.closest('.stg-service'); - if (service) service.classList.toggle('expanded'); + if (service) { + const wasExpanded = service.classList.contains('expanded'); + service.classList.toggle('expanded'); + // Fire verify when expanding a single card (not on collapse). The backend + // caches per service for 5 min, so rapid expand/collapse won't re-ping. + if (!wasExpanded) { + const serviceName = service.getAttribute('data-service'); + if (serviceName && !_stgServiceVerifyInFlight[serviceName]) { + _stgVerifyServices([serviceName]); + } + } + } } function toggleAllServiceAccordions(btn) { const services = document.querySelectorAll('#settings-page .stg-service'); const allExpanded = Array.from(services).every(s => s.classList.contains('expanded')); - services.forEach(s => s.classList.toggle('expanded', !allExpanded)); + const willExpand = !allExpanded; + services.forEach(s => s.classList.toggle('expanded', willExpand)); btn.textContent = allExpanded ? 'Expand All' : 'Collapse All'; + + // On Expand All, fire a single batched verify for every service that has a + // data-service attribute. Backend caps concurrency at 3 to avoid rate limits. + // Skipped on Collapse All. + if (willExpand) { + const serviceNames = Array.from(services) + .map(s => s.getAttribute('data-service')) + .filter(Boolean) + .filter(name => !_stgServiceVerifyInFlight[name]); + if (serviceNames.length > 0) { + _stgVerifyServices(serviceNames); + } + } } // ── Hybrid source priority list (drag-and-drop) ── @@ -7773,12 +7951,15 @@ async function saveSettings(quiet = false) { if (result.success && qualityProfileSaved && lookbackSaved) { showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success'); _forceServiceStatusRefresh(); + _stgRefreshAfterSave(); } else if (result.success && qualityProfileSaved && !lookbackSaved) { showToast('Settings saved, but discovery lookback period failed to save', 'warning'); _forceServiceStatusRefresh(); + _stgRefreshAfterSave(); } else if (result.success && !qualityProfileSaved) { showToast('Settings saved, but quality profile failed to save', 'warning'); _forceServiceStatusRefresh(); + _stgRefreshAfterSave(); } else { showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services'); } diff --git a/webui/static/style.css b/webui/static/style.css index 0865189e..4c2df1fa 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -52349,23 +52349,25 @@ tr.tag-diff-same { color: rgba(255, 255, 255, 0.95); } -/* Brand color dot */ +/* Brand color dot — always glows, glow intensifies on hover/expand */ #settings-page .stg-service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: currentColor; - opacity: 0.45; + opacity: 0.85; + box-shadow: 0 0 6px currentColor; transition: opacity 0.25s, transform 0.25s, box-shadow 0.25s; } #settings-page .stg-service:hover .stg-service-dot { - opacity: 0.75; + opacity: 1; + box-shadow: 0 0 9px currentColor; } #settings-page .stg-service.expanded .stg-service-dot { opacity: 1; transform: scale(1.25); - box-shadow: 0 0 8px currentColor; + box-shadow: 0 0 12px currentColor; } /* Chevron */ @@ -52400,6 +52402,90 @@ tr.tag-diff-same { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.03); } +/* ── Service config-status gradients ── + Applied to the header based on whether the user has filled in credentials. + Subtle left-to-transparent fade that reads at a glance when scrolling. */ +#settings-page .stg-service-header.status-configured { + background-image: linear-gradient( + 90deg, + rgba(46, 204, 113, 0.32) 0%, + rgba(46, 204, 113, 0.12) 18%, + rgba(46, 204, 113, 0.04) 45%, + transparent 80% + ); +} +#settings-page .stg-service-header.status-missing { + background-image: linear-gradient( + 90deg, + rgba(241, 196, 15, 0.35) 0%, + rgba(241, 196, 15, 0.13) 18%, + rgba(241, 196, 15, 0.04) 45%, + transparent 80% + ); +} +/* Keep hover subtlety — overlay a gentle highlight without clobbering the gradient */ +#settings-page .stg-service-header.status-configured:hover, +#settings-page .stg-service-header.status-missing:hover { + background-color: rgba(255, 255, 255, 0.035); +} + +/* ── Verify state: spinner badge in the header ── + Shown while a verify API call is in flight for this service. */ +#settings-page .stg-service-verify-spinner { + display: none; + width: 12px; + height: 12px; + border: 2px solid rgba(255, 255, 255, 0.15); + border-top-color: rgba(255, 255, 255, 0.65); + border-radius: 50%; + animation: stgSpin 0.9s linear infinite; + flex-shrink: 0; + margin-left: 6px; + margin-right: 6px; +} +#settings-page .stg-service-header.status-checking .stg-service-verify-spinner { + display: inline-block; +} +@keyframes stgSpin { + to { transform: rotate(360deg); } +} + +/* ── Verify state: status line inside panel body ── + "Testing connection…" while verify runs, removed when result arrives. */ +#settings-page .stg-service-verify-status { + padding: 10px 18px; + font-size: 0.78em; + color: rgba(255, 255, 255, 0.45); + font-style: italic; + letter-spacing: 0.02em; +} + +/* ── Verify failure warning bar at top of expanded panel ── + Shown when the verify call failed. Removed on next successful verify. */ +#settings-page .stg-service-warning { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 10px 18px; + padding: 10px 12px; + background: rgba(231, 76, 60, 0.08); + border: 1px solid rgba(231, 76, 60, 0.25); + border-left: 3px solid rgba(231, 76, 60, 0.65); + border-radius: 6px; + font-size: 0.82em; + color: rgba(255, 220, 216, 0.9); + line-height: 1.4; +} +#settings-page .stg-service-warning .stg-service-warning-icon { + flex-shrink: 0; + font-size: 1.05em; + color: rgba(231, 76, 60, 0.85); + line-height: 1.3; +} +#settings-page .stg-service-warning .stg-service-warning-text { + flex: 1; +} + /* Expand All / Collapse All toggle button */ #settings-page .stg-accordion-toggle { background: none;