Wishlist overhaul near complete
This commit is contained in:
parent
0809787a79
commit
0b2322f225
5 changed files with 1176 additions and 49 deletions
|
|
@ -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,
|
||||
|
|
|
|||
353
web_server.py
353
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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue