Discover page itunes integration. Spotify and Itunes will have their own pool

pull/126/head
Broque Thomas 3 months ago
parent 1560726bbc
commit 1d14a8b987

@ -371,8 +371,13 @@ class iTunesClient:
return albums[:limit]
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
"""Get album information - normalized to Spotify format"""
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:
@ -400,7 +405,7 @@ class iTunesClient:
else:
album_type = 'album'
return {
album_result = {
'id': str(album_data.get('collectionId', '')),
'name': album_data.get('collectionName', ''),
'images': images,
@ -414,6 +419,16 @@ class iTunesClient:
'_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]]:

@ -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):

@ -112,7 +112,11 @@ class PersonalizedPlaylistsService:
def _build_track_dict(self, row, source: str) -> Dict:
"""Build a standardized track dictionary from a database row."""
track_data = row['track_data_json']
# 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)
@ -123,11 +127,11 @@ class PersonalizedPlaylistsService:
'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['track_name'],
'artist_name': row['artist_name'],
'album_name': row['album_name'],
'album_cover_url': row['album_cover_url'],
'duration_ms': row['duration_ms'],
'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

@ -172,6 +172,19 @@ class SpotifyClient:
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"""
@ -534,9 +547,13 @@ class SpotifyClient:
logger.error(f"Error fetching track details via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for track details: {track_id}")
return self._itunes.get_track_details(track_id)
# 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]]:
@ -563,9 +580,13 @@ class SpotifyClient:
logger.error(f"Error fetching album via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for album: {album_id}")
return self._itunes.get_album(album_id)
# 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]]:
@ -602,9 +623,13 @@ class SpotifyClient:
logger.error(f"Error fetching album tracks via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for album tracks: {album_id}")
return self._itunes.get_album_tracks(album_id)
# 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]:
@ -629,9 +654,13 @@ class SpotifyClient:
logger.error(f"Error fetching artist albums via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for artist albums: {artist_id}")
return self._itunes.get_artist_albums(artist_id, album_type, limit)
# 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]]:
@ -662,6 +691,10 @@ class SpotifyClient:
logger.error(f"Error fetching artist via Spotify: {e}")
# Fall through to iTunes fallback
# iTunes fallback
logger.debug(f"Using iTunes fallback for artist: {artist_id}")
return self._itunes.get_artist(artist_id)
# 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

@ -280,6 +280,7 @@ class WatchlistScanner:
def _get_active_client_and_artist_id(self, watchlist_artist: WatchlistArtist):
"""
Get the appropriate client and artist ID based on active provider.
If iTunes ID is missing, searches by artist name to find and cache it.
Returns:
Tuple of (client, artist_id, provider_name) or (None, None, None) if no valid ID
@ -296,9 +297,80 @@ class WatchlistScanner:
if watchlist_artist.itunes_artist_id:
return (self.metadata_service.itunes, watchlist_artist.itunes_artist_id, 'itunes')
else:
logger.warning(f"No iTunes ID for {watchlist_artist.artist_name}, cannot scan with iTunes")
return (None, None, None)
# No iTunes ID stored - search by name and cache it
logger.info(f"No iTunes ID for {watchlist_artist.artist_name}, searching by name...")
try:
itunes_client = self.metadata_service.itunes
search_results = itunes_client.search_artists(watchlist_artist.artist_name, limit=1)
if search_results and len(search_results) > 0:
itunes_id = search_results[0].id
logger.info(f"Found iTunes ID {itunes_id} for {watchlist_artist.artist_name}")
# Cache the iTunes ID in the database for future use
self.database.update_watchlist_artist_itunes_id(
watchlist_artist.spotify_artist_id or str(watchlist_artist.id),
itunes_id
)
return (itunes_client, itunes_id, 'itunes')
else:
logger.warning(f"Could not find {watchlist_artist.artist_name} on iTunes")
return (None, None, None)
except Exception as e:
logger.error(f"Error searching iTunes for {watchlist_artist.artist_name}: {e}")
return (None, None, None)
def get_active_client_and_artist_id(self, watchlist_artist: WatchlistArtist):
"""
Public wrapper for _get_active_client_and_artist_id.
Gets the appropriate client and artist ID based on active provider.
Returns:
Tuple of (client, artist_id, provider_name) or (None, None, None) if no valid ID
"""
return self._get_active_client_and_artist_id(watchlist_artist)
def get_artist_image_url(self, watchlist_artist: WatchlistArtist) -> Optional[str]:
"""
Get artist image URL using the active provider.
Returns:
Image URL string or None if not available
"""
client, artist_id, provider = self._get_active_client_and_artist_id(watchlist_artist)
if not client or not artist_id:
return None
try:
artist_data = client.get_artist(artist_id)
if artist_data:
# Handle both Spotify and iTunes response formats
if 'images' in artist_data and artist_data['images']:
return artist_data['images'][0].get('url')
elif 'image_url' in artist_data:
return artist_data['image_url']
except Exception as e:
logger.debug(f"Could not fetch artist image for {watchlist_artist.artist_name}: {e}")
return None
def get_artist_discography_for_watchlist(self, watchlist_artist: WatchlistArtist, last_scan_timestamp: Optional[datetime] = None) -> Optional[List]:
"""
Get artist's discography using the active provider, with proper ID resolution.
This is the provider-aware version of get_artist_discography.
Args:
watchlist_artist: WatchlistArtist object (has both spotify and itunes IDs)
last_scan_timestamp: Only return releases after this date (for incremental scans)
Returns:
List of albums or None on error
"""
client, artist_id, provider = self._get_active_client_and_artist_id(watchlist_artist)
if not client or not artist_id:
logger.warning(f"No valid client/ID for {watchlist_artist.artist_name}")
return None
return self._get_artist_discography_with_client(client, artist_id, last_scan_timestamp)
def scan_all_watchlist_artists(self) -> List[ScanResult]:
"""
Scan artists in the watchlist for new releases.
@ -562,7 +634,9 @@ class WatchlistScanner:
try:
# Check if we have fresh similar artists cached (< 30 days old)
if self.database.has_fresh_similar_artists(source_artist_id, days_threshold=30):
logger.info(f"Similar artists for {watchlist_artist.artist_name} are cached and fresh, skipping fetch")
logger.info(f"Similar artists for {watchlist_artist.artist_name} are cached and fresh, skipping MusicMap fetch")
# Even if cached, backfill missing iTunes IDs (seamless dual-source support)
self._backfill_similar_artists_itunes_ids(source_artist_id)
else:
logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...")
self.update_similar_artists(watchlist_artist)
@ -812,6 +886,10 @@ class WatchlistScanner:
album_date = datetime(int(year), int(month), 1, tzinfo=timezone.utc)
elif len(release_date_str) == 10: # Full date (e.g., "2023-10-15")
album_date = datetime.strptime(release_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
elif 'T' in release_date_str: # ISO 8601 with time (e.g., "2017-12-08T08:00:00Z" from iTunes)
# Strip the time portion and parse just the date
date_part = release_date_str.split('T')[0]
album_date = datetime.strptime(date_part, "%Y-%m-%d").replace(tzinfo=timezone.utc)
else:
logger.warning(f"Unknown release date format: {release_date_str}")
return True # Include if we can't parse
@ -1246,6 +1324,54 @@ class WatchlistScanner:
logger.error(f"Error fetching similar artists from MusicMap: {e}")
return []
def _backfill_similar_artists_itunes_ids(self, source_artist_id: str) -> int:
"""
Backfill missing iTunes IDs for cached similar artists.
This ensures seamless dual-source support without clearing cached data.
Args:
source_artist_id: The source artist ID to backfill similar artists for
Returns:
Number of similar artists updated with iTunes IDs
"""
try:
# Get similar artists that are missing iTunes IDs
similar_artists = self.database.get_similar_artists_missing_itunes_ids(source_artist_id)
if not similar_artists:
return 0
logger.info(f"Backfilling iTunes IDs for {len(similar_artists)} similar artists")
# Get iTunes client
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
updated_count = 0
for similar_artist in similar_artists:
try:
# Search iTunes by artist name
itunes_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1)
if itunes_results and len(itunes_results) > 0:
itunes_id = itunes_results[0].id
# Update the similar artist with the iTunes ID
if self.database.update_similar_artist_itunes_id(similar_artist.id, itunes_id):
updated_count += 1
logger.debug(f" Backfilled iTunes ID {itunes_id} for {similar_artist.similar_artist_name}")
except Exception as e:
logger.debug(f" Could not backfill iTunes ID for {similar_artist.similar_artist_name}: {e}")
continue
if updated_count > 0:
logger.info(f"Backfilled {updated_count} similar artists with iTunes IDs")
return updated_count
except Exception as e:
logger.error(f"Error backfilling similar artists iTunes IDs: {e}")
return 0
def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10) -> bool:
"""
Fetch and store similar artists for a watchlist artist.
@ -1351,8 +1477,21 @@ class WatchlistScanner:
sources_to_process = []
# Always add iTunes first (baseline source)
if similar_artist.similar_artist_itunes_id:
sources_to_process.append(('itunes', similar_artist.similar_artist_itunes_id))
itunes_id = similar_artist.similar_artist_itunes_id
if not itunes_id:
# On-the-fly lookup for missing iTunes ID (seamless provider switching)
try:
itunes_results = itunes_client.search_artists(similar_artist.similar_artist_name, limit=1)
if itunes_results and len(itunes_results) > 0:
itunes_id = itunes_results[0].id
# Cache it for future use
self.database.update_similar_artist_itunes_id(similar_artist.id, itunes_id)
logger.debug(f" Resolved iTunes ID {itunes_id} for {similar_artist.similar_artist_name}")
except Exception as e:
logger.debug(f" Could not resolve iTunes ID for {similar_artist.similar_artist_name}: {e}")
if itunes_id:
sources_to_process.append(('itunes', itunes_id))
# Add Spotify if authenticated and we have an ID
if spotify_available and similar_artist.similar_artist_spotify_id:

@ -591,29 +591,7 @@ class MusicDatabase:
)
""")
# Create indexes for performance
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_itunes ON similar_artists (similar_artist_itunes_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_name ON similar_artists (similar_artist_name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_track ON discovery_pool (itunes_track_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_artist ON discovery_pool (itunes_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_source ON discovery_pool (source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_watchlist ON recent_releases (watchlist_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_date ON recent_releases (release_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_source ON recent_releases (source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_source ON discovery_recent_albums (source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_date ON discovery_recent_albums (release_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_type ON listenbrainz_playlists (playlist_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_mbid ON listenbrainz_playlists (playlist_mbid)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_playlist ON listenbrainz_tracks (playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_position ON listenbrainz_tracks (playlist_id, position)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_artist ON discovery_recent_albums (artist_spotify_id)")
# ============== MIGRATIONS (must run BEFORE index creation on new columns) ==============
# Add genres column to discovery_pool if it doesn't exist (migration)
cursor.execute("PRAGMA table_info(discovery_pool)")
@ -658,6 +636,30 @@ class MusicDatabase:
cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'")
logger.info("Added iTunes columns to discovery_recent_albums table for dual-source discovery")
# ============== INDEXES (after migrations to ensure columns exist) ==============
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_itunes ON similar_artists (similar_artist_itunes_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_name ON similar_artists (similar_artist_name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_track ON discovery_pool (itunes_track_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_artist ON discovery_pool (itunes_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_source ON discovery_pool (source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_watchlist ON recent_releases (watchlist_artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_date ON recent_releases (release_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_source ON recent_releases (source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_source ON discovery_recent_albums (source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_date ON discovery_recent_albums (release_date)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_type ON listenbrainz_playlists (playlist_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_mbid ON listenbrainz_playlists (playlist_mbid)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_playlist ON listenbrainz_tracks (playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_tracks_position ON listenbrainz_tracks (playlist_id, position)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_artist ON discovery_recent_albums (artist_spotify_id)")
logger.info("Discovery tables created successfully")
except Exception as e:
@ -2996,6 +2998,27 @@ class MusicDatabase:
logger.error(f"Error updating watchlist iTunes ID: {e}")
return False
def update_watchlist_artist_itunes_id(self, spotify_artist_id: str, itunes_id: str) -> bool:
"""Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ?
""", (itunes_id, spotify_artist_id))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"Cached iTunes ID {itunes_id} for Spotify artist {spotify_artist_id}")
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error caching watchlist iTunes ID: {e}")
return False
# === Discovery Feature Methods ===
def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_name: str,
@ -3056,10 +3079,66 @@ class MusicDatabase:
logger.error(f"Error getting similar artists: {e}")
return []
def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30) -> bool:
def get_similar_artists_missing_itunes_ids(self, source_artist_id: str) -> List[SimilarArtist]:
"""Get similar artists for a source that are missing iTunes IDs (for backfill)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM similar_artists
WHERE source_artist_id = ?
AND (similar_artist_itunes_id IS NULL OR similar_artist_itunes_id = '')
ORDER BY occurrence_count DESC
LIMIT 50
""", (source_artist_id,))
rows = cursor.fetchall()
return [SimilarArtist(
id=row['id'],
source_artist_id=row['source_artist_id'],
similar_artist_spotify_id=row['similar_artist_spotify_id'],
similar_artist_itunes_id=None,
similar_artist_name=row['similar_artist_name'],
similarity_rank=row['similarity_rank'],
occurrence_count=row['occurrence_count'],
last_updated=datetime.fromisoformat(row['last_updated'])
) for row in rows]
except Exception as e:
logger.error(f"Error getting similar artists missing iTunes IDs: {e}")
return []
def update_similar_artist_itunes_id(self, similar_artist_id: int, itunes_id: str) -> bool:
"""Update a similar artist's iTunes ID (for backfill)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE similar_artists
SET similar_artist_itunes_id = ?
WHERE id = ?
""", (itunes_id, similar_artist_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating similar artist iTunes ID: {e}")
return False
def has_fresh_similar_artists(self, source_artist_id: str, days_threshold: int = 30, require_itunes: bool = True) -> bool:
"""
Check if we have cached similar artists that are still fresh (< days_threshold old).
Returns True if we have recent data, False if data is stale or missing.
Also checks that similar artists have the required provider IDs.
Args:
source_artist_id: The source artist ID to check
days_threshold: Maximum age in days to consider fresh
require_itunes: If True, also requires iTunes IDs to be present (for seamless provider switching)
Returns True if we have recent data with required IDs, False if data is stale, missing, or incomplete.
"""
try:
with self._get_connection() as conn:
@ -3081,7 +3160,27 @@ class MusicDatabase:
last_updated = datetime.fromisoformat(row['last_updated'])
days_since_update = (datetime.now() - last_updated).total_seconds() / 86400 # seconds to days
return days_since_update < days_threshold
if days_since_update >= days_threshold:
return False
# Check if we have iTunes IDs (for seamless provider switching)
if require_itunes:
cursor.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN similar_artist_itunes_id IS NOT NULL AND similar_artist_itunes_id != '' THEN 1 ELSE 0 END) as has_itunes
FROM similar_artists
WHERE source_artist_id = ?
""", (source_artist_id,))
id_row = cursor.fetchone()
if id_row and id_row['total'] > 0:
# If less than 50% have iTunes IDs, consider stale and refetch
itunes_ratio = id_row['has_itunes'] / id_row['total']
if itunes_ratio < 0.5:
logger.debug(f"Similar artists for {source_artist_id} missing iTunes IDs ({id_row['has_itunes']}/{id_row['total']}), will refetch")
return False
return True
except Exception as e:
logger.error(f"Error checking similar artists freshness: {e}")

