Add per-profile ListenBrainz support with personal settings modal

This commit is contained in:
Broque Thomas 2026-03-14 20:12:19 -07:00
parent 9a22c49a0e
commit 8f0b9518bc
7 changed files with 1008 additions and 196 deletions

View file

@ -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

View file

@ -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:

View file

@ -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'],
}

View file

@ -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')

View file

@ -97,6 +97,22 @@
</div>
</div>
<!-- Personal Settings Modal -->
<div id="personal-settings-overlay" class="personal-settings-overlay" style="display: none;" onclick="if(event.target===this)closePersonalSettings()">
<div class="personal-settings-container">
<div class="personal-settings-header">
<h3>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
My Settings
</h3>
<button class="personal-settings-close" onclick="closePersonalSettings()">&times;</button>
</div>
<div class="personal-settings-body" id="personal-settings-body">
<!-- Populated dynamically by JS -->
</div>
</div>
</div>
<div class="main-container">
<!-- Mobile Navigation -->
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
@ -115,6 +131,9 @@
<div id="profile-indicator" class="profile-indicator" style="display: none;" title="Switch profile">
<div id="profile-indicator-avatar" class="profile-indicator-avatar"></div>
<span id="profile-indicator-name" class="profile-indicator-name"></span>
<button id="personal-settings-btn" class="personal-settings-trigger" onclick="event.stopPropagation(); openPersonalSettings()" title="My Settings">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</div>
@ -3323,7 +3342,7 @@
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">🧠 ListenBrainz Playlists</h2>
<p class="discover-section-subtitle">Playlists from ListenBrainz</p>
<p class="discover-section-subtitle" id="listenbrainz-section-subtitle">Playlists from ListenBrainz</p>
</div>
<div class="discover-section-actions">
<button class="action-button primary" id="listenbrainz-refresh-btn"

View file

@ -1044,6 +1044,8 @@ async function selectProfile(profileId) {
if (socket && socket.connected) {
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
}
// Invalidate ListenBrainz cache on profile switch (each profile has their own playlists)
_invalidateListenBrainzCache();
}
return data.success;
} catch (e) {
@ -1101,6 +1103,202 @@ function updateProfileIndicator() {
}
}
// =====================
// PERSONAL SETTINGS MODAL
// =====================
async function openPersonalSettings() {
const overlay = document.getElementById('personal-settings-overlay');
if (!overlay) return;
overlay.style.display = 'flex';
const body = document.getElementById('personal-settings-body');
body.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.4);">Loading...</div>';
try {
const res = await fetch('/api/profiles/me/listenbrainz');
const data = await res.json();
renderPersonalSettingsLB(data);
} catch (e) {
body.innerHTML = '<div style="color:#ef4444;padding:16px;">Failed to load settings</div>';
}
}
function closePersonalSettings() {
const overlay = document.getElementById('personal-settings-overlay');
if (overlay) overlay.style.display = 'none';
}
function renderPersonalSettingsLB(data) {
const body = document.getElementById('personal-settings-body');
const connected = data.connected;
const username = data.username || '';
const baseUrl = data.base_url || '';
const source = data.source || 'global';
const tokenFormHtml = `
<div class="ps-form-group">
<label>User Token</label>
<input type="password" id="ps-lb-token" placeholder="Paste your ListenBrainz token">
</div>
<div class="ps-form-group">
<label>Server URL <span style="font-weight:400;color:rgba(255,255,255,0.3)">(optional)</span></label>
<input type="text" id="ps-lb-base-url" placeholder="Leave empty for official (api.listenbrainz.org)">
<div class="ps-help-text">
Get your token from <a href="https://listenbrainz.org/profile/" target="_blank">listenbrainz.org/profile</a>
</div>
</div>
<div id="ps-lb-result"></div>
<div class="ps-actions">
<button class="ps-btn ps-btn-secondary" onclick="testPersonalListenBrainz()">Test</button>
<button class="ps-btn ps-btn-primary" onclick="connectPersonalListenBrainz()">Connect</button>
</div>
`;
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 = `
<div class="ps-connected-info">
<div class="ps-connected-icon">&#129504;</div>
<div class="ps-connected-details">
<div class="ps-connected-username">Connected as ${escapeHtml(username)}</div>
<div class="ps-connected-server">${escapeHtml(serverDisplay)}</div>
<div class="ps-connected-source">Personal token</div>
</div>
</div>
<div class="ps-actions">
<button class="ps-btn ps-btn-danger" onclick="disconnectPersonalListenBrainz()">Disconnect</button>
</div>
`;
} 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 = `
<div class="ps-connected-info">
<div class="ps-connected-icon">&#129504;</div>
<div class="ps-connected-details">
<div class="ps-connected-username">Connected as ${escapeHtml(username)}</div>
<div class="ps-connected-server">${escapeHtml(serverDisplay)}</div>
<div class="ps-connected-source">Using shared token from Settings</div>
</div>
</div>
<div style="margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,0.06);">
<div style="font-size:11px;color:rgba(255,255,255,0.45);margin-bottom:10px;">Set your own token to use a different ListenBrainz account:</div>
${tokenFormHtml}
</div>
`;
} else {
// Not connected at all
contentHtml = tokenFormHtml;
}
body.innerHTML = `
<div class="ps-section">
<div class="ps-section-header">
<h4 class="ps-section-title">ListenBrainz</h4>
<span class="ps-connection-badge ${connected ? 'connected' : 'disconnected'}">
<span class="ps-connection-dot"></span>
${connected ? 'Connected' : 'Not connected'}
</span>
</div>
${contentHtml}
</div>
`;
}
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 = '<div class="ps-inline-result error">Please enter a token</div>';
return;
}
if (resultEl) resultEl.innerHTML = '<div class="ps-inline-result" style="color:rgba(255,255,255,0.5);">Testing...</div>';
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 = `<div class="ps-inline-result success">Valid token — ${escapeHtml(data.username)}</div>`;
} else {
resultEl.innerHTML = `<div class="ps-inline-result error">${escapeHtml(data.error || 'Invalid token')}</div>`;
}
} catch (e) {
resultEl.innerHTML = '<div class="ps-inline-result error">Connection failed</div>';
}
}
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 = '<div class="ps-inline-result error">Please enter a token</div>';
return;
}
// Disable buttons during connect
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = true);
if (resultEl) resultEl.innerHTML = '<div class="ps-inline-result" style="color:rgba(255,255,255,0.5);">Connecting...</div>';
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 = `<div class="ps-inline-result error">${escapeHtml(data.error || 'Connection failed')}</div>`;
document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false);
}
} catch (e) {
resultEl.innerHTML = '<div class="ps-inline-result error">Connection failed</div>';
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 = '<div class="discover-empty"><p>No ListenBrainz playlists available. Configure your token in Settings.</p></div>';
tabsContainer.innerHTML = `
<div class="lb-empty-state">
<div class="lb-empty-icon">&#129504;</div>
<h3>Connect ListenBrainz</h3>
<p>Link your ListenBrainz account to see personalized playlists, recommendations, and collaborative playlists.</p>
<button class="action-button primary lb-connect-btn" onclick="openPersonalSettings()">
Connect ListenBrainz
</button>
<p class="lb-empty-help">Get your token from <a href="https://listenbrainz.org/profile/" target="_blank">listenbrainz.org/profile</a></p>
</div>`;
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) {

View file

@ -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
=========================== */