Add bulk playlist selection and sequential sync across Sync sources.

Introduce sync-bulk-selection.js with per-source processors, header Sync selected controls, and a bottom action bar that stays clear of floating UI widgets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kekkokk 2026-06-04 15:48:38 +02:00
parent 8d564a1178
commit 8c4d6e34dc
10 changed files with 1327 additions and 186 deletions

View file

@ -1079,7 +1079,15 @@
<div class="sync-tab-content" id="spotify-tab-content"> <div class="sync-tab-content" id="spotify-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Spotify Playlists</h3> <h3>Your Spotify Playlists</h3>
<button class="refresh-button" id="spotify-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="spotify"
onclick="selectAllSyncPlaylists('spotify')" disabled title="Select all loaded playlists">Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled title="Clear playlist selection">Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled title="Sync all selected playlists">Sync selected</button>
<button class="refresh-button" id="spotify-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="spotify-playlist-container"> <div class="playlist-scroll-container" id="spotify-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Spotify playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Spotify playlists.</div>
@ -1090,7 +1098,15 @@
<div class="sync-tab-content" id="tidal-tab-content"> <div class="sync-tab-content" id="tidal-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Tidal Playlists</h3> <h3>Your Tidal Playlists</h3>
<button class="refresh-button tidal" id="tidal-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="tidal"
onclick="selectAllSyncPlaylists('tidal')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button tidal" id="tidal-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="tidal-playlist-container"> <div class="playlist-scroll-container" id="tidal-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Tidal playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Tidal playlists.</div>
@ -1101,7 +1117,15 @@
<div class="sync-tab-content" id="deezer-tab-content"> <div class="sync-tab-content" id="deezer-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Deezer Playlists</h3> <h3>Your Deezer Playlists</h3>
<button class="refresh-button deezer" id="deezer-arl-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="deezer"
onclick="selectAllSyncPlaylists('deezer')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button deezer" id="deezer-arl-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="deezer-arl-playlist-container"> <div class="playlist-scroll-container" id="deezer-arl-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Deezer playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Deezer playlists.</div>
@ -1112,7 +1136,15 @@
<div class="sync-tab-content" id="qobuz-tab-content"> <div class="sync-tab-content" id="qobuz-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Qobuz Playlists</h3> <h3>Your Qobuz Playlists</h3>
<button class="refresh-button qobuz" id="qobuz-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="qobuz"
onclick="selectAllSyncPlaylists('qobuz')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button qobuz" id="qobuz-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="qobuz-playlist-container"> <div class="playlist-scroll-container" id="qobuz-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Qobuz playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Qobuz playlists.</div>
@ -1127,6 +1159,14 @@
<button id="deezer-parse-btn">Load Playlist</button> <button id="deezer-parse-btn">Load Playlist</button>
</div> </div>
<div class="url-history-bar" id="deezer-url-history" style="display:none"></div> <div class="url-history-bar" id="deezer-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="deezer-link"
onclick="selectAllSyncPlaylists('deezer-link')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="deezer-playlist-container"> <div class="playlist-scroll-container" id="deezer-playlist-container">
<div class="playlist-placeholder">Paste a Deezer playlist URL above to get started.</div> <div class="playlist-placeholder">Paste a Deezer playlist URL above to get started.</div>
</div> </div>
@ -1140,6 +1180,14 @@
<button id="youtube-parse-btn">Parse Playlist</button> <button id="youtube-parse-btn">Parse Playlist</button>
</div> </div>
<div class="url-history-bar" id="youtube-url-history" style="display:none"></div> <div class="url-history-bar" id="youtube-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="youtube"
onclick="selectAllSyncPlaylists('youtube')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="youtube-playlist-container"> <div class="playlist-scroll-container" id="youtube-playlist-container">
<div class="playlist-placeholder">Parsed YouTube playlists will appear here.</div> <div class="playlist-placeholder">Parsed YouTube playlists will appear here.</div>
</div> </div>
@ -1153,6 +1201,14 @@
<button id="spotify-public-parse-btn">Load</button> <button id="spotify-public-parse-btn">Load</button>
</div> </div>
<div class="url-history-bar" id="spotify-public-url-history" style="display:none"></div> <div class="url-history-bar" id="spotify-public-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="spotify-public"
onclick="selectAllSyncPlaylists('spotify-public')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="spotify-public-playlist-container"> <div class="playlist-scroll-container" id="spotify-public-playlist-container">
<div class="playlist-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div> <div class="playlist-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div>
</div> </div>
@ -1166,6 +1222,14 @@
<button id="itunes-link-parse-btn">Load</button> <button id="itunes-link-parse-btn">Load</button>
</div> </div>
<div class="url-history-bar" id="itunes-link-url-history" style="display:none"></div> <div class="url-history-bar" id="itunes-link-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="itunes-link"
onclick="selectAllSyncPlaylists('itunes-link')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="itunes-link-playlist-container"> <div class="playlist-scroll-container" id="itunes-link-playlist-container">
<div class="playlist-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div> <div class="playlist-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div>
</div> </div>
@ -1587,7 +1651,15 @@
<div class="beatport-tab-content" id="beatport-playlists-content"> <div class="beatport-tab-content" id="beatport-playlists-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>My Beatport Playlists</h3> <h3>My Beatport Playlists</h3>
<button class="refresh-button beatport" id="beatport-clear-btn">🗑️ Clear</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="beatport"
onclick="selectAllSyncPlaylists('beatport')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button beatport" id="beatport-clear-btn">🗑️ Clear charts</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="beatport-playlist-container"> <div class="playlist-scroll-container" id="beatport-playlist-container">
<div class="playlist-placeholder">Your created Beatport playlists will appear here. <div class="playlist-placeholder">Your created Beatport playlists will appear here.
@ -1956,7 +2028,15 @@
<div class="sync-tab-content" id="soulsync-discovery-sync-tab-content"> <div class="sync-tab-content" id="soulsync-discovery-sync-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>SoulSync Discovery Playlists</h3> <h3>SoulSync Discovery Playlists</h3>
<button class="refresh-button soulsync-discovery" id="soulsync-discovery-sync-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="soulsync-discovery-sync"
onclick="selectAllSyncPlaylists('soulsync-discovery-sync')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button soulsync-discovery" id="soulsync-discovery-sync-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container"> <div class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your personalized SoulSync Discovery playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your personalized SoulSync Discovery playlists.</div>
@ -1967,7 +2047,15 @@
<div class="sync-tab-content" id="lastfm-sync-tab-content"> <div class="sync-tab-content" id="lastfm-sync-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Last.fm Radio Playlists</h3> <h3>Your Last.fm Radio Playlists</h3>
<button class="refresh-button lastfm" id="lastfm-sync-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="lastfm-sync"
onclick="selectAllSyncPlaylists('lastfm-sync')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button lastfm" id="lastfm-sync-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="lastfm-sync-playlist-container"> <div class="playlist-scroll-container" id="lastfm-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.</div>
@ -1976,14 +2064,22 @@
<!-- ListenBrainz Sync Tab Content (separate ID from Discover-page LB UI) --> <!-- ListenBrainz Sync Tab Content (separate ID from Discover-page LB UI) -->
<div class="sync-tab-content" id="listenbrainz-sync-tab-content"> <div class="sync-tab-content" id="listenbrainz-sync-tab-content">
<div class="playlist-header"> <div class="playlist-header playlist-header--listenbrainz">
<h3>Your ListenBrainz Playlists</h3> <h3>Your ListenBrainz Playlists</h3>
<div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="listenbrainz-sync"
onclick="selectAllSyncPlaylists('listenbrainz-sync')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button listenbrainz" id="listenbrainz-sync-refresh-btn">🔄 Refresh</button>
</div>
<div class="listenbrainz-sub-tabs"> <div class="listenbrainz-sub-tabs">
<button class="listenbrainz-sub-tab-btn active" data-lb-type="created_for_user">For You</button> <button class="listenbrainz-sub-tab-btn active" data-lb-type="created_for_user">For You</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="user_created">My Playlists</button> <button class="listenbrainz-sub-tab-btn" data-lb-type="user_created">My Playlists</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="collaborative">Collaborative</button> <button class="listenbrainz-sub-tab-btn" data-lb-type="collaborative">Collaborative</button>
</div> </div>
<button class="refresh-button listenbrainz" id="listenbrainz-sync-refresh-btn">🔄 Refresh</button>
</div> </div>
<div class="playlist-scroll-container" id="listenbrainz-sync-playlist-container"> <div class="playlist-scroll-container" id="listenbrainz-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your ListenBrainz playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your ListenBrainz playlists.</div>
@ -2089,6 +2185,20 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Bulk actions when Spotify playlists are selected (sidebar is hidden until sync starts) -->
<div class="sync-playlist-bulk-bar" id="sync-playlist-bulk-bar" hidden>
<div class="sync-playlist-bulk-bar-info">
<span class="sync-playlist-bulk-count" id="sync-playlist-bulk-count">0</span>
<span class="sync-playlist-bulk-label" id="sync-playlist-bulk-label">playlists selected</span>
</div>
<div class="sync-playlist-bulk-bar-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()">Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
</div>
</div> </div>
</div> </div>
@ -8074,6 +8184,7 @@
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-soulsync-discovery.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-soulsync-discovery.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-bulk-selection.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>

