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 @@
+
+
+
+
+
+
+
+
+
+
+
+
Loading your Spotify library...
+
+
+
+
+