diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index c947329d..3bb4ee04 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -5,7 +5,7 @@ Watchlist Scanner Service - Monitors watched artists for new releases """ from typing import List, Dict, Any, Optional -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from dataclasses import dataclass import re import time @@ -336,39 +336,76 @@ class WatchlistScanner: def get_artist_discography(self, spotify_artist_id: str, last_scan_timestamp: Optional[datetime] = None) -> Optional[List]: """ Get artist's discography from Spotify, optionally filtered by release date. - + Args: spotify_artist_id: Spotify artist ID last_scan_timestamp: Only return releases after this date (for incremental scans) + If None, uses lookback period setting from database """ try: # Get all artist albums (albums + singles) - this is rate limited in spotify_client logger.debug(f"Fetching discography for artist {spotify_artist_id}") albums = self.spotify_client.get_artist_albums(spotify_artist_id, album_type='album,single', limit=50) - + if not albums: logger.warning(f"No albums found for artist {spotify_artist_id}") return [] - + # Add small delay after fetching artist discography to be extra safe time.sleep(0.3) # 300ms breathing room - - # Filter by release date if we have a last scan timestamp - if last_scan_timestamp: + + # Determine cutoff date for filtering + cutoff_timestamp = last_scan_timestamp + + # If no last scan timestamp, use lookback period setting + if cutoff_timestamp is None: + lookback_period = self._get_lookback_period_setting() + if lookback_period != 'all': + # Convert period to days and create cutoff date (use UTC) + days = int(lookback_period) + cutoff_timestamp = datetime.now(timezone.utc) - timedelta(days=days) + logger.info(f"Using lookback period: {lookback_period} days (cutoff: {cutoff_timestamp})") + + # Filter by release date if we have a cutoff timestamp + if cutoff_timestamp: filtered_albums = [] for album in albums: - if self.is_album_after_timestamp(album, last_scan_timestamp): + if self.is_album_after_timestamp(album, cutoff_timestamp): filtered_albums.append(album) - - logger.info(f"Filtered {len(albums)} albums to {len(filtered_albums)} released after {last_scan_timestamp}") + + logger.info(f"Filtered {len(albums)} albums to {len(filtered_albums)} released after {cutoff_timestamp}") return filtered_albums - + + # Return all albums if no cutoff (lookback_period = 'all') return albums except Exception as e: logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}") return None - + + def _get_lookback_period_setting(self) -> str: + """ + Get the discovery lookback period setting from database. + + Returns: + str: Period value ('7', '30', '90', '180', or 'all') + """ + try: + with self.database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM metadata WHERE key = 'discovery_lookback_period'") + row = cursor.fetchone() + + if row: + return row['value'] + else: + # Default to 30 days if not set + return '30' + + except Exception as e: + logger.warning(f"Error getting lookback period setting, defaulting to 30 days: {e}") + return '30' + def is_album_after_timestamp(self, album, timestamp: datetime) -> bool: """Check if album was released after the given timestamp""" try: @@ -485,12 +522,14 @@ class WatchlistScanner: album_id = album.get('id', '') album_release_date = album.get('release_date', '') album_images = album.get('images', []) + album_type = album.get('album_type', 'album') # 'album', 'single', or 'ep' else: album_name = album.name album_id = album.id album_release_date = album.release_date album_images = album.images if hasattr(album, 'images') else [] - + album_type = album.album_type if hasattr(album, 'album_type') else 'album' + # Create Spotify track data structure spotify_track_data = { 'id': track_id, @@ -500,7 +539,8 @@ class WatchlistScanner: 'name': album_name, 'id': album_id, 'release_date': album_release_date, - 'images': album_images + 'images': album_images, + 'album_type': album_type # Store album type for category filtering }, 'duration_ms': track_duration, 'explicit': track_explicit, @@ -829,6 +869,19 @@ class WatchlistScanner: # Add each track to discovery pool for track in tracks: try: + # Enhance track object with full album data (including album_type) + enhanced_track = { + **track, + 'album': { + 'id': album_data['id'], + 'name': album_data.get('name', 'Unknown Album'), + 'images': album_data.get('images', []), + 'release_date': album_data.get('release_date', ''), + 'album_type': album_data.get('album_type', 'album'), + 'total_tracks': album_data.get('total_tracks', 0) + } + } + # Build track data for discovery pool track_data = { 'spotify_track_id': track['id'], @@ -842,7 +895,7 @@ class WatchlistScanner: 'popularity': album_data.get('popularity', 0), 'release_date': album_data.get('release_date', ''), 'is_new_release': is_new, - 'track_data_json': track, # Store full Spotify track object + 'track_data_json': enhanced_track, # Store enhanced track with full album data 'artist_genres': artist_genres # Add cached genres } @@ -927,6 +980,19 @@ class WatchlistScanner: for track in tracks: try: + # Enhance track object with full album data (including album_type) + enhanced_track = { + **track, + 'album': { + 'id': album_data['id'], + 'name': album_row['title'], + 'images': album_data.get('images', []), + 'release_date': album_data.get('release_date', ''), + 'album_type': album_data.get('album_type', 'album'), + 'total_tracks': album_data.get('total_tracks', 0) + } + } + track_data = { 'spotify_track_id': track['id'], 'spotify_album_id': album_data['id'], @@ -939,7 +1005,7 @@ class WatchlistScanner: 'popularity': album_data.get('popularity', 0), 'release_date': album_data.get('release_date', ''), 'is_new_release': is_new, - 'track_data_json': track, + 'track_data_json': enhanced_track, # Store enhanced track with full album data 'artist_genres': artist_genres } @@ -1069,6 +1135,19 @@ class WatchlistScanner: # Add each track to discovery pool for track in tracks: try: + # Enhance track object with full album data (including album_type) + enhanced_track = { + **track, + 'album': { + 'id': album_data['id'], + 'name': album_data.get('name', 'Unknown Album'), + 'images': album_data.get('images', []), + 'release_date': album_data.get('release_date', ''), + 'album_type': album_data.get('album_type', 'album'), + 'total_tracks': album_data.get('total_tracks', 0) + } + } + track_data = { 'spotify_track_id': track['id'], 'spotify_album_id': album_data['id'], @@ -1081,7 +1160,7 @@ class WatchlistScanner: 'popularity': album_data.get('popularity', 0), 'release_date': album_data.get('release_date', ''), 'is_new_release': is_new, - 'track_data_json': track, + 'track_data_json': enhanced_track, # Store enhanced track with full album data 'artist_genres': artist_genres } @@ -1304,11 +1383,19 @@ class WatchlistScanner: artist_tracks[artist].append(track_id) # Store full track data with score for sorting + # Only include album metadata (not full album with all tracks) full_track = { 'id': track_id, 'name': track['name'], 'artists': track.get('artists', []), - 'album': album_data, + 'album': { + 'id': album_data['id'], + 'name': album_data.get('name', 'Unknown Album'), + 'images': album_data.get('images', []), + 'release_date': album_data.get('release_date', ''), + 'album_type': album_data.get('album_type', 'album'), + 'total_tracks': album_data.get('total_tracks', 0) + }, 'duration_ms': track.get('duration_ms', 0), 'popularity': popularity_score, 'score': total_score, diff --git a/web_server.py b/web_server.py index aacffeca..78f2d260 100644 --- a/web_server.py +++ b/web_server.py @@ -7664,12 +7664,81 @@ def _process_wishlist_automatically(): for track in raw_wishlist_tracks: sanitized_track = _sanitize_track_data_for_processing(track) wishlist_tracks.append(sanitized_track) - + print(f"🔧 [Auto-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service") - + + # CYCLE FILTERING: Get current cycle and filter tracks by category + from database.music_database import MusicDatabase + db = MusicDatabase() + + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM metadata WHERE key = 'wishlist_cycle'") + row = cursor.fetchone() + + if row: + current_cycle = row['value'] + else: + # Default to albums on first run + current_cycle = 'albums' + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('wishlist_cycle', 'albums', CURRENT_TIMESTAMP) + """) + conn.commit() + + # Filter tracks by current cycle category + import json + filtered_tracks = [] + for track in wishlist_tracks: + # Extract album_type from spotify_data JSON (after sanitization) + spotify_data = track.get('spotify_data', {}) + if isinstance(spotify_data, str): + try: + spotify_data = json.loads(spotify_data) + except: + spotify_data = {} + + album_data = spotify_data.get('album', {}) + album_type = album_data.get('album_type', 'album').lower() + + if current_cycle == 'singles': + if album_type in ['single', 'ep']: + filtered_tracks.append(track) + elif current_cycle == 'albums': + if album_type == 'album': + filtered_tracks.append(track) + + print(f"🔄 [Auto-Wishlist] Current cycle: {current_cycle}") + print(f"📊 [Auto-Wishlist] Filtered {len(filtered_tracks)}/{len(wishlist_tracks)} tracks for '{current_cycle}' category") + + # If no tracks in this category, skip to next cycle immediately + if len(filtered_tracks) == 0: + print(f"â„šī¸ [Auto-Wishlist] No {current_cycle} tracks in wishlist, toggling cycle and scheduling next run") + + # Toggle cycle + next_cycle = 'singles' if current_cycle == 'albums' else 'albums' + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('wishlist_cycle', ?, CURRENT_TIMESTAMP) + """, (next_cycle,)) + conn.commit() + print(f"🔄 [Auto-Wishlist] Cycle toggled: {current_cycle} → {next_cycle}") + + with wishlist_timer_lock: + wishlist_auto_processing = False + wishlist_auto_processing_timestamp = 0 + schedule_next_wishlist_processing() + return + + # Use filtered tracks for processing + wishlist_tracks = filtered_tracks + # Create batch for automatic processing batch_id = str(uuid.uuid4()) - playlist_name = "Wishlist (Auto)" + playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})" # Create task queue - convert wishlist tracks to expected format with tasks_lock: @@ -7689,7 +7758,9 @@ def _process_wishlist_automatically(): 'cancelled_tracks': set(), # Mark as auto-initiated 'auto_initiated': True, - 'auto_processing_timestamp': time.time() + 'auto_processing_timestamp': time.time(), + # Store current cycle for toggling after completion + 'current_cycle': current_cycle } print(f"🚀 Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") @@ -7818,13 +7889,214 @@ def get_wishlist_count(): print(f"Error getting wishlist count: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/wishlist/stats', methods=['GET']) +def get_wishlist_stats(): + """ + Get wishlist statistics broken down by category. + + Returns: + { + "singles": int, # Count of singles + EPs + "albums": int, # Count of album tracks + "total": int # Total count + } + """ + try: + from core.wishlist_service import get_wishlist_service + + wishlist_service = get_wishlist_service() + raw_tracks = wishlist_service.get_wishlist_tracks_for_download() + + singles_count = 0 + albums_count = 0 + + for track in raw_tracks: + # Extract album_type from spotify_data JSON + spotify_data = track.get('spotify_data', {}) + if isinstance(spotify_data, str): + import json + spotify_data = json.loads(spotify_data) + + album_data = spotify_data.get('album', {}) + album_type = album_data.get('album_type', 'album').lower() + + if album_type in ['single', 'ep']: + singles_count += 1 + elif album_type == 'album': + albums_count += 1 + else: + # Default unknown types to albums + albums_count += 1 + + total_count = singles_count + albums_count + + return jsonify({ + "singles": singles_count, + "albums": albums_count, + "total": total_count + }) + + except Exception as e: + print(f"Error getting wishlist stats: {e}") + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + +@app.route('/api/wishlist/cycle', methods=['GET']) +def get_wishlist_cycle(): + """ + Get the current wishlist processing cycle. + + Returns: + {"cycle": "albums" | "singles"} + """ + try: + from database.music_database import MusicDatabase + db = MusicDatabase() + + # Get cycle from metadata table + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM metadata WHERE key = 'wishlist_cycle'") + row = cursor.fetchone() + + if row: + cycle = row['value'] + else: + # Default to albums on first run + cycle = 'albums' + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('wishlist_cycle', 'albums', CURRENT_TIMESTAMP) + """) + conn.commit() + + return jsonify({"cycle": cycle}) + + except Exception as e: + print(f"Error getting wishlist cycle: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/wishlist/cycle', methods=['POST']) +def set_wishlist_cycle(): + """ + Set the current wishlist processing cycle. + + Body: + {"cycle": "albums" | "singles"} + """ + try: + data = request.get_json() + cycle = data.get('cycle') + + if cycle not in ['albums', 'singles']: + return jsonify({"error": "Invalid cycle. Must be 'albums' or 'singles'"}), 400 + + from database.music_database import MusicDatabase + db = MusicDatabase() + + # Store cycle in metadata table + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('wishlist_cycle', ?, CURRENT_TIMESTAMP) + """, (cycle,)) + conn.commit() + + print(f"✅ Wishlist cycle set to: {cycle}") + return jsonify({"success": True, "cycle": cycle}) + + except Exception as e: + print(f"Error setting wishlist cycle: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/discovery/lookback-period', methods=['GET']) +def get_discovery_lookback_period(): + """ + Get the discovery pool lookback period setting. + + Returns: + {"period": "7" | "30" | "90" | "180" | "all"} + """ + try: + from database.music_database import MusicDatabase + db = MusicDatabase() + + # Get lookback period from metadata table + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM metadata WHERE key = 'discovery_lookback_period'") + row = cursor.fetchone() + + if row: + period = row['value'] + else: + # Default to 30 days on first access + period = '30' + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('discovery_lookback_period', '30', CURRENT_TIMESTAMP) + """) + conn.commit() + + return jsonify({"period": period}) + + except Exception as e: + print(f"Error getting discovery lookback period: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/discovery/lookback-period', methods=['POST']) +def set_discovery_lookback_period(): + """ + Set the discovery pool lookback period setting. + + Body: + {"period": "7" | "30" | "90" | "180" | "all"} + """ + try: + data = request.get_json() + period = data.get('period') + + valid_periods = ['7', '30', '90', '180', 'all'] + if period not in valid_periods: + return jsonify({"error": f"Invalid period. Must be one of: {', '.join(valid_periods)}"}), 400 + + from database.music_database import MusicDatabase + db = MusicDatabase() + + # Store lookback period in metadata table + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('discovery_lookback_period', ?, CURRENT_TIMESTAMP) + """, (period,)) + conn.commit() + + print(f"✅ Discovery lookback period set to: {period}") + return jsonify({"success": True, "period": period}) + + except Exception as e: + print(f"Error setting discovery lookback period: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/wishlist/tracks', methods=['GET']) def get_wishlist_tracks(): - """Endpoint to get wishlist tracks for display in modal.""" + """ + Endpoint to get wishlist tracks for display in modal. + Supports category filtering via query parameter. + + Query Parameters: + category (optional): 'singles' or 'albums' - filters tracks by album type + """ try: from core.wishlist_service import get_wishlist_service from database.music_database import MusicDatabase + # Get category filter from query params + category = request.args.get('category', None) # None = all tracks + # Clean duplicates ONLY if no active wishlist download is running # This prevents count mismatches during active downloads with tasks_lock: @@ -7850,6 +8122,34 @@ def get_wishlist_tracks(): sanitized_track = _sanitize_track_data_for_processing(track) sanitized_tracks.append(sanitized_track) + # FILTER by category if specified + if category: + import json + filtered_tracks = [] + for track in sanitized_tracks: + # Extract album_type from spotify_data JSON (same as stats endpoint) + spotify_data = track.get('spotify_data', {}) + if isinstance(spotify_data, str): + try: + spotify_data = json.loads(spotify_data) + except: + spotify_data = {} + + album_data = spotify_data.get('album', {}) + album_type = album_data.get('album_type', 'album').lower() + + if category == 'singles': + # Singles category includes 'single' and 'ep' + if album_type in ['single', 'ep']: + filtered_tracks.append(track) + elif category == 'albums': + # Albums category includes only 'album' + if album_type == 'album': + filtered_tracks.append(track) + + print(f"📊 Wishlist filter: {len(filtered_tracks)}/{len(sanitized_tracks)} tracks in '{category}' category") + return jsonify({"tracks": filtered_tracks, "category": category}) + return jsonify({"tracks": sanitized_tracks}) except Exception as e: print(f"Error getting wishlist tracks: {e}") @@ -8411,7 +8711,10 @@ def _run_quality_scanner(scope='watchlist'): 'id': best_match.id, 'name': best_match.name, 'artists': [{'name': artist} for artist in best_match.artists], - 'album': {'name': best_match.album}, + 'album': { + 'name': best_match.album, + 'album_type': 'album' # Default to 'album' for quality scanner matches + }, 'duration_ms': best_match.duration_ms, 'popularity': best_match.popularity, 'preview_url': best_match.preview_url, @@ -9207,7 +9510,31 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id): # Add activity for wishlist processing if tracks_added > 0: add_activity_item("⭐", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now") - + + # TOGGLE CYCLE: Switch to next category for next run + try: + with tasks_lock: + if batch_id in download_batches: + current_cycle = download_batches[batch_id].get('current_cycle', 'albums') + else: + current_cycle = 'albums' # Default fallback + + next_cycle = 'singles' if current_cycle == 'albums' else 'albums' + + from database.music_database import MusicDatabase + db = MusicDatabase() + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('wishlist_cycle', ?, CURRENT_TIMESTAMP) + """, (next_cycle,)) + conn.commit() + + print(f"🔄 [Auto-Wishlist] Cycle toggled after completion: {current_cycle} → {next_cycle}") + except Exception as cycle_error: + print(f"âš ī¸ [Auto-Wishlist] Error toggling cycle: {cycle_error}") + # Mark auto-processing as complete and reset timestamp with wishlist_timer_lock: wishlist_auto_processing = False @@ -10780,7 +11107,10 @@ def cancel_download_task(): 'id': track_info.get('id'), 'name': track_info.get('name'), 'artists': formatted_artists, - 'album': {'name': track_info.get('album')}, + 'album': { + 'name': track_info.get('album'), + 'album_type': track_info.get('album_type', 'album') # Use track's album type if available + }, 'duration_ms': track_info.get('duration_ms') } @@ -11196,10 +11526,13 @@ def _add_cancelled_task_to_wishlist(task): 'id': track_info.get('id'), 'name': track_info.get('name'), 'artists': formatted_artists, - 'album': {'name': track_info.get('album')}, + 'album': { + 'name': track_info.get('album'), + 'album_type': track_info.get('album_type', 'album') # Use track's album type if available + }, 'duration_ms': track_info.get('duration_ms') } - + source_context = { 'playlist_name': task.get('playlist_name', 'Unknown Playlist'), 'playlist_id': task.get('playlist_id'), diff --git a/webui/index.html b/webui/index.html index f2dedaef..6938eecd 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2405,6 +2405,26 @@ + +
+

