Add Deezer playlist sync tab with discovery, fix modal, and cache persistence

pull/253/head
Broque Thomas 2 months ago
parent f9d80606e3
commit 55186c6a51

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

@ -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-parse-btn {
width: 150px;
background: #ff0000;
color: #fff;
#youtube-url-input,
#deezer-url-input {
flex: 1;
background: transparent;
border: none;
border-radius: 6px;
font-weight: bold;
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 {
background: linear-gradient(135deg, #ff2020, #e00000);
color: #fff;
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…
Cancel
Save