diff --git a/web_server.py b/web_server.py index b1059a93..c67b2b7e 100644 --- a/web_server.py +++ b/web_server.py @@ -2387,6 +2387,205 @@ def stop_database_update(): else: return jsonify({"success": False, "error": "No update is currently running."}), 404 +# =============================== +# == TRACK ANALYSIS API == +# =============================== + +# Global state for track analysis tasks +analysis_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="AnalysisWorker") +analysis_tasks = {} # task_id -> analysis state +analysis_lock = threading.Lock() + +def _run_track_analysis_task(task_id, tracks_json): + """Run track analysis in background thread (same logic as GUI's PlaylistTrackAnalysisWorker)""" + import uuid + from database.music_database import MusicDatabase + from config.settings import config_manager + + print(f"🔍 Starting track analysis task {task_id} for {len(tracks_json)} tracks") + + try: + # Initialize database connection + db = MusicDatabase() + active_server = config_manager.get_active_media_server() + + results = [] + total_tracks = len(tracks_json) + + for i, track_data in enumerate(tracks_json): + with analysis_lock: + # Check if task was cancelled + if analysis_tasks.get(task_id, {}).get('status') == 'cancelled': + print(f"❌ Analysis task {task_id} was cancelled") + return + + track_name = track_data.get('name', '') + artists = track_data.get('artists', []) + + # Try each artist for matching (same as GUI logic) + found = False + confidence = 0.0 + + for artist in artists: + artist_name = artist if isinstance(artist, str) else str(artist) + + # Check database for track existence + db_track, track_confidence = db.check_track_exists( + track_name, artist_name, + confidence_threshold=0.7, + server_source=active_server + ) + + if db_track and track_confidence >= 0.7: + found = True + confidence = track_confidence + print(f"✅ Found: '{track_name}' by {artist_name} (confidence: {confidence:.2f})") + break + + if not found: + print(f"❌ Missing: '{track_name}' by {artists}") + + # Store result + result = { + 'track_index': i, + 'track': track_data, + 'found': found, + 'confidence': confidence + } + results.append(result) + + # Update progress + progress = int((i + 1) / total_tracks * 100) + with analysis_lock: + if task_id in analysis_tasks: + analysis_tasks[task_id].update({ + 'progress': progress, + 'processed': i + 1, + 'results': results.copy() # Store current results + }) + + # Mark as complete + with analysis_lock: + if task_id in analysis_tasks: + analysis_tasks[task_id].update({ + 'status': 'complete', + 'progress': 100, + 'results': results, + 'total_found': len([r for r in results if r['found']]), + 'total_missing': len([r for r in results if not r['found']]) + }) + + print(f"✅ Analysis complete: {len([r for r in results if r['found']])} found, {len([r for r in results if not r['found']])} missing") + + except Exception as e: + print(f"❌ Analysis task {task_id} failed: {e}") + with analysis_lock: + if task_id in analysis_tasks: + analysis_tasks[task_id].update({ + 'status': 'error', + 'error': str(e) + }) + +@app.route('/api/tracks/analyze', methods=['POST']) +def start_track_analysis(): + """Start track analysis to check which tracks exist in media server library""" + data = request.get_json() + tracks = data.get('tracks', []) + + if not tracks: + return jsonify({"success": False, "error": "No tracks provided"}), 400 + + # Generate unique task ID + import uuid + task_id = str(uuid.uuid4()) + + # Initialize task state + with analysis_lock: + analysis_tasks[task_id] = { + 'status': 'running', + 'progress': 0, + 'total': len(tracks), + 'processed': 0, + 'results': [], + 'total_found': 0, + 'total_missing': 0 + } + + # Submit analysis task + future = analysis_executor.submit(_run_track_analysis_task, task_id, tracks) + + return jsonify({ + "success": True, + "task_id": task_id, + "total_tracks": len(tracks) + }) + +@app.route('/api/tracks/analyze/status/', methods=['GET']) +def get_analysis_status(task_id): + """Get status of track analysis task""" + with analysis_lock: + task = analysis_tasks.get(task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + + return jsonify(task) + +@app.route('/api/tracks/analyze/cancel/', methods=['POST']) +def cancel_analysis_task(task_id): + """Cancel a running analysis task""" + with analysis_lock: + if task_id in analysis_tasks: + analysis_tasks[task_id]['status'] = 'cancelled' + return jsonify({"success": True, "message": "Task cancelled"}) + else: + return jsonify({"success": False, "error": "Task not found"}), 404 + +@app.route('/api/tracks/download_missing', methods=['POST']) +def start_missing_downloads(): + """Queue missing tracks for Soulseek download""" + data = request.get_json() + missing_tracks = data.get('missing_tracks', []) + + if not missing_tracks: + return jsonify({"success": False, "error": "No missing tracks provided"}), 400 + + try: + queued_downloads = 0 + + for track_data in missing_tracks: + track = track_data.get('track', {}) + track_name = track.get('name', '') + artists = track.get('artists', []) + + if not track_name or not artists: + continue + + # Generate search query (simplified version of GUI logic) + artist_name = artists[0] if artists else 'Unknown Artist' + search_query = f"{track_name} {artist_name}".strip() + + print(f"📥 Queuing download: '{search_query}'") + + # Queue download using existing soulseek client + try: + asyncio.run(soulseek_client.queue_search_and_download( + query=search_query, + preferred_format='flac' # Use user's preferred format + )) + queued_downloads += 1 + except Exception as e: + print(f"❌ Failed to queue download for '{search_query}': {e}") + + return jsonify({ + "success": True, + "queued": queued_downloads, + "message": f"Queued {queued_downloads} downloads" + }) + + except Exception as e: + print(f"❌ Error queueing downloads: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + # =============================== # == SYNC PAGE API == # =============================== diff --git a/webui/static/script.js b/webui/static/script.js index b395812e..f5348f74 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1578,6 +1578,7 @@ function showPlaylistDetailsModal(playlist) { @@ -1599,6 +1600,466 @@ function formatDuration(ms) { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } +// =============================== +// DOWNLOAD MISSING TRACKS MODAL +// =============================== + +let activeAnalysisTaskId = null; +let currentPlaylistTracks = []; +let analysisResults = []; +let missingTracks = []; + +async function openDownloadMissingModal(playlistId) { + console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`); + + // Close the playlist details modal first + closePlaylistDetailsModal(); + + // Find playlist data + 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; + } + + // Ensure we have track data + let tracks = playlistTrackCache[playlistId]; + if (!tracks) { + console.log(`🔄 Cache miss - fetching tracks for download analysis`); + 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 + } catch (error) { + console.error(`❌ Failed to fetch tracks:`, error); + showToast(`Failed to fetch tracks: ${error.message}`, 'error'); + return; + } + } + + currentPlaylistTracks = tracks; + console.log(`✅ Loaded ${tracks.length} tracks for analysis`); + + // Create or get modal + let modal = document.getElementById('download-missing-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'download-missing-modal'; + document.body.appendChild(modal); + } + + // Build modal HTML + modal.innerHTML = ` +
+
+

