Add per-profile ListenBrainz support with personal settings modal
This commit is contained in:
parent
9a22c49a0e
commit
8f0b9518bc
7 changed files with 1008 additions and 196 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
}
|
||||
|
|
|
|||
429
web_server.py
429
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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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()">×</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"
|
||||
|
|
|
|||
|
|
@ -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">🧠</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">🧠</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">🧠</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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
=========================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue