diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index bf07d547..63d1c760 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -601,7 +601,9 @@ function renderMirroredCard(p, container) { + + `; card.addEventListener('click', () => { const st = youtubePlaylistStates[hash]; @@ -644,6 +646,101 @@ function renderMirroredCard(p, container) { } } +// ── Playlist export to ListenBrainz / JSPF (#903) ─────────────────────────── +// Export button on the mirrored-playlist card -> pick a destination -> background job +// resolves each track to a MusicBrainz recording MBID (cache/DB/file/MusicBrainz) and either +// downloads the .jspf or pushes the playlist straight to ListenBrainz, with live status on the card. +function exportMirroredPlaylist(playlistId, name) { + const existing = document.getElementById('pl-export-modal'); + if (existing) existing.remove(); + const overlay = document.createElement('div'); + overlay.id = 'pl-export-modal'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);'; + overlay.innerHTML = ` +
+
Export playlist
+
${_esc(name)} β†’ ListenBrainz
+ + +
Each track is matched to its MusicBrainz recording ID (cached β†’ library β†’ file tag β†’ MusicBrainz). Tracks without an ID can't be included β€” you'll see how many matched.
+
+
`; + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + overlay.querySelectorAll('.pl-export-choice').forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode; + overlay.remove(); + _startPlaylistExport(playlistId, mode, name); + }); + }); + document.body.appendChild(overlay); +} + +async function _startPlaylistExport(playlistId, mode, name) { + _setExportStatus(playlistId, `Starting export…`); + try { + const resp = await fetch(`/api/playlists/${playlistId}/export/listenbrainz`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }); + const data = await resp.json(); + if (!data.success || !data.job_id) { + _setExportStatus(playlistId, `Export failed to start`); + return; + } + _pollPlaylistExport(data.job_id, playlistId, mode, name); + } catch (e) { + _setExportStatus(playlistId, `Export error`); + } +} + +async function _pollPlaylistExport(jobId, playlistId, mode, name) { + try { + const resp = await fetch(`/api/playlists/export/status/${jobId}`); + const data = await resp.json(); + const job = data.job || {}; + const st = job.stats || {}; + if (job.phase === 'resolving') { + const pct = job.total ? Math.round(100 * (job.done || 0) / job.total) : 0; + _setExportStatus(playlistId, `Matching ${job.done || 0}/${job.total || 0} (${pct}%)${st.resolved != null ? ` Β· ${st.resolved} matched` : ''}`); + } else if (job.phase === 'pushing') { + _setExportStatus(playlistId, `Pushing to ListenBrainz…`); + } else if (job.phase === 'done') { + const sum = job.summary || {}; + const cov = `${sum.included || 0}/${sum.total || 0} matched${sum.skipped ? ` Β· ${sum.skipped} unmatched` : ''}`; + if (mode === 'download') { + window.location = `/api/playlists/export/download/${jobId}`; + _setExportStatus(playlistId, `Downloaded Β· ${cov}`, 8000); + } else { + const url = (job.push && job.push.playlist_url) || ''; + _setExportStatus(playlistId, `Synced to ListenBrainz Β· ${cov}` + (url ? ` view` : ''), 12000); + if (typeof showToast === 'function') showToast(`Playlist synced to ListenBrainz (${cov})`, 'success'); + } + return; + } else if (job.phase === 'error') { + _setExportStatus(playlistId, `${_esc(job.error || 'Export failed')}`, 10000); + return; + } + setTimeout(() => _pollPlaylistExport(jobId, playlistId, mode, name), 1000); + } catch (e) { + setTimeout(() => _pollPlaylistExport(jobId, playlistId, mode, name), 2000); + } +} + +function _setExportStatus(playlistId, html, autoHideMs) { + const el = document.getElementById(`export-status-${playlistId}`); + if (!el) return; + el.innerHTML = html; + el.style.display = ''; + if (autoHideMs) setTimeout(() => { if (el) el.style.display = 'none'; }, autoHideMs); +} + function updateMirroredCardPhase(urlHash, phase) { // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) const state = youtubePlaylistStates[urlHash]; diff --git a/webui/static/style.css b/webui/static/style.css index c43e4c74..20483d19 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -14541,6 +14541,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .mirrored-card-delete, .mirrored-card-clear, .mirrored-card-link, +.mirrored-card-export, .mirrored-card-rename { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); @@ -14562,11 +14563,27 @@ body.helper-mode-active #dashboard-activity-feed:hover { .mirrored-playlist-card:hover .mirrored-card-delete, .mirrored-playlist-card:hover .mirrored-card-clear, .mirrored-playlist-card:hover .mirrored-card-link, +.mirrored-playlist-card:hover .mirrored-card-export, .mirrored-playlist-card:hover .mirrored-card-rename, .mirrored-playlist-card:hover .mirrored-card-pipeline { opacity: 1; } +.mirrored-card-export:hover { + color: rgba(var(--accent-rgb), 1); + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.3); + transform: scale(1.1); +} + +/* Live export status line on the card (Matching N/M, Synced, etc.) */ +.mirrored-card-export-status { + flex-basis: 100%; + font-size: 11.5px; + margin-top: 6px; + padding-left: 2px; +} + .mirrored-card-delete:hover { color: #ff4444; background: rgba(255, 68, 68, 0.15);