From 8f0b9518bc616b0eb692e6ed9d44df8344ef71b0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:12:19 -0700 Subject: [PATCH] Add per-profile ListenBrainz support with personal settings modal --- core/listenbrainz_client.py | 10 +- core/listenbrainz_manager.py | 38 ++-- database/music_database.py | 148 ++++++++++++ web_server.py | 429 +++++++++++++++++++++-------------- webui/index.html | 21 +- webui/static/script.js | 221 +++++++++++++++++- webui/static/style.css | 337 +++++++++++++++++++++++++++ 7 files changed, 1008 insertions(+), 196 deletions(-) diff --git a/core/listenbrainz_client.py b/core/listenbrainz_client.py index bf274e21..914b6e59 100644 --- a/core/listenbrainz_client.py +++ b/core/listenbrainz_client.py @@ -9,8 +9,12 @@ logger = get_logger("listenbrainz_client") class ListenBrainzClient: """Client for interacting with ListenBrainz API""" - def __init__(self): - custom_url = config_manager.get("listenbrainz.base_url", "") + def __init__(self, token=None, base_url=None): + # Use provided params or fall back to global config + if base_url is not None: + custom_url = base_url + else: + custom_url = config_manager.get("listenbrainz.base_url", "") if custom_url: # Strip trailing slashes and ensure /1 API version suffix custom_url = custom_url.rstrip('/') @@ -19,7 +23,7 @@ class ListenBrainzClient: self.base_url = custom_url else: self.base_url = "https://api.listenbrainz.org/1" - self.token = config_manager.get("listenbrainz.token", "") + self.token = token if token is not None else config_manager.get("listenbrainz.token", "") self.username = None # Create a session for connection pooling diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index 77fab7f3..2eeec3b7 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -18,9 +18,10 @@ logger = get_logger("listenbrainz_manager") class ListenBrainzManager: """Manages caching of ListenBrainz data in local database""" - def __init__(self, db_path: str): + def __init__(self, db_path: str, profile_id: int = 1, token: str = None, base_url: str = None): self.db_path = db_path - self.client = ListenBrainzClient() + self.profile_id = profile_id + self.client = ListenBrainzClient(token=token, base_url=base_url) self._ensure_tables() def _ensure_tables(self): @@ -30,14 +31,16 @@ class ListenBrainzManager: cursor.execute(""" CREATE TABLE IF NOT EXISTS listenbrainz_playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, - playlist_mbid TEXT NOT NULL UNIQUE, + playlist_mbid TEXT NOT NULL, title TEXT NOT NULL, creator TEXT, playlist_type TEXT NOT NULL, track_count INTEGER DEFAULT 0, annotation_data TEXT, + profile_id INTEGER DEFAULT 1, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP + cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(playlist_mbid, profile_id) ) """) cursor.execute(""" @@ -151,8 +154,8 @@ class ListenBrainzManager: cursor.execute(""" SELECT id, track_count, last_updated FROM listenbrainz_playlists - WHERE playlist_mbid = ? - """, (playlist_mbid,)) + WHERE playlist_mbid = ? AND profile_id = ? + """, (playlist_mbid, self.profile_id)) existing = cursor.fetchone() @@ -187,15 +190,16 @@ class ListenBrainzManager: # Insert new playlist cursor.execute(""" INSERT INTO listenbrainz_playlists - (playlist_mbid, title, creator, playlist_type, track_count, annotation_data) - VALUES (?, ?, ?, ?, ?, ?) + (playlist_mbid, title, creator, playlist_type, track_count, annotation_data, profile_id) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( playlist_mbid, title, creator, playlist_type, track_count, - json.dumps(playlist.get('annotation', {})) + json.dumps(playlist.get('annotation', {})), + self.profile_id )) playlist_id = cursor.lastrowid @@ -335,10 +339,10 @@ class ListenBrainzManager: # Get IDs of playlists to delete (all except 25 most recent) cursor.execute(""" SELECT id FROM listenbrainz_playlists - WHERE playlist_type = ? + WHERE playlist_type = ? AND profile_id = ? ORDER BY last_updated DESC LIMIT -1 OFFSET 25 - """, (playlist_type,)) + """, (playlist_type, self.profile_id)) old_playlist_ids = [row[0] for row in cursor.fetchall()] @@ -362,7 +366,7 @@ class ListenBrainzManager: """Check if there are any cached playlists in the database""" conn = self._get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM listenbrainz_playlists") + cursor.execute("SELECT COUNT(*) FROM listenbrainz_playlists WHERE profile_id = ?", (self.profile_id,)) count = cursor.fetchone()[0] conn.close() return count > 0 @@ -375,10 +379,10 @@ class ListenBrainzManager: cursor.execute(""" SELECT id, playlist_mbid, title, creator, track_count, annotation_data, last_updated FROM listenbrainz_playlists - WHERE playlist_type = ? + WHERE playlist_type = ? AND profile_id = ? ORDER BY last_updated DESC LIMIT 25 - """, (playlist_type,)) + """, (playlist_type, self.profile_id)) playlists = [] for row in cursor.fetchall(): @@ -400,10 +404,10 @@ class ListenBrainzManager: conn = self._get_db_connection() cursor = conn.cursor() - # Get playlist ID + # Get playlist ID (scoped to this profile) cursor.execute(""" - SELECT id FROM listenbrainz_playlists WHERE playlist_mbid = ? - """, (playlist_mbid,)) + SELECT id FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ? + """, (playlist_mbid, self.profile_id)) playlist_row = cursor.fetchone() if not playlist_row: diff --git a/database/music_database.py b/database/music_database.py index 60636210..6a3200a7 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -348,6 +348,7 @@ class MusicDatabase: self._add_profile_support_v3(cursor) self._add_profile_support_v4(cursor) self._add_profile_settings(cursor) + self._add_profile_listenbrainz_support(cursor) # Spotify library cache self._add_spotify_library_cache_table(cursor) @@ -2279,6 +2280,149 @@ class MusicDatabase: except Exception as e: logger.error(f"Error in profile settings migration: {e}") + def _add_profile_listenbrainz_support(self, cursor): + """Add per-profile ListenBrainz credentials and scope playlist cache by profile""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_listenbrainz_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + + logger.info("Applying per-profile ListenBrainz migration...") + + # Per-profile LB credentials on profiles table + for col_sql in [ + "ALTER TABLE profiles ADD COLUMN listenbrainz_token TEXT DEFAULT NULL", + "ALTER TABLE profiles ADD COLUMN listenbrainz_base_url TEXT DEFAULT NULL", + "ALTER TABLE profiles ADD COLUMN listenbrainz_username TEXT DEFAULT NULL", + ]: + try: + cursor.execute(col_sql) + except sqlite3.OperationalError: + pass # Column already exists + + # Recreate listenbrainz_playlists with profile_id and compound unique constraint + # (SQLite can't ALTER constraints, so we must recreate the table) + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='listenbrainz_playlists'") + if cursor.fetchone(): + cursor.execute(""" + CREATE TABLE IF NOT EXISTS listenbrainz_playlists_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_mbid TEXT NOT NULL, + title TEXT NOT NULL, + creator TEXT, + playlist_type TEXT NOT NULL, + track_count INTEGER DEFAULT 0, + annotation_data TEXT, + profile_id INTEGER DEFAULT 1, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(playlist_mbid, profile_id) + ) + """) + cursor.execute(""" + INSERT OR IGNORE INTO listenbrainz_playlists_new + (id, playlist_mbid, title, creator, playlist_type, track_count, annotation_data, profile_id, last_updated, cached_date) + SELECT id, playlist_mbid, title, creator, playlist_type, track_count, annotation_data, 1, last_updated, cached_date + FROM listenbrainz_playlists + """) + cursor.execute("DROP TABLE listenbrainz_playlists") + cursor.execute("ALTER TABLE listenbrainz_playlists_new RENAME TO listenbrainz_playlists") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_lb_playlists_profile ON listenbrainz_playlists (profile_id)") + + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_listenbrainz_v1', '1') + """) + + logger.info("Per-profile ListenBrainz migration completed successfully") + + except Exception as e: + logger.error(f"Error in per-profile ListenBrainz migration: {e}") + + def set_profile_listenbrainz(self, profile_id: int, token: str, base_url: str = '', username: str = '') -> bool: + """Save encrypted ListenBrainz credentials for a profile""" + try: + from config.settings import config_manager + encrypted_token = config_manager._encrypt_value(token) if token else None + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE profiles + SET listenbrainz_token = ?, listenbrainz_base_url = ?, listenbrainz_username = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (encrypted_token, base_url or None, username or None, profile_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error setting ListenBrainz credentials for profile {profile_id}: {e}") + return False + + def get_profile_listenbrainz(self, profile_id: int) -> Dict[str, Any]: + """Get decrypted ListenBrainz credentials for a profile""" + try: + from config.settings import config_manager + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT listenbrainz_token, listenbrainz_base_url, listenbrainz_username + FROM profiles WHERE id = ? + """, (profile_id,)) + row = cursor.fetchone() + if not row: + return {'token': None, 'base_url': None, 'username': None} + token_raw = row[0] + token = config_manager._decrypt_value(token_raw) if token_raw else None + return { + 'token': token, + 'base_url': row[1] or '', + 'username': row[2] or '', + } + except Exception as e: + logger.error(f"Error getting ListenBrainz credentials for profile {profile_id}: {e}") + return {'token': None, 'base_url': None, 'username': None} + + def clear_profile_listenbrainz(self, profile_id: int) -> bool: + """Clear ListenBrainz credentials for a profile""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE profiles + SET listenbrainz_token = NULL, listenbrainz_base_url = NULL, listenbrainz_username = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (profile_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error clearing ListenBrainz credentials for profile {profile_id}: {e}") + return False + + def get_profiles_with_listenbrainz(self) -> List[Dict[str, Any]]: + """Get all profiles that have ListenBrainz tokens configured""" + try: + from config.settings import config_manager + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT id, listenbrainz_token, listenbrainz_base_url + FROM profiles WHERE listenbrainz_token IS NOT NULL + """) + results = [] + for row in cursor.fetchall(): + token = config_manager._decrypt_value(row[1]) if row[1] else None + if token: + results.append({ + 'id': row[0], + 'token': token, + 'base_url': row[2] or '', + }) + return results + except Exception as e: + logger.error(f"Error getting profiles with ListenBrainz tokens: {e}") + return [] + def _add_spotify_library_cache_table(self, cursor): """Create spotify_library_cache table for caching user's saved Spotify albums""" try: @@ -2478,6 +2622,8 @@ class MusicDatabase: 'home_page': row['home_page'] if 'home_page' in columns else None, 'allowed_pages': json.loads(ap_raw) if ap_raw else None, 'can_download': bool(row['can_download']) if 'can_download' in columns else True, + 'has_listenbrainz': row['listenbrainz_token'] is not None if 'listenbrainz_token' in columns else False, + 'listenbrainz_username': row['listenbrainz_username'] if 'listenbrainz_username' in columns else None, 'created_at': row['created_at'], 'updated_at': row['updated_at'], }) @@ -2506,6 +2652,8 @@ class MusicDatabase: 'home_page': row['home_page'] if 'home_page' in columns else None, 'allowed_pages': json.loads(ap_raw) if ap_raw else None, 'can_download': bool(row['can_download']) if 'can_download' in columns else True, + 'has_listenbrainz': row['listenbrainz_token'] is not None if 'listenbrainz_token' in columns else False, + 'listenbrainz_username': row['listenbrainz_username'] if 'listenbrainz_username' in columns else None, 'created_at': row['created_at'], 'updated_at': row['updated_at'], } diff --git a/web_server.py b/web_server.py index 776f26fa..18240bf7 100644 --- a/web_server.py +++ b/web_server.py @@ -26821,12 +26821,13 @@ def _run_youtube_discovery_worker(url_hash): finally: _resume_enrichment_workers(_ew_state, 'YouTube discovery') -def _run_listenbrainz_discovery_worker(playlist_mbid): +def _run_listenbrainz_discovery_worker(state_key): """Background worker for ListenBrainz music discovery process (Spotify preferred, iTunes fallback)""" + playlist_mbid = state_key.split(':', 1)[1] if ':' in state_key else state_key _ew_state = {} try: _ew_state = _pause_enrichment_workers('ListenBrainz discovery') - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] playlist = state['playlist'] tracks = playlist['tracks'] @@ -28629,6 +28630,119 @@ def set_profile_pin(profile_id): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +# --- Per-Profile ListenBrainz Settings --- + +def _get_lb_credentials_for_profile(profile_id=None): + """Get LB token + base_url for profile, falling back to global config.""" + if profile_id is None: + profile_id = get_current_profile_id() + db = get_database() + settings = db.get_profile_listenbrainz(profile_id) + if settings and settings.get('token'): + return settings['token'], settings.get('base_url', ''), settings.get('username', ''), 'profile' + # Fallback to global config + return (config_manager.get('listenbrainz.token', ''), + config_manager.get('listenbrainz.base_url', ''), + None, 'global') + +def _validate_lb_token(token, base_url=''): + """Validate a ListenBrainz token and return (success, username_or_error)""" + try: + custom_base = (base_url or '').rstrip('/') + if custom_base: + if not custom_base.endswith('/1'): + custom_base += '/1' + lb_api_base = custom_base + else: + lb_api_base = "https://api.listenbrainz.org/1" + url = f"{lb_api_base}/validate-token" + headers = {'Authorization': f'Token {token}'} + response = requests.get(url, headers=headers, timeout=5) + if response.status_code == 200: + data = response.json() + if data.get('valid'): + return True, data.get('user_name', 'Unknown') + return False, "Invalid ListenBrainz token." + elif response.status_code == 401: + return False, "Invalid ListenBrainz token (unauthorized)." + else: + return False, f"Could not connect to ListenBrainz (HTTP {response.status_code})" + except Exception as e: + return False, f"ListenBrainz connection error: {str(e)}" + +@app.route('/api/profiles/me/listenbrainz', methods=['GET']) +def get_profile_listenbrainz(): + """Get current profile's ListenBrainz connection status""" + try: + profile_id = get_current_profile_id() + token, base_url, username, source = _get_lb_credentials_for_profile(profile_id) + connected = bool(token) + return jsonify({ + 'success': True, + 'connected': connected, + 'username': username if connected else None, + 'base_url': base_url or '', + 'source': source + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/me/listenbrainz', methods=['POST']) +def save_profile_listenbrainz(): + """Save ListenBrainz credentials for current profile""" + try: + data = request.json or {} + token = data.get('token', '').strip() + base_url = data.get('base_url', '').strip() + + if not token: + return jsonify({'success': False, 'error': 'Token is required'}), 400 + + # Validate token first + valid, result = _validate_lb_token(token, base_url) + if not valid: + return jsonify({'success': False, 'error': result}), 400 + + username = result + profile_id = get_current_profile_id() + db = get_database() + success = db.set_profile_listenbrainz(profile_id, token, base_url, username) + + if success: + return jsonify({'success': True, 'username': username}) + return jsonify({'success': False, 'error': 'Failed to save credentials'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/me/listenbrainz', methods=['DELETE']) +def delete_profile_listenbrainz(): + """Clear ListenBrainz credentials for current profile""" + try: + profile_id = get_current_profile_id() + db = get_database() + db.clear_profile_listenbrainz(profile_id) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/me/listenbrainz/test', methods=['POST']) +def test_profile_listenbrainz(): + """Test a ListenBrainz token without saving""" + try: + data = request.json or {} + token = data.get('token', '').strip() + base_url = data.get('base_url', '').strip() + + if not token: + return jsonify({'success': False, 'error': 'Token is required'}), 400 + + valid, result = _validate_lb_token(token, base_url) + if valid: + return jsonify({'success': True, 'username': result}) + return jsonify({'success': False, 'error': result}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # --- Watchlist API Endpoints --- @app.route('/api/watchlist/count', methods=['GET']) @@ -29371,13 +29485,24 @@ def start_watchlist_scan(): watchlist_scan_state['current_phase'] = 'updating_listenbrainz' try: from core.listenbrainz_manager import ListenBrainzManager - lb_manager = ListenBrainzManager(str(get_database().database_path)) - lb_result = lb_manager.update_all_playlists() - if lb_result.get('success'): - summary = lb_result.get('summary', {}) - print(f"✅ ListenBrainz update complete: {summary}") + db = get_database() + db_path = str(db.database_path) + # Update for all profiles with LB tokens + lb_profiles = db.get_profiles_with_listenbrainz() + if lb_profiles: + for lb_prof in lb_profiles: + lb_manager = ListenBrainzManager(db_path, profile_id=lb_prof['id'], token=lb_prof['token'], base_url=lb_prof['base_url']) + lb_result = lb_manager.update_all_playlists() + if lb_result.get('success'): + print(f"✅ ListenBrainz update complete for profile {lb_prof['id']}: {lb_result.get('summary', {})}") else: - print(f"⚠️ ListenBrainz update skipped: {lb_result.get('error')}") + # Fallback: use global config token + lb_manager = ListenBrainzManager(db_path) + lb_result = lb_manager.update_all_playlists() + if lb_result.get('success'): + print(f"✅ ListenBrainz update complete (global): {lb_result.get('summary', {})}") + elif lb_result.get('error'): + print(f"⚠️ ListenBrainz update skipped: {lb_result.get('error')}") except Exception as lb_error: print(f"⚠️ Error updating ListenBrainz: {lb_error}") import traceback @@ -30385,19 +30510,30 @@ def _process_watchlist_scan_automatically(automation_id=None, profile_id=None): log_line='Fetching ListenBrainz playlists...', log_type='info') try: from core.listenbrainz_manager import ListenBrainzManager - lb_manager = ListenBrainzManager(str(get_database().database_path)) - lb_result = lb_manager.update_all_playlists() - if lb_result.get('success'): - summary = lb_result.get('summary', {}) - updated = summary.get('updated', 0) - total_lb = summary.get('total', 0) - print(f"✅ ListenBrainz update complete: {summary}") - _update_automation_progress(automation_id, - log_line=f'ListenBrainz: {updated}/{total_lb} playlists updated', log_type='success') + db = get_database() + db_path = str(db.database_path) + lb_profiles = db.get_profiles_with_listenbrainz() + if lb_profiles: + for lb_prof in lb_profiles: + lb_manager = ListenBrainzManager(db_path, profile_id=lb_prof['id'], token=lb_prof['token'], base_url=lb_prof['base_url']) + lb_result = lb_manager.update_all_playlists() + if lb_result.get('success'): + summary = lb_result.get('summary', {}) + print(f"✅ ListenBrainz update complete for profile {lb_prof['id']}: {summary}") + _update_automation_progress(automation_id, + log_line=f'ListenBrainz (profile {lb_prof["id"]}): playlists updated', log_type='success') else: - print(f"⚠️ ListenBrainz update had issues: {lb_result.get('error', 'Unknown error')}") - _update_automation_progress(automation_id, - log_line=f'ListenBrainz: {lb_result.get("error", "Unknown error")}', log_type='error') + lb_manager = ListenBrainzManager(db_path) + lb_result = lb_manager.update_all_playlists() + if lb_result.get('success'): + summary = lb_result.get('summary', {}) + print(f"✅ ListenBrainz update complete (global): {summary}") + _update_automation_progress(automation_id, + log_line=f'ListenBrainz: playlists updated', log_type='success') + else: + print(f"⚠️ ListenBrainz update had issues: {lb_result.get('error', 'Unknown error')}") + _update_automation_progress(automation_id, + log_line=f'ListenBrainz: {lb_result.get("error", "Unknown error")}', log_type='error') except Exception as lb_error: print(f"⚠️ Error updating ListenBrainz: {lb_error}") import traceback @@ -31872,50 +32008,57 @@ def get_discover_genre_playlist(genre_name): # LISTENBRAINZ DISCOVER ENDPOINTS # =============================== +def _get_profile_lb_manager(): + """Create a profile-aware ListenBrainzManager for the current user""" + from core.listenbrainz_manager import ListenBrainzManager + profile_id = get_current_profile_id() + token, base_url, username, source = _get_lb_credentials_for_profile(profile_id) + return ListenBrainzManager(str(get_database().database_path), profile_id=profile_id, token=token, base_url=base_url), username, source + +def _get_lb_discover_playlists(playlist_type): + """Shared logic for the 3 LB discover endpoints""" + lb_manager, username, source = _get_profile_lb_manager() + + # Check if cache is empty - if so, populate it on first load + if not lb_manager.has_cached_playlists(): + if not lb_manager.client.is_authenticated(): + return jsonify({ + "success": False, + "error": "Not authenticated", + "playlists": [], + "count": 0, + "username": None + }) + print(f"📦 Cache empty for profile {lb_manager.profile_id}, populating ListenBrainz playlists...") + lb_manager.update_all_playlists() + + playlists = lb_manager.get_cached_playlists(playlist_type) + + formatted_playlists = [] + for playlist in playlists: + formatted_playlists.append({ + "playlist": { + "identifier": f"https://listenbrainz.org/playlist/{playlist['playlist_mbid']}", + "title": playlist['title'], + "creator": playlist['creator'], + "annotation": playlist.get('annotation', {}), + "track": [] + } + }) + + return jsonify({ + "success": True, + "playlists": formatted_playlists, + "count": len(formatted_playlists), + "username": username, + "source": source + }) + @app.route('/api/discover/listenbrainz/created-for', methods=['GET']) def get_listenbrainz_created_for(): """Get playlists created for the user by ListenBrainz (from cache)""" try: - from core.listenbrainz_manager import ListenBrainzManager - - lb_manager = ListenBrainzManager(str(get_database().database_path)) - - # Check if cache is empty - if so, populate it on first load - if not lb_manager.has_cached_playlists(): - # Check if authenticated - if not lb_manager.client.is_authenticated(): - return jsonify({ - "success": False, - "error": "Not authenticated", - "playlists": [], - "count": 0 - }) - - # Populate cache on first load - print("📦 Cache empty, populating ListenBrainz playlists...") - lb_manager.update_all_playlists() - - playlists = lb_manager.get_cached_playlists('created_for') - - # Convert to JSPF-like format for frontend compatibility - formatted_playlists = [] - for playlist in playlists: - formatted_playlists.append({ - "playlist": { - "identifier": f"https://listenbrainz.org/playlist/{playlist['playlist_mbid']}", - "title": playlist['title'], - "creator": playlist['creator'], - "annotation": playlist.get('annotation', {}), - "track": [] # Track count is in annotation - } - }) - - return jsonify({ - "success": True, - "playlists": formatted_playlists, - "count": len(formatted_playlists) - }) - + return _get_lb_discover_playlists('created_for') except Exception as e: print(f"Error getting cached ListenBrainz created-for playlists: {e}") import traceback @@ -31926,46 +32069,7 @@ def get_listenbrainz_created_for(): def get_listenbrainz_user_playlists(): """Get user's own ListenBrainz playlists (from cache)""" try: - from core.listenbrainz_manager import ListenBrainzManager - - lb_manager = ListenBrainzManager(str(get_database().database_path)) - - # Check if cache is empty - if so, populate it on first load - if not lb_manager.has_cached_playlists(): - # Check if authenticated - if not lb_manager.client.is_authenticated(): - return jsonify({ - "success": False, - "error": "Not authenticated", - "playlists": [], - "count": 0 - }) - - # Populate cache on first load - print("📦 Cache empty, populating ListenBrainz playlists...") - lb_manager.update_all_playlists() - - playlists = lb_manager.get_cached_playlists('user') - - # Convert to JSPF-like format for frontend compatibility - formatted_playlists = [] - for playlist in playlists: - formatted_playlists.append({ - "playlist": { - "identifier": f"https://listenbrainz.org/playlist/{playlist['playlist_mbid']}", - "title": playlist['title'], - "creator": playlist['creator'], - "annotation": playlist.get('annotation', {}), - "track": [] - } - }) - - return jsonify({ - "success": True, - "playlists": formatted_playlists, - "count": len(formatted_playlists) - }) - + return _get_lb_discover_playlists('user') except Exception as e: print(f"Error getting cached ListenBrainz user playlists: {e}") import traceback @@ -31976,46 +32080,7 @@ def get_listenbrainz_user_playlists(): def get_listenbrainz_collaborative(): """Get collaborative ListenBrainz playlists (from cache)""" try: - from core.listenbrainz_manager import ListenBrainzManager - - lb_manager = ListenBrainzManager(str(get_database().database_path)) - - # Check if cache is empty - if so, populate it on first load - if not lb_manager.has_cached_playlists(): - # Check if authenticated - if not lb_manager.client.is_authenticated(): - return jsonify({ - "success": False, - "error": "Not authenticated", - "playlists": [], - "count": 0 - }) - - # Populate cache on first load - print("📦 Cache empty, populating ListenBrainz playlists...") - lb_manager.update_all_playlists() - - playlists = lb_manager.get_cached_playlists('collaborative') - - # Convert to JSPF-like format for frontend compatibility - formatted_playlists = [] - for playlist in playlists: - formatted_playlists.append({ - "playlist": { - "identifier": f"https://listenbrainz.org/playlist/{playlist['playlist_mbid']}", - "title": playlist['title'], - "creator": playlist['creator'], - "annotation": playlist.get('annotation', {}), - "track": [] - } - }) - - return jsonify({ - "success": True, - "playlists": formatted_playlists, - "count": len(formatted_playlists) - }) - + return _get_lb_discover_playlists('collaborative') except Exception as e: print(f"Error getting cached ListenBrainz collaborative playlists: {e}") import traceback @@ -32026,9 +32091,7 @@ def get_listenbrainz_collaborative(): def get_listenbrainz_playlist_tracks(playlist_mbid): """Get tracks from a specific ListenBrainz playlist (from cache)""" try: - from core.listenbrainz_manager import ListenBrainzManager - - lb_manager = ListenBrainzManager(str(get_database().database_path)) + lb_manager, username, source = _get_profile_lb_manager() tracks = lb_manager.get_cached_tracks(playlist_mbid) if not tracks: @@ -32055,9 +32118,7 @@ def get_listenbrainz_playlist_tracks(playlist_mbid): def refresh_listenbrainz(): """Manually refresh ListenBrainz playlists cache""" try: - from core.listenbrainz_manager import ListenBrainzManager - - lb_manager = ListenBrainzManager(str(get_database().database_path)) + lb_manager, username, source = _get_profile_lb_manager() result = lb_manager.update_all_playlists() return jsonify(result) @@ -32072,16 +32133,27 @@ def refresh_listenbrainz(): # LISTENBRAINZ PLAYLIST MANAGEMENT (Discovery System) # ======================================== +def _lb_state_key(playlist_mbid, profile_id=None): + """Build profile-scoped key for listenbrainz_playlist_states""" + if profile_id is None: + profile_id = get_current_profile_id() + return f"{profile_id}:{playlist_mbid}" + @app.route('/api/listenbrainz/playlists', methods=['GET']) def get_all_listenbrainz_playlists(): - """Get all stored ListenBrainz playlists for frontend hydration""" + """Get all stored ListenBrainz playlists for frontend hydration (scoped to current profile)""" try: playlists = [] current_time = time.time() + profile_id = get_current_profile_id() + prefix = f"{profile_id}:" - for playlist_mbid, state in listenbrainz_playlist_states.items(): + for state_key, state in listenbrainz_playlist_states.items(): + if not state_key.startswith(prefix): + continue # Update access time when requested state['last_accessed'] = current_time + playlist_mbid = state_key[len(prefix):] # Return essential data for card recreation playlist_info = { @@ -32099,7 +32171,7 @@ def get_all_listenbrainz_playlists(): } playlists.append(playlist_info) - print(f"📋 Returning {len(playlists)} stored ListenBrainz playlists for hydration") + print(f"📋 Returning {len(playlists)} stored ListenBrainz playlists for profile {profile_id}") return jsonify({"playlists": playlists}) except Exception as e: @@ -32110,10 +32182,11 @@ def get_all_listenbrainz_playlists(): def get_listenbrainz_playlist_state(playlist_mbid): """Get specific ListenBrainz playlist state (detailed version)""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] state['last_accessed'] = time.time() # Return full state information (including results for modal hydration) @@ -32144,10 +32217,11 @@ def get_listenbrainz_playlist_state(playlist_mbid): def reset_listenbrainz_playlist(playlist_mbid): """Reset ListenBrainz playlist to fresh phase (clear discovery/sync data)""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] # Stop any active discovery if 'discovery_future' in state and state['discovery_future']: @@ -32176,17 +32250,18 @@ def reset_listenbrainz_playlist(playlist_mbid): def remove_listenbrainz_playlist(playlist_mbid): """Remove ListenBrainz playlist from state (doesn't affect cache)""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] # Stop any active discovery if 'discovery_future' in state and state['discovery_future']: state['discovery_future'].cancel() # Remove from state - del listenbrainz_playlist_states[playlist_mbid] + del listenbrainz_playlist_states[state_key] print(f"🗑️ Removed ListenBrainz playlist from state: {playlist_mbid}") return jsonify({"success": True}) @@ -32206,9 +32281,10 @@ def start_listenbrainz_discovery(playlist_mbid): return jsonify({"error": "Playlist data required"}), 400 # Create or update state - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: # Initialize new state - listenbrainz_playlist_states[playlist_mbid] = { + listenbrainz_playlist_states[state_key] = { 'playlist_mbid': playlist_mbid, 'playlist': playlist_data, 'phase': 'discovering', @@ -32223,7 +32299,7 @@ def start_listenbrainz_discovery(playlist_mbid): print(f"✅ Created new ListenBrainz playlist state: {playlist_data.get('name', 'Unknown')}") else: # State already exists, update it - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] if state['phase'] == 'discovering': return jsonify({"error": "Discovery already in progress"}), 400 @@ -32235,15 +32311,15 @@ def start_listenbrainz_discovery(playlist_mbid): state['discovery_results'] = [] state['last_accessed'] = time.time() - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] # Add activity for discovery start playlist_name = playlist_data.get('name', 'Unknown Playlist') track_count = len(playlist_data.get('tracks', [])) add_activity_item("🔍", "ListenBrainz Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now") - # Start discovery worker - future = listenbrainz_discovery_executor.submit(_run_listenbrainz_discovery_worker, playlist_mbid) + # Start discovery worker (pass state_key for profile-scoped state access) + future = listenbrainz_discovery_executor.submit(_run_listenbrainz_discovery_worker, state_key) state['discovery_future'] = future print(f"🔍 Started Spotify discovery for ListenBrainz playlist: {playlist_name}") @@ -32259,10 +32335,11 @@ def start_listenbrainz_discovery(playlist_mbid): def get_listenbrainz_discovery_status(playlist_mbid): """Get real-time discovery status for a ListenBrainz playlist""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] state['last_accessed'] = time.time() response = { @@ -32285,7 +32362,8 @@ def get_listenbrainz_discovery_status(playlist_mbid): def update_listenbrainz_phase(playlist_mbid): """Update ListenBrainz playlist phase (for phase transitions and persistence)""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 data = request.get_json() or {} @@ -32294,7 +32372,7 @@ def update_listenbrainz_phase(playlist_mbid): if not new_phase: return jsonify({"error": "Phase is required"}), 400 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] state['phase'] = new_phase state['last_accessed'] = time.time() @@ -32331,8 +32409,8 @@ def update_listenbrainz_discovery_match(): if not identifier or track_index is None or not spotify_track: return jsonify({'error': 'Missing required fields'}), 400 - # Get the state - state = listenbrainz_playlist_states.get(identifier) + # Get the state (identifier is playlist_mbid) + state = listenbrainz_playlist_states.get(_lb_state_key(identifier)) if not state: return jsonify({'error': 'Discovery state not found'}), 404 @@ -32421,10 +32499,11 @@ def convert_listenbrainz_results_to_spotify_tracks(discovery_results): def start_listenbrainz_sync(playlist_mbid): """Start sync process for a ListenBrainz playlist using discovered Spotify tracks""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] state['last_accessed'] = time.time() # Update access time if state['phase'] not in ['discovered', 'sync_complete']: @@ -32473,10 +32552,11 @@ def start_listenbrainz_sync(playlist_mbid): def get_listenbrainz_sync_status(playlist_mbid): """Get sync status for a ListenBrainz playlist""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] state['last_accessed'] = time.time() # Update access time sync_playlist_id = state.get('sync_playlist_id') @@ -32517,10 +32597,11 @@ def get_listenbrainz_sync_status(playlist_mbid): def cancel_listenbrainz_sync(playlist_mbid): """Cancel sync for a ListenBrainz playlist""" try: - if playlist_mbid not in listenbrainz_playlist_states: + state_key = _lb_state_key(playlist_mbid) + if state_key not in listenbrainz_playlist_states: return jsonify({"error": "ListenBrainz playlist not found"}), 404 - state = listenbrainz_playlist_states[playlist_mbid] + state = listenbrainz_playlist_states[state_key] state['last_accessed'] = time.time() # Update access time sync_playlist_id = state.get('sync_playlist_id') diff --git a/webui/index.html b/webui/index.html index ced61526..dacbd5f9 100644 --- a/webui/index.html +++ b/webui/index.html @@ -97,6 +97,22 @@ + + +
@@ -3323,7 +3342,7 @@

