Add Spotify Link tab for public playlist/album scraping without API credentials
Scrapes Spotify's embed endpoint to extract track data from any public playlist or album URL. Full discovery flow with Deezer parity: parse → card → discovery modal → live progress → sync → download missing. - New scraper: core/spotify_public_scraper.py (embed endpoint parsing) - 12 API endpoints mirroring Deezer's discovery/sync/download flow - WebSocket live discovery updates via spotify_public_discovery_states - Green-branded tab, cards, and input styling (#1DB954) - Album vs playlist detection with distinct card icons (💿/🎵) - Download persistence: card click reopens download modal after close - Card phase reset on cancel/completion (closeDownloadMissingModal) - Backend download linking for spotify_public_ and deezer_ prefixes - Completion handlers (V2 + no-missing-tracks) for both platforms - Source URLs stored on mirrored playlists for future auto-refresh
This commit is contained in:
parent
da2b42b59a
commit
483e45cbc0
5 changed files with 2339 additions and 31 deletions
153
core/spotify_public_scraper.py
Normal file
153
core/spotify_public_scraper.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""
|
||||||
|
Spotify Public Scraper - Fetches playlist/album data from Spotify's embed endpoint
|
||||||
|
without requiring API authentication. Uses the __NEXT_DATA__ JSON embedded in the page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_spotify_url(url: str) -> dict:
|
||||||
|
"""
|
||||||
|
Parse a Spotify URL and extract the type (playlist/album) and ID.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||||
|
- https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy
|
||||||
|
- spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||||
|
- URLs with query params (?si=...) or trailing paths
|
||||||
|
|
||||||
|
Returns: {type: 'playlist'|'album', id: str} or None
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = url.strip()
|
||||||
|
|
||||||
|
# Handle spotify: URIs
|
||||||
|
uri_match = re.match(r'spotify:(playlist|album):([a-zA-Z0-9]+)', url)
|
||||||
|
if uri_match:
|
||||||
|
return {'type': uri_match.group(1), 'id': uri_match.group(2)}
|
||||||
|
|
||||||
|
# Handle web URLs
|
||||||
|
url_match = re.match(
|
||||||
|
r'https?://open\.spotify\.com/(playlist|album)/([a-zA-Z0-9]+)',
|
||||||
|
url
|
||||||
|
)
|
||||||
|
if url_match:
|
||||||
|
return {'type': url_match.group(1), 'id': url_match.group(2)}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_spotify_embed(spotify_type: str, spotify_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Scrape track data from Spotify's embed endpoint.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'id': str,
|
||||||
|
'type': 'playlist' | 'album',
|
||||||
|
'name': str,
|
||||||
|
'subtitle': str (owner for playlists, artist for albums),
|
||||||
|
'tracks': [
|
||||||
|
{
|
||||||
|
'id': str (Spotify track ID),
|
||||||
|
'name': str,
|
||||||
|
'artists': [{'name': str}],
|
||||||
|
'duration_ms': int,
|
||||||
|
'is_explicit': bool,
|
||||||
|
'track_number': int
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'url_hash': str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
embed_url = f'https://open.spotify.com/embed/{spotify_type}/{spotify_id}'
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(embed_url, headers=headers, timeout=20)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Failed to fetch Spotify embed: {e}")
|
||||||
|
return {'error': f'Failed to fetch Spotify page: {str(e)}'}
|
||||||
|
|
||||||
|
# Extract __NEXT_DATA__ JSON
|
||||||
|
match = re.search(
|
||||||
|
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
|
response.text
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
logger.error("No __NEXT_DATA__ found in Spotify embed response")
|
||||||
|
return {'error': 'Could not parse Spotify page. The page format may have changed.'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
next_data = json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse __NEXT_DATA__ JSON: {e}")
|
||||||
|
return {'error': 'Failed to parse Spotify data'}
|
||||||
|
|
||||||
|
# Navigate to entity data
|
||||||
|
try:
|
||||||
|
entity = next_data['props']['pageProps']['state']['data']['entity']
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
logger.error(f"Unexpected embed data structure: {e}")
|
||||||
|
return {'error': 'Unexpected Spotify data format'}
|
||||||
|
|
||||||
|
track_list = entity.get('trackList', [])
|
||||||
|
if not track_list:
|
||||||
|
return {'error': 'No tracks found in this Spotify link'}
|
||||||
|
|
||||||
|
# Parse tracks into standardized format
|
||||||
|
tracks = []
|
||||||
|
for i, raw_track in enumerate(track_list):
|
||||||
|
# Extract track ID from URI (spotify:track:XXXX)
|
||||||
|
uri = raw_track.get('uri', '')
|
||||||
|
track_id_match = re.match(r'spotify:track:([a-zA-Z0-9]+)', uri)
|
||||||
|
if not track_id_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
track_id = track_id_match.group(1)
|
||||||
|
|
||||||
|
# Parse artists from subtitle (separated by non-breaking spaces or commas)
|
||||||
|
subtitle = raw_track.get('subtitle', '')
|
||||||
|
# Replace non-breaking spaces used as separators
|
||||||
|
artist_names = [a.strip() for a in subtitle.replace('\xa0', '').split(',') if a.strip()]
|
||||||
|
if not artist_names:
|
||||||
|
artist_names = ['Unknown Artist']
|
||||||
|
|
||||||
|
tracks.append({
|
||||||
|
'id': track_id,
|
||||||
|
'name': raw_track.get('title', 'Unknown Track'),
|
||||||
|
'artists': [{'name': name} for name in artist_names],
|
||||||
|
'duration_ms': raw_track.get('duration', 0),
|
||||||
|
'is_explicit': raw_track.get('isExplicit', False),
|
||||||
|
'track_number': i + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
# Generate URL hash for state management
|
||||||
|
source_url = f'https://open.spotify.com/{spotify_type}/{spotify_id}'
|
||||||
|
url_hash = hashlib.md5(source_url.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'id': spotify_id,
|
||||||
|
'type': entity.get('type', spotify_type),
|
||||||
|
'name': entity.get('name', 'Unknown'),
|
||||||
|
'subtitle': entity.get('subtitle', ''),
|
||||||
|
'tracks': tracks,
|
||||||
|
'url': source_url,
|
||||||
|
'url_hash': url_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Scraped Spotify {spotify_type}: {result['name']} ({len(tracks)} tracks)")
|
||||||
|
return result
|
||||||
882
web_server.py
882
web_server.py
|
|
@ -20743,6 +20743,13 @@ def _on_download_completed(batch_id, task_id, success=True):
|
||||||
deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete'
|
deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete'
|
||||||
print(f"📋 Updated Deezer playlist {deezer_playlist_id} to download_complete phase")
|
print(f"📋 Updated Deezer playlist {deezer_playlist_id} to download_complete phase")
|
||||||
|
|
||||||
|
# Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist
|
||||||
|
if playlist_id and playlist_id.startswith('spotify_public_'):
|
||||||
|
spotify_public_url_hash = playlist_id.replace('spotify_public_', '')
|
||||||
|
if spotify_public_url_hash in spotify_public_discovery_states:
|
||||||
|
spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete'
|
||||||
|
print(f"📋 Updated Spotify Public playlist {spotify_public_url_hash} 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)
|
||||||
|
|
||||||
|
|
@ -20942,6 +20949,20 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
||||||
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 (no missing tracks)")
|
print(f"📋 Updated Tidal playlist {tidal_playlist_id} to download_complete phase (no missing tracks)")
|
||||||
|
|
||||||
|
# Update Deezer playlist phase to 'download_complete' if this is a Deezer playlist
|
||||||
|
if 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 (no missing tracks)")
|
||||||
|
|
||||||
|
# Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist
|
||||||
|
if playlist_id.startswith('spotify_public_'):
|
||||||
|
spotify_public_url_hash = playlist_id.replace('spotify_public_', '')
|
||||||
|
if spotify_public_url_hash in spotify_public_discovery_states:
|
||||||
|
spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete'
|
||||||
|
print(f"📋 Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase (no missing tracks)")
|
||||||
|
|
||||||
# Handle auto-initiated wishlist completion even when no missing tracks
|
# Handle auto-initiated wishlist completion even when no missing tracks
|
||||||
if is_auto_batch and playlist_id == 'wishlist':
|
if is_auto_batch and playlist_id == 'wishlist':
|
||||||
print("🤖 [Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule")
|
print("🤖 [Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule")
|
||||||
|
|
@ -23257,6 +23278,20 @@ def _check_batch_completion_v2(batch_id):
|
||||||
tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
|
tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
|
||||||
print(f"📋 [Completion Check V2] Updated Tidal playlist {tidal_playlist_id} to download_complete phase")
|
print(f"📋 [Completion Check V2] 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"📋 [Completion Check V2] Updated Deezer playlist {deezer_playlist_id} to download_complete phase")
|
||||||
|
|
||||||
|
# Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist
|
||||||
|
if playlist_id and playlist_id.startswith('spotify_public_'):
|
||||||
|
spotify_public_url_hash = playlist_id.replace('spotify_public_', '')
|
||||||
|
if spotify_public_url_hash in spotify_public_discovery_states:
|
||||||
|
spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete'
|
||||||
|
print(f"📋 [Completion Check V2] Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase")
|
||||||
|
|
||||||
print(f"🎉 [Completion Check V2] Batch {batch_id} complete - stopping monitor")
|
print(f"🎉 [Completion Check V2] Batch {batch_id} complete - stopping monitor")
|
||||||
download_monitor.stop_monitoring(batch_id)
|
download_monitor.stop_monitoring(batch_id)
|
||||||
|
|
||||||
|
|
@ -23561,6 +23596,24 @@ def start_missing_tracks_process(playlist_id):
|
||||||
tidal_discovery_states[tidal_playlist_id]['converted_spotify_playlist_id'] = playlist_id
|
tidal_discovery_states[tidal_playlist_id]['converted_spotify_playlist_id'] = playlist_id
|
||||||
print(f"🔗 Linked Tidal playlist {tidal_playlist_id} to download process {batch_id} (converted ID: {playlist_id})")
|
print(f"🔗 Linked Tidal playlist {tidal_playlist_id} to download process {batch_id} (converted ID: {playlist_id})")
|
||||||
|
|
||||||
|
# Link Spotify Public playlist to download process if this is a Spotify Public playlist
|
||||||
|
if playlist_id.startswith('spotify_public_'):
|
||||||
|
sp_url_hash = playlist_id.replace('spotify_public_', '')
|
||||||
|
if sp_url_hash in spotify_public_discovery_states:
|
||||||
|
spotify_public_discovery_states[sp_url_hash]['download_process_id'] = batch_id
|
||||||
|
spotify_public_discovery_states[sp_url_hash]['phase'] = 'downloading'
|
||||||
|
spotify_public_discovery_states[sp_url_hash]['converted_spotify_playlist_id'] = playlist_id
|
||||||
|
print(f"🔗 Linked Spotify Public playlist {sp_url_hash} to download process {batch_id} (converted ID: {playlist_id})")
|
||||||
|
|
||||||
|
# Link Deezer playlist to download process if this is a Deezer playlist
|
||||||
|
if 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]['download_process_id'] = batch_id
|
||||||
|
deezer_discovery_states[deezer_playlist_id]['phase'] = 'downloading'
|
||||||
|
deezer_discovery_states[deezer_playlist_id]['converted_spotify_playlist_id'] = playlist_id
|
||||||
|
print(f"🔗 Linked Deezer playlist {deezer_playlist_id} to download process {batch_id} (converted ID: {playlist_id})")
|
||||||
|
|
||||||
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
|
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
@ -26116,6 +26169,14 @@ def update_deezer_playlist_phase(playlist_id):
|
||||||
state['phase'] = new_phase
|
state['phase'] = new_phase
|
||||||
state['last_accessed'] = time.time()
|
state['last_accessed'] = time.time()
|
||||||
|
|
||||||
|
# Update download process ID if provided (for download persistence)
|
||||||
|
if 'download_process_id' in data:
|
||||||
|
state['download_process_id'] = data['download_process_id']
|
||||||
|
|
||||||
|
# Update converted Spotify playlist ID if provided (for download persistence)
|
||||||
|
if 'converted_spotify_playlist_id' in data:
|
||||||
|
state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id']
|
||||||
|
|
||||||
print(f"🔄 Updated Deezer playlist {playlist_id} phase: {old_phase} → {new_phase}")
|
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})
|
return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase})
|
||||||
|
|
||||||
|
|
@ -26523,6 +26584,826 @@ def cancel_deezer_sync(playlist_id):
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# SPOTIFY PUBLIC PLAYLIST DISCOVERY API ENDPOINTS
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# Global state for Spotify Public playlist management
|
||||||
|
spotify_public_discovery_states = {} # Key: url_hash, Value: discovery state
|
||||||
|
spotify_public_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="spotify_public_discovery")
|
||||||
|
|
||||||
|
@app.route('/api/spotify/parse-public', methods=['POST'])
|
||||||
|
def parse_spotify_public_endpoint():
|
||||||
|
"""Parse a public Spotify playlist or album URL without API auth"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
url = data.get('url', '').strip()
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return jsonify({"error": "Spotify URL is required"}), 400
|
||||||
|
|
||||||
|
from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed
|
||||||
|
|
||||||
|
parsed = parse_spotify_url(url)
|
||||||
|
if not parsed:
|
||||||
|
return jsonify({"error": "Invalid Spotify URL. Please use a playlist or album link from open.spotify.com"}), 400
|
||||||
|
|
||||||
|
print(f"🎵 Scraping public Spotify {parsed['type']}: {parsed['id']}")
|
||||||
|
|
||||||
|
result = scrape_spotify_embed(parsed['type'], parsed['id'])
|
||||||
|
|
||||||
|
if 'error' in result:
|
||||||
|
return jsonify(result), 400
|
||||||
|
|
||||||
|
# Convert scraped tracks to Spotify-compatible format
|
||||||
|
spotify_tracks = []
|
||||||
|
for track in result['tracks']:
|
||||||
|
spotify_tracks.append({
|
||||||
|
'id': track['id'],
|
||||||
|
'name': track['name'],
|
||||||
|
'artists': track['artists'],
|
||||||
|
'album': {
|
||||||
|
'name': result['name'] if result['type'] == 'album' else '',
|
||||||
|
'images': []
|
||||||
|
},
|
||||||
|
'duration_ms': track['duration_ms'],
|
||||||
|
'explicit': track.get('is_explicit', False),
|
||||||
|
'track_number': track.get('track_number', 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
url_hash = result['url_hash']
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'id': result['id'],
|
||||||
|
'type': result['type'],
|
||||||
|
'name': result['name'],
|
||||||
|
'subtitle': result['subtitle'],
|
||||||
|
'url': result['url'],
|
||||||
|
'url_hash': url_hash,
|
||||||
|
'track_count': len(spotify_tracks),
|
||||||
|
'tracks': spotify_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store playlist data in state for discovery (if not already there)
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
spotify_public_discovery_states[url_hash] = {
|
||||||
|
'playlist': response_data,
|
||||||
|
'phase': 'fresh',
|
||||||
|
'status': 'fresh',
|
||||||
|
'discovery_progress': 0,
|
||||||
|
'spotify_matches': 0,
|
||||||
|
'spotify_total': len(spotify_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': {}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Update playlist data in existing state
|
||||||
|
spotify_public_discovery_states[url_hash]['playlist'] = response_data
|
||||||
|
spotify_public_discovery_states[url_hash]['last_accessed'] = time.time()
|
||||||
|
|
||||||
|
print(f"✅ Spotify {parsed['type']} scraped: {result['name']} ({len(spotify_tracks)} tracks)")
|
||||||
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error parsing Spotify URL: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/discovery/start/<url_hash>', methods=['POST'])
|
||||||
|
def start_spotify_public_discovery(url_hash):
|
||||||
|
"""Start Spotify discovery process for a Spotify Public playlist"""
|
||||||
|
try:
|
||||||
|
# Initialize discovery state if it doesn't exist, or update existing state
|
||||||
|
if url_hash in spotify_public_discovery_states:
|
||||||
|
existing_state = spotify_public_discovery_states[url_hash]
|
||||||
|
if existing_state['phase'] == 'discovering':
|
||||||
|
return jsonify({"error": "Discovery already in progress"}), 400
|
||||||
|
|
||||||
|
if not existing_state.get('playlist'):
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found. Please parse the URL first."}), 404
|
||||||
|
|
||||||
|
# Update existing state for discovery
|
||||||
|
existing_state['phase'] = 'discovering'
|
||||||
|
existing_state['status'] = 'discovering'
|
||||||
|
existing_state['last_accessed'] = time.time()
|
||||||
|
state = existing_state
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found. Please parse the URL first."}), 404
|
||||||
|
|
||||||
|
# Add activity for discovery start
|
||||||
|
playlist_name = state['playlist']['name']
|
||||||
|
track_count = len(state['playlist']['tracks'])
|
||||||
|
add_activity_item("🔍", "Spotify Link Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now")
|
||||||
|
|
||||||
|
# Start discovery worker
|
||||||
|
future = spotify_public_discovery_executor.submit(_run_spotify_public_discovery_worker, url_hash)
|
||||||
|
state['discovery_future'] = future
|
||||||
|
|
||||||
|
print(f"🔍 Started Spotify discovery for Spotify Public playlist: {playlist_name}")
|
||||||
|
return jsonify({"success": True, "message": "Discovery started"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error starting Spotify Public discovery: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/discovery/status/<url_hash>', methods=['GET'])
|
||||||
|
def get_spotify_public_discovery_status(url_hash):
|
||||||
|
"""Get real-time discovery status for a Spotify Public playlist"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public discovery not found"}), 404
|
||||||
|
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
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 Spotify Public discovery status: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/discovery/update_match', methods=['POST'])
|
||||||
|
def update_spotify_public_discovery_match():
|
||||||
|
"""Update a Spotify Public discovery result with manually selected Spotify track"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
identifier = data.get('identifier') # url_hash
|
||||||
|
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 = spotify_public_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: spotify_public - {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('spotify_public_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 Spotify Public discovery match: {e}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/playlists/states', methods=['GET'])
|
||||||
|
def get_spotify_public_playlist_states():
|
||||||
|
"""Get all stored Spotify Public playlist discovery states for frontend hydration"""
|
||||||
|
try:
|
||||||
|
states = []
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
for url_hash, state in spotify_public_discovery_states.items():
|
||||||
|
state['last_accessed'] = current_time
|
||||||
|
|
||||||
|
state_info = {
|
||||||
|
'playlist_id': url_hash,
|
||||||
|
'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 Spotify Public playlist states for hydration")
|
||||||
|
return jsonify({"states": states})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error getting Spotify Public playlist states: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/state/<url_hash>', methods=['GET'])
|
||||||
|
def get_spotify_public_playlist_state(url_hash):
|
||||||
|
"""Get specific Spotify Public playlist state (detailed version)"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found"}), 404
|
||||||
|
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
state['last_accessed'] = time.time()
|
||||||
|
|
||||||
|
response = {
|
||||||
|
'playlist_id': url_hash,
|
||||||
|
'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 Spotify Public playlist state: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/reset/<url_hash>', methods=['POST'])
|
||||||
|
def reset_spotify_public_playlist(url_hash):
|
||||||
|
"""Reset Spotify Public playlist to fresh phase (clear discovery/sync data)"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found"}), 404
|
||||||
|
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
|
||||||
|
# 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 Spotify Public playlist to fresh: {url_hash}")
|
||||||
|
return jsonify({"success": True, "message": "Playlist reset to fresh phase"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error resetting Spotify Public playlist: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/delete/<url_hash>', methods=['POST'])
|
||||||
|
def delete_spotify_public_playlist(url_hash):
|
||||||
|
"""Delete Spotify Public playlist state completely"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found"}), 404
|
||||||
|
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
|
||||||
|
# Stop any active discovery
|
||||||
|
if 'discovery_future' in state and state['discovery_future']:
|
||||||
|
state['discovery_future'].cancel()
|
||||||
|
|
||||||
|
# Remove from state dictionary
|
||||||
|
del spotify_public_discovery_states[url_hash]
|
||||||
|
|
||||||
|
print(f"🗑️ Deleted Spotify Public playlist state: {url_hash}")
|
||||||
|
return jsonify({"success": True, "message": "Playlist deleted"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error deleting Spotify Public playlist: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/update_phase/<url_hash>', methods=['POST'])
|
||||||
|
def update_spotify_public_playlist_phase(url_hash):
|
||||||
|
"""Update Spotify Public playlist phase (used when modal closes to reset from download_complete to discovered)"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public 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 = spotify_public_discovery_states[url_hash]
|
||||||
|
old_phase = state.get('phase', 'unknown')
|
||||||
|
state['phase'] = new_phase
|
||||||
|
state['last_accessed'] = time.time()
|
||||||
|
|
||||||
|
# Update download process ID if provided (for download persistence)
|
||||||
|
if 'download_process_id' in data:
|
||||||
|
state['download_process_id'] = data['download_process_id']
|
||||||
|
|
||||||
|
# Update converted Spotify playlist ID if provided (for download persistence)
|
||||||
|
if 'converted_spotify_playlist_id' in data:
|
||||||
|
state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id']
|
||||||
|
|
||||||
|
print(f"🔄 Updated Spotify Public playlist {url_hash} 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 Spotify Public playlist phase: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _run_spotify_public_discovery_worker(url_hash):
|
||||||
|
"""Background worker for Spotify Public discovery process (Spotify preferred, iTunes fallback)"""
|
||||||
|
_ew_state = {}
|
||||||
|
try:
|
||||||
|
_ew_state = _pause_enrichment_workers('Spotify Public discovery')
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
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 Spotify Public 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, sp_track in enumerate(tracks):
|
||||||
|
if state.get('cancelled', False):
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_name = sp_track['name']
|
||||||
|
track_artists_raw = sp_track.get('artists', [])
|
||||||
|
# Normalize artists to list of strings
|
||||||
|
track_artists = []
|
||||||
|
for a in track_artists_raw:
|
||||||
|
if isinstance(a, dict):
|
||||||
|
track_artists.append(a.get('name', ''))
|
||||||
|
else:
|
||||||
|
track_artists.append(str(a))
|
||||||
|
track_id = sp_track.get('id', '')
|
||||||
|
track_album = sp_track.get('album', '')
|
||||||
|
if isinstance(track_album, dict):
|
||||||
|
track_album_name = track_album.get('name', '')
|
||||||
|
else:
|
||||||
|
track_album_name = track_album or ''
|
||||||
|
track_duration_ms = sp_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 = {
|
||||||
|
'spotify_public_track': {
|
||||||
|
'id': track_id,
|
||||||
|
'name': track_name,
|
||||||
|
'artists': track_artists or [],
|
||||||
|
'album': track_album_name,
|
||||||
|
'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_name,
|
||||||
|
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 = {
|
||||||
|
'spotify_public_track': {
|
||||||
|
'id': track_id,
|
||||||
|
'name': track_name,
|
||||||
|
'artists': track_artists or [],
|
||||||
|
'album': track_album_name,
|
||||||
|
'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 {}
|
||||||
|
# Extract image URL from album data or track object
|
||||||
|
_album_images = album_obj.get('images', [])
|
||||||
|
_image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '')
|
||||||
|
|
||||||
|
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,
|
||||||
|
'image_url': _image_url,
|
||||||
|
'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'
|
||||||
|
# Extract image URL from iTunes album images
|
||||||
|
_itunes_album = match_data.get('album', {})
|
||||||
|
_itunes_images = _itunes_album.get('images', []) if isinstance(_itunes_album, dict) else []
|
||||||
|
if _itunes_images and 'image_url' not in match_data:
|
||||||
|
match_data['image_url'] = _itunes_images[0].get('url', '')
|
||||||
|
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 = {
|
||||||
|
'spotify_public_track': {
|
||||||
|
'name': sp_track.get('name', 'Unknown'),
|
||||||
|
'artists': sp_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"Spotify Link Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
|
||||||
|
|
||||||
|
print(f"✅ Spotify Public discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error in Spotify Public discovery worker: {e}")
|
||||||
|
if url_hash in spotify_public_discovery_states:
|
||||||
|
spotify_public_discovery_states[url_hash]['phase'] = 'error'
|
||||||
|
spotify_public_discovery_states[url_hash]['status'] = f'error: {str(e)}'
|
||||||
|
finally:
|
||||||
|
_resume_enrichment_workers(_ew_state, 'Spotify Public discovery')
|
||||||
|
|
||||||
|
|
||||||
|
def convert_spotify_public_results_to_spotify_tracks(discovery_results):
|
||||||
|
"""Convert Spotify Public 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)} Spotify Public matches to Spotify tracks for sync")
|
||||||
|
return spotify_tracks
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# SPOTIFY PUBLIC SYNC API ENDPOINTS
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/sync/start/<url_hash>', methods=['POST'])
|
||||||
|
def start_spotify_public_sync(url_hash):
|
||||||
|
"""Start sync process for a Spotify Public playlist using discovered Spotify tracks"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found"}), 404
|
||||||
|
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
state['last_accessed'] = time.time()
|
||||||
|
|
||||||
|
if state['phase'] not in ['discovered', 'sync_complete']:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not ready for sync"}), 400
|
||||||
|
|
||||||
|
# Convert discovery results to Spotify tracks format
|
||||||
|
spotify_tracks = convert_spotify_public_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"spotify_public_{url_hash}"
|
||||||
|
playlist_name = state['playlist']['name']
|
||||||
|
|
||||||
|
# Add activity for sync start
|
||||||
|
add_activity_item("🔄", "Spotify Link Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
|
||||||
|
|
||||||
|
# Update Spotify Public 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 Spotify Public 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 Spotify Public sync: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/sync/status/<url_hash>', methods=['GET'])
|
||||||
|
def get_spotify_public_sync_status(url_hash):
|
||||||
|
"""Get sync status for a Spotify Public playlist"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found"}), 404
|
||||||
|
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
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 Spotify Public 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"Spotify Link 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"Spotify Link playlist '{playlist_name}' sync failed", "Now")
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error getting Spotify Public sync status: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/spotify-public/sync/cancel/<url_hash>', methods=['POST'])
|
||||||
|
def cancel_spotify_public_sync(url_hash):
|
||||||
|
"""Cancel sync for a Spotify Public playlist"""
|
||||||
|
try:
|
||||||
|
if url_hash not in spotify_public_discovery_states:
|
||||||
|
return jsonify({"error": "Spotify Public playlist not found"}), 404
|
||||||
|
|
||||||
|
state = spotify_public_discovery_states[url_hash]
|
||||||
|
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 Spotify Public state
|
||||||
|
state['phase'] = 'discovered'
|
||||||
|
state['sync_playlist_id'] = None
|
||||||
|
state['sync_progress'] = {}
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "Spotify Public sync cancelled"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error cancelling Spotify Public sync: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# YOUTUBE PLAYLIST API ENDPOINTS
|
# YOUTUBE PLAYLIST API ENDPOINTS
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
@ -39092,6 +39973,7 @@ def _emit_discovery_progress_loop():
|
||||||
'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,
|
||||||
|
'spotify_public': lambda: spotify_public_discovery_states,
|
||||||
}
|
}
|
||||||
while True:
|
while True:
|
||||||
socketio.sleep(1)
|
socketio.sleep(1)
|
||||||
|
|
|
||||||
|
|
@ -989,6 +989,9 @@
|
||||||
<button class="sync-tab-button active" data-tab="spotify">
|
<button class="sync-tab-button active" data-tab="spotify">
|
||||||
<span class="tab-icon spotify-icon"></span> Spotify
|
<span class="tab-icon spotify-icon"></span> Spotify
|
||||||
</button>
|
</button>
|
||||||
|
<button class="sync-tab-button" data-tab="spotify-public">
|
||||||
|
<span class="tab-icon spotify-icon"></span> Spotify Link
|
||||||
|
</button>
|
||||||
<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>
|
||||||
|
|
@ -1055,6 +1058,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Spotify Public Link Tab Content -->
|
||||||
|
<div class="sync-tab-content" id="spotify-public-tab-content">
|
||||||
|
<div class="youtube-input-section">
|
||||||
|
<input type="text" id="spotify-public-url-input"
|
||||||
|
placeholder="Paste Spotify Playlist or Album URL...">
|
||||||
|
<button id="spotify-public-parse-btn">Load</button>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-scroll-container" id="spotify-public-playlist-container">
|
||||||
|
<div class="playlist-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Beatport Tab Content -->
|
<!-- Beatport Tab Content -->
|
||||||
<div class="sync-tab-content" id="beatport-tab-content">
|
<div class="sync-tab-content" id="beatport-tab-content">
|
||||||
<!-- Beatport Nested Tabs -->
|
<!-- Beatport Nested Tabs -->
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6690,6 +6690,12 @@ body {
|
||||||
box-shadow: 0 4px 15px rgba(255, 0, 0, 0.3);
|
box-shadow: 0 4px 15px rgba(255, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-tab-button[data-tab="spotify-public"].active {
|
||||||
|
background: #1DB954;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 15px rgba(29, 185, 84, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.sync-tab-button:hover:not(.active) {
|
.sync-tab-button:hover:not(.active) {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|
@ -10118,7 +10124,8 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#youtube-url-input,
|
#youtube-url-input,
|
||||||
#deezer-url-input {
|
#deezer-url-input,
|
||||||
|
#spotify-public-url-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -10131,13 +10138,15 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#youtube-url-input::placeholder,
|
#youtube-url-input::placeholder,
|
||||||
#deezer-url-input::placeholder {
|
#deezer-url-input::placeholder,
|
||||||
|
#spotify-public-url-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
#youtube-parse-btn,
|
#youtube-parse-btn,
|
||||||
#deezer-parse-btn {
|
#deezer-parse-btn,
|
||||||
|
#spotify-public-parse-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 10px 22px;
|
padding: 10px 22px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -10186,7 +10195,8 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#youtube-parse-btn:disabled,
|
#youtube-parse-btn:disabled,
|
||||||
#deezer-parse-btn:disabled {
|
#deezer-parse-btn:disabled,
|
||||||
|
#spotify-public-parse-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
|
@ -10204,6 +10214,28 @@ body {
|
||||||
box-shadow: 0 0 16px rgba(162, 56, 255, 0.08);
|
box-shadow: 0 0 16px rgba(162, 56, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#spotify-public-parse-btn {
|
||||||
|
background: linear-gradient(135deg, #1DB954, #1ed760);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#spotify-public-parse-btn:hover {
|
||||||
|
background: linear-gradient(135deg, #1ed760, #2de86e);
|
||||||
|
box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#spotify-public-parse-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 4px rgba(29, 185, 84, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#spotify-public-tab-content .youtube-input-section:focus-within {
|
||||||
|
border-color: rgba(29, 185, 84, 0.25);
|
||||||
|
box-shadow: 0 0 16px rgba(29, 185, 84, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
/* Right Sidebar */
|
/* Right Sidebar */
|
||||||
.sync-sidebar {
|
.sync-sidebar {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
@ -10657,6 +10689,25 @@ body {
|
||||||
box-shadow: 0 0 15px rgba(162, 56, 255, 0.4);
|
box-shadow: 0 0 15px rgba(162, 56, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===============================
|
||||||
|
SPOTIFY PUBLIC CARD STYLES (extends YouTube card styles)
|
||||||
|
===============================*/
|
||||||
|
|
||||||
|
.spotify-public-card .playlist-card-icon {
|
||||||
|
background: rgba(29, 185, 84, 0.2);
|
||||||
|
border-color: #1DB954;
|
||||||
|
color: #1DB954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotify-public-card .playlist-card-action-btn {
|
||||||
|
background: linear-gradient(135deg, #1DB954, #1ed760);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotify-public-card .playlist-card-action-btn:hover {
|
||||||
|
background: linear-gradient(135deg, #1ed760, #3be477);
|
||||||
|
box-shadow: 0 0 15px rgba(29, 185, 84, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===============================
|
/* ===============================
|
||||||
YOUTUBE DISCOVERY MODAL STYLES
|
YOUTUBE DISCOVERY MODAL STYLES
|
||||||
=============================== */
|
=============================== */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue