Add Spotify Library discovery section, instrumental filter, custom exclusion terms & album download modal fixes

This commit is contained in:
Broque Thomas 2026-03-14 00:21:44 -07:00
parent a5e72cff05
commit c54e52e18d
7 changed files with 1158 additions and 13 deletions

View file

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

View file

@ -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.

View file

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

View file

@ -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"""

View file

@ -2732,6 +2732,48 @@
</div>
</div>
<!-- Spotify Library Section -->
<div class="discover-section" id="spotify-library-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Your Spotify Library</h2>
<p class="discover-section-subtitle" id="spotify-library-subtitle">Your saved albums on Spotify</p>
</div>
<div class="spotify-library-header-actions">
<button class="spotify-library-action-btn spotify-library-refresh-btn" onclick="refreshSpotifyLibraryCache()" title="Refresh from Spotify">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
Refresh
</button>
<button class="spotify-library-action-btn spotify-library-download-btn" id="spotify-library-download-missing-btn" onclick="downloadMissingSpotifyLibraryAlbums()" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
Download Missing
</button>
</div>
</div>
<div class="spotify-library-filters" id="spotify-library-filters" style="display: none;">
<input type="text" class="spotify-library-search" id="spotify-library-search"
placeholder="Search by artist or album..." oninput="debouncedSpotifyLibrarySearch()">
<select id="spotify-library-status-filter" class="spotify-library-select" onchange="loadSpotifyLibraryAlbums()">
<option value="all">All Albums</option>
<option value="missing">Missing</option>
<option value="owned">Owned</option>
</select>
<select id="spotify-library-sort" class="spotify-library-select" onchange="loadSpotifyLibraryAlbums()">
<option value="date_saved">Date Saved</option>
<option value="artist_name">Artist</option>
<option value="album_name">Album</option>
<option value="release_date">Release Date</option>
</select>
</div>
<div class="spotify-library-grid" id="spotify-library-grid">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading your Spotify library...</p>
</div>
</div>
<div class="spotify-library-pagination" id="spotify-library-pagination" style="display: none;"></div>
</div>
<!-- Recent Releases Section -->
<div class="discover-section">
<div class="discover-section-header">

View file

@ -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
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
<span>Force Download All</span>
</label>
<label class="force-download-toggle">
${isDiscoverAlbum ? '' : `<label class="force-download-toggle">
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
</label>
</label>`}
</div>
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
Begin Analysis
@ -11551,7 +11552,7 @@ async function startMissingTracksProcess(playlistId) {
// If this is an artist album download, use album name and include full context
// Match 'artist_album_', 'enhanced_search_album_', 'enhanced_search_track_', 'discover_album_', and 'seasonal_album_' prefixes
if (playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('issue_download_')) {
if (playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('spotify_library_') || playlistId.startsWith('issue_download_')) {
requestBody.playlist_name = process.album?.name || process.playlist.name;
requestBody.is_album_download = true;
requestBody.album_context = process.album; // Full Spotify album object
@ -21078,10 +21079,11 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
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';
const heroContext = {
type: 'playlist',
@ -31846,7 +31848,7 @@ async function showWatchlistModal() {
<div class="playlist-modal-body">
<div class="watchlist-actions" style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center; padding: 12px 32px; flex-wrap: wrap;">
<button class="playlist-modal-btn playlist-modal-btn-primary watchlist-btn-scan"
<button class="playlist-modal-btn playlist-modal-btn-primary watchlist-btn-scan ${scanStatus === 'scanning' ? 'btn-processing' : ''}"
id="scan-watchlist-btn"
onclick="startWatchlistScan()"
${scanStatus === 'scanning' ? 'disabled' : ''}>
@ -32849,6 +32851,7 @@ async function startWatchlistScan() {
const button = document.getElementById('scan-watchlist-btn');
button.disabled = true;
button.textContent = 'Starting scan...';
button.classList.add('btn-processing');
const response = await fetch('/api/watchlist/scan', {
method: 'POST',
@ -32876,6 +32879,7 @@ async function startWatchlistScan() {
const button = document.getElementById('scan-watchlist-btn');
button.disabled = false;
button.textContent = 'Scan for New Releases';
button.classList.remove('btn-processing');
alert(`Error starting scan: ${error.message}`);
}
}
@ -32946,6 +32950,7 @@ function handleWatchlistScanData(data) {
if (button) {
button.disabled = false;
button.textContent = 'Scan for New Releases';
button.classList.remove('btn-processing');
}
// Hide live activity
@ -32995,6 +33000,7 @@ function handleWatchlistScanData(data) {
if (button) {
button.disabled = false;
button.textContent = 'Scan for New Releases';
button.classList.remove('btn-processing');
}
// Hide live activity
@ -33039,6 +33045,7 @@ async function updateSimilarArtists() {
button.disabled = true;
button.textContent = 'Updating...';
button.classList.add('btn-processing');
if (scanButton) scanButton.disabled = true;
const response = await fetch('/api/watchlist/update-similar-artists', {
@ -33063,6 +33070,7 @@ async function updateSimilarArtists() {
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
if (scanButton) scanButton.disabled = false;
showToast(`Error: ${error.message}`, 'error');
@ -33085,6 +33093,7 @@ async function pollSimilarArtistsUpdate() {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
@ -33095,6 +33104,7 @@ async function pollSimilarArtistsUpdate() {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
@ -33121,6 +33131,7 @@ async function pollSimilarArtistsUpdate() {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
}
@ -42410,6 +42421,7 @@ async function loadDiscoverPage() {
// Load all sections
await Promise.all([
loadDiscoverHero(),
loadSpotifyLibrarySection(),
loadDiscoverRecentReleases(),
loadSeasonalContent(), // Seasonal discovery
loadPersonalizedRecentlyAdded(), // NEW: Recently added from library
@ -43277,6 +43289,342 @@ async function loadDiscoverRecentReleases() {
}
}
// ===============================
// SPOTIFY LIBRARY SECTION
// ===============================
let spotifyLibraryAlbums = [];
let spotifyLibraryPage = 0;
let spotifyLibraryTotal = 0;
const SPOTIFY_LIBRARY_PAGE_SIZE = 48;
let _spotifyLibrarySearchTimeout = null;
function debouncedSpotifyLibrarySearch() {
clearTimeout(_spotifyLibrarySearchTimeout);
_spotifyLibrarySearchTimeout = setTimeout(() => {
spotifyLibraryPage = 0;
loadSpotifyLibraryAlbums();
}, 400);
}
async function loadSpotifyLibrarySection() {
try {
const section = document.getElementById('spotify-library-section');
if (!section) return;
const response = await fetch(`/api/discover/spotify-library?offset=0&limit=${SPOTIFY_LIBRARY_PAGE_SIZE}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (!data.success || !data.albums || data.albums.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
spotifyLibraryAlbums = data.albums;
spotifyLibraryTotal = data.total;
spotifyLibraryPage = 0;
// Update subtitle with stats
const subtitle = document.getElementById('spotify-library-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
// Show download missing button if there are missing albums
const dlBtn = document.getElementById('spotify-library-download-missing-btn');
if (dlBtn && data.stats && data.stats.missing > 0) {
dlBtn.style.display = '';
}
// Show filters
const filters = document.getElementById('spotify-library-filters');
if (filters) filters.style.display = '';
renderSpotifyLibraryGrid(data.albums);
renderSpotifyLibraryPagination(data.total, 0);
} catch (error) {
console.error('Error loading Spotify library section:', error);
const section = document.getElementById('spotify-library-section');
if (section) section.style.display = 'none';
}
}
async function loadSpotifyLibraryAlbums() {
const grid = document.getElementById('spotify-library-grid');
if (!grid) return;
grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading...</p></div>';
try {
const search = (document.getElementById('spotify-library-search')?.value || '').trim();
const status = document.getElementById('spotify-library-status-filter')?.value || 'all';
const sort = document.getElementById('spotify-library-sort')?.value || 'date_saved';
const offset = spotifyLibraryPage * SPOTIFY_LIBRARY_PAGE_SIZE;
const params = new URLSearchParams({
offset, limit: SPOTIFY_LIBRARY_PAGE_SIZE, sort, sort_dir: 'desc', status
});
if (search) params.set('search', search);
const response = await fetch(`/api/discover/spotify-library?${params}`);
const data = await response.json();
if (!data.success) throw new Error(data.error);
spotifyLibraryAlbums = data.albums;
spotifyLibraryTotal = data.total;
// Update subtitle
const subtitle = document.getElementById('spotify-library-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
renderSpotifyLibraryGrid(data.albums);
renderSpotifyLibraryPagination(data.total, offset);
} catch (error) {
console.error('Error loading Spotify library albums:', error);
grid.innerHTML = '<div class="spotify-library-empty"><p>Failed to load albums</p></div>';
}
}
function renderSpotifyLibraryGrid(albums) {
const grid = document.getElementById('spotify-library-grid');
if (!grid) return;
if (!albums || albums.length === 0) {
grid.innerHTML = '<div class="spotify-library-empty"><p>No albums found</p></div>';
return;
}
let html = '';
albums.forEach((album, index) => {
const coverUrl = album.image_url || '/static/placeholder-album.png';
const year = album.release_date ? album.release_date.substring(0, 4) : '';
const badgeClass = album.in_library ? 'owned' : 'missing';
const badgeIcon = album.in_library ? '\u2713' : '\u2193';
const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : '';
const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 ');
html += `
<div class="spotify-library-card" onclick="openSpotifyLibraryAlbumDownload(${index})" title="${album.album_name} — ${album.artist_name}">
<div class="spotify-library-card-img">
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
<div class="spotify-library-card-badge ${badgeClass}">${badgeIcon}</div>
</div>
<div class="spotify-library-card-info">
<p class="spotify-library-card-title">${album.album_name}</p>
<p class="spotify-library-card-artist">${album.artist_name}</p>
<p class="spotify-library-card-meta">${meta}</p>
</div>
</div>
`;
});
grid.innerHTML = html;
}
function renderSpotifyLibraryPagination(total, offset) {
const container = document.getElementById('spotify-library-pagination');
if (!container) return;
if (total <= SPOTIFY_LIBRARY_PAGE_SIZE) {
container.style.display = 'none';
return;
}
container.style.display = '';
const totalPages = Math.ceil(total / SPOTIFY_LIBRARY_PAGE_SIZE);
const currentPage = Math.floor(offset / SPOTIFY_LIBRARY_PAGE_SIZE) + 1;
const showEnd = Math.min(offset + SPOTIFY_LIBRARY_PAGE_SIZE, total);
container.innerHTML = `
<button class="spotify-library-page-btn" onclick="spotifyLibraryPrevPage()" ${currentPage <= 1 ? 'disabled' : ''}>&larr; Previous</button>
<span class="spotify-library-page-info">${offset + 1}\u2013${showEnd} of ${total}</span>
<button class="spotify-library-page-btn" onclick="spotifyLibraryNextPage()" ${currentPage >= totalPages ? 'disabled' : ''}>Next &rarr;</button>
`;
}
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');

View file

@ -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: '<angle>';
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;