Wishlist overhaul near complete

This commit is contained in:
Broque Thomas 2025-11-23 18:22:09 -08:00
parent 0809787a79
commit 0b2322f225
5 changed files with 1176 additions and 49 deletions

View file

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

View file

@ -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'),

View file

@ -2405,6 +2405,26 @@
</div>
</div>
<!-- Discovery Settings -->
<div class="settings-group">
<h3>🔍 Discovery Pool Settings</h3>
<div class="form-group">
<label>Lookback Period:</label>
<select id="discovery-lookback-period" class="form-select">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days (Default)</option>
<option value="90">Last 90 days</option>
<option value="180">Last 6 months</option>
<option value="all">Entire discography</option>
</select>
<div class="setting-help-text">
Controls how far back to scan when adding new artists or refreshing discovery pool.
Once scanned, artists are kept updated with new releases only.
</div>
</div>
</div>
<!-- Quality Profile Settings -->
<div class="settings-group">
<h3>🎵 Quality Profile</h3>

View file

@ -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 = `
<div class="modal-container playlist-modal">
<div class="playlist-modal-header">
<div class="playlist-header-content">
<h2>🎵 Wishlist Overview</h2>
<div class="playlist-quick-info">
<span class="playlist-track-count">${total} Total Tracks</span>
<span class="playlist-owner">Next Auto: ${currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'}</span>
</div>
</div>
<span class="playlist-modal-close" onclick="closeWishlistOverviewModal()">×</span>
</div>
<div class="playlist-modal-body">
<div class="wishlist-category-grid">
<!-- Albums/EPs Category -->
<div class="wishlist-category-card ${currentCycle === 'albums' ? 'next-in-queue' : ''}" data-category="albums" onclick="selectWishlistCategory('albums')">
<div class="wishlist-category-icon">💿</div>
<div class="wishlist-category-title">Albums / EPs</div>
<div class="wishlist-category-count">${albums} tracks</div>
${currentCycle === 'albums' ? '<div class="wishlist-category-badge">Next in Queue</div>' : ''}
</div>
<!-- Singles Category -->
<div class="wishlist-category-card ${currentCycle === 'singles' ? 'next-in-queue' : ''}" data-category="singles" onclick="selectWishlistCategory('singles')">
<div class="wishlist-category-icon">🎵</div>
<div class="wishlist-category-title">Singles</div>
<div class="wishlist-category-count">${singles} tracks</div>
${currentCycle === 'singles' ? '<div class="wishlist-category-badge">Next in Queue</div>' : ''}
</div>
</div>
<!-- Selected Category Track List (initially hidden) -->
<div id="wishlist-category-tracks" class="wishlist-category-tracks" style="display: none;">
<div class="wishlist-category-header">
<button class="wishlist-back-btn" onclick="backToCategories()"> Back</button>
<span id="wishlist-category-name" class="wishlist-category-name"></span>
</div>
<div id="wishlist-tracks-list" class="playlist-tracks-scroll">
<div class="loading-indicator">Loading tracks...</div>
</div>
</div>
</div>
<div class="playlist-modal-footer">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistOverviewModal()">Close</button>
<button id="wishlist-download-btn" class="playlist-modal-btn playlist-modal-btn-primary" style="display: none;" onclick="downloadSelectedCategory()">
Download Selection
</button>
</div>
</div>
`;
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 = '<div class="loading-indicator">Loading tracks...</div>';
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 = '<div class="empty-state">No tracks in this category</div>';
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 = '<div class="wishlist-album-grid">';
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 => `
<div class="wishlist-album-track">
<span class="wishlist-album-track-name">${track.name}</span>
</div>
`).join('');
albumsHTML += `
<div class="wishlist-album-card" onclick="toggleAlbumTracks('${albumId}')">
<div class="wishlist-album-header">
<div class="wishlist-album-image" style="background-image: url('${albumData.albumImage}')"></div>
<div class="wishlist-album-info">
<div class="wishlist-album-name">${albumData.albumName}</div>
<div class="wishlist-album-artist">${albumData.artistName}</div>
<div class="wishlist-album-track-count">${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}</div>
</div>
<div class="wishlist-album-expand-icon" id="expand-icon-${albumId}"></div>
</div>
<div class="wishlist-album-tracks" id="tracks-${albumId}" style="display: none;">
${tracksListHTML}
</div>
</div>
`;
});
albumsHTML += '</div>';
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 += `
<div class="playlist-track-item-with-image">
<div class="playlist-track-image" style="background-image: url('${albumImage}')"></div>
<div class="playlist-track-info">
<div class="playlist-track-name">${trackName}</div>
<div class="playlist-track-artist">${artistName} ${albumName}</div>
</div>
</div>
`;
});
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

View file

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