soulsync/webui/static/sync-bulk-selection.js
kekkokk 8c4d6e34dc 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>
2026-06-04 15:48:38 +02:00

902 lines
35 KiB
JavaScript

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