🧠 ListenBrainz Playlists

-

Playlists from ListenBrainz

+

Playlists from ListenBrainz

+
+ + +
+ Get your token from listenbrainz.org/profile +
+
+
+
+ + +
+ `; + + let contentHtml; + if (connected && source === 'profile') { + // Personal token — show connected state with Disconnect + const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org'; + contentHtml = ` +
+
🧠
+
+
Connected as ${escapeHtml(username)}
+
${escapeHtml(serverDisplay)}
+
Personal token
+
+
+
+ +
+ `; + } else if (connected && source === 'global') { + // Using admin's shared token — show status + option to set own token + const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org'; + contentHtml = ` +
+
🧠
+
+
Connected as ${escapeHtml(username)}
+
${escapeHtml(serverDisplay)}
+
Using shared token from Settings
+
+
+
+
Set your own token to use a different ListenBrainz account:
+ ${tokenFormHtml} +
+ `; + } else { + // Not connected at all + contentHtml = tokenFormHtml; + } + + body.innerHTML = ` +
+
+

ListenBrainz

+ + + ${connected ? 'Connected' : 'Not connected'} + +
+ ${contentHtml} +
+ `; +} + +async function testPersonalListenBrainz() { + const token = document.getElementById('ps-lb-token')?.value?.trim(); + const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || ''; + const resultEl = document.getElementById('ps-lb-result'); + if (!token) { + if (resultEl) resultEl.innerHTML = '
Please enter a token
'; + return; + } + if (resultEl) resultEl.innerHTML = '
Testing...
'; + try { + const res = await fetch('/api/profiles/me/listenbrainz/test', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({token, base_url: baseUrl}) + }); + const data = await res.json(); + if (data.success) { + resultEl.innerHTML = `
Valid token — ${escapeHtml(data.username)}
`; + } else { + resultEl.innerHTML = `
${escapeHtml(data.error || 'Invalid token')}
`; + } + } catch (e) { + resultEl.innerHTML = '
Connection failed
'; + } +} + +async function connectPersonalListenBrainz() { + const token = document.getElementById('ps-lb-token')?.value?.trim(); + const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || ''; + const resultEl = document.getElementById('ps-lb-result'); + if (!token) { + if (resultEl) resultEl.innerHTML = '
Please enter a token
'; + return; + } + // Disable buttons during connect + document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = true); + if (resultEl) resultEl.innerHTML = '
Connecting...
'; + try { + const res = await fetch('/api/profiles/me/listenbrainz', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({token, base_url: baseUrl}) + }); + const data = await res.json(); + if (data.success) { + showToast(`Connected to ListenBrainz as ${data.username}`, 'success'); + // Re-render as connected + renderPersonalSettingsLB({connected: true, username: data.username, base_url: baseUrl, source: 'profile'}); + // Refresh LB playlists on discover page + _invalidateListenBrainzCache(); + if (typeof initializeListenBrainzTabs === 'function') { + initializeListenBrainzTabs(); + } + } else { + resultEl.innerHTML = `
${escapeHtml(data.error || 'Connection failed')}
`; + document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false); + } + } catch (e) { + resultEl.innerHTML = '
Connection failed
'; + document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false); + } +} + +async function disconnectPersonalListenBrainz() { + try { + await fetch('/api/profiles/me/listenbrainz', {method: 'DELETE'}); + showToast('ListenBrainz disconnected', 'info'); + // Re-render as disconnected — re-fetch to check if global fallback exists + const res = await fetch('/api/profiles/me/listenbrainz'); + const data = await res.json(); + renderPersonalSettingsLB(data); + // Refresh LB playlists on discover page + _invalidateListenBrainzCache(); + if (typeof initializeListenBrainzTabs === 'function') { + initializeListenBrainzTabs(); + } + } catch (e) { + showToast('Failed to disconnect', 'error'); + } +} + +function _invalidateListenBrainzCache() { + if (typeof listenbrainzPlaylistsLoaded !== 'undefined') listenbrainzPlaylistsLoaded = false; + if (typeof listenbrainzPlaylistsCache !== 'undefined') { + try { Object.keys(listenbrainzPlaylistsCache).forEach(k => delete listenbrainzPlaylistsCache[k]); } catch(e) {} + } + if (typeof listenbrainzTracksCache !== 'undefined') { + try { Object.keys(listenbrainzTracksCache).forEach(k => delete listenbrainzTracksCache[k]); } catch(e) {} + } +} + function initProfileManagement() { const manageBtn = document.getElementById('manage-profiles-btn'); const closeBtn = document.getElementById('profile-manage-close'); @@ -45545,10 +45743,14 @@ async function initializeListenBrainzTabs() { { id: 'collaborative', label: '🤝 Collaborative', hasData: false } ]; + // Track LB username for header display + let lbUsername = null; + // Check which tabs have data if (createdForRes.ok) { const data = await createdForRes.json(); console.log('📋 Created For data:', data); + if (data.username) lbUsername = data.username; if (data.success && data.playlists && data.playlists.length > 0) { listenbrainzPlaylistsCache['recommendations'] = data.playlists; tabs[0].hasData = true; @@ -45559,6 +45761,7 @@ async function initializeListenBrainzTabs() { if (userPlaylistsRes.ok) { const data = await userPlaylistsRes.json(); console.log('📚 User Playlists data:', data); + if (data.username && !lbUsername) lbUsername = data.username; if (data.success && data.playlists && data.playlists.length > 0) { listenbrainzPlaylistsCache['user'] = data.playlists; tabs[1].hasData = true; @@ -45569,6 +45772,7 @@ async function initializeListenBrainzTabs() { if (collaborativeRes.ok) { const data = await collaborativeRes.json(); console.log('🤝 Collaborative data:', data); + if (data.username && !lbUsername) lbUsername = data.username; if (data.success && data.playlists && data.playlists.length > 0) { listenbrainzPlaylistsCache['collaborative'] = data.playlists; tabs[2].hasData = true; @@ -45598,12 +45802,27 @@ async function initializeListenBrainzTabs() { if (tabs.every(t => !t.hasData)) { console.log('⚠️ No tabs have data'); - tabsContainer.innerHTML = '

No ListenBrainz playlists available. Configure your token in Settings.

'; + tabsContainer.innerHTML = ` +
+
🧠
+

Connect ListenBrainz

+

Link your ListenBrainz account to see personalized playlists, recommendations, and collaborative playlists.

+ +

Get your token from listenbrainz.org/profile

+
`; return; } tabsContainer.innerHTML = tabsHtml; + // Update section subtitle with username + const lbSubtitle = document.getElementById('listenbrainz-section-subtitle'); + if (lbSubtitle) { + lbSubtitle.textContent = lbUsername ? `Playlists for ${lbUsername}` : 'Playlists from ListenBrainz'; + } + // Load first available tab const firstTab = tabs.find(t => t.hasData); if (firstTab) { diff --git a/webui/static/style.css b/webui/static/style.css index dda917c0..edb789e1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -32864,6 +32864,343 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) { white-space: nowrap; } +/* Personal Settings Trigger (gear icon) */ +.personal-settings-trigger { + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 2px; + margin-left: auto; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; +} +.personal-settings-trigger:hover { + color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.08); + transform: rotate(30deg); +} + +/* Personal Settings Modal */ +.personal-settings-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + animation: ps-fade-in 0.2s ease; +} +@keyframes ps-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +.personal-settings-container { + background: linear-gradient(145deg, rgba(28, 28, 32, 0.98), rgba(18, 18, 22, 0.99)); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + width: 90%; + max-width: 460px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + animation: ps-slide-up 0.25s ease; + overflow: hidden; +} +@keyframes ps-slide-up { + from { opacity: 0; transform: translateY(20px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.personal-settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.personal-settings-header h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + gap: 8px; +} +.personal-settings-header h3 svg { + color: rgba(255, 255, 255, 0.5); +} +.personal-settings-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + font-size: 22px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + transition: color 0.2s; +} +.personal-settings-close:hover { + color: rgba(255, 255, 255, 0.9); +} +.personal-settings-body { + padding: 20px 22px 24px; +} + +/* LB Section within Personal Settings */ +.ps-section { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 16px 18px; +} +.ps-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.ps-section-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #eb743b; + margin: 0; +} +.ps-connection-badge { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 500; +} +.ps-connection-badge.connected { + color: rgb(var(--accent-rgb)); +} +.ps-connection-badge.disconnected { + color: rgba(255, 255, 255, 0.35); +} +.ps-connection-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.ps-connection-badge.connected .ps-connection-dot { + background: rgb(var(--accent-rgb)); + box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.4); +} +.ps-connection-badge.disconnected .ps-connection-dot { + background: rgba(255, 255, 255, 0.25); +} + +/* Connected State */ +.ps-connected-info { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(var(--accent-rgb), 0.06); + border: 1px solid rgba(var(--accent-rgb), 0.12); + border-radius: 10px; + margin-bottom: 12px; +} +.ps-connected-icon { + font-size: 24px; + flex-shrink: 0; +} +.ps-connected-details { + flex: 1; + min-width: 0; +} +.ps-connected-username { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} +.ps-connected-server { + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} +.ps-connected-source { + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + font-style: italic; + margin-top: 2px; +} + +/* Form Fields */ +.ps-form-group { + margin-bottom: 12px; +} +.ps-form-group label { + display: block; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.55); + margin-bottom: 5px; + letter-spacing: 0.2px; +} +.ps-form-group input { + width: 100%; + padding: 9px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + color: rgba(255, 255, 255, 0.9); + font-size: 12px; + transition: all 0.25s ease; + box-sizing: border-box; +} +.ps-form-group input:focus { + outline: none; + border-color: rgba(235, 116, 59, 0.5); + background: rgba(235, 116, 59, 0.06); + box-shadow: 0 0 0 3px rgba(235, 116, 59, 0.1); +} +.ps-form-group input::placeholder { + color: rgba(255, 255, 255, 0.2); +} +.ps-help-text { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + margin-top: 4px; +} +.ps-help-text a { + color: #eb743b; + text-decoration: none; +} +.ps-help-text a:hover { + text-decoration: underline; +} + +/* Buttons */ +.ps-actions { + display: flex; + gap: 8px; + margin-top: 14px; +} +.ps-btn { + padding: 8px 16px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s ease; +} +.ps-btn-primary { + background: #eb743b; + color: #fff; +} +.ps-btn-primary:hover { + background: #d4632f; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(235, 116, 59, 0.3); +} +.ps-btn-secondary { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.ps-btn-secondary:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} +.ps-btn-danger { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.2); +} +.ps-btn-danger:hover { + background: rgba(239, 68, 68, 0.2); +} +.ps-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +/* Inline feedback */ +.ps-inline-result { + margin-top: 10px; + padding: 8px 12px; + border-radius: 8px; + font-size: 11px; + font-weight: 500; + animation: ps-fade-in 0.2s ease; +} +.ps-inline-result.success { + background: rgba(var(--accent-rgb), 0.08); + border: 1px solid rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-rgb)); +} +.ps-inline-result.error { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +/* ListenBrainz empty state on Discover page */ +.lb-empty-state { + text-align: center; + padding: 40px 24px; + background: rgba(255, 255, 255, 0.015); + border: 1px dashed rgba(255, 255, 255, 0.08); + border-radius: 16px; + margin: 8px 0; +} +.lb-empty-state .lb-empty-icon { + font-size: 40px; + margin-bottom: 12px; +} +.lb-empty-state h3 { + font-size: 16px; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + margin: 0 0 8px 0; +} +.lb-empty-state p { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + margin: 0 0 16px 0; + max-width: 340px; + margin-left: auto; + margin-right: auto; + line-height: 1.5; +} +.lb-empty-state .lb-connect-btn { + background: #eb743b !important; + border-color: #eb743b !important; + color: #fff !important; + padding: 10px 24px !important; + font-size: 13px !important; + border-radius: 10px !important; +} +.lb-empty-state .lb-connect-btn:hover { + background: #d4632f !important; + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(235, 116, 59, 0.3); +} +.lb-empty-state .lb-empty-help { + font-size: 11px; + color: rgba(255, 255, 255, 0.3); + margin-top: 12px; +} +.lb-empty-state .lb-empty-help a { + color: #eb743b; + text-decoration: none; +} +.lb-empty-state .lb-empty-help a:hover { + text-decoration: underline; +} + /* =========================== AUTOMATIONS PAGE =========================== */