View file

@ -768,14 +768,14 @@ class SequentialSyncManager {
this.startTime = null; this.startTime = null;
} }
start(playlistIds) { start(playlistIds, bulkConfig = null) {
if (this.isRunning) { if (this.isRunning) {
console.warn('Sequential sync already running'); console.warn('Sequential sync already running');
return; return;
} }
// Convert playlist IDs to ordered array (maintain display order)
this.queue = Array.from(playlistIds); this.queue = Array.from(playlistIds);
this.bulkConfig = bulkConfig;
this.currentIndex = 0; this.currentIndex = 0;
this.isRunning = true; this.isRunning = true;
this.startTime = Date.now(); this.startTime = Date.now();
@ -792,42 +792,27 @@ class SequentialSyncManager {
} }
const playlistId = this.queue[this.currentIndex]; const playlistId = this.queue[this.currentIndex];
const playlist = spotifyPlaylists.find(p => p.id === playlistId); const label = this.bulkConfig?.getName(playlistId)
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`); || spotifyPlaylists.find(p => p.id === playlistId)?.name
|| playlistId;
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${label}`);
this.updateUI(); this.updateUI();
try { try {
// Use existing single sync function if (this.bulkConfig?.process) {
await startPlaylistSync(playlistId); await this.bulkConfig.process(playlistId);
} else {
// Wait for sync to complete by monitoring the poller await startPlaylistSync(playlistId);
await this.waitForSyncCompletion(playlistId); await waitForSyncPollerCompletion(playlistId);
}
} catch (error) { } catch (error) {
console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, 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++; this.currentIndex++;
setTimeout(() => this.syncNext(), 1000); // Small delay between syncs setTimeout(() => this.syncNext(), 1000);
}
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();
});
} }
complete() { complete() {
@ -837,14 +822,14 @@ class SequentialSyncManager {
this.isRunning = false; this.isRunning = false;
this.queue = []; this.queue = [];
this.bulkConfig = null;
this.currentIndex = 0; this.currentIndex = 0;
this.startTime = null; this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false); disablePlaylistSelection(false);
this.updateUI(); this.updateUI();
updateRefreshButtonState(); // Refresh button state after completion updateRefreshButtonState();
showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success'); showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success');
// Hide sidebar after completion // Hide sidebar after completion
@ -857,14 +842,14 @@ class SequentialSyncManager {
console.log('🛑 Cancelling sequential sync'); console.log('🛑 Cancelling sequential sync');
this.isRunning = false; this.isRunning = false;
this.queue = []; this.queue = [];
this.bulkConfig = null;
this.currentIndex = 0; this.currentIndex = 0;
this.startTime = null; this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false); disablePlaylistSelection(false);
this.updateUI(); this.updateUI();
updateRefreshButtonState(); // Refresh button state after cancellation updateRefreshButtonState();
showToast('Sequential sync cancelled', 'info'); showToast('Sequential sync cancelled', 'info');
// Hide sidebar after cancellation // Hide sidebar after cancellation
@ -872,33 +857,8 @@ class SequentialSyncManager {
} }
updateUI() { updateUI() {
const startSyncBtn = document.getElementById('start-sync-btn'); if (typeof updateSyncActionsUI === 'function') {
const selectionInfo = document.getElementById('selection-info'); updateSyncActionsUI();
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'}`;
}
} }
} }
} }

