From ae98bb88068b7aaf6adb718876a1193facfa7735 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Tue, 2 Sep 2025 21:27:52 -0700 Subject: [PATCH] sync --- web_server.py | 146 ++++++++++++++++++ webui/static/script.js | 336 +++++++++++++++++++++++++++++++++++------ 2 files changed, 440 insertions(+), 42 deletions(-) diff --git a/web_server.py b/web_server.py index a66c2b4b..c450035b 100644 --- a/web_server.py +++ b/web_server.py @@ -5981,6 +5981,152 @@ def _search_spotify_for_tidal_track(tidal_track): return None +def convert_tidal_results_to_spotify_tracks(discovery_results): + """Convert Tidal discovery results to Spotify tracks format for sync""" + spotify_tracks = [] + + for result in discovery_results: + if result.get('spotify_data'): + spotify_data = result['spotify_data'] + + # Create track object matching the expected format + track = { + 'id': spotify_data['id'], + 'name': spotify_data['name'], + 'artists': spotify_data['artists'], + 'album': spotify_data['album'], + 'duration_ms': spotify_data['duration_ms'] + } + spotify_tracks.append(track) + + print(f"🔄 Converted {len(spotify_tracks)} Tidal matches to Spotify tracks for sync") + return spotify_tracks + + +# =================================================================== +# TIDAL SYNC API ENDPOINTS +# =================================================================== + +@app.route('/api/tidal/sync/start/', methods=['POST']) +def start_tidal_sync(playlist_id): + """Start sync process for a Tidal playlist using discovered Spotify tracks""" + try: + if playlist_id not in tidal_discovery_states: + return jsonify({"error": "Tidal playlist not found"}), 404 + + state = tidal_discovery_states[playlist_id] + state['last_accessed'] = time.time() # Update access time + + if state['phase'] not in ['discovered', 'sync_complete']: + return jsonify({"error": "Tidal playlist not ready for sync"}), 400 + + # Convert discovery results to Spotify tracks format + spotify_tracks = convert_tidal_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"tidal_{playlist_id}" + playlist_name = state['playlist'].name # Tidal playlist object has .name attribute + + # Update Tidal 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': f"[Tidal] {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 Tidal 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 Tidal sync: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/tidal/sync/status/', methods=['GET']) +def get_tidal_sync_status(playlist_id): + """Get sync status for a Tidal playlist""" + try: + if playlist_id not in tidal_discovery_states: + return jsonify({"error": "Tidal playlist not found"}), 404 + + state = tidal_discovery_states[playlist_id] + state['last_accessed'] = time.time() # Update access 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 Tidal state if sync completed + if sync_state.get('status') == 'finished': + state['phase'] = 'sync_complete' + state['sync_progress'] = sync_state.get('progress', {}) + elif sync_state.get('status') == 'error': + state['phase'] = 'discovered' # Revert on error + + return jsonify(response) + + except Exception as e: + print(f"❌ Error getting Tidal sync status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/tidal/sync/cancel/', methods=['POST']) +def cancel_tidal_sync(playlist_id): + """Cancel sync for a Tidal playlist""" + try: + if playlist_id not in tidal_discovery_states: + return jsonify({"error": "Tidal playlist not found"}), 404 + + state = tidal_discovery_states[playlist_id] + state['last_accessed'] = time.time() # Update access 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 Tidal state + state['phase'] = 'discovered' + state['sync_playlist_id'] = None + state['sync_progress'] = {} + + return jsonify({"success": True, "message": "Tidal sync cancelled"}) + + except Exception as e: + print(f"❌ Error cancelling Tidal sync: {e}") + return jsonify({"error": str(e)}), 500 + + # =================================================================== # YOUTUBE PLAYLIST API ENDPOINTS # =================================================================== diff --git a/webui/static/script.js b/webui/static/script.js index 1ecf107f..c0ed4400 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -6396,19 +6396,9 @@ function createTidalCard(playlist) { const phase = state.phase; // Get phase-specific button text (like YouTube cards) - let buttonText = 'Start Discovery'; - let phaseText = 'Ready to discover'; - let phaseColor = '#999'; - - if (phase === 'discovering') { - buttonText = 'View Progress'; - phaseText = 'Discovering...'; - phaseColor = '#ff6600'; - } else if (phase === 'discovered') { - buttonText = 'View Details'; - phaseText = 'Discovery Complete'; - phaseColor = '#1db954'; - } + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); return `
@@ -6421,7 +6411,7 @@ function createTidalCard(playlist) {
- ♪ ${playlist.track_count} / ✓ 0 / ✗ ${playlist.track_count} / 0% +
@@ -6442,7 +6432,7 @@ async function handleTidalCardClick(playlistId) { // Open discovery modal - phase will be updated when discovery actually starts openTidalDiscoveryModal(playlistId, state.playlist); - } else if (state.phase === 'discovering' || state.phase === 'discovered') { + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { // Reopen existing modal (like sync.py) openTidalDiscoveryModal(playlistId, state.playlist); } @@ -6465,6 +6455,13 @@ function updateTidalCardPhase(playlistId, phase) { if (newCard) { newCard.addEventListener('click', () => handleTidalCardClick(playlistId)); } + + // If we have sync progress and we're in sync/sync_complete phase, restore it + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + setTimeout(() => { + updateTidalCardSyncProgress(playlistId, state.lastSyncProgress); + }, 0); + } } console.log(`🎵 Updated Tidal card phase: ${playlistId} -> ${phase}`); @@ -6478,7 +6475,7 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { // Get current Tidal card state to check if discovery is already done or in progress const tidalCardState = tidalPlaylistStates[playlistId]; - const isAlreadyDiscovered = tidalCardState && tidalCardState.phase === 'discovered'; + const isAlreadyDiscovered = tidalCardState && (tidalCardState.phase === 'discovered' || tidalCardState.phase === 'syncing' || tidalCardState.phase === 'sync_complete'); const isCurrentlyDiscovering = tidalCardState && tidalCardState.phase === 'discovering'; // Prepare discovery results in the correct format for modal @@ -6504,7 +6501,7 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { } // Create YouTube-compatible state structure - const modalPhase = isAlreadyDiscovered ? 'discovered' : (isCurrentlyDiscovering ? 'discovering' : 'fresh'); + const modalPhase = tidalCardState ? tidalCardState.phase : 'fresh'; youtubePlaylistStates[fakeUrlHash] = { phase: modalPhase, playlist: { @@ -6560,8 +6557,12 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { // Resume polling if discovery is already in progress (like YouTube) console.log(`🔄 Resuming Tidal discovery polling for: ${playlistData.name}`); startTidalDiscoveryPolling(fakeUrlHash, playlistId); + } else if (tidalCardState && tidalCardState.phase === 'syncing') { + // Resume sync polling if sync is in progress + console.log(`🔄 Resuming Tidal sync polling for: ${playlistData.name}`); + startTidalSyncPolling(fakeUrlHash); } else { - console.log('✅ Using existing discovery results - no need to re-discover'); + console.log('✅ Using existing results - no need to re-discover'); } // Reuse YouTube discovery modal (exact sync.py pattern) @@ -6778,18 +6779,242 @@ function updateTidalCardProgress(playlistId, progress) { console.log('🎵 Updated Tidal card progress:', playlistId, `${matches}/${total} (${percentage}%)`); } -// Tidal-specific sync and download functions (placeholder implementations) -function startTidalPlaylistSync(urlHash) { - console.log(`🎵 Starting Tidal playlist sync for: ${urlHash}`); - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_tidal_playlist) { - console.error('❌ Invalid Tidal playlist state for sync'); - return; +// =============================== +// TIDAL SYNC FUNCTIONALITY +// =============================== + +async function startTidalPlaylistSync(urlHash) { + try { + console.log('🎵 Starting Tidal playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_tidal_playlist) { + console.error('❌ Invalid Tidal playlist state for sync'); + return; + } + + const playlistId = state.tidal_playlist_id; + const response = await fetch(`/api/tidal/sync/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + // Update card and modal to syncing phase + updateTidalCardPhase(playlistId, 'syncing'); + + // Update modal buttons if modal is open + updateTidalModalButtons(urlHash, 'syncing'); + + // Start sync polling + startTidalSyncPolling(urlHash); + + showToast('Tidal playlist sync started!', 'success'); + + } catch (error) { + console.error('❌ Error starting Tidal sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startTidalSyncPolling(urlHash) { + // Stop any existing polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); } - // TODO: Implement Tidal playlist sync logic - // For now, show a message that this feature is coming soon - showToast('🔄 Tidal playlist sync functionality coming soon!', 'info'); + const state = youtubePlaylistStates[urlHash]; + const playlistId = state.tidal_playlist_id; + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/tidal/sync/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling Tidal sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + // Update card progress with sync stats + updateTidalCardSyncProgress(playlistId, status.progress); + + // Update modal sync display if open + updateTidalModalSyncProgress(urlHash, status.progress); + + // Check if complete + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + + // Update both states to sync_complete + if (tidalPlaylistStates[playlistId]) { + tidalPlaylistStates[playlistId].phase = 'sync_complete'; + } + if (youtubePlaylistStates[urlHash]) { + youtubePlaylistStates[urlHash].phase = 'sync_complete'; + } + + // Update card phase to sync complete + updateTidalCardPhase(playlistId, 'sync_complete'); + + // Update modal buttons + updateTidalModalButtons(urlHash, 'sync_complete'); + + console.log('✅ Tidal sync complete:', urlHash); + showToast('Tidal playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + + // Update both states to discovered (revert on error) + if (tidalPlaylistStates[playlistId]) { + tidalPlaylistStates[playlistId].phase = 'discovered'; + } + if (youtubePlaylistStates[urlHash]) { + youtubePlaylistStates[urlHash].phase = 'discovered'; + } + + // Revert to discovered phase on error + updateTidalCardPhase(playlistId, 'discovered'); + updateTidalModalButtons(urlHash, 'discovered'); + + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + + } catch (error) { + console.error('❌ Error polling Tidal sync:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + }, 1000); + + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelTidalSync(urlHash) { + try { + console.log('❌ Cancelling Tidal sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_tidal_playlist) { + console.error('❌ Invalid Tidal playlist state'); + return; + } + + const playlistId = state.tidal_playlist_id; + const response = await fetch(`/api/tidal/sync/cancel/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + // Stop polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Revert to discovered phase + updateTidalCardPhase(playlistId, 'discovered'); + updateTidalModalButtons(urlHash, 'discovered'); + + showToast('Tidal sync cancelled', 'info'); + + } catch (error) { + console.error('❌ Error cancelling Tidal sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateTidalCardSyncProgress(playlistId, progress) { + const state = tidalPlaylistStates[playlistId]; + if (!state || !state.playlist || !progress) return; + + // Save the progress for later restoration + state.lastSyncProgress = progress; + + const card = document.getElementById(`tidal-card-${playlistId}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + + // Build clean status counter HTML exactly like YouTube cards + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
+ ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
+ `; + } + + progressElement.innerHTML = statusCounterHTML || '
🔄 Starting...
'; + + console.log(`🎵 Updated Tidal card sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`); +} + +function updateTidalModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`tidal-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + console.log(`📊 Updating Tidal modal sync progress for ${urlHash}:`, progress); + + // Update individual counters exactly like YouTube sync + const totalEl = document.getElementById(`tidal-total-${urlHash}`); + const matchedEl = document.getElementById(`tidal-matched-${urlHash}`); + const failedEl = document.getElementById(`tidal-failed-${urlHash}`); + const percentageEl = document.getElementById(`tidal-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + // Calculate percentage like YouTube sync + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } + + console.log(`📊 Tidal modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`); +} + +function updateTidalModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } } function startTidalDownloadMissing(urlHash) { @@ -7630,32 +7855,59 @@ function getModalActionButtons(urlHash, phase, state = null) { return buttons; case 'syncing': - return ` - -
- 0 - / - 0 - / - 0 - (0%) -
- `; + if (isTidal) { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } else { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } case 'sync_complete': let syncCompleteButtons = ''; // Only show sync button if there are Spotify matches if (hasSpotifyMatches) { - syncCompleteButtons += ``; + if (isTidal) { + syncCompleteButtons += ``; + } else { + syncCompleteButtons += ``; + } } // Only show download button if we have matches or a converted playlist ID if (hasSpotifyMatches || hasConvertedPlaylistId) { - syncCompleteButtons += ``; + if (isTidal) { + syncCompleteButtons += ``; + } else { + syncCompleteButtons += ``; + } } - syncCompleteButtons += ``; + if (isTidal) { + // Tidal doesn't have a reset function yet, but could be added + // syncCompleteButtons += ``; + } else { + syncCompleteButtons += ``; + } return syncCompleteButtons;