Download Missing Tracks - ${escapeHtml(playlist.name)}

+ × +
+ +
+ +
+
+
${tracks.length}
+
Total Tracks
+
+
+
-
+
Found in Library
+
+
+
-
+
Missing Tracks
+
+
+
0
+
Downloaded
+
+
+ + +
+
+
+ 🔍 Library Analysis + Ready to start +
+
+
+
+
+
+
+ ⏬ Downloads + Waiting for analysis +
+
+
+
+
+
+ + +
+
+

📋 Track Analysis & Download Status

+
+
+ + + + + + + + + + + + + + ${tracks.map((track, index) => ` + + + + + + + + + + `).join('')} + +
#TrackArtistDurationLibrary MatchDownload StatusActions
${index + 1}${escapeHtml(track.name)}${track.artists.join(', ')}${formatDuration(track.duration_ms)}🔍 Pending--
+
+
+
+ + +
+ `; + + // Reset state + activeAnalysisTaskId = null; + analysisResults = []; + missingTracks = []; + + // Show modal + modal.style.display = 'flex'; +} + +function closeDownloadMissingModal() { + // Clean up any active tasks + if (activeAnalysisTaskId) { + fetch(`/api/tracks/analyze/cancel/${activeAnalysisTaskId}`, { method: 'POST' }) + .catch(e => console.warn('Failed to cancel analysis task:', e)); + } + + const modal = document.getElementById('download-missing-modal'); + if (modal) { + modal.style.display = 'none'; + } + + // Reset state + activeAnalysisTaskId = null; + currentPlaylistTracks = []; + analysisResults = []; + missingTracks = []; +} + +async function startTrackAnalysis() { + console.log(`🔍 Starting track analysis for ${currentPlaylistTracks.length} tracks`); + + try { + // Update UI to analysis mode + document.getElementById('begin-analysis-btn').style.display = 'none'; + document.getElementById('cancel-all-btn').style.display = 'inline-block'; + document.getElementById('analysis-progress-text').textContent = 'Starting analysis...'; + + // Start analysis + const response = await fetch('/api/tracks/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tracks: currentPlaylistTracks + }) + }); + + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + activeAnalysisTaskId = data.task_id; + console.log(`✅ Analysis started with task ID: ${activeAnalysisTaskId}`); + + // Start polling for results + startAnalysisPolling(); + + } catch (error) { + console.error('❌ Failed to start analysis:', error); + showToast(`Failed to start analysis: ${error.message}`, 'error'); + + // Reset UI + document.getElementById('begin-analysis-btn').style.display = 'inline-block'; + document.getElementById('cancel-all-btn').style.display = 'none'; + document.getElementById('analysis-progress-text').textContent = 'Ready to start'; + } +} + +function startAnalysisPolling() { + if (!activeAnalysisTaskId) return; + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/tracks/analyze/status/${activeAnalysisTaskId}`); + const status = await response.json(); + + if (response.status === 404 || status.error) { + console.error('❌ Analysis task not found or error:', status.error); + clearInterval(pollInterval); + return; + } + + // Update progress bar + const progressPercent = status.progress || 0; + document.getElementById('analysis-progress-fill').style.width = `${progressPercent}%`; + document.getElementById('analysis-progress-text').textContent = + `${status.processed || 0}/${status.total || 0} tracks analyzed (${progressPercent}%)`; + + // Update table with individual results + if (status.results && status.results.length > 0) { + updateTrackAnalysisResults(status.results); + } + + // Check if complete + if (status.status === 'complete') { + console.log(`✅ Analysis complete: ${status.total_found} found, ${status.total_missing} missing`); + clearInterval(pollInterval); + onAnalysisComplete(status); + } else if (status.status === 'error') { + console.error('❌ Analysis failed:', status.error); + clearInterval(pollInterval); + showToast(`Analysis failed: ${status.error}`, 'error'); + resetToInitialState(); + } else if (status.status === 'cancelled') { + console.log('⚠️ Analysis was cancelled'); + clearInterval(pollInterval); + resetToInitialState(); + } + + } catch (error) { + console.error('❌ Error polling analysis status:', error); + clearInterval(pollInterval); + showToast('Failed to get analysis status', 'error'); + } + }, 1000); // Poll every second +} + +function updateTrackAnalysisResults(results) { + for (const result of results) { + const trackIndex = result.track_index; + const matchElement = document.getElementById(`match-${trackIndex}`); + + if (matchElement) { + if (result.found) { + matchElement.textContent = '✅ Found'; + matchElement.className = 'track-match-status match-found'; + } else { + matchElement.textContent = '❌ Missing'; + matchElement.className = 'track-match-status match-missing'; + } + } + } +} + +function onAnalysisComplete(status) { + // Update dashboard stats + document.getElementById('stat-found').textContent = status.total_found || 0; + document.getElementById('stat-missing').textContent = status.total_missing || 0; + + // Update progress text + document.getElementById('analysis-progress-text').textContent = 'Analysis complete!'; + document.getElementById('download-progress-text').textContent = 'Ready to download missing tracks'; + + // Store results + analysisResults = status.results || []; + missingTracks = analysisResults.filter(r => !r.found); + + console.log(`📊 Analysis results: ${analysisResults.length} total, ${missingTracks.length} missing`); + + // Update UI for download phase + document.getElementById('cancel-all-btn').style.display = 'none'; + if (missingTracks.length > 0) { + document.getElementById('start-downloads-btn').style.display = 'inline-block'; + } else { + showToast('All tracks were found in your library!', 'success'); + document.getElementById('download-progress-text').textContent = 'No downloads needed - all tracks found!'; + } +} + +async function startMissingDownloads() { + if (missingTracks.length === 0) { + showToast('No missing tracks to download', 'info'); + return; + } + + console.log(`⏬ Starting downloads for ${missingTracks.length} missing tracks`); + + try { + // Update UI + document.getElementById('start-downloads-btn').style.display = 'none'; + document.getElementById('cancel-all-btn').style.display = 'inline-block'; + document.getElementById('download-progress-text').textContent = 'Queueing downloads...'; + + // Add cancel buttons to missing tracks + for (const result of missingTracks) { + const actionsElement = document.getElementById(`actions-${result.track_index}`); + if (actionsElement) { + actionsElement.innerHTML = ``; + } + + // Update download status + const statusElement = document.getElementById(`download-${result.track_index}`); + if (statusElement) { + statusElement.textContent = '🔍 Queueing...'; + statusElement.className = 'track-download-status download-searching'; + } + } + + // Queue downloads + const response = await fetch('/api/tracks/download_missing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + missing_tracks: missingTracks + }) + }); + + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + console.log(`✅ Queued ${data.queued} downloads`); + showToast(`Queued ${data.queued} downloads. Check the download queue for progress.`, 'success'); + + // Update UI + document.getElementById('download-progress-text').textContent = + `${data.queued} downloads queued. Check download queue for live progress.`; + + // Update download status for queued tracks + for (const result of missingTracks) { + const statusElement = document.getElementById(`download-${result.track_index}`); + if (statusElement) { + statusElement.textContent = '📥 Queued'; + statusElement.className = 'track-download-status download-searching'; + } + } + + // Hide cancel button since downloads are now handled by the main download system + document.getElementById('cancel-all-btn').style.display = 'none'; + + } catch (error) { + console.error('❌ Failed to start downloads:', error); + showToast(`Failed to start downloads: ${error.message}`, 'error'); + + // Reset UI + document.getElementById('start-downloads-btn').style.display = 'inline-block'; + document.getElementById('cancel-all-btn').style.display = 'none'; + document.getElementById('download-progress-text').textContent = 'Ready to download missing tracks'; + } +} + +function cancelAllOperations() { + console.log('🛑 Cancelling all operations'); + + // Cancel analysis if running + if (activeAnalysisTaskId) { + fetch(`/api/tracks/analyze/cancel/${activeAnalysisTaskId}`, { method: 'POST' }) + .catch(e => console.warn('Failed to cancel analysis:', e)); + } + + resetToInitialState(); + showToast('Operations cancelled', 'info'); +} + +function resetToInitialState() { + // Reset UI + document.getElementById('begin-analysis-btn').style.display = 'inline-block'; + document.getElementById('start-downloads-btn').style.display = 'none'; + document.getElementById('cancel-all-btn').style.display = 'none'; + + // Reset progress bars + document.getElementById('analysis-progress-fill').style.width = '0%'; + document.getElementById('download-progress-fill').style.width = '0%'; + document.getElementById('analysis-progress-text').textContent = 'Ready to start'; + document.getElementById('download-progress-text').textContent = 'Waiting for analysis'; + + // Reset stats + document.getElementById('stat-found').textContent = '-'; + document.getElementById('stat-missing').textContent = '-'; + document.getElementById('stat-downloaded').textContent = '0'; + + // Reset track table + const tbody = document.getElementById('download-tracks-tbody'); + if (tbody) { + const rows = tbody.querySelectorAll('tr'); + rows.forEach((row, index) => { + const matchElement = row.querySelector('.track-match-status'); + const downloadElement = row.querySelector('.track-download-status'); + const actionsElement = row.querySelector('.track-actions'); + + if (matchElement) { + matchElement.textContent = '🔍 Pending'; + matchElement.className = 'track-match-status match-checking'; + } + if (downloadElement) { + downloadElement.textContent = '-'; + downloadElement.className = 'track-download-status'; + } + if (actionsElement) { + actionsElement.textContent = '-'; + } + }); + } + + // Reset state + activeAnalysisTaskId = null; + analysisResults = []; + missingTracks = []; +} + +function cancelTrackDownload(trackIndex) { + console.log(`🛑 Cancelling download for track ${trackIndex}`); + // Individual track cancellation would need to be implemented in the download system + // For now, just update the UI + const statusElement = document.getElementById(`download-${trackIndex}`); + const actionsElement = document.getElementById(`actions-${trackIndex}`); + + if (statusElement) { + statusElement.textContent = '❌ Cancelled'; + statusElement.className = 'track-download-status download-failed'; + } + if (actionsElement) { + actionsElement.textContent = '-'; + } +} + // Find and REPLACE the old startPlaylistSyncFromModal function async function startPlaylistSync(playlistId) { const startTime = Date.now(); diff --git a/webui/static/style.css b/webui/static/style.css index 397c8d57..828649c9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4467,6 +4467,18 @@ body { box-shadow: 0 6px 20px rgba(29, 185, 84, 0.4); } +.playlist-modal-btn-tertiary { + background: #404040; + color: #ffffff; + border: 1px solid #666666; +} + +.playlist-modal-btn-tertiary:hover { + background: #505050; + border-color: #777777; + transform: translateY(-1px); +} + /* Add these styles to the end of style.css */ .sync-progress-indicator { @@ -4549,4 +4561,357 @@ body { gap: 6px; font-size: 11px; font-weight: 500; +} + +/* ============================================== + DOWNLOAD MISSING TRACKS MODAL STYLES + ============================================== */ + +#download-missing-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(8px); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.download-missing-modal-content { + background: #1e1e1e; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + width: 1200px; + height: 900px; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.download-missing-modal-header { + background: linear-gradient(135deg, #2d2d2d 0%, #1a1a1a 100%); + border-bottom: 1px solid #404040; + padding: 20px 25px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.download-missing-modal-title { + color: #1db954; + font-size: 18px; + font-weight: 700; + margin: 0; +} + +.download-missing-modal-close { + color: #cccccc; + font-size: 32px; + font-weight: 300; + cursor: pointer; + transition: all 0.2s ease; + line-height: 1; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.download-missing-modal-close:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); + transform: scale(1.1); +} + +.download-missing-modal-body { + flex: 1; + display: flex; + flex-direction: column; + padding: 25px; + gap: 20px; + overflow: hidden; +} + +/* Dashboard Stats Section */ +.download-dashboard-stats { + background: #2d2d2d; + border: 1px solid #444444; + border-radius: 12px; + padding: 20px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; +} + +.dashboard-stat { + text-align: center; +} + +.dashboard-stat-number { + font-size: 28px; + font-weight: 700; + margin-bottom: 5px; +} + +.dashboard-stat-label { + font-size: 13px; + color: #cccccc; + font-weight: 500; +} + +.stat-total .dashboard-stat-number { color: #1db954; } +.stat-found .dashboard-stat-number { color: #4CAF50; } +.stat-missing .dashboard-stat-number { color: #FF6B35; } +.stat-downloaded .dashboard-stat-number { color: #2196F3; } + +/* Progress Section */ +.download-progress-section { + background: #2d2d2d; + border: 1px solid #444444; + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 15px; +} + +.progress-item { + display: flex; + flex-direction: column; + gap: 6px; +} + +.progress-label { + font-size: 13px; + font-weight: 600; + color: #cccccc; + display: flex; + align-items: center; + gap: 8px; +} + +.progress-bar { + background: #404040; + border-radius: 8px; + height: 8px; + overflow: hidden; +} + +.progress-fill { + background: linear-gradient(90deg, #1db954, #1ed760); + height: 100%; + border-radius: 8px; + transition: width 0.3s ease; + width: 0%; +} + +.progress-fill.analysis { background: linear-gradient(90deg, #2196F3, #21CBF3); } +.progress-fill.download { background: linear-gradient(90deg, #FF6B35, #FF8A35); } + +/* Track Table Section */ +.download-tracks-section { + background: #2d2d2d; + border: 1px solid #444444; + border-radius: 12px; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.download-tracks-header { + padding: 15px 20px; + border-bottom: 1px solid #444444; + background: #333333; +} + +.download-tracks-title { + font-size: 15px; + font-weight: 600; + color: #ffffff; + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.download-tracks-table-container { + flex: 1; + overflow-y: auto; +} + +.download-tracks-table { + width: 100%; + border-collapse: collapse; +} + +.download-tracks-table th { + background: #404040; + color: #ffffff; + font-weight: 600; + font-size: 12px; + text-align: left; + padding: 12px 15px; + border-bottom: 1px solid #555555; + position: sticky; + top: 0; + z-index: 10; +} + +.download-tracks-table td { + padding: 12px 15px; + border-bottom: 1px solid #333333; + color: #e0e0e0; + font-size: 13px; +} + +.download-tracks-table tr:hover { + background: rgba(29, 185, 84, 0.05); +} + +.track-number { + color: #888888; + font-weight: 500; + width: 50px; + text-align: center; +} + +.track-name { + font-weight: 600; + color: #ffffff; + max-width: 200px; +} + +.track-artist { + color: #cccccc; + max-width: 150px; +} + +.track-duration { + color: #999999; + text-align: center; + width: 80px; +} + +.track-match-status { + text-align: center; + width: 100px; + font-weight: 600; +} + +.match-found { color: #4CAF50; } +.match-missing { color: #FF6B35; } +.match-checking { color: #2196F3; } + +.track-download-status { + text-align: center; + width: 120px; + font-weight: 500; +} + +.download-searching { color: #2196F3; } +.download-downloading { color: #FF6B35; } +.download-complete { color: #4CAF50; } +.download-failed { color: #f44336; } + +.track-actions { + text-align: center; + width: 80px; +} + +.cancel-track-btn { + background: #f44336; + color: #ffffff; + border: none; + border-radius: 6px; + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; +} + +.cancel-track-btn:hover { + background: #d32f2f; + transform: scale(1.05); +} + +/* Modal Footer */ +.download-missing-modal-footer { + background: #2a2a2a; + border-top: 1px solid #404040; + padding: 20px 25px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.download-phase-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.download-control-btn { + padding: 12px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; + min-width: 120px; +} + +.download-control-btn.primary { + background: linear-gradient(135deg, #1db954, #1ed760); + color: #000000; + box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3); +} + +.download-control-btn.primary:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(29, 185, 84, 0.4); +} + +.download-control-btn.secondary { + background: #404040; + color: #ffffff; + border: 1px solid #666666; +} + +.download-control-btn.secondary:hover { + background: #505050; + border-color: #777777; + transform: translateY(-1px); +} + +.download-control-btn.danger { + background: #f44336; + color: #ffffff; +} + +.download-control-btn.danger:hover { + background: #d32f2f; + transform: translateY(-1px); +} + +.download-control-btn:disabled { + background: #333333; + color: #666666; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.modal-close-section { + display: flex; + align-items: center; } \ No newline at end of file