View file

@ -817,7 +817,15 @@ const HELPER_CONTENT = {
'#start-sync-btn': { '#start-sync-btn': {
title: 'Start Sync', title: 'Start Sync',
description: 'Begin downloading missing tracks from all selected playlists. Playlists are processed sequentially — each one completes before the next starts.', 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': { '#sync-log-area': {
title: 'Sync Log', 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.' }, { 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 // 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 // 'artists-browse' tour retired — the Artists sidebar entry was replaced by the

View file

@ -15707,6 +15707,136 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #ffffff; 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 { .refresh-button {
background: rgb(var(--accent-rgb)); background: rgb(var(--accent-rgb));
border: none; border: none;

View file

@ -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();
}
}

View file

@ -102,18 +102,20 @@ function renderLastfmSyncPlaylists() {
`; `;
}).join(''); }).join('');
container.querySelectorAll('.lastfm-playlist-card').forEach(card => { if (typeof wirePhaseSyncCards === 'function') {
card.addEventListener('click', () => { wirePhaseSyncCards(
const mbid = card.dataset.lbMbid; 'lastfm-sync',
const title = card.dataset.lbTitle; '#lastfm-sync-playlist-container',
// Reuses the LB Sync-tab click handler — Last.fm radios are '.lastfm-playlist-card',
// stored in the same table + matched by the same discovery card => card.dataset.lbMbid,
// worker, so the click flow is byte-identical. mbid => {
if (typeof handleListenBrainzSyncCardClick === 'function') { const c = document.querySelector(`#lastfm-sync-card-${CSS.escape(mbid)}`);
handleListenBrainzSyncCardClick(mbid, title); if (typeof handleListenBrainzSyncCardClick === 'function') {
handleListenBrainzSyncCardClick(mbid, c?.dataset.lbTitle || '');
}
} }
}); );
}); }
// Reuse the shared refresh loop from sync-listenbrainz.js — it // Reuse the shared refresh loop from sync-listenbrainz.js — it
// already iterates Last.fm cards alongside LB cards. // already iterates Last.fm cards alongside LB cards.

View file

@ -139,13 +139,18 @@ function renderListenBrainzSyncPlaylists() {
}).join(''); }).join('');
// Wire click handlers. // Wire click handlers.
container.querySelectorAll('.listenbrainz-playlist-card').forEach(card => { if (typeof wirePhaseSyncCards === 'function') {
card.addEventListener('click', () => { wirePhaseSyncCards(
const mbid = card.dataset.lbMbid; 'listenbrainz-sync',
const title = card.dataset.lbTitle; '#listenbrainz-sync-playlist-container',
handleListenBrainzSyncCardClick(mbid, title); '.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 // If the tab is currently visible, kick the refresh loop so cards
// start showing live state immediately. ``_startLbSyncCardRefreshLoop`` // start showing live state immediately. ``_startLbSyncCardRefreshLoop``

View file

@ -89,13 +89,15 @@ function renderTidalPlaylists() {
return createTidalCard(p); return createTidalCard(p);
}).join(''); }).join('');
// Add click handlers to cards if (typeof wirePhaseSyncCards === 'function') {
tidalPlaylists.forEach(p => { wirePhaseSyncCards(
const card = document.getElementById(`tidal-card-${p.id}`); 'tidal',
if (card) { '#tidal-playlist-container',
card.addEventListener('click', () => handleTidalCardClick(p.id)); '.tidal-playlist-card',
} card => card.id.replace(/^tidal-card-/, ''),
}); handleTidalCardClick
);
}
} }
function createTidalCard(playlist) { function createTidalCard(playlist) {
@ -1598,12 +1600,15 @@ function renderQobuzPlaylists() {
return createQobuzCard(p); return createQobuzCard(p);
}).join(''); }).join('');
qobuzPlaylists.forEach(p => { if (typeof wirePhaseSyncCards === 'function') {
const card = document.getElementById(`qobuz-card-${p.id}`); wirePhaseSyncCards(
if (card) { 'qobuz',
card.addEventListener('click', () => handleQobuzCardClick(p.id)); '#qobuz-playlist-container',
} '.qobuz-playlist-card',
}); card => card.id.replace(/^qobuz-card-/, ''),
handleQobuzCardClick
);
}
} }
function createQobuzCard(playlist) { function createQobuzCard(playlist) {
@ -2523,6 +2528,11 @@ function renderDeezerArlPlaylists() {
</div> </div>
`; `;
}).join(''); }).join('');
if (typeof wirePlaylistCardSelection === 'function') {
wirePlaylistCardSelection('deezer', 'deezer-arl-playlist-container');
}
if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI();
} }
function handleDeezerArlViewProgressClick(event, playlistId) { function handleDeezerArlViewProgressClick(event, playlistId) {
@ -2800,13 +2810,15 @@ function renderDeezerPlaylists() {
return createDeezerCard(p); return createDeezerCard(p);
}).join(''); }).join('');
// Add click handlers to cards if (typeof wirePhaseSyncCards === 'function') {
deezerPlaylists.forEach(p => { wirePhaseSyncCards(
const card = document.getElementById(`deezer-card-${p.id}`); 'deezer-link',
if (card) { '#deezer-playlist-container',
card.addEventListener('click', () => handleDeezerCardClick(p.id)); '.deezer-playlist-card',
} card => card.id.replace(/^deezer-card-/, ''),
}); handleDeezerCardClick
);
}
} }
function createDeezerCard(playlist) { function createDeezerCard(playlist) {
@ -3726,6 +3738,12 @@ function initializeSyncPage() {
syncContentArea.style.gridTemplateColumns = '1fr'; 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 // Auto-load Deezer ARL playlists on first tab activation
if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) { if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) {
// Check ARL status first // Check ARL status first
@ -3811,6 +3829,11 @@ function initializeSyncPage() {
ensureBeatportContentLoaded(); ensureBeatportContentLoaded();
} }
const initialActiveSyncTab = document.querySelector('.sync-tab-button.active');
if (initialActiveSyncTab && typeof onSyncTabChanged === 'function') {
onSyncTabChanged(initialActiveSyncTab.dataset.tab);
}
// Logic for the Spotify refresh button // Logic for the Spotify refresh button
const refreshBtn = document.getElementById('spotify-refresh-btn'); const refreshBtn = document.getElementById('spotify-refresh-btn');
if (refreshBtn) { if (refreshBtn) {
@ -5128,9 +5151,14 @@ function addBeatportCardToContainer(chartData) {
}; };
// Add click handler // Add click handler
const card = document.getElementById(`beatport-card-${chartData.hash}`); if (typeof wirePhaseSyncCards === 'function') {
if (card) { wirePhaseSyncCards(
card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash)); 'beatport',
'#beatport-playlist-container',
'[id^="beatport-card-"]',
card => card.id.replace(/^beatport-card-/, ''),
chartHash => handleBeatportCardClick(chartHash)
);
} }
console.log(`🃏 Created Beatport card: ${chartData.name}`); console.log(`🃏 Created Beatport card: ${chartData.name}`);
@ -6776,13 +6804,15 @@ function renderSpotifyPublicPlaylists() {
return createSpotifyPublicCard(p); return createSpotifyPublicCard(p);
}).join(''); }).join('');
// Add click handlers to cards if (typeof wirePhaseSyncCards === 'function') {
spotifyPublicPlaylists.forEach(p => { wirePhaseSyncCards(
const card = document.getElementById(`spotify-public-card-${p.url_hash}`); 'spotify-public',
if (card) { '#spotify-public-playlist-container',
card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash)); '.spotify-public-card',
} card => card.id.replace(/^spotify-public-card-/, ''),
}); handleSpotifyPublicCardClick
);
}
} }
function createSpotifyPublicCard(playlist) { function createSpotifyPublicCard(playlist) {
@ -7802,12 +7832,15 @@ function renderITunesLinkPlaylists() {
}).join(''); }).join('');
// Add click handlers to cards // Add click handlers to cards
itunesLinkPlaylists.forEach(p => { if (typeof wirePhaseSyncCards === 'function') {
const card = document.getElementById(`itunes-link-card-${p.url_hash}`); wirePhaseSyncCards(
if (card) { 'itunes-link',
card.addEventListener('click', () => handleITunesLinkCardClick(p.url_hash)); '#itunes-link-playlist-container',
} '.itunes-link-card',
}); card => card.id.replace(/^itunes-link-card-/, ''),
handleITunesLinkCardClick
);
}
} }
function createITunesLinkCard(playlist) { function createITunesLinkCard(playlist) {
@ -9018,15 +9051,15 @@ function updateYouTubeCardData(urlHash, playlistData) {
state.playlist = playlistData; state.playlist = playlistData;
state.urlHash = urlHash; state.urlHash = urlHash;
// Add click handler for card and action button if (typeof wirePhaseSyncCards === 'function') {
const handleCardClick = () => handleYouTubeCardClick(urlHash); wirePhaseSyncCards(
const actionBtn = card.querySelector('.playlist-card-action-btn'); 'youtube',
'#youtube-playlist-container',
card.addEventListener('click', handleCardClick); '.youtube-playlist-card[id^="youtube-card-"]',
actionBtn.addEventListener('click', (e) => { c => c.id.replace(/^youtube-card-/, ''),
e.stopPropagation(); // Prevent card click handleYouTubeCardClick
handleCardClick(); );
}); }
console.log('🃏 Updated YouTube card data:', playlistData.name); console.log('🃏 Updated YouTube card data:', playlistData.name);
} }

View file

@ -91,14 +91,24 @@ function renderSoulsyncDiscoverySyncPlaylists() {
`; `;
}).join(''); }).join('');
container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => { if (typeof wirePhaseSyncCards === 'function') {
card.addEventListener('click', () => { wirePhaseSyncCards(
const kind = card.dataset.ssdKind; 'soulsync-discovery-sync',
const variant = card.dataset.ssdVariant; '#soulsync-discovery-sync-playlist-container',
const name = card.dataset.ssdName; '.soulsync-discovery-playlist-card',
handleSoulsyncDiscoverySyncCardClick(kind, variant, name, 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) { function _soulsyncSyntheticId(kind, variant) {

View file

@ -1199,10 +1199,14 @@ function createBeatportCardFromBackendState(chartInfo) {
cardElement: document.getElementById(`beatport-card-${chartHash}`) cardElement: document.getElementById(`beatport-card-${chartHash}`)
}; };
// Add click handler if (typeof wirePhaseSyncCards === 'function') {
const card = document.getElementById(`beatport-card-${chartHash}`); wirePhaseSyncCards(
if (card) { 'beatport',
card.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); '#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})`); 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) // Create card HTML (using EXACT same structure as createYouTubeCard)
const cardHtml = ` const cardHtml = `
<div class="youtube-playlist-card" id="youtube-card-${urlHash}" data-url="${playlistInfo.url}" onclick="handleYouTubeCardClick('${urlHash}')"> <div class="youtube-playlist-card" id="youtube-card-${urlHash}" data-url="${playlistInfo.url}">
<div class="playlist-card-icon youtube-icon"></div> <div class="playlist-card-icon youtube-icon"></div>
<div class="playlist-card-content"> <div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div> <div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
@ -1388,6 +1392,16 @@ function createYouTubeCardFromBackendState(playlistInfo) {
backendSynced: true // Flag to indicate this came from backend backendSynced: true // Flag to indicate this came from backend
}; };
if (typeof wirePhaseSyncCards === 'function') {
wirePhaseSyncCards(
'youtube',
'#youtube-playlist-container',
'.youtube-playlist-card[id^="youtube-card-"]',
c => c.id.replace(/^youtube-card-/, ''),
handleYouTubeCardClick
);
}
console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`); console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`);
} }
@ -1643,7 +1657,7 @@ function renderSpotifyPlaylists() {
// This HTML structure creates the interactive playlist cards // This HTML structure creates the interactive playlist cards
return ` return `
<div class="playlist-card" data-playlist-id="${p.id}" onclick="togglePlaylistSelection(event)"> <div class="playlist-card" data-playlist-id="${p.id}">
<div class="playlist-card-main"> <div class="playlist-card-main">
<div class="playlist-card-content"> <div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(p.name)}</div> <div class="playlist-card-name">${escapeHtml(p.name)}</div>
@ -1663,6 +1677,10 @@ function renderSpotifyPlaylists() {
</div> </div>
`; `;
}).join(''); }).join('');
if (typeof wirePlaylistCardSelection === 'function') {
wirePlaylistCardSelection('spotify', 'spotify-playlist-container');
}
if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI();
} }
function handleViewProgressClick(event, playlistId) { function handleViewProgressClick(event, playlistId) {
@ -1791,44 +1809,6 @@ async function cleanupDownloadProcess(playlistId) {
updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist
} }
function togglePlaylistSelection(event) {
const card = event.currentTarget;
const playlistId = card.dataset.playlistId;
// Don't toggle if clicking the button
if (event.target.tagName === 'BUTTON') return;
const isSelected = !card.classList.contains('selected');
card.classList.toggle('selected', isSelected);
if (isSelected) {
selectedPlaylists.add(playlistId);
} else {
selectedPlaylists.delete(playlistId);
}
updateSyncActionsUI();
}
function updateSyncActionsUI() {
// If sequential sync is running, let the manager handle UI updates
if (sequentialSyncManager && sequentialSyncManager.isRunning) {
sequentialSyncManager.updateUI();
return;
}
const selectionInfo = document.getElementById('selection-info');
const startSyncBtn = document.getElementById('start-sync-btn');
const count = selectedPlaylists.size;
if (count === 0) {
if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync';
if (startSyncBtn) startSyncBtn.disabled = true;
} else {
if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`;
if (startSyncBtn) startSyncBtn.disabled = false;
}
}
async function openPlaylistDetailsModal(event, playlistId) { async function openPlaylistDetailsModal(event, playlistId) {
event.stopPropagation(); event.stopPropagation();