Add Deezer playlist sync tab with discovery, fix modal, and cache persistence
This commit is contained in:
parent
f9d80606e3
commit
55186c6a51
5 changed files with 2212 additions and 37 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import re
|
||||
import requests
|
||||
import time
|
||||
import threading
|
||||
from typing import Dict, Optional, Any
|
||||
from typing import Dict, List, Optional, Any
|
||||
from functools import wraps
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
|
@ -223,3 +224,124 @@ class DeezerClient:
|
|||
except Exception as e:
|
||||
logger.error(f"Error getting track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
@rate_limited
|
||||
def get_playlist(self, playlist_id) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get a playlist with all its tracks by ID.
|
||||
|
||||
Fetches playlist metadata and tracks, paginating if the playlist
|
||||
contains more tracks than a single response returns (400 per page).
|
||||
|
||||
Args:
|
||||
playlist_id: Deezer playlist ID (string or int)
|
||||
|
||||
Returns:
|
||||
Dict with id, name, description, track_count, image_url, owner,
|
||||
and tracks list, or None on error
|
||||
"""
|
||||
try:
|
||||
playlist_id = str(playlist_id)
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.BASE_URL}/playlist/{playlist_id}",
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if 'error' in data:
|
||||
logger.error(f"Deezer API error getting playlist {playlist_id}: {data['error']}")
|
||||
return None
|
||||
|
||||
total_tracks = data.get('nb_tracks', 0)
|
||||
raw_tracks = data.get('tracks', {}).get('data', [])
|
||||
|
||||
# Paginate if we didn't get all tracks
|
||||
while len(raw_tracks) < total_tracks:
|
||||
index = len(raw_tracks)
|
||||
logger.debug(f"Paginating playlist {playlist_id} tracks at index {index}")
|
||||
page_response = self.session.get(
|
||||
f"{self.BASE_URL}/playlist/{playlist_id}/tracks",
|
||||
params={'index': index, 'limit': 400},
|
||||
timeout=15
|
||||
)
|
||||
page_response.raise_for_status()
|
||||
|
||||
page_data = page_response.json()
|
||||
if 'error' in page_data:
|
||||
logger.warning(f"Error paginating playlist tracks at index {index}: {page_data['error']}")
|
||||
break
|
||||
|
||||
page_tracks = page_data.get('data', [])
|
||||
if not page_tracks:
|
||||
break
|
||||
|
||||
raw_tracks.extend(page_tracks)
|
||||
|
||||
# Normalize tracks
|
||||
tracks: List[Dict[str, Any]] = []
|
||||
for i, t in enumerate(raw_tracks, start=1):
|
||||
artist_name = t.get('artist', {}).get('name', 'Unknown Artist')
|
||||
# Some tracks list multiple artists separated by commas or slashes
|
||||
tracks.append({
|
||||
'id': str(t.get('id', '')),
|
||||
'name': t.get('title', ''),
|
||||
'artists': [artist_name],
|
||||
'album': t.get('album', {}).get('title', ''),
|
||||
'duration_ms': t.get('duration', 0) * 1000,
|
||||
'track_number': i,
|
||||
})
|
||||
|
||||
result = {
|
||||
'id': str(data.get('id', '')),
|
||||
'name': data.get('title', ''),
|
||||
'description': data.get('description', ''),
|
||||
'track_count': total_tracks,
|
||||
'image_url': data.get('picture_medium', ''),
|
||||
'owner': data.get('creator', {}).get('name', ''),
|
||||
'tracks': tracks,
|
||||
}
|
||||
|
||||
logger.info(f"Fetched playlist '{result['name']}' with {len(tracks)} tracks")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting playlist {playlist_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def parse_playlist_url(url: str) -> Optional[str]:
|
||||
"""
|
||||
Extract a Deezer playlist ID from a URL or raw numeric string.
|
||||
|
||||
Supported formats:
|
||||
https://www.deezer.com/playlist/1234567890
|
||||
https://www.deezer.com/en/playlist/1234567890
|
||||
https://deezer.com/playlist/1234567890
|
||||
1234567890
|
||||
|
||||
Args:
|
||||
url: Deezer playlist URL or numeric ID
|
||||
|
||||
Returns:
|
||||
Playlist ID as a string, or None if the input is invalid
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
|
||||
url = url.strip()
|
||||
|
||||
# Raw numeric ID
|
||||
if url.isdigit():
|
||||
return url
|
||||
|
||||
# URL pattern: optional www, optional locale segment, /playlist/{id}
|
||||
match = re.match(
|
||||
r'https?://(?:www\.)?deezer\.com/(?:[a-z]{2}/)?playlist/(\d+)',
|
||||
url
|
||||
)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
|
|
|||
877
web_server.py
877
web_server.py
|
|
@ -13,6 +13,7 @@ import glob
|
|||
import uuid
|
||||
import re
|
||||
import sqlite3
|
||||
import types
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
|
@ -19033,6 +19034,13 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
|
||||
print(f"📋 Updated Tidal playlist {tidal_playlist_id} to download_complete phase")
|
||||
|
||||
# Update Deezer playlist phase to 'download_complete' if this is a Deezer playlist
|
||||
if playlist_id and playlist_id.startswith('deezer_'):
|
||||
deezer_playlist_id = playlist_id.replace('deezer_', '')
|
||||
if deezer_playlist_id in deezer_discovery_states:
|
||||
deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete'
|
||||
print(f"📋 Updated Deezer playlist {deezer_playlist_id} to download_complete phase")
|
||||
|
||||
print(f"🎉 [Batch Manager] Batch {batch_id} complete - stopping monitor")
|
||||
download_monitor.stop_monitoring(batch_id)
|
||||
|
||||
|
|
@ -22871,6 +22879,40 @@ def update_tidal_discovery_match():
|
|||
print(f"✅ Manual match updated: tidal - {identifier} - track {track_index}")
|
||||
print(f" → {result['spotify_artist']} - {result['spotify_track']}")
|
||||
|
||||
# Save manual fix to discovery cache so it appears in discovery pool
|
||||
try:
|
||||
original_track = result.get('tidal_track', {})
|
||||
original_name = original_track.get('name', spotify_track['name'])
|
||||
original_artist = ''
|
||||
original_artists = original_track.get('artists', [])
|
||||
if original_artists:
|
||||
original_artist = original_artists[0] if isinstance(original_artists[0], str) else original_artists[0].get('name', '')
|
||||
|
||||
cache_key = _get_discovery_cache_key(original_name, original_artist)
|
||||
# Normalize artists to plain strings for cache consistency
|
||||
artists_list = spotify_track['artists']
|
||||
if isinstance(artists_list, list):
|
||||
artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list]
|
||||
album_raw = spotify_track.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
|
||||
matched_data = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': artists_list,
|
||||
'album': album_obj,
|
||||
'duration_ms': spotify_track.get('duration_ms', 0),
|
||||
'source': 'spotify',
|
||||
}
|
||||
cache_db = get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], 'spotify', 1.0, matched_data,
|
||||
original_name, original_artist
|
||||
)
|
||||
print(f"💾 Manual fix saved to discovery cache: {original_name} by {original_artist}")
|
||||
except Exception as cache_err:
|
||||
print(f"⚠️ Error saving manual fix to discovery cache: {cache_err}")
|
||||
|
||||
return jsonify({'success': True, 'result': result})
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -23971,6 +24013,791 @@ def cancel_tidal_sync(playlist_id):
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# DEEZER PLAYLIST DISCOVERY API ENDPOINTS
|
||||
# ===================================================================
|
||||
|
||||
# Global state for Deezer playlist discovery management
|
||||
deezer_discovery_states = {} # Key: playlist_id, Value: discovery state
|
||||
deezer_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="deezer_discovery")
|
||||
|
||||
# Lazy-initialized global DeezerClient instance
|
||||
_deezer_client_instance = None
|
||||
_deezer_client_lock = threading.Lock()
|
||||
|
||||
def _get_deezer_client():
|
||||
"""Get or create the global DeezerClient instance (thread-safe)."""
|
||||
global _deezer_client_instance
|
||||
if _deezer_client_instance is None:
|
||||
with _deezer_client_lock:
|
||||
if _deezer_client_instance is None:
|
||||
from core.deezer_client import DeezerClient
|
||||
_deezer_client_instance = DeezerClient()
|
||||
return _deezer_client_instance
|
||||
|
||||
@app.route('/api/deezer/playlist/<playlist_id>', methods=['GET'])
|
||||
def get_deezer_playlist(playlist_id):
|
||||
"""Fetch a Deezer playlist by ID or URL"""
|
||||
try:
|
||||
from core.deezer_client import DeezerClient
|
||||
|
||||
# Parse URL if needed
|
||||
parsed_id = DeezerClient.parse_playlist_url(playlist_id)
|
||||
if not parsed_id:
|
||||
return jsonify({"error": "Invalid Deezer playlist ID or URL"}), 400
|
||||
|
||||
client = _get_deezer_client()
|
||||
playlist = client.get_playlist(parsed_id)
|
||||
|
||||
if not playlist:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
return jsonify(playlist)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error fetching Deezer playlist: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/discovery/start/<playlist_id>', methods=['POST'])
|
||||
def start_deezer_discovery(playlist_id):
|
||||
"""Start Spotify discovery process for a Deezer playlist"""
|
||||
try:
|
||||
from core.deezer_client import DeezerClient
|
||||
|
||||
# Parse URL if needed
|
||||
parsed_id = DeezerClient.parse_playlist_url(playlist_id)
|
||||
if parsed_id:
|
||||
playlist_id = parsed_id
|
||||
|
||||
# Initialize discovery state if it doesn't exist, or update existing state
|
||||
if playlist_id in deezer_discovery_states:
|
||||
existing_state = deezer_discovery_states[playlist_id]
|
||||
if existing_state['phase'] == 'discovering':
|
||||
return jsonify({"error": "Discovery already in progress"}), 400
|
||||
|
||||
# Fetch fresh playlist data if not already stored
|
||||
if not existing_state.get('playlist'):
|
||||
client = _get_deezer_client()
|
||||
playlist_data = client.get_playlist(playlist_id)
|
||||
if not playlist_data:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
existing_state['playlist'] = playlist_data
|
||||
|
||||
# Update existing state for discovery
|
||||
existing_state['phase'] = 'discovering'
|
||||
existing_state['status'] = 'discovering'
|
||||
existing_state['last_accessed'] = time.time()
|
||||
state = existing_state
|
||||
else:
|
||||
# Fetch playlist data from Deezer
|
||||
client = _get_deezer_client()
|
||||
playlist_data = client.get_playlist(playlist_id)
|
||||
|
||||
if not playlist_data:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
if not playlist_data.get('tracks'):
|
||||
return jsonify({"error": "Playlist has no tracks"}), 400
|
||||
|
||||
# Create new state for first-time discovery
|
||||
state = {
|
||||
'playlist': playlist_data,
|
||||
'phase': 'discovering', # fresh -> discovering -> discovered -> syncing -> sync_complete -> downloading -> download_complete
|
||||
'status': 'discovering',
|
||||
'discovery_progress': 0,
|
||||
'spotify_matches': 0,
|
||||
'spotify_total': len(playlist_data['tracks']),
|
||||
'discovery_results': [],
|
||||
'sync_playlist_id': None,
|
||||
'converted_spotify_playlist_id': None,
|
||||
'download_process_id': None,
|
||||
'created_at': time.time(),
|
||||
'last_accessed': time.time(),
|
||||
'discovery_future': None,
|
||||
'sync_progress': {}
|
||||
}
|
||||
deezer_discovery_states[playlist_id] = state
|
||||
|
||||
# Add activity for discovery start
|
||||
playlist_name = state['playlist']['name']
|
||||
track_count = len(state['playlist']['tracks'])
|
||||
add_activity_item("🔍", "Deezer Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now")
|
||||
|
||||
# Start discovery worker
|
||||
future = deezer_discovery_executor.submit(_run_deezer_discovery_worker, playlist_id)
|
||||
state['discovery_future'] = future
|
||||
|
||||
print(f"🔍 Started Spotify discovery for Deezer playlist: {playlist_name}")
|
||||
return jsonify({"success": True, "message": "Discovery started"})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error starting Deezer discovery: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/discovery/status/<playlist_id>', methods=['GET'])
|
||||
def get_deezer_discovery_status(playlist_id):
|
||||
"""Get real-time discovery status for a Deezer playlist"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer discovery not found"}), 404
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
state['last_accessed'] = time.time()
|
||||
|
||||
response = {
|
||||
'phase': state['phase'],
|
||||
'status': state['status'],
|
||||
'progress': state['discovery_progress'],
|
||||
'spotify_matches': state['spotify_matches'],
|
||||
'spotify_total': state['spotify_total'],
|
||||
'results': state['discovery_results'],
|
||||
'complete': state['phase'] == 'discovered'
|
||||
}
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting Deezer discovery status: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/discovery/update_match', methods=['POST'])
|
||||
def update_deezer_discovery_match():
|
||||
"""Update a Deezer discovery result with manually selected Spotify track"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
identifier = data.get('identifier') # playlist_id
|
||||
track_index = data.get('track_index')
|
||||
spotify_track = data.get('spotify_track')
|
||||
|
||||
if not identifier or track_index is None or not spotify_track:
|
||||
return jsonify({'error': 'Missing required fields'}), 400
|
||||
|
||||
# Get the state
|
||||
state = deezer_discovery_states.get(identifier)
|
||||
|
||||
if not state:
|
||||
return jsonify({'error': 'Discovery state not found'}), 404
|
||||
|
||||
if track_index >= len(state['discovery_results']):
|
||||
return jsonify({'error': 'Invalid track index'}), 400
|
||||
|
||||
# Update the result
|
||||
result = state['discovery_results'][track_index]
|
||||
old_status = result.get('status')
|
||||
|
||||
# Update with user-selected track
|
||||
result['status'] = '✅ Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = spotify_track['name']
|
||||
result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists'])
|
||||
result['spotify_album'] = spotify_track['album']
|
||||
result['spotify_id'] = spotify_track['id']
|
||||
|
||||
# Format duration
|
||||
duration_ms = spotify_track.get('duration_ms', 0)
|
||||
if duration_ms:
|
||||
minutes = duration_ms // 60000
|
||||
seconds = (duration_ms % 60000) // 1000
|
||||
result['duration'] = f"{minutes}:{seconds:02d}"
|
||||
else:
|
||||
result['duration'] = '0:00'
|
||||
|
||||
# IMPORTANT: Also set spotify_data for sync/download compatibility
|
||||
result['spotify_data'] = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': spotify_track['artists'],
|
||||
'album': spotify_track['album'],
|
||||
'duration_ms': spotify_track.get('duration_ms', 0)
|
||||
}
|
||||
|
||||
result['manual_match'] = True
|
||||
|
||||
# Update match count if status changed from not found/error
|
||||
if old_status != 'found' and old_status != '✅ Found':
|
||||
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
|
||||
|
||||
print(f"✅ Manual match updated: deezer - {identifier} - track {track_index}")
|
||||
print(f" → {result['spotify_artist']} - {result['spotify_track']}")
|
||||
|
||||
# Save manual fix to discovery cache so it appears in discovery pool
|
||||
try:
|
||||
original_track = result.get('deezer_track', {})
|
||||
original_name = original_track.get('name', spotify_track['name'])
|
||||
original_artists = original_track.get('artists', [])
|
||||
original_artist = original_artists[0] if original_artists else ''
|
||||
|
||||
cache_key = _get_discovery_cache_key(original_name, original_artist)
|
||||
# Normalize artists to plain strings for cache consistency
|
||||
artists_list = spotify_track['artists']
|
||||
if isinstance(artists_list, list):
|
||||
artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list]
|
||||
album_raw = spotify_track.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
|
||||
matched_data = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': artists_list,
|
||||
'album': album_obj,
|
||||
'duration_ms': spotify_track.get('duration_ms', 0),
|
||||
'source': 'spotify',
|
||||
}
|
||||
cache_db = get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], 'spotify', 1.0, matched_data,
|
||||
original_name, original_artist
|
||||
)
|
||||
print(f"💾 Manual fix saved to discovery cache: {original_name} by {original_artist}")
|
||||
except Exception as cache_err:
|
||||
print(f"⚠️ Error saving manual fix to discovery cache: {cache_err}")
|
||||
|
||||
return jsonify({'success': True, 'result': result})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error updating Deezer discovery match: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/playlists/states', methods=['GET'])
|
||||
def get_deezer_playlist_states():
|
||||
"""Get all stored Deezer playlist discovery states for frontend hydration"""
|
||||
try:
|
||||
states = []
|
||||
current_time = time.time()
|
||||
|
||||
for playlist_id, state in deezer_discovery_states.items():
|
||||
state['last_accessed'] = current_time
|
||||
|
||||
state_info = {
|
||||
'playlist_id': playlist_id,
|
||||
'phase': state['phase'],
|
||||
'status': state['status'],
|
||||
'discovery_progress': state['discovery_progress'],
|
||||
'spotify_matches': state['spotify_matches'],
|
||||
'spotify_total': state['spotify_total'],
|
||||
'discovery_results': state['discovery_results'],
|
||||
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
|
||||
'download_process_id': state.get('download_process_id'),
|
||||
'last_accessed': state['last_accessed']
|
||||
}
|
||||
states.append(state_info)
|
||||
|
||||
print(f"🎵 Returning {len(states)} stored Deezer playlist states for hydration")
|
||||
return jsonify({"states": states})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting Deezer playlist states: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/state/<playlist_id>', methods=['GET'])
|
||||
def get_deezer_playlist_state(playlist_id):
|
||||
"""Get specific Deezer playlist state (detailed version)"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
state['last_accessed'] = time.time()
|
||||
|
||||
# Deezer playlist is a dict, no __dict__ needed
|
||||
response = {
|
||||
'playlist_id': playlist_id,
|
||||
'playlist': state['playlist'],
|
||||
'phase': state['phase'],
|
||||
'status': state['status'],
|
||||
'discovery_progress': state['discovery_progress'],
|
||||
'spotify_matches': state['spotify_matches'],
|
||||
'spotify_total': state['spotify_total'],
|
||||
'discovery_results': state['discovery_results'],
|
||||
'sync_playlist_id': state.get('sync_playlist_id'),
|
||||
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
|
||||
'download_process_id': state.get('download_process_id'),
|
||||
'sync_progress': state.get('sync_progress', {}),
|
||||
'last_accessed': state['last_accessed']
|
||||
}
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting Deezer playlist state: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/reset/<playlist_id>', methods=['POST'])
|
||||
def reset_deezer_playlist(playlist_id):
|
||||
"""Reset Deezer playlist to fresh phase (clear discovery/sync data)"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
|
||||
# Stop any active discovery
|
||||
if 'discovery_future' in state and state['discovery_future']:
|
||||
state['discovery_future'].cancel()
|
||||
|
||||
# Reset state to fresh (preserve original playlist data)
|
||||
state['phase'] = 'fresh'
|
||||
state['status'] = 'fresh'
|
||||
state['discovery_results'] = []
|
||||
state['discovery_progress'] = 0
|
||||
state['spotify_matches'] = 0
|
||||
state['sync_playlist_id'] = None
|
||||
state['converted_spotify_playlist_id'] = None
|
||||
state['download_process_id'] = None
|
||||
state['sync_progress'] = {}
|
||||
state['discovery_future'] = None
|
||||
state['last_accessed'] = time.time()
|
||||
|
||||
print(f"🔄 Reset Deezer playlist to fresh: {playlist_id}")
|
||||
return jsonify({"success": True, "message": "Playlist reset to fresh phase"})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error resetting Deezer playlist: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/delete/<playlist_id>', methods=['POST'])
|
||||
def delete_deezer_playlist(playlist_id):
|
||||
"""Delete Deezer playlist state completely"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
|
||||
# Stop any active discovery
|
||||
if 'discovery_future' in state and state['discovery_future']:
|
||||
state['discovery_future'].cancel()
|
||||
|
||||
# Remove from state dictionary
|
||||
del deezer_discovery_states[playlist_id]
|
||||
|
||||
print(f"🗑️ Deleted Deezer playlist state: {playlist_id}")
|
||||
return jsonify({"success": True, "message": "Playlist deleted"})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error deleting Deezer playlist: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/update_phase/<playlist_id>', methods=['POST'])
|
||||
def update_deezer_playlist_phase(playlist_id):
|
||||
"""Update Deezer playlist phase (used when modal closes to reset from download_complete to discovered)"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if not data or 'phase' not in data:
|
||||
return jsonify({"error": "Phase not provided"}), 400
|
||||
|
||||
new_phase = data['phase']
|
||||
valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete']
|
||||
|
||||
if new_phase not in valid_phases:
|
||||
return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
old_phase = state.get('phase', 'unknown')
|
||||
state['phase'] = new_phase
|
||||
state['last_accessed'] = time.time()
|
||||
|
||||
print(f"🔄 Updated Deezer playlist {playlist_id} phase: {old_phase} → {new_phase}")
|
||||
return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error updating Deezer playlist phase: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
def _run_deezer_discovery_worker(playlist_id):
|
||||
"""Background worker for Deezer discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = _pause_enrichment_workers('Deezer discovery')
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
playlist = state['playlist']
|
||||
|
||||
# Determine which provider to use
|
||||
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
|
||||
discovery_source = 'spotify' if use_spotify else 'itunes'
|
||||
|
||||
# Initialize iTunes client if needed
|
||||
itunes_client_instance = None
|
||||
if not use_spotify:
|
||||
from core.itunes_client import iTunesClient
|
||||
itunes_client_instance = iTunesClient()
|
||||
|
||||
print(f"🎵 Starting Deezer discovery for: {playlist['name']} (using {discovery_source.upper()})")
|
||||
|
||||
# Store discovery source in state for frontend
|
||||
state['discovery_source'] = discovery_source
|
||||
|
||||
successful_discoveries = 0
|
||||
tracks = playlist['tracks']
|
||||
|
||||
for i, deezer_track in enumerate(tracks):
|
||||
if state.get('cancelled', False):
|
||||
break
|
||||
|
||||
try:
|
||||
track_name = deezer_track['name']
|
||||
track_artists = deezer_track['artists']
|
||||
track_id = deezer_track['id']
|
||||
track_album = deezer_track.get('album', '')
|
||||
track_duration_ms = deezer_track.get('duration_ms', 0)
|
||||
|
||||
print(f"🔍 [{i+1}/{len(tracks)}] Searching {discovery_source.upper()}: {track_name} by {', '.join(track_artists)}")
|
||||
|
||||
# Check discovery cache first
|
||||
cache_key = _get_discovery_cache_key(track_name, track_artists[0] if track_artists else '')
|
||||
try:
|
||||
cache_db = get_database()
|
||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and _validate_discovery_cache_artist(track_artists[0] if track_artists else '', cached_match):
|
||||
print(f"⚡ CACHE HIT [{i+1}/{len(tracks)}]: {track_name} by {', '.join(track_artists)}")
|
||||
# Extract display-friendly artist string from cached match
|
||||
cached_artists = cached_match.get('artists', [])
|
||||
if cached_artists:
|
||||
cached_artist_str = ', '.join(
|
||||
a if isinstance(a, str) else a.get('name', '') for a in cached_artists
|
||||
)
|
||||
else:
|
||||
cached_artist_str = ''
|
||||
cached_album = cached_match.get('album', '')
|
||||
if isinstance(cached_album, dict):
|
||||
cached_album = cached_album.get('name', '')
|
||||
|
||||
result = {
|
||||
'deezer_track': {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'artists': track_artists or [],
|
||||
'album': track_album,
|
||||
'duration_ms': track_duration_ms,
|
||||
},
|
||||
'spotify_data': cached_match,
|
||||
'match_data': cached_match,
|
||||
'status': '✅ Found',
|
||||
'status_class': 'found',
|
||||
'spotify_track': cached_match.get('name', ''),
|
||||
'spotify_artist': cached_artist_str,
|
||||
'spotify_album': cached_album,
|
||||
'spotify_id': cached_match.get('id', ''),
|
||||
'discovery_source': discovery_source,
|
||||
'index': i
|
||||
}
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
continue
|
||||
except Exception as cache_err:
|
||||
print(f"⚠️ Cache lookup error: {cache_err}")
|
||||
|
||||
# Create a SimpleNamespace duck-type object for _search_spotify_for_tidal_track
|
||||
track_ns = types.SimpleNamespace(
|
||||
id=track_id,
|
||||
name=track_name,
|
||||
artists=track_artists,
|
||||
album=track_album,
|
||||
duration_ms=track_duration_ms
|
||||
)
|
||||
|
||||
# Use the search function with appropriate provider
|
||||
track_result = _search_spotify_for_tidal_track(
|
||||
track_ns,
|
||||
use_spotify=use_spotify,
|
||||
itunes_client=itunes_client_instance
|
||||
)
|
||||
|
||||
# Create result entry
|
||||
result = {
|
||||
'deezer_track': {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'artists': track_artists or [],
|
||||
'album': track_album,
|
||||
'duration_ms': track_duration_ms,
|
||||
},
|
||||
'spotify_data': None,
|
||||
'match_data': None,
|
||||
'status': '❌ Not Found',
|
||||
'status_class': 'not-found',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'discovery_source': discovery_source
|
||||
}
|
||||
|
||||
match_confidence = 0.0
|
||||
|
||||
if use_spotify and isinstance(track_result, tuple):
|
||||
# Spotify: Function returns (Track, raw_data, confidence)
|
||||
track_obj, raw_track_data, match_confidence = track_result
|
||||
album_obj = raw_track_data.get('album', {}) if raw_track_data else {}
|
||||
|
||||
match_data = {
|
||||
'id': track_obj.id,
|
||||
'name': track_obj.name,
|
||||
'artists': track_obj.artists,
|
||||
'album': album_obj,
|
||||
'duration_ms': track_obj.duration_ms,
|
||||
'external_urls': track_obj.external_urls,
|
||||
'source': 'spotify'
|
||||
}
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = '✅ Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = track_obj.name
|
||||
result['spotify_artist'] = ', '.join(track_obj.artists) if isinstance(track_obj.artists, list) else str(track_obj.artists)
|
||||
result['spotify_album'] = album_obj.get('name', '') if isinstance(album_obj, dict) else str(album_obj)
|
||||
result['spotify_id'] = track_obj.id
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
elif not use_spotify and track_result and isinstance(track_result, dict):
|
||||
# iTunes: Function returns a dict with track data (includes 'confidence' key)
|
||||
match_confidence = track_result.pop('confidence', 0.80)
|
||||
match_data = track_result
|
||||
match_data['source'] = 'itunes'
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = '✅ Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = match_data.get('name', '')
|
||||
itunes_artists = match_data.get('artists', [])
|
||||
result['spotify_artist'] = ', '.join(a if isinstance(a, str) else a.get('name', '') for a in itunes_artists) if itunes_artists else ''
|
||||
result['spotify_album'] = match_data.get('album', {}).get('name', '') if isinstance(match_data.get('album'), dict) else match_data.get('album', '')
|
||||
result['spotify_id'] = match_data.get('id', '')
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
# Save to discovery cache if match found
|
||||
if result['status_class'] == 'found' and result.get('match_data'):
|
||||
try:
|
||||
cache_db = get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source, match_confidence,
|
||||
result['match_data'], track_name,
|
||||
track_artists[0] if track_artists else ''
|
||||
)
|
||||
print(f"💾 CACHE SAVED: {track_name} (confidence: {match_confidence:.3f})")
|
||||
except Exception as cache_err:
|
||||
print(f"⚠️ Cache save error: {cache_err}")
|
||||
|
||||
result['index'] = i
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
|
||||
# Add delay between requests
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing track {i+1}: {e}")
|
||||
# Add error result
|
||||
result = {
|
||||
'deezer_track': {
|
||||
'name': deezer_track.get('name', 'Unknown'),
|
||||
'artists': deezer_track.get('artists', []),
|
||||
},
|
||||
'spotify_data': None,
|
||||
'match_data': None,
|
||||
'status': '❌ Error',
|
||||
'status_class': 'error',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'error': str(e),
|
||||
'discovery_source': discovery_source,
|
||||
'index': i
|
||||
}
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
|
||||
# Mark as complete
|
||||
state['phase'] = 'discovered'
|
||||
state['status'] = 'discovered'
|
||||
state['discovery_progress'] = 100
|
||||
|
||||
# Add activity for discovery completion
|
||||
source_label = discovery_source.upper()
|
||||
add_activity_item("✅", f"Deezer Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
|
||||
|
||||
print(f"✅ Deezer discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in Deezer discovery worker: {e}")
|
||||
if playlist_id in deezer_discovery_states:
|
||||
deezer_discovery_states[playlist_id]['phase'] = 'error'
|
||||
deezer_discovery_states[playlist_id]['status'] = f'error: {str(e)}'
|
||||
finally:
|
||||
_resume_enrichment_workers(_ew_state, 'Deezer discovery')
|
||||
|
||||
|
||||
def convert_deezer_results_to_spotify_tracks(discovery_results):
|
||||
"""Convert Deezer discovery results to Spotify tracks format for sync"""
|
||||
spotify_tracks = []
|
||||
|
||||
for result in discovery_results:
|
||||
# Support both data formats: spotify_data (manual fixes) and individual fields (automatic discovery)
|
||||
if result.get('spotify_data'):
|
||||
spotify_data = result['spotify_data']
|
||||
|
||||
track = {
|
||||
'id': spotify_data['id'],
|
||||
'name': spotify_data['name'],
|
||||
'artists': spotify_data['artists'],
|
||||
'album': spotify_data['album'],
|
||||
'duration_ms': spotify_data.get('duration_ms', 0)
|
||||
}
|
||||
spotify_tracks.append(track)
|
||||
elif result.get('spotify_track') and result.get('status_class') == 'found':
|
||||
track = {
|
||||
'id': result.get('spotify_id', 'unknown'),
|
||||
'name': result.get('spotify_track', 'Unknown Track'),
|
||||
'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'],
|
||||
'album': result.get('spotify_album', 'Unknown Album'),
|
||||
'duration_ms': 0
|
||||
}
|
||||
spotify_tracks.append(track)
|
||||
|
||||
print(f"🔄 Converted {len(spotify_tracks)} Deezer matches to Spotify tracks for sync")
|
||||
return spotify_tracks
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# DEEZER SYNC API ENDPOINTS
|
||||
# ===================================================================
|
||||
|
||||
@app.route('/api/deezer/sync/start/<playlist_id>', methods=['POST'])
|
||||
def start_deezer_sync(playlist_id):
|
||||
"""Start sync process for a Deezer playlist using discovered Spotify tracks"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
state['last_accessed'] = time.time()
|
||||
|
||||
if state['phase'] not in ['discovered', 'sync_complete']:
|
||||
return jsonify({"error": "Deezer playlist not ready for sync"}), 400
|
||||
|
||||
# Convert discovery results to Spotify tracks format
|
||||
spotify_tracks = convert_deezer_results_to_spotify_tracks(state['discovery_results'])
|
||||
|
||||
if not spotify_tracks:
|
||||
return jsonify({"error": "No Spotify matches found for sync"}), 400
|
||||
|
||||
# Create a temporary playlist ID for sync tracking
|
||||
sync_playlist_id = f"deezer_{playlist_id}"
|
||||
playlist_name = state['playlist']['name']
|
||||
|
||||
# Add activity for sync start
|
||||
add_activity_item("🔄", "Deezer Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
|
||||
|
||||
# Update Deezer state
|
||||
state['phase'] = 'syncing'
|
||||
state['sync_playlist_id'] = sync_playlist_id
|
||||
state['sync_progress'] = {}
|
||||
|
||||
# Start the sync using existing sync infrastructure
|
||||
sync_data = {
|
||||
'playlist_id': sync_playlist_id,
|
||||
'playlist_name': playlist_name,
|
||||
'tracks': spotify_tracks
|
||||
}
|
||||
|
||||
with sync_lock:
|
||||
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
|
||||
|
||||
# Submit sync task
|
||||
future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks)
|
||||
active_sync_workers[sync_playlist_id] = future
|
||||
|
||||
print(f"🔄 Started Deezer sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
|
||||
return jsonify({"success": True, "sync_playlist_id": sync_playlist_id})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error starting Deezer sync: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/sync/status/<playlist_id>', methods=['GET'])
|
||||
def get_deezer_sync_status(playlist_id):
|
||||
"""Get sync status for a Deezer playlist"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
state['last_accessed'] = time.time()
|
||||
sync_playlist_id = state.get('sync_playlist_id')
|
||||
|
||||
if not sync_playlist_id:
|
||||
return jsonify({"error": "No sync in progress"}), 404
|
||||
|
||||
# Get sync status from existing sync infrastructure
|
||||
with sync_lock:
|
||||
sync_state = sync_states.get(sync_playlist_id, {})
|
||||
|
||||
response = {
|
||||
'phase': state['phase'],
|
||||
'sync_status': sync_state.get('status', 'unknown'),
|
||||
'progress': sync_state.get('progress', {}),
|
||||
'complete': sync_state.get('status') == 'finished',
|
||||
'error': sync_state.get('error')
|
||||
}
|
||||
|
||||
# Update Deezer state if sync completed
|
||||
if sync_state.get('status') == 'finished':
|
||||
state['phase'] = 'sync_complete'
|
||||
state['sync_progress'] = sync_state.get('progress', {})
|
||||
playlist_name = state['playlist']['name']
|
||||
add_activity_item("🔄", "Sync Complete", f"Deezer playlist '{playlist_name}' synced successfully", "Now")
|
||||
elif sync_state.get('status') == 'error':
|
||||
state['phase'] = 'discovered' # Revert on error
|
||||
playlist_name = state['playlist']['name']
|
||||
add_activity_item("❌", "Sync Failed", f"Deezer playlist '{playlist_name}' sync failed", "Now")
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting Deezer sync status: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/deezer/sync/cancel/<playlist_id>', methods=['POST'])
|
||||
def cancel_deezer_sync(playlist_id):
|
||||
"""Cancel sync for a Deezer playlist"""
|
||||
try:
|
||||
if playlist_id not in deezer_discovery_states:
|
||||
return jsonify({"error": "Deezer playlist not found"}), 404
|
||||
|
||||
state = deezer_discovery_states[playlist_id]
|
||||
state['last_accessed'] = time.time()
|
||||
sync_playlist_id = state.get('sync_playlist_id')
|
||||
|
||||
if sync_playlist_id:
|
||||
# Cancel the sync using existing sync infrastructure
|
||||
with sync_lock:
|
||||
sync_states[sync_playlist_id] = {"status": "cancelled"}
|
||||
|
||||
# Clean up sync worker
|
||||
if sync_playlist_id in active_sync_workers:
|
||||
del active_sync_workers[sync_playlist_id]
|
||||
|
||||
# Revert Deezer state
|
||||
state['phase'] = 'discovered'
|
||||
state['sync_playlist_id'] = None
|
||||
state['sync_progress'] = {}
|
||||
|
||||
return jsonify({"success": True, "message": "Deezer sync cancelled"})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error cancelling Deezer sync: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# YOUTUBE PLAYLIST API ENDPOINTS
|
||||
# ===================================================================
|
||||
|
|
@ -24167,6 +24994,42 @@ def update_youtube_discovery_match():
|
|||
print(f"✅ Manual match updated: youtube - {identifier} - track {track_index}")
|
||||
print(f" → {result['spotify_artist']} - {result['spotify_track']}")
|
||||
|
||||
# Save manual fix to discovery cache so it appears in discovery pool
|
||||
try:
|
||||
# Get original track name from the YouTube/source track data
|
||||
original_track = result.get('youtube_track', result.get('tidal_track', result.get('deezer_track', {})))
|
||||
original_name = original_track.get('name', spotify_track['name'])
|
||||
original_artists = original_track.get('artists', [])
|
||||
if original_artists:
|
||||
original_artist = original_artists[0] if isinstance(original_artists[0], str) else original_artists[0].get('name', '')
|
||||
else:
|
||||
original_artist = ''
|
||||
|
||||
cache_key = _get_discovery_cache_key(original_name, original_artist)
|
||||
# Normalize artists to plain strings for cache consistency
|
||||
artists_list = spotify_track['artists']
|
||||
if isinstance(artists_list, list):
|
||||
artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list]
|
||||
album_raw = spotify_track.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
|
||||
matched_data = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': artists_list,
|
||||
'album': album_obj,
|
||||
'duration_ms': spotify_track.get('duration_ms', 0),
|
||||
'source': 'spotify',
|
||||
}
|
||||
cache_db = get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], 'spotify', 1.0, matched_data,
|
||||
original_name, original_artist
|
||||
)
|
||||
print(f"💾 Manual fix saved to discovery cache: {original_name} by {original_artist}")
|
||||
except Exception as cache_err:
|
||||
print(f"⚠️ Error saving manual fix to discovery cache: {cache_err}")
|
||||
|
||||
# Persist manual fix to DB for mirrored playlists
|
||||
if identifier.startswith('mirrored_'):
|
||||
try:
|
||||
|
|
@ -24175,19 +25038,6 @@ def update_youtube_discovery_match():
|
|||
db_track_id = tracks[track_index].get('db_track_id')
|
||||
if db_track_id:
|
||||
db = get_database()
|
||||
artists_list = spotify_track['artists']
|
||||
if isinstance(artists_list, list):
|
||||
artists_list = [{'name': a} if isinstance(a, str) else a for a in artists_list]
|
||||
album_raw = spotify_track.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
matched_data = {
|
||||
'id': spotify_track['id'],
|
||||
'name': spotify_track['name'],
|
||||
'artists': artists_list,
|
||||
'album': album_obj,
|
||||
'duration_ms': spotify_track.get('duration_ms', 0),
|
||||
'source': 'spotify',
|
||||
}
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': 'spotify',
|
||||
|
|
@ -35811,6 +36661,7 @@ def _emit_discovery_progress_loop():
|
|||
"""Push discovery progress to subscribed rooms every 1 second."""
|
||||
platform_states = {
|
||||
'tidal': lambda: tidal_discovery_states,
|
||||
'deezer': lambda: deezer_discovery_states,
|
||||
'youtube': lambda: youtube_playlist_states,
|
||||
'beatport': lambda: beatport_chart_states,
|
||||
'listenbrainz': lambda: listenbrainz_playlist_states,
|
||||
|
|
|
|||
|
|
@ -936,6 +936,9 @@
|
|||
<button class="sync-tab-button" data-tab="tidal">
|
||||
<span class="tab-icon tidal-icon"></span> Tidal
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="deezer">
|
||||
<span class="tab-icon deezer-icon"></span> Deezer
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="youtube">
|
||||
<span class="tab-icon youtube-icon"></span> YouTube
|
||||
</button>
|
||||
|
|
@ -972,6 +975,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deezer Tab Content -->
|
||||
<div class="sync-tab-content" id="deezer-tab-content">
|
||||
<div class="youtube-input-section">
|
||||
<input type="text" id="deezer-url-input"
|
||||
placeholder="Paste Deezer Playlist URL...">
|
||||
<button id="deezer-parse-btn">Load Playlist</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="deezer-playlist-container">
|
||||
<div class="playlist-placeholder">Paste a Deezer playlist URL above to get started.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YouTube Tab Content -->
|
||||
<div class="sync-tab-content" id="youtube-tab-content">
|
||||
<div class="youtube-input-section">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6438,6 +6438,12 @@ body {
|
|||
box-shadow: 0 4px 15px rgba(255, 102, 0, 0.3);
|
||||
}
|
||||
|
||||
.sync-tab-button[data-tab="deezer"].active {
|
||||
background: #a238ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 12px rgba(162, 56, 255, 0.4);
|
||||
}
|
||||
|
||||
.sync-tab-button[data-tab="youtube"].active {
|
||||
background: #ff0000;
|
||||
color: #fff;
|
||||
|
|
@ -6465,6 +6471,16 @@ body {
|
|||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M12.012 3.992L8.008 7.996 12.012 12l4.004-4.004-4.004-4.004zm7.996 8.008L16.004 8.004 12 12.008l4.004 4.004L20.008 12zm-16.008 0L8.004 16.004 12 12l-4.004-4.004L3.996 12zm8.004 4.004L8.004 20.008 12.012 24l4.004-4.004-4.004-3.996z"/></svg>');
|
||||
}
|
||||
|
||||
.deezer-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Crect x='0' y='18' width='4' height='4' rx='0.5'/%3E%3Crect x='6' y='14' width='4' height='8' rx='0.5'/%3E%3Crect x='12' y='10' width='4' height='12' rx='0.5'/%3E%3Crect x='18' y='6' width='4' height='16' rx='0.5'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.youtube-icon {
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ff0000"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>');
|
||||
}
|
||||
|
|
@ -6967,6 +6983,11 @@ body {
|
|||
border-color: rgba(255, 0, 0, 0.25);
|
||||
color: #ff4444;
|
||||
}
|
||||
.mirrored-playlist-card .source-icon.deezer {
|
||||
background: linear-gradient(135deg, rgba(162, 56, 255, 0.2) 0%, rgba(162, 56, 255, 0.08) 100%);
|
||||
border-color: rgba(162, 56, 255, 0.25);
|
||||
color: #a238ff;
|
||||
}
|
||||
.mirrored-playlist-card .source-icon.beatport {
|
||||
background: linear-gradient(135deg, rgba(1, 255, 149, 0.2) 0%, rgba(1, 255, 149, 0.08) 100%);
|
||||
border-color: rgba(1, 255, 149, 0.25);
|
||||
|
|
@ -6992,6 +7013,7 @@ body {
|
|||
.mirrored-playlist-card .source-badge.spotify { background: #1db954; }
|
||||
.mirrored-playlist-card .source-badge.tidal { background: #ff6600; }
|
||||
.mirrored-playlist-card .source-badge.youtube { background: #ff0000; }
|
||||
.mirrored-playlist-card .source-badge.deezer { background: #a238ff; }
|
||||
.mirrored-playlist-card .source-badge.beatport { background: #01ff95; color: #000; }
|
||||
.mirrored-playlist-card .source-badge.file { background: #60a5fa; }
|
||||
|
||||
|
|
@ -9838,28 +9860,108 @@ body {
|
|||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Playlist URL input section (YouTube, Deezer) */
|
||||
.youtube-input-section {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
#youtube-url-input {
|
||||
flex-grow: 1;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: #ffffff;
|
||||
.youtube-input-section:focus-within {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#youtube-url-input,
|
||||
#deezer-url-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13.5px;
|
||||
font-family: 'SF Pro Text', -apple-system, sans-serif;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#youtube-url-input::placeholder,
|
||||
#deezer-url-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#youtube-parse-btn,
|
||||
#deezer-parse-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 22px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Pro Text', -apple-system, sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#youtube-parse-btn {
|
||||
width: 150px;
|
||||
background: #ff0000;
|
||||
background: linear-gradient(135deg, #ff2020, #e00000);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(255, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#youtube-parse-btn:hover {
|
||||
background: linear-gradient(135deg, #ff3333, #ff1111);
|
||||
box-shadow: 0 4px 16px rgba(255, 0, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#youtube-parse-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(255, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#deezer-parse-btn {
|
||||
background: linear-gradient(135deg, #a238ff, #b44dff);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(162, 56, 255, 0.2);
|
||||
}
|
||||
|
||||
#deezer-parse-btn:hover {
|
||||
background: linear-gradient(135deg, #b044ff, #c058ff);
|
||||
box-shadow: 0 4px 16px rgba(162, 56, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#deezer-parse-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(162, 56, 255, 0.2);
|
||||
}
|
||||
|
||||
#youtube-parse-btn:disabled,
|
||||
#deezer-parse-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Brand-colored focus glow per tab */
|
||||
#youtube-tab-content .youtube-input-section:focus-within {
|
||||
border-color: rgba(255, 0, 0, 0.25);
|
||||
box-shadow: 0 0 16px rgba(255, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
#deezer-tab-content .youtube-input-section:focus-within {
|
||||
border-color: rgba(162, 56, 255, 0.25);
|
||||
box-shadow: 0 0 16px rgba(162, 56, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Right Sidebar */
|
||||
|
|
@ -10296,6 +10398,25 @@ body {
|
|||
box-shadow: 0 4px 15px rgba(255, 102, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
DEEZER PLAYLIST CARD STYLES (extends YouTube card styles)
|
||||
===============================*/
|
||||
|
||||
.deezer-playlist-card .playlist-card-icon {
|
||||
background: rgba(162, 56, 255, 0.2);
|
||||
border-color: #a238ff;
|
||||
color: #a238ff;
|
||||
}
|
||||
|
||||
.deezer-playlist-card .playlist-card-action-btn {
|
||||
background: linear-gradient(135deg, #a238ff, #b44dff);
|
||||
}
|
||||
|
||||
.deezer-playlist-card .playlist-card-action-btn:hover {
|
||||
background: linear-gradient(135deg, #b44dff, #c562ff);
|
||||
box-shadow: 0 0 15px rgba(162, 56, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
YOUTUBE DISCOVERY MODAL STYLES
|
||||
=============================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue