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) {
Auto-Sync
βοΈ
π
+ π€
β
+
`;
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
+
+ Sync to ListenBrainz
+ Create the playlist directly on your ListenBrainz account (needs your LB token).
+
+
+ Download .jspf file
+ Save a JSPF playlist you can upload to ListenBrainz manually.
+
+
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.
+
Cancel
+
`;
+ 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);