From dc612da9d5e55b5f6a18d7353e033249d37ef8a3 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:54:58 -0700 Subject: [PATCH] Add Sync to Server button for Beatport playlists in download modal - "Sync to Server" button appears for Beatport chart/playlist downloads - Starts media server sync via /api/sync/start with live progress bar - Progress area renders below all modal buttons with matched/failed counts - Cancel button to abort sync mid-way, auto-cleanup on completion --- webui/static/script.js | 187 +++++++++++++++++++++++++++++++++++++++++ webui/static/style.css | 40 +++++++++ 2 files changed, 227 insertions(+) diff --git a/webui/static/script.js b/webui/static/script.js index b0dc7582..e9cdf831 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -18624,6 +18624,175 @@ async function deleteSyncHistoryEntry(entryId) { } } +// ── Sync Playlist to Server (from Download Modal) ────────────────── + +// Track active modal syncs +let _activeModalSyncs = {}; + +function _isBeatportPlaylistId(id) { + return id.startsWith('beatport_chart_') || id.startsWith('beatport_top100_') || id.startsWith('beatport_hype100_'); +} + +async function syncPlaylistToServer(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) { showToast('No playlist data found', 'error'); return; } + + // Disable the sync button + const btn = document.getElementById(`sync-server-btn-${playlistId}`); + if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } + + // Show progress area + const progressArea = document.getElementById(`modal-sync-progress-${playlistId}`); + if (progressArea) progressArea.style.display = ''; + + const syncPlaylistId = `beatport_sync_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const playlistName = process.playlist?.name || 'Beatport Playlist'; + + // Format tracks for the sync API + const tracks = (process.tracks || []).map(t => { + const artists = Array.isArray(t.artists) + ? (typeof t.artists[0] === 'object' ? t.artists.map(a => a.name || a) : t.artists) + : [t.artists || 'Unknown Artist']; + const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); + return { + id: t.id || '', + name: t.name || '', + artists: artists, + album: albumName, + duration_ms: t.duration_ms || 0, + popularity: t.popularity || 0 + }; + }); + + try { + const response = await fetch('/api/sync/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: syncPlaylistId, + playlist_name: playlistName, + tracks: tracks + }) + }); + + const result = await response.json(); + if (!result.success) { + showToast(`Sync failed: ${result.error || 'Unknown error'}`, 'error'); + _cleanupModalSync(playlistId); + return; + } + + _activeModalSyncs[playlistId] = { syncPlaylistId }; + _pollModalSyncProgress(playlistId, syncPlaylistId); + + } catch (err) { + console.error('Error starting playlist sync:', err); + showToast('Failed to start sync', 'error'); + _cleanupModalSync(playlistId); + } +} + +function _pollModalSyncProgress(playlistId, syncPlaylistId) { + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/api/sync/status/${syncPlaylistId}`); + if (!resp.ok) { clearInterval(pollInterval); _cleanupModalSync(playlistId, 'error'); return; } + const state = await resp.json(); + + const bar = document.getElementById(`modal-sync-bar-${playlistId}`); + const stepEl = document.getElementById(`modal-sync-step-${playlistId}`); + const matchedEl = document.getElementById(`modal-sync-matched-${playlistId}`); + const failedEl = document.getElementById(`modal-sync-failed-${playlistId}`); + + if (state.status === 'syncing' || state.status === 'starting') { + const p = state.progress || {}; + const matched = p.matched_tracks || 0; + const failed = p.failed_tracks || 0; + const total = p.total_tracks || 0; + const step = p.current_step || 'Processing'; + const currentTrack = p.current_track || ''; + const processed = matched + failed; + const percent = total > 0 ? Math.round((processed / total) * 100) : 0; + + if (bar) bar.style.width = `${percent}%`; + if (stepEl) stepEl.textContent = currentTrack ? `${step} — ${currentTrack}` : step; + if (matchedEl) matchedEl.textContent = `${matched} matched`; + if (failedEl) failedEl.textContent = `${failed} failed`; + + } else if (state.status === 'finished') { + clearInterval(pollInterval); + const p = state.progress || state.result || {}; + const matched = p.matched_tracks || 0; + const failed = p.failed_tracks || 0; + const total = p.total_tracks || 0; + const synced = p.synced_tracks || 0; + + if (bar) bar.style.width = '100%'; + if (stepEl) stepEl.textContent = `Sync complete — ${matched}/${total} matched, ${synced} synced`; + if (matchedEl) matchedEl.textContent = `${matched} matched`; + if (failedEl) failedEl.textContent = `${failed} failed`; + + const cancelBtn = document.getElementById(`modal-sync-cancel-${playlistId}`); + if (cancelBtn) cancelBtn.style.display = 'none'; + + showToast(`Server sync complete: ${matched}/${total} matched`, 'success'); + + // Re-enable sync button after a delay + setTimeout(() => _cleanupModalSync(playlistId, 'finished'), 5000); + + } else if (state.status === 'cancelled' || state.status === 'error') { + clearInterval(pollInterval); + if (stepEl) stepEl.textContent = state.status === 'cancelled' ? 'Sync cancelled' : `Sync error`; + const cancelBtn = document.getElementById(`modal-sync-cancel-${playlistId}`); + if (cancelBtn) cancelBtn.style.display = 'none'; + setTimeout(() => _cleanupModalSync(playlistId, state.status), 3000); + } + } catch (err) { + console.error('Error polling modal sync status:', err); + clearInterval(pollInterval); + _cleanupModalSync(playlistId, 'error'); + } + }, 2000); + + if (_activeModalSyncs[playlistId]) { + _activeModalSyncs[playlistId].pollInterval = pollInterval; + } +} + +async function cancelModalSync(playlistId) { + const active = _activeModalSyncs[playlistId]; + if (!active) return; + + try { + await fetch('/api/sync/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist_id: active.syncPlaylistId }) + }); + const stepEl = document.getElementById(`modal-sync-step-${playlistId}`); + if (stepEl) stepEl.textContent = 'Cancelling...'; + } catch (err) { + console.error('Error cancelling modal sync:', err); + } +} + +function _cleanupModalSync(playlistId, finalStatus) { + const active = _activeModalSyncs[playlistId]; + if (active && active.pollInterval) clearInterval(active.pollInterval); + delete _activeModalSyncs[playlistId]; + + const progressArea = document.getElementById(`modal-sync-progress-${playlistId}`); + const btn = document.getElementById(`sync-server-btn-${playlistId}`); + + if (finalStatus === 'finished') { + // Keep progress visible but hide after fade + if (progressArea) setTimeout(() => { progressArea.style.display = 'none'; }, 300); + } else { + if (progressArea) progressArea.style.display = 'none'; + } + if (btn) { btn.disabled = false; btn.textContent = 'Sync to Server'; } +} + // ── Metadata Cache Modal ──────────────────────────────────────────── let _mcacheCurrentTab = 'artist'; let _mcachePage = 0; @@ -32121,6 +32290,10 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis + ${_isBeatportPlaylistId(virtualPlaylistId) ? ` + ` : ''} @@ -32135,6 +32308,20 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis + + `; diff --git a/webui/static/style.css b/webui/static/style.css index 1e12613c..24d4000b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -7181,6 +7181,46 @@ body { .sync-history-cancel-btn:hover { background: rgba(244, 67, 54, 0.2); border-color: rgba(244, 67, 54, 0.5); } +/* Sync to Server button in download modal */ +.sync-to-server-btn { + background: rgba(30, 136, 229, 0.15) !important; color: #42a5f5 !important; + border: 1px solid rgba(30, 136, 229, 0.3) !important; +} +.sync-to-server-btn:hover { + background: rgba(30, 136, 229, 0.25) !important; border-color: rgba(30, 136, 229, 0.5) !important; +} +.sync-to-server-btn:disabled { opacity: 0.4; cursor: not-allowed; } +/* Sync progress area at bottom of download modal */ +.modal-sync-progress-area { + padding: 10px 16px; background: rgba(30, 136, 229, 0.05); + border-top: 1px solid rgba(30, 136, 229, 0.15); border-radius: 0 0 12px 12px; +} +.modal-sync-progress-bar-bg { + height: 4px; background: rgba(255, 255, 255, 0.08); border-radius: 4px; + overflow: hidden; margin-bottom: 8px; +} +.modal-sync-progress-bar-fill { + height: 100%; background: #42a5f5; border-radius: 4px; + transition: width 0.3s ease; width: 0%; +} +.modal-sync-progress-info { + display: flex; align-items: center; gap: 10px; font-size: 11px; +} +.modal-sync-step { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; color: rgba(255, 255, 255, 0.5); +} +.modal-sync-stats { display: flex; gap: 8px; flex-shrink: 0; } +.modal-sync-stats .matched { color: #4caf50; font-size: 11px; } +.modal-sync-stats .failed { color: #f44336; font-size: 11px; } +.modal-sync-cancel-btn { + font-size: 10px; font-weight: 600; color: #f44336; + background: rgba(244, 67, 54, 0.1); border: 1px solid rgba(244, 67, 54, 0.25); + border-radius: 4px; padding: 3px 8px; cursor: pointer; flex-shrink: 0; +} +.modal-sync-cancel-btn:hover { + background: rgba(244, 67, 54, 0.2); border-color: rgba(244, 67, 54, 0.5); +} .sync-history-empty { text-align: center; padding: 48px 24px; color: rgba(255, 255, 255, 0.3); font-size: 14px; }