diff --git a/webui/static/script.js b/webui/static/script.js
index 621cfbad..1ba6bec9 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -10035,13 +10035,14 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' :
virtualPlaylistId.startsWith('tidal_') ? 'Tidal' :
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
- virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
- virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
- 'YouTube';
+ virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' :
+ virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
+ virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
+ 'YouTube';
// Store metadata for discover download sidebar (will be added when Begin Analysis is clicked)
if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_')) {
@@ -10387,6 +10388,88 @@ async function closeDownloadMissingModal(playlistId) {
}
}
+ // Reset Spotify Public playlist phase to 'discovered' when modal is closed
+ if (playlistId.startsWith('spotify_public_')) {
+ const spUrlHash = playlistId.replace('spotify_public_', '');
+
+ console.log(`๐งน [Modal Close] Processing Spotify Public playlist close: playlistId="${playlistId}", urlHash="${spUrlHash}"`);
+
+ if (spotifyPublicPlaylistStates[spUrlHash]) {
+ const currentPhase = spotifyPublicPlaylistStates[spUrlHash].phase;
+ console.log(`๐งน [Modal Close] Current phase before reset: ${currentPhase}`);
+
+ const preservedData = {
+ playlist: spotifyPublicPlaylistStates[spUrlHash].playlist,
+ discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results,
+ spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches,
+ discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress,
+ convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId
+ };
+
+ delete spotifyPublicPlaylistStates[spUrlHash].download_process_id;
+ delete spotifyPublicPlaylistStates[spUrlHash].phase;
+
+ Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData);
+ spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered';
+
+ console.log(`๐งน [Modal Close] Reset Spotify Public playlist ${spUrlHash} - cleared download state, preserved discovery data`);
+ }
+
+ updateSpotifyPublicCardPhase(spUrlHash, 'discovered');
+
+ try {
+ await fetch(`/api/spotify-public/update_phase/${spUrlHash}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phase: 'discovered' })
+ });
+ console.log(`โ
[Modal Close] Updated backend phase for Spotify Public playlist ${spUrlHash} to 'discovered'`);
+ } catch (error) {
+ console.error(`โ [Modal Close] Error updating backend phase for Spotify Public playlist ${spUrlHash}:`, error);
+ }
+ }
+
+ // Reset Deezer playlist phase to 'discovered' when modal is closed
+ if (playlistId.startsWith('deezer_')) {
+ const deezerPlaylistId = playlistId.replace('deezer_', '');
+
+ console.log(`๐งน [Modal Close] Processing Deezer playlist close: playlistId="${playlistId}", deezerPlaylistId="${deezerPlaylistId}"`);
+
+ if (deezerPlaylistStates[deezerPlaylistId]) {
+ const currentPhase = deezerPlaylistStates[deezerPlaylistId].phase;
+ console.log(`๐งน [Modal Close] Current phase before reset: ${currentPhase}`);
+
+ const preservedData = {
+ playlist: deezerPlaylistStates[deezerPlaylistId].playlist,
+ discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results,
+ spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches,
+ discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress,
+ convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId
+ };
+
+ delete deezerPlaylistStates[deezerPlaylistId].download_process_id;
+ delete deezerPlaylistStates[deezerPlaylistId].phase;
+
+ Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData);
+ deezerPlaylistStates[deezerPlaylistId].phase = 'discovered';
+
+ console.log(`๐งน [Modal Close] Reset Deezer playlist ${deezerPlaylistId} - cleared download state, preserved discovery data`);
+ }
+
+ updateDeezerCardPhase(deezerPlaylistId, 'discovered');
+
+ try {
+ await fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phase: 'discovered' })
+ });
+ console.log(`โ
[Modal Close] Updated backend phase for Deezer playlist ${deezerPlaylistId} to 'discovered'`);
+ } catch (error) {
+ console.error(`โ [Modal Close] Error updating backend phase for Deezer playlist ${deezerPlaylistId}:`, error);
+ }
+ }
+
// Clear wishlist modal state when modal is fully closed
if (playlistId === 'wishlist') {
WishlistModalState.clear(); // Clear all tracking since modal is fully closed
@@ -11786,6 +11869,50 @@ async function startMissingTracksProcess(playlistId) {
}
}
+ // Update Spotify Public playlist phase to 'downloading' if this is a Spotify Public playlist
+ if (playlistId.startsWith('spotify_public_')) {
+ const urlHash = playlistId.replace('spotify_public_', '');
+ if (spotifyPublicPlaylistStates[urlHash]) {
+ spotifyPublicPlaylistStates[urlHash].phase = 'downloading';
+ spotifyPublicPlaylistStates[urlHash].convertedSpotifyPlaylistId = playlistId;
+ updateSpotifyPublicCardPhase(urlHash, 'downloading');
+
+ try {
+ fetch(`/api/spotify-public/update_phase/${urlHash}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId })
+ });
+ } catch (error) {
+ console.warn('Error updating backend Spotify Public phase to downloading:', error);
+ }
+
+ console.log(`๐ Updated Spotify Public playlist ${urlHash} to downloading phase`);
+ }
+ }
+
+ // Update Deezer playlist phase to 'downloading' if this is a Deezer playlist
+ if (playlistId.startsWith('deezer_')) {
+ const deezerPlaylistId = playlistId.replace('deezer_', '');
+ if (deezerPlaylistStates[deezerPlaylistId]) {
+ deezerPlaylistStates[deezerPlaylistId].phase = 'downloading';
+ deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId = playlistId;
+ updateDeezerCardPhase(deezerPlaylistId, 'downloading');
+
+ try {
+ fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId })
+ });
+ } catch (error) {
+ console.warn('Error updating backend Deezer phase to downloading:', error);
+ }
+
+ console.log(`๐ Updated Deezer playlist ${deezerPlaylistId} to downloading phase`);
+ }
+ }
+
// Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
@@ -15154,13 +15281,17 @@ async function selectDiscoveryFixTrack(track) {
// For Deezer, backend expects the actual playlist_id, not url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.deezer_playlist_id || identifier;
+ } else if (platform === 'spotify_public') {
+ // For Spotify Public, backend expects the url_hash
+ const state = youtubePlaylistStates[identifier];
+ backendIdentifier = state?.spotify_public_playlist_id || identifier;
} else if (platform === 'beatport') {
// For Beatport, backend expects url_hash (same as identifier)
backendIdentifier = identifier;
}
// Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes)
- const apiPlatform = platform === 'mirrored' ? 'youtube' : platform;
+ const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform);
const requestBody = {
identifier: backendIdentifier,
@@ -15280,6 +15411,18 @@ async function selectDiscoveryFixTrack(track) {
tidalState.spotifyMatches = state.spotifyMatches;
}
}
+
+ // Also update the Spotify Public playlist card if this is a Spotify Public fix
+ if (platform === 'spotify_public' && state.spotify_public_playlist_id) {
+ const spState = spotifyPublicPlaylistStates?.[state.spotify_public_playlist_id];
+ if (spState) {
+ spState.spotifyMatches = state.spotifyMatches;
+ updateSpotifyPublicCardProgress(state.spotify_public_playlist_id, {
+ spotify_matches: state.spotifyMatches,
+ spotify_total: spotify_total
+ });
+ }
+ }
}
// Update UI - refresh the table row
@@ -21890,13 +22033,14 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' :
virtualPlaylistId.startsWith('tidal_') ? 'Tidal' :
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
- virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
- virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
- virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
- 'YouTube';
+ virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' :
+ virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
+ virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
+ virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
+ 'YouTube';
const heroContext = {
type: 'playlist',
@@ -22073,7 +22217,7 @@ async function loadDeezerPlaylist() {
track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''),
album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
- })), { owner: playlist.owner, image_url: playlist.image_url, description: playlist.description });
+ })), { owner: playlist.owner, image_url: playlist.image_url, description: rawUrl });
}
renderDeezerPlaylists();
@@ -23188,6 +23332,22 @@ function initializeSyncPage() {
});
}
+ // Logic for Spotify Public parse button
+ const spotifyPublicParseBtn = document.getElementById('spotify-public-parse-btn');
+ if (spotifyPublicParseBtn) {
+ spotifyPublicParseBtn.addEventListener('click', parseSpotifyPublicUrl);
+ }
+
+ // Logic for Spotify Public URL input (Enter key support)
+ const spotifyPublicUrlInput = document.getElementById('spotify-public-url-input');
+ if (spotifyPublicUrlInput) {
+ spotifyPublicUrlInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ parseSpotifyPublicUrl();
+ }
+ });
+ }
+
// Logic for Beatport Top 100 button
const beatportTop100Btn = document.getElementById('beatport-top100-btn');
if (beatportTop100Btn) {
@@ -26139,6 +26299,991 @@ async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartTyp
}
}
+// ===============================
+// SPOTIFY PUBLIC LINK FUNCTIONALITY
+// ===============================
+
+let spotifyPublicPlaylists = []; // Array of loaded Spotify public playlist objects
+let spotifyPublicPlaylistStates = {}; // Key: url_hash, Value: state dict
+
+async function parseSpotifyPublicUrl() {
+ const urlInput = document.getElementById('spotify-public-url-input');
+ const url = urlInput.value.trim();
+
+ if (!url) {
+ showToast('Please enter a Spotify URL', 'error');
+ return;
+ }
+
+ // Basic URL validation
+ if (!url.includes('open.spotify.com/playlist') && !url.includes('open.spotify.com/album') &&
+ !url.startsWith('spotify:playlist:') && !url.startsWith('spotify:album:')) {
+ showToast('Please enter a valid Spotify playlist or album URL', 'error');
+ return;
+ }
+
+ const parseBtn = document.getElementById('spotify-public-parse-btn');
+ if (parseBtn) {
+ parseBtn.disabled = true;
+ parseBtn.textContent = 'Loading...';
+ }
+
+ try {
+ console.log('๐ต Parsing public Spotify URL:', url);
+
+ const response = await fetch('/api/spotify/parse-public', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url })
+ });
+
+ const result = await response.json();
+
+ if (result.error) {
+ showToast(`Error: ${result.error}`, 'error');
+ return;
+ }
+
+ // Check if already loaded
+ if (spotifyPublicPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) {
+ showToast('This playlist is already loaded', 'info');
+ urlInput.value = '';
+ return;
+ }
+
+ console.log(`โ
Spotify ${result.type} parsed: ${result.name} (${result.track_count} tracks)`);
+
+ spotifyPublicPlaylists.push(result);
+
+ // Auto-mirror
+ if (result.tracks && result.tracks.length > 0) {
+ mirrorPlaylist('spotify_public', result.url_hash, result.name, result.tracks.map(t => ({
+ track_name: t.name || '',
+ artist_name: Array.isArray(t.artists) ? t.artists.map(a => a.name).join(', ') : '',
+ album_name: t.album?.name || '',
+ duration_ms: t.duration_ms || 0,
+ source_track_id: t.id || ''
+ })), { owner: result.subtitle || '', image_url: '', description: result.url || '' });
+ }
+
+ renderSpotifyPublicPlaylists();
+ await loadSpotifyPublicPlaylistStatesFromBackend();
+
+ urlInput.value = '';
+ showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success');
+ console.log(`๐ต Loaded Spotify playlist: ${result.name}`);
+
+ } catch (error) {
+ console.error('โ Error parsing Spotify URL:', error);
+ showToast(`Error parsing Spotify URL: ${error.message}`, 'error');
+ } finally {
+ if (parseBtn) {
+ parseBtn.disabled = false;
+ parseBtn.textContent = 'Load';
+ }
+ }
+}
+
+function renderSpotifyPublicPlaylists() {
+ const container = document.getElementById('spotify-public-playlist-container');
+ if (spotifyPublicPlaylists.length === 0) {
+ container.innerHTML = `
Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
`;
+ return;
+ }
+
+ container.innerHTML = spotifyPublicPlaylists.map(p => {
+ if (!spotifyPublicPlaylistStates[p.url_hash]) {
+ spotifyPublicPlaylistStates[p.url_hash] = {
+ phase: 'fresh',
+ playlist: p
+ };
+ }
+ 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));
+ }
+ });
+}
+
+function createSpotifyPublicCard(playlist) {
+ const state = spotifyPublicPlaylistStates[playlist.url_hash];
+ const phase = state ? state.phase : 'fresh';
+ const isAlbum = playlist.type === 'album';
+
+ let buttonText = getActionButtonText(phase);
+ let phaseText = getPhaseText(phase);
+ let phaseColor = getPhaseColor(phase);
+
+ return `
+
+
${isAlbum ? '๐ฟ' : '๐ต'}
+
+
${escapeHtml(playlist.name)}
+
+ ${isAlbum ? 'Album' : 'Playlist'}
+ ${playlist.track_count || playlist.tracks.length} tracks
+ ${phaseText}
+
+
+
+
+
+
+
+ `;
+}
+
+async function handleSpotifyPublicCardClick(urlHash) {
+ const state = spotifyPublicPlaylistStates[urlHash];
+ if (!state) {
+ console.error(`No state found for Spotify public playlist: ${urlHash}`);
+ showToast('Playlist state not found - try refreshing the page', 'error');
+ return;
+ }
+
+ if (!state.playlist) {
+ console.error(`No playlist data found for Spotify public playlist: ${urlHash}`);
+ showToast('Playlist data missing - try refreshing the page', 'error');
+ return;
+ }
+
+ if (!state.phase) {
+ state.phase = 'fresh';
+ }
+
+ console.log(`๐ต [Card Click] Spotify public card clicked: ${urlHash}, Phase: ${state.phase}`);
+
+ if (state.phase === 'fresh') {
+ console.log(`๐ต Using pre-loaded Spotify public playlist data for: ${state.playlist.name}`);
+ openSpotifyPublicDiscoveryModal(urlHash, state.playlist);
+
+ } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
+ console.log(`๐ต [Card Click] Opening Spotify public discovery modal for ${state.phase} phase`);
+
+ if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
+ try {
+ const stateResponse = await fetch(`/api/spotify-public/state/${urlHash}`);
+ if (stateResponse.ok) {
+ const fullState = await stateResponse.json();
+ if (fullState.discovery_results) {
+ state.discovery_results = fullState.discovery_results;
+ state.spotify_matches = fullState.spotify_matches || state.spotify_matches;
+ state.discovery_progress = fullState.discovery_progress || state.discovery_progress;
+ spotifyPublicPlaylistStates[urlHash] = { ...spotifyPublicPlaylistStates[urlHash], ...state };
+ console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`);
+ }
+ }
+ } catch (error) {
+ console.error(`Failed to fetch discovery results from backend: ${error}`);
+ }
+ }
+
+ openSpotifyPublicDiscoveryModal(urlHash, state.playlist);
+ } else if (state.phase === 'downloading' || state.phase === 'download_complete') {
+ if (state.convertedSpotifyPlaylistId) {
+ if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
+ const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
+ if (process.modalElement) {
+ process.modalElement.style.display = 'flex';
+ } else {
+ await rehydrateSpotifyPublicDownloadModal(urlHash, state);
+ }
+ } else {
+ await rehydrateSpotifyPublicDownloadModal(urlHash, state);
+ }
+ } else {
+ if (state.discovery_results && state.discovery_results.length > 0) {
+ openSpotifyPublicDiscoveryModal(urlHash, state.playlist);
+ } else {
+ showToast('Unable to open download modal - missing playlist data', 'error');
+ }
+ }
+ }
+}
+
+async function rehydrateSpotifyPublicDownloadModal(urlHash, state) {
+ try {
+ if (!state || !state.playlist) {
+ showToast('Cannot open download modal - invalid playlist data', 'error');
+ return;
+ }
+
+ const spotifyTracks = state.discovery_results
+ ?.filter(result => result.spotify_data)
+ ?.map(result => result.spotify_data) || [];
+
+ if (spotifyTracks.length > 0) {
+ const virtualPlaylistId = state.convertedSpotifyPlaylistId || `spotify_public_${urlHash}`;
+ await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks);
+
+ if (state.download_process_id) {
+ const process = activeDownloadProcesses[virtualPlaylistId];
+ if (process) {
+ process.status = 'running';
+ process.batchId = state.download_process_id;
+ const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
+ const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
+ if (beginBtn) beginBtn.style.display = 'none';
+ if (cancelBtn) cancelBtn.style.display = 'inline-block';
+ startModalDownloadPolling(virtualPlaylistId);
+ }
+ }
+ } else {
+ showToast('No Spotify tracks found for download', 'error');
+ }
+ } catch (error) {
+ console.error(`Error rehydrating Spotify public download modal: ${error}`);
+ }
+}
+
+async function openSpotifyPublicDiscoveryModal(urlHash, playlistData) {
+ console.log(`๐ต Opening Spotify public discovery modal (reusing YouTube modal): ${playlistData.name}`);
+
+ const fakeUrlHash = `spotifypublic_${urlHash}`;
+
+ const cardState = spotifyPublicPlaylistStates[urlHash];
+ const isAlreadyDiscovered = cardState && (cardState.phase === 'discovered' || cardState.phase === 'syncing' || cardState.phase === 'sync_complete');
+ const isCurrentlyDiscovering = cardState && cardState.phase === 'discovering';
+
+ let transformedResults = [];
+ let actualMatches = 0;
+ if (isAlreadyDiscovered && cardState.discovery_results) {
+ transformedResults = cardState.discovery_results.map((result, index) => {
+ const isFound = result.status === 'found' ||
+ result.status === 'โ
Found' ||
+ result.status_class === 'found' ||
+ result.spotify_data ||
+ result.spotify_track;
+ if (isFound) actualMatches++;
+
+ return {
+ index: index,
+ yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown',
+ yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown',
+ status: isFound ? 'โ
Found' : 'โ Not Found',
+ status_class: isFound ? 'found' : 'not-found',
+ spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
+ spotify_artist: result.spotify_data && result.spotify_data.artists ?
+ (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
+ spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
+ spotify_data: result.spotify_data,
+ spotify_id: result.spotify_id,
+ manual_match: result.manual_match
+ };
+ });
+ console.log(`๐ต Spotify public modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
+ }
+
+ // Normalize artist objects to strings for the discovery modal table
+ const normalizedTracks = playlistData.tracks.map(t => ({
+ ...t,
+ artists: Array.isArray(t.artists)
+ ? t.artists.map(a => typeof a === 'object' ? a.name : a)
+ : t.artists
+ }));
+
+ const modalPhase = cardState ? cardState.phase : 'fresh';
+ youtubePlaylistStates[fakeUrlHash] = {
+ phase: modalPhase,
+ playlist: {
+ name: playlistData.name,
+ tracks: normalizedTracks
+ },
+ is_spotify_public_playlist: true,
+ spotify_public_playlist_id: urlHash,
+ discovery_progress: isAlreadyDiscovered ? 100 : 0,
+ spotify_matches: isAlreadyDiscovered ? actualMatches : 0,
+ spotifyMatches: isAlreadyDiscovered ? actualMatches : 0,
+ spotify_total: playlistData.tracks.length,
+ discovery_results: transformedResults,
+ discoveryResults: transformedResults,
+ discoveryProgress: isAlreadyDiscovered ? 100 : 0
+ };
+
+ if (!isAlreadyDiscovered && !isCurrentlyDiscovering) {
+ try {
+ console.log(`๐ Starting Spotify public discovery for: ${playlistData.name}`);
+
+ const response = await fetch(`/api/spotify-public/discovery/start/${urlHash}`, {
+ method: 'POST'
+ });
+
+ const result = await response.json();
+
+ if (result.error) {
+ console.error('Error starting Spotify public discovery:', result.error);
+ showToast(`Error starting discovery: ${result.error}`, 'error');
+ return;
+ }
+
+ console.log('Spotify public discovery started, beginning polling...');
+
+ spotifyPublicPlaylistStates[urlHash].phase = 'discovering';
+ updateSpotifyPublicCardPhase(urlHash, 'discovering');
+ youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
+
+ startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash);
+
+ } catch (error) {
+ console.error('Error starting Spotify public discovery:', error);
+ showToast(`Error starting discovery: ${error.message}`, 'error');
+ }
+ } else if (isCurrentlyDiscovering) {
+ console.log(`๐ Resuming Spotify public discovery polling for: ${playlistData.name}`);
+ startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash);
+ } else if (cardState && cardState.phase === 'syncing') {
+ console.log(`๐ Resuming Spotify public sync polling for: ${playlistData.name}`);
+ startSpotifyPublicSyncPolling(fakeUrlHash);
+ } else {
+ console.log('Using existing results - no need to re-discover');
+ }
+
+ openYouTubeDiscoveryModal(fakeUrlHash);
+}
+
+function startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash) {
+ console.log(`๐ Starting Spotify public discovery polling for: ${urlHash}`);
+
+ if (activeYouTubePollers[fakeUrlHash]) {
+ clearInterval(activeYouTubePollers[fakeUrlHash]);
+ }
+
+ // WebSocket subscription
+ if (socketConnected) {
+ socket.emit('discovery:subscribe', { ids: [urlHash] });
+ _discoveryProgressCallbacks[urlHash] = (data) => {
+ if (data.error) {
+ if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
+ socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
+ return;
+ }
+ const transformed = {
+ progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total,
+ results: (data.results || []).map((r, i) => {
+ const isFound = r.status === 'found' || r.status === 'โ
Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track;
+ return {
+ index: i, yt_track: r.spotify_public_track ? r.spotify_public_track.name : 'Unknown',
+ yt_artist: r.spotify_public_track ? (r.spotify_public_track.artists ? r.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown',
+ status: isFound ? 'โ
Found' : 'โ Not Found', status_class: isFound ? 'found' : 'not-found',
+ spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
+ spotify_artist: r.spotify_data && r.spotify_data.artists ? (Array.isArray(r.spotify_data.artists) ? r.spotify_data.artists.join(', ') : r.spotify_data.artists) : (r.spotify_artist || '-'),
+ spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
+ spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match
+ };
+ })
+ };
+ const st = youtubePlaylistStates[fakeUrlHash];
+ if (st) {
+ st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
+ st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
+ st.discovery_results = data.results; st.discoveryResults = transformed.results;
+ st.phase = data.phase;
+ updateYouTubeDiscoveryModal(fakeUrlHash, transformed);
+ }
+ if (spotifyPublicPlaylistStates[urlHash]) {
+ spotifyPublicPlaylistStates[urlHash].phase = data.phase;
+ spotifyPublicPlaylistStates[urlHash].discovery_results = data.results;
+ spotifyPublicPlaylistStates[urlHash].spotify_matches = data.spotify_matches;
+ spotifyPublicPlaylistStates[urlHash].discovery_progress = data.progress;
+ updateSpotifyPublicCardPhase(urlHash, data.phase);
+ }
+ updateSpotifyPublicCardProgress(urlHash, data);
+ if (data.complete) {
+ if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
+ socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
+ }
+ };
+ }
+
+ const pollInterval = setInterval(async () => {
+ if (socketConnected) return;
+ try {
+ const response = await fetch(`/api/spotify-public/discovery/status/${urlHash}`);
+ const status = await response.json();
+
+ if (status.error) {
+ console.error('Error polling Spotify public discovery status:', status.error);
+ clearInterval(pollInterval);
+ delete activeYouTubePollers[fakeUrlHash];
+ return;
+ }
+
+ const transformedStatus = {
+ progress: status.progress,
+ spotify_matches: status.spotify_matches,
+ spotify_total: status.spotify_total,
+ results: status.results.map((result, index) => {
+ const isFound = result.status === 'found' ||
+ result.status === 'โ
Found' ||
+ result.status_class === 'found' ||
+ result.spotify_data ||
+ result.spotify_track;
+
+ return {
+ index: index,
+ yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown',
+ yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown',
+ status: isFound ? 'โ
Found' : 'โ Not Found',
+ status_class: isFound ? 'found' : 'not-found',
+ spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
+ spotify_artist: result.spotify_data && result.spotify_data.artists ?
+ (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
+ spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
+ spotify_data: result.spotify_data,
+ spotify_id: result.spotify_id,
+ manual_match: result.manual_match
+ };
+ })
+ };
+
+ const state = youtubePlaylistStates[fakeUrlHash];
+ if (state) {
+ state.discovery_progress = status.progress;
+ state.discoveryProgress = status.progress;
+ state.spotify_matches = status.spotify_matches;
+ state.spotifyMatches = status.spotify_matches;
+ state.discovery_results = status.results;
+ state.discoveryResults = transformedStatus.results;
+ state.phase = status.phase;
+
+ updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
+
+ if (spotifyPublicPlaylistStates[urlHash]) {
+ spotifyPublicPlaylistStates[urlHash].phase = status.phase;
+ spotifyPublicPlaylistStates[urlHash].discovery_results = status.results;
+ spotifyPublicPlaylistStates[urlHash].spotify_matches = status.spotify_matches;
+ spotifyPublicPlaylistStates[urlHash].discovery_progress = status.progress;
+ updateSpotifyPublicCardPhase(urlHash, status.phase);
+ }
+
+ updateSpotifyPublicCardProgress(urlHash, status);
+
+ console.log(`๐ Spotify public discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
+ }
+
+ if (status.complete) {
+ console.log(`Spotify public discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
+ clearInterval(pollInterval);
+ delete activeYouTubePollers[fakeUrlHash];
+ }
+
+ } catch (error) {
+ console.error('Error polling Spotify public discovery:', error);
+ clearInterval(pollInterval);
+ delete activeYouTubePollers[fakeUrlHash];
+ }
+ }, 1000);
+
+ activeYouTubePollers[fakeUrlHash] = pollInterval;
+}
+
+async function loadSpotifyPublicPlaylistStatesFromBackend() {
+ try {
+ console.log('๐ต Loading Spotify public playlist states from backend...');
+
+ const response = await fetch('/api/spotify-public/playlists/states');
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to fetch Spotify public playlist states');
+ }
+
+ const data = await response.json();
+ const states = data.states || [];
+
+ console.log(`๐ต Found ${states.length} stored Spotify public playlist states in backend`);
+
+ if (states.length === 0) return;
+
+ for (const stateInfo of states) {
+ await applySpotifyPublicPlaylistState(stateInfo);
+ }
+
+ // Rehydrate download modals for playlists in downloading/download_complete phases
+ for (const stateInfo of states) {
+ if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') &&
+ stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) {
+
+ const convertedPlaylistId = stateInfo.converted_spotify_playlist_id;
+
+ if (!activeDownloadProcesses[convertedPlaylistId]) {
+ console.log(`Rehydrating download modal for Spotify public playlist: ${stateInfo.playlist_id}`);
+ try {
+ const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id));
+ if (!playlistData) continue;
+
+ const spotifyTracks = spotifyPublicPlaylistStates[stateInfo.playlist_id]?.discovery_results
+ ?.filter(result => result.spotify_data)
+ ?.map(result => result.spotify_data) || [];
+
+ if (spotifyTracks.length > 0) {
+ await openDownloadMissingModalForTidal(
+ convertedPlaylistId,
+ playlistData.name,
+ spotifyTracks
+ );
+
+ const process = activeDownloadProcesses[convertedPlaylistId];
+ if (process) {
+ process.status = 'running';
+ process.batchId = stateInfo.download_process_id;
+ const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
+ const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
+ if (beginBtn) beginBtn.style.display = 'none';
+ if (cancelBtn) cancelBtn.style.display = 'inline-block';
+ startModalDownloadPolling(convertedPlaylistId);
+ }
+ }
+ } catch (error) {
+ console.error(`Error rehydrating Spotify public download modal for ${stateInfo.playlist_id}:`, error);
+ }
+ }
+ }
+ }
+
+ console.log('Spotify public playlist states loaded and applied');
+
+ } catch (error) {
+ console.error('Error loading Spotify public playlist states:', error);
+ }
+}
+
+async function applySpotifyPublicPlaylistState(stateInfo) {
+ const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
+
+ try {
+ console.log(`๐ต Applying saved state for Spotify public playlist: ${playlist_id}, Phase: ${phase}`);
+
+ const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(playlist_id));
+ if (!playlistData) {
+ console.warn(`Playlist data not found for state ${playlist_id} - skipping`);
+ return;
+ }
+
+ if (!spotifyPublicPlaylistStates[playlist_id]) {
+ spotifyPublicPlaylistStates[playlist_id] = {
+ playlist: playlistData,
+ phase: 'fresh'
+ };
+ }
+
+ spotifyPublicPlaylistStates[playlist_id].phase = phase;
+ spotifyPublicPlaylistStates[playlist_id].discovery_progress = discovery_progress;
+ spotifyPublicPlaylistStates[playlist_id].spotify_matches = spotify_matches;
+ spotifyPublicPlaylistStates[playlist_id].discovery_results = discovery_results;
+ spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
+ spotifyPublicPlaylistStates[playlist_id].download_process_id = download_process_id;
+ spotifyPublicPlaylistStates[playlist_id].playlist = playlistData;
+
+ if (phase !== 'fresh' && phase !== 'discovering') {
+ try {
+ const stateResponse = await fetch(`/api/spotify-public/state/${playlist_id}`);
+ if (stateResponse.ok) {
+ const fullState = await stateResponse.json();
+ if (fullState.discovery_results && spotifyPublicPlaylistStates[playlist_id]) {
+ spotifyPublicPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
+ spotifyPublicPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
+ spotifyPublicPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
+ spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
+ spotifyPublicPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
+ }
+ }
+ } catch (error) {
+ console.warn(`Error fetching full discovery results for Spotify public playlist ${playlistData.name}:`, error.message);
+ }
+ }
+
+ updateSpotifyPublicCardPhase(playlist_id, phase);
+
+ if (phase === 'discovered' && spotifyPublicPlaylistStates[playlist_id]) {
+ const progressInfo = {
+ spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
+ spotify_matches: spotifyPublicPlaylistStates[playlist_id].spotify_matches || 0
+ };
+ updateSpotifyPublicCardProgress(playlist_id, progressInfo);
+ }
+
+ if (phase === 'discovering') {
+ const fakeUrlHash = `spotifypublic_${playlist_id}`;
+ startSpotifyPublicDiscoveryPolling(fakeUrlHash, playlist_id);
+ } else if (phase === 'syncing') {
+ const fakeUrlHash = `spotifypublic_${playlist_id}`;
+ startSpotifyPublicSyncPolling(fakeUrlHash);
+ }
+
+ } catch (error) {
+ console.error(`Error applying Spotify public playlist state for ${playlist_id}:`, error);
+ }
+}
+
+function updateSpotifyPublicCardPhase(urlHash, phase) {
+ const state = spotifyPublicPlaylistStates[urlHash];
+ if (!state) return;
+
+ state.phase = phase;
+
+ const card = document.getElementById(`spotify-public-card-${urlHash}`);
+ if (card) {
+ const newCardHtml = createSpotifyPublicCard(state.playlist);
+ card.outerHTML = newCardHtml;
+
+ const newCard = document.getElementById(`spotify-public-card-${urlHash}`);
+ if (newCard) {
+ newCard.addEventListener('click', () => handleSpotifyPublicCardClick(urlHash));
+ }
+
+ if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
+ setTimeout(() => {
+ updateSpotifyPublicCardSyncProgress(urlHash, state.lastSyncProgress);
+ }, 0);
+ }
+ }
+}
+
+function updateSpotifyPublicCardProgress(urlHash, progress) {
+ const state = spotifyPublicPlaylistStates[urlHash];
+ if (!state) return;
+
+ const card = document.getElementById(`spotify-public-card-${urlHash}`);
+ if (!card) return;
+
+ const progressElement = card.querySelector('.playlist-card-progress');
+ if (!progressElement) return;
+
+ progressElement.classList.remove('hidden');
+
+ const total = progress.spotify_total || 0;
+ const matches = progress.spotify_matches || 0;
+
+ if (total > 0) {
+ progressElement.innerHTML = `
+
+ โ ${matches}
+ /
+ โช ${total}
+
+ `;
+ }
+}
+
+// ===============================
+// SPOTIFY PUBLIC SYNC FUNCTIONALITY
+// ===============================
+
+async function startSpotifyPublicPlaylistSync(urlHash) {
+ try {
+ console.log('๐ต Starting Spotify public playlist sync:', urlHash);
+
+ const state = youtubePlaylistStates[urlHash];
+ if (!state || !state.is_spotify_public_playlist) {
+ console.error('Invalid Spotify public playlist state for sync');
+ return;
+ }
+
+ const playlistId = state.spotify_public_playlist_id;
+ const response = await fetch(`/api/spotify-public/sync/start/${playlistId}`, {
+ method: 'POST'
+ });
+
+ const result = await response.json();
+
+ if (result.error) {
+ showToast(`Error starting sync: ${result.error}`, 'error');
+ return;
+ }
+
+ const syncPlaylistId = result.sync_playlist_id;
+ if (state) state.syncPlaylistId = syncPlaylistId;
+
+ updateSpotifyPublicCardPhase(playlistId, 'syncing');
+ updateSpotifyPublicModalButtons(urlHash, 'syncing');
+
+ startSpotifyPublicSyncPolling(urlHash, syncPlaylistId);
+
+ showToast('Spotify public playlist sync started!', 'success');
+
+ } catch (error) {
+ console.error('Error starting Spotify public sync:', error);
+ showToast(`Error starting sync: ${error.message}`, 'error');
+ }
+}
+
+function startSpotifyPublicSyncPolling(urlHash, syncPlaylistId) {
+ if (activeYouTubePollers[urlHash]) {
+ clearInterval(activeYouTubePollers[urlHash]);
+ }
+
+ const state = youtubePlaylistStates[urlHash];
+ const playlistId = state.spotify_public_playlist_id;
+
+ syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId);
+
+ // WebSocket subscription
+ if (socketConnected && syncPlaylistId) {
+ socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
+ _syncProgressCallbacks[syncPlaylistId] = (data) => {
+ const progress = data.progress || {};
+ updateSpotifyPublicCardSyncProgress(playlistId, progress);
+ updateSpotifyPublicModalSyncProgress(urlHash, progress);
+
+ if (data.status === 'finished') {
+ if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
+ socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
+ delete _syncProgressCallbacks[syncPlaylistId];
+ if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete';
+ if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
+ updateSpotifyPublicCardPhase(playlistId, 'sync_complete');
+ updateSpotifyPublicModalButtons(urlHash, 'sync_complete');
+ showToast('Spotify public playlist sync complete!', 'success');
+ } else if (data.status === 'error' || data.status === 'cancelled') {
+ if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
+ socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
+ delete _syncProgressCallbacks[syncPlaylistId];
+ if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered';
+ if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
+ updateSpotifyPublicCardPhase(playlistId, 'discovered');
+ updateSpotifyPublicModalButtons(urlHash, 'discovered');
+ showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
+ }
+ };
+ }
+
+ const pollFunction = async () => {
+ if (socketConnected) return;
+ try {
+ const response = await fetch(`/api/spotify-public/sync/status/${playlistId}`);
+ const status = await response.json();
+
+ if (status.error) {
+ console.error('Error polling Spotify public sync status:', status.error);
+ clearInterval(pollInterval);
+ delete activeYouTubePollers[urlHash];
+ return;
+ }
+
+ updateSpotifyPublicCardSyncProgress(playlistId, status.progress);
+ updateSpotifyPublicModalSyncProgress(urlHash, status.progress);
+
+ if (status.complete) {
+ clearInterval(pollInterval);
+ delete activeYouTubePollers[urlHash];
+ if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete';
+ if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
+ updateSpotifyPublicCardPhase(playlistId, 'sync_complete');
+ updateSpotifyPublicModalButtons(urlHash, 'sync_complete');
+ showToast('Spotify public playlist sync complete!', 'success');
+ } else if (status.sync_status === 'error') {
+ clearInterval(pollInterval);
+ delete activeYouTubePollers[urlHash];
+ if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered';
+ if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
+ updateSpotifyPublicCardPhase(playlistId, 'discovered');
+ updateSpotifyPublicModalButtons(urlHash, 'discovered');
+ showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
+ }
+ } catch (error) {
+ console.error('Error polling Spotify public sync:', error);
+ if (activeYouTubePollers[urlHash]) {
+ clearInterval(activeYouTubePollers[urlHash]);
+ delete activeYouTubePollers[urlHash];
+ }
+ }
+ };
+
+ if (!socketConnected) pollFunction();
+
+ const pollInterval = setInterval(pollFunction, 1000);
+ activeYouTubePollers[urlHash] = pollInterval;
+}
+
+async function cancelSpotifyPublicSync(urlHash) {
+ try {
+ console.log('Cancelling Spotify public sync:', urlHash);
+
+ const state = youtubePlaylistStates[urlHash];
+ if (!state || !state.is_spotify_public_playlist) {
+ console.error('Invalid Spotify public playlist state');
+ return;
+ }
+
+ const playlistId = state.spotify_public_playlist_id;
+ const response = await fetch(`/api/spotify-public/sync/cancel/${playlistId}`, {
+ method: 'POST'
+ });
+
+ const result = await response.json();
+
+ if (result.error) {
+ showToast(`Error cancelling sync: ${result.error}`, 'error');
+ return;
+ }
+
+ if (activeYouTubePollers[urlHash]) {
+ clearInterval(activeYouTubePollers[urlHash]);
+ delete activeYouTubePollers[urlHash];
+ }
+
+ const syncId = state && state.syncPlaylistId;
+ if (syncId && _syncProgressCallbacks[syncId]) {
+ if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] });
+ delete _syncProgressCallbacks[syncId];
+ }
+
+ updateSpotifyPublicCardPhase(playlistId, 'discovered');
+ updateSpotifyPublicModalButtons(urlHash, 'discovered');
+
+ showToast('Spotify public sync cancelled', 'info');
+
+ } catch (error) {
+ console.error('Error cancelling Spotify public sync:', error);
+ showToast(`Error cancelling sync: ${error.message}`, 'error');
+ }
+}
+
+function updateSpotifyPublicCardSyncProgress(urlHash, progress) {
+ const state = spotifyPublicPlaylistStates[urlHash];
+ if (!state || !state.playlist || !progress) return;
+
+ state.lastSyncProgress = progress;
+
+ const card = document.getElementById(`spotify-public-card-${urlHash}`);
+ if (!card) return;
+
+ const progressElement = card.querySelector('.playlist-card-progress');
+
+ let statusCounterHTML = '';
+ if (progress && progress.total_tracks > 0) {
+ const matched = progress.matched_tracks || 0;
+ const failed = progress.failed_tracks || 0;
+ const total = progress.total_tracks || 0;
+ const processed = matched + failed;
+ const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
+
+ statusCounterHTML = `
+
+ โช ${total}
+ /
+ โ ${matched}
+ /
+ โ ${failed}
+ (${percentage}%)
+
+ `;
+ }
+
+ if (statusCounterHTML) {
+ progressElement.innerHTML = statusCounterHTML;
+ }
+}
+
+function updateSpotifyPublicModalSyncProgress(urlHash, progress) {
+ const statusDisplay = document.getElementById(`spotify-public-sync-status-${urlHash}`);
+ if (!statusDisplay || !progress) return;
+
+ const totalEl = document.getElementById(`spotify-public-total-${urlHash}`);
+ const matchedEl = document.getElementById(`spotify-public-matched-${urlHash}`);
+ const failedEl = document.getElementById(`spotify-public-failed-${urlHash}`);
+ const percentageEl = document.getElementById(`spotify-public-percentage-${urlHash}`);
+
+ const total = progress.total_tracks || 0;
+ const matched = progress.matched_tracks || 0;
+ const failed = progress.failed_tracks || 0;
+
+ if (totalEl) totalEl.textContent = total;
+ if (matchedEl) matchedEl.textContent = matched;
+ if (failedEl) failedEl.textContent = failed;
+
+ if (total > 0) {
+ const processed = matched + failed;
+ const percentage = Math.round((processed / total) * 100);
+ if (percentageEl) percentageEl.textContent = percentage;
+ }
+}
+
+function updateSpotifyPublicModalButtons(urlHash, phase) {
+ const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
+ if (!modal) return;
+
+ const footerLeft = modal.querySelector('.modal-footer-left');
+ if (footerLeft) {
+ footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
+ }
+}
+
+async function startSpotifyPublicDownloadMissing(urlHash) {
+ try {
+ console.log('๐ Starting download missing tracks for Spotify public playlist:', urlHash);
+
+ const state = youtubePlaylistStates[urlHash];
+ if (!state || !state.is_spotify_public_playlist) {
+ console.error('Invalid Spotify public playlist state for download');
+ return;
+ }
+
+ const discoveryResults = state.discoveryResults || state.discovery_results;
+
+ if (!discoveryResults) {
+ showToast('No discovery results available for download', 'error');
+ return;
+ }
+
+ const spotifyTracks = [];
+ for (const result of discoveryResults) {
+ if (result.spotify_data) {
+ spotifyTracks.push(result.spotify_data);
+ } else if (result.spotify_track && result.status_class === 'found') {
+ const albumData = result.spotify_album || 'Unknown Album';
+ const albumObject = typeof albumData === 'object' && albumData !== null
+ ? albumData
+ : {
+ name: typeof albumData === 'string' ? albumData : 'Unknown Album',
+ album_type: 'album',
+ images: []
+ };
+
+ spotifyTracks.push({
+ id: result.spotify_id || 'unknown',
+ name: result.spotify_track || 'Unknown Track',
+ artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
+ album: albumObject,
+ duration_ms: 0
+ });
+ }
+ }
+
+ if (spotifyTracks.length === 0) {
+ showToast('No Spotify matches found for download', 'error');
+ return;
+ }
+
+ const realUrlHash = state.spotify_public_playlist_id;
+ const virtualPlaylistId = `spotify_public_${realUrlHash}`;
+ const playlistName = state.playlist.name;
+
+ state.convertedSpotifyPlaylistId = virtualPlaylistId;
+
+ // Sync convertedSpotifyPlaylistId to spotifyPublicPlaylistStates for card click routing
+ if (realUrlHash && spotifyPublicPlaylistStates[realUrlHash]) {
+ spotifyPublicPlaylistStates[realUrlHash].convertedSpotifyPlaylistId = virtualPlaylistId;
+ }
+
+ const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
+ if (discoveryModal) {
+ discoveryModal.classList.add('hidden');
+ }
+
+ await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
+
+ } catch (error) {
+ console.error('Error starting Spotify public download missing:', error);
+ showToast(`Error: ${error.message}`, 'error');
+ }
+}
+
// ===============================
// YOUTUBE PLAYLIST FUNCTIONALITY
// ===============================
@@ -26191,7 +27336,7 @@ async function parseYouTubePlaylist() {
mirrorPlaylist('youtube', result.url_hash, result.name, result.tracks.map(t => ({
track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''),
album_name: '', duration_ms: t.duration_ms || 0, source_track_id: t.id || ''
- })));
+ })), { description: url });
// Clear input
urlInput.value = '';
@@ -26640,6 +27785,8 @@ function openYouTubeDiscoveryModal(urlHash) {
startTidalSyncPolling(urlHash);
} else if (state.is_deezer_playlist) {
startDeezerSyncPolling(urlHash);
+ } else if (state.is_spotify_public_playlist) {
+ startSpotifyPublicSyncPolling(urlHash);
} else if (state.is_beatport_playlist) {
startBeatportSyncPolling(urlHash);
} else if (state.is_listenbrainz_playlist) {
@@ -26649,19 +27796,22 @@ function openYouTubeDiscoveryModal(urlHash) {
}
}
} else {
- // Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, and Mirrored)
+ // Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, Spotify Public, and Mirrored)
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
+ const isSpotifyPublic = state.is_spotify_public_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const isMirrored = state.is_mirrored_playlist;
const modalTitle = isMirrored ? '๐ต Mirrored Playlist Discovery' :
+ isSpotifyPublic ? '๐ต Spotify Playlist Discovery' :
isDeezer ? '๐ต Deezer Playlist Discovery' :
isTidal ? '๐ต Tidal Playlist Discovery' :
isBeatport ? '๐ต Beatport Chart Discovery' :
isListenBrainz ? '๐ต ListenBrainz Playlist Discovery' :
'๐ต YouTube Playlist Discovery';
const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') :
+ isSpotifyPublic ? 'Spotify' :
isDeezer ? 'Deezer' :
isTidal ? 'Tidal' :
isBeatport ? 'Beatport' :
@@ -26674,7 +27824,7 @@ function openYouTubeDiscoveryModal(urlHash) {
@@ -26809,6 +27959,8 @@ function openYouTubeDiscoveryModal(urlHash) {
startTidalSyncPolling(urlHash);
} else if (state.is_deezer_playlist) {
startDeezerSyncPolling(urlHash);
+ } else if (state.is_spotify_public_playlist) {
+ startSpotifyPublicSyncPolling(urlHash);
} else if (state.is_beatport_playlist) {
startBeatportSyncPolling(urlHash);
} else {
@@ -26828,6 +27980,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
const isTidal = state && state.is_tidal_playlist;
const isDeezer = state && state.is_deezer_playlist;
+ const isSpotifyPublic = state && state.is_spotify_public_playlist;
const isBeatport = state && state.is_beatport_playlist;
const isListenBrainz = state && state.is_listenbrainz_playlist;
@@ -26847,6 +28000,8 @@ function getModalActionButtons(urlHash, phase, state = null) {
return `
`;
} else if (isDeezer) {
return `
`;
+ } else if (isSpotifyPublic) {
+ return `
`;
} else if (isBeatport) {
return `
`;
} else {
@@ -26875,6 +28030,8 @@ function getModalActionButtons(urlHash, phase, state = null) {
buttons += `
`;
} else if (isDeezer) {
buttons += `
`;
+ } else if (isSpotifyPublic) {
+ buttons += `
`;
} else if (isBeatport) {
buttons += `
`;
} else {
@@ -26885,12 +28042,13 @@ function getModalActionButtons(urlHash, phase, state = null) {
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
- // ListenBrainz uses same download function as others (to be implemented)
buttons += `
`;
} else if (isTidal) {
buttons += `
`;
} else if (isDeezer) {
buttons += `
`;
+ } else if (isSpotifyPublic) {
+ buttons += `
`;
} else if (isBeatport) {
buttons += `
`;
} else {
@@ -26950,6 +28108,18 @@ function getModalActionButtons(urlHash, phase, state = null) {
(0%)
`;
+ } else if (isSpotifyPublic) {
+ return `
+