From c54e52e18dde880c92283a1a622a822d9fe74493 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:21:44 -0700 Subject: [PATCH] Add Spotify Library discovery section, instrumental filter, custom exclusion terms & album download modal fixes --- core/spotify_client.py | 85 +++++++++ core/watchlist_scanner.py | 66 +++++++ database/music_database.py | 188 +++++++++++++++++++ web_server.py | 126 +++++++++++++ webui/index.html | 42 +++++ webui/static/script.js | 374 +++++++++++++++++++++++++++++++++++-- webui/static/style.css | 290 ++++++++++++++++++++++++++++ 7 files changed, 1158 insertions(+), 13 deletions(-) diff --git a/core/spotify_client.py b/core/spotify_client.py index 9b3e4eb1..2ea214c7 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -770,6 +770,91 @@ class SpotifyClient: logger.error(f"Error fetching saved tracks: {e}") return [] + @rate_limited + def get_saved_albums(self, since_timestamp=None) -> list: + """Fetch user's saved albums from Spotify library. + + Args: + since_timestamp: Optional ISO timestamp string. If provided, stops fetching + when reaching albums saved before this time (incremental sync). + + Returns: + List of dicts with album metadata ready for DB upsert. + """ + if not self.is_spotify_authenticated(): + logger.error("Not authenticated with Spotify") + return [] + + albums = [] + + try: + limit = 50 # Maximum allowed by Spotify API + offset = 0 + total_fetched = 0 + + while True: + results = self.sp.current_user_saved_albums(limit=limit, offset=offset) + + if not results or 'items' not in results: + break + + batch_count = 0 + stop_fetching = False + + for item in results['items']: + album_data = item.get('album') + added_at = item.get('added_at', '') + + if not album_data or not album_data.get('id'): + continue + + # Incremental sync: stop when we hit albums saved before last sync + if since_timestamp and added_at and added_at < since_timestamp: + stop_fetching = True + break + + # Extract primary artist + artists = album_data.get('artists', []) + artist_name = artists[0]['name'] if artists else 'Unknown Artist' + artist_id = artists[0].get('id', '') if artists else '' + + # Get best image + images = album_data.get('images', []) + image_url = images[0]['url'] if images else None + + albums.append({ + 'spotify_album_id': album_data['id'], + 'album_name': album_data.get('name', ''), + 'artist_name': artist_name, + 'artist_id': artist_id, + 'release_date': album_data.get('release_date', ''), + 'total_tracks': album_data.get('total_tracks', 0), + 'album_type': album_data.get('album_type', 'album'), + 'image_url': image_url, + 'date_saved': added_at, + }) + batch_count += 1 + + total_fetched += batch_count + logger.info(f"Retrieved {batch_count} saved albums in batch (offset {offset}), total: {total_fetched}") + + if stop_fetching: + logger.info(f"Incremental sync: reached albums saved before {since_timestamp}, stopping") + break + + # Check if we've fetched all saved albums + if len(results['items']) < limit or not results.get('next'): + break + + offset += limit + + logger.info(f"Retrieved {len(albums)} total saved albums from Spotify library") + return albums + + except Exception as e: + logger.error(f"Error fetching saved albums: {e}") + return [] + def _get_playlist_items_page(self, playlist_id: str, limit: int = 100, offset: int = 0) -> dict: """Fetch playlist items using the /items endpoint (Feb 2026 Spotify API migration). diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index caf95274..c067be68 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -607,6 +607,12 @@ class WatchlistScanner: logger.info("Updating seasonal content...") self._populate_seasonal_content() + # Sync Spotify library cache (runs after main scan) + try: + self.sync_spotify_library_cache() + except Exception as lib_err: + logger.warning(f"Error syncing Spotify library cache: {lib_err}") + return scan_results except Exception as e: @@ -2742,6 +2748,66 @@ class WatchlistScanner: import traceback traceback.print_exc() + def sync_spotify_library_cache(self, profile_id=1): + """Sync user's saved Spotify albums into the local cache. + + Runs after the main watchlist scan. First sync fetches all saved albums; + subsequent syncs are incremental (only fetch newly saved albums). + Every 7 days, does a full re-sync to detect un-saved albums. + """ + if not self.spotify_client or not self.spotify_client.is_spotify_authenticated(): + logger.debug("Spotify not authenticated, skipping library cache sync") + return + + logger.info("📚 Syncing Spotify library cache...") + + try: + last_sync = self.database.get_metadata('spotify_library_last_sync') + last_full_sync = self.database.get_metadata('spotify_library_last_full_sync') + + # Determine if we need a full sync (first time or every 7 days) + do_full_sync = False + if not last_sync: + do_full_sync = True + logger.info("First-time Spotify library sync — fetching all saved albums") + elif not last_full_sync: + # last_sync exists but last_full_sync doesn't — first run with this code + do_full_sync = True + logger.info("Full re-sync triggered (no full sync recorded)") + else: + try: + last_full_dt = datetime.fromisoformat(last_full_sync) + if datetime.now() - last_full_dt > timedelta(days=7): + do_full_sync = True + logger.info("Full re-sync triggered (>7 days since last full sync)") + except (ValueError, TypeError): + do_full_sync = True + + # Fetch albums from Spotify + since_timestamp = None if do_full_sync else last_sync + albums = self.spotify_client.get_saved_albums(since_timestamp=since_timestamp) + + if not albums and not do_full_sync: + logger.info("No new saved albums since last sync") + return + + if albums: + self.database.upsert_spotify_library_albums(albums, profile_id=profile_id) + + # On full sync, remove albums that are no longer saved + if do_full_sync and albums: + fetched_ids = {a['spotify_album_id'] for a in albums} + self.database.remove_spotify_library_albums_not_in(fetched_ids, profile_id=profile_id) + self.database.set_metadata('spotify_library_last_full_sync', datetime.now().isoformat()) + + # Update last sync timestamp + self.database.set_metadata('spotify_library_last_sync', datetime.now().isoformat()) + + logger.info(f"✅ Spotify library cache sync complete — {len(albums)} albums processed") + + except Exception as e: + logger.error(f"Error syncing Spotify library cache: {e}") + def _populate_seasonal_content(self): """ Populate seasonal content as part of watchlist scan. diff --git a/database/music_database.py b/database/music_database.py index b5d4fa1b..d5310b88 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -349,6 +349,9 @@ class MusicDatabase: self._add_profile_support_v4(cursor) self._add_profile_settings(cursor) + # Spotify library cache + self._add_spotify_library_cache_table(cursor) + # Mirrored playlists — persistent backup of parsed playlists from any service cursor.execute(""" CREATE TABLE IF NOT EXISTS mirrored_playlists ( @@ -2256,6 +2259,43 @@ class MusicDatabase: except Exception as e: logger.error(f"Error in profile settings migration: {e}") + def _add_spotify_library_cache_table(self, cursor): + """Create spotify_library_cache table for caching user's saved Spotify albums""" + try: + cursor.execute("SELECT value FROM metadata WHERE key = 'spotify_library_cache_v1' LIMIT 1") + if cursor.fetchone(): + return # Already migrated + + logger.info("Creating spotify_library_cache table...") + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS spotify_library_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spotify_album_id TEXT NOT NULL, + album_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + artist_id TEXT, + release_date TEXT, + total_tracks INTEGER DEFAULT 0, + album_type TEXT DEFAULT 'album', + image_url TEXT, + date_saved TEXT, + cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + profile_id INTEGER DEFAULT 1, + UNIQUE(spotify_album_id, profile_id) + ) + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_spotify_library_album_id ON spotify_library_cache (spotify_album_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_spotify_library_profile ON spotify_library_cache (profile_id)") + + cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('spotify_library_cache_v1', '1')") + + logger.info("spotify_library_cache table created successfully") + + except Exception as e: + logger.error(f"Error creating spotify_library_cache table: {e}") + # ── Profile CRUD ────────────────────────────────────────────────── def get_all_profiles(self) -> List[Dict[str, Any]]: @@ -5133,6 +5173,154 @@ class MusicDatabase: logger.error(f"Error getting watchlist artists: {e}") return [] + # ── Spotify Library Cache ────────────────────────────────────────── + + def upsert_spotify_library_albums(self, albums: list, profile_id: int = 1): + """Bulk upsert saved Spotify albums into cache table""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + for album in albums: + cursor.execute(""" + INSERT OR REPLACE INTO spotify_library_cache + (spotify_album_id, album_name, artist_name, artist_id, + release_date, total_tracks, album_type, image_url, + date_saved, cached_at, profile_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) + """, ( + album['spotify_album_id'], + album['album_name'], + album['artist_name'], + album.get('artist_id'), + album.get('release_date'), + album.get('total_tracks', 0), + album.get('album_type', 'album'), + album.get('image_url'), + album.get('date_saved'), + profile_id, + )) + conn.commit() + logger.info(f"Upserted {len(albums)} albums into spotify_library_cache") + except Exception as e: + logger.error(f"Error upserting spotify library albums: {e}") + + def get_spotify_library_albums(self, offset=0, limit=50, search='', sort='date_saved', + sort_dir='desc', profile_id=1): + """Get cached Spotify library albums with pagination, search, and sorting. + Returns (albums_list, total_count).""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + where_clauses = ['profile_id = ?'] + params = [profile_id] + + if search: + where_clauses.append('(album_name LIKE ? OR artist_name LIKE ?)') + params.extend([f'%{search}%', f'%{search}%']) + + where_sql = ' AND '.join(where_clauses) + + # Count total + cursor.execute(f"SELECT COUNT(*) as count FROM spotify_library_cache WHERE {where_sql}", params) + total = cursor.fetchone()['count'] + + # Validate sort column + valid_sorts = {'date_saved', 'artist_name', 'album_name', 'release_date'} + if sort not in valid_sorts: + sort = 'date_saved' + sort_direction = 'ASC' if sort_dir == 'asc' else 'DESC' + + cursor.execute(f""" + SELECT * FROM spotify_library_cache + WHERE {where_sql} + ORDER BY {sort} {sort_direction} + LIMIT ? OFFSET ? + """, params + [limit, offset]) + + albums = [] + for row in cursor.fetchall(): + albums.append({ + 'id': row['id'], + 'spotify_album_id': row['spotify_album_id'], + 'album_name': row['album_name'], + 'artist_name': row['artist_name'], + 'artist_id': row['artist_id'], + 'release_date': row['release_date'], + 'total_tracks': row['total_tracks'], + 'album_type': row['album_type'], + 'image_url': row['image_url'], + 'date_saved': row['date_saved'], + }) + + return albums, total + + except Exception as e: + logger.error(f"Error getting spotify library albums: {e}") + return [], 0 + + def get_spotify_library_album_ids(self, profile_id=1): + """Get all cached spotify album IDs as a set""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT spotify_album_id FROM spotify_library_cache WHERE profile_id = ?", (profile_id,)) + return {row['spotify_album_id'] for row in cursor.fetchall()} + except Exception as e: + logger.error(f"Error getting spotify library album IDs: {e}") + return set() + + def remove_spotify_library_albums_not_in(self, keep_ids: set, profile_id=1): + """Remove cached albums that are no longer in the user's Spotify library""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if not keep_ids: + cursor.execute("DELETE FROM spotify_library_cache WHERE profile_id = ?", (profile_id,)) + else: + placeholders = ','.join('?' * len(keep_ids)) + cursor.execute(f""" + DELETE FROM spotify_library_cache + WHERE profile_id = ? AND spotify_album_id NOT IN ({placeholders}) + """, [profile_id] + list(keep_ids)) + removed = cursor.rowcount + conn.commit() + if removed > 0: + logger.info(f"Removed {removed} un-saved albums from spotify_library_cache") + return removed + except Exception as e: + logger.error(f"Error removing spotify library albums: {e}") + return 0 + + def get_library_spotify_album_ids(self, profile_id=1): + """Get all spotify_album_id values from the local music library albums table""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT DISTINCT spotify_album_id FROM albums + WHERE spotify_album_id IS NOT NULL AND spotify_album_id != '' + """) + return {row['spotify_album_id'] for row in cursor.fetchall()} + except Exception as e: + logger.error(f"Error getting library spotify album IDs: {e}") + return set() + + def get_library_album_names(self): + """Get normalized (artist, album) pairs from library for fuzzy ownership matching""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT LOWER(a.title) as album, LOWER(ar.name) as artist + FROM albums a + JOIN artists ar ON a.artist_id = ar.id + """) + return {(row['artist'], row['album']) for row in cursor.fetchall()} + except Exception as e: + logger.error(f"Error getting library album names: {e}") + return set() + def get_watchlist_count(self, profile_id: int = 1) -> int: """Get the number of artists in the watchlist for the given profile""" try: diff --git a/web_server.py b/web_server.py index b10b3936..a2568363 100644 --- a/web_server.py +++ b/web_server.py @@ -28874,6 +28874,15 @@ def start_watchlist_scan(): import traceback traceback.print_exc() + # Sync Spotify library cache + print("📚 Syncing Spotify library cache...") + watchlist_scan_state['current_phase'] = 'syncing_spotify_library' + try: + scanner.sync_spotify_library_cache(profile_id=scan_profile_id) + print("✅ Spotify library cache sync complete") + except Exception as lib_error: + print(f"⚠️ Error syncing Spotify library: {lib_error}") + except Exception as e: print(f"Error during watchlist scan: {e}") watchlist_scan_state['status'] = 'error' @@ -29866,6 +29875,19 @@ def _process_watchlist_scan_automatically(automation_id=None): _update_automation_progress(automation_id, log_line=f'Seasonal error: {seasonal_error}', log_type='error') + # Sync Spotify library cache + print("📚 Syncing Spotify library cache...") + try: + for p in all_profiles: + scanner.sync_spotify_library_cache(profile_id=p['id']) + print("✅ Spotify library cache sync complete") + _update_automation_progress(automation_id, + log_line='Spotify library cache synced', log_type='info') + except Exception as lib_error: + print(f"⚠️ Error syncing Spotify library: {lib_error}") + _update_automation_progress(automation_id, + log_line=f'Library cache error: {lib_error}', log_type='error') + # Add activity for watchlist scan completion if total_added_to_wishlist > 0: add_activity_item("👁️", "Watchlist Scan Complete", f"{total_added_to_wishlist} new tracks added to wishlist", "Now") @@ -30270,6 +30292,110 @@ def enrich_similar_artists(): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/discover/spotify-library', methods=['GET']) +def get_spotify_library(): + """Get cached Spotify library albums with ownership status""" + try: + database = get_database() + profile_id = get_current_profile_id() + + offset = request.args.get('offset', 0, type=int) + limit = request.args.get('limit', 48, type=int) + search = request.args.get('search', '', type=str) + status_filter = request.args.get('status', 'all', type=str) + sort = request.args.get('sort', 'date_saved', type=str) + sort_dir = request.args.get('sort_dir', 'desc', type=str) + + # Fetch all matching albums (ownership requires post-query computation) + all_albums, total = database.get_spotify_library_albums( + offset=0, limit=10000, + search=search, sort=sort, sort_dir=sort_dir, profile_id=profile_id + ) + + if not all_albums: + return jsonify({ + "success": True, "albums": [], "total": 0, + "offset": offset, "limit": limit, + "stats": {"total": 0, "owned": 0, "missing": 0} + }) + + # Cross-reference with local library for ownership status + library_spotify_ids = database.get_library_spotify_album_ids(profile_id) + library_album_names = database.get_library_album_names() + + owned_count = 0 + for album in all_albums: + # Check by Spotify album ID first, then fuzzy match by name + if album['spotify_album_id'] in library_spotify_ids: + album['in_library'] = True + elif (album['artist_name'].lower(), album['album_name'].lower()) in library_album_names: + album['in_library'] = True + else: + album['in_library'] = False + + if album['in_library']: + owned_count += 1 + + # Apply status filter then paginate + if status_filter == 'missing': + filtered = [a for a in all_albums if not a['in_library']] + elif status_filter == 'owned': + filtered = [a for a in all_albums if a['in_library']] + else: + filtered = all_albums + + filtered_total = len(filtered) + albums = filtered[offset:offset + limit] + + stats = { + 'total': total, + 'owned': owned_count, + 'missing': total - owned_count, + } + + return jsonify({ + "success": True, + "albums": albums, + "total": filtered_total, + "offset": offset, + "limit": limit, + "stats": stats, + }) + + except Exception as e: + print(f"Error getting Spotify library: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/discover/spotify-library/refresh', methods=['POST']) +def refresh_spotify_library(): + """Manually trigger a re-sync of the Spotify library cache""" + try: + def _run_sync(): + try: + from core.watchlist_scanner import get_watchlist_scanner + scanner = get_watchlist_scanner() + if scanner: + # Force full sync by clearing last_sync timestamp + database = get_database() + database.set_metadata('spotify_library_last_sync', '') + database.set_metadata('spotify_library_last_full_sync', '') + scanner.sync_spotify_library_cache(profile_id=get_current_profile_id()) + print("✅ Manual Spotify library refresh complete") + except Exception as e: + print(f"Error in manual Spotify library refresh: {e}") + + import threading + thread = threading.Thread(target=_run_sync, daemon=True) + thread.start() + + return jsonify({"success": True, "message": "Spotify library refresh started"}) + + except Exception as e: + print(f"Error starting Spotify library refresh: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/discover/recent-releases', methods=['GET']) def get_discover_recent_releases(): """Get cached recent albums from watchlist and similar artists""" diff --git a/webui/index.html b/webui/index.html index 82a87ca2..5268e1c0 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2732,6 +2732,48 @@ + + +
diff --git a/webui/static/script.js b/webui/static/script.js index 4996a86f..16d63ef0 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -9757,10 +9757,11 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; + virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : + 'YouTube'; // Store metadata for discover download sidebar (will be added when Begin Analysis is clicked) if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_')) { @@ -9782,7 +9783,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam } // CRITICAL FIX: Use album context for discover_album playlists - const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('seasonal_album_'); + const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_'); const heroContext = isDiscoverAlbum && album && artist ? { type: 'album', artist: { @@ -9885,10 +9886,10 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam Force Download All - `}
+ ${offset + 1}\u2013${showEnd} of ${total} + + `; +} + +function spotifyLibraryPrevPage() { + if (spotifyLibraryPage > 0) { + spotifyLibraryPage--; + loadSpotifyLibraryAlbums(); + } +} + +function spotifyLibraryNextPage() { + const totalPages = Math.ceil(spotifyLibraryTotal / SPOTIFY_LIBRARY_PAGE_SIZE); + if (spotifyLibraryPage < totalPages - 1) { + spotifyLibraryPage++; + loadSpotifyLibraryAlbums(); + } +} + +async function openSpotifyLibraryAlbumDownload(index) { + const album = spotifyLibraryAlbums[index]; + if (!album) { + showToast('Album data not found', 'error'); + return; + } + + console.log(`\u{1F4E5} Opening download modal for Spotify library album: ${album.album_name}`); + showLoadingOverlay(`Loading tracks for ${album.album_name}...`); + + try { + const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`); + if (!response.ok) throw new Error('Failed to fetch album tracks'); + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + return { + id: track.id, + name: track.name, + artists: artists, + album: { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 + }; + }); + + const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`; + const artistContext = { + id: album.artist_id, + name: album.artist_name, + source: 'spotify' + }; + const albumContext = { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); + hideLoadingOverlay(); + + } catch (error) { + console.error('Error opening Spotify library album download:', error); + showToast(`Failed to load album: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} + +async function refreshSpotifyLibraryCache() { + try { + showToast('Refreshing Spotify library...', 'info'); + const response = await fetch('/api/discover/spotify-library/refresh', { method: 'POST' }); + const data = await response.json(); + if (data.success) { + showToast('Spotify library refresh started — will update shortly', 'success'); + // Reload after a delay to let the sync run + setTimeout(() => loadSpotifyLibrarySection(), 10000); + } else { + showToast(`Error: ${data.error}`, 'error'); + } + } catch (error) { + showToast(`Error: ${error.message}`, 'error'); + } +} + +async function downloadMissingSpotifyLibraryAlbums() { + // Fetch all missing albums (no pagination limit) + try { + const response = await fetch('/api/discover/spotify-library?status=missing&limit=500&offset=0'); + const data = await response.json(); + if (!data.success || !data.albums || data.albums.length === 0) { + showToast('No missing albums to download', 'info'); + return; + } + + const missing = data.albums.filter(a => !a.in_library); + if (missing.length === 0) { + showToast('All albums are already in your library!', 'success'); + return; + } + + if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your Spotify library?`)) { + return; + } + + showToast(`Starting download for ${missing.length} albums...`, 'info'); + + // Download one at a time to avoid overwhelming the system + for (let i = 0; i < missing.length; i++) { + const album = missing[i]; + try { + showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info'); + + const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`); + if (!response.ok) continue; + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) continue; + + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) artists = artists.map(a => a.name || a); + return { + id: track.id, + name: track.name, + artists: artists, + album: { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 + }; + }); + + const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`; + await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, { + id: album.artist_id, name: album.artist_name, source: 'spotify' + }, { + id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', + images: albumData.images || [] + }); + + } catch (err) { + console.error(`Error downloading album ${album.album_name}:`, err); + } + } + + } catch (error) { + console.error('Error downloading missing Spotify library albums:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + async function loadDiscoverReleaseRadar() { try { const playlistContainer = document.getElementById('release-radar-playlist'); diff --git a/webui/static/style.css b/webui/static/style.css index 80b56947..e7278dae 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11322,6 +11322,48 @@ body { } /* Scan button: shimmer on hover */ +/* ── Processing Button Animation (rotating border gradient) ── */ + +.btn-processing { + position: relative !important; + overflow: hidden !important; + color: rgba(255, 255, 255, 0.6) !important; + pointer-events: none; +} + +.btn-processing::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 2px; + background: conic-gradient( + from var(--border-angle, 0deg), + transparent 0%, + rgb(var(--accent-rgb)) 25%, + transparent 50%, + rgb(var(--accent-rgb)) 75%, + transparent 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; + animation: btn-border-spin 2.5s linear infinite; + pointer-events: none; +} + +@keyframes btn-border-spin { + 0% { --border-angle: 0deg; } + 100% { --border-angle: 360deg; } +} + +@property --border-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} + .watchlist-btn-scan { position: relative; overflow: hidden; @@ -24409,6 +24451,254 @@ body { } /* Discover Empty State */ +/* ── Spotify Library Section ────────────────────────── */ + +.spotify-library-header-actions { + display: flex; + gap: 10px; + align-items: center; + margin-left: auto; + flex-shrink: 0; +} + +.spotify-library-action-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s ease; +} + +.spotify-library-refresh-btn { + background: rgba(255, 255, 255, 0.08); + color: #b3b3b3; +} + +.spotify-library-refresh-btn:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.spotify-library-download-btn { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-rgb)); +} + +.spotify-library-download-btn:hover { + background: rgba(var(--accent-rgb), 0.25); +} + +.spotify-library-filters { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; + align-items: center; +} + +.spotify-library-search { + flex: 1; + min-width: 200px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.2s ease; +} + +.spotify-library-search:focus { + border-color: rgba(var(--accent-rgb), 0.5); +} + +.spotify-library-search::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.spotify-library-select { + padding: 10px 14px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #fff; + font-size: 13px; + cursor: pointer; + outline: none; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 30px; +} + +.spotify-library-select option { + background: #1a1a1a; + color: #fff; +} + +.spotify-library-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(175px, 1fr)); + gap: 16px; +} + +.spotify-library-card { + background: rgba(255, 255, 255, 0.04); + border-radius: 10px; + overflow: hidden; + cursor: pointer; + transition: all 0.25s ease; + position: relative; +} + +.spotify-library-card:hover { + background: rgba(255, 255, 255, 0.08); + transform: translateY(-3px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + +.spotify-library-card-img { + width: 100%; + aspect-ratio: 1; + overflow: hidden; + position: relative; +} + +.spotify-library-card-img img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.spotify-library-card-badge { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.spotify-library-card-badge.owned { + background: rgba(29, 185, 84, 0.85); + color: #fff; +} + +.spotify-library-card-badge.missing { + background: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.7); +} + +.spotify-library-card-info { + padding: 12px; +} + +.spotify-library-card-title { + font-size: 13px; + font-weight: 600; + color: #fff; + margin: 0 0 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.spotify-library-card-artist { + font-size: 12px; + color: #b3b3b3; + margin: 0 0 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.spotify-library-card-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + margin: 0; +} + +.spotify-library-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin-top: 24px; + padding: 12px 0; +} + +.spotify-library-page-btn { + padding: 8px 18px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #b3b3b3; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +.spotify-library-page-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.spotify-library-page-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.spotify-library-page-info { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; +} + +.spotify-library-empty { + text-align: center; + padding: 60px 20px; + color: #666; +} + +.spotify-library-empty p { + margin: 8px 0; + font-size: 14px; +} + +@media (max-width: 768px) { + .spotify-library-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + } + + .spotify-library-header-actions { + flex-wrap: wrap; + } + + .spotify-library-filters { + flex-direction: column; + } + + .spotify-library-search { + min-width: unset; + width: 100%; + } +} + .discover-empty { text-align: center; padding: 60px 20px;