From 628e8b7709e89aa4bb1bddf94ce8ec58f9cd347c Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 24 Aug 2025 19:47:24 -0700 Subject: [PATCH] sync working --- web_server.py | 322 ++++++++++++++++++++++++++++++++++++++++- webui/static/script.js | 279 +++++++++++++++++++++++++++++++++-- webui/static/style.css | 69 +++++++++ 3 files changed, 655 insertions(+), 15 deletions(-) diff --git a/web_server.py b/web_server.py index b2593aeb..175f2a6e 100644 --- a/web_server.py +++ b/web_server.py @@ -18,7 +18,7 @@ from flask import Flask, render_template, request, jsonify, redirect, send_file # --- Core Application Imports --- # Import the same core clients and config manager used by the GUI app from config.settings import config_manager -from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist +from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack from core.plex_client import PlexClient from core.jellyfin_client import JellyfinClient from core.soulseek_client import SoulseekClient @@ -97,13 +97,13 @@ db_update_state = { "total": 0, "error_message": "" } -db_update_lock = threading.Lock() -# --- Add these globals for the Sync Page --- +# --- Sync Page Globals --- sync_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SyncWorker") active_sync_workers = {} # Key: playlist_id, Value: Future object sync_states = {} # Key: playlist_id, Value: dict with progress info sync_lock = threading.Lock() +db_update_lock = threading.Lock() # --- Global Matched Downloads Context Management --- # Thread-safe storage for matched download contexts @@ -2455,12 +2455,326 @@ def get_playlist_tracks(playlist_id): 'track_count': full_playlist.total_tracks, 'image_url': getattr(full_playlist, 'image_url', None), 'snapshot_id': getattr(full_playlist, 'snapshot_id', ''), - 'tracks': [{'name': t.name, 'artists': t.artists, 'album': t.album, 'duration_ms': t.duration_ms} for t in full_playlist.tracks] + 'tracks': [{'id': t.id, 'name': t.name, 'artists': t.artists, 'album': t.album, 'duration_ms': t.duration_ms, 'popularity': t.popularity} for t in full_playlist.tracks] } return jsonify(playlist_dict) except Exception as e: return jsonify({"error": str(e)}), 500 +# Add these new endpoints to the end of web_server.py + +def _run_sync_task(playlist_id, playlist_name, tracks_json): + """The actual sync function that runs in the background thread.""" + global sync_states, sync_service + + print(f"๐Ÿš€ _run_sync_task STARTED for playlist '{playlist_name}' (ID: {playlist_id})") + print(f"๐Ÿ“Š Received {len(tracks_json)} tracks from frontend") + + try: + # Recreate a Playlist object from the JSON data sent by the frontend + # This avoids needing to re-fetch it from Spotify + print(f"๐Ÿ”„ Converting JSON tracks to SpotifyTrack objects...") + tracks = [] + for i, t in enumerate(tracks_json): + # Create SpotifyTrack objects with proper default values for missing fields + track = SpotifyTrack( + id=t.get('id', ''), # Provide default empty string + name=t.get('name', ''), + artists=t.get('artists', []), + album=t.get('album', ''), + duration_ms=t.get('duration_ms', 0), + popularity=t.get('popularity', 0), # Default value + preview_url=t.get('preview_url'), + external_urls=t.get('external_urls') + ) + tracks.append(track) + if i < 3: # Log first 3 tracks for debugging + print(f" Track {i+1}: '{track.name}' by {track.artists}") + + print(f"โœ… Created {len(tracks)} SpotifyTrack objects") + + playlist = SpotifyPlaylist( + id=playlist_id, + name=playlist_name, + description=None, # Not needed for sync + owner="web_user", # Placeholder + public=False, # Default + collaborative=False, # Default + tracks=tracks, + total_tracks=len(tracks) + ) + print(f"โœ… Created SpotifyPlaylist object: '{playlist.name}' with {playlist.total_tracks} tracks") + + def progress_callback(progress): + """Callback to update the shared state.""" + print(f"โšก PROGRESS CALLBACK: {progress.current_step} - {progress.current_track}") + print(f" ๐Ÿ“Š Progress: {progress.progress}% ({progress.matched_tracks}/{progress.total_tracks} matched, {progress.failed_tracks} failed)") + + with sync_lock: + sync_states[playlist_id] = { + "status": "syncing", + "progress": progress.__dict__ # Convert dataclass to dict + } + print(f" โœ… Updated sync_states for {playlist_id}") + + except Exception as setup_error: + print(f"โŒ SETUP ERROR in _run_sync_task: {setup_error}") + import traceback + traceback.print_exc() + with sync_lock: + sync_states[playlist_id] = { + "status": "error", + "error": f"Setup error: {str(setup_error)}" + } + return + + try: + print(f"๐Ÿ”ง Setting up sync service...") + print(f" sync_service available: {sync_service is not None}") + + if sync_service is None: + raise Exception("sync_service is None - not initialized properly") + + # Check sync service components + print(f" spotify_client: {sync_service.spotify_client is not None}") + print(f" plex_client: {sync_service.plex_client is not None}") + print(f" jellyfin_client: {sync_service.jellyfin_client is not None}") + + # Check media server connection before starting + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + print(f" Active media server: {active_server}") + + media_client, server_type = sync_service._get_active_media_client() + print(f" Media client available: {media_client is not None}") + + if media_client: + is_connected = media_client.is_connected() + print(f" Media client connected: {is_connected}") + + # Check database access + try: + from database.music_database import MusicDatabase + db = MusicDatabase() + print(f" Database initialized: {db is not None}") + except Exception as db_error: + print(f" โŒ Database initialization failed: {db_error}") + + print(f"๐Ÿ”„ Attaching progress callback...") + # Attach the progress callback + sync_service.set_progress_callback(progress_callback, playlist.name) + print(f"โœ… Progress callback attached for playlist: {playlist.name}") + + # CRITICAL FIX: Add database-only fallback for web context + # If media client is not connected, patch the sync service to use database-only matching + if media_client is None or not media_client.is_connected(): + print(f"โš ๏ธ Media client not connected - patching sync service for database-only matching") + + # Store original method + original_find_track = sync_service._find_track_in_media_server + + # Create database-only replacement method + async def database_only_find_track(spotify_track): + print(f"๐Ÿ—ƒ๏ธ Database-only search for: '{spotify_track.name}' by {spotify_track.artists}") + try: + from database.music_database import MusicDatabase + from config.settings import config_manager + + db = MusicDatabase() + active_server = config_manager.get_active_media_server() + original_title = spotify_track.name + + # Try each artist (same logic as original) + for artist in spotify_track.artists: + artist_name = artist if isinstance(artist, str) else str(artist) + + db_track, confidence = db.check_track_exists( + original_title, artist_name, + confidence_threshold=0.7, + server_source=active_server + ) + + if db_track and confidence >= 0.7: + print(f"โœ… Database match: '{db_track.title}' (confidence: {confidence:.2f})") + + # Create mock track object for playlist creation + class DatabaseTrackMock: + def __init__(self, db_track): + self.ratingKey = db_track.id + self.title = db_track.title + self.id = db_track.id + # Add any other attributes needed for playlist creation + + return DatabaseTrackMock(db_track), confidence + + print(f"โŒ No database match found for: '{original_title}'") + return None, 0.0 + + except Exception as e: + print(f"โŒ Database search error: {e}") + return None, 0.0 + + # Patch the method + sync_service._find_track_in_media_server = database_only_find_track + print(f"โœ… Patched sync service to use database-only matching") + + print(f"๐Ÿš€ Starting actual sync process with asyncio.run()...") + # Run the sync (this is a blocking call within this thread) + result = asyncio.run(sync_service.sync_playlist(playlist, download_missing=False)) + print(f"โœ… Sync process completed! Result type: {type(result)}") + print(f" Result details: matched={getattr(result, 'matched_tracks', 'N/A')}, total={getattr(result, 'total_tracks', 'N/A')}") + + # Update final state on completion + with sync_lock: + sync_states[playlist_id] = { + "status": "finished", + "result": result.__dict__ # Convert dataclass to dict + } + print(f"๐Ÿ Sync finished for {playlist_id} - state updated") + + except Exception as e: + print(f"โŒ SYNC FAILED for {playlist_id}: {e}") + import traceback + traceback.print_exc() + with sync_lock: + sync_states[playlist_id] = { + "status": "error", + "error": str(e) + } + finally: + print(f"๐Ÿงน Cleaning up progress callback for {playlist.name}") + # Clean up the callback + if sync_service: + sync_service.clear_progress_callback(playlist.name) + print(f"โœ… Cleanup completed for {playlist_id}") + + +@app.route('/api/sync/start', methods=['POST']) +def start_playlist_sync(): + """Starts a new sync process for a given playlist.""" + data = request.get_json() + playlist_id = data.get('playlist_id') + playlist_name = data.get('playlist_name') + tracks_json = data.get('tracks') # Pass the full track list + + if not all([playlist_id, playlist_name, tracks_json]): + return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400 + + with sync_lock: + if playlist_id in active_sync_workers and not active_sync_workers[playlist_id].done(): + return jsonify({"success": False, "error": "Sync is already in progress for this playlist."}), 409 + + # Initial state + sync_states[playlist_id] = {"status": "starting", "progress": {}} + + # Submit the task to the thread pool + future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json) + active_sync_workers[playlist_id] = future + + return jsonify({"success": True, "message": "Sync started."}) + + +@app.route('/api/sync/status/', methods=['GET']) +def get_sync_status(playlist_id): + """Polls for the status of an ongoing sync.""" + with sync_lock: + state = sync_states.get(playlist_id) + if not state: + return jsonify({"status": "not_found"}), 404 + + # If the task is finished but the state hasn't been updated, check the future + if state['status'] not in ['finished', 'error'] and playlist_id in active_sync_workers: + if active_sync_workers[playlist_id].done(): + # The task might have finished between polls, trigger final state update + # This is handled by the _run_sync_task itself + pass + + return jsonify(state) + + +@app.route('/api/sync/cancel', methods=['POST']) +def cancel_playlist_sync(): + """Cancels an ongoing sync process.""" + data = request.get_json() + playlist_id = data.get('playlist_id') + + if not playlist_id: + return jsonify({"success": False, "error": "Missing playlist_id."}), 400 + + with sync_lock: + future = active_sync_workers.get(playlist_id) + if not future or future.done(): + return jsonify({"success": False, "error": "Sync not running or already complete."}), 404 + + # The GUI's sync_service has a cancel_sync method. We'll replicate that idea. + # Since we can't easily stop the thread, we'll set a flag. + # The elegant solution is to have the sync_service check for a cancellation flag. + # Your `sync_service.py` already has this logic with `self._cancelled`. + sync_service.cancel_sync() + + # We can't guarantee immediate stop, but we can update the state + sync_states[playlist_id] = {"status": "cancelled"} + + # It's best practice to let the task finish and clean itself up. + # We don't use future.cancel() as it may not work if the task is already running. + + return jsonify({"success": True, "message": "Sync cancellation requested."}) + +@app.route('/api/sync/test-database', methods=['GET']) +def test_database_access(): + """Test endpoint to verify database connectivity for sync operations""" + try: + print(f"๐Ÿงช Testing database access for sync operations...") + + # Test database initialization + from database.music_database import MusicDatabase + db = MusicDatabase() + print(f" โœ… Database initialized: {db is not None}") + + # Test basic database query + stats = db.get_database_info_for_server() + print(f" โœ… Database stats retrieved: {stats}") + + # Test track existence check (like sync service does) + db_track, confidence = db.check_track_exists("test track", "test artist", confidence_threshold=0.7) + print(f" โœ… Track existence check works: found={db_track is not None}, confidence={confidence}") + + # Test config manager + from config.settings import config_manager + active_server = config_manager.get_active_media_server() + print(f" โœ… Active media server: {active_server}") + + # Test media clients + print(f" Media clients status:") + print(f" plex_client: {plex_client is not None}") + if plex_client: + print(f" plex_client.is_connected(): {plex_client.is_connected()}") + print(f" jellyfin_client: {jellyfin_client is not None}") + if jellyfin_client: + print(f" jellyfin_client.is_connected(): {jellyfin_client.is_connected()}") + + return jsonify({ + "success": True, + "message": "Database access test successful", + "details": { + "database_initialized": db is not None, + "database_stats": stats, + "active_server": active_server, + "plex_connected": plex_client.is_connected() if plex_client else False, + "jellyfin_connected": jellyfin_client.is_connected() if jellyfin_client else False, + } + }) + + except Exception as e: + print(f" โŒ Database test failed: {e}") + import traceback + traceback.print_exc() + return jsonify({ + "success": False, + "error": str(e), + "message": "Database access test failed" + }), 500 + # --- Main Execution --- if __name__ == '__main__': diff --git a/webui/static/script.js b/webui/static/script.js index c6050189..3a93b2f4 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -29,6 +29,7 @@ let dbUpdateStatusInterval = null; let spotifyPlaylists = []; let selectedPlaylists = new Set(); let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId +let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array let spotifyPlaylistsLoaded = false; // API endpoints @@ -1504,17 +1505,33 @@ function updateSyncActionsUI() { async function openPlaylistDetailsModal(event, playlistId) { event.stopPropagation(); - + const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) return; - showLoadingOverlay(`Fetching tracks for ${playlist.name}...`); + showLoadingOverlay(`Loading playlist: ${playlist.name}...`); + try { - const response = await fetch(`/api/spotify/playlist/${playlistId}`); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - - showPlaylistDetailsModal(fullPlaylist); + // --- CACHING LOGIC START --- + if (playlistTrackCache[playlistId]) { + console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`); + // Use the cached tracks instead of fetching + const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] }; + showPlaylistDetailsModal(fullPlaylist); + } else { + console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`); + // Fetch from the server if not in cache + const response = await fetch(`/api/spotify/playlist/${playlistId}`); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + + // Store the fetched tracks in the cache + playlistTrackCache[playlistId] = fullPlaylist.tracks; + console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`); + + showPlaylistDetailsModal(fullPlaylist); + } + // --- CACHING LOGIC END --- } catch (error) { showToast(`Error: ${error.message}`, 'error'); @@ -1542,6 +1559,15 @@ function showPlaylistDetailsModal(playlist) { ${playlist.track_count} tracks by ${escapeHtml(playlist.owner)} + + × @@ -1567,7 +1593,7 @@ function showPlaylistDetailsModal(playlist) { `; @@ -1588,11 +1614,242 @@ function formatDuration(ms) { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } -function startPlaylistSyncFromModal(playlistId) { - closePlaylistDetailsModal(); - showToast('Sync functionality will be implemented next!', 'info'); +// Find and REPLACE the old startPlaylistSyncFromModal function +async function startPlaylistSync(playlistId) { + console.log(`๐Ÿš€ Starting sync for playlist: ${playlistId}`); + const playlist = spotifyPlaylists.find(p => p.id === playlistId); + if (!playlist) { + console.error(`โŒ Could not find playlist data for ID: ${playlistId}`); + showToast('Could not find playlist data.', 'error'); + return; + } + console.log(`โœ… Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`); + + // Ensure we have the full track list before starting + let tracks = playlistTrackCache[playlistId]; + if (!tracks) { + console.log(`๐Ÿ”„ Cache miss - fetching tracks for playlist ${playlistId}`); + try { + const response = await fetch(`/api/spotify/playlist/${playlistId}`); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + tracks = fullPlaylist.tracks; + playlistTrackCache[playlistId] = tracks; // Cache it + console.log(`โœ… Fetched and cached ${tracks.length} tracks`); + } catch (error) { + console.error(`โŒ Failed to fetch tracks:`, error); + showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error'); + return; + } + } else { + console.log(`โœ… Using cached tracks: ${tracks.length} tracks`); + } + + // DON'T close the modal - let it show live progress like the GUI + + try { + // First test database access + console.log(`๐Ÿงช Testing database access before sync...`); + try { + const testResponse = await fetch('/api/sync/test-database'); + const testData = await testResponse.json(); + console.log(`๐Ÿงช Database test result:`, testData); + } catch (testError) { + console.warn(`โš ๏ธ Database test failed:`, testError); + } + + console.log(`๐Ÿ”„ Making API call to /api/sync/start with ${tracks.length} tracks`); + const response = await fetch('/api/sync/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: playlist.id, + playlist_name: playlist.name, + tracks: tracks // Send the full track list + }) + }); + + console.log(`๐Ÿ“ก API response status: ${response.status}`); + const data = await response.json(); + console.log(`๐Ÿ“ก API response data:`, data); + + if (!data.success) throw new Error(data.error); + + console.log(`โœ… Sync started successfully for "${playlist.name}"`); + showToast(`Sync started for "${playlist.name}"`, 'success'); + + // Show initial sync state in modal if open + const modal = document.getElementById('playlist-details-modal'); + if (modal && modal.style.display !== 'none') { + const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`); + if (statusDisplay) { + statusDisplay.style.display = 'flex'; + console.log(`๐Ÿ“Š Showing modal sync status for ${playlist.id}`); + } + } + + updateCardToSyncing(playlist.id, 0); // Initial state + startSyncPolling(playlist.id); + + } catch (error) { + console.error(`โŒ Failed to start sync:`, error); + showToast(`Failed to start sync: ${error.message}`, 'error'); + updateCardToDefault(playlist.id); + } } +// Add these new helper functions to script.js + +function startSyncPolling(playlistId) { + // Clear any existing poller for this playlist + if (activeSyncPollers[playlistId]) { + clearInterval(activeSyncPollers[playlistId]); + } + + // Start a new poller that checks every 2 seconds + console.log(`๐Ÿ”„ Starting sync polling for playlist: ${playlistId}`); + activeSyncPollers[playlistId] = setInterval(async () => { + try { + console.log(`๐Ÿ“Š Polling sync status for: ${playlistId}`); + const response = await fetch(`/api/sync/status/${playlistId}`); + const state = await response.json(); + console.log(`๐Ÿ“Š Poll response:`, state); + + if (state.status === 'syncing') { + const progress = state.progress; + console.log(`๐Ÿ“Š Sync progress:`, progress); + console.log(` ๐Ÿ“Š Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`); + console.log(` ๐Ÿ“Š Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`); + + // Use the actual progress percentage from the sync service + updateCardToSyncing(playlistId, progress.progress, progress); + // Also update the modal if it's open + updateModalSyncProgress(playlistId, progress); + } else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') { + console.log(`๐Ÿ Sync completed with status: ${state.status}`); + stopSyncPolling(playlistId); + updateCardToDefault(playlistId, state); + // Also update the modal if it's open + closePlaylistDetailsModal(); // Close modal on completion/error + } + } catch (error) { + console.error(`โŒ Error polling sync status for ${playlistId}:`, error); + stopSyncPolling(playlistId); + updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' }); + } + }, 2000); // Poll every 2 seconds +} + +function stopSyncPolling(playlistId) { + if (activeSyncPollers[playlistId]) { + clearInterval(activeSyncPollers[playlistId]); + delete activeSyncPollers[playlistId]; + } +} + +function updateCardToSyncing(playlistId, percent, progress = null) { + const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); + if (!card) return; + + const progressBar = card.querySelector('.sync-progress-indicator'); + progressBar.style.display = 'block'; + + let progressText = 'Starting...'; + let actualPercent = percent || 0; + + if (progress) { + // Use the actual progress percentage from the sync service + actualPercent = progress.progress || 0; + + // Create detailed progress text like the GUI + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const currentStep = progress.current_step || 'Processing'; + + if (total > 0) { + const processed = matched + failed; + progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`; + } else { + progressText = currentStep; + } + + // If there's a current track being processed, show it + if (progress.current_track) { + progressText += ` - ${progress.current_track}`; + } + } + + progressBar.innerHTML = ` +
+
+
+
${progressText}
+ `; +} + +function updateCardToDefault(playlistId, finalState = null) { + const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); + if (!card) return; + + const progressBar = card.querySelector('.sync-progress-indicator'); + progressBar.style.display = 'none'; + progressBar.innerHTML = ''; + + const statusEl = card.querySelector('.playlist-card-status'); + if (finalState) { + if (finalState.status === 'finished') { + statusEl.textContent = `Synced: Just now`; + statusEl.className = 'playlist-card-status status-synced'; + showToast(`Sync complete for "${card.querySelector('.playlist-card-name').textContent}"`, 'success'); + } else { + statusEl.textContent = `Sync Failed`; + statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class + showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error'); + } + } +} + +// Update the modal's sync progress display (matches GUI functionality) +function updateModalSyncProgress(playlistId, progress) { + const modal = document.getElementById('playlist-details-modal'); + if (modal && modal.style.display !== 'none') { + console.log(`๐Ÿ“Š Updating modal sync progress for ${playlistId}:`, progress); + + // Show sync status display + const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`); + if (statusDisplay) { + statusDisplay.style.display = 'flex'; + + // Update counters (matching GUI exactly) + const totalEl = document.getElementById(`modal-total-${playlistId}`); + const matchedEl = document.getElementById(`modal-matched-${playlistId}`); + const failedEl = document.getElementById(`modal-failed-${playlistId}`); + const percentageEl = document.getElementById(`modal-percentage-${playlistId}`); + + 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; + + // Calculate percentage like GUI + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } + + console.log(`๐Ÿ“Š Modal updated: โ™ช ${total} / โœ“ ${matched} / โœ— ${failed} (${Math.round((matched + failed) / total * 100)}%)`); + } else { + console.warn(`โŒ Modal sync status display not found for ${playlistId}`); + } + } else { + console.log(`๐Ÿ“Š Modal not open for ${playlistId}, skipping update`); + } +} // Download tracking state management - matching GUI functionality diff --git a/webui/static/style.css b/webui/static/style.css index 3acf797f..1ca2c8a0 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4465,4 +4465,73 @@ body { .playlist-modal-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(29, 185, 84, 0.4); +} + +/* Add these styles to the end of style.css */ + +.sync-progress-indicator { + margin-top: 10px; + display: none; /* Hidden by default */ +} + +.progress-bar-sync { + height: 8px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill-sync { + height: 100%; + background-color: #1ed760; + width: 0%; + border-radius: 4px; + transition: width 0.5s ease-in-out; +} + +.progress-text-sync { + font-size: 11px; + color: #b3b3b3; + text-align: center; + margin-top: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + line-height: 1.2; +} + +/* Modal sync status display (matches GUI) */ +.playlist-modal-sync-status { + background: rgba(29, 185, 84, 0.1); + border: 1px solid rgba(29, 185, 84, 0.3); + border-radius: 12px; + padding: 8px 12px; + margin-top: 8px; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 500; +} + +.sync-stat.total-tracks { + color: #ffa500; +} + +.sync-stat.matched-tracks { + color: #1db954; +} + +.sync-stat.failed-tracks { + color: #e22134; +} + +.sync-stat.percentage { + color: #1db954; + font-weight: 700; +} + +.sync-separator { + color: #666666; } \ No newline at end of file