From 0b4647ddd47d4506a839b565109cc1f8d0c2e63b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:24:41 -0700 Subject: [PATCH] Add per-service config status indicators to Settings Connections tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds green/yellow header gradient on each service card showing whether the user has filled in credentials, plus an expand-triggered verification layer that surfaces working-or-not status inline. Backend (web_server.py): - SERVICE_CONFIG_REGISTRY mapping each of the 11 services in Connections to its config requirements. Supports required-keys, always-green, any-of, and custom-check semantics (Tidal uses token-file check, Qobuz accepts either email/password OR cached auth token). - _is_service_configured(service) — cheap config presence check, no APIs hit. - GET /api/settings/config-status — returns {service: {configured}} for all services in one call. Drives the page-load gradient. - POST /api/settings/verify — takes {services: [...]}, runs run_service_test per service, caches results 5 min in-memory, parallelizes with ThreadPoolExecutor(max_workers=3) to avoid self-rate-limiting. Query param ?force=true busts cache. - Added verify branches for iTunes, Deezer, Discogs, Qobuz, Hydrabase in run_service_test (previously missing — these services couldn't be tested). HTML (webui/index.html): - data-service="..." on all 11 .stg-service containers so JS can map card to backend service name. CSS (webui/static/style.css): - .status-configured gradient (subtle green, left-to-transparent fade) - .status-missing gradient (yellow, same shape) - Spinner badge in header for .status-checking state - "Testing connection…" status line style inside panel body - Red warning bar style for verify failures at top of expanded panel - Brand dot now glows always (was only glowing when expanded); hover and expand states intensify the glow progressively. JS (webui/static/script.js): - applyServiceStatusGradients() fetches config-status and applies green/yellow class per card. Called on Connections tab activate + after any settings save. - _stgVerifyServices(services, {force}) — batch verify POST, tracks in-flight state, renders spinners/status lines/warnings per service. - toggleStgService() fires single-service verify when a card is expanded (not on collapse). Skipped if a verify is already in flight for that service. - toggleAllServiceAccordions() fires one batched verify for all 11 services when "Expand All" is clicked; skipped on "Collapse All". - _stgRefreshAfterSave() — after settings save, refreshes gradient (cheap) and re-verifies only the cards the user currently has expanded (so freshly-edited credentials show their new verify result immediately, without re-pinging every service). Failure UI: top-of-panel red warning bar with the error message (e.g. "Discogs token rejected (HTTP 401)", "Hydrabase not connected…"). Removed automatically on next successful verify. No existing tests changed. Full suite stays at 263 passed. Ruff clean. --- web_server.py | 237 +++++++++++++++++++++++++++++++++++++++++ webui/index.html | 22 ++-- webui/static/script.js | 185 +++++++++++++++++++++++++++++++- webui/static/style.css | 94 +++++++++++++++- 4 files changed, 521 insertions(+), 17 deletions(-) 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 @@ -