From f99c550484df957368907d5b7452eb7ff222accf Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 31 Aug 2025 14:54:16 -0700 Subject: [PATCH] youtube playlists --- web_server.py | 137 +++++++++++++++ webui/static/script.js | 387 ++++++++++++++++++++++++++++++++++++++++- webui/static/style.css | 49 +++++- 3 files changed, 566 insertions(+), 7 deletions(-) diff --git a/web_server.py b/web_server.py index 174f3455..913b985e 100644 --- a/web_server.py +++ b/web_server.py @@ -5731,6 +5731,143 @@ def _calculate_similarity(str1, str2): return intersection / union if union > 0 else 0 +@app.route('/api/youtube/sync/start/', methods=['POST']) +def start_youtube_sync(url_hash): + """Start sync process for a YouTube playlist using discovered Spotify tracks""" + try: + if url_hash not in youtube_discovery_states: + return jsonify({"error": "YouTube playlist not found"}), 404 + + state = youtube_discovery_states[url_hash] + + if state['phase'] not in ['discovered', 'sync_complete']: + return jsonify({"error": "YouTube playlist not ready for sync"}), 400 + + # Convert discovery results to Spotify tracks format + spotify_tracks = convert_youtube_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"youtube_{url_hash}" + playlist_name = state['playlist']['name'] + + # Update YouTube 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"[YouTube] {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 YouTube 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 YouTube sync: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/youtube/sync/status/', methods=['GET']) +def get_youtube_sync_status(url_hash): + """Get sync status for a YouTube playlist""" + try: + if url_hash not in youtube_discovery_states: + return jsonify({"error": "YouTube playlist not found"}), 404 + + state = youtube_discovery_states[url_hash] + 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 YouTube 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 YouTube sync status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/youtube/sync/cancel/', methods=['POST']) +def cancel_youtube_sync(url_hash): + """Cancel sync for a YouTube playlist""" + try: + if url_hash not in youtube_discovery_states: + return jsonify({"error": "YouTube playlist not found"}), 404 + + state = youtube_discovery_states[url_hash] + 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 YouTube state + state['phase'] = 'discovered' + state['sync_playlist_id'] = None + state['sync_progress'] = {} + + return jsonify({"success": True, "message": "YouTube sync cancelled"}) + + except Exception as e: + print(f"❌ Error cancelling YouTube sync: {e}") + return jsonify({"error": str(e)}), 500 + +def convert_youtube_results_to_spotify_tracks(discovery_results): + """Convert YouTube 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)} YouTube matches to Spotify tracks for sync") + return spotify_tracks + # Add these new endpoints to the end of web_server.py diff --git a/webui/static/script.js b/webui/static/script.js index 5d800aac..b825e7c2 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -5993,6 +5993,38 @@ function updateYouTubeCardPhase(urlHash, phase) { actionBtn.disabled = false; progressElement.classList.add('hidden'); break; + + case 'syncing': + phaseTextElement.textContent = 'Syncing...'; + phaseTextElement.style.color = '#ffa500'; // Orange + actionBtn.textContent = 'View Progress'; + actionBtn.disabled = false; + progressElement.classList.remove('hidden'); + break; + + case 'sync_complete': + phaseTextElement.textContent = 'Sync Complete'; + phaseTextElement.style.color = '#1db954'; // Green + actionBtn.textContent = 'View Details'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; + + case 'downloading': + phaseTextElement.textContent = 'Downloading...'; + phaseTextElement.style.color = '#ffa500'; // Orange + actionBtn.textContent = 'View Downloads'; + actionBtn.disabled = false; + progressElement.classList.remove('hidden'); + break; + + case 'download_complete': + phaseTextElement.textContent = 'Download Complete'; + phaseTextElement.style.color = '#1db954'; // Green + actionBtn.textContent = 'View Results'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; } console.log('🃏 Updated YouTube card phase:', urlHash, phase); @@ -6013,10 +6045,26 @@ function handleYouTubeCardClick(urlHash) { case 'discovering': case 'discovered': - // Subsequent clicks: Just open modal with preserved state - console.log('🎬 Opening existing YouTube modal:', urlHash); + case 'syncing': + case 'sync_complete': + // Open discovery modal with current state + console.log('🎬 Opening YouTube discovery modal:', urlHash); openYouTubeDiscoveryModal(urlHash); break; + + case 'downloading': + case 'download_complete': + // Open download missing tracks modal + console.log('🎬 Opening download modal for YouTube playlist:', urlHash); + // Need to get playlist ID from converted Spotify data + const spotifyPlaylistId = state.convertedSpotifyPlaylistId; + if (spotifyPlaylistId) { + openDownloadMissingModal(spotifyPlaylistId); + } else { + console.error('❌ No converted Spotify playlist ID found for downloads'); + showToast('Unable to open download modal - missing playlist data', 'error'); + } + break; } } @@ -6127,6 +6175,9 @@ function startYouTubeDiscoveryPolling(urlHash) { // Update card phase to discovered updateYouTubeCardPhase(urlHash, 'discovered'); + // Update modal buttons to show sync and download buttons + updateYouTubeModalButtons(urlHash, 'discovered'); + console.log('✅ YouTube discovery complete:', urlHash); showToast('YouTube discovery complete!', 'success'); } @@ -6213,7 +6264,12 @@ function openYouTubeDiscoveryModal(urlHash) { @@ -6241,6 +6297,36 @@ function openYouTubeDiscoveryModal(urlHash) { } } +function getModalActionButtons(urlHash, phase) { + switch (phase) { + case 'discovered': + return ` + + + `; + case 'syncing': + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + case 'sync_complete': + return ` + + + + `; + default: + return ''; + } +} + function getModalDescription(phase) { switch (phase) { case 'fresh': @@ -6319,10 +6405,28 @@ function updateYouTubeDiscoveryModal(urlHash, status) { progressBar.style.width = `${status.progress}%`; progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`; - // Update table rows + // Update table rows - create missing rows if needed status.results.forEach(result => { - const row = document.getElementById(`youtube-discovery-row-${result.index}`); - if (!row) return; + let row = document.getElementById(`youtube-discovery-row-${result.index}`); + + // Create missing row if it doesn't exist + if (!row) { + const rowHtml = ` + + ${result.yt_track} + ${result.yt_artist} + 🔍 Pending... + - + - + - + ${result.duration || '0:00'} + + `; + tableBody.insertAdjacentHTML('beforeend', rowHtml); + row = document.getElementById(`youtube-discovery-row-${result.index}`); + } + + if (!row) return; // Safety check const statusCell = row.querySelector('.discovery-status'); const spotifyTrackCell = row.querySelector('.spotify-track'); @@ -6350,6 +6454,277 @@ function closeYouTubeDiscoveryModal(urlHash) { // Discovery polling continues in background if active } +// =============================== +// YOUTUBE SYNC FUNCTIONALITY +// =============================== + +async function startYouTubePlaylistSync(urlHash) { + try { + console.log('🔄 Starting YouTube playlist sync:', urlHash); + + const response = await fetch(`/api/youtube/sync/start/${urlHash}`, { + 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 + updateYouTubeCardPhase(urlHash, 'syncing'); + + // Update modal buttons if modal is open + updateYouTubeModalButtons(urlHash, 'syncing'); + + // Start sync polling + startYouTubeSyncPolling(urlHash); + + showToast('YouTube playlist sync started!', 'success'); + + } catch (error) { + console.error('❌ Error starting YouTube sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startYouTubeSyncPolling(urlHash) { + // Stop any existing polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/youtube/sync/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling YouTube sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + // Update card progress with sync stats + updateYouTubeCardSyncProgress(urlHash, status.progress); + + // Update modal sync display if open + updateYouTubeModalSyncProgress(urlHash, status.progress); + + // Check if complete + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + + // Update card phase to sync complete + updateYouTubeCardPhase(urlHash, 'sync_complete'); + + // Update modal buttons + updateYouTubeModalButtons(urlHash, 'sync_complete'); + + console.log('✅ YouTube sync complete:', urlHash); + showToast('YouTube playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + + // Revert to discovered phase on error + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + + } catch (error) { + console.error('❌ Error polling YouTube sync:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + }, 1000); + + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelYouTubeSync(urlHash) { + try { + console.log('❌ Cancelling YouTube sync:', urlHash); + + const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, { + 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 + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + + showToast('YouTube sync cancelled', 'info'); + + } catch (error) { + console.error('❌ Error cancelling YouTube sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateYouTubeCardSyncProgress(urlHash, progress) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.cardElement || !progress) return; + + const card = state.cardElement; + const progressElement = card.querySelector('.playlist-card-progress'); + + // Build clean status counter HTML exactly like Spotify 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 YouTube sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`); +} + +function updateYouTubeModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`youtube-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + console.log(`📊 Updating YouTube modal sync progress for ${urlHash}:`, progress); + + // Update individual counters exactly like Spotify sync + const totalEl = document.getElementById(`youtube-total-${urlHash}`); + const matchedEl = document.getElementById(`youtube-matched-${urlHash}`); + const failedEl = document.getElementById(`youtube-failed-${urlHash}`); + const percentageEl = document.getElementById(`youtube-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 Spotify sync + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } + + console.log(`📊 YouTube modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`); +} + +function updateYouTubeModalButtons(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); + } +} + +// =============================== +// YOUTUBE DOWNLOAD MISSING TRACKS +// =============================== + +async function startYouTubeDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks for YouTube playlist:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + // Convert YouTube results to a format compatible with the download modal + const spotifyTracks = state.discoveryResults + .filter(result => result.spotify_data) + .map(result => result.spotify_data); + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + // Create a virtual playlist for the download system + const virtualPlaylistId = `youtube_${urlHash}`; + const playlistName = `[YouTube] ${state.playlist.name}`; + + // Store reference for card navigation + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + // Open the existing download missing tracks modal + await openDownloadMissingModal(virtualPlaylistId, playlistName, spotifyTracks); + + // Update YouTube card phase when download process starts + updateYouTubeCardPhase(urlHash, 'downloading'); + + } catch (error) { + console.error('❌ Error starting download missing tracks:', error); + showToast(`Error starting downloads: ${error.message}`, 'error'); + } +} + +function resetYouTubePlaylist(urlHash) { + const state = youtubePlaylistStates[urlHash]; + if (!state) return; + + // Stop any active polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Reset to fresh phase + state.phase = 'fresh'; + state.discoveryResults = []; + state.discoveryProgress = 0; + state.spotifyMatches = 0; + state.syncPlaylistId = null; + state.syncProgress = {}; + state.convertedSpotifyPlaylistId = null; + + // Update card + updateYouTubeCardPhase(urlHash, 'fresh'); + + // Close modal + closeYouTubeDiscoveryModal(urlHash); + + showToast('YouTube playlist reset to fresh state', 'info'); +} + // --- Global Cleanup on Page Unload --- // Note: Automatic wishlist processing now runs server-side and continues even when browser is closed \ No newline at end of file diff --git a/webui/static/style.css b/webui/static/style.css index 51eed9ff..cb70bf25 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4754,9 +4754,20 @@ body { .youtube-discovery-modal .modal-footer { padding: 30px; border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: space-between; +} + +.youtube-discovery-modal .modal-footer-left { + display: flex; + gap: 12px; + align-items: center; +} + +.youtube-discovery-modal .modal-footer-right { display: flex; gap: 12px; - justify-content: flex-end; } .youtube-discovery-modal .modal-btn { @@ -4780,6 +4791,42 @@ body { border-color: rgba(255, 255, 255, 0.3); } +.youtube-discovery-modal .modal-btn-primary { + background: linear-gradient(135deg, #1db954 0%, #1ed760 100%); + color: #ffffff; + border: 1px solid rgba(29, 185, 84, 0.3); +} + +.youtube-discovery-modal .modal-btn-primary:hover { + background: linear-gradient(135deg, #1ed760 0%, #1fbc56 100%); + border-color: rgba(29, 185, 84, 0.5); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(29, 185, 84, 0.3); +} + +.youtube-discovery-modal .modal-btn-danger { + background: linear-gradient(135deg, #ff6b6b 0%, #ff5555 100%); + color: #ffffff; + border: 1px solid rgba(255, 107, 107, 0.3); +} + +.youtube-discovery-modal .modal-btn-danger:hover { + background: linear-gradient(135deg, #ff5555 0%, #ff4444 100%); + border-color: rgba(255, 107, 107, 0.5); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3); +} + +.youtube-discovery-modal .sync-progress-display { + color: #1ed760; + font-size: 14px; + font-weight: 500; + padding: 8px 12px; + background: rgba(29, 185, 84, 0.1); + border-radius: 8px; + border: 1px solid rgba(29, 185, 84, 0.2); +} + /* Modal state management */ .modal-overlay.hidden { display: none !important;