Merge branch 'Nezreka:main' into feat/drop-in-folder
This commit is contained in:
commit
34faffc819
21 changed files with 6525 additions and 1446 deletions
|
|
@ -59,6 +59,15 @@ SoulSync bridges streaming services to your media server with automated discover
|
||||||
- Batch processing with retry logic
|
- Batch processing with retry logic
|
||||||
- Synchronized lyrics (LRC) for every track
|
- Synchronized lyrics (LRC) for every track
|
||||||
|
|
||||||
|
### Metadata & Reliability
|
||||||
|
|
||||||
|
**Dual-Source System**
|
||||||
|
- **Primary**: Spotify (Preferred for richer data and discovery features)
|
||||||
|
- **Backup**: iTunes (No authentication required)
|
||||||
|
- **Redundancy**: System automatically manages both sources. If Spotify is authorized, it is prioritized. If Spotify is unavailable, rate-limited, or unauthorized, SoulSync **seamlessly switches to iTunes** for metadata, cover art, and artist tracking.
|
||||||
|
- **Fail-Safe**: Even with Spotify authorized, iTunes metadata is maintained as a redundant layer to ensure zero downtime.
|
||||||
|
|
||||||
|
|
||||||
### Advanced Matching
|
### Advanced Matching
|
||||||
|
|
||||||
- Unicode/accent handling (KoЯn, Björk, A$AP Rocky)
|
- Unicode/accent handling (KoЯn, Björk, A$AP Rocky)
|
||||||
|
|
|
||||||
1219
Support/METADATA-FALLBACK-IMPLEMENTATION.md
Normal file
1219
Support/METADATA-FALLBACK-IMPLEMENTATION.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -533,7 +533,7 @@ class DatabaseUpdateWorker(QThread):
|
||||||
total_tracks_checked += len(album_tracks)
|
total_tracks_checked += len(album_tracks)
|
||||||
|
|
||||||
for track in album_tracks:
|
for track in album_tracks:
|
||||||
if not self.database.track_exists(track.ratingKey, self.server_type):
|
if not self.database.track_exists_by_server(track.ratingKey, self.server_type):
|
||||||
album_has_new_tracks = True
|
album_has_new_tracks = True
|
||||||
consecutive_complete_albums = 0 # Reset counter
|
consecutive_complete_albums = 0 # Reset counter
|
||||||
break
|
break
|
||||||
|
|
|
||||||
800
core/itunes_client.py
Normal file
800
core/itunes_client.py
Normal file
|
|
@ -0,0 +1,800 @@
|
||||||
|
import requests
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from functools import wraps
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("itunes_client")
|
||||||
|
|
||||||
|
# Global rate limiting variables
|
||||||
|
_last_api_call_time = 0
|
||||||
|
_api_call_lock = threading.Lock()
|
||||||
|
MIN_API_INTERVAL = 3.0 # iTunes has ~20 calls/minute limit = 1 call per 3 seconds
|
||||||
|
|
||||||
|
def rate_limited(func):
|
||||||
|
"""Decorator to enforce rate limiting on iTunes API calls"""
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
global _last_api_call_time
|
||||||
|
|
||||||
|
with _api_call_lock:
|
||||||
|
current_time = time.time()
|
||||||
|
time_since_last_call = current_time - _last_api_call_time
|
||||||
|
|
||||||
|
if time_since_last_call < MIN_API_INTERVAL:
|
||||||
|
sleep_time = MIN_API_INTERVAL - time_since_last_call
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
|
_last_api_call_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
# Implement exponential backoff for API errors
|
||||||
|
if "403" in str(e):
|
||||||
|
logger.warning(f"Rate limit hit, implementing backoff: {e}")
|
||||||
|
time.sleep(60.0) # Wait 60 seconds for iTunes rate limit
|
||||||
|
raise e
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def _clean_itunes_album_name(album_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Remove iTunes-specific suffixes like " - Single", " - EP" from album names.
|
||||||
|
iTunes API adds these suffixes but users don't want them displayed.
|
||||||
|
"""
|
||||||
|
if not album_name:
|
||||||
|
return album_name
|
||||||
|
|
||||||
|
# List of suffixes to remove
|
||||||
|
suffixes_to_remove = [' - Single', ' - EP']
|
||||||
|
|
||||||
|
for suffix in suffixes_to_remove:
|
||||||
|
if album_name.endswith(suffix):
|
||||||
|
return album_name[:-len(suffix)]
|
||||||
|
|
||||||
|
return album_name
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Track:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
artists: List[str]
|
||||||
|
album: str
|
||||||
|
duration_ms: int
|
||||||
|
popularity: int
|
||||||
|
preview_url: Optional[str] = None
|
||||||
|
external_urls: Optional[Dict[str, str]] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_itunes_track(cls, track_data: Dict[str, Any], clean_artist_name: Optional[str] = None) -> 'Track':
|
||||||
|
# Extract album image (highest quality)
|
||||||
|
album_image_url = None
|
||||||
|
if 'artworkUrl100' in track_data:
|
||||||
|
# Replace 100x100 with 600x600 for higher quality
|
||||||
|
album_image_url = track_data['artworkUrl100'].replace('100x100bb', '600x600bb')
|
||||||
|
|
||||||
|
# Get artist name(s) - prefer clean name from ID lookup if available
|
||||||
|
if clean_artist_name:
|
||||||
|
artists = [clean_artist_name]
|
||||||
|
else:
|
||||||
|
artists = [track_data.get('artistName', 'Unknown Artist')]
|
||||||
|
|
||||||
|
# Build external URLs
|
||||||
|
external_urls = {}
|
||||||
|
if 'trackViewUrl' in track_data:
|
||||||
|
external_urls['itunes'] = track_data['trackViewUrl']
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=str(track_data.get('trackId', '')),
|
||||||
|
name=track_data.get('trackName', ''),
|
||||||
|
artists=artists,
|
||||||
|
album=_clean_itunes_album_name(track_data.get('collectionName', '')),
|
||||||
|
duration_ms=track_data.get('trackTimeMillis', 0),
|
||||||
|
popularity=0, # iTunes doesn't provide popularity
|
||||||
|
preview_url=track_data.get('previewUrl'),
|
||||||
|
external_urls=external_urls if external_urls else None,
|
||||||
|
image_url=album_image_url
|
||||||
|
)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Artist:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
popularity: int # iTunes doesn't provide this, will be 0
|
||||||
|
genres: List[str]
|
||||||
|
followers: int # iTunes doesn't provide this, will be 0
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
external_urls: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_itunes_artist(cls, artist_data: Dict[str, Any]) -> 'Artist':
|
||||||
|
# iTunes artist search doesn't reliably return images
|
||||||
|
image_url = None
|
||||||
|
if 'artworkUrl100' in artist_data:
|
||||||
|
image_url = artist_data['artworkUrl100'].replace('100x100bb', '600x600bb')
|
||||||
|
|
||||||
|
# Build external URLs
|
||||||
|
external_urls = {}
|
||||||
|
if 'artistViewUrl' in artist_data:
|
||||||
|
external_urls['itunes'] = artist_data['artistViewUrl']
|
||||||
|
|
||||||
|
# Get genre
|
||||||
|
genre = artist_data.get('primaryGenreName', '')
|
||||||
|
genres = [genre] if genre else []
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=str(artist_data.get('artistId', '')),
|
||||||
|
name=artist_data.get('artistName', ''),
|
||||||
|
popularity=0, # iTunes doesn't provide popularity
|
||||||
|
genres=genres,
|
||||||
|
followers=0, # iTunes doesn't provide follower count
|
||||||
|
image_url=image_url,
|
||||||
|
external_urls=external_urls if external_urls else None
|
||||||
|
)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Album:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
artists: List[str]
|
||||||
|
release_date: str
|
||||||
|
total_tracks: int
|
||||||
|
album_type: str
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
external_urls: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_itunes_album(cls, album_data: Dict[str, Any]) -> 'Album':
|
||||||
|
# Get highest quality artwork
|
||||||
|
image_url = None
|
||||||
|
if album_data.get('artworkUrl100'):
|
||||||
|
image_url = album_data['artworkUrl100'].replace('100x100bb', '600x600bb')
|
||||||
|
|
||||||
|
# Build external URLs
|
||||||
|
external_urls = {}
|
||||||
|
if 'collectionViewUrl' in album_data:
|
||||||
|
external_urls['itunes'] = album_data['collectionViewUrl']
|
||||||
|
|
||||||
|
# Determine album type from collection type
|
||||||
|
track_count = album_data.get('trackCount', 0)
|
||||||
|
|
||||||
|
# iTunes doesn't clearly distinguish EPs, but we can infer:
|
||||||
|
# Singles typically have 1-3 tracks, EPs have 4-6, Albums have 7+
|
||||||
|
if track_count <= 3:
|
||||||
|
album_type = 'single'
|
||||||
|
elif track_count <= 6:
|
||||||
|
album_type = 'ep' # 4-6 tracks = EP
|
||||||
|
else:
|
||||||
|
album_type = 'album'
|
||||||
|
|
||||||
|
# Check if it's explicitly marked as compilation
|
||||||
|
collection_type = album_data.get('collectionType', 'Album')
|
||||||
|
if 'compilation' in collection_type.lower():
|
||||||
|
album_type = 'compilation'
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=str(album_data.get('collectionId', '')),
|
||||||
|
name=_clean_itunes_album_name(album_data.get('collectionName', '')),
|
||||||
|
artists=[album_data.get('artistName', 'Unknown Artist')],
|
||||||
|
release_date=album_data.get('releaseDate', ''),
|
||||||
|
total_tracks=track_count,
|
||||||
|
album_type=album_type,
|
||||||
|
image_url=image_url,
|
||||||
|
external_urls=external_urls if external_urls else None
|
||||||
|
)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Playlist:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
owner: str
|
||||||
|
public: bool
|
||||||
|
collaborative: bool
|
||||||
|
tracks: List[Track]
|
||||||
|
total_tracks: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_itunes_playlist(cls, playlist_data: Dict[str, Any], tracks: List[Track]) -> 'Playlist':
|
||||||
|
# iTunes doesn't have playlists in the same way, but we maintain the structure
|
||||||
|
return cls(
|
||||||
|
id=playlist_data.get('id', ''),
|
||||||
|
name=playlist_data.get('name', ''),
|
||||||
|
description=playlist_data.get('description'),
|
||||||
|
owner='iTunes',
|
||||||
|
public=True,
|
||||||
|
collaborative=False,
|
||||||
|
tracks=tracks,
|
||||||
|
total_tracks=len(tracks)
|
||||||
|
)
|
||||||
|
|
||||||
|
class iTunesClient:
|
||||||
|
"""
|
||||||
|
iTunes Search API client for music metadata.
|
||||||
|
|
||||||
|
Provides full parity with SpotifyClient functionality.
|
||||||
|
Free, no authentication required.
|
||||||
|
Rate limit: ~20 calls/minute on /search, /lookup appears unlimited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SEARCH_URL = "https://itunes.apple.com/search"
|
||||||
|
LOOKUP_URL = "https://itunes.apple.com/lookup"
|
||||||
|
|
||||||
|
def __init__(self, country: str = "US"):
|
||||||
|
self.country = country
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'User-Agent': 'SoulSync/1.0',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
})
|
||||||
|
logger.info(f"iTunes client initialized for country: {country}")
|
||||||
|
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if iTunes client is available (always True since no auth required)
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@rate_limited
|
||||||
|
def _search(self, term: str, entity: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""Generic search method for iTunes API"""
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
'term': term,
|
||||||
|
'country': self.country,
|
||||||
|
'media': 'music',
|
||||||
|
'entity': entity,
|
||||||
|
'limit': min(limit, 200), # iTunes max is 200
|
||||||
|
'explicit': 'Yes' # Include explicit content (prefer over clean versions)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
self.SEARCH_URL,
|
||||||
|
params=params,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 403:
|
||||||
|
logger.warning("iTunes API rate limit hit")
|
||||||
|
time.sleep(60)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"iTunes search failed with status {response.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
results = data.get('results', [])
|
||||||
|
logger.info(f"iTunes search for '{term}' ({entity}) returned {len(results)} results")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching iTunes: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _lookup(self, **params) -> List[Dict[str, Any]]:
|
||||||
|
"""Generic lookup method (not rate limited)"""
|
||||||
|
try:
|
||||||
|
params['country'] = self.country
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
self.LOOKUP_URL,
|
||||||
|
params=params,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"iTunes lookup failed with status {response.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data.get('results', [])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in iTunes lookup: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ==================== Track Methods ====================
|
||||||
|
|
||||||
|
@rate_limited
|
||||||
|
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
||||||
|
"""Search for tracks using iTunes API"""
|
||||||
|
results = self._search(query, 'song', limit)
|
||||||
|
tracks = []
|
||||||
|
|
||||||
|
# Collect artist IDs for batch lookup
|
||||||
|
artist_ids = set()
|
||||||
|
for track_data in results:
|
||||||
|
if track_data.get('wrapperType') == 'track' and track_data.get('kind') == 'song':
|
||||||
|
artist_id = str(track_data.get('artistId', ''))
|
||||||
|
if artist_id:
|
||||||
|
artist_ids.add(artist_id)
|
||||||
|
|
||||||
|
# Batch lookup artist clean names
|
||||||
|
clean_artist_map = {}
|
||||||
|
if artist_ids:
|
||||||
|
clean_artist_map = self._get_clean_artist_names(list(artist_ids))
|
||||||
|
|
||||||
|
for track_data in results:
|
||||||
|
if track_data.get('wrapperType') == 'track' and track_data.get('kind') == 'song':
|
||||||
|
artist_id = str(track_data.get('artistId', ''))
|
||||||
|
clean_artist = clean_artist_map.get(artist_id)
|
||||||
|
track = Track.from_itunes_track(track_data, clean_artist_name=clean_artist)
|
||||||
|
tracks.append(track)
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def _get_clean_artist_names(self, artist_ids: List[str]) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Perform a batched lookup of artist IDs to get clean artist names.
|
||||||
|
Returns a map of {artist_id: clean_artist_name}
|
||||||
|
"""
|
||||||
|
if not artist_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
clean_names = {}
|
||||||
|
# iTunes lookup allows comma-separated IDs, but keep batch size reasonable (e.g. 50)
|
||||||
|
batch_size = 50
|
||||||
|
|
||||||
|
for i in range(0, len(artist_ids), batch_size):
|
||||||
|
batch = artist_ids[i:i+batch_size]
|
||||||
|
ids_str = ",".join(batch)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Lookup is fast/unlimited compared to search
|
||||||
|
results = self._lookup(id=ids_str)
|
||||||
|
|
||||||
|
for item in results:
|
||||||
|
if item.get('wrapperType') == 'artist':
|
||||||
|
a_id = str(item.get('artistId', ''))
|
||||||
|
a_name = item.get('artistName', '')
|
||||||
|
if a_id and a_name:
|
||||||
|
clean_names[a_id] = a_name
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed batch artist lookup: {e}")
|
||||||
|
|
||||||
|
return clean_names
|
||||||
|
|
||||||
|
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get detailed track information including album data and track number"""
|
||||||
|
results = self._lookup(id=track_id)
|
||||||
|
|
||||||
|
for track_data in results:
|
||||||
|
if track_data.get('wrapperType') == 'track':
|
||||||
|
# Enhance with additional useful metadata
|
||||||
|
# Enhance with additional useful metadata
|
||||||
|
# Get clean artist name
|
||||||
|
clean_artist_name = 'Unknown Artist'
|
||||||
|
artist_id = str(track_data.get('artistId', ''))
|
||||||
|
if artist_id:
|
||||||
|
clean_names = self._get_clean_artist_names([artist_id])
|
||||||
|
clean_artist_name = clean_names.get(artist_id, track_data.get('artistName', 'Unknown Artist'))
|
||||||
|
else:
|
||||||
|
clean_artist_name = track_data.get('artistName', 'Unknown Artist')
|
||||||
|
|
||||||
|
enhanced_data = {
|
||||||
|
'id': str(track_data.get('trackId', '')),
|
||||||
|
'name': track_data.get('trackName', ''),
|
||||||
|
'track_number': track_data.get('trackNumber', 0),
|
||||||
|
'disc_number': track_data.get('discNumber', 1),
|
||||||
|
'duration_ms': track_data.get('trackTimeMillis', 0),
|
||||||
|
'explicit': track_data.get('trackExplicitness') == 'explicit',
|
||||||
|
'artists': [clean_artist_name],
|
||||||
|
'primary_artist': clean_artist_name,
|
||||||
|
'album': {
|
||||||
|
'id': str(track_data.get('collectionId', '')),
|
||||||
|
'name': _clean_itunes_album_name(track_data.get('collectionName', '')),
|
||||||
|
'total_tracks': track_data.get('trackCount', 0),
|
||||||
|
'release_date': track_data.get('releaseDate', ''),
|
||||||
|
'album_type': 'album', # iTunes doesn't distinguish clearly
|
||||||
|
'artists': [clean_artist_name]
|
||||||
|
},
|
||||||
|
'is_album_track': track_data.get('trackCount', 0) > 1,
|
||||||
|
'raw_data': track_data
|
||||||
|
}
|
||||||
|
return enhanced_data
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get track audio features (NOT SUPPORTED by iTunes API)
|
||||||
|
Returns None as iTunes doesn't provide audio features like Spotify
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support audio features")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ==================== Album Methods ====================
|
||||||
|
|
||||||
|
@rate_limited
|
||||||
|
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
||||||
|
"""Search for albums using iTunes API.
|
||||||
|
|
||||||
|
Filters out clean versions when explicit versions are available.
|
||||||
|
"""
|
||||||
|
results = self._search(query, 'album', limit * 2) # Fetch more to account for filtering
|
||||||
|
albums = []
|
||||||
|
seen_albums = {} # Track albums by normalized name to prefer explicit versions
|
||||||
|
|
||||||
|
for album_data in results:
|
||||||
|
if album_data.get('wrapperType') != 'collection':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get album name and explicitness
|
||||||
|
# Clean album name before comparison for better deduplication
|
||||||
|
album_name = _clean_itunes_album_name(album_data.get('collectionName', '')).lower().strip()
|
||||||
|
artist_name = album_data.get('artistName', '').lower().strip()
|
||||||
|
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
|
||||||
|
|
||||||
|
# Create a key for deduplication (album name + artist)
|
||||||
|
key = f"{album_name}|{artist_name}"
|
||||||
|
|
||||||
|
# If we've seen this album before
|
||||||
|
if key in seen_albums:
|
||||||
|
# Only replace if current one is explicit and previous was clean
|
||||||
|
if is_explicit and not seen_albums[key]['is_explicit']:
|
||||||
|
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
|
||||||
|
else:
|
||||||
|
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
|
||||||
|
|
||||||
|
# Convert to Album objects
|
||||||
|
for item in seen_albums.values():
|
||||||
|
album = Album.from_itunes_album(item['data'])
|
||||||
|
albums.append(album)
|
||||||
|
|
||||||
|
return albums[:limit]
|
||||||
|
|
||||||
|
def get_album(self, album_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get album information with tracks - normalized to Spotify format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
album_id: iTunes album/collection ID
|
||||||
|
include_tracks: If True, also fetches and includes tracks (default True for Spotify compatibility)
|
||||||
|
"""
|
||||||
|
results = self._lookup(id=album_id)
|
||||||
|
|
||||||
|
for album_data in results:
|
||||||
|
if album_data.get('wrapperType') == 'collection':
|
||||||
|
# Normalize to Spotify-compatible format
|
||||||
|
image_url = None
|
||||||
|
if album_data.get('artworkUrl100'):
|
||||||
|
image_url = album_data['artworkUrl100'].replace('100x100bb', '600x600bb')
|
||||||
|
|
||||||
|
# Build images array like Spotify (multiple sizes)
|
||||||
|
images = []
|
||||||
|
if image_url:
|
||||||
|
images = [
|
||||||
|
{'url': image_url, 'height': 600, 'width': 600},
|
||||||
|
{'url': album_data['artworkUrl100'].replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
||||||
|
{'url': album_data['artworkUrl100'], 'height': 100, 'width': 100}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Determine album type
|
||||||
|
track_count = album_data.get('trackCount', 0)
|
||||||
|
if track_count <= 3:
|
||||||
|
album_type = 'single'
|
||||||
|
elif track_count <= 6:
|
||||||
|
album_type = 'ep' # 4-6 tracks = EP
|
||||||
|
else:
|
||||||
|
album_type = 'album'
|
||||||
|
|
||||||
|
album_result = {
|
||||||
|
'id': str(album_data.get('collectionId', '')),
|
||||||
|
'name': _clean_itunes_album_name(album_data.get('collectionName', '')),
|
||||||
|
'images': images,
|
||||||
|
'artists': [{'name': album_data.get('artistName', 'Unknown Artist'), 'id': str(album_data.get('artistId', ''))}],
|
||||||
|
'release_date': album_data.get('releaseDate', '')[:10] if album_data.get('releaseDate') else '', # YYYY-MM-DD format
|
||||||
|
'total_tracks': track_count,
|
||||||
|
'album_type': album_type,
|
||||||
|
'external_urls': {'itunes': album_data.get('collectionViewUrl', '')},
|
||||||
|
'uri': f"itunes:album:{album_data.get('collectionId', '')}",
|
||||||
|
'_source': 'itunes',
|
||||||
|
'_raw_data': album_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include tracks to match Spotify's get_album format
|
||||||
|
if include_tracks:
|
||||||
|
tracks_data = self.get_album_tracks(album_id)
|
||||||
|
if tracks_data and 'items' in tracks_data:
|
||||||
|
album_result['tracks'] = tracks_data
|
||||||
|
else:
|
||||||
|
album_result['tracks'] = {'items': [], 'total': 0}
|
||||||
|
|
||||||
|
return album_result
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get album tracks - normalized to Spotify format"""
|
||||||
|
results = self._lookup(id=album_id, entity='song')
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# First result is usually the album/collection info
|
||||||
|
# Extract album information to include in each track (like Spotify does)
|
||||||
|
album_info = None
|
||||||
|
album_images = []
|
||||||
|
for item in results:
|
||||||
|
if item.get('wrapperType') == 'collection':
|
||||||
|
album_info = item
|
||||||
|
# Build album images array
|
||||||
|
if item.get('artworkUrl100'):
|
||||||
|
base_url = item['artworkUrl100'].replace('100x100bb', '{size}x{size}bb')
|
||||||
|
album_images = [
|
||||||
|
{'url': base_url.replace('{size}x{size}bb', '600x600bb'), 'height': 600, 'width': 600},
|
||||||
|
{'url': base_url.replace('{size}x{size}bb', '300x300bb'), 'height': 300, 'width': 300},
|
||||||
|
{'url': item['artworkUrl100'], 'height': 100, 'width': 100}
|
||||||
|
]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Collect artist IDs for batch lookup
|
||||||
|
artist_ids = set()
|
||||||
|
for item in results:
|
||||||
|
if item.get('wrapperType') == 'track' and item.get('kind') == 'song':
|
||||||
|
artist_id = str(item.get('artistId', ''))
|
||||||
|
if artist_id:
|
||||||
|
artist_ids.add(artist_id)
|
||||||
|
|
||||||
|
# Batch lookup artist clean names
|
||||||
|
clean_artist_map = {}
|
||||||
|
if artist_ids:
|
||||||
|
clean_artist_map = self._get_clean_artist_names(list(artist_ids))
|
||||||
|
|
||||||
|
tracks = []
|
||||||
|
for item in results:
|
||||||
|
if item.get('wrapperType') == 'track' and item.get('kind') == 'song':
|
||||||
|
artist_id = str(item.get('artistId', ''))
|
||||||
|
clean_artist = clean_artist_map.get(artist_id, item.get('artistName', 'Unknown Artist'))
|
||||||
|
|
||||||
|
# Build album object for this track (like Spotify format)
|
||||||
|
track_album = {
|
||||||
|
'id': str(item.get('collectionId', album_id)),
|
||||||
|
'name': _clean_itunes_album_name(item.get('collectionName', 'Unknown Album')),
|
||||||
|
'images': album_images,
|
||||||
|
'release_date': item.get('releaseDate', '')[:10] if item.get('releaseDate') else ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize each track to Spotify-compatible format
|
||||||
|
normalized_track = {
|
||||||
|
'id': str(item.get('trackId', '')),
|
||||||
|
'name': item.get('trackName', ''),
|
||||||
|
'artists': [{'name': clean_artist}], # List of dicts like Spotify
|
||||||
|
'album': track_album, # CRITICAL: Include album info like Spotify does
|
||||||
|
'duration_ms': item.get('trackTimeMillis', 0),
|
||||||
|
'track_number': item.get('trackNumber', 0),
|
||||||
|
'disc_number': item.get('discNumber', 1),
|
||||||
|
'explicit': item.get('trackExplicitness') == 'explicit',
|
||||||
|
'preview_url': item.get('previewUrl'),
|
||||||
|
'uri': f"itunes:track:{item.get('trackId', '')}", # Synthetic URI
|
||||||
|
'external_urls': {'itunes': item.get('trackViewUrl', '')},
|
||||||
|
'_source': 'itunes'
|
||||||
|
}
|
||||||
|
tracks.append(normalized_track)
|
||||||
|
|
||||||
|
# Sort by disc and track number
|
||||||
|
tracks.sort(key=lambda t: (t.get('disc_number', 1), t.get('track_number', 0)))
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(tracks)} tracks for album {album_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'items': tracks,
|
||||||
|
'total': len(tracks),
|
||||||
|
'limit': len(tracks),
|
||||||
|
'next': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==================== Artist Methods ====================
|
||||||
|
|
||||||
|
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get artist image by fetching their first album's artwork.
|
||||||
|
iTunes doesn't reliably return artist images, so we use album art as fallback.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Lookup is not rate-limited, so this is fast
|
||||||
|
results = self._lookup(id=artist_id, entity='album', limit=1)
|
||||||
|
|
||||||
|
for item in results:
|
||||||
|
if item.get('wrapperType') == 'collection' and item.get('artworkUrl100'):
|
||||||
|
# Return high-res version
|
||||||
|
return item['artworkUrl100'].replace('100x100bb', '600x600bb')
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not fetch album art for artist {artist_id}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@rate_limited
|
||||||
|
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||||
|
"""Search for artists using iTunes API.
|
||||||
|
|
||||||
|
Note: Artist images are not fetched during search to keep it fast.
|
||||||
|
Images are fetched when viewing artist details (get_artist method).
|
||||||
|
"""
|
||||||
|
results = self._search(query, 'musicArtist', limit)
|
||||||
|
artists = []
|
||||||
|
|
||||||
|
for artist_data in results:
|
||||||
|
if artist_data.get('wrapperType') == 'artist':
|
||||||
|
artist = Artist.from_itunes_artist(artist_data)
|
||||||
|
artists.append(artist)
|
||||||
|
|
||||||
|
return artists
|
||||||
|
|
||||||
|
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get full artist details - normalized to Spotify format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artist_id: iTunes artist ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with artist data matching Spotify's format
|
||||||
|
"""
|
||||||
|
results = self._lookup(id=artist_id)
|
||||||
|
|
||||||
|
for artist_data in results:
|
||||||
|
if artist_data.get('wrapperType') == 'artist':
|
||||||
|
# Build images array - iTunes artist search doesn't reliably return images
|
||||||
|
# Use album art as fallback
|
||||||
|
images = []
|
||||||
|
artwork_url = artist_data.get('artworkUrl100')
|
||||||
|
|
||||||
|
# If no artist artwork, try to get from their first album
|
||||||
|
if not artwork_url:
|
||||||
|
album_art = self._get_artist_image_from_albums(str(artist_data.get('artistId', '')))
|
||||||
|
if album_art:
|
||||||
|
# Convert back to base URL format for building array
|
||||||
|
artwork_url = album_art.replace('600x600bb', '100x100bb')
|
||||||
|
|
||||||
|
if artwork_url:
|
||||||
|
images = [
|
||||||
|
{'url': artwork_url.replace('100x100bb', '600x600bb'), 'height': 600, 'width': 600},
|
||||||
|
{'url': artwork_url.replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
||||||
|
{'url': artwork_url, 'height': 100, 'width': 100}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get genre
|
||||||
|
genres = []
|
||||||
|
if artist_data.get('primaryGenreName'):
|
||||||
|
genres = [artist_data['primaryGenreName']]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': str(artist_data.get('artistId', '')),
|
||||||
|
'name': artist_data.get('artistName', ''),
|
||||||
|
'images': images,
|
||||||
|
'genres': genres,
|
||||||
|
'popularity': 0, # iTunes doesn't provide this
|
||||||
|
'followers': {'total': 0}, # iTunes doesn't provide this
|
||||||
|
'external_urls': {'itunes': artist_data.get('artistViewUrl', '')},
|
||||||
|
'uri': f"itunes:artist:{artist_data.get('artistId', '')}",
|
||||||
|
'_source': 'itunes',
|
||||||
|
'_raw_data': artist_data
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
||||||
|
"""
|
||||||
|
Get albums by artist ID
|
||||||
|
|
||||||
|
Note: iTunes doesn't support filtering by album_type in the same way as Spotify,
|
||||||
|
so we fetch all albums and can filter client-side if needed.
|
||||||
|
Prefers explicit versions over clean versions when both exist.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200))
|
||||||
|
seen_albums = {} # Track albums by normalized name, prefer explicit versions
|
||||||
|
|
||||||
|
def normalize_album_name(name: str) -> str:
|
||||||
|
"""Normalize album name for deduplication (removes edition suffixes, etc.)"""
|
||||||
|
normalized = name.lower().strip()
|
||||||
|
# Remove common edition suffixes
|
||||||
|
normalized = re.sub(r'\s*[\(\[]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version|bonus|special|standard).*?[\)\]]', '', normalized, flags=re.IGNORECASE)
|
||||||
|
# Remove trailing edition keywords without brackets
|
||||||
|
normalized = re.sub(r'\s*[-–—]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version).*$', '', normalized, flags=re.IGNORECASE)
|
||||||
|
# Normalize whitespace
|
||||||
|
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
for album_data in results:
|
||||||
|
if album_data.get('wrapperType') != 'collection':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if explicit
|
||||||
|
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
|
||||||
|
|
||||||
|
# Create album object
|
||||||
|
album = Album.from_itunes_album(album_data)
|
||||||
|
|
||||||
|
# Filter by album_type if specified (now includes 'ep')
|
||||||
|
if album_type != 'album,single':
|
||||||
|
requested_types = [t.strip() for t in album_type.split(',')]
|
||||||
|
# Also accept 'ep' when 'single' is requested (for backward compat)
|
||||||
|
if album.album_type not in requested_types:
|
||||||
|
if not (album.album_type == 'ep' and 'single' in requested_types):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Deduplicate by normalized name, prefer explicit versions
|
||||||
|
normalized_name = normalize_album_name(album.name)
|
||||||
|
|
||||||
|
if normalized_name in seen_albums:
|
||||||
|
# Only replace if current one is explicit and previous was clean
|
||||||
|
if is_explicit and not seen_albums[normalized_name]['is_explicit']:
|
||||||
|
logger.debug(f"Replacing clean version with explicit: {album.name}")
|
||||||
|
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
||||||
|
else:
|
||||||
|
logger.debug(f"Skipping duplicate album: {album.name} (normalized: {normalized_name})")
|
||||||
|
else:
|
||||||
|
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
||||||
|
|
||||||
|
# Extract albums from dict
|
||||||
|
albums = [item['album'] for item in seen_albums.values()]
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)")
|
||||||
|
return albums[:limit]
|
||||||
|
|
||||||
|
# ==================== Playlist Methods ====================
|
||||||
|
|
||||||
|
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
|
||||||
|
"""
|
||||||
|
Get playlist tracks (NOT SUPPORTED by iTunes API)
|
||||||
|
Internal helper method to match Spotify client structure
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support playlists")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_user_playlists(self) -> List[Playlist]:
|
||||||
|
"""
|
||||||
|
Get user playlists (NOT SUPPORTED by iTunes API)
|
||||||
|
iTunes doesn't have user playlists accessible via API
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support user playlists")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_user_playlists_metadata_only(self) -> List[Playlist]:
|
||||||
|
"""
|
||||||
|
Get playlists metadata only (NOT SUPPORTED by iTunes API)
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support user playlists")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_saved_tracks_count(self) -> int:
|
||||||
|
"""
|
||||||
|
Get saved tracks count (NOT SUPPORTED by iTunes API)
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support saved/liked tracks")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_saved_tracks(self) -> List[Track]:
|
||||||
|
"""
|
||||||
|
Get saved/liked tracks (NOT SUPPORTED by iTunes API)
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support saved/liked tracks")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
|
||||||
|
"""
|
||||||
|
Get playlist by ID (NOT SUPPORTED by iTunes API)
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support playlists")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ==================== User Methods ====================
|
||||||
|
|
||||||
|
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get user info (NOT SUPPORTED by iTunes API - no authentication)
|
||||||
|
"""
|
||||||
|
logger.warning("iTunes API does not support user authentication")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def reload_config(self):
|
||||||
|
"""Reload configuration (no-op for iTunes since no auth required)"""
|
||||||
|
logger.info("iTunes client config reload requested (no-op)")
|
||||||
|
pass
|
||||||
229
core/metadata_service.py
Normal file
229
core/metadata_service.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""
|
||||||
|
Metadata Service - Hot-swappable Spotify/iTunes provider
|
||||||
|
|
||||||
|
Automatically uses Spotify when authenticated, falls back to iTunes when not.
|
||||||
|
Provides unified interface for all metadata operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional, Dict, Any, Literal
|
||||||
|
from core.spotify_client import SpotifyClient
|
||||||
|
from core.itunes_client import iTunesClient
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("metadata_service")
|
||||||
|
|
||||||
|
MetadataProvider = Literal["spotify", "itunes", "auto"]
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataService:
|
||||||
|
"""
|
||||||
|
Unified metadata service that seamlessly switches between Spotify and iTunes.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
service = MetadataService()
|
||||||
|
tracks = service.search_tracks("Radiohead OK Computer")
|
||||||
|
# Uses Spotify if authenticated, otherwise iTunes
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, preferred_provider: MetadataProvider = "auto"):
|
||||||
|
"""
|
||||||
|
Initialize metadata service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preferred_provider: "spotify", "itunes", or "auto" (default)
|
||||||
|
- "auto": Use Spotify if authenticated, else iTunes
|
||||||
|
- "spotify": Always use Spotify (may fail if not authenticated)
|
||||||
|
- "itunes": Always use iTunes
|
||||||
|
"""
|
||||||
|
self.preferred_provider = preferred_provider
|
||||||
|
self.spotify = SpotifyClient()
|
||||||
|
self.itunes = iTunesClient()
|
||||||
|
|
||||||
|
self._log_initialization()
|
||||||
|
|
||||||
|
def _log_initialization(self):
|
||||||
|
"""Log initialization status"""
|
||||||
|
spotify_status = "✅ Authenticated" if self.spotify.is_spotify_authenticated() else "❌ Not authenticated"
|
||||||
|
itunes_status = "✅ Available" if self.itunes.is_authenticated() else "❌ Not available"
|
||||||
|
|
||||||
|
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, iTunes: {itunes_status}")
|
||||||
|
logger.info(f"Preferred provider: {self.preferred_provider}")
|
||||||
|
|
||||||
|
def get_active_provider(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the currently active metadata provider.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"spotify" or "itunes"
|
||||||
|
"""
|
||||||
|
if self.preferred_provider == "spotify":
|
||||||
|
return "spotify"
|
||||||
|
elif self.preferred_provider == "itunes":
|
||||||
|
return "itunes"
|
||||||
|
else: # auto
|
||||||
|
# Use is_spotify_authenticated() to check actual Spotify auth status
|
||||||
|
# (is_authenticated() always returns True due to iTunes fallback)
|
||||||
|
return "spotify" if self.spotify.is_spotify_authenticated() else "itunes"
|
||||||
|
|
||||||
|
def _get_client(self):
|
||||||
|
"""Get the appropriate client based on provider selection"""
|
||||||
|
provider = self.get_active_provider()
|
||||||
|
|
||||||
|
if provider == "spotify":
|
||||||
|
if not self.spotify.is_spotify_authenticated():
|
||||||
|
logger.warning("Spotify requested but not authenticated, falling back to iTunes")
|
||||||
|
return self.itunes
|
||||||
|
return self.spotify
|
||||||
|
else:
|
||||||
|
return self.itunes
|
||||||
|
|
||||||
|
# ==================== Search Methods ====================
|
||||||
|
|
||||||
|
def search_tracks(self, query: str, limit: int = 20) -> List:
|
||||||
|
"""
|
||||||
|
Search for tracks using active provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
limit: Maximum results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Track objects
|
||||||
|
"""
|
||||||
|
client = self._get_client()
|
||||||
|
provider = self.get_active_provider()
|
||||||
|
logger.debug(f"Searching tracks with {provider}: '{query}'")
|
||||||
|
return client.search_tracks(query, limit)
|
||||||
|
|
||||||
|
def search_artists(self, query: str, limit: int = 20) -> List:
|
||||||
|
"""
|
||||||
|
Search for artists using active provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
limit: Maximum results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Artist objects
|
||||||
|
"""
|
||||||
|
client = self._get_client()
|
||||||
|
provider = self.get_active_provider()
|
||||||
|
logger.debug(f"Searching artists with {provider}: '{query}'")
|
||||||
|
return client.search_artists(query, limit)
|
||||||
|
|
||||||
|
def search_albums(self, query: str, limit: int = 20) -> List:
|
||||||
|
"""
|
||||||
|
Search for albums using active provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
limit: Maximum results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Album objects
|
||||||
|
"""
|
||||||
|
client = self._get_client()
|
||||||
|
provider = self.get_active_provider()
|
||||||
|
logger.debug(f"Searching albums with {provider}: '{query}'")
|
||||||
|
return client.search_albums(query, limit)
|
||||||
|
|
||||||
|
# ==================== Detail Fetching ====================
|
||||||
|
|
||||||
|
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get detailed track information"""
|
||||||
|
client = self._get_client()
|
||||||
|
return client.get_track_details(track_id)
|
||||||
|
|
||||||
|
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get album information"""
|
||||||
|
client = self._get_client()
|
||||||
|
return client.get_album(album_id)
|
||||||
|
|
||||||
|
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get all tracks from an album"""
|
||||||
|
client = self._get_client()
|
||||||
|
provider = self.get_active_provider()
|
||||||
|
logger.debug(f"Fetching album tracks with {provider}: {album_id}")
|
||||||
|
return client.get_album_tracks(album_id)
|
||||||
|
|
||||||
|
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get artist information"""
|
||||||
|
client = self._get_client()
|
||||||
|
return client.get_artist(artist_id)
|
||||||
|
|
||||||
|
def get_artist_albums(self, artist_id: str, album_type: str = "album,single", limit: int = 50) -> List:
|
||||||
|
"""Get artist's albums/discography"""
|
||||||
|
client = self._get_client()
|
||||||
|
provider = self.get_active_provider()
|
||||||
|
logger.debug(f"Fetching artist albums with {provider}: {artist_id}")
|
||||||
|
return client.get_artist_albums(artist_id, album_type, limit)
|
||||||
|
|
||||||
|
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get track audio features (Spotify only).
|
||||||
|
Returns None for iTunes.
|
||||||
|
"""
|
||||||
|
client = self._get_client()
|
||||||
|
return client.get_track_features(track_id)
|
||||||
|
|
||||||
|
# ==================== User Library (Spotify only) ====================
|
||||||
|
|
||||||
|
def get_user_playlists(self) -> List:
|
||||||
|
"""Get user playlists (Spotify only)"""
|
||||||
|
if self.spotify.is_spotify_authenticated():
|
||||||
|
return self.spotify.get_user_playlists()
|
||||||
|
logger.warning("User playlists only available with Spotify authentication")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_saved_tracks(self) -> List:
|
||||||
|
"""Get user's saved/liked tracks (Spotify only)"""
|
||||||
|
if self.spotify.is_spotify_authenticated():
|
||||||
|
return self.spotify.get_saved_tracks()
|
||||||
|
logger.warning("Saved tracks only available with Spotify authentication")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_saved_tracks_count(self) -> int:
|
||||||
|
"""Get count of user's saved tracks (Spotify only)"""
|
||||||
|
if self.spotify.is_spotify_authenticated():
|
||||||
|
return self.spotify.get_saved_tracks_count()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ==================== Utility Methods ====================
|
||||||
|
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
"""Check if any provider is available"""
|
||||||
|
return self.spotify.is_spotify_authenticated() or self.itunes.is_authenticated()
|
||||||
|
|
||||||
|
def get_provider_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get information about available providers"""
|
||||||
|
return {
|
||||||
|
"active_provider": self.get_active_provider(),
|
||||||
|
"spotify_authenticated": self.spotify.is_spotify_authenticated(),
|
||||||
|
"itunes_available": self.itunes.is_authenticated(),
|
||||||
|
"preferred_provider": self.preferred_provider,
|
||||||
|
"can_access_user_data": self.spotify.is_spotify_authenticated(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def reload_config(self):
|
||||||
|
"""Reload configuration for both clients"""
|
||||||
|
logger.info("Reloading metadata service configuration")
|
||||||
|
self.spotify.reload_config()
|
||||||
|
self.itunes.reload_config()
|
||||||
|
self._log_initialization()
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience singleton instance
|
||||||
|
_metadata_service_instance: Optional[MetadataService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata_service() -> MetadataService:
|
||||||
|
"""
|
||||||
|
Get global metadata service instance (singleton pattern).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MetadataService instance
|
||||||
|
"""
|
||||||
|
global _metadata_service_instance
|
||||||
|
if _metadata_service_instance is None:
|
||||||
|
_metadata_service_instance = MetadataService()
|
||||||
|
return _metadata_service_instance
|
||||||
|
|
@ -505,8 +505,8 @@ class NavidromeClient:
|
||||||
return playlist
|
return playlist
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def create_playlist(self, name: str, tracks) -> bool:
|
def create_playlist(self, name: str, tracks, playlist_id: str = None) -> bool:
|
||||||
"""Create a new playlist with given tracks"""
|
"""Create a new playlist or update existing one if playlist_id provided"""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -523,25 +523,29 @@ class NavidromeClient:
|
||||||
logger.warning(f"No valid tracks provided for playlist '{name}'")
|
logger.warning(f"No valid tracks provided for playlist '{name}'")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"Creating Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
logger.info(f"{'Updating' if playlist_id else 'Creating'} Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
||||||
|
|
||||||
# Create playlist with tracks
|
# Create/Update playlist params
|
||||||
params = {
|
params = {
|
||||||
'name': name,
|
'name': name,
|
||||||
'songId': track_ids # Subsonic API accepts multiple songId parameters
|
'songId': track_ids # Subsonic API accepts multiple songId parameters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# If playlist_id is provided, it acts as an overwrite/update
|
||||||
|
if playlist_id:
|
||||||
|
params['playlistId'] = playlist_id
|
||||||
|
|
||||||
response = self._make_request('createPlaylist', params)
|
response = self._make_request('createPlaylist', params)
|
||||||
|
|
||||||
if response and response.get('status') == 'ok':
|
if response and response.get('status') == 'ok':
|
||||||
logger.info(f"✅ Created Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
logger.info(f"✅ {'Updated' if playlist_id else 'Created'} Navidrome playlist '{name}' with {len(track_ids)} tracks")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to create Navidrome playlist '{name}'")
|
logger.error(f"Failed to {'update' if playlist_id else 'create'} Navidrome playlist '{name}'")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating Navidrome playlist '{name}': {e}")
|
logger.error(f"Error {'updating' if playlist_id else 'creating'} Navidrome playlist '{name}': {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def copy_playlist(self, source_name: str, target_name: str) -> bool:
|
def copy_playlist(self, source_name: str, target_name: str) -> bool:
|
||||||
|
|
@ -614,36 +618,60 @@ class NavidromeClient:
|
||||||
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
|
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_playlists_by_name(self, name: str) -> List[NavidromePlaylistInfo]:
|
||||||
|
"""Get all playlists matching a specific name (case-insensitive)"""
|
||||||
|
matches = []
|
||||||
|
playlists = self.get_all_playlists()
|
||||||
|
for playlist in playlists:
|
||||||
|
if playlist.title.lower() == name.lower():
|
||||||
|
matches.append(playlist)
|
||||||
|
return matches
|
||||||
|
|
||||||
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
||||||
"""Update an existing playlist or create it if it doesn't exist"""
|
"""Update an existing playlist or create it if it doesn't exist. Handles duplicates."""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing_playlist = self.get_playlist_by_name(playlist_name)
|
# Find ALL existing playlists with this name to handle duplicates
|
||||||
|
existing_playlists = self.get_playlists_by_name(playlist_name)
|
||||||
|
|
||||||
# Check if backup is enabled in config
|
# Check if backup is enabled in config
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
create_backup = config_manager.get('playlist_sync.create_backup', True)
|
create_backup = config_manager.get('playlist_sync.create_backup', True)
|
||||||
|
|
||||||
if existing_playlist and create_backup:
|
# If we have existing playlists and want to backup, use the first one found
|
||||||
|
if existing_playlists and create_backup:
|
||||||
backup_name = f"{playlist_name} Backup"
|
backup_name = f"{playlist_name} Backup"
|
||||||
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
|
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync")
|
||||||
|
|
||||||
|
# We only need to backup once, even if duplicates exist
|
||||||
if self.copy_playlist(playlist_name, backup_name):
|
if self.copy_playlist(playlist_name, backup_name):
|
||||||
logger.info(f"✅ Backup created successfully")
|
logger.info(f"✅ Backup created successfully")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
|
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
|
||||||
|
|
||||||
if existing_playlist:
|
# STRATEGY: Update the first match, delete the rest
|
||||||
# Delete existing playlist
|
if existing_playlists:
|
||||||
response = self._make_request('deletePlaylist', {'id': existing_playlist.id})
|
primary_playlist = existing_playlists[0]
|
||||||
if response and response.get('status') == 'ok':
|
duplicates = existing_playlists[1:]
|
||||||
logger.info(f"Deleted existing Navidrome playlist '{playlist_name}'")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Could not delete existing playlist '{playlist_name}', creating anyway")
|
|
||||||
|
|
||||||
# Create new playlist with tracks
|
if duplicates:
|
||||||
|
logger.info(f"Found {len(duplicates)} duplicate playlists for '{playlist_name}'. Cleaning them up...")
|
||||||
|
for dup in duplicates:
|
||||||
|
try:
|
||||||
|
self._make_request('deletePlaylist', {'id': dup.id})
|
||||||
|
logger.info(f"Deleted duplicate playlist '{playlist_name}' (ID: {dup.id})")
|
||||||
|
except Exception as del_err:
|
||||||
|
logger.error(f"Error deleting duplicate playlist '{playlist_name}': {del_err}")
|
||||||
|
|
||||||
|
# Update the primary playlist using overwrite (passing playlistId)
|
||||||
|
logger.info(f"Updating existing playlist '{playlist_name}' (ID: {primary_playlist.id})")
|
||||||
|
return self.create_playlist(playlist_name, tracks, playlist_id=primary_playlist.id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No existing playlist, create new
|
||||||
|
logger.info(f"Creating new playlist '{playlist_name}'")
|
||||||
return self.create_playlist(playlist_name, tracks)
|
return self.create_playlist(playlist_name, tracks)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,43 @@ class PersonalizedPlaylistsService:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.spotify_client = spotify_client
|
self.spotify_client = spotify_client
|
||||||
|
|
||||||
|
def _get_active_source(self) -> str:
|
||||||
|
"""
|
||||||
|
Determine which music source is active for discovery.
|
||||||
|
Returns 'spotify' if Spotify is authenticated, 'itunes' otherwise.
|
||||||
|
"""
|
||||||
|
if self.spotify_client and hasattr(self.spotify_client, 'is_spotify_authenticated'):
|
||||||
|
if self.spotify_client.is_spotify_authenticated():
|
||||||
|
return 'spotify'
|
||||||
|
return 'itunes'
|
||||||
|
|
||||||
|
def _build_track_dict(self, row, source: str) -> Dict:
|
||||||
|
"""Build a standardized track dictionary from a database row."""
|
||||||
|
# Convert sqlite3.Row to dict if needed (Row objects don't support .get())
|
||||||
|
if hasattr(row, 'keys'):
|
||||||
|
row = dict(row)
|
||||||
|
|
||||||
|
track_data = row.get('track_data_json')
|
||||||
|
if isinstance(track_data, str):
|
||||||
|
try:
|
||||||
|
track_data = json.loads(track_data)
|
||||||
|
except:
|
||||||
|
track_data = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'track_id': row.get('spotify_track_id') or row.get('itunes_track_id'),
|
||||||
|
'spotify_track_id': row.get('spotify_track_id'),
|
||||||
|
'itunes_track_id': row.get('itunes_track_id'),
|
||||||
|
'track_name': row.get('track_name', 'Unknown'),
|
||||||
|
'artist_name': row.get('artist_name', 'Unknown'),
|
||||||
|
'album_name': row.get('album_name', 'Unknown'),
|
||||||
|
'album_cover_url': row.get('album_cover_url'),
|
||||||
|
'duration_ms': row.get('duration_ms', 0),
|
||||||
|
'popularity': row.get('popularity', 0),
|
||||||
|
'track_data_json': track_data,
|
||||||
|
'source': source
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_parent_genre(spotify_genre: str) -> str:
|
def get_parent_genre(spotify_genre: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -166,25 +203,30 @@ class PersonalizedPlaylistsService:
|
||||||
logger.error(f"Error getting forgotten favorites: {e}")
|
logger.error(f"Error getting forgotten favorites: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_decade_playlist(self, decade: int, limit: int = 100) -> List[Dict]:
|
def get_decade_playlist(self, decade: int, limit: int = 100, source: str = None) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get tracks from a specific decade from discovery pool with diversity filtering.
|
Get tracks from a specific decade from discovery pool with diversity filtering.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s)
|
decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s)
|
||||||
limit: Maximum tracks to return
|
limit: Maximum tracks to return
|
||||||
|
source: Optional source filter ('spotify' or 'itunes'), auto-detects if not provided
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
start_year = decade
|
start_year = decade
|
||||||
end_year = decade + 9
|
end_year = decade + 9
|
||||||
|
|
||||||
|
# Determine active source if not specified
|
||||||
|
active_source = source or self._get_active_source()
|
||||||
|
|
||||||
with self.database._get_connection() as conn:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Query discovery_pool - get 10x more for diversity filtering
|
# Query discovery_pool - get 10x more for diversity filtering, filtered by source
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
spotify_track_id,
|
spotify_track_id,
|
||||||
|
itunes_track_id,
|
||||||
track_name,
|
track_name,
|
||||||
artist_name,
|
artist_name,
|
||||||
album_name,
|
album_name,
|
||||||
|
|
@ -192,26 +234,20 @@ class PersonalizedPlaylistsService:
|
||||||
duration_ms,
|
duration_ms,
|
||||||
popularity,
|
popularity,
|
||||||
release_date,
|
release_date,
|
||||||
track_data_json
|
track_data_json,
|
||||||
|
source
|
||||||
FROM discovery_pool
|
FROM discovery_pool
|
||||||
WHERE release_date IS NOT NULL
|
WHERE release_date IS NOT NULL
|
||||||
AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?
|
AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?
|
||||||
|
AND source = ?
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", (start_year, end_year, limit * 10))
|
""", (start_year, end_year, active_source, limit * 10))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
all_tracks = []
|
all_tracks = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
track_dict = dict(row)
|
all_tracks.append(self._build_track_dict(row, active_source))
|
||||||
# Parse track_data_json if available
|
|
||||||
if track_dict.get('track_data_json'):
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
all_tracks.append(track_dict)
|
|
||||||
|
|
||||||
if not all_tracks:
|
if not all_tracks:
|
||||||
logger.warning(f"No tracks found for {decade}s")
|
logger.warning(f"No tracks found for {decade}s")
|
||||||
|
|
@ -268,22 +304,25 @@ class PersonalizedPlaylistsService:
|
||||||
logger.error(f"Error getting decade playlist for {decade}s: {e}")
|
logger.error(f"Error getting decade playlist for {decade}s: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_available_genres(self) -> List[Dict]:
|
def get_available_genres(self, source: str = None) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get list of consolidated parent genres with track counts from discovery pool.
|
Get list of consolidated parent genres with track counts from discovery pool.
|
||||||
Uses cached artist genres from database (populated during discovery scan).
|
Uses cached artist genres from database (populated during discovery scan).
|
||||||
Consolidates specific Spotify genres into broader parent categories.
|
Consolidates specific Spotify genres into broader parent categories.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Determine active source if not specified
|
||||||
|
active_source = source or self._get_active_source()
|
||||||
|
|
||||||
with self.database._get_connection() as conn:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Get all tracks with genres from discovery pool
|
# Get all tracks with genres from discovery pool, filtered by source
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT artist_genres
|
SELECT artist_genres
|
||||||
FROM discovery_pool
|
FROM discovery_pool
|
||||||
WHERE artist_genres IS NOT NULL
|
WHERE artist_genres IS NOT NULL AND source = ?
|
||||||
""")
|
""", (active_source,))
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|
@ -327,20 +366,24 @@ class PersonalizedPlaylistsService:
|
||||||
logger.error(f"Error getting available genres: {e}")
|
logger.error(f"Error getting available genres: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_genre_playlist(self, genre: str, limit: int = 50) -> List[Dict]:
|
def get_genre_playlist(self, genre: str, limit: int = 50, source: str = None) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get tracks from a specific genre with diversity filtering.
|
Get tracks from a specific genre with diversity filtering.
|
||||||
Uses cached artist genres from database (populated during discovery scan).
|
Uses cached artist genres from database (populated during discovery scan).
|
||||||
Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house").
|
Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house").
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Determine active source if not specified
|
||||||
|
active_source = source or self._get_active_source()
|
||||||
|
|
||||||
with self.database._get_connection() as conn:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Get all tracks with genres from discovery pool
|
# Get all tracks with genres from discovery pool, filtered by source
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
spotify_track_id,
|
spotify_track_id,
|
||||||
|
itunes_track_id,
|
||||||
track_name,
|
track_name,
|
||||||
artist_name,
|
artist_name,
|
||||||
album_name,
|
album_name,
|
||||||
|
|
@ -348,10 +391,12 @@ class PersonalizedPlaylistsService:
|
||||||
duration_ms,
|
duration_ms,
|
||||||
popularity,
|
popularity,
|
||||||
artist_genres,
|
artist_genres,
|
||||||
track_data_json
|
track_data_json,
|
||||||
|
source
|
||||||
FROM discovery_pool
|
FROM discovery_pool
|
||||||
WHERE artist_genres IS NOT NULL
|
WHERE artist_genres IS NOT NULL
|
||||||
""")
|
AND source = ?
|
||||||
|
""", (active_source,))
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
# Determine if this is a parent genre or specific genre
|
# Determine if this is a parent genre or specific genre
|
||||||
|
|
@ -372,7 +417,7 @@ class PersonalizedPlaylistsService:
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
artist_genres_json = row[7] # artist_genres column
|
artist_genres_json = row['artist_genres']
|
||||||
if artist_genres_json:
|
if artist_genres_json:
|
||||||
genres = json.loads(artist_genres_json)
|
genres = json.loads(artist_genres_json)
|
||||||
|
|
||||||
|
|
@ -388,23 +433,7 @@ class PersonalizedPlaylistsService:
|
||||||
break
|
break
|
||||||
|
|
||||||
if genre_match:
|
if genre_match:
|
||||||
# Convert row to dict (exclude artist_genres from output)
|
matching_tracks.append(self._build_track_dict(row, active_source))
|
||||||
track_dict = {
|
|
||||||
'spotify_track_id': row[0],
|
|
||||||
'track_name': row[1],
|
|
||||||
'artist_name': row[2],
|
|
||||||
'album_name': row[3],
|
|
||||||
'album_cover_url': row[4],
|
|
||||||
'duration_ms': row[5],
|
|
||||||
'popularity': row[6]
|
|
||||||
}
|
|
||||||
# Parse track_data_json if available
|
|
||||||
if row[8]: # track_data_json column
|
|
||||||
try:
|
|
||||||
track_dict['track_data_json'] = json.loads(row[8])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
matching_tracks.append(track_dict)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error parsing genres for track: {e}")
|
logger.debug(f"Error parsing genres for track: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
@ -475,39 +504,34 @@ class PersonalizedPlaylistsService:
|
||||||
|
|
||||||
def get_popular_picks(self, limit: int = 50) -> List[Dict]:
|
def get_popular_picks(self, limit: int = 50) -> List[Dict]:
|
||||||
"""Get high popularity tracks from discovery pool with diversity (max 2 tracks per album/artist)"""
|
"""Get high popularity tracks from discovery pool with diversity (max 2 tracks per album/artist)"""
|
||||||
|
# Determine active source
|
||||||
|
active_source = self._get_active_source()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self.database._get_connection() as conn:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Get more tracks than needed to allow for filtering
|
# Get more tracks than needed to allow for filtering, filtered by source
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
spotify_track_id,
|
spotify_track_id,
|
||||||
|
itunes_track_id,
|
||||||
track_name,
|
track_name,
|
||||||
artist_name,
|
artist_name,
|
||||||
album_name,
|
album_name,
|
||||||
album_cover_url,
|
album_cover_url,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
popularity,
|
popularity,
|
||||||
track_data_json
|
track_data_json,
|
||||||
|
source
|
||||||
FROM discovery_pool
|
FROM discovery_pool
|
||||||
WHERE popularity >= 60
|
WHERE popularity >= 60 AND source = ?
|
||||||
ORDER BY popularity DESC, RANDOM()
|
ORDER BY popularity DESC, RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", (limit * 3,)) # Get 3x more for diversity filtering
|
""", (active_source, limit * 3))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
all_tracks = []
|
all_tracks = [self._build_track_dict(row, active_source) for row in rows]
|
||||||
for row in rows:
|
|
||||||
track_dict = dict(row)
|
|
||||||
# Parse track_data_json if available
|
|
||||||
if track_dict.get('track_data_json'):
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
all_tracks.append(track_dict)
|
|
||||||
|
|
||||||
# Apply diversity constraint: max 2 tracks per album, max 3 per artist
|
# Apply diversity constraint: max 2 tracks per album, max 3 per artist
|
||||||
tracks_by_album = {}
|
tracks_by_album = {}
|
||||||
|
|
@ -531,7 +555,7 @@ class PersonalizedPlaylistsService:
|
||||||
if len(diverse_tracks) >= limit:
|
if len(diverse_tracks) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info(f"Popular Picks: Selected {len(diverse_tracks)} tracks with diversity")
|
logger.info(f"Popular Picks ({active_source}): Selected {len(diverse_tracks)} tracks with diversity")
|
||||||
return diverse_tracks[:limit]
|
return diverse_tracks[:limit]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -540,6 +564,9 @@ class PersonalizedPlaylistsService:
|
||||||
|
|
||||||
def get_hidden_gems(self, limit: int = 50) -> List[Dict]:
|
def get_hidden_gems(self, limit: int = 50) -> List[Dict]:
|
||||||
"""Get low popularity (underground/indie) tracks from discovery pool"""
|
"""Get low popularity (underground/indie) tracks from discovery pool"""
|
||||||
|
# Determine active source
|
||||||
|
active_source = self._get_active_source()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self.database._get_connection() as conn:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
@ -547,32 +574,23 @@ class PersonalizedPlaylistsService:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
spotify_track_id,
|
spotify_track_id,
|
||||||
|
itunes_track_id,
|
||||||
track_name,
|
track_name,
|
||||||
artist_name,
|
artist_name,
|
||||||
album_name,
|
album_name,
|
||||||
album_cover_url,
|
album_cover_url,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
popularity,
|
popularity,
|
||||||
track_data_json
|
track_data_json,
|
||||||
|
source
|
||||||
FROM discovery_pool
|
FROM discovery_pool
|
||||||
WHERE popularity < 40
|
WHERE popularity < 40 AND source = ?
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", (limit,))
|
""", (active_source, limit))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
tracks = []
|
return [self._build_track_dict(row, active_source) for row in rows]
|
||||||
for row in rows:
|
|
||||||
track_dict = dict(row)
|
|
||||||
# Parse track_data_json if available
|
|
||||||
if track_dict.get('track_data_json'):
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
tracks.append(track_dict)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting hidden gems: {e}")
|
logger.error(f"Error getting hidden gems: {e}")
|
||||||
|
|
@ -584,6 +602,9 @@ class PersonalizedPlaylistsService:
|
||||||
|
|
||||||
Different every time you call it!
|
Different every time you call it!
|
||||||
"""
|
"""
|
||||||
|
# Determine active source
|
||||||
|
active_source = self._get_active_source()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self.database._get_connection() as conn:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
@ -591,31 +612,23 @@ class PersonalizedPlaylistsService:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
spotify_track_id,
|
spotify_track_id,
|
||||||
|
itunes_track_id,
|
||||||
track_name,
|
track_name,
|
||||||
artist_name,
|
artist_name,
|
||||||
album_name,
|
album_name,
|
||||||
album_cover_url,
|
album_cover_url,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
popularity,
|
popularity,
|
||||||
track_data_json
|
track_data_json,
|
||||||
|
source
|
||||||
FROM discovery_pool
|
FROM discovery_pool
|
||||||
|
WHERE source = ?
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", (limit,))
|
""", (active_source, limit))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
tracks = []
|
return [self._build_track_dict(row, active_source) for row in rows]
|
||||||
for row in rows:
|
|
||||||
track_dict = dict(row)
|
|
||||||
# Parse track_data_json if available
|
|
||||||
if track_dict.get('track_data_json'):
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
tracks.append(track_dict)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting discovery shuffle: {e}")
|
logger.error(f"Error getting discovery shuffle: {e}")
|
||||||
|
|
@ -769,6 +782,9 @@ class PersonalizedPlaylistsService:
|
||||||
|
|
||||||
def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
|
def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
|
||||||
"""Get tracks from discovery pool matching genre or artist"""
|
"""Get tracks from discovery pool matching genre or artist"""
|
||||||
|
# Determine active source
|
||||||
|
active_source = self._get_active_source()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self.database._get_connection() as conn:
|
with self.database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
@ -776,32 +792,23 @@ class PersonalizedPlaylistsService:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
spotify_track_id,
|
spotify_track_id,
|
||||||
|
itunes_track_id,
|
||||||
track_name,
|
track_name,
|
||||||
artist_name,
|
artist_name,
|
||||||
album_name,
|
album_name,
|
||||||
album_cover_url,
|
album_cover_url,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
popularity,
|
popularity,
|
||||||
track_data_json
|
track_data_json,
|
||||||
|
source
|
||||||
FROM discovery_pool
|
FROM discovery_pool
|
||||||
WHERE artist_name LIKE ? OR track_name LIKE ?
|
WHERE (artist_name LIKE ? OR track_name LIKE ?) AND source = ?
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", (f'%{category}%', f'%{category}%', limit))
|
""", (f'%{category}%', f'%{category}%', active_source, limit))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
tracks = []
|
return [self._build_track_dict(row, active_source) for row in rows]
|
||||||
for row in rows:
|
|
||||||
track_dict = dict(row)
|
|
||||||
# Parse track_data_json if available
|
|
||||||
if track_dict.get('track_data_json'):
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
tracks.append(track_dict)
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting discovery tracks by category: {e}")
|
logger.error(f"Error getting discovery tracks by category: {e}")
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,31 @@ class SpotifyClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sp: Optional[spotipy.Spotify] = None
|
self.sp: Optional[spotipy.Spotify] = None
|
||||||
self.user_id: Optional[str] = None
|
self.user_id: Optional[str] = None
|
||||||
|
self._itunes_client = None # Lazy-loaded iTunes fallback
|
||||||
self._setup_client()
|
self._setup_client()
|
||||||
|
|
||||||
|
def _is_spotify_id(self, id_str: str) -> bool:
|
||||||
|
"""Check if an ID is a Spotify ID (alphanumeric) vs iTunes ID (numeric only)"""
|
||||||
|
if not id_str:
|
||||||
|
return False
|
||||||
|
# Spotify IDs contain letters and numbers, iTunes IDs are purely numeric
|
||||||
|
return not id_str.isdigit()
|
||||||
|
|
||||||
|
def _is_itunes_id(self, id_str: str) -> bool:
|
||||||
|
"""Check if an ID is an iTunes ID (numeric only)"""
|
||||||
|
if not id_str:
|
||||||
|
return False
|
||||||
|
return id_str.isdigit()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _itunes(self):
|
||||||
|
"""Lazy-load iTunes client for fallback when Spotify not authenticated"""
|
||||||
|
if self._itunes_client is None:
|
||||||
|
from core.itunes_client import iTunesClient
|
||||||
|
self._itunes_client = iTunesClient()
|
||||||
|
logger.info("iTunes fallback client initialized")
|
||||||
|
return self._itunes_client
|
||||||
|
|
||||||
def reload_config(self):
|
def reload_config(self):
|
||||||
"""Reload configuration and re-initialize client"""
|
"""Reload configuration and re-initialize client"""
|
||||||
self._setup_client()
|
self._setup_client()
|
||||||
|
|
@ -201,7 +224,20 @@ class SpotifyClient:
|
||||||
self.sp = None
|
self.sp = None
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
"""Check if Spotify client is authenticated and working"""
|
"""
|
||||||
|
Check if client can service metadata requests.
|
||||||
|
Returns True if Spotify is authenticated OR iTunes fallback is available.
|
||||||
|
For Spotify-specific auth check, use is_spotify_authenticated().
|
||||||
|
"""
|
||||||
|
# If Spotify is authenticated, we're good
|
||||||
|
if self.is_spotify_authenticated():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# iTunes fallback is always available
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_spotify_authenticated(self) -> bool:
|
||||||
|
"""Check if Spotify client is specifically authenticated (not just iTunes fallback)"""
|
||||||
if self.sp is None:
|
if self.sp is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -228,7 +264,7 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_user_playlists(self) -> List[Playlist]:
|
def get_user_playlists(self) -> List[Playlist]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -262,7 +298,7 @@ class SpotifyClient:
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_user_playlists_metadata_only(self) -> List[Playlist]:
|
def get_user_playlists_metadata_only(self) -> List[Playlist]:
|
||||||
"""Get playlists without fetching all track details for faster loading"""
|
"""Get playlists without fetching all track details for faster loading"""
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -278,23 +314,42 @@ class SpotifyClient:
|
||||||
offset = 0
|
offset = 0
|
||||||
total_fetched = 0
|
total_fetched = 0
|
||||||
|
|
||||||
|
logger.info("Beginning fetch of user playlists...")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
results = self.sp.current_user_playlists(limit=limit, offset=offset)
|
results = self.sp.current_user_playlists(limit=limit, offset=offset)
|
||||||
|
|
||||||
if not results or 'items' not in results:
|
if not results or 'items' not in results:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Log expected total on first page
|
||||||
|
if offset == 0:
|
||||||
|
expected_total = results.get('total', 'Unknown')
|
||||||
|
logger.info(f"Spotify reports {expected_total} total playlists to fetch.")
|
||||||
|
|
||||||
batch_count = 0
|
batch_count = 0
|
||||||
for playlist_data in results['items']:
|
for playlist_data in results['items']:
|
||||||
|
try:
|
||||||
# Spotify API already returns all playlists the user has access to
|
# Spotify API already returns all playlists the user has access to
|
||||||
# (owned + followed), so no need to filter
|
# (owned + followed), so no need to filter
|
||||||
|
|
||||||
|
# Handle potential missing owner data safely
|
||||||
|
if not playlist_data.get('owner'):
|
||||||
|
playlist_data['owner'] = {'display_name': 'Unknown Owner', 'id': 'unknown'}
|
||||||
|
elif not playlist_data['owner'].get('display_name'):
|
||||||
|
playlist_data['owner']['display_name'] = 'Unknown'
|
||||||
|
|
||||||
# Create playlist with empty tracks list for now
|
# Create playlist with empty tracks list for now
|
||||||
playlist = Playlist.from_spotify_playlist(playlist_data, [])
|
playlist = Playlist.from_spotify_playlist(playlist_data, [])
|
||||||
playlists.append(playlist)
|
playlists.append(playlist)
|
||||||
batch_count += 1
|
batch_count += 1
|
||||||
|
|
||||||
|
except Exception as p_error:
|
||||||
|
p_name = playlist_data.get('name', 'Unknown') if playlist_data else 'None'
|
||||||
|
logger.warning(f"Skipping malformed playlist '{p_name}': {p_error}")
|
||||||
|
|
||||||
total_fetched += batch_count
|
total_fetched += batch_count
|
||||||
logger.info(f"Retrieved {batch_count} playlists in batch (offset {offset}), total: {total_fetched}")
|
logger.info(f"Retrieved {batch_count} playlists in batch (offset {offset}), total so far: {total_fetched}")
|
||||||
|
|
||||||
# Check if we've fetched all playlists
|
# Check if we've fetched all playlists
|
||||||
if len(results['items']) < limit or not results.get('next'):
|
if len(results['items']) < limit or not results.get('next'):
|
||||||
|
|
@ -307,12 +362,16 @@ class SpotifyClient:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching user playlists metadata: {e}")
|
logger.error(f"Error fetching user playlists metadata: {e}")
|
||||||
|
# Return partial results if we crashed mid-way but have some data
|
||||||
|
if playlists:
|
||||||
|
logger.info(f"Returning {len(playlists)} playlists fetched before error.")
|
||||||
|
return playlists
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_saved_tracks_count(self) -> int:
|
def get_saved_tracks_count(self) -> int:
|
||||||
"""Get the total count of user's saved/liked songs without fetching all tracks"""
|
"""Get the total count of user's saved/liked songs without fetching all tracks"""
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
@ -331,7 +390,7 @@ class SpotifyClient:
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_saved_tracks(self) -> List[Track]:
|
def get_saved_tracks(self) -> List[Track]:
|
||||||
"""Fetch all user's saved/liked songs from Spotify"""
|
"""Fetch all user's saved/liked songs from Spotify"""
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -373,7 +432,7 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
|
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
@ -397,7 +456,7 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
|
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -411,9 +470,8 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
||||||
if not self.is_authenticated():
|
"""Search for tracks - falls back to iTunes if Spotify not authenticated"""
|
||||||
return []
|
if self.is_spotify_authenticated():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = self.sp.search(q=query, type='track', limit=limit)
|
results = self.sp.search(q=query, type='track', limit=limit)
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
@ -425,15 +483,17 @@ class SpotifyClient:
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching tracks: {e}")
|
logger.error(f"Error searching tracks via Spotify: {e}")
|
||||||
return []
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback
|
||||||
|
logger.debug(f"Using iTunes fallback for track search: {query}")
|
||||||
|
return self._itunes.search_tracks(query, limit)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||||
"""Search for artists using Spotify API"""
|
"""Search for artists - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = self.sp.search(q=query, type='artist', limit=limit)
|
results = self.sp.search(q=query, type='artist', limit=limit)
|
||||||
artists = []
|
artists = []
|
||||||
|
|
@ -445,15 +505,17 @@ class SpotifyClient:
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching artists: {e}")
|
logger.error(f"Error searching artists via Spotify: {e}")
|
||||||
return []
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback
|
||||||
|
logger.debug(f"Using iTunes fallback for artist search: {query}")
|
||||||
|
return self._itunes.search_artists(query, limit)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
||||||
"""Search for albums using Spotify API"""
|
"""Search for albums - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = self.sp.search(q=query, type='album', limit=limit)
|
results = self.sp.search(q=query, type='album', limit=limit)
|
||||||
albums = []
|
albums = []
|
||||||
|
|
@ -465,15 +527,17 @@ class SpotifyClient:
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching albums: {e}")
|
logger.error(f"Error searching albums via Spotify: {e}")
|
||||||
return []
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback
|
||||||
|
logger.debug(f"Using iTunes fallback for album search: {query}")
|
||||||
|
return self._itunes.search_albums(query, limit)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get detailed track information including album data and track number"""
|
"""Get detailed track information - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
track_data = self.sp.track(track_id)
|
track_data = self.sp.track(track_id)
|
||||||
|
|
||||||
|
|
@ -503,12 +567,20 @@ class SpotifyClient:
|
||||||
return track_data
|
return track_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching track details: {e}")
|
logger.error(f"Error fetching track details via Spotify: {e}")
|
||||||
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback - only if ID is numeric (iTunes format)
|
||||||
|
if self._is_itunes_id(track_id):
|
||||||
|
logger.debug(f"Using iTunes fallback for track details: {track_id}")
|
||||||
|
return self._itunes.get_track_details(track_id)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Cannot use iTunes fallback for Spotify track ID: {track_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -521,24 +593,28 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get album information including tracks"""
|
"""Get album information - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
album_data = self.sp.album(album_id)
|
album_data = self.sp.album(album_id)
|
||||||
return album_data
|
return album_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching album: {e}")
|
logger.error(f"Error fetching album via Spotify: {e}")
|
||||||
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback - only if ID is numeric (iTunes format)
|
||||||
|
if self._is_itunes_id(album_id):
|
||||||
|
logger.debug(f"Using iTunes fallback for album: {album_id}")
|
||||||
|
return self._itunes.get_album(album_id)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get album tracks with pagination to fetch all tracks"""
|
"""Get album tracks - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get first page of tracks
|
# Get first page of tracks
|
||||||
first_page = self.sp.album_tracks(album_id)
|
first_page = self.sp.album_tracks(album_id)
|
||||||
|
|
@ -567,15 +643,21 @@ class SpotifyClient:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching album tracks: {e}")
|
logger.error(f"Error fetching album tracks via Spotify: {e}")
|
||||||
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback - only if ID is numeric (iTunes format)
|
||||||
|
if self._is_itunes_id(album_id):
|
||||||
|
logger.debug(f"Using iTunes fallback for album tracks: {album_id}")
|
||||||
|
return self._itunes.get_album_tracks(album_id)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Cannot use iTunes fallback for Spotify album ID: {album_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
||||||
"""Get albums by artist ID"""
|
"""Get albums by artist ID - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
albums = []
|
albums = []
|
||||||
results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit)
|
results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit)
|
||||||
|
|
@ -592,12 +674,20 @@ class SpotifyClient:
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching artist albums: {e}")
|
logger.error(f"Error fetching artist albums via Spotify: {e}")
|
||||||
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback - only if ID is numeric (iTunes format)
|
||||||
|
if self._is_itunes_id(artist_id):
|
||||||
|
logger.debug(f"Using iTunes fallback for artist albums: {artist_id}")
|
||||||
|
return self._itunes.get_artist_albums(artist_id, album_type, limit)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -609,19 +699,25 @@ class SpotifyClient:
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get full artist details from Spotify API.
|
Get full artist details - falls back to iTunes if Spotify not authenticated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
artist_id: Spotify artist ID
|
artist_id: Artist ID (Spotify or iTunes depending on authentication)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with artist data including images, genres, popularity
|
Dictionary with artist data including images, genres, popularity
|
||||||
"""
|
"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return self.sp.artist(artist_id)
|
return self.sp.artist(artist_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching artist {artist_id}: {e}")
|
logger.error(f"Error fetching artist via Spotify: {e}")
|
||||||
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
|
# iTunes fallback - only if ID is numeric (iTunes format)
|
||||||
|
if self._is_itunes_id(artist_id):
|
||||||
|
logger.debug(f"Using iTunes fallback for artist: {artist_id}")
|
||||||
|
return self._itunes.get_artist(artist_id)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Cannot use iTunes fallback for Spotify artist ID: {artist_id}")
|
||||||
return None
|
return None
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -159,7 +159,7 @@ class WishlistService:
|
||||||
'id': spotify_data.get('id'),
|
'id': spotify_data.get('id'),
|
||||||
'name': spotify_data.get('name', 'Unknown Track'),
|
'name': spotify_data.get('name', 'Unknown Track'),
|
||||||
'artists': spotify_data.get('artists', []),
|
'artists': spotify_data.get('artists', []),
|
||||||
'album': spotify_data.get('album', {}),
|
'album': spotify_data.get('album') or {},
|
||||||
'duration_ms': spotify_data.get('duration_ms', 0),
|
'duration_ms': spotify_data.get('duration_ms', 0),
|
||||||
'preview_url': spotify_data.get('preview_url'),
|
'preview_url': spotify_data.get('preview_url'),
|
||||||
'external_urls': spotify_data.get('external_urls', {}),
|
'external_urls': spotify_data.get('external_urls', {}),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
270
tools/diagnose_itunes_discover.py
Normal file
270
tools/diagnose_itunes_discover.py
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Diagnostic script to check iTunes data availability for the Discover page.
|
||||||
|
|
||||||
|
Run this script to identify issues with iTunes data population:
|
||||||
|
- Similar artists missing iTunes IDs
|
||||||
|
- Discovery pool tracks by source
|
||||||
|
- Recent albums by source
|
||||||
|
- Curated playlists status
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/diagnose_itunes_discover.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from database.music_database import MusicDatabase
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_itunes_discover():
|
||||||
|
"""Run diagnostic checks for iTunes discover data."""
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("iTunes Discover Page Diagnostic Report")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
db = MusicDatabase()
|
||||||
|
|
||||||
|
# 1. Check Similar Artists
|
||||||
|
print("\n[1] SIMILAR ARTISTS")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Total similar artists
|
||||||
|
cursor.execute("SELECT COUNT(*) as total FROM similar_artists")
|
||||||
|
total = cursor.fetchone()['total']
|
||||||
|
|
||||||
|
# With iTunes IDs
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_itunes_id IS NOT NULL")
|
||||||
|
with_itunes = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
# With Spotify IDs
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM similar_artists WHERE similar_artist_spotify_id IS NOT NULL")
|
||||||
|
with_spotify = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
# With both
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM similar_artists
|
||||||
|
WHERE similar_artist_itunes_id IS NOT NULL
|
||||||
|
AND similar_artist_spotify_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
with_both = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
print(f" Total similar artists: {total}")
|
||||||
|
print(f" With iTunes ID: {with_itunes} ({100*with_itunes/total:.1f}%)" if total > 0 else " With iTunes ID: 0")
|
||||||
|
print(f" With Spotify ID: {with_spotify} ({100*with_spotify/total:.1f}%)" if total > 0 else " With Spotify ID: 0")
|
||||||
|
print(f" With BOTH IDs: {with_both} ({100*with_both/total:.1f}%)" if total > 0 else " With BOTH IDs: 0")
|
||||||
|
|
||||||
|
if with_itunes == 0 and total > 0:
|
||||||
|
print(" [CRITICAL] No similar artists have iTunes IDs - Hero section will be empty!")
|
||||||
|
elif with_itunes < total * 0.5:
|
||||||
|
print(" [WARNING] Less than 50% of similar artists have iTunes IDs")
|
||||||
|
else:
|
||||||
|
print(" [OK] iTunes coverage is adequate")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] Could not check similar artists: {e}")
|
||||||
|
|
||||||
|
# 2. Check Discovery Pool
|
||||||
|
print("\n[2] DISCOVERY POOL")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Total tracks
|
||||||
|
cursor.execute("SELECT COUNT(*) as total FROM discovery_pool")
|
||||||
|
total = cursor.fetchone()['total']
|
||||||
|
|
||||||
|
# By source
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT source, COUNT(*) as count
|
||||||
|
FROM discovery_pool
|
||||||
|
GROUP BY source
|
||||||
|
""")
|
||||||
|
source_counts = {row['source']: row['count'] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
print(f" Total tracks: {total}")
|
||||||
|
print(f" Spotify tracks: {source_counts.get('spotify', 0)}")
|
||||||
|
print(f" iTunes tracks: {source_counts.get('itunes', 0)}")
|
||||||
|
|
||||||
|
if source_counts.get('itunes', 0) == 0 and total > 0:
|
||||||
|
print(" [CRITICAL] No iTunes tracks in discovery pool - Fresh Tape/Archives will be empty!")
|
||||||
|
elif source_counts.get('itunes', 0) < total * 0.3:
|
||||||
|
print(" [WARNING] Low iTunes track count in discovery pool")
|
||||||
|
else:
|
||||||
|
print(" [OK] iTunes tracks present")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] Could not check discovery pool: {e}")
|
||||||
|
|
||||||
|
# 3. Check Recent Albums
|
||||||
|
print("\n[3] RECENT ALBUMS CACHE")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Total albums
|
||||||
|
cursor.execute("SELECT COUNT(*) as total FROM discovery_recent_albums")
|
||||||
|
total = cursor.fetchone()['total']
|
||||||
|
|
||||||
|
# By source
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT source, COUNT(*) as count
|
||||||
|
FROM discovery_recent_albums
|
||||||
|
GROUP BY source
|
||||||
|
""")
|
||||||
|
source_counts = {row['source']: row['count'] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
print(f" Total recent albums: {total}")
|
||||||
|
print(f" Spotify albums: {source_counts.get('spotify', 0)}")
|
||||||
|
print(f" iTunes albums: {source_counts.get('itunes', 0)}")
|
||||||
|
|
||||||
|
if source_counts.get('itunes', 0) == 0 and total > 0:
|
||||||
|
print(" [CRITICAL] No iTunes albums cached - Recent Releases section will be empty!")
|
||||||
|
elif source_counts.get('itunes', 0) < 5:
|
||||||
|
print(" [WARNING] Very few iTunes albums cached")
|
||||||
|
else:
|
||||||
|
print(" [OK] iTunes albums cached")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] Could not check recent albums: {e}")
|
||||||
|
|
||||||
|
# 4. Check Curated Playlists
|
||||||
|
print("\n[4] CURATED PLAYLISTS")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
playlists_to_check = [
|
||||||
|
'release_radar',
|
||||||
|
'release_radar_spotify',
|
||||||
|
'release_radar_itunes',
|
||||||
|
'discovery_weekly',
|
||||||
|
'discovery_weekly_spotify',
|
||||||
|
'discovery_weekly_itunes'
|
||||||
|
]
|
||||||
|
|
||||||
|
for playlist_type in playlists_to_check:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT track_ids_json FROM discovery_curated_playlists
|
||||||
|
WHERE playlist_type = ?
|
||||||
|
""", (playlist_type,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
track_ids = json.loads(row['track_ids_json'])
|
||||||
|
status = f"{len(track_ids)} tracks"
|
||||||
|
if len(track_ids) == 0:
|
||||||
|
status += " [EMPTY]"
|
||||||
|
else:
|
||||||
|
status = "[NOT FOUND]"
|
||||||
|
|
||||||
|
print(f" {playlist_type}: {status}")
|
||||||
|
|
||||||
|
# Check iTunes-specific playlists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT track_ids_json FROM discovery_curated_playlists
|
||||||
|
WHERE playlist_type = 'release_radar_itunes'
|
||||||
|
""")
|
||||||
|
itunes_rr = cursor.fetchone()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT track_ids_json FROM discovery_curated_playlists
|
||||||
|
WHERE playlist_type = 'discovery_weekly_itunes'
|
||||||
|
""")
|
||||||
|
itunes_dw = cursor.fetchone()
|
||||||
|
|
||||||
|
if not itunes_rr or len(json.loads(itunes_rr['track_ids_json'])) == 0:
|
||||||
|
print("\n [CRITICAL] release_radar_itunes is empty or missing!")
|
||||||
|
if not itunes_dw or len(json.loads(itunes_dw['track_ids_json'])) == 0:
|
||||||
|
print(" [CRITICAL] discovery_weekly_itunes is empty or missing!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] Could not check curated playlists: {e}")
|
||||||
|
|
||||||
|
# 5. Check Watchlist Artists
|
||||||
|
print("\n[5] WATCHLIST ARTISTS")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Total artists
|
||||||
|
cursor.execute("SELECT COUNT(*) as total FROM watchlist_artists")
|
||||||
|
total = cursor.fetchone()['total']
|
||||||
|
|
||||||
|
# With iTunes IDs
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE itunes_artist_id IS NOT NULL")
|
||||||
|
with_itunes = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
# With Spotify IDs
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM watchlist_artists WHERE spotify_artist_id IS NOT NULL")
|
||||||
|
with_spotify = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
print(f" Total watchlist artists: {total}")
|
||||||
|
print(f" With iTunes ID: {with_itunes} ({100*with_itunes/total:.1f}%)" if total > 0 else " With iTunes ID: 0")
|
||||||
|
print(f" With Spotify ID: {with_spotify} ({100*with_spotify/total:.1f}%)" if total > 0 else " With Spotify ID: 0")
|
||||||
|
|
||||||
|
if with_itunes == 0 and total > 0:
|
||||||
|
print(" [WARNING] No watchlist artists have iTunes IDs - source artist data limited")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] Could not check watchlist artists: {e}")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SUMMARY & RECOMMENDED ACTIONS")
|
||||||
|
print("=" * 60)
|
||||||
|
print("""
|
||||||
|
If you see [CRITICAL] or [WARNING] messages above, follow these steps:
|
||||||
|
|
||||||
|
QUICK FIX - Force Refresh Discover Data:
|
||||||
|
-----------------------------------------
|
||||||
|
Call the API endpoint to refresh discover data:
|
||||||
|
curl -X POST http://localhost:5000/api/discover/refresh
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Cache recent albums from your watchlist artists
|
||||||
|
- Create curated playlists (Release Radar & Discovery Weekly)
|
||||||
|
|
||||||
|
FULL FIX - Run Watchlist Scan:
|
||||||
|
------------------------------
|
||||||
|
1. Go to the web UI Settings page
|
||||||
|
2. Click "Scan Watchlist" button
|
||||||
|
3. Wait for scan to complete
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Fetch similar artists from MusicMap for each watchlist artist
|
||||||
|
- Populate the discovery pool with tracks
|
||||||
|
- Cache recent albums
|
||||||
|
- Create curated playlists
|
||||||
|
|
||||||
|
ROOT CAUSE NOTES:
|
||||||
|
-----------------
|
||||||
|
- Similar artists = 0: MusicMap fetch may have failed. Watchlist scan needed.
|
||||||
|
- Recent albums = 0: cache_discovery_recent_albums() needs to run.
|
||||||
|
- Curated playlists missing: curate_discovery_playlists() needs to run.
|
||||||
|
|
||||||
|
The discover page will now fall back to watchlist artists if similar
|
||||||
|
artists are not available, so basic functionality should still work.
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
diagnose_itunes_discover()
|
||||||
1559
web_server.py
1559
web_server.py
File diff suppressed because it is too large
Load diff
|
|
@ -131,7 +131,7 @@
|
||||||
|
|
||||||
<!-- Version Section -->
|
<!-- Version Section -->
|
||||||
<div class="version-section">
|
<div class="version-section">
|
||||||
<button class="version-button" onclick="showVersionInfo()">v1.3</button>
|
<button class="version-button" onclick="showVersionInfo()">v1.4</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Section -->
|
<!-- Status Section -->
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
<h4 class="status-title">Service Status</h4>
|
<h4 class="status-title">Service Status</h4>
|
||||||
<div class="status-indicator" id="spotify-indicator">
|
<div class="status-indicator" id="spotify-indicator">
|
||||||
<span class="status-dot disconnected"></span>
|
<span class="status-dot disconnected"></span>
|
||||||
<span class="status-name">Spotify</span>
|
<span class="status-name" id="music-source-name">Spotify</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-indicator" id="media-server-indicator">
|
<div class="status-indicator" id="media-server-indicator">
|
||||||
<span class="status-dot disconnected"></span>
|
<span class="status-dot disconnected"></span>
|
||||||
|
|
@ -175,7 +175,7 @@
|
||||||
<div class="service-status-grid">
|
<div class="service-status-grid">
|
||||||
<div class="service-card" id="spotify-service-card">
|
<div class="service-card" id="spotify-service-card">
|
||||||
<div class="service-card-header">
|
<div class="service-card-header">
|
||||||
<span class="service-card-title">Spotify</span>
|
<span class="service-card-title" id="music-source-title">Spotify</span>
|
||||||
<span class="service-card-indicator disconnected"
|
<span class="service-card-indicator disconnected"
|
||||||
id="spotify-status-indicator">●</span>
|
id="spotify-status-indicator">●</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3332,7 +3332,8 @@
|
||||||
|
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<h3 class="config-section-title">Content Filters</h3>
|
<h3 class="config-section-title">Content Filters</h3>
|
||||||
<p class="config-section-subtitle">Check to INCLUDE, leave unchecked to EXCLUDE (default: all excluded)</p>
|
<p class="config-section-subtitle">Check to INCLUDE, leave unchecked to EXCLUDE (default: all
|
||||||
|
excluded)</p>
|
||||||
|
|
||||||
<div class="config-options">
|
<div class="config-options">
|
||||||
<label class="config-option">
|
<label class="config-option">
|
||||||
|
|
@ -3341,7 +3342,8 @@
|
||||||
<div class="config-option-icon">🎤</div>
|
<div class="config-option-icon">🎤</div>
|
||||||
<div class="config-option-text">
|
<div class="config-option-text">
|
||||||
<span class="config-option-title">Include Live Versions</span>
|
<span class="config-option-title">Include Live Versions</span>
|
||||||
<span class="config-option-description">Check to include live performances and concerts</span>
|
<span class="config-option-description">Check to include live performances and
|
||||||
|
concerts</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -3352,7 +3354,8 @@
|
||||||
<div class="config-option-icon">🎧</div>
|
<div class="config-option-icon">🎧</div>
|
||||||
<div class="config-option-text">
|
<div class="config-option-text">
|
||||||
<span class="config-option-title">Include Remixes</span>
|
<span class="config-option-title">Include Remixes</span>
|
||||||
<span class="config-option-description">Check to include remix versions and edits</span>
|
<span class="config-option-description">Check to include remix versions and
|
||||||
|
edits</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -3363,7 +3366,8 @@
|
||||||
<div class="config-option-icon">🎸</div>
|
<div class="config-option-icon">🎸</div>
|
||||||
<div class="config-option-text">
|
<div class="config-option-text">
|
||||||
<span class="config-option-title">Include Acoustic Versions</span>
|
<span class="config-option-title">Include Acoustic Versions</span>
|
||||||
<span class="config-option-description">Check to include acoustic and stripped versions</span>
|
<span class="config-option-description">Check to include acoustic and stripped
|
||||||
|
versions</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -3374,7 +3378,8 @@
|
||||||
<div class="config-option-icon">📀</div>
|
<div class="config-option-icon">📀</div>
|
||||||
<div class="config-option-text">
|
<div class="config-option-text">
|
||||||
<span class="config-option-title">Include Compilations</span>
|
<span class="config-option-title">Include Compilations</span>
|
||||||
<span class="config-option-description">Check to include greatest hits and collections</span>
|
<span class="config-option-description">Check to include greatest hits and
|
||||||
|
collections</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ let currentStream = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
track: null
|
track: null
|
||||||
};
|
};
|
||||||
|
let currentMusicSourceName = 'Spotify'; // 'Spotify' or 'Apple Music' - updated from status endpoint
|
||||||
|
|
||||||
// Streaming state management (enhanced functionality)
|
// Streaming state management (enhanced functionality)
|
||||||
let streamStatusPoller = null;
|
let streamStatusPoller = null;
|
||||||
|
|
@ -2172,7 +2173,8 @@ async function testConnection(service) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast(`${service} connection successful`, 'success');
|
// Use backend's message which contains dynamic source name (Spotify or Apple Music)
|
||||||
|
showToast(result.message || `${service} connection successful`, 'success');
|
||||||
|
|
||||||
// Load music libraries after successful connection
|
// Load music libraries after successful connection
|
||||||
if (service === 'plex') {
|
if (service === 'plex') {
|
||||||
|
|
@ -2205,7 +2207,8 @@ async function testDashboardConnection(service) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast(`${service} service verified`, 'success');
|
// Use backend's message which contains dynamic source name (Spotify or Apple Music)
|
||||||
|
showToast(result.message || `${service} service verified`, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast(`${service} service check failed: ${result.error}`, 'error');
|
showToast(`${service} service check failed: ${result.error}`, 'error');
|
||||||
}
|
}
|
||||||
|
|
@ -2689,6 +2692,57 @@ function initializeSearchModeToggle() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Lazy load artist images that are missing
|
||||||
|
lazyLoadEnhancedSearchArtistImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy load artist images for enhanced search results
|
||||||
|
async function lazyLoadEnhancedSearchArtistImages() {
|
||||||
|
const artistLists = [
|
||||||
|
document.getElementById('enh-db-artists-list'),
|
||||||
|
document.getElementById('enh-spotify-artists-list')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const list of artistLists) {
|
||||||
|
if (!list) continue;
|
||||||
|
|
||||||
|
const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]');
|
||||||
|
if (cardsNeedingImages.length === 0) continue;
|
||||||
|
|
||||||
|
console.log(`🖼️ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`);
|
||||||
|
|
||||||
|
for (const card of cardsNeedingImages) {
|
||||||
|
const artistId = card.dataset.artistId;
|
||||||
|
if (!artistId) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/artist/${artistId}/image`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.image_url) {
|
||||||
|
// Find the placeholder and replace with image
|
||||||
|
const placeholder = card.querySelector('.enh-item-image-placeholder');
|
||||||
|
if (placeholder) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = data.image_url;
|
||||||
|
img.className = 'enh-item-image artist-image';
|
||||||
|
img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist';
|
||||||
|
placeholder.replaceWith(img);
|
||||||
|
|
||||||
|
// Apply dynamic glow
|
||||||
|
extractImageColors(data.image_url, (colors) => {
|
||||||
|
applyDynamicGlow(card, colors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
card.dataset.needsImage = 'false';
|
||||||
|
console.log(`✅ Loaded image for artist ${artistId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Failed to load image for artist ${artistId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(durationMs) {
|
function formatDuration(durationMs) {
|
||||||
|
|
@ -2737,6 +2791,11 @@ function initializeSearchModeToggle() {
|
||||||
// Add appropriate card class
|
// Add appropriate card class
|
||||||
if (isArtist) {
|
if (isArtist) {
|
||||||
elem.className = 'enh-compact-item artist-card';
|
elem.className = 'enh-compact-item artist-card';
|
||||||
|
// Add data attributes for lazy loading
|
||||||
|
if (item.id) {
|
||||||
|
elem.dataset.artistId = item.id;
|
||||||
|
elem.dataset.needsImage = config.image ? 'false' : 'true';
|
||||||
|
}
|
||||||
} else if (isAlbum) {
|
} else if (isAlbum) {
|
||||||
elem.className = 'enh-compact-item album-card';
|
elem.className = 'enh-compact-item album-card';
|
||||||
} else if (isTrack) {
|
} else if (isTrack) {
|
||||||
|
|
@ -2760,7 +2819,7 @@ function initializeSearchModeToggle() {
|
||||||
|
|
||||||
const imageHtml = config.image
|
const imageHtml = config.image
|
||||||
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
|
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
|
||||||
: `<div class="${placeholderClass}">${config.placeholder}</div>`;
|
: `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
|
||||||
|
|
||||||
const badgeHtml = config.badge
|
const badgeHtml = config.badge
|
||||||
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
|
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
|
||||||
|
|
@ -5017,12 +5076,31 @@ async function cleanupDownloadProcess(playlistId) {
|
||||||
if (process.batchId) {
|
if (process.batchId) {
|
||||||
try {
|
try {
|
||||||
console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`);
|
console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`);
|
||||||
|
const response = await fetch('/api/playlists/cleanup_batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ batch_id: process.batchId })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle deferred cleanup (202 = wishlist processing in progress)
|
||||||
|
if (response.status === 202) {
|
||||||
|
console.log(`⏳ Wishlist processing in progress for batch ${process.batchId}, will retry cleanup in 2s...`);
|
||||||
|
// Retry cleanup after delay to allow wishlist processing to complete
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
await fetch('/api/playlists/cleanup_batch', {
|
await fetch('/api/playlists/cleanup_batch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ batch_id: process.batchId })
|
body: JSON.stringify({ batch_id: process.batchId })
|
||||||
});
|
});
|
||||||
|
console.log(`✅ Delayed cleanup completed for batch: ${process.batchId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Delayed cleanup failed:`, error);
|
||||||
|
}
|
||||||
|
}, 2000); // 2 second delay
|
||||||
|
} else {
|
||||||
console.log(`✅ Server cleanup completed for batch: ${process.batchId}`);
|
console.log(`✅ Server cleanup completed for batch: ${process.batchId}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`⚠️ Failed to send cleanup request to server:`, error);
|
console.warn(`⚠️ Failed to send cleanup request to server:`, error);
|
||||||
// Don't show toast for cleanup failures - they're not user-facing
|
// Don't show toast for cleanup failures - they're not user-facing
|
||||||
|
|
@ -7334,8 +7412,8 @@ async function startMissingTracksProcess(playlistId) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// If this is an artist album download, use album name and include full context
|
// If this is an artist album download, use album name and include full context
|
||||||
// Match both 'artist_album_' and 'enhanced_search_album_' prefixes
|
// Match 'artist_album_', 'enhanced_search_album_', and 'discover_album_' prefixes
|
||||||
if (playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_')) {
|
if (playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('discover_album_')) {
|
||||||
requestBody.playlist_name = process.album?.name || process.playlist.name;
|
requestBody.playlist_name = process.album?.name || process.playlist.name;
|
||||||
requestBody.is_album_download = true;
|
requestBody.is_album_download = true;
|
||||||
requestBody.album_context = process.album; // Full Spotify album object
|
requestBody.album_context = process.album; // Full Spotify album object
|
||||||
|
|
@ -9898,7 +9976,8 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) {
|
||||||
// Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure
|
// Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure
|
||||||
let state, result;
|
let state, result;
|
||||||
if (platform === 'youtube') {
|
if (platform === 'youtube') {
|
||||||
state = youtubePlaylistStates[identifier];
|
// Check both states - ListenBrainz also uses YouTube modal infrastructure
|
||||||
|
state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
|
||||||
} else if (platform === 'tidal') {
|
} else if (platform === 'tidal') {
|
||||||
state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure
|
state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure
|
||||||
} else if (platform === 'beatport') {
|
} else if (platform === 'beatport') {
|
||||||
|
|
@ -10051,15 +10130,23 @@ async function searchDiscoveryFix() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine discovery source from state
|
||||||
|
const identifier = currentDiscoveryFix.identifier;
|
||||||
|
const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
|
||||||
|
const discoverySource = state?.discovery_source || state?.discoverySource || 'spotify';
|
||||||
|
const useItunes = discoverySource === 'itunes';
|
||||||
|
|
||||||
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
|
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
|
||||||
resultsContainer.innerHTML = '<div class="loading">🔍 Searching Spotify...</div>';
|
const sourceLabel = useItunes ? 'iTunes' : 'Spotify';
|
||||||
|
resultsContainer.innerHTML = `<div class="loading">🔍 Searching ${sourceLabel}...</div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build search query
|
// Build search query
|
||||||
const query = `${artistInput} ${trackInput}`.trim();
|
const query = `${artistInput} ${trackInput}`.trim();
|
||||||
|
|
||||||
// Call Spotify search API
|
// Call appropriate search API based on discovery source
|
||||||
const response = await fetch(`/api/spotify/search_tracks?query=${encodeURIComponent(query)}&limit=20`);
|
const searchEndpoint = useItunes ? '/api/itunes/search_tracks' : '/api/spotify/search_tracks';
|
||||||
|
const response = await fetch(`${searchEndpoint}?query=${encodeURIComponent(query)}&limit=20`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
|
|
@ -10072,7 +10159,7 @@ async function searchDiscoveryFix() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render results
|
// Render results (same format for both Spotify and iTunes)
|
||||||
renderDiscoveryFixResults(data.tracks, fixModalOverlay);
|
renderDiscoveryFixResults(data.tracks, fixModalOverlay);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -10168,13 +10255,16 @@ async function selectDiscoveryFixTrack(track) {
|
||||||
|
|
||||||
// Update frontend state
|
// Update frontend state
|
||||||
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
|
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
|
||||||
|
// ListenBrainz uses its own state but may also be accessed via YouTube
|
||||||
let state;
|
let state;
|
||||||
if (platform === 'youtube') {
|
if (platform === 'youtube') {
|
||||||
state = youtubePlaylistStates[identifier];
|
state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
|
||||||
} else if (platform === 'tidal') {
|
} else if (platform === 'tidal') {
|
||||||
state = youtubePlaylistStates[identifier];
|
state = youtubePlaylistStates[identifier];
|
||||||
} else if (platform === 'beatport') {
|
} else if (platform === 'beatport') {
|
||||||
state = youtubePlaylistStates[identifier];
|
state = youtubePlaylistStates[identifier];
|
||||||
|
} else if (platform === 'listenbrainz') {
|
||||||
|
state = listenbrainzPlaylistStates[identifier];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support both camelCase and snake_case
|
// Support both camelCase and snake_case
|
||||||
|
|
@ -10615,7 +10705,28 @@ async function handleAddToWishlist() {
|
||||||
artists: formattedArtists
|
artists: formattedArtists
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use track's album data if available (from API), falling back to modal's album data
|
||||||
|
// This ensures consistency with how the Artists page handles wishlisting
|
||||||
|
let trackAlbum = track.album;
|
||||||
|
let trackAlbumType = albumType || 'album';
|
||||||
|
|
||||||
|
if (trackAlbum && typeof trackAlbum === 'object') {
|
||||||
|
// Track has album data from API - use its album_type
|
||||||
|
trackAlbumType = trackAlbum.album_type || albumType || 'album';
|
||||||
|
// Ensure album has required fields
|
||||||
|
if (!trackAlbum.name) {
|
||||||
|
trackAlbum.name = album.name;
|
||||||
|
}
|
||||||
|
if (!trackAlbum.id) {
|
||||||
|
trackAlbum.id = album.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to the album passed to the modal
|
||||||
|
trackAlbum = album;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🔄 Adding track with formatted artists:`, formattedTrack.name, formattedTrack.artists);
|
console.log(`🔄 Adding track with formatted artists:`, formattedTrack.name, formattedTrack.artists);
|
||||||
|
console.log(`🔄 Using album_type: ${trackAlbumType} (from ${track.album ? 'track.album' : 'modal album'})`);
|
||||||
|
|
||||||
const response = await fetch('/api/add-album-to-wishlist', {
|
const response = await fetch('/api/add-album-to-wishlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -10625,12 +10736,12 @@ async function handleAddToWishlist() {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
track: formattedTrack,
|
track: formattedTrack,
|
||||||
artist: artist,
|
artist: artist,
|
||||||
album: album,
|
album: trackAlbum,
|
||||||
source_type: 'album',
|
source_type: 'album',
|
||||||
source_context: {
|
source_context: {
|
||||||
album_name: album.name,
|
album_name: trackAlbum.name,
|
||||||
artist_name: artist.name,
|
artist_name: artist.name,
|
||||||
album_type: albumType
|
album_type: trackAlbumType
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
@ -11459,6 +11570,10 @@ function createArtistCard(artist, confidence) {
|
||||||
const imageUrl = artist.image_url || '';
|
const imageUrl = artist.image_url || '';
|
||||||
const confidencePercent = Math.round(confidence * 100);
|
const confidencePercent = Math.round(confidence * 100);
|
||||||
|
|
||||||
|
// Add data attribute for lazy loading
|
||||||
|
card.dataset.artistId = artist.id;
|
||||||
|
card.dataset.needsImage = imageUrl ? 'false' : 'true';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="suggestion-card-overlay"></div>
|
<div class="suggestion-card-overlay"></div>
|
||||||
<div class="suggestion-card-content">
|
<div class="suggestion-card-content">
|
||||||
|
|
@ -11691,6 +11806,16 @@ function renderArtistSearchResults(results) {
|
||||||
console.error(`Error calling createArtistCard for result ${index}:`, error);
|
console.error(`Error calling createArtistCard for result ${index}:`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lazy load missing artist images
|
||||||
|
console.log('🖼️ Starting lazy load for artist images in matching modal...');
|
||||||
|
if (typeof lazyLoadArtistImages === 'function') {
|
||||||
|
lazyLoadArtistImages(container);
|
||||||
|
} else if (typeof window.lazyLoadArtistImages === 'function') {
|
||||||
|
window.lazyLoadArtistImages(container);
|
||||||
|
} else {
|
||||||
|
console.error('❌ lazyLoadArtistImages function not found!');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAlbumSearchResults(results) {
|
function renderAlbumSearchResults(results) {
|
||||||
|
|
@ -18061,7 +18186,7 @@ function openYouTubeDiscoveryModal(urlHash) {
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="progress-section">
|
<div class="progress-section">
|
||||||
<div class="progress-label">🔍 Spotify Discovery Progress</div>
|
<div class="progress-label">🔍 ${currentMusicSourceName} Discovery Progress</div>
|
||||||
<div class="progress-bar-container">
|
<div class="progress-bar-container">
|
||||||
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
|
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -18075,8 +18200,8 @@ function openYouTubeDiscoveryModal(urlHash) {
|
||||||
<th>${sourceLabel} Track</th>
|
<th>${sourceLabel} Track</th>
|
||||||
<th>${sourceLabel} Artist</th>
|
<th>${sourceLabel} Artist</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Spotify Track</th>
|
<th>${currentMusicSourceName} Track</th>
|
||||||
<th>Spotify Artist</th>
|
<th>${currentMusicSourceName} Artist</th>
|
||||||
<th>Album</th>
|
<th>Album</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -18224,7 +18349,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Discovering phase - show progress
|
// Discovering phase - show progress
|
||||||
return `<div class="modal-info">🔍 Discovering Spotify matches...</div>`;
|
return `<div class="modal-info">🔍 Discovering ${currentMusicSourceName} matches...</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'discovered':
|
case 'discovered':
|
||||||
|
|
@ -18370,13 +18495,13 @@ function getModalDescription(phase, isTidal = false, isBeatport = false, isListe
|
||||||
const source = isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'));
|
const source = isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'));
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case 'fresh':
|
case 'fresh':
|
||||||
return `Ready to discover clean Spotify metadata for ${source} tracks...`;
|
return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`;
|
||||||
case 'discovering':
|
case 'discovering':
|
||||||
return `Discovering clean Spotify metadata for ${source} tracks...`;
|
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
|
||||||
case 'discovered':
|
case 'discovered':
|
||||||
return 'Discovery complete! View the results below.';
|
return 'Discovery complete! View the results below.';
|
||||||
default:
|
default:
|
||||||
return `Discovering clean Spotify metadata for ${source} tracks...`;
|
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18540,15 +18665,17 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
|
||||||
|
|
||||||
// Update actions cell with appropriate button
|
// Update actions cell with appropriate button
|
||||||
if (actionsCell) {
|
if (actionsCell) {
|
||||||
const state = youtubePlaylistStates[urlHash];
|
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
|
||||||
const platform = state?.is_tidal_playlist ? 'tidal' : (state?.is_beatport_playlist ? 'beatport' : 'youtube');
|
const platform = state?.is_listenbrainz_playlist ? 'listenbrainz' :
|
||||||
|
(state?.is_tidal_playlist ? 'tidal' :
|
||||||
|
(state?.is_beatport_playlist ? 'beatport' : 'youtube'));
|
||||||
actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform);
|
actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update action buttons if discovery is complete (progress = 100%)
|
// Update action buttons if discovery is complete (progress = 100%)
|
||||||
if (status.progress >= 100) {
|
if (status.progress >= 100) {
|
||||||
const state = youtubePlaylistStates[urlHash];
|
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
|
||||||
if (state && state.phase === 'discovered') {
|
if (state && state.phase === 'discovered') {
|
||||||
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
|
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
|
||||||
if (actionButtonsContainer) {
|
if (actionButtonsContainer) {
|
||||||
|
|
@ -19776,6 +19903,16 @@ function displayArtistsResults(query, results) {
|
||||||
// Update watchlist status for all cards
|
// Update watchlist status for all cards
|
||||||
updateArtistCardWatchlistStatus();
|
updateArtistCardWatchlistStatus();
|
||||||
|
|
||||||
|
// Lazy load missing artist images
|
||||||
|
console.log('🖼️ Starting lazy load for artist images on Artists page...');
|
||||||
|
if (typeof lazyLoadArtistImages === 'function') {
|
||||||
|
lazyLoadArtistImages(container);
|
||||||
|
} else if (typeof window.lazyLoadArtistImages === 'function') {
|
||||||
|
window.lazyLoadArtistImages(container);
|
||||||
|
} else {
|
||||||
|
console.error('❌ lazyLoadArtistImages function not found!');
|
||||||
|
}
|
||||||
|
|
||||||
// Add mouse wheel horizontal scrolling
|
// Add mouse wheel horizontal scrolling
|
||||||
container.addEventListener('wheel', (event) => {
|
container.addEventListener('wheel', (event) => {
|
||||||
if (event.deltaY !== 0) {
|
if (event.deltaY !== 0) {
|
||||||
|
|
@ -19785,6 +19922,77 @@ function displayArtistsResults(query, results) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy load artist images for cards that don't have images yet.
|
||||||
|
* Fetches images asynchronously so search results appear immediately.
|
||||||
|
*/
|
||||||
|
async function lazyLoadArtistImages(container) {
|
||||||
|
if (!container) {
|
||||||
|
console.error('❌ lazyLoadArtistImages: container is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all cards that need images
|
||||||
|
const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]');
|
||||||
|
|
||||||
|
if (cardsNeedingImages.length === 0) {
|
||||||
|
console.log('✅ All artist cards have images');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`);
|
||||||
|
|
||||||
|
// Load images in parallel (but with a small batch to avoid overwhelming the server)
|
||||||
|
const batchSize = 5;
|
||||||
|
const cards = Array.from(cardsNeedingImages);
|
||||||
|
|
||||||
|
for (let i = 0; i < cards.length; i += batchSize) {
|
||||||
|
const batch = cards.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
await Promise.all(batch.map(async (card) => {
|
||||||
|
const artistId = card.dataset.artistId;
|
||||||
|
if (!artistId) {
|
||||||
|
console.warn('⚠️ Card missing artistId:', card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🔄 Fetching image for artist ${artistId}...`);
|
||||||
|
const response = await fetch(`/api/artist/${artistId}/image`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log(`📥 Got response for ${artistId}:`, data);
|
||||||
|
|
||||||
|
if (data.success && data.image_url) {
|
||||||
|
// Update the card's background image
|
||||||
|
// Handle both card types (suggestion-card and artist-card)
|
||||||
|
if (card.classList.contains('suggestion-card')) {
|
||||||
|
card.style.backgroundImage = `url(${data.image_url})`;
|
||||||
|
card.style.backgroundSize = 'cover';
|
||||||
|
card.style.backgroundPosition = 'center';
|
||||||
|
} else if (card.classList.contains('artist-card')) {
|
||||||
|
const bgElement = card.querySelector('.artist-card-background');
|
||||||
|
if (bgElement) {
|
||||||
|
// Clear the gradient first, then set the image
|
||||||
|
bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.dataset.needsImage = 'false';
|
||||||
|
console.log(`✅ Loaded image for artist ${artistId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to load image for artist ${artistId}:`, error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Finished lazy loading artist images');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make function globally accessible
|
||||||
|
window.lazyLoadArtistImages = lazyLoadArtistImages;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create HTML for an artist card
|
* Create HTML for an artist card
|
||||||
*/
|
*/
|
||||||
|
|
@ -19802,8 +20010,11 @@ function createArtistCardHTML(artist) {
|
||||||
// Format popularity as a percentage for better UX
|
// Format popularity as a percentage for better UX
|
||||||
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
|
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
|
||||||
|
|
||||||
|
// Track if image needs to be lazy loaded
|
||||||
|
const needsImage = imageUrl ? 'false' : 'true';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="artist-card" data-artist-id="${artist.id}">
|
<div class="artist-card" data-artist-id="${artist.id}" data-needs-image="${needsImage}">
|
||||||
<div class="artist-card-background" style="${backgroundStyle}"></div>
|
<div class="artist-card-background" style="${backgroundStyle}"></div>
|
||||||
<div class="artist-card-overlay"></div>
|
<div class="artist-card-overlay"></div>
|
||||||
<div class="artist-card-content">
|
<div class="artist-card-content">
|
||||||
|
|
@ -19854,15 +20065,17 @@ async function selectArtistForDetail(artist) {
|
||||||
// Update artist info in header
|
// Update artist info in header
|
||||||
updateArtistDetailHeader(artist);
|
updateArtistDetailHeader(artist);
|
||||||
|
|
||||||
// Load discography
|
// Load discography (pass artist name for cross-source fallback)
|
||||||
await loadArtistDiscography(artist.id);
|
await loadArtistDiscography(artist.id, artist.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load artist's discography from Spotify
|
* Load artist's discography from Spotify or iTunes
|
||||||
|
* @param {string} artistId - Artist ID (Spotify or iTunes format)
|
||||||
|
* @param {string} [artistName] - Optional artist name for fallback searches
|
||||||
*/
|
*/
|
||||||
async function loadArtistDiscography(artistId) {
|
async function loadArtistDiscography(artistId, artistName = null) {
|
||||||
console.log(`💿 Loading discography for artist: ${artistId}`);
|
console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName})`);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if (artistsPageState.cache.discography[artistId]) {
|
if (artistsPageState.cache.discography[artistId]) {
|
||||||
|
|
@ -19884,8 +20097,14 @@ async function loadArtistDiscography(artistId) {
|
||||||
// Show loading states
|
// Show loading states
|
||||||
showDiscographyLoading();
|
showDiscographyLoading();
|
||||||
|
|
||||||
|
// Build URL with optional artist name for fallback
|
||||||
|
let url = `/api/artist/${artistId}/discography`;
|
||||||
|
if (artistName) {
|
||||||
|
url += `?artist_name=${encodeURIComponent(artistName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Call the real API endpoint
|
// Call the real API endpoint
|
||||||
const response = await fetch(`/api/artist/${artistId}/discography`);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
|
@ -20115,6 +20334,9 @@ async function loadSimilarArtists(artistName) {
|
||||||
<div style="font-size: 14px;">No similar artists found</div>
|
<div style="font-size: 14px;">No similar artists found</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
} else {
|
||||||
|
// Lazy load images for similar artists that don't have them
|
||||||
|
lazyLoadSimilarArtistImages(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
|
@ -20153,6 +20375,54 @@ async function loadSimilarArtists(artistName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy load images for similar artist bubbles that don't have images
|
||||||
|
*/
|
||||||
|
async function lazyLoadSimilarArtistImages(container) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
|
||||||
|
|
||||||
|
if (bubblesNeedingImages.length === 0) {
|
||||||
|
console.log('✅ All similar artist bubbles have images');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
|
||||||
|
|
||||||
|
// Load images in parallel batches
|
||||||
|
const batchSize = 5;
|
||||||
|
const bubbles = Array.from(bubblesNeedingImages);
|
||||||
|
|
||||||
|
for (let i = 0; i < bubbles.length; i += batchSize) {
|
||||||
|
const batch = bubbles.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
await Promise.all(batch.map(async (bubble) => {
|
||||||
|
const artistId = bubble.getAttribute('data-artist-id');
|
||||||
|
if (!artistId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/artist/${artistId}/image`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.image_url) {
|
||||||
|
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
|
||||||
|
if (imageContainer) {
|
||||||
|
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
|
||||||
|
imageContainer.innerHTML = `<img src="${data.image_url}" alt="${artistName}">`;
|
||||||
|
bubble.setAttribute('data-needs-image', 'false');
|
||||||
|
console.log(`✅ Loaded image for similar artist ${artistId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Finished lazy loading similar artist images');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display similar artist bubble cards progressively (one at a time with delay)
|
* Display similar artist bubble cards progressively (one at a time with delay)
|
||||||
*/
|
*/
|
||||||
|
|
@ -20214,11 +20484,15 @@ function createSimilarArtistBubble(artist) {
|
||||||
bubble.className = 'similar-artist-bubble';
|
bubble.className = 'similar-artist-bubble';
|
||||||
bubble.setAttribute('data-artist-id', artist.id);
|
bubble.setAttribute('data-artist-id', artist.id);
|
||||||
|
|
||||||
|
// Track if image needs lazy loading
|
||||||
|
const hasImage = artist.image_url && artist.image_url.trim() !== '';
|
||||||
|
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
|
||||||
|
|
||||||
// Create image container
|
// Create image container
|
||||||
const imageContainer = document.createElement('div');
|
const imageContainer = document.createElement('div');
|
||||||
imageContainer.className = 'similar-artist-bubble-image';
|
imageContainer.className = 'similar-artist-bubble-image';
|
||||||
|
|
||||||
if (artist.image_url && artist.image_url.trim() !== '') {
|
if (hasImage) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = artist.image_url;
|
img.src = artist.image_url;
|
||||||
img.alt = artist.name;
|
img.alt = artist.name;
|
||||||
|
|
@ -20227,11 +20501,12 @@ function createSimilarArtistBubble(artist) {
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
console.log(`Failed to load image for ${artist.name}`);
|
console.log(`Failed to load image for ${artist.name}`);
|
||||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||||
|
bubble.setAttribute('data-needs-image', 'true');
|
||||||
};
|
};
|
||||||
|
|
||||||
imageContainer.appendChild(img);
|
imageContainer.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
// No image - show fallback
|
// No image - show fallback (will be lazy loaded)
|
||||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20825,8 +21100,24 @@ function updateArtistDetailHeader(artist) {
|
||||||
const nameElement = document.getElementById('search-artist-detail-name');
|
const nameElement = document.getElementById('search-artist-detail-name');
|
||||||
const genresElement = document.getElementById('search-artist-detail-genres');
|
const genresElement = document.getElementById('search-artist-detail-genres');
|
||||||
|
|
||||||
if (imageElement && artist.image_url) {
|
if (imageElement) {
|
||||||
|
if (artist.image_url) {
|
||||||
imageElement.style.backgroundImage = `url('${artist.image_url}')`;
|
imageElement.style.backgroundImage = `url('${artist.image_url}')`;
|
||||||
|
} else {
|
||||||
|
// Lazy load image if missing (common for iTunes artists)
|
||||||
|
console.log(`🖼️ Lazy loading detail image for ${artist.name} (${artist.id})`);
|
||||||
|
fetch(`/api/artist/${artist.id}/image`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success && data.image_url) {
|
||||||
|
console.log(`✅ Loaded detail image for ${artist.name}`);
|
||||||
|
imageElement.style.backgroundImage = `url('${data.image_url}')`;
|
||||||
|
// Update the artist object in memory too
|
||||||
|
artist.image_url = data.image_url;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('❌ Failed to load detail image:', err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nameElement) {
|
if (nameElement) {
|
||||||
|
|
@ -21947,8 +22238,35 @@ async function openSearchDownloadModal(artistName) {
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
// Start monitoring for status changes
|
||||||
// Start monitoring for status changes
|
// Start monitoring for status changes
|
||||||
monitorSearchDownloadModal(artistName);
|
monitorSearchDownloadModal(artistName);
|
||||||
|
|
||||||
|
// Lazy load artist image if missing (common for iTunes)
|
||||||
|
if (!artistBubbleData.artist.image_url) {
|
||||||
|
console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`);
|
||||||
|
fetch(`/api/artist/${artistBubbleData.artist.id}/image`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success && data.image_url) {
|
||||||
|
// Update header background
|
||||||
|
const headerBg = modal.querySelector('.artist-download-modal-hero-bg');
|
||||||
|
if (headerBg) {
|
||||||
|
headerBg.style.backgroundImage = `url('${data.image_url}')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update avatar
|
||||||
|
const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar');
|
||||||
|
if (avatarContainer) {
|
||||||
|
avatarContainer.innerHTML = `<img src="${data.image_url}" alt="${artistBubbleData.artist.name}" class="artist-download-modal-hero-image" loading="lazy">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update artist object in memory
|
||||||
|
artistBubbleData.artist.image_url = data.image_url;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('❌ Failed to load modal image:', err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -22990,6 +23308,17 @@ function updateServiceStatus(service, statusData) {
|
||||||
statusText.className = 'service-card-status-text disconnected';
|
statusText.className = 'service-card-status-text disconnected';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update music source title (Spotify or Apple Music) based on active source
|
||||||
|
if (service === 'spotify' && statusData.source) {
|
||||||
|
const musicSourceTitleElement = document.getElementById('music-source-title');
|
||||||
|
if (musicSourceTitleElement) {
|
||||||
|
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
|
||||||
|
musicSourceTitleElement.textContent = sourceName;
|
||||||
|
// Update global variable for use in discovery modals
|
||||||
|
currentMusicSourceName = sourceName;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSidebarServiceStatus(service, statusData) {
|
function updateSidebarServiceStatus(service, statusData) {
|
||||||
|
|
@ -23014,6 +23343,15 @@ function updateSidebarServiceStatus(service, statusData) {
|
||||||
mediaServerNameElement.textContent = serverName;
|
mediaServerNameElement.textContent = serverName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update music source name (Spotify or Apple Music) based on active source
|
||||||
|
if (service === 'spotify' && statusData.source) {
|
||||||
|
const musicSourceNameElement = document.getElementById('music-source-name');
|
||||||
|
if (musicSourceNameElement) {
|
||||||
|
const sourceName = statusData.source === 'itunes' ? 'Apple Music' : 'Spotify';
|
||||||
|
musicSourceNameElement.textContent = sourceName;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23422,7 +23760,7 @@ async function showWatchlistModal() {
|
||||||
${artistsData.artists.map(artist => `
|
${artistsData.artists.map(artist => `
|
||||||
<div class="watchlist-artist-item"
|
<div class="watchlist-artist-item"
|
||||||
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
||||||
data-artist-id="${artist.spotify_artist_id}"
|
data-artist-id="${artist.spotify_artist_id || artist.itunes_artist_id}"
|
||||||
style="cursor: pointer;">
|
style="cursor: pointer;">
|
||||||
${artist.image_url ? `
|
${artist.image_url ? `
|
||||||
<img src="${artist.image_url}"
|
<img src="${artist.image_url}"
|
||||||
|
|
@ -23442,7 +23780,7 @@ async function showWatchlistModal() {
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-remove-btn"
|
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-remove-btn"
|
||||||
data-artist-id="${artist.spotify_artist_id}"
|
data-artist-id="${artist.spotify_artist_id || artist.itunes_artist_id}"
|
||||||
data-artist-name="${escapeHtml(artist.artist_name)}"
|
data-artist-name="${escapeHtml(artist.artist_name)}"
|
||||||
onclick="event.stopPropagation();">
|
onclick="event.stopPropagation();">
|
||||||
Remove
|
Remove
|
||||||
|
|
@ -25419,7 +25757,7 @@ function createReleaseCard(release) {
|
||||||
name: release.title,
|
name: release.title,
|
||||||
image_url: release.image_url,
|
image_url: release.image_url,
|
||||||
release_date: release.year ? `${release.year}-01-01` : '',
|
release_date: release.year ? `${release.year}-01-01` : '',
|
||||||
album_type: release.type || 'album',
|
album_type: release.album_type || release.type || 'album',
|
||||||
total_tracks: (release.track_completion && typeof release.track_completion === 'object')
|
total_tracks: (release.track_completion && typeof release.track_completion === 'object')
|
||||||
? release.track_completion.total_tracks : 1
|
? release.track_completion.total_tracks : 1
|
||||||
};
|
};
|
||||||
|
|
@ -25448,8 +25786,8 @@ function createReleaseCard(release) {
|
||||||
throw new Error('No tracks found for this release');
|
throw new Error('No tracks found for this release');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine album type based on release data
|
// Use the actual album type from release data
|
||||||
const albumType = release.type === 'single' ? 'singles' : 'albums';
|
const albumType = release.album_type || release.type || 'album';
|
||||||
|
|
||||||
// Open the Add to Wishlist modal
|
// Open the Add to Wishlist modal
|
||||||
// Note: openAddToWishlistModal has its own loading overlay
|
// Note: openAddToWishlistModal has its own loading overlay
|
||||||
|
|
@ -29841,20 +30179,28 @@ function displayDiscoverHeroArtist(artist) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store artist ID for both buttons and update watchlist state
|
// Store artist ID for both buttons and update watchlist state
|
||||||
|
// Use artist_id which is set by the backend to the appropriate ID for the active source
|
||||||
const addBtn = document.getElementById('discover-hero-add');
|
const addBtn = document.getElementById('discover-hero-add');
|
||||||
const discographyBtn = document.getElementById('discover-hero-discography');
|
const discographyBtn = document.getElementById('discover-hero-discography');
|
||||||
|
const artistId = artist.artist_id || artist.spotify_artist_id || artist.itunes_artist_id;
|
||||||
|
|
||||||
if (addBtn && artist.spotify_artist_id) {
|
if (addBtn && artistId) {
|
||||||
addBtn.setAttribute('data-artist-id', artist.spotify_artist_id);
|
addBtn.setAttribute('data-artist-id', artistId);
|
||||||
addBtn.setAttribute('data-artist-name', artist.artist_name);
|
addBtn.setAttribute('data-artist-name', artist.artist_name);
|
||||||
|
// Also store both IDs for cross-source operations
|
||||||
|
if (artist.spotify_artist_id) addBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
|
||||||
|
if (artist.itunes_artist_id) addBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
|
||||||
|
|
||||||
// Check if this artist is already in watchlist and update button appearance
|
// Check if this artist is already in watchlist and update button appearance
|
||||||
checkAndUpdateDiscoverHeroWatchlistButton(artist.spotify_artist_id);
|
checkAndUpdateDiscoverHeroWatchlistButton(artistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discographyBtn && artist.spotify_artist_id) {
|
if (discographyBtn && artistId) {
|
||||||
discographyBtn.setAttribute('data-artist-id', artist.spotify_artist_id);
|
discographyBtn.setAttribute('data-artist-id', artistId);
|
||||||
discographyBtn.setAttribute('data-artist-name', artist.artist_name);
|
discographyBtn.setAttribute('data-artist-name', artist.artist_name);
|
||||||
|
// Also store both IDs for cross-source operations
|
||||||
|
if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
|
||||||
|
if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update slideshow indicators
|
// Update slideshow indicators
|
||||||
|
|
@ -32897,8 +33243,16 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
|
||||||
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
|
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch album tracks from Spotify API via backend
|
// Determine source and album ID - use source-agnostic endpoint
|
||||||
const response = await fetch(`/api/spotify/album/${album.album_spotify_id}`);
|
const source = album.source || (album.album_spotify_id ? 'spotify' : 'itunes');
|
||||||
|
const albumId = source === 'spotify' ? album.album_spotify_id : album.album_itunes_id;
|
||||||
|
|
||||||
|
if (!albumId) {
|
||||||
|
throw new Error(`No ${source} album ID available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch album tracks from appropriate source via backend
|
||||||
|
const response = await fetch(`/api/discover/album/${source}/${albumId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch album tracks');
|
throw new Error('Failed to fetch album tracks');
|
||||||
}
|
}
|
||||||
|
|
@ -32933,13 +33287,14 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create virtual playlist ID
|
// Create virtual playlist ID using the appropriate album ID
|
||||||
const virtualPlaylistId = `discover_album_${album.album_spotify_id}`;
|
const virtualPlaylistId = `discover_album_${albumId}`;
|
||||||
|
|
||||||
// CRITICAL FIX: Pass proper artist/album context for modal display
|
// CRITICAL FIX: Pass proper artist/album context for modal display
|
||||||
const artistContext = {
|
const artistContext = {
|
||||||
id: album.artist_spotify_id,
|
id: source === 'spotify' ? album.artist_spotify_id : album.artist_itunes_id,
|
||||||
name: album.artist_name
|
name: album.artist_name,
|
||||||
|
source: source
|
||||||
};
|
};
|
||||||
|
|
||||||
const albumContext = {
|
const albumContext = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue