Add Spotify Library discovery section, instrumental filter, custom exclusion terms & album download modal fixes
This commit is contained in:
parent
a5e72cff05
commit
c54e52e18d
7 changed files with 1158 additions and 13 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
126
web_server.py
126
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"""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' : ''}>← 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 →</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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue