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:
parent
d61a779b0e
commit
58d8e830c6
11 changed files with 2067 additions and 10 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
687
web_server.py
687
web_server.py
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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"' : ''}>♫</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()">×</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">♫</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()">×</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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue