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}")
|
logger.error(f"Error fetching saved tracks: {e}")
|
||||||
return []
|
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:
|
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).
|
"""Fetch playlist items using the /items endpoint (Feb 2026 Spotify API migration).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,12 @@ class WatchlistScanner:
|
||||||
logger.info("Updating seasonal content...")
|
logger.info("Updating seasonal content...")
|
||||||
self._populate_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
|
return scan_results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2742,6 +2748,66 @@ class WatchlistScanner:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
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):
|
def _populate_seasonal_content(self):
|
||||||
"""
|
"""
|
||||||
Populate seasonal content as part of watchlist scan.
|
Populate seasonal content as part of watchlist scan.
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,9 @@ class MusicDatabase:
|
||||||
self._add_profile_support_v4(cursor)
|
self._add_profile_support_v4(cursor)
|
||||||
self._add_profile_settings(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
|
# Mirrored playlists — persistent backup of parsed playlists from any service
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS mirrored_playlists (
|
CREATE TABLE IF NOT EXISTS mirrored_playlists (
|
||||||
|
|
@ -2256,6 +2259,43 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in profile settings migration: {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 ──────────────────────────────────────────────────
|
# ── Profile CRUD ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_all_profiles(self) -> List[Dict[str, Any]]:
|
def get_all_profiles(self) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -5133,6 +5173,154 @@ class MusicDatabase:
|
||||||
logger.error(f"Error getting watchlist artists: {e}")
|
logger.error(f"Error getting watchlist artists: {e}")
|
||||||
return []
|
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:
|
def get_watchlist_count(self, profile_id: int = 1) -> int:
|
||||||
"""Get the number of artists in the watchlist for the given profile"""
|
"""Get the number of artists in the watchlist for the given profile"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
126
web_server.py
126
web_server.py
|
|
@ -28874,6 +28874,15 @@ def start_watchlist_scan():
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
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:
|
except Exception as e:
|
||||||
print(f"Error during watchlist scan: {e}")
|
print(f"Error during watchlist scan: {e}")
|
||||||
watchlist_scan_state['status'] = 'error'
|
watchlist_scan_state['status'] = 'error'
|
||||||
|
|
@ -29866,6 +29875,19 @@ def _process_watchlist_scan_automatically(automation_id=None):
|
||||||
_update_automation_progress(automation_id,
|
_update_automation_progress(automation_id,
|
||||||
log_line=f'Seasonal error: {seasonal_error}', log_type='error')
|
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
|
# Add activity for watchlist scan completion
|
||||||
if total_added_to_wishlist > 0:
|
if total_added_to_wishlist > 0:
|
||||||
add_activity_item("👁️", "Watchlist Scan Complete", f"{total_added_to_wishlist} new tracks added to wishlist", "Now")
|
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
|
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'])
|
@app.route('/api/discover/recent-releases', methods=['GET'])
|
||||||
def get_discover_recent_releases():
|
def get_discover_recent_releases():
|
||||||
"""Get cached recent albums from watchlist and similar artists"""
|
"""Get cached recent albums from watchlist and similar artists"""
|
||||||
|
|
|
||||||
|
|
@ -2732,6 +2732,48 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Recent Releases Section -->
|
||||||
<div class="discover-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
|
|
|
||||||
|
|
@ -9757,10 +9757,11 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
|
||||||
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
|
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
|
||||||
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
|
||||||
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
|
||||||
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
|
||||||
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
|
||||||
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
|
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
|
||||||
'YouTube';
|
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
|
||||||
|
'YouTube';
|
||||||
|
|
||||||
// Store metadata for discover download sidebar (will be added when Begin Analysis is clicked)
|
// Store metadata for discover download sidebar (will be added when Begin Analysis is clicked)
|
||||||
if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_')) {
|
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
|
// 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 ? {
|
const heroContext = isDiscoverAlbum && album && artist ? {
|
||||||
type: 'album',
|
type: 'album',
|
||||||
artist: {
|
artist: {
|
||||||
|
|
@ -9885,10 +9886,10 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
|
||||||
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
|
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
|
||||||
<span>Force Download All</span>
|
<span>Force Download All</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="force-download-toggle">
|
${isDiscoverAlbum ? '' : `<label class="force-download-toggle">
|
||||||
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
|
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
|
||||||
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
|
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
|
||||||
</label>
|
</label>`}
|
||||||
</div>
|
</div>
|
||||||
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
|
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
|
||||||
Begin Analysis
|
Begin Analysis
|
||||||
|
|
@ -11551,7 +11552,7 @@ async function startMissingTracksProcess(playlistId) {
|
||||||
|
|
||||||
// If this is an artist album download, use album name and include full context
|
// 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
|
// 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.playlist_name = process.album?.name || process.playlist.name;
|
||||||
requestBody.is_album_download = true;
|
requestBody.is_album_download = true;
|
||||||
requestBody.album_context = process.album; // Full Spotify album object
|
requestBody.album_context = process.album; // Full Spotify album object
|
||||||
|
|
@ -21078,10 +21079,11 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
|
||||||
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
|
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
|
||||||
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
|
||||||
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
|
||||||
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
|
||||||
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
|
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
|
||||||
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
|
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
|
||||||
'YouTube';
|
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
|
||||||
|
'YouTube';
|
||||||
|
|
||||||
const heroContext = {
|
const heroContext = {
|
||||||
type: 'playlist',
|
type: 'playlist',
|
||||||
|
|
@ -31846,7 +31848,7 @@ async function showWatchlistModal() {
|
||||||
|
|
||||||
<div class="playlist-modal-body">
|
<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;">
|
<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"
|
id="scan-watchlist-btn"
|
||||||
onclick="startWatchlistScan()"
|
onclick="startWatchlistScan()"
|
||||||
${scanStatus === 'scanning' ? 'disabled' : ''}>
|
${scanStatus === 'scanning' ? 'disabled' : ''}>
|
||||||
|
|
@ -32849,6 +32851,7 @@ async function startWatchlistScan() {
|
||||||
const button = document.getElementById('scan-watchlist-btn');
|
const button = document.getElementById('scan-watchlist-btn');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.textContent = 'Starting scan...';
|
button.textContent = 'Starting scan...';
|
||||||
|
button.classList.add('btn-processing');
|
||||||
|
|
||||||
const response = await fetch('/api/watchlist/scan', {
|
const response = await fetch('/api/watchlist/scan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -32876,6 +32879,7 @@ async function startWatchlistScan() {
|
||||||
const button = document.getElementById('scan-watchlist-btn');
|
const button = document.getElementById('scan-watchlist-btn');
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Scan for New Releases';
|
button.textContent = 'Scan for New Releases';
|
||||||
|
button.classList.remove('btn-processing');
|
||||||
alert(`Error starting scan: ${error.message}`);
|
alert(`Error starting scan: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -32946,6 +32950,7 @@ function handleWatchlistScanData(data) {
|
||||||
if (button) {
|
if (button) {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Scan for New Releases';
|
button.textContent = 'Scan for New Releases';
|
||||||
|
button.classList.remove('btn-processing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide live activity
|
// Hide live activity
|
||||||
|
|
@ -32995,6 +33000,7 @@ function handleWatchlistScanData(data) {
|
||||||
if (button) {
|
if (button) {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Scan for New Releases';
|
button.textContent = 'Scan for New Releases';
|
||||||
|
button.classList.remove('btn-processing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide live activity
|
// Hide live activity
|
||||||
|
|
@ -33039,6 +33045,7 @@ async function updateSimilarArtists() {
|
||||||
|
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.textContent = 'Updating...';
|
button.textContent = 'Updating...';
|
||||||
|
button.classList.add('btn-processing');
|
||||||
if (scanButton) scanButton.disabled = true;
|
if (scanButton) scanButton.disabled = true;
|
||||||
|
|
||||||
const response = await fetch('/api/watchlist/update-similar-artists', {
|
const response = await fetch('/api/watchlist/update-similar-artists', {
|
||||||
|
|
@ -33063,6 +33070,7 @@ async function updateSimilarArtists() {
|
||||||
|
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Update Similar Artists';
|
button.textContent = 'Update Similar Artists';
|
||||||
|
button.classList.remove('btn-processing');
|
||||||
if (scanButton) scanButton.disabled = false;
|
if (scanButton) scanButton.disabled = false;
|
||||||
|
|
||||||
showToast(`Error: ${error.message}`, 'error');
|
showToast(`Error: ${error.message}`, 'error');
|
||||||
|
|
@ -33085,6 +33093,7 @@ async function pollSimilarArtistsUpdate() {
|
||||||
if (button) {
|
if (button) {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Update Similar Artists';
|
button.textContent = 'Update Similar Artists';
|
||||||
|
button.classList.remove('btn-processing');
|
||||||
}
|
}
|
||||||
if (scanButton) scanButton.disabled = false;
|
if (scanButton) scanButton.disabled = false;
|
||||||
|
|
||||||
|
|
@ -33095,6 +33104,7 @@ async function pollSimilarArtistsUpdate() {
|
||||||
if (button) {
|
if (button) {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Update Similar Artists';
|
button.textContent = 'Update Similar Artists';
|
||||||
|
button.classList.remove('btn-processing');
|
||||||
}
|
}
|
||||||
if (scanButton) scanButton.disabled = false;
|
if (scanButton) scanButton.disabled = false;
|
||||||
|
|
||||||
|
|
@ -33121,6 +33131,7 @@ async function pollSimilarArtistsUpdate() {
|
||||||
if (button) {
|
if (button) {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = 'Update Similar Artists';
|
button.textContent = 'Update Similar Artists';
|
||||||
|
button.classList.remove('btn-processing');
|
||||||
}
|
}
|
||||||
if (scanButton) scanButton.disabled = false;
|
if (scanButton) scanButton.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -42410,6 +42421,7 @@ async function loadDiscoverPage() {
|
||||||
// Load all sections
|
// Load all sections
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadDiscoverHero(),
|
loadDiscoverHero(),
|
||||||
|
loadSpotifyLibrarySection(),
|
||||||
loadDiscoverRecentReleases(),
|
loadDiscoverRecentReleases(),
|
||||||
loadSeasonalContent(), // Seasonal discovery
|
loadSeasonalContent(), // Seasonal discovery
|
||||||
loadPersonalizedRecentlyAdded(), // NEW: Recently added from library
|
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() {
|
async function loadDiscoverReleaseRadar() {
|
||||||
try {
|
try {
|
||||||
const playlistContainer = document.getElementById('release-radar-playlist');
|
const playlistContainer = document.getElementById('release-radar-playlist');
|
||||||
|
|
|
||||||
|
|
@ -11322,6 +11322,48 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scan button: shimmer on hover */
|
/* 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 {
|
.watchlist-btn-scan {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -24409,6 +24451,254 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Discover Empty State */
|
/* 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 {
|
.discover-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue