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:
parent
8d564a1178
commit
8c4d6e34dc
10 changed files with 1327 additions and 186 deletions
129
webui/index.html
129
webui/index.html
|
|
@ -1079,7 +1079,15 @@
|
|||
<div class="sync-tab-content" id="spotify-tab-content">
|
||||
<div class="playlist-header">
|
||||
<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 class="playlist-scroll-container" id="spotify-playlist-container">
|
||||
<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="playlist-header">
|
||||
<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 class="playlist-scroll-container" id="tidal-playlist-container">
|
||||
<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="playlist-header">
|
||||
<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 class="playlist-scroll-container" id="deezer-arl-playlist-container">
|
||||
<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="playlist-header">
|
||||
<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 class="playlist-scroll-container" id="qobuz-playlist-container">
|
||||
<div class="playlist-placeholder">Click 'Refresh' to load your Qobuz playlists.</div>
|
||||
|
|
@ -1127,6 +1159,14 @@
|
|||
<button id="deezer-parse-btn">Load Playlist</button>
|
||||
</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-placeholder">Paste a Deezer playlist URL above to get started.</div>
|
||||
</div>
|
||||
|
|
@ -1140,6 +1180,14 @@
|
|||
<button id="youtube-parse-btn">Parse Playlist</button>
|
||||
</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-placeholder">Parsed YouTube playlists will appear here.</div>
|
||||
</div>
|
||||
|
|
@ -1153,6 +1201,14 @@
|
|||
<button id="spotify-public-parse-btn">Load</button>
|
||||
</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-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div>
|
||||
</div>
|
||||
|
|
@ -1166,6 +1222,14 @@
|
|||
<button id="itunes-link-parse-btn">Load</button>
|
||||
</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-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div>
|
||||
</div>
|
||||
|
|
@ -1587,7 +1651,15 @@
|
|||
<div class="beatport-tab-content" id="beatport-playlists-content">
|
||||
<div class="playlist-header">
|
||||
<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 class="playlist-scroll-container" id="beatport-playlist-container">
|
||||
<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="playlist-header">
|
||||
<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 class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container">
|
||||
<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="playlist-header">
|
||||
<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 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>
|
||||
|
|
@ -1976,14 +2064,22 @@
|
|||
|
||||
<!-- ListenBrainz Sync Tab Content (separate ID from Discover-page LB UI) -->
|
||||
<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>
|
||||
<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">
|
||||
<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="collaborative">Collaborative</button>
|
||||
</div>
|
||||
<button class="refresh-button listenbrainz" id="listenbrainz-sync-refresh-btn">🔄 Refresh</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="listenbrainz-sync-playlist-container">
|
||||
<div class="playlist-placeholder">Click 'Refresh' to load your ListenBrainz playlists.</div>
|
||||
|
|
@ -2089,6 +2185,20 @@
|
|||
</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>
|
||||
|
||||
|
|
@ -8074,6 +8184,7 @@
|
|||
<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-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='library.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
902
webui/static/sync-bulk-selection.js
Normal file
902
webui/static/sync-bulk-selection.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
`;
|
||||
}).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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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-content">
|
||||
<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
|
||||
};
|
||||
|
||||
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})`);
|
||||
}
|
||||
|
||||
|
|
@ -1643,7 +1657,7 @@ function renderSpotifyPlaylists() {
|
|||
|
||||
// This HTML structure creates the interactive playlist cards
|
||||
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-content">
|
||||
<div class="playlist-card-name">${escapeHtml(p.name)}</div>
|
||||
|
|
@ -1663,6 +1677,10 @@ function renderSpotifyPlaylists() {
|
|||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
if (typeof wirePlaylistCardSelection === 'function') {
|
||||
wirePlaylistCardSelection('spotify', 'spotify-playlist-container');
|
||||
}
|
||||
if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI();
|
||||
}
|
||||
|
||||
function handleViewProgressClick(event, playlistId) {
|
||||
|
|
@ -1791,44 +1809,6 @@ async function cleanupDownloadProcess(playlistId) {
|
|||
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) {
|
||||
event.stopPropagation();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue