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 typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
@ -340,6 +340,7 @@ class WatchlistScanner:
|
||||||
Args:
|
Args:
|
||||||
spotify_artist_id: Spotify artist ID
|
spotify_artist_id: Spotify artist ID
|
||||||
last_scan_timestamp: Only return releases after this date (for incremental scans)
|
last_scan_timestamp: Only return releases after this date (for incremental scans)
|
||||||
|
If None, uses lookback period setting from database
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get all artist albums (albums + singles) - this is rate limited in spotify_client
|
# Get all artist albums (albums + singles) - this is rate limited in spotify_client
|
||||||
|
|
@ -353,22 +354,58 @@ class WatchlistScanner:
|
||||||
# Add small delay after fetching artist discography to be extra safe
|
# Add small delay after fetching artist discography to be extra safe
|
||||||
time.sleep(0.3) # 300ms breathing room
|
time.sleep(0.3) # 300ms breathing room
|
||||||
|
|
||||||
# Filter by release date if we have a last scan timestamp
|
# Determine cutoff date for filtering
|
||||||
if last_scan_timestamp:
|
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 = []
|
filtered_albums = []
|
||||||
for album in 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)
|
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 filtered_albums
|
||||||
|
|
||||||
|
# Return all albums if no cutoff (lookback_period = 'all')
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}")
|
logger.error(f"Error getting discography for artist {spotify_artist_id}: {e}")
|
||||||
return None
|
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:
|
def is_album_after_timestamp(self, album, timestamp: datetime) -> bool:
|
||||||
"""Check if album was released after the given timestamp"""
|
"""Check if album was released after the given timestamp"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -485,11 +522,13 @@ class WatchlistScanner:
|
||||||
album_id = album.get('id', '')
|
album_id = album.get('id', '')
|
||||||
album_release_date = album.get('release_date', '')
|
album_release_date = album.get('release_date', '')
|
||||||
album_images = album.get('images', [])
|
album_images = album.get('images', [])
|
||||||
|
album_type = album.get('album_type', 'album') # 'album', 'single', or 'ep'
|
||||||
else:
|
else:
|
||||||
album_name = album.name
|
album_name = album.name
|
||||||
album_id = album.id
|
album_id = album.id
|
||||||
album_release_date = album.release_date
|
album_release_date = album.release_date
|
||||||
album_images = album.images if hasattr(album, 'images') else []
|
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
|
# Create Spotify track data structure
|
||||||
spotify_track_data = {
|
spotify_track_data = {
|
||||||
|
|
@ -500,7 +539,8 @@ class WatchlistScanner:
|
||||||
'name': album_name,
|
'name': album_name,
|
||||||
'id': album_id,
|
'id': album_id,
|
||||||
'release_date': album_release_date,
|
'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,
|
'duration_ms': track_duration,
|
||||||
'explicit': track_explicit,
|
'explicit': track_explicit,
|
||||||
|
|
@ -829,6 +869,19 @@ class WatchlistScanner:
|
||||||
# Add each track to discovery pool
|
# Add each track to discovery pool
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
try:
|
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
|
# Build track data for discovery pool
|
||||||
track_data = {
|
track_data = {
|
||||||
'spotify_track_id': track['id'],
|
'spotify_track_id': track['id'],
|
||||||
|
|
@ -842,7 +895,7 @@ class WatchlistScanner:
|
||||||
'popularity': album_data.get('popularity', 0),
|
'popularity': album_data.get('popularity', 0),
|
||||||
'release_date': album_data.get('release_date', ''),
|
'release_date': album_data.get('release_date', ''),
|
||||||
'is_new_release': is_new,
|
'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
|
'artist_genres': artist_genres # Add cached genres
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -927,6 +980,19 @@ class WatchlistScanner:
|
||||||
|
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
try:
|
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 = {
|
track_data = {
|
||||||
'spotify_track_id': track['id'],
|
'spotify_track_id': track['id'],
|
||||||
'spotify_album_id': album_data['id'],
|
'spotify_album_id': album_data['id'],
|
||||||
|
|
@ -939,7 +1005,7 @@ class WatchlistScanner:
|
||||||
'popularity': album_data.get('popularity', 0),
|
'popularity': album_data.get('popularity', 0),
|
||||||
'release_date': album_data.get('release_date', ''),
|
'release_date': album_data.get('release_date', ''),
|
||||||
'is_new_release': is_new,
|
'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
|
'artist_genres': artist_genres
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1069,6 +1135,19 @@ class WatchlistScanner:
|
||||||
# Add each track to discovery pool
|
# Add each track to discovery pool
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
try:
|
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 = {
|
track_data = {
|
||||||
'spotify_track_id': track['id'],
|
'spotify_track_id': track['id'],
|
||||||
'spotify_album_id': album_data['id'],
|
'spotify_album_id': album_data['id'],
|
||||||
|
|
@ -1081,7 +1160,7 @@ class WatchlistScanner:
|
||||||
'popularity': album_data.get('popularity', 0),
|
'popularity': album_data.get('popularity', 0),
|
||||||
'release_date': album_data.get('release_date', ''),
|
'release_date': album_data.get('release_date', ''),
|
||||||
'is_new_release': is_new,
|
'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
|
'artist_genres': artist_genres
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1304,11 +1383,19 @@ class WatchlistScanner:
|
||||||
artist_tracks[artist].append(track_id)
|
artist_tracks[artist].append(track_id)
|
||||||
|
|
||||||
# Store full track data with score for sorting
|
# Store full track data with score for sorting
|
||||||
|
# Only include album metadata (not full album with all tracks)
|
||||||
full_track = {
|
full_track = {
|
||||||
'id': track_id,
|
'id': track_id,
|
||||||
'name': track['name'],
|
'name': track['name'],
|
||||||
'artists': track.get('artists', []),
|
'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),
|
'duration_ms': track.get('duration_ms', 0),
|
||||||
'popularity': popularity_score,
|
'popularity': popularity_score,
|
||||||
'score': total_score,
|
'score': total_score,
|
||||||
|
|
|
||||||
345
web_server.py
345
web_server.py
|
|
@ -7667,9 +7667,78 @@ def _process_wishlist_automatically():
|
||||||
|
|
||||||
print(f"🔧 [Auto-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service")
|
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
|
# Create batch for automatic processing
|
||||||
batch_id = str(uuid.uuid4())
|
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
|
# Create task queue - convert wishlist tracks to expected format
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
|
|
@ -7689,7 +7758,9 @@ def _process_wishlist_automatically():
|
||||||
'cancelled_tracks': set(),
|
'cancelled_tracks': set(),
|
||||||
# Mark as auto-initiated
|
# Mark as auto-initiated
|
||||||
'auto_initiated': True,
|
'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")
|
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}")
|
print(f"Error getting wishlist count: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
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'])
|
@app.route('/api/wishlist/tracks', methods=['GET'])
|
||||||
def get_wishlist_tracks():
|
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:
|
try:
|
||||||
from core.wishlist_service import get_wishlist_service
|
from core.wishlist_service import get_wishlist_service
|
||||||
from database.music_database import MusicDatabase
|
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
|
# Clean duplicates ONLY if no active wishlist download is running
|
||||||
# This prevents count mismatches during active downloads
|
# This prevents count mismatches during active downloads
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
|
|
@ -7850,6 +8122,34 @@ def get_wishlist_tracks():
|
||||||
sanitized_track = _sanitize_track_data_for_processing(track)
|
sanitized_track = _sanitize_track_data_for_processing(track)
|
||||||
sanitized_tracks.append(sanitized_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})
|
return jsonify({"tracks": sanitized_tracks})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting wishlist tracks: {e}")
|
print(f"Error getting wishlist tracks: {e}")
|
||||||
|
|
@ -8411,7 +8711,10 @@ def _run_quality_scanner(scope='watchlist'):
|
||||||
'id': best_match.id,
|
'id': best_match.id,
|
||||||
'name': best_match.name,
|
'name': best_match.name,
|
||||||
'artists': [{'name': artist} for artist in best_match.artists],
|
'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,
|
'duration_ms': best_match.duration_ms,
|
||||||
'popularity': best_match.popularity,
|
'popularity': best_match.popularity,
|
||||||
'preview_url': best_match.preview_url,
|
'preview_url': best_match.preview_url,
|
||||||
|
|
@ -9208,6 +9511,30 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id):
|
||||||
if tracks_added > 0:
|
if tracks_added > 0:
|
||||||
add_activity_item("⭐", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now")
|
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
|
# Mark auto-processing as complete and reset timestamp
|
||||||
with wishlist_timer_lock:
|
with wishlist_timer_lock:
|
||||||
wishlist_auto_processing = False
|
wishlist_auto_processing = False
|
||||||
|
|
@ -10780,7 +11107,10 @@ def cancel_download_task():
|
||||||
'id': track_info.get('id'),
|
'id': track_info.get('id'),
|
||||||
'name': track_info.get('name'),
|
'name': track_info.get('name'),
|
||||||
'artists': formatted_artists,
|
'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')
|
'duration_ms': track_info.get('duration_ms')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11196,7 +11526,10 @@ def _add_cancelled_task_to_wishlist(task):
|
||||||
'id': track_info.get('id'),
|
'id': track_info.get('id'),
|
||||||
'name': track_info.get('name'),
|
'name': track_info.get('name'),
|
||||||
'artists': formatted_artists,
|
'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')
|
'duration_ms': track_info.get('duration_ms')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2405,6 +2405,26 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Quality Profile Settings -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>🎵 Quality Profile</h3>
|
<h3>🎵 Quality Profile</h3>
|
||||||
|
|
|
||||||
|
|
@ -1506,6 +1506,17 @@ async function loadSettingsData() {
|
||||||
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
|
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
|
||||||
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
showToast('Failed to load settings', 'error');
|
showToast('Failed to load settings', 'error');
|
||||||
|
|
@ -1826,10 +1837,29 @@ async function saveSettings() {
|
||||||
// Save quality profile
|
// Save quality profile
|
||||||
const qualityProfileSaved = await saveQualityProfile();
|
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');
|
showToast('Settings saved successfully', 'success');
|
||||||
// Trigger immediate status update
|
// Trigger immediate status update
|
||||||
setTimeout(updateServiceStatus, 1000);
|
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) {
|
} else if (result.success && !qualityProfileSaved) {
|
||||||
showToast('Settings saved, but quality profile failed to save', 'warning');
|
showToast('Settings saved, but quality profile failed to save', 'warning');
|
||||||
setTimeout(updateServiceStatus, 1000);
|
setTimeout(updateServiceStatus, 1000);
|
||||||
|
|
@ -4641,7 +4671,309 @@ 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...');
|
showLoadingOverlay('Loading wishlist...');
|
||||||
const playlistId = "wishlist"; // Use a consistent ID for wishlist
|
const playlistId = "wishlist"; // Use a consistent ID for wishlist
|
||||||
|
|
||||||
|
|
@ -4660,20 +4992,24 @@ async function openDownloadMissingWishlistModal() {
|
||||||
return; // Don't create a new one
|
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
|
// Fetch actual wishlist tracks from the server
|
||||||
let tracks;
|
let tracks;
|
||||||
try {
|
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 response = await fetch('/api/wishlist/count');
|
||||||
const countData = await response.json();
|
const countData = await response.json();
|
||||||
if (countData.count === 0) {
|
if (countData.count === 0) {
|
||||||
showToast('Wishlist is empty. No tracks to download.', 'info');
|
showToast('Wishlist is empty. No tracks to download.', 'info');
|
||||||
|
hideLoadingOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the actual wishlist tracks for display
|
// Fetch the actual wishlist tracks for display (filtered by category if specified)
|
||||||
const tracksResponse = await fetch('/api/wishlist/tracks');
|
const tracksResponse = await fetch(apiUrl);
|
||||||
if (!tracksResponse.ok) {
|
if (!tracksResponse.ok) {
|
||||||
throw new Error('Failed to fetch wishlist tracks');
|
throw new Error('Failed to fetch wishlist tracks');
|
||||||
}
|
}
|
||||||
|
|
@ -8354,6 +8690,17 @@ async function addModalTracksToWishlist(playlistId) {
|
||||||
artists: formattedArtists
|
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', {
|
const response = await fetch('/api/add-album-to-wishlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -8362,12 +8709,12 @@ async function addModalTracksToWishlist(playlistId) {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
track: formattedTrack,
|
track: formattedTrack,
|
||||||
artist: artist,
|
artist: artist,
|
||||||
album: album,
|
album: trackAlbum,
|
||||||
source_type: 'album',
|
source_type: 'album',
|
||||||
source_context: {
|
source_context: {
|
||||||
album_name: album.name,
|
album_name: trackAlbum.name,
|
||||||
artist_name: artist.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.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess;
|
||||||
window.handleWishlistButtonClick = handleWishlistButtonClick;
|
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)
|
// Add to Wishlist Modal functions (new)
|
||||||
window.openAddToWishlistModal = openAddToWishlistModal;
|
window.openAddToWishlistModal = openAddToWishlistModal;
|
||||||
window.closeAddToWishlistModal = closeAddToWishlistModal;
|
window.closeAddToWishlistModal = closeAddToWishlistModal;
|
||||||
|
|
@ -11883,9 +12237,9 @@ async function handleWishlistButtonClick() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 4: Create fresh modal for new wishlist process
|
// STEP 4: Open wishlist overview modal (NEW - category selection)
|
||||||
console.log(`🆕 [Wishlist Button] Creating fresh modal for ${countData.count} wishlist tracks`);
|
console.log(`🆕 [Wishlist Button] Opening wishlist overview for ${countData.count} tracks`);
|
||||||
await openDownloadMissingWishlistModal();
|
await openWishlistOverviewModal();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [Wishlist Button] Error handling wishlist button click:', error);
|
console.error('❌ [Wishlist Button] Error handling wishlist button click:', error);
|
||||||
|
|
@ -25085,7 +25439,7 @@ async function loadDiscoverPage() {
|
||||||
loadDiscoverRecentReleases(),
|
loadDiscoverRecentReleases(),
|
||||||
loadSeasonalContent(), // Seasonal discovery
|
loadSeasonalContent(), // Seasonal discovery
|
||||||
loadPersonalizedRecentlyAdded(), // NEW: Recently added from library
|
loadPersonalizedRecentlyAdded(), // NEW: Recently added from library
|
||||||
loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists
|
// loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists (HIDDEN)
|
||||||
loadDiscoverReleaseRadar(),
|
loadDiscoverReleaseRadar(),
|
||||||
loadDiscoverWeekly(),
|
loadDiscoverWeekly(),
|
||||||
loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool
|
loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool
|
||||||
|
|
|
||||||
|
|
@ -1189,6 +1189,18 @@ body {
|
||||||
line-height: 1.3;
|
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 Styling */
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|
@ -7227,9 +7239,9 @@ body {
|
||||||
|
|
||||||
/* Playlist Details Modal - Clean Premium Design */
|
/* Playlist Details Modal - Clean Premium Design */
|
||||||
.playlist-modal {
|
.playlist-modal {
|
||||||
max-width: 800px;
|
max-width: 900px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-height: 85vh;
|
max-height: 90vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
|
@ -7489,6 +7501,327 @@ body {
|
||||||
transform: translateY(-1px);
|
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 {
|
.watchlist-artist-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue