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>
902 lines
35 KiB
JavaScript
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();
|
|
}
|
|
}
|