🔍 Discovery Pool Settings

+ +
+ + +
+ Controls how far back to scan when adding new artists or refreshing discovery pool. + Once scanned, artists are kept updated with new releases only. +
+
+
+

đŸŽĩ Quality Profile

diff --git a/webui/static/script.js b/webui/static/script.js index edf817a2..71d9408b 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1505,7 +1505,18 @@ async function loadSettingsData() { // Populate Logging information (read-only) document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO'; document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; - + + // Load Discovery Lookback Period setting + try { + const lookbackResponse = await fetch('/api/discovery/lookback-period'); + const lookbackData = await lookbackResponse.json(); + if (lookbackData.period) { + document.getElementById('discovery-lookback-period').value = lookbackData.period; + } + } catch (error) { + console.error('Error loading discovery lookback period:', error); + } + } catch (error) { console.error('Error loading settings:', error); showToast('Failed to load settings', 'error'); @@ -1826,10 +1837,29 @@ async function saveSettings() { // Save quality profile const qualityProfileSaved = await saveQualityProfile(); - if (result.success && qualityProfileSaved) { + // Save discovery lookback period + let lookbackSaved = true; + try { + const lookbackPeriod = document.getElementById('discovery-lookback-period').value; + const lookbackResponse = await fetch('/api/discovery/lookback-period', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ period: lookbackPeriod }) + }); + const lookbackResult = await lookbackResponse.json(); + lookbackSaved = lookbackResult.success === true; + } catch (error) { + console.error('Error saving discovery lookback period:', error); + lookbackSaved = false; + } + + if (result.success && qualityProfileSaved && lookbackSaved) { showToast('Settings saved successfully', 'success'); // Trigger immediate status update setTimeout(updateServiceStatus, 1000); + } else if (result.success && qualityProfileSaved && !lookbackSaved) { + showToast('Settings saved, but discovery lookback period failed to save', 'warning'); + setTimeout(updateServiceStatus, 1000); } else if (result.success && !qualityProfileSaved) { showToast('Settings saved, but quality profile failed to save', 'warning'); setTimeout(updateServiceStatus, 1000); @@ -4641,10 +4671,312 @@ async function closeDownloadMissingModal(playlistId) { } } -async function openDownloadMissingWishlistModal() { +/** + * Open wishlist overview modal showing category breakdown + * This is the NEW entry point for wishlist from dashboard + */ +async function openWishlistOverviewModal() { + try { + showLoadingOverlay('Loading wishlist...'); + + // Fetch wishlist stats + const statsResponse = await fetch('/api/wishlist/stats'); + const statsData = await statsResponse.json(); + + if (!statsResponse.ok) { + throw new Error(statsData.error || 'Failed to fetch wishlist stats'); + } + + const { singles, albums, total } = statsData; + + if (total === 0) { + hideLoadingOverlay(); + showToast('Wishlist is empty. No tracks to process.', 'info'); + return; + } + + // Create modal if it doesn't exist + let modal = document.getElementById('wishlist-overview-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'wishlist-overview-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + // Fetch current cycle + const cycleResponse = await fetch('/api/wishlist/cycle'); + const cycleData = await cycleResponse.json(); + const currentCycle = cycleData.cycle || 'albums'; + + modal.innerHTML = ` + + `; + + modal.style.display = 'flex'; + hideLoadingOverlay(); + + } catch (error) { + console.error('Error opening wishlist overview:', error); + showToast(`Failed to load wishlist: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} + +function closeWishlistOverviewModal() { + const modal = document.getElementById('wishlist-overview-modal'); + if (modal) { + modal.style.display = 'none'; + } + window.selectedWishlistCategory = null; +} + +async function selectWishlistCategory(category) { + try { + window.selectedWishlistCategory = category; + + const tracksList = document.getElementById('wishlist-tracks-list'); + const categoryTracksSection = document.getElementById('wishlist-category-tracks'); + const categoryGrid = document.querySelector('.wishlist-category-grid'); + const downloadBtn = document.getElementById('wishlist-download-btn'); + const categoryName = document.getElementById('wishlist-category-name'); + + categoryGrid.style.display = 'none'; + categoryTracksSection.style.display = 'block'; + downloadBtn.style.display = 'inline-block'; + categoryName.textContent = category === 'albums' ? 'Albums / EPs' : 'Singles'; + + tracksList.innerHTML = '
Loading tracks...
'; + + const response = await fetch(`/api/wishlist/tracks?category=${category}`); + const data = await response.json(); + + if (!response.ok) throw new Error(data.error || 'Failed to fetch tracks'); + + const tracks = data.tracks || []; + + if (tracks.length === 0) { + tracksList.innerHTML = '
No tracks in this category
'; + return; + } + + // For Albums/EPs, group by album + if (category === 'albums') { + const albumGroups = {}; + + tracks.forEach(track => { + let spotifyData = track.spotify_data; + if (typeof spotifyData === 'string') { + try { + spotifyData = JSON.parse(spotifyData); + } catch (e) { + spotifyData = null; + } + } + + const albumName = spotifyData?.album?.name || 'Unknown Album'; + const artistName = spotifyData?.artists?.[0]?.name || 'Unknown Artist'; + const artistId = spotifyData?.artists?.[0]?.id || null; + const albumImage = spotifyData?.album?.images?.[0]?.url || ''; + + // Use album ID if available, otherwise create unique key from album + artist + const albumId = spotifyData?.album?.id || `${albumName}_${artistName}`.replace(/\s+/g, '_').toLowerCase(); + + if (!albumGroups[albumId]) { + albumGroups[albumId] = { + albumName, + artistName, + artistId, + albumImage, + tracks: [] + }; + } + + albumGroups[albumId].tracks.push({ + name: track.name || 'Unknown Track', + artistName, + trackNumber: spotifyData?.track_number || 0 + }); + }); + + // Render album cards + let albumsHTML = '
'; + Object.entries(albumGroups).forEach(([albumId, albumData]) => { + // Sort tracks by track number + albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber); + + const tracksListHTML = albumData.tracks.map(track => ` +
+ ${track.name} +
+ `).join(''); + + albumsHTML += ` +
+
+
+
+
${albumData.albumName}
+
${albumData.artistName}
+
${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}
+
+
â–ŧ
+
+ +
+ `; + }); + albumsHTML += '
'; + + tracksList.innerHTML = albumsHTML; + + } else { + // For Singles, show list with album images + let tracksHTML = ''; + tracks.forEach((track, index) => { + const trackName = track.name || 'Unknown Track'; + + let spotifyData = track.spotify_data; + if (typeof spotifyData === 'string') { + try { + spotifyData = JSON.parse(spotifyData); + } catch (e) { + spotifyData = null; + } + } + + let artistName = 'Unknown Artist'; + if (spotifyData?.artists?.[0]?.name) { + artistName = spotifyData.artists[0].name; + } else if (Array.isArray(track.artists) && track.artists.length > 0) { + if (typeof track.artists[0] === 'string') { + artistName = track.artists[0]; + } else if (track.artists[0]?.name) { + artistName = track.artists[0].name; + } + } + + let albumName = 'Unknown Album'; + if (spotifyData?.album?.name) { + albumName = spotifyData.album.name; + } else if (typeof track.album === 'string') { + albumName = track.album; + } else if (track.album?.name) { + albumName = track.album.name; + } + + const albumImage = spotifyData?.album?.images?.[0]?.url || ''; + + tracksHTML += ` +
+
+
+
${trackName}
+
${artistName} â€ĸ ${albumName}
+
+
+ `; + }); + + tracksList.innerHTML = tracksHTML; + } + + } catch (error) { + console.error('Error loading category tracks:', error); + showToast(`Failed to load tracks: ${error.message}`, 'error'); + } +} + +function backToCategories() { + const categoryTracksSection = document.getElementById('wishlist-category-tracks'); + const categoryGrid = document.querySelector('.wishlist-category-grid'); + const downloadBtn = document.getElementById('wishlist-download-btn'); + + categoryTracksSection.style.display = 'none'; + categoryGrid.style.display = 'grid'; + downloadBtn.style.display = 'none'; + window.selectedWishlistCategory = null; +} + +function toggleAlbumTracks(albumId) { + const tracksElement = document.getElementById(`tracks-${albumId}`); + const expandIcon = document.getElementById(`expand-icon-${albumId}`); + + if (tracksElement.style.display === 'none') { + tracksElement.style.display = 'block'; + expandIcon.textContent = '▲'; + } else { + tracksElement.style.display = 'none'; + expandIcon.textContent = 'â–ŧ'; + } +} + +async function downloadSelectedCategory() { + const category = window.selectedWishlistCategory; + if (!category) { + showToast('No category selected', 'error'); + return; + } + + closeWishlistOverviewModal(); + await openDownloadMissingWishlistModal(category); +} + +async function openDownloadMissingWishlistModal(category = null) { showLoadingOverlay('Loading wishlist...'); const playlistId = "wishlist"; // Use a consistent ID for wishlist - + // Check if a process is already active for the wishlist if (activeDownloadProcesses[playlistId]) { console.log(`Modal for wishlist already exists. Showing it.`); @@ -4660,20 +4992,24 @@ async function openDownloadMissingWishlistModal() { return; // Don't create a new one } - console.log(`đŸ“Ĩ Opening Download Missing Tracks modal for wishlist`); - + console.log(`đŸ“Ĩ Opening Download Missing Tracks modal for wishlist${category ? ' (' + category + ')' : ''}`); + // Fetch actual wishlist tracks from the server let tracks; try { + // Build API URL with optional category filter + const apiUrl = category ? `/api/wishlist/tracks?category=${category}` : '/api/wishlist/tracks'; + const response = await fetch('/api/wishlist/count'); const countData = await response.json(); if (countData.count === 0) { showToast('Wishlist is empty. No tracks to download.', 'info'); + hideLoadingOverlay(); return; } - - // Fetch the actual wishlist tracks for display - const tracksResponse = await fetch('/api/wishlist/tracks'); + + // Fetch the actual wishlist tracks for display (filtered by category if specified) + const tracksResponse = await fetch(apiUrl); if (!tracksResponse.ok) { throw new Error('Failed to fetch wishlist tracks'); } @@ -8354,6 +8690,17 @@ async function addModalTracksToWishlist(playlistId) { artists: formattedArtists }; + // Use track's own album data if available (has correct album_type) + // Don't fall back to process album/playlist if track has no album + // (better to skip than add with wrong data) + if (!track.album || !track.album.name) { + console.warn(`âš ī¸ Skipping track "${track.name}" - missing album data`); + continue; + } + + const trackAlbum = track.album; + const trackAlbumType = track.album.album_type || 'album'; + const response = await fetch('/api/add-album-to-wishlist', { method: 'POST', headers: { @@ -8362,12 +8709,12 @@ async function addModalTracksToWishlist(playlistId) { body: JSON.stringify({ track: formattedTrack, artist: artist, - album: album, + album: trackAlbum, source_type: 'album', source_context: { - album_name: album.name, + album_name: trackAlbum.name, artist_name: artist.name, - album_type: album.album_type || 'album' + album_type: trackAlbumType } }) }); @@ -8441,6 +8788,13 @@ window.openDownloadMissingWishlistModal = openDownloadMissingWishlistModal; window.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess; window.handleWishlistButtonClick = handleWishlistButtonClick; +// Wishlist Overview Modal functions (new) +window.openWishlistOverviewModal = openWishlistOverviewModal; +window.closeWishlistOverviewModal = closeWishlistOverviewModal; +window.selectWishlistCategory = selectWishlistCategory; +window.backToCategories = backToCategories; +window.downloadSelectedCategory = downloadSelectedCategory; + // Add to Wishlist Modal functions (new) window.openAddToWishlistModal = openAddToWishlistModal; window.closeAddToWishlistModal = closeAddToWishlistModal; @@ -11883,9 +12237,9 @@ async function handleWishlistButtonClick() { return; } - // STEP 4: Create fresh modal for new wishlist process - console.log(`🆕 [Wishlist Button] Creating fresh modal for ${countData.count} wishlist tracks`); - await openDownloadMissingWishlistModal(); + // STEP 4: Open wishlist overview modal (NEW - category selection) + console.log(`🆕 [Wishlist Button] Opening wishlist overview for ${countData.count} tracks`); + await openWishlistOverviewModal(); } catch (error) { console.error('❌ [Wishlist Button] Error handling wishlist button click:', error); @@ -25085,7 +25439,7 @@ async function loadDiscoverPage() { loadDiscoverRecentReleases(), loadSeasonalContent(), // Seasonal discovery loadPersonalizedRecentlyAdded(), // NEW: Recently added from library - loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists + // loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists (HIDDEN) loadDiscoverReleaseRadar(), loadDiscoverWeekly(), loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool diff --git a/webui/static/style.css b/webui/static/style.css index e9fa7253..0f506963 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -1189,6 +1189,18 @@ body { line-height: 1.3; } +.setting-help-text { + color: #b3b3b3; + font-size: 11px; + font-style: italic; + line-height: 1.4; + margin-top: 6px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.03); + border-left: 2px solid rgba(29, 185, 84, 0.3); + border-radius: 4px; +} + /* Form Styling */ .form-group { margin-bottom: 12px; @@ -7227,15 +7239,15 @@ body { /* Playlist Details Modal - Clean Premium Design */ .playlist-modal { - max-width: 800px; + max-width: 900px; width: 90%; - max-height: 85vh; + max-height: 90vh; display: flex; flex-direction: column; - + /* Premium glassmorphic foundation */ - background: linear-gradient(135deg, - rgba(20, 20, 20, 0.95) 0%, + background: linear-gradient(135deg, + rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%); backdrop-filter: blur(20px) saturate(1.2); border-radius: 20px; @@ -7489,6 +7501,327 @@ body { transform: translateY(-1px); } +/* ===== WISHLIST OVERVIEW MODAL STYLES ===== */ + +/* Category Grid */ +.wishlist-category-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + padding: 28px; +} + +/* Category Card */ +.wishlist-category-card { + background: linear-gradient(135deg, + rgba(26, 26, 26, 0.95) 0%, + rgba(18, 18, 18, 0.98) 100%); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 32px 24px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.wishlist-category-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #1db954, #1ed760); + opacity: 0; + transition: opacity 0.3s ease; +} + +.wishlist-category-card:hover::before { + opacity: 1; +} + +.wishlist-category-card:hover { + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.1) 0%, + rgba(18, 18, 18, 0.98) 100%); + border-color: rgba(29, 185, 84, 0.3); + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(29, 185, 84, 0.2); +} + +.wishlist-category-card.next-in-queue { + border-color: rgba(29, 185, 84, 0.4); + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.08) 0%, + rgba(18, 18, 18, 0.98) 100%); +} + +.wishlist-category-icon { + font-size: 48px; + margin-bottom: 16px; + filter: drop-shadow(0 4px 12px rgba(29, 185, 84, 0.3)); +} + +.wishlist-category-title { + font-size: 20px; + font-weight: 600; + color: #ffffff; + margin-bottom: 8px; +} + +.wishlist-category-count { + font-size: 32px; + font-weight: 700; + color: #1ed760; + margin: 12px 0; +} + +.wishlist-category-badge { + display: inline-block; + background: rgba(29, 185, 84, 0.2); + border: 1px solid rgba(29, 185, 84, 0.4); + color: #1ed760; + font-size: 12px; + font-weight: 600; + padding: 6px 12px; + border-radius: 20px; + margin-top: 12px; +} + +/* Category Tracks View */ +.wishlist-category-tracks { + padding: 0 28px; + display: flex; + flex-direction: column; + height: 100%; +} + +.wishlist-category-header { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + margin-bottom: 16px; +} + +.wishlist-back-btn { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #e0e0e0; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.wishlist-back-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + transform: translateX(-2px); +} + +.wishlist-category-name { + font-size: 18px; + font-weight: 600; + color: #ffffff; +} + +.loading-indicator { + text-align: center; + padding: 60px 20px; + color: #b3b3b3; + font-size: 16px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: #b3b3b3; + font-size: 16px; +} + +.playlist-tracks-scroll { + flex: 1; + overflow-y: auto; + max-height: 600px; + padding-right: 8px; +} + +.playlist-tracks-scroll::-webkit-scrollbar { + width: 8px; +} + +.playlist-tracks-scroll::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; +} + +.playlist-tracks-scroll::-webkit-scrollbar-thumb { + background: rgba(29, 185, 84, 0.4); + border-radius: 4px; +} + +.playlist-tracks-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(29, 185, 84, 0.6); +} + +/* Album Grouping Cards */ +.wishlist-album-grid { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 0; +} + +.wishlist-album-card { + background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; + cursor: pointer; +} + +.wishlist-album-card:hover { + border-color: rgba(29, 185, 84, 0.3); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(29, 185, 84, 0.15); +} + +.wishlist-album-header { + display: flex; + align-items: center; + padding: 16px; + gap: 16px; +} + +.wishlist-album-image { + width: 64px; + height: 64px; + background-size: cover; + background-position: center; + background-color: rgba(255, 255, 255, 0.05); + border-radius: 8px; + flex-shrink: 0; +} + +.wishlist-album-info { + flex: 1; + min-width: 0; +} + +.wishlist-album-name { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wishlist-album-artist { + font-size: 14px; + color: #b3b3b3; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wishlist-album-track-count { + font-size: 12px; + color: #1db954; + font-weight: 500; +} + +.wishlist-album-expand-icon { + font-size: 14px; + color: #b3b3b3; + transition: transform 0.3s ease; + flex-shrink: 0; +} + +.wishlist-album-tracks { + border-top: 1px solid rgba(255, 255, 255, 0.05); + padding: 0; + background: rgba(0, 0, 0, 0.2); +} + +.wishlist-album-track { + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.2s ease; +} + +.wishlist-album-track:last-child { + border-bottom: none; +} + +.wishlist-album-track:hover { + background: rgba(29, 185, 84, 0.05); +} + +.wishlist-album-track-name { + font-size: 14px; + color: #e0e0e0; +} + +/* Singles Track Items with Images */ +.playlist-track-item-with-image { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 8px; + transition: background 0.2s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.playlist-track-item-with-image:hover { + background: rgba(29, 185, 84, 0.05); +} + +.playlist-track-image { + width: 48px; + height: 48px; + background-size: cover; + background-position: center; + background-color: rgba(255, 255, 255, 0.05); + border-radius: 6px; + flex-shrink: 0; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .wishlist-category-grid { + grid-template-columns: 1fr; + gap: 16px; + padding: 20px; + } + + .wishlist-category-card { + padding: 24px 20px; + } + + .wishlist-category-icon { + font-size: 36px; + } + + .wishlist-category-title { + font-size: 18px; + } + + .wishlist-category-count { + font-size: 28px; + } +} + .watchlist-artist-info { display: flex; flex-direction: column;