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 @@ + +
+Playlists from ListenBrainz
+Playlists from ListenBrainz
No ListenBrainz playlists available. Configure your token in Settings.
Link your ListenBrainz account to see personalized playlists, recommendations, and collaborative playlists.
+Get your token from listenbrainz.org/profile
+