@ -17269,14 +17269,8 @@ def start_watchlist_scan():
for i, artist in enumerate(watchlist_artists):
try:
# Fetch artist image
artist_image_url = ''
try:
artist_data = spotify_client.get_artist(artist.spotify_artist_id)
if artist_data and 'images' in artist_data and artist_data['images']:
artist_image_url = artist_data['images'][0]['url']
except:
pass
# Fetch artist image using provider-aware method
artist_image_url = scanner.get_artist_image_url(artist) or ''
# Update progress
watchlist_scan_state.update({
@ -17290,9 +17284,9 @@ def start_watchlist_scan():
'current_album_image_url': '',
'current_track_name': ''
})
# Get artist discography
albums = scanner.get_artist_discography(artist.spotify_artist_id, artist.last_scan_timestamp)
# Get artist discography using provider-aware method
albums = scanner.get_artist_discography_for_watchlist(artist, artist.last_scan_timestamp)
if albums is None:
scan_results.append(type('ScanResult', (), {
@ -17320,8 +17314,8 @@ def start_watchlist_scan():
# Scan each album
for album_index, album in enumerate(albums):
try:
# Get album tracks
album_data = scanner.spotify_client.get_album(album.id)
# Get album tracks using provider-aware method
album_data = scanner.metadata_service.get_album(album.id)
if not album_data or 'tracks' not in album_data:
continue
@ -17969,14 +17963,8 @@ def _process_watchlist_scan_automatically():
# Scan each artist with detailed tracking
for i, artist in enumerate(watchlist_artists):
try:
# Fetch artist image
artist_image_url = ''
try:
artist_data = spotify_client.get_artist(artist.spotify_artist_id)
if artist_data and 'images' in artist_data and artist_data['images']:
artist_image_url = artist_data['images'][0]['url']
except:
pass
# Fetch artist image using provider-aware method
artist_image_url = scanner.get_artist_image_url(artist) or ''
# Update progress
watchlist_scan_state.update({
@ -17991,8 +17979,8 @@ def _process_watchlist_scan_automatically():
'current_track_name': ''
})
# Get artist discography
albums = scanner.get_artist_discography(artist.spotify_artist_id, artist.last_scan_timestamp)
# Get artist discography using provider-aware method
albums = scanner.get_artist_discography_for_watchlist(artist, artist.last_scan_timestamp)
if albums is None:
scan_results.append(type('ScanResult', (), {
@ -18020,8 +18008,8 @@ def _process_watchlist_scan_automatically():
# Scan each album
for album_index, album in enumerate(albums):
try:
# Get album tracks
album_data = scanner.spotify_client.get_album(album.id)
# Get album tracks using provider-aware method
album_data = scanner.metadata_service.get_album(album.id)
if not album_data or 'tracks' not in album_data:
continue

Loading…
Cancel
Save