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

This commit is contained in:
Broque Thomas 2026-03-13 12:29:49 -07:00
parent f9d80606e3
commit 55186c6a51
5 changed files with 2212 additions and 37 deletions

View file

@ -1,7 +1,8 @@
import re
import requests import requests
import time import time
import threading import threading
from typing import Dict, Optional, Any from typing import Dict, List, Optional, Any
from functools import wraps from functools import wraps
from utils.logging_config import get_logger from utils.logging_config import get_logger
@ -223,3 +224,124 @@ class DeezerClient:
except Exception as e: except Exception as e:
logger.error(f"Error getting track {track_id}: {e}") logger.error(f"Error getting track {track_id}: {e}")
return None 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

View file

@ -13,6 +13,7 @@ import glob
import uuid import uuid
import re import re
import sqlite3 import sqlite3
import types
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin 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' tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
print(f"📋 Updated Tidal playlist {tidal_playlist_id} to download_complete phase") 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") print(f"🎉 [Batch Manager] Batch {batch_id} complete - stopping monitor")
download_monitor.stop_monitoring(batch_id) 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"✅ Manual match updated: tidal - {identifier} - track {track_index}")
print(f"{result['spotify_artist']} - {result['spotify_track']}") 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}) return jsonify({'success': True, 'result': result})
except Exception as e: except Exception as e:
@ -23971,6 +24013,791 @@ def cancel_tidal_sync(playlist_id):
return jsonify({"error": str(e)}), 500 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 # 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"✅ Manual match updated: youtube - {identifier} - track {track_index}")
print(f"{result['spotify_artist']} - {result['spotify_track']}") 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 # Persist manual fix to DB for mirrored playlists
if identifier.startswith('mirrored_'): if identifier.startswith('mirrored_'):
try: try:
@ -24175,19 +25038,6 @@ def update_youtube_discovery_match():
db_track_id = tracks[track_index].get('db_track_id') db_track_id = tracks[track_index].get('db_track_id')
if db_track_id: if db_track_id:
db = get_database() 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 = { extra_data = {
'discovered': True, 'discovered': True,
'provider': 'spotify', 'provider': 'spotify',
@ -35811,6 +36661,7 @@ def _emit_discovery_progress_loop():
"""Push discovery progress to subscribed rooms every 1 second.""" """Push discovery progress to subscribed rooms every 1 second."""
platform_states = { platform_states = {
'tidal': lambda: tidal_discovery_states, 'tidal': lambda: tidal_discovery_states,
'deezer': lambda: deezer_discovery_states,
'youtube': lambda: youtube_playlist_states, 'youtube': lambda: youtube_playlist_states,
'beatport': lambda: beatport_chart_states, 'beatport': lambda: beatport_chart_states,
'listenbrainz': lambda: listenbrainz_playlist_states, 'listenbrainz': lambda: listenbrainz_playlist_states,

View file

@ -936,6 +936,9 @@
<button class="sync-tab-button" data-tab="tidal"> <button class="sync-tab-button" data-tab="tidal">
<span class="tab-icon tidal-icon"></span> Tidal <span class="tab-icon tidal-icon"></span> Tidal
</button> </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"> <button class="sync-tab-button" data-tab="youtube">
<span class="tab-icon youtube-icon"></span> YouTube <span class="tab-icon youtube-icon"></span> YouTube
</button> </button>
@ -972,6 +975,18 @@
</div> </div>
</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 --> <!-- YouTube Tab Content -->
<div class="sync-tab-content" id="youtube-tab-content"> <div class="sync-tab-content" id="youtube-tab-content">
<div class="youtube-input-section"> <div class="youtube-input-section">

File diff suppressed because it is too large Load diff

View file

@ -6438,6 +6438,12 @@ body {
box-shadow: 0 4px 15px rgba(255, 102, 0, 0.3); 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 { .sync-tab-button[data-tab="youtube"].active {
background: #ff0000; background: #ff0000;
color: #fff; 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>'); 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 { .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>'); 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); border-color: rgba(255, 0, 0, 0.25);
color: #ff4444; 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 { .mirrored-playlist-card .source-icon.beatport {
background: linear-gradient(135deg, rgba(1, 255, 149, 0.2) 0%, rgba(1, 255, 149, 0.08) 100%); 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); 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.spotify { background: #1db954; }
.mirrored-playlist-card .source-badge.tidal { background: #ff6600; } .mirrored-playlist-card .source-badge.tidal { background: #ff6600; }
.mirrored-playlist-card .source-badge.youtube { background: #ff0000; } .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.beatport { background: #01ff95; color: #000; }
.mirrored-playlist-card .source-badge.file { background: #60a5fa; } .mirrored-playlist-card .source-badge.file { background: #60a5fa; }
@ -9838,28 +9860,108 @@ body {
padding: 40px; padding: 40px;
} }
/* Playlist URL input section (YouTube, Deezer) */
.youtube-input-section { .youtube-input-section {
display: flex; 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 { .youtube-input-section:focus-within {
flex-grow: 1; border-color: rgba(255, 255, 255, 0.15);
background: #3a3a3a; box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
border: 1px solid #555555; }
border-radius: 6px;
padding: 10px; #youtube-url-input,
color: #ffffff; #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 { #youtube-parse-btn {
width: 150px; background: linear-gradient(135deg, #ff2020, #e00000);
background: #ff0000;
color: #fff; color: #fff;
border: none; box-shadow: 0 2px 8px rgba(255, 0, 0, 0.2);
border-radius: 6px; }
font-weight: bold;
cursor: pointer; #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 */ /* Right Sidebar */
@ -10296,6 +10398,25 @@ body {
box-shadow: 0 4px 15px rgba(255, 102, 0, 0.3); 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 YOUTUBE DISCOVERY MODAL STYLES
=============================== */ =============================== */