Merge pull request #126 from Nezreka/itunes
Apple Music added as fallback metadata source if Spotify is not available. Spotify is always preferred if it is available with its richer data responses. Will easily swap from Spotify to Apple Music on the fly if you are rate limited by Spotify or worse, temp banned. Apple Music and Spotify will now each have their own discovery pool for the discover page. Both will always be updated on every watchlist scan so long as Spotify is authorized, otherwise only Apple Music data is pulled. **Known issues:** Any artist image pulled while Apple Music is the primary source will only pull album art for that artist since they do not provide artist images :( This can, very rarely, lead to cases where the album image that is pulled could have another another artist displayed if it's some collab single, EP or something. Seen it happen once with an indie artist so it's possible. Looking for a great api / website to parse easily specifically for artist names and a huge db to pull from.
This commit is contained in:
commit
61618c2fc7
11 changed files with 4296 additions and 1451 deletions
|
|
@ -59,6 +59,15 @@ SoulSync bridges streaming services to your media server with automated discover
|
|||
- Batch processing with retry logic
|
||||
- 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
|
||||
|
||||
- Unicode/accent handling (KoЯn, Björk, A$AP Rocky)
|
||||
|
|
|
|||
|
|
@ -141,13 +141,13 @@ class Album:
|
|||
|
||||
# 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 = 'single' # iTunes calls EPs "albums" but we can mark shorter ones
|
||||
album_type = 'ep' # 4-6 tracks = EP
|
||||
else:
|
||||
album_type = 'album'
|
||||
|
||||
|
|
@ -228,7 +228,8 @@ class iTunesClient:
|
|||
'country': self.country,
|
||||
'media': 'music',
|
||||
'entity': entity,
|
||||
'limit': min(limit, 200) # iTunes max is 200
|
||||
'limit': min(limit, 200), # iTunes max is 200
|
||||
'explicit': 'Yes' # Include explicit content (prefer over clean versions)
|
||||
}
|
||||
|
||||
response = self.session.get(
|
||||
|
|
@ -335,46 +336,134 @@ class iTunesClient:
|
|||
|
||||
@rate_limited
|
||||
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
||||
"""Search for albums using iTunes API"""
|
||||
results = self._search(query, 'album', limit)
|
||||
"""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':
|
||||
album = Album.from_itunes_album(album_data)
|
||||
albums.append(album)
|
||||
|
||||
return albums
|
||||
if album_data.get('wrapperType') != 'collection':
|
||||
continue
|
||||
|
||||
# Get album name and explicitness
|
||||
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) -> Optional[Dict[str, Any]]:
|
||||
"""Get album information"""
|
||||
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':
|
||||
return album_data
|
||||
|
||||
# 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': 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 with all tracks included"""
|
||||
"""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
|
||||
# Remaining results are tracks
|
||||
tracks = []
|
||||
for item in results:
|
||||
if item.get('wrapperType') == 'track' and item.get('kind') == 'song':
|
||||
tracks.append(item)
|
||||
|
||||
# Normalize each track to Spotify-compatible format
|
||||
normalized_track = {
|
||||
'id': str(item.get('trackId', '')),
|
||||
'name': item.get('trackName', ''),
|
||||
'artists': [{'name': item.get('artistName', 'Unknown Artist')}], # List of dicts like Spotify
|
||||
'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('discNumber', 1), t.get('trackNumber', 0)))
|
||||
|
||||
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),
|
||||
|
|
@ -384,60 +473,153 @@ class iTunesClient:
|
|||
|
||||
# ==================== 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"""
|
||||
"""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 from iTunes API.
|
||||
|
||||
Get full artist details - normalized to Spotify format.
|
||||
|
||||
Args:
|
||||
artist_id: iTunes artist ID
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with artist data
|
||||
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':
|
||||
return artist_data
|
||||
|
||||
# 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))
|
||||
albums = []
|
||||
|
||||
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':
|
||||
album = Album.from_itunes_album(album_data)
|
||||
|
||||
# Filter by album_type if specified
|
||||
if album_type != 'album,single':
|
||||
requested_types = album_type.split(',')
|
||||
if album.album_type not in requested_types:
|
||||
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
|
||||
|
||||
albums.append(album)
|
||||
|
||||
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
||||
|
||||
# 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 ====================
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class MetadataService:
|
|||
|
||||
def _log_initialization(self):
|
||||
"""Log initialization status"""
|
||||
spotify_status = "✅ Authenticated" if self.spotify.is_authenticated() else "❌ Not authenticated"
|
||||
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}")
|
||||
|
|
@ -52,7 +52,7 @@ class MetadataService:
|
|||
def get_active_provider(self) -> str:
|
||||
"""
|
||||
Get the currently active metadata provider.
|
||||
|
||||
|
||||
Returns:
|
||||
"spotify" or "itunes"
|
||||
"""
|
||||
|
|
@ -61,14 +61,16 @@ class MetadataService:
|
|||
elif self.preferred_provider == "itunes":
|
||||
return "itunes"
|
||||
else: # auto
|
||||
return "spotify" if self.spotify.is_authenticated() else "itunes"
|
||||
# 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_authenticated():
|
||||
if not self.spotify.is_spotify_authenticated():
|
||||
logger.warning("Spotify requested but not authenticated, falling back to iTunes")
|
||||
return self.itunes
|
||||
return self.spotify
|
||||
|
|
@ -168,38 +170,38 @@ class MetadataService:
|
|||
|
||||
def get_user_playlists(self) -> List:
|
||||
"""Get user playlists (Spotify only)"""
|
||||
if self.get_active_provider() == "spotify" and self.spotify.is_authenticated():
|
||||
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.get_active_provider() == "spotify" and self.spotify.is_authenticated():
|
||||
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.get_active_provider() == "spotify" and self.spotify.is_authenticated():
|
||||
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_authenticated() or self.itunes.is_authenticated()
|
||||
|
||||
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_authenticated(),
|
||||
"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_authenticated(),
|
||||
"can_access_user_data": self.spotify.is_spotify_authenticated(),
|
||||
}
|
||||
|
||||
def reload_config(self):
|
||||
|
|
|
|||
|
|
@ -100,6 +100,43 @@ class PersonalizedPlaylistsService:
|
|||
self.database = database
|
||||
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
|
||||
def get_parent_genre(spotify_genre: str) -> str:
|
||||
"""
|
||||
|
|
@ -166,25 +203,30 @@ class PersonalizedPlaylistsService:
|
|||
logger.error(f"Error getting forgotten favorites: {e}")
|
||||
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.
|
||||
|
||||
Args:
|
||||
decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s)
|
||||
limit: Maximum tracks to return
|
||||
source: Optional source filter ('spotify' or 'itunes'), auto-detects if not provided
|
||||
"""
|
||||
try:
|
||||
start_year = decade
|
||||
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:
|
||||
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("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
itunes_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
|
|
@ -192,26 +234,20 @@ class PersonalizedPlaylistsService:
|
|||
duration_ms,
|
||||
popularity,
|
||||
release_date,
|
||||
track_data_json
|
||||
track_data_json,
|
||||
source
|
||||
FROM discovery_pool
|
||||
WHERE release_date IS NOT NULL
|
||||
AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?
|
||||
AND source = ?
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", (start_year, end_year, limit * 10))
|
||||
""", (start_year, end_year, active_source, limit * 10))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
all_tracks = []
|
||||
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)
|
||||
all_tracks.append(self._build_track_dict(row, active_source))
|
||||
|
||||
if not all_tracks:
|
||||
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}")
|
||||
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.
|
||||
Uses cached artist genres from database (populated during discovery scan).
|
||||
Consolidates specific Spotify genres into broader parent categories.
|
||||
"""
|
||||
try:
|
||||
# Determine active source if not specified
|
||||
active_source = source or self._get_active_source()
|
||||
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all tracks with genres from discovery pool
|
||||
# Get all tracks with genres from discovery pool, filtered by source
|
||||
cursor.execute("""
|
||||
SELECT artist_genres
|
||||
FROM discovery_pool
|
||||
WHERE artist_genres IS NOT NULL
|
||||
""")
|
||||
WHERE artist_genres IS NOT NULL AND source = ?
|
||||
""", (active_source,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
|
|
@ -327,20 +366,24 @@ class PersonalizedPlaylistsService:
|
|||
logger.error(f"Error getting available genres: {e}")
|
||||
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.
|
||||
Uses cached artist genres from database (populated during discovery scan).
|
||||
Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house").
|
||||
"""
|
||||
try:
|
||||
# Determine active source if not specified
|
||||
active_source = source or self._get_active_source()
|
||||
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all tracks with genres from discovery pool
|
||||
# Get all tracks with genres from discovery pool, filtered by source
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
itunes_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
|
|
@ -348,10 +391,12 @@ class PersonalizedPlaylistsService:
|
|||
duration_ms,
|
||||
popularity,
|
||||
artist_genres,
|
||||
track_data_json
|
||||
track_data_json,
|
||||
source
|
||||
FROM discovery_pool
|
||||
WHERE artist_genres IS NOT NULL
|
||||
""")
|
||||
AND source = ?
|
||||
""", (active_source,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Determine if this is a parent genre or specific genre
|
||||
|
|
@ -372,7 +417,7 @@ class PersonalizedPlaylistsService:
|
|||
|
||||
for row in rows:
|
||||
try:
|
||||
artist_genres_json = row[7] # artist_genres column
|
||||
artist_genres_json = row['artist_genres']
|
||||
if artist_genres_json:
|
||||
genres = json.loads(artist_genres_json)
|
||||
|
||||
|
|
@ -388,23 +433,7 @@ class PersonalizedPlaylistsService:
|
|||
break
|
||||
|
||||
if genre_match:
|
||||
# Convert row to dict (exclude artist_genres from output)
|
||||
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)
|
||||
matching_tracks.append(self._build_track_dict(row, active_source))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing genres for track: {e}")
|
||||
continue
|
||||
|
|
@ -475,39 +504,34 @@ class PersonalizedPlaylistsService:
|
|||
|
||||
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)"""
|
||||
# Determine active source
|
||||
active_source = self._get_active_source()
|
||||
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
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("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
itunes_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity,
|
||||
track_data_json
|
||||
track_data_json,
|
||||
source
|
||||
FROM discovery_pool
|
||||
WHERE popularity >= 60
|
||||
WHERE popularity >= 60 AND source = ?
|
||||
ORDER BY popularity DESC, RANDOM()
|
||||
LIMIT ?
|
||||
""", (limit * 3,)) # Get 3x more for diversity filtering
|
||||
""", (active_source, limit * 3))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
all_tracks = []
|
||||
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)
|
||||
all_tracks = [self._build_track_dict(row, active_source) for row in rows]
|
||||
|
||||
# Apply diversity constraint: max 2 tracks per album, max 3 per artist
|
||||
tracks_by_album = {}
|
||||
|
|
@ -531,7 +555,7 @@ class PersonalizedPlaylistsService:
|
|||
if len(diverse_tracks) >= limit:
|
||||
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]
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -540,6 +564,9 @@ class PersonalizedPlaylistsService:
|
|||
|
||||
def get_hidden_gems(self, limit: int = 50) -> List[Dict]:
|
||||
"""Get low popularity (underground/indie) tracks from discovery pool"""
|
||||
# Determine active source
|
||||
active_source = self._get_active_source()
|
||||
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
|
@ -547,32 +574,23 @@ class PersonalizedPlaylistsService:
|
|||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
itunes_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity,
|
||||
track_data_json
|
||||
track_data_json,
|
||||
source
|
||||
FROM discovery_pool
|
||||
WHERE popularity < 40
|
||||
WHERE popularity < 40 AND source = ?
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
""", (active_source, limit))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
tracks = []
|
||||
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
|
||||
return [self._build_track_dict(row, active_source) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting hidden gems: {e}")
|
||||
|
|
@ -584,6 +602,9 @@ class PersonalizedPlaylistsService:
|
|||
|
||||
Different every time you call it!
|
||||
"""
|
||||
# Determine active source
|
||||
active_source = self._get_active_source()
|
||||
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
|
@ -591,31 +612,23 @@ class PersonalizedPlaylistsService:
|
|||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
itunes_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity,
|
||||
track_data_json
|
||||
track_data_json,
|
||||
source
|
||||
FROM discovery_pool
|
||||
WHERE source = ?
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
""", (active_source, limit))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
tracks = []
|
||||
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
|
||||
return [self._build_track_dict(row, active_source) for row in rows]
|
||||
|
||||
except Exception as 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]:
|
||||
"""Get tracks from discovery pool matching genre or artist"""
|
||||
# Determine active source
|
||||
active_source = self._get_active_source()
|
||||
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
|
@ -776,32 +792,23 @@ class PersonalizedPlaylistsService:
|
|||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
itunes_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity,
|
||||
track_data_json
|
||||
track_data_json,
|
||||
source
|
||||
FROM discovery_pool
|
||||
WHERE artist_name LIKE ? OR track_name LIKE ?
|
||||
WHERE (artist_name LIKE ? OR track_name LIKE ?) AND source = ?
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", (f'%{category}%', f'%{category}%', limit))
|
||||
""", (f'%{category}%', f'%{category}%', active_source, limit))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
tracks = []
|
||||
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
|
||||
return [self._build_track_dict(row, active_source) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discovery tracks by category: {e}")
|
||||
|
|
|
|||
|
|
@ -169,8 +169,31 @@ class SpotifyClient:
|
|||
def __init__(self):
|
||||
self.sp: Optional[spotipy.Spotify] = None
|
||||
self.user_id: Optional[str] = None
|
||||
self._itunes_client = None # Lazy-loaded iTunes fallback
|
||||
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):
|
||||
"""Reload configuration and re-initialize client"""
|
||||
self._setup_client()
|
||||
|
|
@ -201,10 +224,23 @@ class SpotifyClient:
|
|||
self.sp = None
|
||||
|
||||
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:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
# Make a simple API call to verify authentication
|
||||
self.sp.current_user()
|
||||
|
|
@ -228,7 +264,7 @@ class SpotifyClient:
|
|||
|
||||
@rate_limited
|
||||
def get_user_playlists(self) -> List[Playlist]:
|
||||
if not self.is_authenticated():
|
||||
if not self.is_spotify_authenticated():
|
||||
logger.error("Not authenticated with Spotify")
|
||||
return []
|
||||
|
||||
|
|
@ -262,7 +298,7 @@ class SpotifyClient:
|
|||
@rate_limited
|
||||
def get_user_playlists_metadata_only(self) -> List[Playlist]:
|
||||
"""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")
|
||||
return []
|
||||
|
||||
|
|
@ -312,7 +348,7 @@ class SpotifyClient:
|
|||
@rate_limited
|
||||
def get_saved_tracks_count(self) -> int:
|
||||
"""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")
|
||||
return 0
|
||||
|
||||
|
|
@ -331,7 +367,7 @@ class SpotifyClient:
|
|||
@rate_limited
|
||||
def get_saved_tracks(self) -> List[Track]:
|
||||
"""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")
|
||||
return []
|
||||
|
||||
|
|
@ -373,7 +409,7 @@ class SpotifyClient:
|
|||
|
||||
@rate_limited
|
||||
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
|
||||
if not self.is_authenticated():
|
||||
if not self.is_spotify_authenticated():
|
||||
return []
|
||||
|
||||
tracks = []
|
||||
|
|
@ -397,7 +433,7 @@ class SpotifyClient:
|
|||
|
||||
@rate_limited
|
||||
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
|
||||
if not self.is_authenticated():
|
||||
if not self.is_spotify_authenticated():
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
@ -411,104 +447,117 @@ class SpotifyClient:
|
|||
|
||||
@rate_limited
|
||||
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
||||
if not self.is_authenticated():
|
||||
return []
|
||||
|
||||
try:
|
||||
results = self.sp.search(q=query, type='track', limit=limit)
|
||||
tracks = []
|
||||
|
||||
for track_data in results['tracks']['items']:
|
||||
track = Track.from_spotify_track(track_data)
|
||||
tracks.append(track)
|
||||
|
||||
return tracks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching tracks: {e}")
|
||||
return []
|
||||
|
||||
"""Search for tracks - falls back to iTunes if Spotify not authenticated"""
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
results = self.sp.search(q=query, type='track', limit=limit)
|
||||
tracks = []
|
||||
|
||||
for track_data in results['tracks']['items']:
|
||||
track = Track.from_spotify_track(track_data)
|
||||
tracks.append(track)
|
||||
|
||||
return tracks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching tracks via Spotify: {e}")
|
||||
# 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
|
||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||
"""Search for artists using Spotify API"""
|
||||
if not self.is_authenticated():
|
||||
return []
|
||||
|
||||
try:
|
||||
results = self.sp.search(q=query, type='artist', limit=limit)
|
||||
artists = []
|
||||
|
||||
for artist_data in results['artists']['items']:
|
||||
artist = Artist.from_spotify_artist(artist_data)
|
||||
artists.append(artist)
|
||||
|
||||
return artists
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching artists: {e}")
|
||||
return []
|
||||
|
||||
"""Search for artists - falls back to iTunes if Spotify not authenticated"""
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
results = self.sp.search(q=query, type='artist', limit=limit)
|
||||
artists = []
|
||||
|
||||
for artist_data in results['artists']['items']:
|
||||
artist = Artist.from_spotify_artist(artist_data)
|
||||
artists.append(artist)
|
||||
|
||||
return artists
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching artists via Spotify: {e}")
|
||||
# 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
|
||||
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
||||
"""Search for albums using Spotify API"""
|
||||
if not self.is_authenticated():
|
||||
return []
|
||||
|
||||
try:
|
||||
results = self.sp.search(q=query, type='album', limit=limit)
|
||||
albums = []
|
||||
|
||||
for album_data in results['albums']['items']:
|
||||
album = Album.from_spotify_album(album_data)
|
||||
albums.append(album)
|
||||
|
||||
return albums
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching albums: {e}")
|
||||
return []
|
||||
"""Search for albums - falls back to iTunes if Spotify not authenticated"""
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
results = self.sp.search(q=query, type='album', limit=limit)
|
||||
albums = []
|
||||
|
||||
for album_data in results['albums']['items']:
|
||||
album = Album.from_spotify_album(album_data)
|
||||
albums.append(album)
|
||||
|
||||
return albums
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching albums via Spotify: {e}")
|
||||
# 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
|
||||
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed track information including album data and track number"""
|
||||
if not self.is_authenticated():
|
||||
return None
|
||||
|
||||
try:
|
||||
track_data = self.sp.track(track_id)
|
||||
|
||||
# Enhance with additional useful metadata for our purposes
|
||||
if track_data:
|
||||
enhanced_data = {
|
||||
'id': track_data['id'],
|
||||
'name': track_data['name'],
|
||||
'track_number': track_data['track_number'],
|
||||
'disc_number': track_data['disc_number'],
|
||||
'duration_ms': track_data['duration_ms'],
|
||||
'explicit': track_data['explicit'],
|
||||
'artists': [artist['name'] for artist in track_data['artists']],
|
||||
'primary_artist': track_data['artists'][0]['name'] if track_data['artists'] else None,
|
||||
'album': {
|
||||
'id': track_data['album']['id'],
|
||||
'name': track_data['album']['name'],
|
||||
'total_tracks': track_data['album']['total_tracks'],
|
||||
'release_date': track_data['album']['release_date'],
|
||||
'album_type': track_data['album']['album_type'],
|
||||
'artists': [artist['name'] for artist in track_data['album']['artists']]
|
||||
},
|
||||
'is_album_track': track_data['album']['total_tracks'] > 1,
|
||||
'raw_data': track_data # Keep original for fallback
|
||||
}
|
||||
return enhanced_data
|
||||
return track_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching track details: {e}")
|
||||
"""Get detailed track information - falls back to iTunes if Spotify not authenticated"""
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
track_data = self.sp.track(track_id)
|
||||
|
||||
# Enhance with additional useful metadata for our purposes
|
||||
if track_data:
|
||||
enhanced_data = {
|
||||
'id': track_data['id'],
|
||||
'name': track_data['name'],
|
||||
'track_number': track_data['track_number'],
|
||||
'disc_number': track_data['disc_number'],
|
||||
'duration_ms': track_data['duration_ms'],
|
||||
'explicit': track_data['explicit'],
|
||||
'artists': [artist['name'] for artist in track_data['artists']],
|
||||
'primary_artist': track_data['artists'][0]['name'] if track_data['artists'] else None,
|
||||
'album': {
|
||||
'id': track_data['album']['id'],
|
||||
'name': track_data['album']['name'],
|
||||
'total_tracks': track_data['album']['total_tracks'],
|
||||
'release_date': track_data['album']['release_date'],
|
||||
'album_type': track_data['album']['album_type'],
|
||||
'artists': [artist['name'] for artist in track_data['album']['artists']]
|
||||
},
|
||||
'is_album_track': track_data['album']['total_tracks'] > 1,
|
||||
'raw_data': track_data # Keep original for fallback
|
||||
}
|
||||
return enhanced_data
|
||||
return track_data
|
||||
|
||||
except Exception as 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
|
||||
|
||||
@rate_limited
|
||||
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
|
||||
|
||||
try:
|
||||
|
|
@ -521,83 +570,101 @@ class SpotifyClient:
|
|||
|
||||
@rate_limited
|
||||
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get album information including tracks"""
|
||||
if not self.is_authenticated():
|
||||
return None
|
||||
|
||||
try:
|
||||
album_data = self.sp.album(album_id)
|
||||
return album_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching album: {e}")
|
||||
"""Get album information - falls back to iTunes if Spotify not authenticated"""
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
album_data = self.sp.album(album_id)
|
||||
return album_data
|
||||
|
||||
except Exception as 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
|
||||
|
||||
@rate_limited
|
||||
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get album tracks with pagination to fetch all tracks"""
|
||||
if not self.is_authenticated():
|
||||
return None
|
||||
"""Get album tracks - falls back to iTunes if Spotify not authenticated"""
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
# Get first page of tracks
|
||||
first_page = self.sp.album_tracks(album_id)
|
||||
if not first_page or 'items' not in first_page:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get first page of tracks
|
||||
first_page = self.sp.album_tracks(album_id)
|
||||
if not first_page or 'items' not in first_page:
|
||||
return None
|
||||
# Collect all tracks starting with first page
|
||||
all_tracks = first_page['items'][:]
|
||||
|
||||
# Collect all tracks starting with first page
|
||||
all_tracks = first_page['items'][:]
|
||||
# Fetch remaining pages if they exist
|
||||
next_page = first_page
|
||||
while next_page.get('next'):
|
||||
next_page = self.sp.next(next_page)
|
||||
if next_page and 'items' in next_page:
|
||||
all_tracks.extend(next_page['items'])
|
||||
|
||||
# Fetch remaining pages if they exist
|
||||
next_page = first_page
|
||||
while next_page.get('next'):
|
||||
next_page = self.sp.next(next_page)
|
||||
if next_page and 'items' in next_page:
|
||||
all_tracks.extend(next_page['items'])
|
||||
# Log success
|
||||
logger.info(f"Retrieved {len(all_tracks)} tracks for album {album_id}")
|
||||
|
||||
# Log success
|
||||
logger.info(f"Retrieved {len(all_tracks)} tracks for album {album_id}")
|
||||
# Return structure with all tracks
|
||||
result = first_page.copy()
|
||||
result['items'] = all_tracks
|
||||
result['next'] = None # No more pages
|
||||
result['limit'] = len(all_tracks) # Update to reflect all tracks fetched
|
||||
|
||||
# Return structure with all tracks
|
||||
result = first_page.copy()
|
||||
result['items'] = all_tracks
|
||||
result['next'] = None # No more pages
|
||||
result['limit'] = len(all_tracks) # Update to reflect all tracks fetched
|
||||
return result
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching album tracks via Spotify: {e}")
|
||||
# Fall through to iTunes fallback
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching album tracks: {e}")
|
||||
# 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
|
||||
|
||||
@rate_limited
|
||||
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
||||
"""Get albums by artist ID"""
|
||||
if not self.is_authenticated():
|
||||
return []
|
||||
|
||||
try:
|
||||
albums = []
|
||||
results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit)
|
||||
|
||||
while results:
|
||||
for album_data in results['items']:
|
||||
album = Album.from_spotify_album(album_data)
|
||||
albums.append(album)
|
||||
|
||||
# Get next batch if available
|
||||
results = self.sp.next(results) if results['next'] else None
|
||||
|
||||
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
||||
return albums
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching artist albums: {e}")
|
||||
"""Get albums by artist ID - falls back to iTunes if Spotify not authenticated"""
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
albums = []
|
||||
results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit)
|
||||
|
||||
while results:
|
||||
for album_data in results['items']:
|
||||
album = Album.from_spotify_album(album_data)
|
||||
albums.append(album)
|
||||
|
||||
# Get next batch if available
|
||||
results = self.sp.next(results) if results['next'] else None
|
||||
|
||||
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
||||
return albums
|
||||
|
||||
except Exception as 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 []
|
||||
|
||||
@rate_limited
|
||||
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_authenticated():
|
||||
if not self.is_spotify_authenticated():
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
@ -609,19 +676,25 @@ class SpotifyClient:
|
|||
@rate_limited
|
||||
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:
|
||||
artist_id: Spotify artist ID
|
||||
artist_id: Artist ID (Spotify or iTunes depending on authentication)
|
||||
|
||||
Returns:
|
||||
Dictionary with artist data including images, genres, popularity
|
||||
"""
|
||||
if not self.is_authenticated():
|
||||
return None
|
||||
if self.is_spotify_authenticated():
|
||||
try:
|
||||
return self.sp.artist(artist_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching artist via Spotify: {e}")
|
||||
# Fall through to iTunes fallback
|
||||
|
||||
try:
|
||||
return self.sp.artist(artist_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching artist {artist_id}: {e}")
|
||||
# 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
|
||||
File diff suppressed because it is too large
Load diff
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()
|
||||
1822
web_server.py
1822
web_server.py
File diff suppressed because it is too large
Load diff
|
|
@ -131,7 +131,7 @@
|
|||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Status Section -->
|
||||
|
|
@ -139,7 +139,7 @@
|
|||
<h4 class="status-title">Service Status</h4>
|
||||
<div class="status-indicator" id="spotify-indicator">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-name">Spotify</span>
|
||||
<span class="status-name" id="music-source-name">Spotify</span>
|
||||
</div>
|
||||
<div class="status-indicator" id="media-server-indicator">
|
||||
<span class="status-dot disconnected"></span>
|
||||
|
|
@ -175,7 +175,7 @@
|
|||
<div class="service-status-grid">
|
||||
<div class="service-card" id="spotify-service-card">
|
||||
<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"
|
||||
id="spotify-status-indicator">●</span>
|
||||
</div>
|
||||
|
|
@ -3305,7 +3305,8 @@
|
|||
|
||||
<div class="config-section">
|
||||
<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">
|
||||
<label class="config-option">
|
||||
|
|
@ -3314,7 +3315,8 @@
|
|||
<div class="config-option-icon">🎤</div>
|
||||
<div class="config-option-text">
|
||||
<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>
|
||||
</label>
|
||||
|
|
@ -3325,7 +3327,8 @@
|
|||
<div class="config-option-icon">🎧</div>
|
||||
<div class="config-option-text">
|
||||
<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>
|
||||
</label>
|
||||
|
|
@ -3336,7 +3339,8 @@
|
|||
<div class="config-option-icon">🎸</div>
|
||||
<div class="config-option-text">
|
||||
<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>
|
||||
</label>
|
||||
|
|
@ -3347,7 +3351,8 @@
|
|||
<div class="config-option-icon">📀</div>
|
||||
<div class="config-option-text">
|
||||
<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>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ let currentStream = {
|
|||
progress: 0,
|
||||
track: null
|
||||
};
|
||||
let currentMusicSourceName = 'Spotify'; // 'Spotify' or 'Apple Music' - updated from status endpoint
|
||||
|
||||
// Streaming state management (enhanced functionality)
|
||||
let streamStatusPoller = null;
|
||||
|
|
@ -2164,7 +2165,8 @@ async function testConnection(service) {
|
|||
const result = await response.json();
|
||||
|
||||
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
|
||||
if (service === 'plex') {
|
||||
|
|
@ -2197,7 +2199,8 @@ async function testDashboardConnection(service) {
|
|||
const result = await response.json();
|
||||
|
||||
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 {
|
||||
showToast(`${service} service check failed: ${result.error}`, 'error');
|
||||
}
|
||||
|
|
@ -2681,6 +2684,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) {
|
||||
|
|
@ -2729,6 +2783,11 @@ function initializeSearchModeToggle() {
|
|||
// Add appropriate card class
|
||||
if (isArtist) {
|
||||
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) {
|
||||
elem.className = 'enh-compact-item album-card';
|
||||
} else if (isTrack) {
|
||||
|
|
@ -2752,7 +2811,7 @@ function initializeSearchModeToggle() {
|
|||
|
||||
const imageHtml = config.image
|
||||
? `<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
|
||||
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
|
||||
|
|
@ -9890,7 +9949,8 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) {
|
|||
// Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure
|
||||
let state, result;
|
||||
if (platform === 'youtube') {
|
||||
state = youtubePlaylistStates[identifier];
|
||||
// Check both states - ListenBrainz also uses YouTube modal infrastructure
|
||||
state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
|
||||
} else if (platform === 'tidal') {
|
||||
state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure
|
||||
} else if (platform === 'beatport') {
|
||||
|
|
@ -10043,15 +10103,23 @@ async function searchDiscoveryFix() {
|
|||
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');
|
||||
resultsContainer.innerHTML = '<div class="loading">🔍 Searching Spotify...</div>';
|
||||
const sourceLabel = useItunes ? 'iTunes' : 'Spotify';
|
||||
resultsContainer.innerHTML = `<div class="loading">🔍 Searching ${sourceLabel}...</div>`;
|
||||
|
||||
try {
|
||||
// Build search query
|
||||
const query = `${artistInput} ${trackInput}`.trim();
|
||||
|
||||
// Call Spotify search API
|
||||
const response = await fetch(`/api/spotify/search_tracks?query=${encodeURIComponent(query)}&limit=20`);
|
||||
// Call appropriate search API based on discovery source
|
||||
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();
|
||||
|
||||
if (data.error) {
|
||||
|
|
@ -10064,7 +10132,7 @@ async function searchDiscoveryFix() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Render results
|
||||
// Render results (same format for both Spotify and iTunes)
|
||||
renderDiscoveryFixResults(data.tracks, fixModalOverlay);
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -10160,13 +10228,16 @@ async function selectDiscoveryFixTrack(track) {
|
|||
|
||||
// Update frontend state
|
||||
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
|
||||
// ListenBrainz uses its own state but may also be accessed via YouTube
|
||||
let state;
|
||||
if (platform === 'youtube') {
|
||||
state = youtubePlaylistStates[identifier];
|
||||
state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
|
||||
} else if (platform === 'tidal') {
|
||||
state = youtubePlaylistStates[identifier];
|
||||
} else if (platform === 'beatport') {
|
||||
state = youtubePlaylistStates[identifier];
|
||||
} else if (platform === 'listenbrainz') {
|
||||
state = listenbrainzPlaylistStates[identifier];
|
||||
}
|
||||
|
||||
// Support both camelCase and snake_case
|
||||
|
|
@ -11451,6 +11522,10 @@ function createArtistCard(artist, confidence) {
|
|||
const imageUrl = artist.image_url || '';
|
||||
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 = `
|
||||
<div class="suggestion-card-overlay"></div>
|
||||
<div class="suggestion-card-content">
|
||||
|
|
@ -11683,6 +11758,16 @@ function renderArtistSearchResults(results) {
|
|||
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) {
|
||||
|
|
@ -18053,7 +18138,7 @@ function openYouTubeDiscoveryModal(urlHash) {
|
|||
|
||||
<div class="modal-body">
|
||||
<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-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
|
||||
</div>
|
||||
|
|
@ -18067,8 +18152,8 @@ function openYouTubeDiscoveryModal(urlHash) {
|
|||
<th>${sourceLabel} Track</th>
|
||||
<th>${sourceLabel} Artist</th>
|
||||
<th>Status</th>
|
||||
<th>Spotify Track</th>
|
||||
<th>Spotify Artist</th>
|
||||
<th>${currentMusicSourceName} Track</th>
|
||||
<th>${currentMusicSourceName} Artist</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
|
|
@ -18216,7 +18301,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
|
|||
}
|
||||
} else {
|
||||
// Discovering phase - show progress
|
||||
return `<div class="modal-info">🔍 Discovering Spotify matches...</div>`;
|
||||
return `<div class="modal-info">🔍 Discovering ${currentMusicSourceName} matches...</div>`;
|
||||
}
|
||||
|
||||
case 'discovered':
|
||||
|
|
@ -18362,13 +18447,13 @@ function getModalDescription(phase, isTidal = false, isBeatport = false, isListe
|
|||
const source = isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'));
|
||||
switch (phase) {
|
||||
case 'fresh':
|
||||
return `Ready to discover clean Spotify metadata for ${source} tracks...`;
|
||||
return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`;
|
||||
case 'discovering':
|
||||
return `Discovering clean Spotify metadata for ${source} tracks...`;
|
||||
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
|
||||
case 'discovered':
|
||||
return 'Discovery complete! View the results below.';
|
||||
default:
|
||||
return `Discovering clean Spotify metadata for ${source} tracks...`;
|
||||
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -18532,15 +18617,17 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
|
|||
|
||||
// Update actions cell with appropriate button
|
||||
if (actionsCell) {
|
||||
const state = youtubePlaylistStates[urlHash];
|
||||
const platform = state?.is_tidal_playlist ? 'tidal' : (state?.is_beatport_playlist ? 'beatport' : 'youtube');
|
||||
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
|
||||
const platform = state?.is_listenbrainz_playlist ? 'listenbrainz' :
|
||||
(state?.is_tidal_playlist ? 'tidal' :
|
||||
(state?.is_beatport_playlist ? 'beatport' : 'youtube'));
|
||||
actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform);
|
||||
}
|
||||
});
|
||||
|
||||
// Update action buttons if discovery is complete (progress = 100%)
|
||||
if (status.progress >= 100) {
|
||||
const state = youtubePlaylistStates[urlHash];
|
||||
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
|
||||
if (state && state.phase === 'discovered') {
|
||||
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
|
||||
if (actionButtonsContainer) {
|
||||
|
|
@ -19768,6 +19855,16 @@ function displayArtistsResults(query, results) {
|
|||
// Update watchlist status for all cards
|
||||
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
|
||||
container.addEventListener('wheel', (event) => {
|
||||
if (event.deltaY !== 0) {
|
||||
|
|
@ -19777,6 +19874,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
|
||||
*/
|
||||
|
|
@ -19794,8 +19962,11 @@ function createArtistCardHTML(artist) {
|
|||
// Format popularity as a percentage for better UX
|
||||
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
|
||||
|
||||
// Track if image needs to be lazy loaded
|
||||
const needsImage = imageUrl ? 'false' : 'true';
|
||||
|
||||
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-overlay"></div>
|
||||
<div class="artist-card-content">
|
||||
|
|
@ -19846,15 +20017,17 @@ async function selectArtistForDetail(artist) {
|
|||
// Update artist info in header
|
||||
updateArtistDetailHeader(artist);
|
||||
|
||||
// Load discography
|
||||
await loadArtistDiscography(artist.id);
|
||||
// Load discography (pass artist name for cross-source fallback)
|
||||
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) {
|
||||
console.log(`💿 Loading discography for artist: ${artistId}`);
|
||||
async function loadArtistDiscography(artistId, artistName = null) {
|
||||
console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName})`);
|
||||
|
||||
// Check cache first
|
||||
if (artistsPageState.cache.discography[artistId]) {
|
||||
|
|
@ -19876,8 +20049,14 @@ async function loadArtistDiscography(artistId) {
|
|||
// Show loading states
|
||||
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
|
||||
const response = await fetch(`/api/artist/${artistId}/discography`);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
|
|
@ -20107,6 +20286,9 @@ async function loadSimilarArtists(artistName) {
|
|||
<div style="font-size: 14px;">No similar artists found</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Lazy load images for similar artists that don't have them
|
||||
lazyLoadSimilarArtistImages(container);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
|
|
@ -20145,6 +20327,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)
|
||||
*/
|
||||
|
|
@ -20206,11 +20436,15 @@ function createSimilarArtistBubble(artist) {
|
|||
bubble.className = 'similar-artist-bubble';
|
||||
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
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'similar-artist-bubble-image';
|
||||
|
||||
if (artist.image_url && artist.image_url.trim() !== '') {
|
||||
if (hasImage) {
|
||||
const img = document.createElement('img');
|
||||
img.src = artist.image_url;
|
||||
img.alt = artist.name;
|
||||
|
|
@ -20219,11 +20453,12 @@ function createSimilarArtistBubble(artist) {
|
|||
img.onerror = () => {
|
||||
console.log(`Failed to load image for ${artist.name}`);
|
||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||
bubble.setAttribute('data-needs-image', 'true');
|
||||
};
|
||||
|
||||
imageContainer.appendChild(img);
|
||||
} else {
|
||||
// No image - show fallback
|
||||
// No image - show fallback (will be lazy loaded)
|
||||
imageContainer.innerHTML = `<div class="similar-artist-bubble-image-fallback">🎵</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -20817,8 +21052,24 @@ function updateArtistDetailHeader(artist) {
|
|||
const nameElement = document.getElementById('search-artist-detail-name');
|
||||
const genresElement = document.getElementById('search-artist-detail-genres');
|
||||
|
||||
if (imageElement && artist.image_url) {
|
||||
imageElement.style.backgroundImage = `url('${artist.image_url}')`;
|
||||
if (imageElement) {
|
||||
if (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) {
|
||||
|
|
@ -21939,8 +22190,35 @@ async function openSearchDownloadModal(artistName) {
|
|||
document.body.appendChild(modal);
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Start monitoring for status changes
|
||||
// Start monitoring for status changes
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -22982,6 +23260,17 @@ function updateServiceStatus(service, statusData) {
|
|||
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) {
|
||||
|
|
@ -23006,6 +23295,15 @@ function updateSidebarServiceStatus(service, statusData) {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -23414,7 +23712,7 @@ async function showWatchlistModal() {
|
|||
${artistsData.artists.map(artist => `
|
||||
<div class="watchlist-artist-item"
|
||||
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;">
|
||||
${artist.image_url ? `
|
||||
<img src="${artist.image_url}"
|
||||
|
|
@ -23434,7 +23732,7 @@ async function showWatchlistModal() {
|
|||
` : ''}
|
||||
</div>
|
||||
<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)}"
|
||||
onclick="event.stopPropagation();">
|
||||
Remove
|
||||
|
|
@ -25411,7 +25709,7 @@ function createReleaseCard(release) {
|
|||
name: release.title,
|
||||
image_url: release.image_url,
|
||||
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')
|
||||
? release.track_completion.total_tracks : 1
|
||||
};
|
||||
|
|
@ -25440,8 +25738,8 @@ function createReleaseCard(release) {
|
|||
throw new Error('No tracks found for this release');
|
||||
}
|
||||
|
||||
// Determine album type based on release data
|
||||
const albumType = release.type === 'single' ? 'singles' : 'albums';
|
||||
// Use the actual album type from release data
|
||||
const albumType = release.album_type || release.type || 'album';
|
||||
|
||||
// Open the Add to Wishlist modal
|
||||
// Note: openAddToWishlistModal has its own loading overlay
|
||||
|
|
@ -29833,20 +30131,28 @@ function displayDiscoverHeroArtist(artist) {
|
|||
}
|
||||
|
||||
// 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 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) {
|
||||
addBtn.setAttribute('data-artist-id', artist.spotify_artist_id);
|
||||
if (addBtn && artistId) {
|
||||
addBtn.setAttribute('data-artist-id', artistId);
|
||||
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
|
||||
checkAndUpdateDiscoverHeroWatchlistButton(artist.spotify_artist_id);
|
||||
checkAndUpdateDiscoverHeroWatchlistButton(artistId);
|
||||
}
|
||||
|
||||
if (discographyBtn && artist.spotify_artist_id) {
|
||||
discographyBtn.setAttribute('data-artist-id', artist.spotify_artist_id);
|
||||
if (discographyBtn && artistId) {
|
||||
discographyBtn.setAttribute('data-artist-id', artistId);
|
||||
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
|
||||
|
|
@ -32889,8 +33195,16 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
|
|||
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
|
||||
|
||||
try {
|
||||
// Fetch album tracks from Spotify API via backend
|
||||
const response = await fetch(`/api/spotify/album/${album.album_spotify_id}`);
|
||||
// Determine source and album ID - use source-agnostic endpoint
|
||||
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) {
|
||||
throw new Error('Failed to fetch album tracks');
|
||||
}
|
||||
|
|
@ -32925,13 +33239,14 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
|
|||
};
|
||||
});
|
||||
|
||||
// Create virtual playlist ID
|
||||
const virtualPlaylistId = `discover_album_${album.album_spotify_id}`;
|
||||
// Create virtual playlist ID using the appropriate album ID
|
||||
const virtualPlaylistId = `discover_album_${albumId}`;
|
||||
|
||||
// CRITICAL FIX: Pass proper artist/album context for modal display
|
||||
const artistContext = {
|
||||
id: album.artist_spotify_id,
|
||||
name: album.artist_name
|
||||
id: source === 'spotify' ? album.artist_spotify_id : album.artist_itunes_id,
|
||||
name: album.artist_name,
|
||||
source: source
|
||||
};
|
||||
|
||||
const albumContext = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue