Your Artists on Discover + Deezer OAuth + MB Lookups Manager + Explorer improvements + bug fixes

YOUR ARTISTS (major feature):
- Aggregates liked/followed artists from Spotify, Tidal, Last.fm, Deezer
- Matches to ALL metadata sources (Spotify, iTunes, Deezer, Discogs)
- DB-first matching: library → watchlist → cache → API search (capped)
- Image backfill from Spotify API for artists missing artwork
- Carousel on Discover page with 20 random matched artists
- View All modal with search, source filters, sort, pagination
- Artist info modal: hero image, matched source badges, genres, bio,
  listeners/plays from Last.fm, watchlist toggle, view discography
- Auto-refresh with loading state on first load, polls until ready
- Deduplication by normalized name across all services

DEEZER OAUTH:
- Full OAuth flow: /auth/deezer + /deezer/callback
- Settings UI on Connections tab (App ID, Secret, Redirect URI)
- Token stored encrypted, auto-included in API calls
- get_user_favorite_artists() for liked artists pool

SERVICE CLIENTS:
- Spotify: added user-follow-read scope + get_followed_artists()
- Tidal: get_favorite_artists() with V2/V1 fallback
- Last.fm: get_authenticated_username() + get_user_top_artists()

FAILED MB LOOKUPS MANAGER:
- Manage button on Cache Health modal
- Browse/filter/search all failed MusicBrainz lookups
- Search MusicBrainz directly and manually match entries
- Optimized cache health queries (11 → 4 consolidated)
- Dashboard cache stats now poll every 15s

EXPLORER IMPROVEMENTS:
- Discover button on undiscovered playlist cards
- Status badges: explored/wishlisted/downloaded/ready
- Auto-refresh during discovery via polling
- Redesigned controls: prominent Explore button, icons

BUG FIXES:
- Fix album artist splitting on collab albums (collab mode fed
  album-level artists instead of per-track)
- Fix cover.jpg not moving during library reorganize (post-pass sweep)
- Fix cover.jpg missing when album detection takes fallback path
- Fix wishlist auto-processing toast spam (was firing every 2s)
- Fix media player collapsing on short viewports
- Fix watchlist rate limiting (~90% fewer API calls)
- Configurable spotify.min_api_interval setting
- Better Retry-After header extraction
- Encrypt Last.fm and Discogs credentials at rest
- Add $discnum template variable (unpadded disc number)
This commit is contained in:
Broque Thomas 2026-04-03 22:39:05 -07:00
parent d61a779b0e
commit 58d8e830c6
11 changed files with 2067 additions and 10 deletions

View file

@ -86,6 +86,10 @@ class ConfigManager:
'lastfm.api_secret',
'lastfm.session_key',
'genius.access_token',
# Deezer OAuth
'deezer.app_id',
'deezer.app_secret',
'deezer.access_token',
# Other
'hydrabase.api_key',
'discogs.token',

View file

@ -223,20 +223,40 @@ class DeezerClient:
'User-Agent': 'SoulSync/1.0',
'Accept': 'application/json'
})
logger.info("Deezer client initialized")
self._access_token = None
self._load_token()
logger.info("Deezer client initialized" + (" (authenticated)" if self._access_token else " (public)"))
def _load_token(self):
"""Load OAuth access token from config if available."""
try:
from config.settings import config_manager
self._access_token = config_manager.get('deezer.access_token', None)
except Exception:
self._access_token = None
def is_user_authenticated(self) -> bool:
"""Check if we have a Deezer OAuth user token (for favorites, playlists, etc.)"""
return bool(self._access_token)
def is_authenticated(self) -> bool:
"""Deezer public API requires no authentication — always available"""
return True
def reload_config(self):
"""Reload configuration (no-op for Deezer since no auth required)"""
pass
"""Reload configuration — refresh OAuth token from config."""
self._load_token()
def _api_get(self, endpoint: str, params: dict = None, timeout: int = 15) -> Optional[Dict[str, Any]]:
"""Generic GET request to Deezer API with error handling"""
"""Generic GET request to Deezer API with error handling.
Includes OAuth access_token when available for user-level endpoints."""
try:
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
if params is None:
params = {}
# Include access token for authenticated requests
if self._access_token and 'access_token' not in params:
params['access_token'] = self._access_token
response = self.session.get(url, params=params, timeout=timeout)
if response.status_code != 200:
@ -635,6 +655,45 @@ class DeezerClient:
return artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium')
return None
# ==================== User Methods (require OAuth) ====================
@rate_limited
def get_user_favorite_artists(self, limit: int = 200) -> list:
"""Fetch user's favorite artists from Deezer. Requires OAuth access token.
Returns list of dicts with deezer_id, name, image_url."""
if not self._access_token:
logger.debug("Deezer not user-authenticated — cannot fetch favorites")
return []
try:
artists = []
index = 0
while len(artists) < limit:
data = self._api_get('user/me/artists', params={
'limit': min(100, limit - len(artists)),
'index': index
})
if not data or 'data' not in data:
break
items = data['data']
if not items:
break
for a in items:
artists.append({
'deezer_id': str(a.get('id', '')),
'name': a.get('name', ''),
'image_url': a.get('picture_xl') or a.get('picture_big') or a.get('picture_medium', ''),
})
if not data.get('next'):
break
index += len(items)
time.sleep(0.3) # Extra breathing room
logger.info(f"Retrieved {len(artists)} favorite artists from Deezer")
return artists
except Exception as e:
logger.error(f"Error fetching Deezer favorite artists: {e}")
return []
# ==================== Stub Methods (match iTunesClient interface) ====================
def get_user_playlists(self) -> List[Playlist]:

View file

@ -252,6 +252,83 @@ class LastFMClient:
logger.debug(f"No artist found for query: {artist_name}")
return None
def get_authenticated_username(self) -> Optional[str]:
"""Get the username of the authenticated Last.fm user via signed user.getInfo call."""
if not self.api_key or not self.api_secret or not self.session_key:
return None
try:
params = {
'method': 'user.getInfo',
'api_key': self.api_key,
'sk': self.session_key,
'format': 'json'
}
params['api_sig'] = self._sign_request({k: v for k, v in params.items() if k != 'format'})
response = self.session.get(self.BASE_URL, params=params, timeout=10)
response.raise_for_status()
data = response.json()
username = data.get('user', {}).get('name')
if username:
logger.info(f"Last.fm authenticated user: {username}")
return username
except Exception as e:
logger.error(f"Error getting Last.fm username: {e}")
return None
@rate_limited
def get_user_top_artists(self, username: str, period: str = 'overall', limit: int = 200) -> list:
"""Fetch user's top artists from Last.fm.
Args:
username: Last.fm username
period: overall|7day|1month|3month|6month|12month
limit: max artists to return
Returns:
List of dicts with name, playcount, image_url
"""
if not username:
return []
try:
artists = []
page = 1
per_page = min(limit, 200)
while len(artists) < limit:
data = self._make_request('user.getTopArtists', {
'user': username,
'period': period,
'limit': str(per_page),
'page': str(page)
})
if not data:
break
items = data.get('topartists', {}).get('artist', [])
if not items:
break
for a in items:
image_url = None
images = a.get('image', [])
for img in reversed(images): # largest first
if img.get('#text'):
image_url = img['#text']
break
artists.append({
'name': a.get('name', ''),
'playcount': int(a.get('playcount', 0)),
'image_url': image_url,
})
if len(artists) >= limit:
break
# Check if more pages exist
total_pages = int(data.get('topartists', {}).get('@attr', {}).get('totalPages', 1))
if page >= total_pages:
break
page += 1
logger.info(f"Retrieved {len(artists)} top artists from Last.fm for {username}")
return artists
except Exception as e:
logger.error(f"Error fetching Last.fm top artists: {e}")
return []
@rate_limited
def get_artist_info(self, artist_name: str) -> Optional[Dict[str, Any]]:
"""

View file

@ -529,7 +529,7 @@ class SpotifyClient:
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"),
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path='config/.spotify_cache'
)
@ -1018,6 +1018,56 @@ class SpotifyClient:
logger.error(f"Error fetching playlist {playlist_id}: {e}")
return None
@rate_limited
def get_followed_artists(self) -> list:
"""Fetch all artists the user follows on Spotify.
Returns list of dicts with id, name, image_url, genres.
Requires user-follow-read scope returns empty list on 403."""
if not self.is_spotify_authenticated():
return []
try:
artists = []
after = None
while True:
results = self.sp.current_user_followed_artists(limit=50, after=after)
if not results or 'artists' not in results:
break
items = results['artists'].get('items', [])
if not items:
break
for a in items:
image_url = a['images'][0]['url'] if a.get('images') else None
artists.append({
'spotify_id': a['id'],
'name': a['name'],
'image_url': image_url,
'genres': a.get('genres', []),
})
# Cursor-based pagination
cursors = results['artists'].get('cursors', {})
after = cursors.get('after')
if not after:
break
# Throttle pagination
_pi = _get_min_api_interval()
with _api_call_lock:
elapsed = time.time() - _last_api_call_time
if elapsed < _pi:
time.sleep(_pi - elapsed)
globals()['_last_api_call_time'] = time.time()
from core.api_call_tracker import api_call_tracker
api_call_tracker.record_call('spotify', endpoint='get_followed_artists_page')
logger.info(f"Retrieved {len(artists)} followed artists from Spotify")
return artists
except Exception as e:
if '403' in str(e) or 'Forbidden' in str(e):
logger.warning("Spotify user-follow-read scope not granted — re-authorize to see followed artists")
return []
_detect_and_set_rate_limit(e, 'get_followed_artists')
logger.error(f"Error fetching followed artists: {e}")
return []
@rate_limited
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
"""Search for tracks - falls back to configured metadata source if Spotify not authenticated"""

View file

@ -1371,19 +1371,132 @@ class TidalClient:
if not self._ensure_valid_token():
logger.error("Not authenticated with Tidal")
return None
# Note: This would require user OAuth authentication
# For now, return basic info since we're using client credentials
return {
'display_name': 'Tidal User',
'id': 'tidal_user',
'type': 'user'
}
except Exception as e:
logger.error(f"Error getting Tidal user info: {e}")
return None
def get_favorite_artists(self, limit: int = 200) -> list:
"""Fetch user's favorite artists from Tidal.
Returns list of dicts with tidal_id, name, image_url."""
try:
if not self._ensure_valid_token():
logger.debug("Tidal not authenticated — cannot fetch favorites")
return []
user_id, api_version = self._get_user_id()
if not user_id:
logger.warning("Could not get Tidal user ID for favorites")
return []
artists = []
if api_version == 'v2':
# V2 API: /v2/favorites with filter
offset = 0
while len(artists) < limit:
try:
headers = self.session.headers.copy()
headers['accept'] = 'application/vnd.api+json'
resp = requests.get(
f"{self.base_url}/favorites",
params={
'countryCode': 'US',
'filter[user.id]': user_id,
'filter[type]': 'ARTISTS',
'include': 'artists',
'page[limit]': min(50, limit - len(artists)),
'page[offset]': offset
},
headers=headers, timeout=15
)
if resp.status_code != 200:
logger.debug(f"Tidal V2 favorites returned {resp.status_code}, trying V1")
break
data = resp.json()
# Parse included artists
included = data.get('included', [])
if not included:
items = data.get('data', [])
if not items:
break
# Try to extract from data items directly
for item in items:
attrs = item.get('attributes', {})
name = attrs.get('name', '')
if name:
img = None
img_data = item.get('relationships', {}).get('image', {}).get('data', {})
if isinstance(img_data, dict) and img_data.get('id'):
img = f"https://resources.tidal.com/images/{img_data['id'].replace('-', '/')}/750x750.jpg"
artists.append({'tidal_id': item.get('id', ''), 'name': name, 'image_url': img})
else:
for inc in included:
if inc.get('type') == 'artists':
attrs = inc.get('attributes', {})
img = None
img_rel = inc.get('relationships', {}).get('image', {}).get('data', {})
if isinstance(img_rel, dict) and img_rel.get('id'):
img = f"https://resources.tidal.com/images/{img_rel['id'].replace('-', '/')}/750x750.jpg"
artists.append({
'tidal_id': str(inc.get('id', '')),
'name': attrs.get('name', ''),
'image_url': img,
})
if not data.get('links', {}).get('next'):
break
offset += 50
import time
time.sleep(0.5)
except Exception as e:
logger.debug(f"Tidal V2 favorites error: {e}")
break
# Fallback to V1 API if V2 returned nothing
if not artists:
try:
offset = 0
while len(artists) < limit:
resp = self.session.get(
f"{self.alt_base_url}/users/{user_id}/favorites/artists",
params={'countryCode': 'US', 'limit': min(50, limit - len(artists)), 'offset': offset},
timeout=15
)
if resp.status_code != 200:
logger.debug(f"Tidal V1 favorites returned {resp.status_code}")
break
data = resp.json()
items = data.get('items', [])
if not items:
break
for item in items:
a = item.get('item', item)
img_id = (a.get('picture') or '').replace('-', '/')
img = f"https://resources.tidal.com/images/{img_id}/750x750.jpg" if img_id else None
artists.append({
'tidal_id': str(a.get('id', '')),
'name': a.get('name', ''),
'image_url': img,
})
total = data.get('totalNumberOfItems', 0)
offset += len(items)
if offset >= total:
break
import time
time.sleep(0.5)
except Exception as e:
logger.debug(f"Tidal V1 favorites error: {e}")
logger.info(f"Retrieved {len(artists)} favorite artists from Tidal")
return artists
except Exception as e:
logger.error(f"Error fetching Tidal favorite artists: {e}")
return []
# Global instance
_tidal_client = None

View file

@ -1184,6 +1184,33 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_name ON discovery_artist_blacklist (artist_name COLLATE NOCASE)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_spotify ON discovery_artist_blacklist (spotify_artist_id)")
# Liked artists pool — aggregated followed/liked artists from connected services
cursor.execute("""
CREATE TABLE IF NOT EXISTS liked_artists_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist_name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
spotify_artist_id TEXT,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
image_url TEXT,
genres TEXT,
source_services TEXT DEFAULT '[]',
active_source_id TEXT,
active_source TEXT,
match_status TEXT DEFAULT 'pending',
on_watchlist INTEGER DEFAULT 0,
profile_id INTEGER DEFAULT 1,
last_fetched_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, normalized_name)
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
logger.info("Discovery tables created successfully")
except Exception as e:
@ -8713,6 +8740,291 @@ class MusicDatabase:
logger.error(f"Error getting discovery blacklist names: {e}")
return set()
# ==================== Liked Artists Pool Methods ====================
@staticmethod
def _normalize_artist_name(name: str) -> str:
"""Normalize artist name for deduplication. Lowercases, strips diacritics,
removes 'the ' prefix, collapses whitespace."""
import unicodedata
if not name:
return ''
n = unicodedata.normalize('NFKD', name)
n = ''.join(c for c in n if not unicodedata.combining(c))
n = n.lower().strip()
if n.startswith('the '):
n = n[4:]
# Handle "Artist, The" format (Last.fm)
if n.endswith(', the'):
n = n[:-5]
n = ' '.join(n.split()) # collapse whitespace
return n
# Known placeholder/default images that should be treated as "no image"
_PLACEHOLDER_IMAGES = {
'2a96cbd8b46e442fc41c2b86b821562f', # Last.fm default star
}
@classmethod
def _is_placeholder_image(cls, url: str) -> bool:
"""Check if an image URL is a known service placeholder."""
if not url:
return True
return any(ph in url for ph in cls._PLACEHOLDER_IMAGES)
def upsert_liked_artist(self, artist_name: str, source_service: str,
source_id: str = None, source_id_type: str = None,
image_url: str = None, genres: list = None,
profile_id: int = 1) -> bool:
"""Insert or merge a liked artist into the pool. Deduplicates by normalized name."""
try:
import json
# Reject known placeholder images
if self._is_placeholder_image(image_url):
image_url = None
normalized = self._normalize_artist_name(artist_name)
if not normalized:
return False
conn = self._get_connection()
cursor = conn.cursor()
# Check if exists to merge source_services
cursor.execute(
"SELECT id, source_services FROM liked_artists_pool WHERE profile_id = ? AND normalized_name = ?",
(profile_id, normalized)
)
existing = cursor.fetchone()
if existing:
# Merge source into existing entry
current_sources = json.loads(existing['source_services'] or '[]')
if source_service not in current_sources:
current_sources.append(source_service)
# Build SET clause with COALESCE for IDs and image
set_parts = [
"source_services = ?",
"updated_at = CURRENT_TIMESTAMP",
"last_fetched_at = CURRENT_TIMESTAMP",
]
params = [json.dumps(current_sources)]
if source_id and source_id_type:
col = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}.get(source_id_type)
if col:
set_parts.append(f"{col} = COALESCE({col}, ?)")
params.append(source_id)
if image_url:
set_parts.append("image_url = COALESCE(image_url, ?)")
params.append(image_url)
if genres:
set_parts.append("genres = COALESCE(genres, ?)")
params.append(json.dumps(genres))
params.extend([profile_id, normalized])
cursor.execute(
f"UPDATE liked_artists_pool SET {', '.join(set_parts)} WHERE profile_id = ? AND normalized_name = ?",
params
)
else:
# New entry
sources_json = json.dumps([source_service])
id_cols = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}
col_values = {v: None for v in id_cols.values()}
if source_id and source_id_type and source_id_type in id_cols:
col_values[id_cols[source_id_type]] = source_id
cursor.execute("""
INSERT INTO liked_artists_pool
(artist_name, normalized_name, spotify_artist_id, itunes_artist_id,
deezer_artist_id, discogs_artist_id, image_url, genres,
source_services, profile_id, last_fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (
artist_name, normalized, col_values['spotify_artist_id'],
col_values['itunes_artist_id'], col_values['deezer_artist_id'],
col_values['discogs_artist_id'], image_url,
json.dumps(genres) if genres else None, sources_json, profile_id
))
conn.commit()
return True
except Exception as e:
logger.error(f"Error upserting liked artist '{artist_name}': {e}")
return False
def get_liked_artists(self, profile_id: int = 1, limit: int = None,
random: bool = False, matched_only: bool = True,
page: int = 1, per_page: int = 50,
search: str = None, source_filter: str = None,
sort: str = 'name',
require_source_id: str = None,
require_image: bool = False) -> dict:
"""Get liked artists from the pool. Returns {artists: [...], total: N}.
require_source_id: column name like 'spotify_artist_id' only return artists with this ID set.
require_image: if True, only return artists with a non-empty image_url."""
try:
conn = self._get_connection()
cursor = conn.cursor()
where = ["profile_id = ?"]
params = [profile_id]
if matched_only:
where.append("match_status = 'matched'")
if require_source_id:
where.append(f"{require_source_id} IS NOT NULL AND {require_source_id} != ''")
if require_image:
where.append("image_url IS NOT NULL AND image_url != ''")
if search:
where.append("artist_name LIKE ? COLLATE NOCASE")
params.append(f"%{search}%")
if source_filter:
where.append("source_services LIKE ?")
params.append(f'%"{source_filter}"%')
where_clause = " AND ".join(where)
cursor.execute(f"SELECT COUNT(*) FROM liked_artists_pool WHERE {where_clause}", params)
total = cursor.fetchone()[0]
order = "RANDOM()" if random else {
'name': 'artist_name COLLATE NOCASE',
'recent': 'created_at DESC',
'source': 'source_services, artist_name COLLATE NOCASE'
}.get(sort, 'artist_name COLLATE NOCASE')
query_limit = limit if limit else per_page
offset = (page - 1) * per_page if not limit else 0
cursor.execute(f"""
SELECT * FROM liked_artists_pool
WHERE {where_clause}
ORDER BY {order}
LIMIT ? OFFSET ?
""", params + [query_limit, offset])
import json
artists = []
for r in cursor.fetchall():
d = dict(r)
d['source_services'] = json.loads(d['source_services'] or '[]')
d['genres'] = json.loads(d['genres']) if d['genres'] else []
artists.append(d)
return {'artists': artists, 'total': total}
except Exception as e:
logger.error(f"Error getting liked artists: {e}")
return {'artists': [], 'total': 0}
def get_liked_artists_last_fetch(self, profile_id: int = 1):
"""Get the most recent fetch timestamp."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT MAX(last_fetched_at) FROM liked_artists_pool WHERE profile_id = ?",
(profile_id,)
)
row = cursor.fetchone()
return row[0] if row and row[0] else None
except Exception:
return None
def update_liked_artist_match(self, pool_id: int, active_source: str = None,
active_source_id: str = None, image_url: str = None,
all_ids: dict = None) -> bool:
"""Mark a liked artist as matched. Stores all discovered source IDs, not just active.
all_ids: optional dict like {'spotify_artist_id': '...', 'itunes_artist_id': '...'}"""
try:
conn = self._get_connection()
cursor = conn.cursor()
set_parts = ["match_status = 'matched'", "updated_at = CURRENT_TIMESTAMP"]
params = []
if active_source and active_source_id:
set_parts.append("active_source = ?")
set_parts.append("active_source_id = ?")
params.extend([active_source, active_source_id])
# Store all discovered source IDs (COALESCE preserves existing values)
if all_ids:
for col in ('spotify_artist_id', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id'):
val = all_ids.get(col)
if val:
set_parts.append(f"{col} = COALESCE({col}, ?)")
params.append(str(val))
# Update image — replace if current is NULL or empty string
if image_url:
set_parts.append("image_url = CASE WHEN image_url IS NULL OR image_url = '' THEN ? ELSE image_url END")
params.append(image_url)
params.append(pool_id)
cursor.execute(f"UPDATE liked_artists_pool SET {', '.join(set_parts)} WHERE id = ?", params)
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating liked artist match: {e}")
return False
def sync_liked_artists_watchlist_flags(self, profile_id: int = 1) -> int:
"""Batch-update on_watchlist flags by checking against watchlist_artists.
Uses case-insensitive artist_name comparison (not normalized_name) to avoid
normalization mismatches like 'The Beatles' vs 'beatles'."""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Reset all, then set matches
cursor.execute(
"UPDATE liked_artists_pool SET on_watchlist = 0 WHERE profile_id = ?",
(profile_id,)
)
cursor.execute("""
UPDATE liked_artists_pool SET on_watchlist = 1
WHERE profile_id = ? AND EXISTS (
SELECT 1 FROM watchlist_artists wa
WHERE wa.profile_id = liked_artists_pool.profile_id
AND wa.artist_name = liked_artists_pool.artist_name COLLATE NOCASE
)
""", (profile_id,))
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error(f"Error syncing liked artists watchlist flags: {e}")
return 0
def get_liked_artists_pending_match(self, profile_id: int = 1, limit: int = 50) -> list:
"""Get artists that haven't been matched to the active source yet."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM liked_artists_pool
WHERE profile_id = ? AND match_status = 'pending'
ORDER BY created_at
LIMIT ?
""", (profile_id, limit))
import json
return [dict(r) for r in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting pending liked artists: {e}")
return []
def clear_liked_artists(self, profile_id: int = 1) -> int:
"""Clear all liked artists for a profile."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM liked_artists_pool WHERE profile_id = ?", (profile_id,))
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error(f"Error clearing liked artists: {e}")
return 0
# ==================== Track Download Provenance Methods ====================
def record_track_download(self, file_path: str, source_service: str, source_username: str,

View file

@ -6632,6 +6632,100 @@ def tidal_callback():
return f"<h1>❌ An Error Occurred</h1><p>An unexpected error occurred during the authentication process: {e}</p>", 500
# --- Deezer OAuth ---
@app.route('/auth/deezer')
def auth_deezer():
"""Initialize Deezer OAuth flow. Redirects user to Deezer authorization page."""
try:
app_id = config_manager.get('deezer.app_id', '')
redirect_uri = config_manager.get('deezer.redirect_uri', 'http://127.0.0.1:8008/deezer/callback')
if not app_id:
return "<h1>Deezer App ID not configured</h1><p>Go to Settings → Connections and enter your Deezer App ID first.</p>", 400
perms = 'basic_access,email,offline_access,manage_library,listening_history'
import urllib.parse
auth_url = f"https://connect.deezer.com/oauth/auth.php?app_id={app_id}&redirect_uri={urllib.parse.quote(redirect_uri)}&perms={perms}"
host = request.host.split(':')[0]
return f"""
<html><body style="font-family:system-ui;max-width:600px;margin:40px auto;padding:20px;background:#111;color:#eee">
<h1>🎵 Deezer Authorization</h1>
<p>Click the link below to authorize SoulSync with your Deezer account:</p>
<p><a href="{auth_url}" style="color:#A238FF;font-size:18px">Authorize on Deezer </a></p>
<hr style="border-color:#333">
<p style="color:#888;font-size:13px">If running remotely, replace <code>127.0.0.1</code> in the redirect URI with <code>{host}</code></p>
</body></html>
"""
except Exception as e:
return f"<h1>Error</h1><p>{e}</p>", 500
@app.route('/deezer/callback')
def deezer_callback():
"""Handle Deezer OAuth callback — exchange code for access token."""
auth_code = request.args.get('code')
error_reason = request.args.get('error_reason', '')
if not auth_code:
return f"<h1>Deezer Authentication Failed</h1><p>{error_reason or 'No authorization code received.'}</p>", 400
try:
app_id = config_manager.get('deezer.app_id', '')
app_secret = config_manager.get('deezer.app_secret', '')
if not app_id or not app_secret:
return "<h1>Missing Credentials</h1><p>Deezer App ID or Secret not configured.</p>", 400
# Exchange code for token — simple GET request (Deezer's unique approach)
token_url = f"https://connect.deezer.com/oauth/access_token.php?app_id={app_id}&secret={app_secret}&code={auth_code}"
resp = requests.get(token_url, timeout=15)
if resp.status_code != 200:
return f"<h1>Token Exchange Failed</h1><p>Deezer returned status {resp.status_code}</p>", 400
# Deezer returns: access_token=TOKEN&expires=SECONDS (URL-encoded, not JSON)
import urllib.parse
token_data = dict(urllib.parse.parse_qsl(resp.text))
access_token = token_data.get('access_token')
if not access_token:
# Try JSON format (some Deezer API versions)
try:
json_data = resp.json()
access_token = json_data.get('access_token')
except Exception:
pass
if not access_token:
return f"<h1>No Access Token</h1><p>Deezer response: {resp.text[:200]}</p>", 400
# Save token to config (encrypted at rest)
config_manager.set('deezer.access_token', access_token)
# Reload the global deezer client to pick up the token
global deezer_client
if deezer_client:
deezer_client.reload_config()
else:
from core.deezer_client import DeezerClient
deezer_client = DeezerClient()
add_activity_item("", "Deezer Auth Complete", "Deezer account connected via OAuth", "Now")
logger.info("Deezer OAuth authentication successful")
return """
<html><body style="font-family:system-ui;max-width:600px;margin:40px auto;padding:20px;background:#111;color:#eee;text-align:center">
<h1> Deezer Authentication Successful!</h1>
<p>Your Deezer account is now connected. You can close this window.</p>
</body></html>
"""
except Exception as e:
logger.error(f"Deezer OAuth callback error: {e}")
return f"<h1>Error</h1><p>{e}</p>", 500
# --- Beatport Data API ---
@app.route('/api/beatport/hero-tracks')
@ -40366,6 +40460,599 @@ def remove_discovery_artist_blacklist(blacklist_id):
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# ── Your Artists (Liked Artists Pool) ──
@app.route('/api/discover/your-artists', methods=['GET'])
def get_your_artists():
"""Get liked artists for the Discover carousel (20 random matched on active source)."""
try:
database = get_database()
profile_id = get_current_profile_id()
# Determine active source column — only show artists with THIS source's ID
active_source = 'spotify'
if spotify_client and spotify_client.is_spotify_authenticated():
active_source = 'spotify'
else:
fb = _get_metadata_fallback_source()
if fb:
active_source = fb
active_col = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}.get(active_source, 'spotify_artist_id')
# Check if refresh needed (>24h stale or empty)
last_fetch = database.get_liked_artists_last_fetch(profile_id)
stale = True
if last_fetch:
from datetime import datetime, timedelta
try:
if isinstance(last_fetch, str):
last_dt = datetime.fromisoformat(last_fetch.replace('Z', '+00:00'))
else:
last_dt = last_fetch
stale = (datetime.now() - last_dt.replace(tzinfo=None)) > timedelta(hours=24)
except Exception:
stale = True
if stale:
_trigger_your_artists_refresh(profile_id)
database.sync_liked_artists_watchlist_flags(profile_id)
# Only return artists matched to the active source
result = database.get_liked_artists(
profile_id=profile_id, limit=20, random=True, matched_only=True,
require_source_id=active_col
)
result['stale'] = stale
result['success'] = True
result['active_source'] = active_source
return jsonify(result)
except Exception as e:
logger.error(f"Error getting your artists: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/your-artists/all', methods=['GET'])
def get_your_artists_all():
"""Get all liked artists for the View All modal (paginated)."""
try:
database = get_database()
profile_id = get_current_profile_id()
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 50))
search = request.args.get('search', '').strip()
source_filter = request.args.get('source', '').strip()
sort = request.args.get('sort', 'name')
# Same active source filtering as carousel
active_source = 'spotify'
if spotify_client and spotify_client.is_spotify_authenticated():
active_source = 'spotify'
else:
fb = _get_metadata_fallback_source()
if fb:
active_source = fb
active_col = {'spotify': 'spotify_artist_id', 'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id', 'discogs': 'discogs_artist_id'}.get(active_source, 'spotify_artist_id')
database.sync_liked_artists_watchlist_flags(profile_id)
result = database.get_liked_artists(
profile_id=profile_id, matched_only=True,
page=page, per_page=per_page,
search=search, source_filter=source_filter or None,
sort=sort, require_source_id=active_col
)
result['success'] = True
result['active_source'] = active_source
return jsonify(result)
except Exception as e:
logger.error(f"Error getting all your artists: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/your-artists/refresh', methods=['POST'])
def refresh_your_artists():
"""Force-trigger a fetch + match cycle for liked artists. ?clear=true wipes pool first."""
try:
profile_id = get_current_profile_id()
if request.args.get('clear', '').lower() == 'true':
database = get_database()
cleared = database.clear_liked_artists(profile_id)
print(f"🎵 [Your Artists] Cleared {cleared} entries before refresh")
_trigger_your_artists_refresh(profile_id)
return jsonify({"success": True, "message": "Refresh started"})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
_your_artists_refresh_lock = threading.Lock()
_your_artists_refreshing = False
def _trigger_your_artists_refresh(profile_id: int):
"""Start background fetch + match if not already running."""
global _your_artists_refreshing
if _your_artists_refreshing:
return
with _your_artists_refresh_lock:
if _your_artists_refreshing:
return
_your_artists_refreshing = True
def _run():
global _your_artists_refreshing
try:
_fetch_and_match_liked_artists(profile_id)
except Exception as e:
logger.error(f"Your artists refresh failed: {e}")
import traceback
traceback.print_exc()
finally:
_your_artists_refreshing = False
threading.Thread(target=_run, daemon=True, name="YourArtistsRefresh").start()
def _fetch_and_match_liked_artists(profile_id: int):
"""Background worker: fetch from services, deduplicate, match to active source."""
database = get_database()
fetched = 0
# 1. Fetch from Spotify (followed artists)
try:
if spotify_client and spotify_client.is_spotify_authenticated():
print("🎵 [Your Artists] Fetching followed artists from Spotify...")
artists = spotify_client.get_followed_artists()
for a in artists:
database.upsert_liked_artist(
artist_name=a['name'], source_service='spotify',
source_id=a['spotify_id'], source_id_type='spotify',
image_url=a.get('image_url'), genres=a.get('genres'),
profile_id=profile_id
)
fetched += len(artists)
print(f"🎵 [Your Artists] Fetched {len(artists)} from Spotify")
except Exception as e:
logger.error(f"[Your Artists] Spotify fetch error: {e}")
# 2. Fetch from Tidal (favorite artists)
try:
if tidal_client and hasattr(tidal_client, 'get_favorite_artists'):
tidal_auth = tidal_client._ensure_valid_token() if hasattr(tidal_client, '_ensure_valid_token') else False
if tidal_auth:
print("🎵 [Your Artists] Fetching favorite artists from Tidal...")
artists = tidal_client.get_favorite_artists(limit=200)
for a in artists:
database.upsert_liked_artist(
artist_name=a['name'], source_service='tidal',
image_url=a.get('image_url'), profile_id=profile_id
)
fetched += len(artists)
print(f"🎵 [Your Artists] Fetched {len(artists)} from Tidal")
except Exception as e:
logger.error(f"[Your Artists] Tidal fetch error: {e}")
# 3. Fetch from Last.fm (top artists)
try:
lastfm_key = config_manager.get('lastfm.api_key', '')
lastfm_secret = config_manager.get('lastfm.api_secret', '')
lastfm_session = config_manager.get('lastfm.session_key', '')
print(f"🎵 [Your Artists] Last.fm credentials: key={'yes' if lastfm_key else 'NO'}, secret={'yes' if lastfm_secret else 'NO'}, session={'yes' if lastfm_session else 'NO'}")
if lastfm_key and lastfm_secret and lastfm_session:
from core.lastfm_client import LastFMClient
lfm = LastFMClient(api_key=lastfm_key, api_secret=lastfm_secret, session_key=lastfm_session)
username = lfm.get_authenticated_username()
print(f"🎵 [Your Artists] Last.fm username resolved: {username or 'NONE'}")
if username:
print(f"🎵 [Your Artists] Fetching top artists from Last.fm ({username})...")
artists = lfm.get_user_top_artists(username, period='overall', limit=200)
for a in artists:
database.upsert_liked_artist(
artist_name=a['name'], source_service='lastfm',
image_url=a.get('image_url'), profile_id=profile_id
)
fetched += len(artists)
print(f"🎵 [Your Artists] Fetched {len(artists)} from Last.fm")
except Exception as e:
logger.error(f"[Your Artists] Last.fm fetch error: {e}")
# 4. Fetch from Deezer (favorite artists — requires OAuth)
try:
deezer_cl = _get_deezer_client()
if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated():
print("🎵 [Your Artists] Fetching favorite artists from Deezer...")
artists = deezer_cl.get_user_favorite_artists(limit=200)
for a in artists:
database.upsert_liked_artist(
artist_name=a['name'], source_service='deezer',
source_id=a.get('deezer_id'), source_id_type='deezer',
image_url=a.get('image_url'), profile_id=profile_id
)
fetched += len(artists)
print(f"🎵 [Your Artists] Fetched {len(artists)} from Deezer")
except Exception as e:
logger.error(f"[Your Artists] Deezer fetch error: {e}")
print(f"🎵 [Your Artists] Total fetched: {fetched}")
# 5. Match pending artists to active source
_match_liked_artists_to_all_sources(database, profile_id)
def _match_liked_artists_to_all_sources(database, profile_id: int):
"""Match pending liked artists to ALL metadata sources (Spotify, iTunes, Deezer, Discogs).
Uses the same matching pattern as the watchlist scanner: DB-first, then API search
with fuzzy name matching. Stores all resolved IDs so source switching works instantly."""
pending = database.get_liked_artists_pending_match(profile_id, limit=200)
if not pending:
return
# Source → column mapping
source_cols = {
'spotify': 'spotify_artist_id',
'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id',
}
id_cols = list(source_cols.values())
# Reject known placeholder images and local server paths
_placeholder_hashes = {'2a96cbd8b46e442fc41c2b86b821562f'}
def _valid_image(url):
if not url or not url.strip():
return None
if any(ph in url for ph in _placeholder_hashes):
return None
# Reject local media server paths (Plex/Jellyfin) — not loadable in browser
if url.startswith('/') or url.startswith('\\'):
return None
if not url.startswith('http'):
return None
return url
# Build search clients for each source
from core.deezer_client import DeezerClient
search_clients = {}
if spotify_client and spotify_client.is_spotify_authenticated():
search_clients['spotify'] = spotify_client
try:
from core.itunes_client import iTunesClient
search_clients['itunes'] = iTunesClient()
except Exception:
pass
try:
search_clients['deezer'] = DeezerClient()
except Exception:
pass
try:
from core.discogs_client import DiscogsClient
dc = DiscogsClient()
# Only use Discogs if token is configured
from config.settings import config_manager as _cm
if _cm.get('discogs.token', ''):
search_clients['discogs'] = dc
except Exception:
pass
# Reuse watchlist scanner's fuzzy matching logic
from core.watchlist_scanner import WatchlistScanner
_normalize = WatchlistScanner._normalize_artist_name
def _best_match(results, artist_name):
"""Pick best match from search results using name similarity (same as watchlist scanner)."""
if not results:
return None
# Exact normalized match
for r in results:
if _normalize(r.name) == _normalize(artist_name):
return r
# Fuzzy scoring
best = None
best_sim = 0
for r in results:
# Simple normalized comparison
n1 = _normalize(artist_name)
n2 = _normalize(r.name)
if n1 == n2:
return r
# Levenshtein-style similarity
max_len = max(len(n1), len(n2))
if max_len == 0:
continue
distance = sum(1 for a, b in zip(n1, n2) if a != b) + abs(len(n1) - len(n2))
sim = (max_len - distance) / max_len
if sim > best_sim:
best_sim = sim
best = r
if best and best_sim >= 0.85:
return best
return None
api_calls = 0
matched = 0
for entry in pending:
name = entry['artist_name']
pool_id = entry['id']
harvested_ids = {}
best_image = None
# Pre-load existing IDs from the entry itself
for col in id_cols:
if entry.get(col):
harvested_ids[col] = entry[col]
# --- DB STRATEGIES (free, no API calls) ---
# 1. Library artists table
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (name,))
row = cursor.fetchone()
if row:
r = dict(row)
for col in id_cols:
if r.get(col) and col not in harvested_ids:
harvested_ids[col] = str(r[col])
if _valid_image(r.get('thumb_url')):
best_image = r['thumb_url']
except Exception:
pass
# 2. Watchlist artists
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE AND profile_id = ? LIMIT 1",
(name, profile_id)
)
row = cursor.fetchone()
if row:
wl = dict(row)
for col in id_cols:
if wl.get(col) and col not in harvested_ids:
harvested_ids[col] = str(wl[col])
if _valid_image(wl.get('image_url')) and not best_image:
best_image = wl['image_url']
except Exception:
pass
# 3. Metadata cache (all sources)
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT entity_id, source, image_url FROM metadata_cache_entities WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE",
(name,)
)
for row in cursor.fetchall():
col = source_cols.get(row['source'])
if col and col not in harvested_ids:
harvested_ids[col] = row['entity_id']
if _valid_image(row['image_url']) and not best_image:
best_image = row['image_url']
except Exception:
pass
# --- API STRATEGIES (search each missing source) ---
# Same pattern as watchlist scanner's _backfill_missing_ids
for source, col in source_cols.items():
if col in harvested_ids:
continue # Already have this source's ID
client = search_clients.get(source)
if not client:
continue
if api_calls >= 200: # Hard cap per refresh cycle
break
try:
results = client.search_artists(name, limit=5)
best = _best_match(results, name)
if best:
harvested_ids[col] = best.id
if hasattr(best, 'image_url') and _valid_image(best.image_url) and not best_image:
best_image = best.image_url
api_calls += 1
time.sleep(0.4) # Rate limit breathing room
except Exception as e:
logger.debug(f"[Your Artists] {source} search failed for '{name}': {e}")
api_calls += 1
# Save all harvested IDs
if harvested_ids:
# Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs
resolved_source = None
resolved_id = None
for src in ('spotify', 'itunes', 'deezer', 'discogs'):
col = source_cols[src]
if col in harvested_ids:
resolved_source = src
resolved_id = harvested_ids[col]
break
database.update_liked_artist_match(
pool_id, active_source=resolved_source, active_source_id=resolved_id,
image_url=best_image, all_ids=harvested_ids
)
matched += 1
database.sync_liked_artists_watchlist_flags(profile_id)
print(f"🎵 [Your Artists] Matched {matched}/{len(pending)} artists to {len(search_clients)} sources ({api_calls} API calls)")
# Image backfill: fetch images for matched artists that have IDs but no image
_backfill_liked_artist_images(database, profile_id, search_clients)
def _backfill_liked_artist_images(database, profile_id: int, search_clients: dict):
"""Fetch images for matched artists missing artwork using their stored source IDs."""
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id
FROM liked_artists_pool
WHERE profile_id = ? AND match_status = 'matched'
AND (image_url IS NULL OR image_url = ''
OR image_url LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'
OR image_url NOT LIKE 'http%')
LIMIT 100
""", (profile_id,))
rows = cursor.fetchall()
if not rows:
return
print(f"🖼️ [Your Artists] Backfilling images for {len(rows)} artists...")
filled = 0
for row in rows:
r = dict(row)
image_url = None
# Try Spotify artist lookup (has best images)
if r.get('spotify_artist_id') and 'spotify' in search_clients:
try:
sp = search_clients['spotify']
if hasattr(sp, 'sp') and sp.sp:
artist_data = sp.sp.artist(r['spotify_artist_id'])
if artist_data and artist_data.get('images'):
image_url = artist_data['images'][0]['url']
except Exception:
pass
# Try Deezer (direct image URL from ID)
if not image_url and r.get('deezer_artist_id'):
image_url = f"https://api.deezer.com/artist/{r['deezer_artist_id']}/image?size=big"
if image_url:
try:
cursor2 = conn.cursor()
cursor2.execute(
"UPDATE liked_artists_pool SET image_url = ? WHERE id = ?",
(image_url, r['id'])
)
filled += 1
except Exception:
pass
time.sleep(0.3)
conn.commit()
if filled:
print(f"🖼️ [Your Artists] Backfilled {filled}/{len(rows)} artist images")
except Exception as e:
logger.debug(f"[Your Artists] Image backfill error: {e}")
@app.route('/api/discover/your-artists/info/<artist_id>', methods=['GET'])
def get_your_artist_info(artist_id):
"""Get artist info for the Your Artists info modal. Checks library, cache, then API."""
try:
artist_name = request.args.get('name', '')
result = {'name': artist_name, 'success': True}
# 1. Try library DB (has enrichment data)
try:
database = get_database()
conn = database._get_connection()
cursor = conn.cursor()
# Check by various ID columns
cursor.execute("""
SELECT * FROM artists WHERE id = ? OR spotify_artist_id = ? OR itunes_artist_id = ?
OR deezer_id = ? OR discogs_id = ? LIMIT 1
""", (artist_id, artist_id, artist_id, artist_id, artist_id))
row = cursor.fetchone()
if row:
r = dict(row)
result.update({
'name': r.get('name', artist_name),
'genres': json.loads(r['genres']) if r.get('genres') else [],
'summary': r.get('summary', ''),
'image_url': r.get('thumb_url', ''),
'spotify_artist_id': r.get('spotify_artist_id'),
'musicbrainz_id': r.get('musicbrainz_id'),
'deezer_id': r.get('deezer_id'),
'itunes_artist_id': r.get('itunes_artist_id'),
'discogs_id': r.get('discogs_id'),
'lastfm_url': r.get('lastfm_url'),
'tidal_id': r.get('tidal_id'),
'lastfm_listeners': r.get('lastfm_listeners', 0),
'lastfm_playcount': r.get('lastfm_playcount', 0),
})
return jsonify(result)
except Exception:
pass
# 2. Try metadata cache
try:
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT raw_json, image_url FROM metadata_cache_entities
WHERE entity_type = 'artist' AND entity_id = ? LIMIT 1
""", (artist_id,))
row = cursor.fetchone()
if row and row['raw_json']:
cached = json.loads(row['raw_json'])
result.update({
'name': cached.get('name', artist_name),
'genres': cached.get('genres', []),
'image_url': row['image_url'] or cached.get('image_url', ''),
'popularity': cached.get('popularity', 0),
'followers': cached.get('followers', {}).get('total', 0) if isinstance(cached.get('followers'), dict) else cached.get('followers', 0),
})
return jsonify(result)
except Exception:
pass
# 3. Try Spotify API directly (genres, image, followers)
try:
if spotify_client and spotify_client.is_spotify_authenticated() and not artist_id.isdigit():
artist_data = spotify_client.sp.artist(artist_id)
if artist_data:
result.update({
'name': artist_data.get('name', artist_name),
'genres': artist_data.get('genres', []),
'image_url': artist_data['images'][0]['url'] if artist_data.get('images') else '',
'spotify_artist_id': artist_data.get('id'),
'popularity': artist_data.get('popularity', 0),
'followers': artist_data.get('followers', {}).get('total', 0),
})
except Exception as e:
logger.debug(f"Spotify artist lookup failed for {artist_id}: {e}")
# 4. Last.fm: bio, listeners, playcount (always try — has the best artist bios)
try:
_lfm_name = result.get('name') or artist_name
if _lfm_name and lastfm_worker and lastfm_worker.client:
lfm_info = lastfm_worker.client.get_artist_info(_lfm_name)
if lfm_info:
bio = lfm_info.get('bio', {})
if isinstance(bio, dict):
summary = bio.get('summary', '')
else:
summary = str(bio) if bio else ''
if summary and not result.get('summary'):
result['summary'] = summary
stats = lfm_info.get('stats', {})
if stats:
result['lastfm_listeners'] = int(stats.get('listeners', 0))
result['lastfm_playcount'] = int(stats.get('playcount', 0))
if not result.get('genres'):
tags = lfm_info.get('tags', {}).get('tag', [])
if tags:
result['genres'] = [t.get('name', '') for t in tags[:5] if isinstance(t, dict)]
lfm_url = lfm_info.get('url')
if lfm_url:
result['lastfm_url'] = lfm_url
except Exception as e:
logger.debug(f"Last.fm artist info failed for {artist_name}: {e}")
# 5. Return combined info
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/build-playlist/search-artists', methods=['GET'])
def search_artists_for_playlist():
"""Search for artists to use as seeds for custom playlist building"""

View file

@ -3063,6 +3063,28 @@
</div>
</div>
<!-- Your Artists Section -->
<div class="discover-section" id="your-artists-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Your Artists</h2>
<p class="discover-section-subtitle" id="your-artists-subtitle">Artists you follow across your music services</p>
</div>
<div class="discover-section-actions">
<button class="ya-header-btn ya-refresh-btn" id="your-artists-refresh-btn" onclick="refreshYourArtists()" title="Refresh from services">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button class="ya-header-btn ya-viewall-btn" onclick="openYourArtistsModal()">
<span>View All</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
<div class="discover-carousel" id="your-artists-carousel">
<!-- Populated by JS -->
</div>
</div>
<!-- Spotify Library Section -->
<div class="discover-section" id="spotify-library-section" style="display: none;">
<div class="discover-section-header">
@ -3881,6 +3903,34 @@
</div>
<!-- Deezer OAuth Auth -->
<div class="api-service-frame">
<h4 class="service-title deezer-title">Deezer (Favorites & Playlists)</h4>
<div class="form-group">
<label>App ID:</label>
<input type="text" id="deezer-app-id" placeholder="Deezer App ID">
</div>
<div class="form-group">
<label>App Secret:</label>
<input type="password" id="deezer-app-secret" placeholder="Deezer App Secret">
</div>
<div class="form-group">
<label>Redirect URI:</label>
<input type="text" id="deezer-redirect-uri"
placeholder="http://127.0.0.1:8008/deezer/callback">
</div>
<div class="callback-info">
<div class="callback-label">Current Redirect URI:</div>
<div class="callback-url" id="deezer-callback-display">
http://127.0.0.1:8008/deezer/callback</div>
<div class="callback-help">Add this URL to your Deezer app at developers.deezer.com</div>
</div>
<div class="form-actions">
<button class="auth-button" onclick="authenticateDeezer()">🔐
Authenticate</button>
</div>
</div>
<!-- Qobuz Metadata/Enrichment Auth -->
<div class="api-service-frame">
<h4 class="service-title qobuz-title">Qobuz (Metadata & Enrichment)</h4>

View file

@ -3403,6 +3403,10 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.2': [
// Newest features first
{ title: 'Your Artists on Discover', desc: 'Aggregates liked/followed artists from Spotify, Tidal, Last.fm, and Deezer. Auto-matched to all metadata sources. Click for artist info modal with bio, genres, stats, and watchlist toggle', page: 'discover' },
{ title: 'Deezer OAuth', desc: 'Full Deezer OAuth integration for user favorites and playlists. Configure in Settings → Connections' },
{ title: 'Failed MB Lookups Manager', desc: 'Browse, search, and manually match failed MusicBrainz lookups from the Cache Health modal. Search MusicBrainz directly and save matches' },
{ title: 'Explorer: Discover + Badges + Auto-Refresh', desc: 'Trigger discovery directly from Explorer, status badges (explored/wishlisted/downloaded), auto-refresh when discovery completes', page: 'playlist-explorer' },
{ title: 'Fix Album Folder Splitting', desc: 'Collab albums and artist name changes no longer scatter tracks across multiple folders — $albumartist now uses album-level artist consistently' },
{ title: 'Fix Watchlist Rate Limiting', desc: 'Watchlist scans now fetch only newest albums instead of full discography (~90% fewer API calls). Configurable API interval in settings. Better Retry-After header extraction' },
{ title: 'Discogs Integration', desc: 'New metadata source — enrichment worker, fallback source, enhanced search tab, watchlist support, cache browser. Genres, styles, labels, bios, ratings from 400+ taxonomy', page: 'dashboard' },

View file

@ -5666,12 +5666,18 @@ async function loadSettingsData() {
document.getElementById('spotify-redirect-uri').value = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback';
document.getElementById('spotify-callback-display').textContent = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback';
// Populate Tidal settings
// Populate Tidal settings
document.getElementById('tidal-client-id').value = settings.tidal?.client_id || '';
document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || '';
document.getElementById('tidal-redirect-uri').value = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback';
document.getElementById('tidal-callback-display').textContent = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback';
// Populate Deezer OAuth settings
document.getElementById('deezer-app-id').value = settings.deezer?.app_id || '';
document.getElementById('deezer-app-secret').value = settings.deezer?.app_secret || '';
document.getElementById('deezer-redirect-uri').value = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback';
document.getElementById('deezer-callback-display').textContent = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback';
// Add event listeners to update display URLs when input changes
document.getElementById('spotify-redirect-uri').addEventListener('input', function () {
document.getElementById('spotify-callback-display').textContent = this.value || 'http://127.0.0.1:8888/callback';
@ -5681,6 +5687,10 @@ async function loadSettingsData() {
document.getElementById('tidal-callback-display').textContent = this.value || 'http://127.0.0.1:8889/tidal/callback';
});
document.getElementById('deezer-redirect-uri').addEventListener('input', function () {
document.getElementById('deezer-callback-display').textContent = this.value || 'http://127.0.0.1:8008/deezer/callback';
});
// Populate Plex settings
document.getElementById('plex-url').value = settings.plex?.base_url || '';
document.getElementById('plex-token').value = settings.plex?.token || '';
@ -6868,6 +6878,9 @@ async function saveSettings(quiet = false) {
tags: _collectServiceTags('musicbrainz')
},
deezer: {
app_id: document.getElementById('deezer-app-id').value,
app_secret: document.getElementById('deezer-app-secret').value,
redirect_uri: document.getElementById('deezer-redirect-uri').value,
embed_tags: document.getElementById('embed-deezer').checked,
tags: _collectServiceTags('deezer')
},
@ -7518,6 +7531,20 @@ async function authenticateTidal() {
}
}
async function authenticateDeezer() {
try {
showLoadingOverlay('Saving credentials and starting Deezer authentication...');
await saveSettings();
showToast('Deezer authentication started', 'success');
window.open('/auth/deezer', '_blank');
} catch (error) {
console.error('Error authenticating Deezer:', error);
showToast('Failed to start Deezer authentication', 'error');
} finally {
hideLoadingOverlay();
}
}
// ===== Tidal Download Auth (Device Flow) =====
async function testHiFiConnection() {
@ -50990,6 +51017,7 @@ async function loadDiscoverPage() {
// Load all sections
await Promise.all([
loadDiscoverHero(),
loadYourArtists(),
loadSpotifyLibrarySection(),
loadDiscoverRecentReleases(),
loadSeasonalContent(), // Seasonal discovery
@ -54933,6 +54961,465 @@ async function unblockDiscoveryArtist(id, name) {
}
// Backwards compat — called during page init but now a no-op (modal handles it)
// ── Your Artists (Liked Artists Pool) ──
async function loadYourArtists() {
const section = document.getElementById('your-artists-section');
const carousel = document.getElementById('your-artists-carousel');
const subtitle = document.getElementById('your-artists-subtitle');
if (!section || !carousel) return;
try {
const resp = await fetch('/api/discover/your-artists');
if (!resp.ok) return;
const data = await resp.json();
if (!data.artists || data.artists.length === 0) {
if (data.stale) {
// First load — show section with loading state, poll until ready
section.style.display = '';
if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...';
carousel.innerHTML = `
<div class="ya-loading">
<div class="watch-all-loading-spinner"></div>
<span>Fetching and matching artists from your services...</span>
</div>
`;
_pollYourArtists();
} else {
section.style.display = 'none';
}
return;
}
// Show section
section.style.display = '';
// Update subtitle with source info
const sources = new Set();
data.artists.forEach(a => (a.source_services || []).forEach(s => sources.add(s)));
const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' };
const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and ');
if (subtitle) subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`;
if (data.stale) {
if (subtitle) subtitle.textContent += ' (updating...)';
_pollYourArtists();
}
// Store for modal access and render carousel cards
window._yaArtists = {};
data.artists.forEach(a => { window._yaArtists[a.id] = a; });
carousel.innerHTML = data.artists.map(a => _renderYourArtistCard(a)).join('');
} catch (err) {
console.error('Error loading Your Artists:', err);
}
}
function _pollYourArtists() {
// Poll every 5s until artists appear, then stop
if (window._yaPoller) clearInterval(window._yaPoller);
let attempts = 0;
window._yaPoller = setInterval(async () => {
attempts++;
if (attempts > 60) { clearInterval(window._yaPoller); window._yaPoller = null; return; }
try {
const resp = await fetch('/api/discover/your-artists');
if (!resp.ok) return;
const data = await resp.json();
if (data.artists && data.artists.length > 0) {
clearInterval(window._yaPoller);
window._yaPoller = null;
loadYourArtists(); // Re-render with real data
}
} catch (e) {}
}, 5000);
}
function _renderYourArtistCard(artist) {
const _esc = (s) => escapeHtml(s || '');
const img = artist.image_url || '';
// Build metadata source badges (same pattern as library page)
const badges = [];
if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify' });
if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music' });
if (artist.deezer_artist_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer' });
if (artist.discogs_artist_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs' });
const badgeHTML = badges.map(b =>
`<div class="ya-badge" title="${b.title}">${b.logo ? `<img src="${b.logo}" onerror="this.parentNode.textContent='${b.fb}'">` : `<span>${b.fb}</span>`}</div>`
).join('');
// Origin dots (which services the artist came from)
const sources = artist.source_services || [];
const sourceColors = { spotify: '#1DB954', lastfm: '#D51007', tidal: '#00FFFF', deezer: '#A238FF' };
const originDots = sources.map(s =>
`<span class="ya-origin-dot" style="background:${sourceColors[s] || '#666'}" title="From ${s}"></span>`
).join('');
const watchlistClass = artist.on_watchlist ? 'active' : '';
const hasId = artist.active_source_id && artist.active_source_id !== '';
// Navigate to artist page (name click)
const navAction = hasId
? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)`
: '';
// Open info modal (card body click) — pass pool ID so we can look up all data
const infoAction = hasId
? `openYourArtistInfoModal(${artist.id})`
: '';
// Deezer fallback for images
const deezerFb = artist.deezer_artist_id ? `onerror="if(!this.dataset.tried){this.dataset.tried='1';this.src='https://api.deezer.com/artist/${artist.deezer_artist_id}/image?size=big'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}"` : `onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"`;
return `
<div class="ya-card" ${infoAction ? `onclick="${infoAction}"` : ''}>
<div class="ya-card-img">
${img ? `<img src="${img}" alt="" loading="lazy" ${deezerFb}>` : ''}
<div class="ya-card-placeholder" ${img ? 'style="display:none"' : ''}>&#9835;</div>
</div>
<div class="ya-card-gradient"></div>
<div class="ya-card-badges">${badgeHTML}</div>
<button class="ya-watchlist-btn ${watchlistClass}" title="${artist.on_watchlist ? 'On watchlist' : 'Add to watchlist'}"
onclick="event.stopPropagation(); toggleYourArtistWatchlist(${artist.id}, '${escapeForInlineJs(artist.artist_name)}', '${escapeForInlineJs(artist.active_source_id || '')}', '${escapeForInlineJs(artist.active_source || '')}', this)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="${artist.on_watchlist ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
<div class="ya-card-info">
<div class="ya-card-info-row">
<div class="ya-origin-dots">${originDots}</div>
</div>
<div class="ya-card-name" ${navAction ? `onclick="${navAction}"` : ''}>${_esc(artist.artist_name)}</div>
</div>
</div>
`;
}
async function openYourArtistInfoModal(poolId) {
const pool = (window._yaArtists || {})[poolId];
if (!pool) return;
const artistId = pool.active_source_id;
const artistName = pool.artist_name;
const imageUrl = pool.image_url || '';
const existing = document.getElementById('ya-info-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'ya-info-modal-overlay';
overlay.className = 'modal-overlay';
overlay.style.zIndex = '10001';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
// Build matched source badges from pool data
const _mb = (logo, fb, title) => `<div class="ya-info-badge" title="${title}">${logo ? `<img src="${logo}" onerror="this.parentNode.textContent='${fb}'">` : `<span>${fb}</span>`}</div>`;
const matchBadges = [];
if (pool.spotify_artist_id) matchBadges.push(_mb(SPOTIFY_LOGO_URL, 'SP', 'Matched on Spotify'));
if (pool.itunes_artist_id) matchBadges.push(_mb(ITUNES_LOGO_URL, 'IT', 'Matched on Apple Music'));
if (pool.deezer_artist_id) matchBadges.push(_mb(DEEZER_LOGO_URL, 'Dz', 'Matched on Deezer'));
if (pool.discogs_artist_id) matchBadges.push(_mb(DISCOGS_LOGO_URL, 'DC', 'Matched on Discogs'));
// Origin info
const sources = pool.source_services || [];
const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' };
const originText = sources.map(s => sourceNames[s] || s).join(', ');
overlay.innerHTML = `
<div class="ya-info-modal">
<button class="watch-all-close" onclick="document.getElementById('ya-info-modal-overlay').remove()">&times;</button>
<div class="ya-info-hero">
<div class="ya-info-hero-bg" ${imageUrl ? `style="background-image:url('${escapeHtml(imageUrl)}')"` : ''}></div>
<div class="ya-info-hero-content">
<div class="ya-info-hero-img">
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="">` : '<div class="ya-info-img-fallback">&#9835;</div>'}
</div>
<div class="ya-info-hero-text">
<h2 class="ya-info-name">${escapeHtml(artistName)}</h2>
<div class="ya-info-badges">${matchBadges.join('')}</div>
${originText ? `<div class="ya-info-origin">Followed on ${escapeHtml(originText)}</div>` : ''}
</div>
</div>
</div>
<div class="ya-info-body" id="ya-info-body">
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading artist info...</div></div>
</div>
<div class="ya-info-footer" id="ya-info-footer"></div>
</div>
`;
document.body.appendChild(overlay);
// Fetch enrichment data
try {
const resp = await fetch(`/api/discover/your-artists/info/${artistId}?name=${encodeURIComponent(artistName)}`);
const artist = resp.ok ? await resp.json() : {};
const bodyEl = document.getElementById('ya-info-body');
const footerEl = document.getElementById('ya-info-footer');
const genres = artist.genres || [];
const bio = artist.summary || '';
const listeners = artist.lastfm_listeners || artist.followers || 0;
const playcount = artist.lastfm_playcount || 0;
const popularity = artist.popularity || 0;
let bodyHTML = '';
// Stats
if (listeners || playcount || popularity) {
bodyHTML += `<div class="ya-info-stats">
${listeners ? `<div class="ya-info-stat"><span class="ya-info-stat-value">${Number(listeners).toLocaleString()}</span><span class="ya-info-stat-label">listeners</span></div>` : ''}
${playcount ? `<div class="ya-info-stat"><span class="ya-info-stat-value">${Number(playcount).toLocaleString()}</span><span class="ya-info-stat-label">plays</span></div>` : ''}
${popularity ? `<div class="ya-info-stat"><span class="ya-info-stat-value">${popularity}</span><span class="ya-info-stat-label">popularity</span></div>` : ''}
</div>`;
}
// Genres
if (genres.length > 0) {
bodyHTML += `<div class="ya-info-section">
<div class="ya-info-genres">${genres.map(g => `<span class="ya-info-genre">${escapeHtml(g)}</span>`).join('')}</div>
</div>`;
}
// Bio
if (bio) {
const cleanBio = bio.replace(/<a[^>]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim();
if (cleanBio) {
bodyHTML += `<div class="ya-info-section">
<div class="ya-info-section-title">About</div>
<div class="ya-info-bio">${escapeHtml(cleanBio.length > 600 ? cleanBio.substring(0, 600) + '...' : cleanBio)}</div>
</div>`;
}
}
if (!bodyHTML) bodyHTML = '<div class="ya-info-empty">No additional info available</div>';
if (bodyEl) bodyEl.innerHTML = bodyHTML;
// Footer
if (footerEl) {
const watchBtn = pool.on_watchlist
? `<button class="ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Done'; this.disabled=true">Remove from Watchlist</button>`
: `<button class="ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Added!'; this.disabled=true">Add to Watchlist</button>`;
footerEl.innerHTML = `
${watchBtn}
<button class="ya-header-btn ya-viewall-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artistId)}', name:'${escapeForInlineJs(artistName)}', image_url:'${escapeForInlineJs(imageUrl)}'}), 200)">
<span>View Discography</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
`;
}
} catch (err) {
const bodyEl = document.getElementById('ya-info-body');
if (bodyEl) bodyEl.innerHTML = `<div class="ya-info-empty">Could not load artist info</div>`;
}
}
async function toggleYourArtistWatchlist(poolId, artistName, sourceId, source, btnEl) {
const isWatched = btnEl && btnEl.classList.contains('active');
try {
if (isWatched) {
const resp = await fetch('/api/watchlist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: sourceId })
});
if (resp.ok) {
if (btnEl) {
btnEl.classList.remove('active');
const svg = btnEl.querySelector('svg');
if (svg) svg.setAttribute('fill', 'none');
}
showToast(`Removed ${artistName} from watchlist`, 'info');
// Sync card eye icon
_syncYaCardWatchlist(poolId, false);
}
} else {
const resp = await fetch('/api/watchlist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: sourceId, artist_name: artistName, source: source })
});
if (resp.ok) {
if (btnEl) {
btnEl.classList.add('active');
const svg = btnEl.querySelector('svg');
if (svg) svg.setAttribute('fill', 'currentColor');
}
showToast(`Added ${artistName} to watchlist`, 'success');
_syncYaCardWatchlist(poolId, true);
}
}
} catch (err) {
showToast('Failed to update watchlist', 'error');
}
}
function _syncYaCardWatchlist(poolId, watched) {
// Sync the card's eye icon with watchlist state (covers modal → card sync)
document.querySelectorAll('.ya-card .ya-watchlist-btn').forEach(btn => {
const card = btn.closest('.ya-card');
if (!card) return;
// Match by onclick containing the poolId
const onclick = btn.getAttribute('onclick') || '';
if (onclick.includes(`(${poolId},`)) {
if (watched) {
btn.classList.add('active');
const svg = btn.querySelector('svg');
if (svg) svg.setAttribute('fill', 'currentColor');
} else {
btn.classList.remove('active');
const svg = btn.querySelector('svg');
if (svg) svg.setAttribute('fill', 'none');
}
}
});
// Update pool data
if (window._yaArtists && window._yaArtists[poolId]) {
window._yaArtists[poolId].on_watchlist = watched ? 1 : 0;
}
}
async function refreshYourArtists() {
const btn = document.getElementById('your-artists-refresh-btn');
if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; }
const subtitle = document.getElementById('your-artists-subtitle');
if (subtitle) subtitle.textContent = 'Refreshing from your services...';
try {
await fetch('/api/discover/your-artists/refresh?clear=true', { method: 'POST' });
// Poll until done
let attempts = 0;
const poll = setInterval(async () => {
attempts++;
if (attempts > 60) { clearInterval(poll); return; } // 5 min max
try {
const resp = await fetch('/api/discover/your-artists');
const data = await resp.json();
if (!data.stale && data.artists && data.artists.length > 0) {
clearInterval(poll);
loadYourArtists();
if (btn) { btn.disabled = false; btn.style.opacity = ''; }
showToast(`Found ${data.total} artists from your services`, 'success');
}
} catch (e) {}
}, 5000);
} catch (err) {
showToast('Failed to start refresh', 'error');
if (btn) { btn.disabled = false; btn.style.opacity = ''; }
}
}
async function openYourArtistsModal() {
const existing = document.getElementById('your-artists-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'your-artists-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div class="ya-modal">
<div class="ya-modal-header">
<div>
<h2 class="ya-modal-title">Your Artists</h2>
<p class="ya-modal-subtitle" id="ya-modal-subtitle">Loading...</p>
</div>
<button class="watch-all-close" onclick="document.getElementById('your-artists-modal-overlay').remove()">&times;</button>
</div>
<div class="ya-modal-toolbar">
<input type="text" id="ya-modal-search" class="ya-modal-search" placeholder="Search artists...">
<div class="ya-modal-filters">
<button class="ya-filter-btn active" data-source="" onclick="_yaFilterSource('')">All</button>
<button class="ya-filter-btn" data-source="spotify" onclick="_yaFilterSource('spotify')">Spotify</button>
<button class="ya-filter-btn" data-source="tidal" onclick="_yaFilterSource('tidal')">Tidal</button>
<button class="ya-filter-btn" data-source="lastfm" onclick="_yaFilterSource('lastfm')">Last.fm</button>
<button class="ya-filter-btn" data-source="deezer" onclick="_yaFilterSource('deezer')">Deezer</button>
</div>
<select class="ya-modal-sort" id="ya-modal-sort" onchange="_yaLoadModal()">
<option value="name">A-Z</option>
<option value="recent">Recently Added</option>
<option value="source">By Source</option>
</select>
</div>
<div class="ya-modal-body" id="ya-modal-body">
<div class="cache-health-loading"><div class="watch-all-loading-spinner"></div><div>Loading...</div></div>
</div>
<div class="ya-modal-footer" id="ya-modal-footer"></div>
</div>
`;
document.body.appendChild(overlay);
// Search debounce
let searchTimer = null;
overlay.querySelector('#ya-modal-search').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => _yaLoadModal(), 300);
});
window._yaModalState = { page: 1, source: '', sort: 'name' };
_yaLoadModal();
}
function _yaFilterSource(source) {
window._yaModalState.source = source;
window._yaModalState.page = 1;
document.querySelectorAll('.ya-filter-btn').forEach(b => b.classList.toggle('active', b.dataset.source === source));
_yaLoadModal();
}
async function _yaLoadModal() {
const body = document.getElementById('ya-modal-body');
const footer = document.getElementById('ya-modal-footer');
const subtitle = document.getElementById('ya-modal-subtitle');
if (!body) return;
const state = window._yaModalState || { page: 1, source: '', sort: 'name' };
const search = document.getElementById('ya-modal-search')?.value || '';
const sort = document.getElementById('ya-modal-sort')?.value || 'name';
state.sort = sort;
const params = new URLSearchParams({ page: state.page, per_page: 60, sort: state.sort });
if (state.source) params.set('source', state.source);
if (search) params.set('search', search);
try {
const resp = await fetch(`/api/discover/your-artists/all?${params}`);
const data = await resp.json();
if (subtitle) subtitle.textContent = `${data.total} artists matched`;
if (!data.artists || data.artists.length === 0) {
body.innerHTML = '<div class="failed-mb-empty">No artists found</div>';
if (footer) footer.innerHTML = '';
return;
}
// Store for info modal access
if (!window._yaArtists) window._yaArtists = {};
data.artists.forEach(a => { window._yaArtists[a.id] = a; });
body.innerHTML = `<div class="ya-modal-grid">${data.artists.map(a => _renderYourArtistCard(a)).join('')}</div>`;
// Pagination
const totalPages = Math.ceil(data.total / 60);
if (footer && totalPages > 1) {
footer.innerHTML = `
<div class="failed-mb-pagination">
<button class="failed-mb-btn-sm" ${state.page <= 1 ? 'disabled' : ''} onclick="window._yaModalState.page--; _yaLoadModal()">Prev</button>
<span>Page ${state.page} of ${totalPages}</span>
<button class="failed-mb-btn-sm" ${state.page >= totalPages ? 'disabled' : ''} onclick="window._yaModalState.page++; _yaLoadModal()">Next</button>
</div>
`;
} else if (footer) {
footer.innerHTML = '';
}
} catch (err) {
body.innerHTML = '<div class="failed-mb-empty">Failed to load</div>';
}
}
function loadDiscoveryBlacklist() {}
async function loadDiscoveryShuffle() {

View file

@ -29528,6 +29528,220 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin: 0;
}
/* ── Your Artists Cards ── */
.ya-card {
flex-shrink: 0; width: 190px; aspect-ratio: 0.85;
border-radius: 14px; overflow: hidden; cursor: pointer;
position: relative; background: #111;
border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
transition: transform 0.3s cubic-bezier(0.4,0,0.2,1), border-color 0.3s, box-shadow 0.3s;
}
.ya-card:hover {
transform: translateY(-5px) scale(1.02);
border-color: rgba(var(--accent-rgb), 0.25);
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 24px rgba(var(--accent-rgb), 0.12);
}
.ya-card-img { position: absolute; inset: 0; }
.ya-card-img img { width: 100%; height: 100%; object-fit: cover; }
.ya-card-placeholder {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
font-size: 32px; color: rgba(255,255,255,0.08); background: linear-gradient(135deg, rgba(var(--accent-rgb),0.1), rgba(255,255,255,0.02));
}
.ya-card-gradient {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background: linear-gradient(0deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.3) 40%, transparent 65%);
}
.ya-card-info {
position: absolute; bottom: 0; left: 0; right: 0; padding: 12px; z-index: 2;
}
.ya-card-name {
font-size: 13px; font-weight: 700; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
}
/* Metadata source badges (SP, IT, Dz, DC) */
.ya-card-badges {
position: absolute; top: 8px; left: 8px; z-index: 3;
display: flex; gap: 3px;
}
.ya-badge {
width: 20px; height: 20px; border-radius: 5px;
background: rgba(0,0,0,0.6); backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.1);
display: flex; align-items: center; justify-content: center;
font-size: 8px; font-weight: 700; color: rgba(255,255,255,0.7);
}
.ya-badge img { width: 12px; height: 12px; object-fit: contain; }
/* Origin service dots (where artist was liked/followed) */
.ya-card-info-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.ya-origin-dots { display: flex; gap: 3px; }
.ya-origin-dot {
width: 6px; height: 6px; border-radius: 50%;
box-shadow: 0 0 3px rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.12);
}
/* Artist name clickable */
.ya-card-name {
cursor: pointer; transition: color 0.15s;
}
.ya-card-name:hover { color: var(--accent); }
/* ── Artist Info Modal ── */
.ya-info-modal {
background: #141420; border-radius: 18px;
width: 480px; max-width: 95vw; max-height: 85vh;
display: flex; flex-direction: column;
box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06);
overflow: hidden; position: relative;
}
.ya-info-modal > .watch-all-close {
position: absolute; top: 12px; right: 12px; z-index: 5;
background: rgba(0,0,0,0.5); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1); border-radius: 8px;
width: 32px; height: 32px;
}
/* Hero with blurred background image */
.ya-info-hero {
position: relative; overflow: hidden; min-height: 180px;
display: flex; align-items: flex-end;
}
.ya-info-hero-bg {
position: absolute; inset: -20px; z-index: 0;
background-size: cover; background-position: center;
filter: blur(20px) brightness(0.4) saturate(1.3);
}
.ya-info-hero-content {
position: relative; z-index: 1; display: flex; align-items: flex-end; gap: 18px;
padding: 24px; width: 100%;
background: linear-gradient(0deg, rgba(20,20,32,1) 0%, rgba(20,20,32,0.6) 60%, transparent 100%);
}
.ya-info-hero-img {
width: 110px; height: 110px; border-radius: 14px; overflow: hidden;
flex-shrink: 0; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.1);
}
.ya-info-hero-img img { width: 100%; height: 100%; object-fit: cover; }
.ya-info-img-fallback { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 36px; color: rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); }
.ya-info-hero-text { flex: 1; min-width: 0; }
.ya-info-name { font-size: 22px; font-weight: 800; color: #fff; margin: 0; letter-spacing: -0.3px; text-shadow: 0 2px 8px rgba(0,0,0,0.5); }
.ya-info-badges { display: flex; gap: 4px; margin-top: 8px; flex-wrap: wrap; }
.ya-info-badge {
width: 26px; height: 26px; border-radius: 7px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; color: rgba(255,255,255,0.7);
backdrop-filter: blur(4px);
}
.ya-info-badge img { width: 15px; height: 15px; object-fit: contain; }
.ya-info-badge:hover { background: rgba(255,255,255,0.15); border-color: rgba(255,255,255,0.2); }
.ya-info-origin { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 6px; }
.ya-info-body { flex: 1; overflow-y: auto; padding: 20px 24px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }
.ya-info-section { margin-bottom: 16px; }
.ya-info-section-title { font-size: 10px; font-weight: 700; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px; }
.ya-info-genres { display: flex; gap: 6px; flex-wrap: wrap; }
.ya-info-genre {
padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 500;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5); text-transform: capitalize;
}
.ya-info-stats { display: flex; gap: 28px; padding: 16px 0; }
.ya-info-stat { display: flex; flex-direction: column; gap: 2px; }
.ya-info-stat-value { font-size: 22px; font-weight: 800; color: rgba(255,255,255,0.9); }
.ya-info-stat-label { font-size: 10px; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 0.5px; }
.ya-info-bio { font-size: 13px; color: rgba(255,255,255,0.4); line-height: 1.7; }
.ya-info-empty { text-align: center; padding: 30px 0; color: rgba(255,255,255,0.2); font-size: 13px; }
.ya-info-footer { padding: 16px 24px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: center; gap: 10px; }
/* Loading state */
.ya-loading {
display: flex; align-items: center; gap: 12px; padding: 30px 20px;
color: rgba(255,255,255,0.4); font-size: 13px;
}
.ya-watchlist-btn {
position: absolute; top: 8px; right: 8px; z-index: 3;
width: 28px; height: 28px; border-radius: 8px;
background: rgba(0,0,0,0.5); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.5); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.ya-watchlist-btn:hover { background: rgba(0,0,0,0.7); color: rgba(255,255,255,0.8); }
.ya-watchlist-btn.active { color: var(--accent); border-color: rgba(var(--accent-rgb),0.3); background: rgba(var(--accent-rgb),0.15); }
/* Your Artists header buttons */
.ya-header-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 10px; font-size: 12px; font-weight: 600;
cursor: pointer; transition: all 0.2s; border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.5);
}
.ya-header-btn:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); border-color: rgba(255,255,255,0.12); }
.ya-refresh-btn { padding: 7px 10px; }
.ya-refresh-btn:hover svg { transform: rotate(45deg); }
.ya-refresh-btn svg { transition: transform 0.3s; }
.ya-viewall-btn {
background: rgba(var(--accent-rgb),0.1); border-color: rgba(var(--accent-rgb),0.2);
color: rgba(var(--accent-rgb),0.9);
}
.ya-viewall-btn:hover {
background: rgba(var(--accent-rgb),0.18); border-color: rgba(var(--accent-rgb),0.3);
color: var(--accent); transform: translateX(2px);
}
.ya-viewall-btn svg { transition: transform 0.2s; }
.ya-viewall-btn:hover svg { transform: translateX(3px); }
/* Your Artists Modal */
.ya-modal {
background: linear-gradient(165deg, #1e1e32 0%, #181828 100%); border-radius: 16px;
width: 800px; max-width: 95vw; max-height: 85vh;
display: flex; flex-direction: column;
box-shadow: 0 24px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.06);
}
.ya-modal-header {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 22px 28px 14px;
background: linear-gradient(180deg, rgba(var(--accent-rgb),0.06) 0%, transparent 100%);
border-bottom: 1px solid rgba(255,255,255,0.06); border-radius: 16px 16px 0 0;
}
.ya-modal-title { font-size: 17px; font-weight: 700; color: #fff; margin: 0; }
.ya-modal-subtitle { font-size: 12px; color: rgba(255,255,255,0.35); margin: 4px 0 0; }
.ya-modal-toolbar {
padding: 14px 28px; border-bottom: 1px solid rgba(255,255,255,0.04);
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
}
.ya-modal-search {
flex: 1; min-width: 150px; padding: 8px 14px; border-radius: 10px; font-size: 13px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07);
color: #fff; outline: none;
}
.ya-modal-search::placeholder { color: rgba(255,255,255,0.2); }
.ya-modal-search:focus { border-color: rgba(var(--accent-rgb),0.4); }
.ya-modal-filters { display: flex; gap: 4px; }
.ya-filter-btn {
padding: 6px 12px; border-radius: 8px; font-size: 11px; font-weight: 600;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
color: rgba(255,255,255,0.4); cursor: pointer; transition: all 0.2s;
}
.ya-filter-btn:hover { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.7); }
.ya-filter-btn.active { background: rgba(var(--accent-rgb),0.15); border-color: rgba(var(--accent-rgb),0.3); color: var(--accent); }
.ya-modal-sort {
padding: 6px 10px; border-radius: 8px; font-size: 11px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07);
color: rgba(255,255,255,0.6); outline: none;
}
.ya-modal-sort option { background: #1e1e32; }
.ya-modal-body { flex: 1; overflow-y: auto; padding: 16px 28px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }
.ya-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.ya-modal-grid .ya-card { width: auto; }
.ya-modal-footer { padding: 14px 28px; border-top: 1px solid rgba(255,255,255,0.05); }
/* Genre Explorer Grid */
.genre-explorer-grid {
display: flex;