Add iTunes fallback and improve artist/album handling
Adds iTunes fallback to SpotifyClient for search and metadata when Spotify is not authenticated. Updates album type logic to distinguish EPs, singles, and albums more accurately. Refactors watchlist database methods to support both Spotify and iTunes artist IDs. Improves deduplication and normalization of album names from iTunes. Updates web server and frontend to use new album type logic and support both ID types. Adds artist bubble snapshot example data.
This commit is contained in:
parent
b790c34657
commit
f12478ee70
5 changed files with 349 additions and 225 deletions
|
|
@ -147,7 +147,7 @@ class Album:
|
||||||
if track_count <= 3:
|
if track_count <= 3:
|
||||||
album_type = 'single'
|
album_type = 'single'
|
||||||
elif track_count <= 6:
|
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:
|
else:
|
||||||
album_type = 'album'
|
album_type = 'album'
|
||||||
|
|
||||||
|
|
@ -371,7 +371,7 @@ class iTunesClient:
|
||||||
if track_count <= 3:
|
if track_count <= 3:
|
||||||
album_type = 'single'
|
album_type = 'single'
|
||||||
elif track_count <= 6:
|
elif track_count <= 6:
|
||||||
album_type = 'single' # EP treated as single
|
album_type = 'ep' # 4-6 tracks = EP
|
||||||
else:
|
else:
|
||||||
album_type = 'album'
|
album_type = 'album'
|
||||||
|
|
||||||
|
|
@ -433,15 +433,40 @@ class iTunesClient:
|
||||||
|
|
||||||
# ==================== Artist Methods ====================
|
# ==================== 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
|
@rate_limited
|
||||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||||
"""Search for artists using iTunes API"""
|
"""Search for artists using iTunes API - includes album art fallback for images"""
|
||||||
results = self._search(query, 'musicArtist', limit)
|
results = self._search(query, 'musicArtist', limit)
|
||||||
artists = []
|
artists = []
|
||||||
|
|
||||||
for artist_data in results:
|
for artist_data in results:
|
||||||
if artist_data.get('wrapperType') == 'artist':
|
if artist_data.get('wrapperType') == 'artist':
|
||||||
artist = Artist.from_itunes_artist(artist_data)
|
artist = Artist.from_itunes_artist(artist_data)
|
||||||
|
|
||||||
|
# If no artist image, try to get their first album's artwork
|
||||||
|
if not artist.image_url:
|
||||||
|
album_art = self._get_artist_image_from_albums(str(artist_data.get('artistId', '')))
|
||||||
|
if album_art:
|
||||||
|
artist.image_url = album_art
|
||||||
|
|
||||||
artists.append(artist)
|
artists.append(artist)
|
||||||
|
|
||||||
return artists
|
return artists
|
||||||
|
|
@ -461,14 +486,22 @@ class iTunesClient:
|
||||||
for artist_data in results:
|
for artist_data in results:
|
||||||
if artist_data.get('wrapperType') == 'artist':
|
if artist_data.get('wrapperType') == 'artist':
|
||||||
# Build images array - iTunes artist search doesn't reliably return images
|
# Build images array - iTunes artist search doesn't reliably return images
|
||||||
# but we include the structure for compatibility
|
# Use album art as fallback
|
||||||
images = []
|
images = []
|
||||||
if artist_data.get('artworkUrl100'):
|
artwork_url = artist_data.get('artworkUrl100')
|
||||||
artwork_base = artist_data['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 = [
|
images = [
|
||||||
{'url': artwork_base.replace('100x100bb', '600x600bb'), 'height': 600, 'width': 600},
|
{'url': artwork_url.replace('100x100bb', '600x600bb'), 'height': 600, 'width': 600},
|
||||||
{'url': artwork_base.replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
{'url': artwork_url.replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
||||||
{'url': artwork_base, 'height': 100, 'width': 100}
|
{'url': artwork_url, 'height': 100, 'width': 100}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Get genre
|
# Get genre
|
||||||
|
|
@ -498,22 +531,45 @@ class iTunesClient:
|
||||||
Note: iTunes doesn't support filtering by album_type in the same way as Spotify,
|
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.
|
so we fetch all albums and can filter client-side if needed.
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200))
|
results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200))
|
||||||
albums = []
|
albums = []
|
||||||
|
seen_albums = set() # Track normalized names to prevent duplicates
|
||||||
|
|
||||||
|
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:
|
for album_data in results:
|
||||||
if album_data.get('wrapperType') == 'collection':
|
if album_data.get('wrapperType') == 'collection':
|
||||||
album = Album.from_itunes_album(album_data)
|
album = Album.from_itunes_album(album_data)
|
||||||
|
|
||||||
# Filter by album_type if specified
|
# Filter by album_type if specified (now includes 'ep')
|
||||||
if album_type != 'album,single':
|
if album_type != 'album,single':
|
||||||
requested_types = album_type.split(',')
|
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 album.album_type not in requested_types:
|
||||||
continue
|
if not (album.album_type == 'ep' and 'single' in requested_types):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Deduplicate by normalized name
|
||||||
|
normalized_name = normalize_album_name(album.name)
|
||||||
|
if normalized_name in seen_albums:
|
||||||
|
logger.debug(f"Skipping duplicate album: {album.name} (normalized: {normalized_name})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_albums.add(normalized_name)
|
||||||
albums.append(album)
|
albums.append(album)
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)")
|
||||||
return albums[:limit]
|
return albums[:limit]
|
||||||
|
|
||||||
# ==================== Playlist Methods ====================
|
# ==================== Playlist Methods ====================
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,18 @@ class SpotifyClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sp: Optional[spotipy.Spotify] = None
|
self.sp: Optional[spotipy.Spotify] = None
|
||||||
self.user_id: Optional[str] = None
|
self.user_id: Optional[str] = None
|
||||||
|
self._itunes_client = None # Lazy-loaded iTunes fallback
|
||||||
self._setup_client()
|
self._setup_client()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _itunes(self):
|
||||||
|
"""Lazy-load iTunes client for fallback when Spotify not authenticated"""
|
||||||
|
if self._itunes_client is None:
|
||||||
|
from core.itunes_client import iTunesClient
|
||||||
|
self._itunes_client = iTunesClient()
|
||||||
|
logger.info("iTunes fallback client initialized")
|
||||||
|
return self._itunes_client
|
||||||
|
|
||||||
def reload_config(self):
|
def reload_config(self):
|
||||||
"""Reload configuration and re-initialize client"""
|
"""Reload configuration and re-initialize client"""
|
||||||
self._setup_client()
|
self._setup_client()
|
||||||
|
|
@ -201,7 +211,20 @@ class SpotifyClient:
|
||||||
self.sp = None
|
self.sp = None
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
"""Check if Spotify client is authenticated and working"""
|
"""
|
||||||
|
Check if client can service metadata requests.
|
||||||
|
Returns True if Spotify is authenticated OR iTunes fallback is available.
|
||||||
|
For Spotify-specific auth check, use is_spotify_authenticated().
|
||||||
|
"""
|
||||||
|
# If Spotify is authenticated, we're good
|
||||||
|
if self.is_spotify_authenticated():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# iTunes fallback is always available
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_spotify_authenticated(self) -> bool:
|
||||||
|
"""Check if Spotify client is specifically authenticated (not just iTunes fallback)"""
|
||||||
if self.sp is None:
|
if self.sp is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -228,7 +251,7 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_user_playlists(self) -> List[Playlist]:
|
def get_user_playlists(self) -> List[Playlist]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -262,7 +285,7 @@ class SpotifyClient:
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_user_playlists_metadata_only(self) -> List[Playlist]:
|
def get_user_playlists_metadata_only(self) -> List[Playlist]:
|
||||||
"""Get playlists without fetching all track details for faster loading"""
|
"""Get playlists without fetching all track details for faster loading"""
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -312,7 +335,7 @@ class SpotifyClient:
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_saved_tracks_count(self) -> int:
|
def get_saved_tracks_count(self) -> int:
|
||||||
"""Get the total count of user's saved/liked songs without fetching all tracks"""
|
"""Get the total count of user's saved/liked songs without fetching all tracks"""
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
@ -331,7 +354,7 @@ class SpotifyClient:
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_saved_tracks(self) -> List[Track]:
|
def get_saved_tracks(self) -> List[Track]:
|
||||||
"""Fetch all user's saved/liked songs from Spotify"""
|
"""Fetch all user's saved/liked songs from Spotify"""
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
logger.error("Not authenticated with Spotify")
|
logger.error("Not authenticated with Spotify")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -373,7 +396,7 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
|
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
@ -397,7 +420,7 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
|
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -411,104 +434,113 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
||||||
if not self.is_authenticated():
|
"""Search for tracks - falls back to iTunes if Spotify not authenticated"""
|
||||||
return []
|
if self.is_spotify_authenticated():
|
||||||
|
try:
|
||||||
|
results = self.sp.search(q=query, type='track', limit=limit)
|
||||||
|
tracks = []
|
||||||
|
|
||||||
try:
|
for track_data in results['tracks']['items']:
|
||||||
results = self.sp.search(q=query, type='track', limit=limit)
|
track = Track.from_spotify_track(track_data)
|
||||||
tracks = []
|
tracks.append(track)
|
||||||
|
|
||||||
for track_data in results['tracks']['items']:
|
return tracks
|
||||||
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
|
||||||
|
|
||||||
except Exception as e:
|
# iTunes fallback
|
||||||
logger.error(f"Error searching tracks: {e}")
|
logger.debug(f"Using iTunes fallback for track search: {query}")
|
||||||
return []
|
return self._itunes.search_tracks(query, limit)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||||
"""Search for artists using Spotify API"""
|
"""Search for artists - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return []
|
try:
|
||||||
|
results = self.sp.search(q=query, type='artist', limit=limit)
|
||||||
|
artists = []
|
||||||
|
|
||||||
try:
|
for artist_data in results['artists']['items']:
|
||||||
results = self.sp.search(q=query, type='artist', limit=limit)
|
artist = Artist.from_spotify_artist(artist_data)
|
||||||
artists = []
|
artists.append(artist)
|
||||||
|
|
||||||
for artist_data in results['artists']['items']:
|
return artists
|
||||||
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
|
||||||
|
|
||||||
except Exception as e:
|
# iTunes fallback
|
||||||
logger.error(f"Error searching artists: {e}")
|
logger.debug(f"Using iTunes fallback for artist search: {query}")
|
||||||
return []
|
return self._itunes.search_artists(query, limit)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
||||||
"""Search for albums using Spotify API"""
|
"""Search for albums - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return []
|
try:
|
||||||
|
results = self.sp.search(q=query, type='album', limit=limit)
|
||||||
|
albums = []
|
||||||
|
|
||||||
try:
|
for album_data in results['albums']['items']:
|
||||||
results = self.sp.search(q=query, type='album', limit=limit)
|
album = Album.from_spotify_album(album_data)
|
||||||
albums = []
|
albums.append(album)
|
||||||
|
|
||||||
for album_data in results['albums']['items']:
|
return albums
|
||||||
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
|
||||||
|
|
||||||
except Exception as e:
|
# iTunes fallback
|
||||||
logger.error(f"Error searching albums: {e}")
|
logger.debug(f"Using iTunes fallback for album search: {query}")
|
||||||
return []
|
return self._itunes.search_albums(query, limit)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get detailed track information including album data and track number"""
|
"""Get detailed track information - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
try:
|
||||||
|
track_data = self.sp.track(track_id)
|
||||||
|
|
||||||
try:
|
# Enhance with additional useful metadata for our purposes
|
||||||
track_data = self.sp.track(track_id)
|
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
|
||||||
|
|
||||||
# Enhance with additional useful metadata for our purposes
|
except Exception as e:
|
||||||
if track_data:
|
logger.error(f"Error fetching track details via Spotify: {e}")
|
||||||
enhanced_data = {
|
# Fall through to iTunes fallback
|
||||||
'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:
|
# iTunes fallback
|
||||||
logger.error(f"Error fetching track details: {e}")
|
logger.debug(f"Using iTunes fallback for track details: {track_id}")
|
||||||
return None
|
return self._itunes.get_track_details(track_id)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -521,83 +553,89 @@ class SpotifyClient:
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get album information including tracks"""
|
"""Get album information - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
try:
|
||||||
|
album_data = self.sp.album(album_id)
|
||||||
|
return album_data
|
||||||
|
|
||||||
try:
|
except Exception as e:
|
||||||
album_data = self.sp.album(album_id)
|
logger.error(f"Error fetching album via Spotify: {e}")
|
||||||
return album_data
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
except Exception as e:
|
# iTunes fallback
|
||||||
logger.error(f"Error fetching album: {e}")
|
logger.debug(f"Using iTunes fallback for album: {album_id}")
|
||||||
return None
|
return self._itunes.get_album(album_id)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get album tracks with pagination to fetch all tracks"""
|
"""Get album tracks - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
try:
|
||||||
|
# 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:
|
# Collect all tracks starting with first page
|
||||||
# Get first page of tracks
|
all_tracks = first_page['items'][:]
|
||||||
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
|
# Fetch remaining pages if they exist
|
||||||
all_tracks = first_page['items'][:]
|
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
|
# Log success
|
||||||
next_page = first_page
|
logger.info(f"Retrieved {len(all_tracks)} tracks for album {album_id}")
|
||||||
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
|
# Return structure with all tracks
|
||||||
logger.info(f"Retrieved {len(all_tracks)} tracks for album {album_id}")
|
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
|
return result
|
||||||
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
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching album tracks via Spotify: {e}")
|
||||||
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
except Exception as e:
|
# iTunes fallback
|
||||||
logger.error(f"Error fetching album tracks: {e}")
|
logger.debug(f"Using iTunes fallback for album tracks: {album_id}")
|
||||||
return None
|
return self._itunes.get_album_tracks(album_id)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
||||||
"""Get albums by artist ID"""
|
"""Get albums by artist ID - falls back to iTunes if Spotify not authenticated"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return []
|
try:
|
||||||
|
albums = []
|
||||||
|
results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit)
|
||||||
|
|
||||||
try:
|
while results:
|
||||||
albums = []
|
for album_data in results['items']:
|
||||||
results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit)
|
album = Album.from_spotify_album(album_data)
|
||||||
|
albums.append(album)
|
||||||
|
|
||||||
while results:
|
# Get next batch if available
|
||||||
for album_data in results['items']:
|
results = self.sp.next(results) if results['next'] else None
|
||||||
album = Album.from_spotify_album(album_data)
|
|
||||||
albums.append(album)
|
|
||||||
|
|
||||||
# Get next batch if available
|
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
||||||
results = self.sp.next(results) if results['next'] else None
|
return albums
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
|
except Exception as e:
|
||||||
return albums
|
logger.error(f"Error fetching artist albums via Spotify: {e}")
|
||||||
|
# Fall through to iTunes fallback
|
||||||
|
|
||||||
except Exception as e:
|
# iTunes fallback
|
||||||
logger.error(f"Error fetching artist albums: {e}")
|
logger.debug(f"Using iTunes fallback for artist albums: {artist_id}")
|
||||||
return []
|
return self._itunes.get_artist_albums(artist_id, album_type, limit)
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
||||||
if not self.is_authenticated():
|
if not self.is_spotify_authenticated():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -609,19 +647,21 @@ class SpotifyClient:
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get full artist details from Spotify API.
|
Get full artist details - falls back to iTunes if Spotify not authenticated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
artist_id: Spotify artist ID
|
artist_id: Artist ID (Spotify or iTunes depending on authentication)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with artist data including images, genres, popularity
|
Dictionary with artist data including images, genres, popularity
|
||||||
"""
|
"""
|
||||||
if not self.is_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
return None
|
try:
|
||||||
|
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:
|
# iTunes fallback
|
||||||
return self.sp.artist(artist_id)
|
logger.debug(f"Using iTunes fallback for artist: {artist_id}")
|
||||||
except Exception as e:
|
return self._itunes.get_artist(artist_id)
|
||||||
logger.error(f"Error fetching artist {artist_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
@ -2700,64 +2700,89 @@ class MusicDatabase:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Watchlist operations
|
# Watchlist operations
|
||||||
def add_artist_to_watchlist(self, spotify_artist_id: str, artist_name: str) -> bool:
|
def add_artist_to_watchlist(self, artist_id: str, artist_name: str) -> bool:
|
||||||
"""Add an artist to the watchlist for monitoring new releases"""
|
"""Add an artist to the watchlist for monitoring new releases.
|
||||||
|
|
||||||
|
Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes ID (numeric).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("""
|
# Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric
|
||||||
INSERT OR REPLACE INTO watchlist_artists
|
is_itunes_id = artist_id.isdigit()
|
||||||
(spotify_artist_id, artist_name, date_added, updated_at)
|
|
||||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
if is_itunes_id:
|
||||||
""", (spotify_artist_id, artist_name))
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO watchlist_artists
|
||||||
|
(itunes_artist_id, artist_name, date_added, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
""", (artist_id, artist_name))
|
||||||
|
logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id})")
|
||||||
|
else:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO watchlist_artists
|
||||||
|
(spotify_artist_id, artist_name, date_added, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
""", (artist_id, artist_name))
|
||||||
|
logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {artist_id})")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {spotify_artist_id})")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}")
|
logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def remove_artist_from_watchlist(self, spotify_artist_id: str) -> bool:
|
def remove_artist_from_watchlist(self, artist_id: str) -> bool:
|
||||||
"""Remove an artist from the watchlist"""
|
"""Remove an artist from the watchlist (checks both Spotify and iTunes IDs)"""
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Get artist name for logging
|
# Get artist name for logging (check both ID columns)
|
||||||
cursor.execute("SELECT artist_name FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,))
|
cursor.execute("""
|
||||||
|
SELECT artist_name FROM watchlist_artists
|
||||||
|
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
|
||||||
|
""", (artist_id, artist_id))
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
artist_name = result['artist_name'] if result else "Unknown"
|
artist_name = result['artist_name'] if result else "Unknown"
|
||||||
|
|
||||||
cursor.execute("DELETE FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,))
|
cursor.execute("""
|
||||||
|
DELETE FROM watchlist_artists
|
||||||
|
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
|
||||||
|
""", (artist_id, artist_id))
|
||||||
|
|
||||||
if cursor.rowcount > 0:
|
if cursor.rowcount > 0:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info(f"Removed artist '{artist_name}' from watchlist (Spotify ID: {spotify_artist_id})")
|
logger.info(f"Removed artist '{artist_name}' from watchlist (ID: {artist_id})")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Artist with Spotify ID {spotify_artist_id} not found in watchlist")
|
logger.warning(f"Artist with ID {artist_id} not found in watchlist")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error removing artist from watchlist (Spotify ID: {spotify_artist_id}): {e}")
|
logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_artist_in_watchlist(self, spotify_artist_id: str) -> bool:
|
def is_artist_in_watchlist(self, artist_id: str) -> bool:
|
||||||
"""Check if an artist is currently in the watchlist"""
|
"""Check if an artist is currently in the watchlist (checks both Spotify and iTunes IDs)"""
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("SELECT 1 FROM watchlist_artists WHERE spotify_artist_id = ? LIMIT 1", (spotify_artist_id,))
|
# Check both spotify_artist_id and itunes_artist_id columns
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT 1 FROM watchlist_artists
|
||||||
|
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", (artist_id, artist_id))
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
|
|
||||||
return result is not None
|
return result is not None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking if artist is in watchlist (Spotify ID: {spotify_artist_id}): {e}")
|
logger.error(f"Error checking if artist is in watchlist (ID: {artist_id}): {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_watchlist_artists(self) -> List[WatchlistArtist]:
|
def get_watchlist_artists(self) -> List[WatchlistArtist]:
|
||||||
|
|
@ -2839,8 +2864,8 @@ class MusicDatabase:
|
||||||
logger.error(f"Error getting watchlist count: {e}")
|
logger.error(f"Error getting watchlist count: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def update_watchlist_artist_image(self, spotify_artist_id: str, image_url: str) -> bool:
|
def update_watchlist_artist_image(self, artist_id: str, image_url: str) -> bool:
|
||||||
"""Update the image URL for a watchlist artist"""
|
"""Update the image URL for a watchlist artist (checks both Spotify and iTunes IDs)"""
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
@ -2856,8 +2881,8 @@ class MusicDatabase:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE watchlist_artists
|
UPDATE watchlist_artists
|
||||||
SET image_url = ?, updated_at = CURRENT_TIMESTAMP
|
SET image_url = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE spotify_artist_id = ?
|
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
|
||||||
""", (image_url, spotify_artist_id))
|
""", (image_url, artist_id, artist_id))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
|
||||||
|
|
@ -22684,8 +22684,11 @@ def get_spotify_artist_discography(artist_name):
|
||||||
|
|
||||||
if album_type == 'single' or track_count <= 3:
|
if album_type == 'single' or track_count <= 3:
|
||||||
singles.append(release_data)
|
singles.append(release_data)
|
||||||
elif album_type == 'compilation' or (track_count <= 8 and track_count > 3):
|
elif album_type == 'ep' or (track_count >= 4 and track_count <= 6):
|
||||||
eps.append(release_data)
|
eps.append(release_data)
|
||||||
|
elif album_type == 'compilation':
|
||||||
|
# Compilations go with albums
|
||||||
|
albums.append(release_data)
|
||||||
else:
|
else:
|
||||||
albums.append(release_data)
|
albums.append(release_data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25411,7 +25411,7 @@ function createReleaseCard(release) {
|
||||||
name: release.title,
|
name: release.title,
|
||||||
image_url: release.image_url,
|
image_url: release.image_url,
|
||||||
release_date: release.year ? `${release.year}-01-01` : '',
|
release_date: release.year ? `${release.year}-01-01` : '',
|
||||||
album_type: release.type || 'album',
|
album_type: release.album_type || release.type || 'album',
|
||||||
total_tracks: (release.track_completion && typeof release.track_completion === 'object')
|
total_tracks: (release.track_completion && typeof release.track_completion === 'object')
|
||||||
? release.track_completion.total_tracks : 1
|
? release.track_completion.total_tracks : 1
|
||||||
};
|
};
|
||||||
|
|
@ -25440,8 +25440,8 @@ function createReleaseCard(release) {
|
||||||
throw new Error('No tracks found for this release');
|
throw new Error('No tracks found for this release');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine album type based on release data
|
// Use the actual album type from release data
|
||||||
const albumType = release.type === 'single' ? 'singles' : 'albums';
|
const albumType = release.album_type || release.type || 'album';
|
||||||
|
|
||||||
// Open the Add to Wishlist modal
|
// Open the Add to Wishlist modal
|
||||||
// Note: openAddToWishlistModal has its own loading overlay
|
// Note: openAddToWishlistModal has its own loading overlay
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue