Click 'Refresh' to load your personalized SoulSync Discovery playlists.
@@ -1967,7 +2047,15 @@
@@ -8074,6 +8184,7 @@
+
diff --git a/webui/static/core.js b/webui/static/core.js
index 48843c3e..04c52983 100644
--- a/webui/static/core.js
+++ b/webui/static/core.js
@@ -768,14 +768,14 @@ class SequentialSyncManager {
this.startTime = null;
}
- start(playlistIds) {
+ start(playlistIds, bulkConfig = null) {
if (this.isRunning) {
console.warn('Sequential sync already running');
return;
}
- // Convert playlist IDs to ordered array (maintain display order)
this.queue = Array.from(playlistIds);
+ this.bulkConfig = bulkConfig;
this.currentIndex = 0;
this.isRunning = true;
this.startTime = Date.now();
@@ -792,42 +792,27 @@ class SequentialSyncManager {
}
const playlistId = this.queue[this.currentIndex];
- const playlist = spotifyPlaylists.find(p => p.id === playlistId);
- console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`);
+ const label = this.bulkConfig?.getName(playlistId)
+ || spotifyPlaylists.find(p => p.id === playlistId)?.name
+ || playlistId;
+ console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${label}`);
this.updateUI();
try {
- // Use existing single sync function
- await startPlaylistSync(playlistId);
-
- // Wait for sync to complete by monitoring the poller
- await this.waitForSyncCompletion(playlistId);
-
+ if (this.bulkConfig?.process) {
+ await this.bulkConfig.process(playlistId);
+ } else {
+ await startPlaylistSync(playlistId);
+ await waitForSyncPollerCompletion(playlistId);
+ }
} catch (error) {
console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, error);
- showToast(`Failed to sync "${playlist?.name || playlistId}": ${error.message}`, 'error');
+ showToast(`Failed to sync "${label}": ${error.message}`, 'error');
}
- // Move to next playlist
this.currentIndex++;
- setTimeout(() => this.syncNext(), 1000); // Small delay between syncs
- }
-
- async waitForSyncCompletion(playlistId) {
- return new Promise((resolve) => {
- // Monitor the existing sync poller for completion
- const checkCompletion = () => {
- if (!activeSyncPollers[playlistId]) {
- // Poller stopped = sync completed
- resolve();
- return;
- }
- // Check again in 1 second
- setTimeout(checkCompletion, 1000);
- };
- checkCompletion();
- });
+ setTimeout(() => this.syncNext(), 1000);
}
complete() {
@@ -837,14 +822,14 @@ class SequentialSyncManager {
this.isRunning = false;
this.queue = [];
+ this.bulkConfig = null;
this.currentIndex = 0;
this.startTime = null;
- // Re-enable playlist selection
disablePlaylistSelection(false);
this.updateUI();
- updateRefreshButtonState(); // Refresh button state after completion
+ updateRefreshButtonState();
showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success');
// Hide sidebar after completion
@@ -857,14 +842,14 @@ class SequentialSyncManager {
console.log('🛑 Cancelling sequential sync');
this.isRunning = false;
this.queue = [];
+ this.bulkConfig = null;
this.currentIndex = 0;
this.startTime = null;
- // Re-enable playlist selection
disablePlaylistSelection(false);
this.updateUI();
- updateRefreshButtonState(); // Refresh button state after cancellation
+ updateRefreshButtonState();
showToast('Sequential sync cancelled', 'info');
// Hide sidebar after cancellation
@@ -872,33 +857,8 @@ class SequentialSyncManager {
}
updateUI() {
- const startSyncBtn = document.getElementById('start-sync-btn');
- const selectionInfo = document.getElementById('selection-info');
-
- if (!this.isRunning) {
- // Reset to normal state
- if (startSyncBtn) {
- startSyncBtn.textContent = 'Start Sync';
- startSyncBtn.disabled = selectedPlaylists.size === 0;
- }
- if (selectionInfo) {
- const count = selectedPlaylists.size;
- selectionInfo.textContent = count === 0
- ? 'Select playlists to sync'
- : `${count} playlist${count > 1 ? 's' : ''} selected`;
- }
- } else {
- // Show sequential sync status
- if (startSyncBtn) {
- startSyncBtn.textContent = 'Cancel Sequential Sync';
- startSyncBtn.disabled = false;
- }
- if (selectionInfo) {
- const current = this.currentIndex + 1;
- const total = this.queue.length;
- const currentPlaylist = spotifyPlaylists.find(p => p.id === this.queue[this.currentIndex]);
- selectionInfo.textContent = `Syncing ${current}/${total}: ${currentPlaylist?.name || 'Unknown'}`;
- }
+ if (typeof updateSyncActionsUI === 'function') {
+ updateSyncActionsUI();
}
}
}
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 7c79cde7..5877071f 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -817,7 +817,15 @@ const HELPER_CONTENT = {
'#start-sync-btn': {
title: 'Start Sync',
description: 'Begin downloading missing tracks from all selected playlists. Playlists are processed sequentially — each one completes before the next starts.',
- tips: ['Select playlists first using checkboxes on the cards', 'Progress bar and log update in real-time', 'Button is disabled until at least one playlist is selected'],
+ tips: ['Click playlist cards to select them, or use Select all in the Spotify header', 'Use Sync selected in the bottom bar (visible when playlists are selected)', 'Progress bar and log update in real-time when the sync sidebar is open'],
+ },
+ '.sync-playlist-select-all-btn': {
+ title: 'Select All Playlists',
+ description: 'Select every loaded playlist in the current source tab for bulk sequential sync.',
+ },
+ '.sync-playlist-start-btn': {
+ title: 'Sync Selected Playlists',
+ description: 'Start sequential sync for all selected playlists in this tab. Each playlist finishes before the next begins. Click again while running to cancel.',
},
'#sync-log-area': {
title: 'Sync Log',
@@ -2402,7 +2410,7 @@ const HELPER_TOURS = {
{ page: 'sync', selector: '.sync-tab-button[data-tab="mirrored"]', title: 'Mirrored Playlists', description: 'Every imported playlist is saved here permanently. Re-sync anytime to catch new additions, check match status, or view the Discovery Pool for unmatched tracks.' },
// Sidebar
- { page: 'sync', selector: '.sync-sidebar', title: 'Sync Controls', description: 'The command center. Select playlists with checkboxes on the left, then click "Start Sync" here. Progress bars, match counts, and logs update in real-time. That\'s the sync flow! 🎉' },
+ { page: 'sync', selector: '#sync-playlist-bulk-bar', title: 'Bulk Sync', description: 'Click Spotify playlist cards to select them (or use Select all), then Sync selected in the bar at the bottom. Playlists sync one after another. Progress and logs appear in the sidebar on wide screens when sync is running.' },
]
},
// 'artists-browse' tour retired — the Artists sidebar entry was replaced by the
diff --git a/webui/static/style.css b/webui/static/style.css
index a4f1bba1..6cc79f2e 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -15707,6 +15707,136 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #ffffff;
}
+.playlist-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.playlist-scroll-container.selection-disabled .playlist-card,
+.playlist-scroll-container.selection-disabled .youtube-playlist-card,
+.playlist-scroll-container.selection-disabled .tidal-playlist-card,
+.playlist-scroll-container.selection-disabled .qobuz-playlist-card,
+.playlist-scroll-container.selection-disabled .deezer-playlist-card,
+.playlist-scroll-container.selection-disabled .spotify-public-card,
+.playlist-scroll-container.selection-disabled .itunes-link-card,
+.playlist-scroll-container.selection-disabled .listenbrainz-playlist-card,
+.playlist-scroll-container.selection-disabled .lastfm-playlist-card,
+.playlist-scroll-container.selection-disabled .soulsync-discovery-playlist-card {
+ pointer-events: none;
+ opacity: 0.65;
+}
+
+.playlist-scroll-container.selection-disabled .playlist-card .playlist-card-actions button,
+.playlist-scroll-container.selection-disabled .youtube-playlist-card .playlist-card-action-btn {
+ pointer-events: auto;
+}
+
+.playlist-bulk-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 0 10px 10px;
+}
+
+.playlist-header--listenbrainz {
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.playlist-header--listenbrainz .listenbrainz-sub-tabs {
+ flex-basis: 100%;
+}
+
+.youtube-playlist-card.selected,
+.tidal-playlist-card.selected,
+.qobuz-playlist-card.selected,
+.deezer-playlist-card.selected,
+.spotify-public-card.selected,
+.itunes-link-card.selected,
+.listenbrainz-playlist-card.selected,
+.lastfm-playlist-card.selected,
+.soulsync-discovery-playlist-card.selected,
+[id^="beatport-card-"].selected {
+ border-color: rgba(var(--accent-rgb), 0.3);
+ background: rgba(var(--accent-rgb), 0.04);
+}
+
+.sync-playlist-bulk-bar {
+ position: fixed;
+ bottom: -80px;
+ left: 240px;
+ right: 0;
+ height: 60px;
+ background: linear-gradient(135deg, rgba(30, 30, 30, 0.98), rgba(20, 20, 20, 0.98));
+ backdrop-filter: blur(12px);
+ border-top: 1px solid rgba(var(--accent-rgb), 0.25);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 160px 0 24px;
+ z-index: 99990;
+ transition: bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4);
+ gap: 16px;
+}
+
+.sync-playlist-bulk-bar.visible {
+ bottom: 0;
+}
+
+.sync-playlist-start-btn--ready:not(:disabled) {
+ box-shadow: 0 0 16px rgba(var(--accent-rgb), 0.35);
+}
+
+.sync-playlist-bulk-bar-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.sync-playlist-bulk-count {
+ font-size: 14px;
+ font-weight: 600;
+ color: rgb(var(--accent-light-rgb));
+}
+
+.sync-playlist-bulk-label {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.65);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sync-playlist-bulk-bar-actions {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+@media (max-width: 900px) {
+ .sync-playlist-bulk-bar {
+ left: 0;
+ flex-direction: column;
+ height: auto;
+ padding: 12px 16px 72px;
+ bottom: -120px;
+ }
+
+ .sync-playlist-bulk-bar.visible {
+ bottom: 0;
+ }
+
+ .sync-playlist-bulk-bar-actions {
+ width: 100%;
+ justify-content: flex-end;
+ }
+}
+
.refresh-button {
background: rgb(var(--accent-rgb));
border: none;
diff --git a/webui/static/sync-bulk-selection.js b/webui/static/sync-bulk-selection.js
new file mode 100644
index 00000000..a223ef14
--- /dev/null
+++ b/webui/static/sync-bulk-selection.js
@@ -0,0 +1,902 @@
+// Bulk playlist selection + sequential processing across Sync page sources.
+
+let activeSyncSelectionSource = null;
+
+const SYNC_TERMINAL_PHASES = ['sync_complete', 'downloading', 'download_complete'];
+
+function getSyncSourceKeyFromTab(tabId) {
+ const map = {
+ spotify: 'spotify',
+ tidal: 'tidal',
+ deezer: 'deezer',
+ 'deezer-link': 'deezer-link',
+ qobuz: 'qobuz',
+ youtube: 'youtube',
+ 'spotify-public': 'spotify-public',
+ 'itunes-link': 'itunes-link',
+ beatport: 'beatport',
+ 'listenbrainz-sync': 'listenbrainz-sync',
+ 'lastfm-sync': 'lastfm-sync',
+ 'soulsync-discovery-sync': 'soulsync-discovery-sync',
+ };
+ return map[tabId] || null;
+}
+
+function onSyncTabChanged(tabId) {
+ selectedPlaylists.clear();
+ activeSyncSelectionSource = getSyncSourceKeyFromTab(tabId);
+ updateSyncActionsUI();
+}
+
+function getActiveSyncBulkConfig() {
+ if (!activeSyncSelectionSource) return null;
+ return SYNC_BULK_SOURCES[activeSyncSelectionSource] || null;
+}
+
+function isSyncBulkTabActive() {
+ const config = getActiveSyncBulkConfig();
+ if (!config) return false;
+ const tab = document.getElementById(config.tabContentId);
+ return !!(tab && tab.classList.contains('active'));
+}
+
+function getCardSelectId(card, config) {
+ if (!card || !config) return null;
+ if (config.idFromCard) return config.idFromCard(card);
+ if (config.idAttr === 'playlistId') return card.dataset.playlistId;
+ if (config.idAttr === 'urlHash') return card.dataset.urlHash;
+ if (config.idAttr === 'lbMbid') return card.dataset.lbMbid;
+ if (config.idAttr === 'ssdId') return card.dataset.ssdId;
+ if (config.idAttr === 'chartHash') return card.dataset.chartHash;
+ return card.dataset.syncSelectId || null;
+}
+
+function applySyncPlaylistSelectionToCards(sourceKey) {
+ const config = SYNC_BULK_SOURCES[sourceKey];
+ if (!config) return;
+ document.querySelectorAll(config.cardQuery).forEach(card => {
+ const id = getCardSelectId(card, config);
+ if (id) card.classList.toggle('selected', selectedPlaylists.has(id));
+ });
+}
+
+function toggleSyncPlaylistSelection(event, sourceKey, playlistId, cardEl) {
+ const config = SYNC_BULK_SOURCES[sourceKey];
+ if (!config) return;
+
+ const container = document.getElementById(config.containerId);
+ if (container?.classList.contains('selection-disabled')) return;
+
+ const card = cardEl || event?.currentTarget;
+ if (!card || !playlistId) return;
+
+ if (event?.target?.closest?.('button') && !event.target.closest('.playlist-card-action-btn')) {
+ return;
+ }
+ if (event?.target?.tagName === 'BUTTON' && event.target.classList.contains('playlist-card-action-btn')) {
+ return;
+ }
+
+ const isSelected = !card.classList.contains('selected');
+ card.classList.toggle('selected', isSelected);
+ if (isSelected) {
+ selectedPlaylists.add(playlistId);
+ } else {
+ selectedPlaylists.delete(playlistId);
+ }
+ updateSyncActionsUI();
+}
+
+function selectAllSyncPlaylists(sourceKey) {
+ const config = SYNC_BULK_SOURCES[sourceKey];
+ if (!config || (sequentialSyncManager && sequentialSyncManager.isRunning)) return;
+
+ const ids = config.getIds();
+ if (!ids.length) return;
+
+ selectedPlaylists.clear();
+ ids.forEach(id => selectedPlaylists.add(id));
+ applySyncPlaylistSelectionToCards(sourceKey);
+ updateSyncActionsUI();
+}
+
+function clearSyncPlaylistSelection() {
+ if (sequentialSyncManager && sequentialSyncManager.isRunning) return;
+
+ const sourceKey = activeSyncSelectionSource;
+ selectedPlaylists.clear();
+ if (sourceKey) applySyncPlaylistSelectionToCards(sourceKey);
+ updateSyncActionsUI();
+}
+
+function waitUntilSyncStep(predicate, timeoutMs = 600000, intervalMs = 1000) {
+ return new Promise((resolve, reject) => {
+ const started = Date.now();
+ const tick = () => {
+ if (predicate()) return resolve();
+ if (sequentialSyncManager && !sequentialSyncManager.isRunning) {
+ return reject(new Error('Cancelled'));
+ }
+ if (Date.now() - started > timeoutMs) {
+ return reject(new Error('Timed out waiting for playlist step'));
+ }
+ setTimeout(tick, intervalMs);
+ };
+ tick();
+ });
+}
+
+function isTerminalSyncPhase(phase) {
+ return SYNC_TERMINAL_PHASES.includes(phase);
+}
+
+async function bulkAutoAdvancePhaseSource(sourceKey, playlistId) {
+ const handlers = {
+ tidal: () => bulkAutoAdvanceTidal(playlistId),
+ qobuz: () => bulkAutoAdvanceQobuz(playlistId),
+ 'deezer-link': () => bulkAutoAdvanceDeezerLink(playlistId),
+ youtube: () => bulkAutoAdvanceYouTube(playlistId),
+ 'spotify-public': () => bulkAutoAdvanceSpotifyPublic(playlistId),
+ 'itunes-link': () => bulkAutoAdvanceITunesLink(playlistId),
+ beatport: () => bulkAutoAdvanceBeatport(playlistId),
+ 'listenbrainz-sync': () => bulkAutoAdvanceListenBrainz(playlistId),
+ 'lastfm-sync': () => bulkAutoAdvanceListenBrainz(playlistId),
+ 'soulsync-discovery-sync': () => bulkAutoAdvanceSoulsyncDiscovery(playlistId),
+ };
+ const fn = handlers[sourceKey];
+ if (!fn) throw new Error(`Bulk sync not supported for source: ${sourceKey}`);
+ await fn();
+}
+
+async function bulkAutoAdvanceTidal(playlistId) {
+ const urlHash = `tidal_${playlistId}`;
+ for (let step = 0; step < 12; step++) {
+ const state = tidalPlaylistStates[playlistId];
+ if (!state) return;
+ const phase = state.phase || 'fresh';
+
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh') {
+ if (!state.playlist?.tracks?.length) {
+ const resp = await fetch(`/api/tidal/playlist/${playlistId}`);
+ if (resp.ok) {
+ const fullData = await resp.json();
+ if (fullData.tracks?.length) {
+ state.playlist.tracks = fullData.tracks.map(t => ({
+ id: t.id, name: t.name, artists: t.artists || [],
+ album: t.album || '', duration_ms: t.duration_ms || 0,
+ track_number: t.track_number || 0,
+ }));
+ }
+ }
+ }
+ const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, { method: 'POST' });
+ const result = await response.json();
+ if (result.error) throw new Error(result.error);
+ state.phase = 'discovering';
+ updateTidalCardPhase(playlistId, 'discovering');
+ startTidalDiscoveryPolling(urlHash, playlistId);
+ await waitUntilSyncStep(() => {
+ const p = tidalPlaylistStates[playlistId]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovering') {
+ await waitUntilSyncStep(() => {
+ const p = tidalPlaylistStates[playlistId]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovered') {
+ await startTidalPlaylistSync(urlHash);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(tidalPlaylistStates[playlistId]?.phase));
+ return;
+ }
+
+ if (phase === 'syncing') {
+ await waitUntilSyncStep(() => isTerminalSyncPhase(tidalPlaylistStates[playlistId]?.phase));
+ return;
+ }
+
+ return;
+ }
+}
+
+async function bulkAutoAdvanceQobuz(playlistId) {
+ const urlHash = `qobuz_${playlistId}`;
+ for (let step = 0; step < 12; step++) {
+ const state = qobuzPlaylistStates[playlistId];
+ if (!state) return;
+ const phase = state.phase || 'fresh';
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh') {
+ const response = await fetch(`/api/qobuz/discovery/start/${playlistId}`, { method: 'POST' });
+ const result = await response.json();
+ if (result.error) throw new Error(result.error);
+ state.phase = 'discovering';
+ updateQobuzCardPhase(playlistId, 'discovering');
+ startQobuzDiscoveryPolling(urlHash, playlistId);
+ await waitUntilSyncStep(() => {
+ const p = qobuzPlaylistStates[playlistId]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovering') {
+ await waitUntilSyncStep(() => {
+ const p = qobuzPlaylistStates[playlistId]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovered') {
+ await startQobuzPlaylistSync(urlHash);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(qobuzPlaylistStates[playlistId]?.phase));
+ return;
+ }
+
+ if (phase === 'syncing') {
+ await waitUntilSyncStep(() => isTerminalSyncPhase(qobuzPlaylistStates[playlistId]?.phase));
+ return;
+ }
+
+ return;
+ }
+}
+
+async function bulkAutoAdvanceDeezerLink(playlistId) {
+ const urlHash = `deezer_${playlistId}`;
+ for (let step = 0; step < 12; step++) {
+ const state = deezerPlaylistStates[playlistId];
+ if (!state) return;
+ const phase = state.phase || 'fresh';
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh') {
+ const response = await fetch(`/api/deezer/discovery/start/${playlistId}`, { method: 'POST' });
+ const result = await response.json();
+ if (result.error) throw new Error(result.error);
+ state.phase = 'discovering';
+ updateDeezerCardPhase(playlistId, 'discovering');
+ startDeezerDiscoveryPolling(urlHash, playlistId);
+ await waitUntilSyncStep(() => {
+ const p = deezerPlaylistStates[playlistId]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovering') {
+ await waitUntilSyncStep(() => {
+ const p = deezerPlaylistStates[playlistId]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovered') {
+ await startDeezerPlaylistSync(urlHash);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(deezerPlaylistStates[playlistId]?.phase));
+ return;
+ }
+
+ if (phase === 'syncing') {
+ await waitUntilSyncStep(() => isTerminalSyncPhase(deezerPlaylistStates[playlistId]?.phase));
+ return;
+ }
+
+ return;
+ }
+}
+
+async function bulkAutoAdvanceYouTube(urlHash) {
+ for (let step = 0; step < 12; step++) {
+ const state = youtubePlaylistStates[urlHash];
+ if (!state) return;
+ const phase = state.phase || 'fresh';
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh') {
+ const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, { method: 'POST' });
+ const result = await response.json();
+ if (result.error) throw new Error(result.error);
+ state.phase = 'discovering';
+ updateYouTubeCardPhase(urlHash, 'discovering');
+ startYouTubeDiscoveryPolling(urlHash);
+ await waitUntilSyncStep(() => {
+ const p = youtubePlaylistStates[urlHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovering') {
+ await waitUntilSyncStep(() => {
+ const p = youtubePlaylistStates[urlHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovered') {
+ await startYouTubePlaylistSync(urlHash);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(youtubePlaylistStates[urlHash]?.phase));
+ return;
+ }
+
+ if (phase === 'syncing') {
+ await waitUntilSyncStep(() => isTerminalSyncPhase(youtubePlaylistStates[urlHash]?.phase));
+ return;
+ }
+
+ return;
+ }
+}
+
+function mirrorYoutubeStateKey(fakeUrlHash, urlHash) {
+ if (youtubePlaylistStates[fakeUrlHash] && !youtubePlaylistStates[urlHash]) {
+ youtubePlaylistStates[urlHash] = { ...youtubePlaylistStates[fakeUrlHash] };
+ }
+}
+
+async function bulkAutoAdvanceSpotifyPublic(urlHash) {
+ const fakeUrlHash = `spotifypublic_${urlHash}`;
+ for (let step = 0; step < 12; step++) {
+ const state = spotifyPublicPlaylistStates[urlHash];
+ if (!state) return;
+ const phase = state.phase || 'fresh';
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh') {
+ const response = await fetch(`/api/spotify-public/discovery/start/${urlHash}`, { method: 'POST' });
+ const result = await response.json();
+ if (result.error) throw new Error(result.error);
+ state.phase = 'discovering';
+ updateSpotifyPublicCardPhase(urlHash, 'discovering');
+ if (youtubePlaylistStates[fakeUrlHash]) youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
+ startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash);
+ await waitUntilSyncStep(() => {
+ const p = spotifyPublicPlaylistStates[urlHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovering') {
+ await waitUntilSyncStep(() => {
+ const p = spotifyPublicPlaylistStates[urlHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovered') {
+ mirrorYoutubeStateKey(fakeUrlHash, urlHash);
+ await startSpotifyPublicPlaylistSync(urlHash);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(spotifyPublicPlaylistStates[urlHash]?.phase));
+ return;
+ }
+
+ if (phase === 'syncing') {
+ await waitUntilSyncStep(() => isTerminalSyncPhase(spotifyPublicPlaylistStates[urlHash]?.phase));
+ return;
+ }
+
+ return;
+ }
+}
+
+async function bulkAutoAdvanceITunesLink(urlHash) {
+ const fakeUrlHash = `ituneslink_${urlHash}`;
+ for (let step = 0; step < 12; step++) {
+ const state = itunesLinkPlaylistStates[urlHash];
+ if (!state) return;
+ const phase = state.phase || 'fresh';
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh') {
+ const response = await fetch(`/api/itunes-link/discovery/start/${urlHash}`, { method: 'POST' });
+ const result = await response.json();
+ if (result.error) throw new Error(result.error);
+ state.phase = 'discovering';
+ updateITunesLinkCardPhase(urlHash, 'discovering');
+ if (youtubePlaylistStates[fakeUrlHash]) youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
+ startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash);
+ await waitUntilSyncStep(() => {
+ const p = itunesLinkPlaylistStates[urlHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovering') {
+ await waitUntilSyncStep(() => {
+ const p = itunesLinkPlaylistStates[urlHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovered') {
+ mirrorYoutubeStateKey(fakeUrlHash, urlHash);
+ await startITunesLinkPlaylistSync(urlHash);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(itunesLinkPlaylistStates[urlHash]?.phase));
+ return;
+ }
+
+ if (phase === 'syncing') {
+ await waitUntilSyncStep(() => isTerminalSyncPhase(itunesLinkPlaylistStates[urlHash]?.phase));
+ return;
+ }
+
+ return;
+ }
+}
+
+async function bulkAutoAdvanceBeatport(chartHash) {
+ for (let step = 0; step < 12; step++) {
+ const state = beatportChartStates[chartHash];
+ if (!state) return;
+ const phase = state.phase || 'fresh';
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh') {
+ const response = await fetch(`/api/beatport/discovery/start/${chartHash}`, { method: 'POST' });
+ const result = await response.json();
+ if (result.error) throw new Error(result.error);
+ state.phase = 'discovering';
+ updateBeatportCardPhase(chartHash, 'discovering');
+ startBeatportDiscoveryPolling(chartHash);
+ await waitUntilSyncStep(() => {
+ const p = beatportChartStates[chartHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovering') {
+ await waitUntilSyncStep(() => {
+ const p = beatportChartStates[chartHash]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ continue;
+ }
+
+ if (phase === 'discovered') {
+ await startBeatportPlaylistSync(chartHash);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(beatportChartStates[chartHash]?.phase));
+ return;
+ }
+
+ if (phase === 'syncing') {
+ await waitUntilSyncStep(() => isTerminalSyncPhase(beatportChartStates[chartHash]?.phase));
+ return;
+ }
+
+ return;
+ }
+}
+
+async function ensureListenBrainzPlaylistState(playlistMbid, playlistTitle) {
+ if (listenbrainzPlaylistStates[playlistMbid]?.playlist?.tracks?.length) {
+ return listenbrainzPlaylistStates[playlistMbid];
+ }
+
+ if (typeof listenbrainzTracksCache === 'undefined') {
+ window.listenbrainzTracksCache = {};
+ }
+ let tracks = listenbrainzTracksCache[playlistMbid];
+ if (!tracks?.length) {
+ const resp = await fetch(`/api/discover/listenbrainz/playlist/${encodeURIComponent(playlistMbid)}`);
+ if (!resp.ok) throw new Error(`Failed to load playlist tracks (${resp.status})`);
+ const data = await resp.json();
+ tracks = (data.tracks || []).map(t => ({
+ track_name: t.track_name || '',
+ artist_name: t.artist_name || '',
+ album_name: t.album_name || '',
+ duration_ms: t.duration_ms || 0,
+ mbid: t.recording_mbid || t.mbid || '',
+ release_mbid: t.release_mbid || '',
+ album_cover_url: t.album_cover_url || '',
+ }));
+ listenbrainzTracksCache[playlistMbid] = tracks;
+ }
+ if (!tracks.length) throw new Error('Playlist has no tracks');
+
+ const title = playlistTitle || 'ListenBrainz playlist';
+ listenbrainzPlaylistStates[playlistMbid] = {
+ phase: 'fresh',
+ playlist: {
+ name: title,
+ tracks: tracks.map(track => ({ ...track })),
+ description: `${tracks.length} tracks from ${title}`,
+ source: 'listenbrainz',
+ },
+ is_listenbrainz_playlist: true,
+ playlist_mbid: playlistMbid,
+ discovery_results: [],
+ discoveryResults: [],
+ discovery_progress: 0,
+ discoveryProgress: 0,
+ spotify_matches: 0,
+ spotifyMatches: 0,
+ spotify_total: tracks.length,
+ spotifyTotal: tracks.length,
+ };
+ return listenbrainzPlaylistStates[playlistMbid];
+}
+
+async function bulkAutoAdvanceListenBrainz(playlistMbid) {
+ const card = document.querySelector(`#listenbrainz-sync-card-${CSS.escape(playlistMbid)}, #lastfm-sync-card-${CSS.escape(playlistMbid)}`);
+ const title = card?.dataset.lbTitle || 'Playlist';
+ await ensureListenBrainzPlaylistState(playlistMbid, title);
+
+ const phase = listenbrainzPlaylistStates[playlistMbid]?.phase || 'fresh';
+ if (isTerminalSyncPhase(phase)) return;
+
+ if (phase === 'fresh' || phase === 'discovering') {
+ if (typeof startListenBrainzDiscovery === 'function') {
+ await startListenBrainzDiscovery(playlistMbid);
+ }
+ await waitUntilSyncStep(() => {
+ const p = listenbrainzPlaylistStates[playlistMbid]?.phase;
+ return p && p !== 'fresh' && p !== 'discovering';
+ });
+ }
+
+ const afterDiscover = listenbrainzPlaylistStates[playlistMbid]?.phase;
+ if (afterDiscover === 'discovered' && typeof startListenBrainzPlaylistSync === 'function') {
+ await startListenBrainzPlaylistSync(playlistMbid);
+ await waitUntilSyncStep(() => isTerminalSyncPhase(listenbrainzPlaylistStates[playlistMbid]?.phase));
+ }
+}
+
+async function bulkAutoAdvanceSoulsyncDiscovery(syntheticId) {
+ const card = document.getElementById(`soulsync-discovery-sync-card-${syntheticId}`);
+ if (!card || typeof handleSoulsyncDiscoverySyncCardClick !== 'function') return;
+ const kind = card.dataset.ssdKind;
+ const variant = card.dataset.ssdVariant;
+ const name = card.dataset.ssdName;
+ await handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card);
+}
+
+async function bulkSyncDeezerArlPlaylist(arlPlaylistId) {
+ const rawId = arlPlaylistId.replace(/^deezer_arl_/, '');
+ const playlistMeta = deezerArlPlaylists.find(p => String(p.id) === String(rawId));
+ const cacheStale = typeof playlistTrackCacheIsStale === 'function'
+ && playlistTrackCacheIsStale(arlPlaylistId, playlistMeta);
+ if (!playlistTrackCache[arlPlaylistId] || cacheStale) {
+ if (typeof fetchAndCacheDeezerArlPlaylistTracks === 'function') {
+ await fetchAndCacheDeezerArlPlaylistTracks(arlPlaylistId, rawId);
+ } else {
+ const response = await fetch(`/api/deezer/arl-playlist/${rawId}`);
+ const data = await response.json();
+ if (data.error) throw new Error(data.error);
+ playlistTrackCache[arlPlaylistId] = data.tracks;
+ }
+ }
+ await startPlaylistSync(arlPlaylistId);
+ await waitForSyncPollerCompletion(arlPlaylistId);
+}
+
+async function waitForSyncPollerCompletion(playlistId) {
+ return new Promise((resolve) => {
+ const checkCompletion = () => {
+ if (!activeSyncPollers[playlistId]) {
+ resolve();
+ return;
+ }
+ if (sequentialSyncManager && !sequentialSyncManager.isRunning) {
+ resolve();
+ return;
+ }
+ setTimeout(checkCompletion, 1000);
+ };
+ checkCompletion();
+ });
+}
+
+function wirePhaseSyncCards(sourceKey, containerSelector, cardSelector, getIdFromCard, onActionClick) {
+ const container = document.querySelector(containerSelector);
+ if (!container) return;
+
+ container.querySelectorAll(cardSelector).forEach(card => {
+ const id = getIdFromCard(card);
+ if (!id) return;
+
+ card.dataset.syncSelectId = id;
+
+ const btn = card.querySelector('.playlist-card-action-btn');
+ if (btn && !btn.dataset.syncBulkWired) {
+ btn.dataset.syncBulkWired = '1';
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (typeof onActionClick === 'function') onActionClick(id);
+ });
+ }
+
+ if (!card.dataset.syncBulkWired) {
+ card.dataset.syncBulkWired = '1';
+ card.addEventListener('click', (e) => {
+ if (e.target.closest('.playlist-card-action-btn')) return;
+ toggleSyncPlaylistSelection(e, sourceKey, id, card);
+ });
+ }
+ });
+
+ applySyncPlaylistSelectionToCards(sourceKey);
+}
+
+function wirePlaylistCardSelection(sourceKey, containerId) {
+ const config = SYNC_BULK_SOURCES[sourceKey];
+ if (!config) return;
+ const container = document.getElementById(containerId);
+ if (!container) return;
+
+ container.querySelectorAll('.playlist-card[data-playlist-id]').forEach(card => {
+ const id = card.dataset.playlistId;
+ if (!id) return;
+
+ if (!card.dataset.syncBulkWired) {
+ card.dataset.syncBulkWired = '1';
+ card.addEventListener('click', (e) => {
+ if (e.target.tagName === 'BUTTON') return;
+ toggleSyncPlaylistSelection(e, sourceKey, id, card);
+ });
+ }
+ });
+
+ applySyncPlaylistSelectionToCards(sourceKey);
+}
+
+const SYNC_BULK_SOURCES = {
+ spotify: {
+ tabContentId: 'spotify-tab-content',
+ containerId: 'spotify-playlist-container',
+ cardQuery: '#spotify-playlist-container .playlist-card[data-playlist-id]',
+ idAttr: 'playlistId',
+ getIds: () => spotifyPlaylists.map(p => p.id),
+ getName: (id) => spotifyPlaylists.find(p => p.id === id)?.name || id,
+ process: async (id) => {
+ await startPlaylistSync(id);
+ await waitForSyncPollerCompletion(id);
+ },
+ },
+ deezer: {
+ tabContentId: 'deezer-tab-content',
+ containerId: 'deezer-arl-playlist-container',
+ cardQuery: '#deezer-arl-playlist-container .playlist-card[data-playlist-id]',
+ idAttr: 'playlistId',
+ getIds: () => deezerArlPlaylists.map(p => `deezer_arl_${p.id}`),
+ getName: (id) => {
+ const raw = id.replace(/^deezer_arl_/, '');
+ return deezerArlPlaylists.find(p => String(p.id) === String(raw))?.name || id;
+ },
+ process: (id) => bulkSyncDeezerArlPlaylist(id),
+ },
+ tidal: {
+ tabContentId: 'tidal-tab-content',
+ containerId: 'tidal-playlist-container',
+ cardQuery: '#tidal-playlist-container .tidal-playlist-card',
+ idFromCard: (card) => card.id.replace(/^tidal-card-/, ''),
+ getIds: () => tidalPlaylists.map(p => p.id),
+ getName: (id) => tidalPlaylistStates[id]?.playlist?.name || id,
+ process: (id) => bulkAutoAdvancePhaseSource('tidal', id),
+ },
+ qobuz: {
+ tabContentId: 'qobuz-tab-content',
+ containerId: 'qobuz-playlist-container',
+ cardQuery: '#qobuz-playlist-container .qobuz-playlist-card',
+ idFromCard: (card) => card.id.replace(/^qobuz-card-/, ''),
+ getIds: () => qobuzPlaylists.map(p => p.id),
+ getName: (id) => qobuzPlaylistStates[id]?.playlist?.name || id,
+ process: (id) => bulkAutoAdvancePhaseSource('qobuz', id),
+ },
+ 'deezer-link': {
+ tabContentId: 'deezer-link-tab-content',
+ containerId: 'deezer-playlist-container',
+ cardQuery: '#deezer-playlist-container .deezer-playlist-card',
+ idFromCard: (card) => card.id.replace(/^deezer-card-/, ''),
+ getIds: () => deezerPlaylists.map(p => p.id),
+ getName: (id) => deezerPlaylistStates[id]?.playlist?.name || id,
+ process: (id) => bulkAutoAdvancePhaseSource('deezer-link', id),
+ },
+ youtube: {
+ tabContentId: 'youtube-tab-content',
+ containerId: 'youtube-playlist-container',
+ cardQuery: '#youtube-playlist-container .youtube-playlist-card[id^="youtube-card-"]',
+ idFromCard: (card) => card.id.replace(/^youtube-card-/, ''),
+ getIds: () => Object.keys(youtubePlaylistStates),
+ getName: (id) => youtubePlaylistStates[id]?.playlist?.name || id,
+ process: (id) => bulkAutoAdvancePhaseSource('youtube', id),
+ },
+ 'spotify-public': {
+ tabContentId: 'spotify-public-tab-content',
+ containerId: 'spotify-public-playlist-container',
+ cardQuery: '#spotify-public-playlist-container .spotify-public-card',
+ idFromCard: (card) => card.id.replace(/^spotify-public-card-/, ''),
+ getIds: () => spotifyPublicPlaylists.map(p => p.url_hash),
+ getName: (id) => spotifyPublicPlaylistStates[id]?.playlist?.name || id,
+ process: (id) => bulkAutoAdvancePhaseSource('spotify-public', id),
+ },
+ 'itunes-link': {
+ tabContentId: 'itunes-link-tab-content',
+ containerId: 'itunes-link-playlist-container',
+ cardQuery: '#itunes-link-playlist-container .itunes-link-card',
+ idFromCard: (card) => card.id.replace(/^itunes-link-card-/, ''),
+ getIds: () => itunesLinkPlaylists.map(p => p.url_hash),
+ getName: (id) => itunesLinkPlaylistStates[id]?.playlist?.name || id,
+ process: (id) => bulkAutoAdvancePhaseSource('itunes-link', id),
+ },
+ beatport: {
+ tabContentId: 'beatport-tab-content',
+ containerId: 'beatport-playlist-container',
+ cardQuery: '#youtube-playlist-container [id^="beatport-card-"], #beatport-playlist-container [id^="beatport-card-"]',
+ idFromCard: (card) => card.id.replace(/^beatport-card-/, ''),
+ getIds: () => Object.keys(beatportChartStates),
+ getName: (id) => beatportChartStates[id]?.chart?.name || beatportChartStates[id]?.name || id,
+ process: (id) => bulkAutoAdvancePhaseSource('beatport', id),
+ },
+ 'listenbrainz-sync': {
+ tabContentId: 'listenbrainz-sync-tab-content',
+ containerId: 'listenbrainz-sync-playlist-container',
+ cardQuery: '#listenbrainz-sync-playlist-container .listenbrainz-playlist-card',
+ idAttr: 'lbMbid',
+ getIds: () => {
+ const ids = [];
+ document.querySelectorAll('#listenbrainz-sync-playlist-container .listenbrainz-playlist-card').forEach(card => {
+ if (card.dataset.lbMbid) ids.push(card.dataset.lbMbid);
+ });
+ return ids;
+ },
+ getName: (id) => {
+ const card = document.querySelector(`#listenbrainz-sync-card-${CSS.escape(id)}`);
+ return card?.dataset.lbTitle || id;
+ },
+ process: (id) => bulkAutoAdvancePhaseSource('listenbrainz-sync', id),
+ },
+ 'lastfm-sync': {
+ tabContentId: 'lastfm-sync-tab-content',
+ containerId: 'lastfm-sync-playlist-container',
+ cardQuery: '#lastfm-sync-playlist-container .lastfm-playlist-card',
+ idAttr: 'lbMbid',
+ getIds: () => {
+ const ids = [];
+ document.querySelectorAll('#lastfm-sync-playlist-container .lastfm-playlist-card').forEach(card => {
+ if (card.dataset.lbMbid) ids.push(card.dataset.lbMbid);
+ });
+ return ids;
+ },
+ getName: (id) => {
+ const card = document.querySelector(`#lastfm-sync-card-${CSS.escape(id)}`);
+ return card?.dataset.lbTitle || id;
+ },
+ process: (id) => bulkAutoAdvancePhaseSource('lastfm-sync', id),
+ },
+ 'soulsync-discovery-sync': {
+ tabContentId: 'soulsync-discovery-sync-tab-content',
+ containerId: 'soulsync-discovery-sync-playlist-container',
+ cardQuery: '#soulsync-discovery-sync-playlist-container .soulsync-discovery-playlist-card',
+ idAttr: 'ssdId',
+ getIds: () => {
+ const ids = [];
+ document.querySelectorAll('#soulsync-discovery-sync-playlist-container .soulsync-discovery-playlist-card').forEach(card => {
+ if (card.dataset.ssdId) ids.push(card.dataset.ssdId);
+ });
+ return ids;
+ },
+ getName: (id) => {
+ const card = document.getElementById(`soulsync-discovery-sync-card-${id}`);
+ return card?.dataset.ssdName || id;
+ },
+ process: (id) => bulkAutoAdvancePhaseSource('soulsync-discovery-sync', id),
+ },
+};
+
+function updateSyncActionsUI() {
+ const count = selectedPlaylists.size;
+ const isRunning = !!(sequentialSyncManager && sequentialSyncManager.isRunning);
+ const bulkTabActive = isSyncBulkTabActive();
+ const config = getActiveSyncBulkConfig();
+
+ const selectionInfo = document.getElementById('selection-info');
+ const startSyncBtn = document.getElementById('start-sync-btn');
+ const bulkBar = document.getElementById('sync-playlist-bulk-bar');
+ const bulkCount = document.getElementById('sync-playlist-bulk-count');
+ const bulkLabel = document.getElementById('sync-playlist-bulk-label');
+ let statusText;
+ let syncBtnLabel;
+ let syncEnabled = count > 0 && !!config;
+
+ if (isRunning && sequentialSyncManager.bulkConfig) {
+ const current = sequentialSyncManager.currentIndex + 1;
+ const total = sequentialSyncManager.queue.length;
+ const currentId = sequentialSyncManager.queue[sequentialSyncManager.currentIndex];
+ const name = sequentialSyncManager.bulkConfig.getName(currentId);
+ statusText = `Syncing ${current}/${total}: ${name || 'Unknown'}`;
+ syncBtnLabel = 'Cancel sync';
+ syncEnabled = true;
+ } else if (!config) {
+ statusText = 'Select playlists to sync';
+ syncBtnLabel = 'Sync selected';
+ syncEnabled = false;
+ } else {
+ statusText = count === 0
+ ? 'Select playlists to sync'
+ : `${count} playlist${count > 1 ? 's' : ''} selected`;
+ syncBtnLabel = 'Sync selected';
+ }
+
+ if (selectionInfo) selectionInfo.textContent = statusText;
+ if (startSyncBtn) {
+ startSyncBtn.textContent = isRunning ? 'Cancel Sequential Sync' : 'Start Sync';
+ startSyncBtn.disabled = !syncEnabled;
+ }
+ document.querySelectorAll('.sync-playlist-start-btn').forEach(btn => {
+ const inActiveTab = btn.closest('.sync-tab-content.active');
+ btn.textContent = isRunning && inActiveTab ? syncBtnLabel : 'Sync selected';
+ btn.disabled = !(inActiveTab && syncEnabled);
+ btn.classList.toggle('sync-playlist-start-btn--ready', !!(inActiveTab && count > 0 && !isRunning));
+ });
+ if (bulkCount) bulkCount.textContent = isRunning ? '' : String(count);
+ if (bulkLabel) {
+ bulkLabel.textContent = isRunning ? statusText : `playlist${count === 1 ? '' : 's'} selected`;
+ }
+ if (bulkBar) {
+ const showBar = bulkTabActive && !!config && (count > 0 || isRunning);
+ bulkBar.hidden = !showBar;
+ bulkBar.classList.toggle('visible', showBar);
+ }
+
+ document.querySelectorAll('.sync-playlist-select-all-btn').forEach(btn => {
+ const sourceKey = btn.dataset.syncSource;
+ const sourceConfig = SYNC_BULK_SOURCES[sourceKey];
+ const tab = sourceConfig && document.getElementById(sourceConfig.tabContentId);
+ const tabActive = tab && tab.classList.contains('active');
+ btn.disabled = !tabActive || isRunning || !sourceConfig?.getIds()?.length;
+ });
+ document.querySelectorAll('.sync-playlist-clear-btn').forEach(btn => {
+ btn.disabled = count === 0 || isRunning;
+ });
+}
+
+function getOrderedSelectedPlaylistIds() {
+ const config = getActiveSyncBulkConfig();
+ if (!config) return [];
+
+ const ordered = [];
+ document.querySelectorAll(config.cardQuery).forEach(card => {
+ const id = getCardSelectId(card, config);
+ if (id && selectedPlaylists.has(id)) ordered.push(id);
+ });
+ return ordered;
+}
+
+function disablePlaylistSelection(disabled) {
+ const containerIds = new Set(Object.values(SYNC_BULK_SOURCES).map(c => c.containerId));
+ containerIds.add('youtube-playlist-container');
+ containerIds.forEach(id => {
+ const container = document.getElementById(id);
+ if (container) container.classList.toggle('selection-disabled', disabled);
+ });
+
+ document.querySelectorAll('.playlist-checkbox').forEach(checkbox => {
+ checkbox.disabled = disabled;
+ });
+
+ if (disabled) {
+ document.querySelectorAll('.sync-playlist-select-all-btn, .sync-playlist-clear-btn').forEach(btn => {
+ btn.disabled = true;
+ });
+ } else {
+ updateSyncActionsUI();
+ }
+}
diff --git a/webui/static/sync-lastfm.js b/webui/static/sync-lastfm.js
index b54379a1..52d43cfd 100644
--- a/webui/static/sync-lastfm.js
+++ b/webui/static/sync-lastfm.js
@@ -102,18 +102,20 @@ function renderLastfmSyncPlaylists() {
`;
}).join('');
- container.querySelectorAll('.lastfm-playlist-card').forEach(card => {
- card.addEventListener('click', () => {
- const mbid = card.dataset.lbMbid;
- const title = card.dataset.lbTitle;
- // Reuses the LB Sync-tab click handler — Last.fm radios are
- // stored in the same table + matched by the same discovery
- // worker, so the click flow is byte-identical.
- if (typeof handleListenBrainzSyncCardClick === 'function') {
- handleListenBrainzSyncCardClick(mbid, title);
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'lastfm-sync',
+ '#lastfm-sync-playlist-container',
+ '.lastfm-playlist-card',
+ card => card.dataset.lbMbid,
+ mbid => {
+ const c = document.querySelector(`#lastfm-sync-card-${CSS.escape(mbid)}`);
+ if (typeof handleListenBrainzSyncCardClick === 'function') {
+ handleListenBrainzSyncCardClick(mbid, c?.dataset.lbTitle || '');
+ }
}
- });
- });
+ );
+ }
// Reuse the shared refresh loop from sync-listenbrainz.js — it
// already iterates Last.fm cards alongside LB cards.
diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js
index b8415ce6..a32e6f51 100644
--- a/webui/static/sync-listenbrainz.js
+++ b/webui/static/sync-listenbrainz.js
@@ -139,13 +139,18 @@ function renderListenBrainzSyncPlaylists() {
}).join('');
// Wire click handlers.
- container.querySelectorAll('.listenbrainz-playlist-card').forEach(card => {
- card.addEventListener('click', () => {
- const mbid = card.dataset.lbMbid;
- const title = card.dataset.lbTitle;
- handleListenBrainzSyncCardClick(mbid, title);
- });
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'listenbrainz-sync',
+ '#listenbrainz-sync-playlist-container',
+ '.listenbrainz-playlist-card',
+ card => card.dataset.lbMbid,
+ mbid => {
+ const c = document.querySelector(`#listenbrainz-sync-card-${CSS.escape(mbid)}`);
+ handleListenBrainzSyncCardClick(mbid, c?.dataset.lbTitle || '');
+ }
+ );
+ }
// If the tab is currently visible, kick the refresh loop so cards
// start showing live state immediately. ``_startLbSyncCardRefreshLoop``
diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js
index 6acd1ea6..124ed5bd 100644
--- a/webui/static/sync-services.js
+++ b/webui/static/sync-services.js
@@ -89,13 +89,15 @@ function renderTidalPlaylists() {
return createTidalCard(p);
}).join('');
- // Add click handlers to cards
- tidalPlaylists.forEach(p => {
- const card = document.getElementById(`tidal-card-${p.id}`);
- if (card) {
- card.addEventListener('click', () => handleTidalCardClick(p.id));
- }
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'tidal',
+ '#tidal-playlist-container',
+ '.tidal-playlist-card',
+ card => card.id.replace(/^tidal-card-/, ''),
+ handleTidalCardClick
+ );
+ }
}
function createTidalCard(playlist) {
@@ -1598,12 +1600,15 @@ function renderQobuzPlaylists() {
return createQobuzCard(p);
}).join('');
- qobuzPlaylists.forEach(p => {
- const card = document.getElementById(`qobuz-card-${p.id}`);
- if (card) {
- card.addEventListener('click', () => handleQobuzCardClick(p.id));
- }
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'qobuz',
+ '#qobuz-playlist-container',
+ '.qobuz-playlist-card',
+ card => card.id.replace(/^qobuz-card-/, ''),
+ handleQobuzCardClick
+ );
+ }
}
function createQobuzCard(playlist) {
@@ -2523,6 +2528,11 @@ function renderDeezerArlPlaylists() {
`;
}).join('');
+
+ if (typeof wirePlaylistCardSelection === 'function') {
+ wirePlaylistCardSelection('deezer', 'deezer-arl-playlist-container');
+ }
+ if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI();
}
function handleDeezerArlViewProgressClick(event, playlistId) {
@@ -2800,13 +2810,15 @@ function renderDeezerPlaylists() {
return createDeezerCard(p);
}).join('');
- // Add click handlers to cards
- deezerPlaylists.forEach(p => {
- const card = document.getElementById(`deezer-card-${p.id}`);
- if (card) {
- card.addEventListener('click', () => handleDeezerCardClick(p.id));
- }
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'deezer-link',
+ '#deezer-playlist-container',
+ '.deezer-playlist-card',
+ card => card.id.replace(/^deezer-card-/, ''),
+ handleDeezerCardClick
+ );
+ }
}
function createDeezerCard(playlist) {
@@ -3726,6 +3738,12 @@ function initializeSyncPage() {
syncContentArea.style.gridTemplateColumns = '1fr';
}
+ if (typeof onSyncTabChanged === 'function') {
+ onSyncTabChanged(tabId);
+ } else if (typeof updateSyncActionsUI === 'function') {
+ updateSyncActionsUI();
+ }
+
// Auto-load Deezer ARL playlists on first tab activation
if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) {
// Check ARL status first
@@ -3811,6 +3829,11 @@ function initializeSyncPage() {
ensureBeatportContentLoaded();
}
+ const initialActiveSyncTab = document.querySelector('.sync-tab-button.active');
+ if (initialActiveSyncTab && typeof onSyncTabChanged === 'function') {
+ onSyncTabChanged(initialActiveSyncTab.dataset.tab);
+ }
+
// Logic for the Spotify refresh button
const refreshBtn = document.getElementById('spotify-refresh-btn');
if (refreshBtn) {
@@ -5128,9 +5151,14 @@ function addBeatportCardToContainer(chartData) {
};
// Add click handler
- const card = document.getElementById(`beatport-card-${chartData.hash}`);
- if (card) {
- card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash));
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'beatport',
+ '#beatport-playlist-container',
+ '[id^="beatport-card-"]',
+ card => card.id.replace(/^beatport-card-/, ''),
+ chartHash => handleBeatportCardClick(chartHash)
+ );
}
console.log(`🃏 Created Beatport card: ${chartData.name}`);
@@ -6776,13 +6804,15 @@ function renderSpotifyPublicPlaylists() {
return createSpotifyPublicCard(p);
}).join('');
- // Add click handlers to cards
- spotifyPublicPlaylists.forEach(p => {
- const card = document.getElementById(`spotify-public-card-${p.url_hash}`);
- if (card) {
- card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash));
- }
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'spotify-public',
+ '#spotify-public-playlist-container',
+ '.spotify-public-card',
+ card => card.id.replace(/^spotify-public-card-/, ''),
+ handleSpotifyPublicCardClick
+ );
+ }
}
function createSpotifyPublicCard(playlist) {
@@ -7802,12 +7832,15 @@ function renderITunesLinkPlaylists() {
}).join('');
// Add click handlers to cards
- itunesLinkPlaylists.forEach(p => {
- const card = document.getElementById(`itunes-link-card-${p.url_hash}`);
- if (card) {
- card.addEventListener('click', () => handleITunesLinkCardClick(p.url_hash));
- }
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'itunes-link',
+ '#itunes-link-playlist-container',
+ '.itunes-link-card',
+ card => card.id.replace(/^itunes-link-card-/, ''),
+ handleITunesLinkCardClick
+ );
+ }
}
function createITunesLinkCard(playlist) {
@@ -9018,15 +9051,15 @@ function updateYouTubeCardData(urlHash, playlistData) {
state.playlist = playlistData;
state.urlHash = urlHash;
- // Add click handler for card and action button
- const handleCardClick = () => handleYouTubeCardClick(urlHash);
- const actionBtn = card.querySelector('.playlist-card-action-btn');
-
- card.addEventListener('click', handleCardClick);
- actionBtn.addEventListener('click', (e) => {
- e.stopPropagation(); // Prevent card click
- handleCardClick();
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'youtube',
+ '#youtube-playlist-container',
+ '.youtube-playlist-card[id^="youtube-card-"]',
+ c => c.id.replace(/^youtube-card-/, ''),
+ handleYouTubeCardClick
+ );
+ }
console.log('🃏 Updated YouTube card data:', playlistData.name);
}
diff --git a/webui/static/sync-soulsync-discovery.js b/webui/static/sync-soulsync-discovery.js
index 6a707daa..dd4bd8aa 100644
--- a/webui/static/sync-soulsync-discovery.js
+++ b/webui/static/sync-soulsync-discovery.js
@@ -91,14 +91,24 @@ function renderSoulsyncDiscoverySyncPlaylists() {
`;
}).join('');
- container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => {
- card.addEventListener('click', () => {
- const kind = card.dataset.ssdKind;
- const variant = card.dataset.ssdVariant;
- const name = card.dataset.ssdName;
- handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card);
- });
- });
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'soulsync-discovery-sync',
+ '#soulsync-discovery-sync-playlist-container',
+ '.soulsync-discovery-playlist-card',
+ card => card.dataset.ssdId,
+ syntheticId => {
+ const c = document.getElementById(`soulsync-discovery-sync-card-${syntheticId}`);
+ if (!c) return;
+ handleSoulsyncDiscoverySyncCardClick(
+ c.dataset.ssdKind,
+ c.dataset.ssdVariant,
+ c.dataset.ssdName,
+ c
+ );
+ }
+ );
+ }
}
function _soulsyncSyntheticId(kind, variant) {
diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js
index 6af72167..8e6a1780 100644
--- a/webui/static/sync-spotify.js
+++ b/webui/static/sync-spotify.js
@@ -1199,10 +1199,14 @@ function createBeatportCardFromBackendState(chartInfo) {
cardElement: document.getElementById(`beatport-card-${chartHash}`)
};
- // Add click handler
- const card = document.getElementById(`beatport-card-${chartHash}`);
- if (card) {
- card.addEventListener('click', async () => await handleBeatportCardClick(chartHash));
+ if (typeof wirePhaseSyncCards === 'function') {
+ wirePhaseSyncCards(
+ 'beatport',
+ '#beatport-playlist-container',
+ '[id^="beatport-card-"]',
+ card => card.id.replace(/^beatport-card-/, ''),
+ chartHash => handleBeatportCardClick(chartHash)
+ );
}
console.log(`🃏 Created Beatport card from backend state: ${chartInfo.name} (${phase})`);
@@ -1357,7 +1361,7 @@ function createYouTubeCardFromBackendState(playlistInfo) {
// Create card HTML (using EXACT same structure as createYouTubeCard)
const cardHtml = `
-