diff --git a/web_server.py b/web_server.py index 280135f4..5b902112 100644 --- a/web_server.py +++ b/web_server.py @@ -504,16 +504,611 @@ def start_sync(): @app.route('/api/search', methods=['POST']) def search_music(): - # Placeholder: simulates a music search + """ + Perform real Soulseek search using the actual soulseek_client. + Returns progressive search results matching the GUI's SearchThread implementation. + """ + if not soulseek_client: + return jsonify({"error": "Soulseek client not initialized"}), 500 + data = request.get_json() - query = data.get('query', '') - print(f"Simulating search for: {query}") - # In a real implementation, you would call soulseek_client.search() - mock_results = [ - {"title": "Bohemian Rhapsody", "artist": "Queen", "album": "A Night at the Opera", "type": "track", "quality": "FLAC", "username": "user1", "filename": "Queen - Bohemian Rhapsody.flac", "file_size": 35000000}, - {"title": "A Night at the Opera", "artist": "Queen", "type": "album", "track_count": 12, "size_mb": 350, "username": "user2"} - ] - return jsonify({"results": mock_results}) + query = data.get('query', '').strip() + + if not query: + return jsonify({"error": "No search query provided"}), 400 + + print(f"🔍 Starting Soulseek search for: '{query}'") + + try: + import asyncio + + # Create new event loop for this request + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + # Perform the actual search using soulseek_client + results = loop.run_until_complete(soulseek_client.search(query)) + + # Process results to match frontend expectations + if isinstance(results, tuple) and len(results) == 2: + tracks, albums = results + else: + # Fallback for backward compatibility + tracks = results if isinstance(results, list) else [] + albums = [] + + # Convert track results to JSON-serializable format + tracks_json = [] + for track in tracks: + tracks_json.append({ + "type": "track", + "title": getattr(track, 'title', 'Unknown Title'), + "artist": getattr(track, 'artist', 'Unknown Artist'), + "album": getattr(track, 'album', 'Unknown Album'), + "quality": getattr(track, 'quality', 'Unknown'), + "bitrate": getattr(track, 'bitrate', None), + "duration": getattr(track, 'duration', None), + "filename": getattr(track, 'filename', ''), + "username": getattr(track, 'username', ''), + "file_size": getattr(track, 'file_size', 0), + "search_result_data": { + # Store the original object data for download purposes + "filename": getattr(track, 'filename', ''), + "username": getattr(track, 'username', ''), + "file_size": getattr(track, 'file_size', 0), + } + }) + + # Convert album results to JSON-serializable format + albums_json = [] + for album in albums: + albums_json.append({ + "type": "album", + "title": getattr(album, 'album_name', getattr(album, 'title', 'Unknown Album')), + "artist": getattr(album, 'artist', 'Unknown Artist'), + "track_count": getattr(album, 'track_count', 0), + "username": getattr(album, 'username', ''), + "size_mb": getattr(album, 'total_size', 0) / (1024 * 1024) if hasattr(album, 'total_size') else 0, + "tracks": getattr(album, 'tracks', []), + "search_result_data": { + # Store the original object data for download purposes + "album_name": getattr(album, 'album_name', getattr(album, 'title', '')), + "artist": getattr(album, 'artist', ''), + "username": getattr(album, 'username', ''), + "tracks": getattr(album, 'tracks', []) + } + }) + + total_results = len(tracks_json) + len(albums_json) + print(f"✅ Search completed: {len(tracks_json)} tracks, {len(albums_json)} albums ({total_results} total)") + + return jsonify({ + "success": True, + "results": { + "tracks": tracks_json, + "albums": albums_json, + "total_tracks": len(tracks_json), + "total_albums": len(albums_json), + "query": query + } + }) + + finally: + # Clean up the event loop + try: + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.close() + except Exception as e: + print(f"Error cleaning up search event loop: {e}") + + except Exception as e: + import traceback + traceback.print_exc() + print(f"❌ Search failed: {e}") + return jsonify({"error": f"Search failed: {str(e)}"}), 500 + +@app.route('/api/search/cancel', methods=['POST']) +def cancel_search(): + """Cancel any active search operations""" + # Note: In a full implementation, you would track active search operations + # and cancel them here. For now, this is a placeholder. + print("🛑 Search cancellation requested") + return jsonify({"success": True, "message": "Search cancellation requested"}) + +# Global download tracking +active_downloads = {} # Dict to track active downloads +completed_downloads = [] # List to store completed downloads + +@app.route('/api/downloads/start', methods=['POST']) +def start_download(): + """ + Start a regular download using the soulseek_client. + This matches the GUI's start_download functionality. + """ + if not soulseek_client: + return jsonify({"error": "Soulseek client not initialized"}), 500 + + data = request.get_json() + if not data: + return jsonify({"error": "No download data provided"}), 400 + + try: + # Extract search result data + search_data = data.get('search_result_data', data) + filename = search_data.get('filename') + username = search_data.get('username') + + if not filename or not username: + return jsonify({"error": "Missing required download parameters (filename, username)"}), 400 + + print(f"⬇️ Starting download: '{filename}' from '{username}'") + + # Create download item for tracking + download_id = f"{username}_{filename}_{len(active_downloads)}" + download_item = { + "id": download_id, + "title": data.get('title', filename), + "artist": data.get('artist', 'Unknown Artist'), + "filename": filename, + "username": username, + "status": "queued", + "progress": 0, + "file_size": data.get('file_size', 0), + "download_speed": 0, + "eta": None, + "start_time": None, + "spotify_matched": False + } + + active_downloads[download_id] = download_item + + # Start the actual download using asyncio + import asyncio + import threading + + def download_worker(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # This would call the actual soulseek_client.download method + # For now, we'll simulate the download process + result = loop.run_until_complete(simulate_download(download_item)) + print(f"✅ Download completed: {download_id}") + except Exception as e: + print(f"❌ Download failed: {download_id} - {e}") + download_item["status"] = "failed" + download_item["error"] = str(e) + finally: + loop.close() + + # Start download in background thread + thread = threading.Thread(target=download_worker) + thread.daemon = True + thread.start() + + return jsonify({ + "success": True, + "download_id": download_id, + "message": f"Download started for '{filename}'" + }) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({"error": f"Failed to start download: {str(e)}"}), 500 + +@app.route('/api/downloads/start-matched', methods=['POST']) +def start_matched_download(): + """ + Start a download with confirmed Spotify match data. + This matches the GUI's start_matched_download functionality. + """ + if not soulseek_client: + return jsonify({"error": "Soulseek client not initialized"}), 500 + + data = request.get_json() + if not data: + return jsonify({"error": "No download data provided"}), 400 + + try: + # Extract search result data and Spotify match data + search_data = data.get('search_result_data', data) + spotify_match = data.get('spotify_match', {}) + + filename = search_data.get('filename') + username = search_data.get('username') + + if not filename or not username: + return jsonify({"error": "Missing required download parameters"}), 400 + + matched_artist = spotify_match.get('artist', {}) + matched_album = spotify_match.get('album', {}) + + print(f"⬇️🎵 Starting matched download: '{filename}' from '{username}'") + print(f" 🎤 Matched Artist: {matched_artist.get('name', 'Unknown')}") + print(f" 💿 Matched Album: {matched_album.get('name', 'Unknown')}") + + # Create download item for tracking with Spotify match info + download_id = f"{username}_{filename}_{len(active_downloads)}_matched" + download_item = { + "id": download_id, + "title": data.get('title', filename), + "artist": data.get('artist', 'Unknown Artist'), + "filename": filename, + "username": username, + "status": "queued", + "progress": 0, + "file_size": data.get('file_size', 0), + "download_speed": 0, + "eta": None, + "start_time": None, + "spotify_matched": True, + "matched_artist": matched_artist, + "matched_album": matched_album + } + + active_downloads[download_id] = download_item + + # Start the actual download using asyncio + import asyncio + import threading + + def matched_download_worker(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # This would call the actual soulseek_client.download method + # and then apply metadata enhancement with the Spotify match + result = loop.run_until_complete(simulate_matched_download(download_item)) + print(f"✅ Matched download completed: {download_id}") + except Exception as e: + print(f"❌ Matched download failed: {download_id} - {e}") + download_item["status"] = "failed" + download_item["error"] = str(e) + finally: + loop.close() + + # Start download in background thread + thread = threading.Thread(target=matched_download_worker) + thread.daemon = True + thread.start() + + return jsonify({ + "success": True, + "download_id": download_id, + "message": f"Matched download started for '{filename}'" + }) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({"error": f"Failed to start matched download: {str(e)}"}), 500 + +@app.route('/api/downloads/status', methods=['GET']) +def get_download_status(): + """ + Get the current status of all downloads (active and completed). + This matches the GUI's download queue functionality. + """ + try: + # Get real download status from soulseek_client if available + real_downloads = [] + if soulseek_client: + try: + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # This would call soulseek_client.get_all_downloads() + # For now, we'll use our tracked downloads + pass + finally: + loop.close() + except Exception as e: + print(f"Error getting real download status: {e}") + + # Separate active and completed downloads + active = [] + completed = [] + + for download_id, download in active_downloads.items(): + if download["status"] in ["downloading", "queued"]: + active.append(download) + else: + completed.append(download) + + # Add any completed downloads from our completed list + completed.extend(completed_downloads) + + return jsonify({ + "success": True, + "downloads": { + "active": active, + "completed": completed, + "active_count": len(active), + "completed_count": len(completed) + } + }) + + except Exception as e: + print(f"Error getting download status: {e}") + return jsonify({"error": f"Failed to get download status: {str(e)}"}), 500 + +@app.route('/api/downloads/cancel/', methods=['POST']) +def cancel_download(download_id): + """Cancel a specific download""" + if download_id in active_downloads: + download = active_downloads[download_id] + download["status"] = "cancelled" + print(f"🛑 Download cancelled: {download_id}") + return jsonify({"success": True, "message": f"Download {download_id} cancelled"}) + else: + return jsonify({"error": "Download not found"}), 404 + +@app.route('/api/downloads/clear-completed', methods=['POST']) +def clear_completed_downloads(): + """Clear all completed downloads from the queue""" + global completed_downloads, active_downloads + + # Remove completed downloads from active_downloads + to_remove = [did for did, download in active_downloads.items() + if download["status"] in ["completed", "failed", "cancelled"]] + + for download_id in to_remove: + del active_downloads[download_id] + + # Clear completed downloads list + cleared_count = len(completed_downloads) + completed_downloads.clear() + + print(f"🗑️ Cleared {cleared_count + len(to_remove)} completed downloads") + return jsonify({ + "success": True, + "message": f"Cleared {cleared_count + len(to_remove)} completed downloads" + }) + +# Helper functions for simulating downloads (replace with real implementations) +async def simulate_download(download_item): + """Simulate a download process - replace with real soulseek_client.download()""" + import asyncio + import time + + download_item["status"] = "downloading" + download_item["start_time"] = time.time() + + # Simulate download progress + for progress in range(0, 101, 10): + download_item["progress"] = progress + download_item["download_speed"] = 1024 * 1024 # 1 MB/s simulation + await asyncio.sleep(0.1) # Simulate time + + if download_item["status"] == "cancelled": + return False + + download_item["status"] = "completed" + download_item["progress"] = 100 + + # Move to completed downloads + global completed_downloads + completed_downloads.append(download_item.copy()) + + return True + +async def simulate_matched_download(download_item): + """Simulate a matched download with metadata enhancement""" + # First do the regular download + result = await simulate_download(download_item) + + if result and download_item.get("spotify_matched"): + print(f"🎵 Applying metadata enhancement for: {download_item['title']}") + # Here you would apply the Spotify metadata enhancement + # using the matched_artist and matched_album data + download_item["metadata_enhanced"] = True + + return result + +# ===== SPOTIFY INTEGRATION ENDPOINTS ===== + +@app.route('/api/spotify/search-artist', methods=['POST']) +def spotify_search_artist(): + """ + Search for artists using Spotify API for the matching modal. + This matches the GUI's ArtistSearchThread functionality. + """ + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"error": "Spotify client not available or not authenticated"}), 500 + + data = request.get_json() + query = data.get('query', '').strip() + + if not query: + return jsonify({"error": "No search query provided"}), 400 + + try: + print(f"🎵 Searching Spotify for artist: '{query}'") + + # Perform artist search using spotify_client + artists = spotify_client.search_artists(query, limit=6) # Limit to 6 for modal display + + # Convert artists to JSON format matching frontend expectations + artists_json = [] + for artist in artists: + artist_data = { + "id": artist.id, + "name": artist.name, + "genres": getattr(artist, 'genres', []), + "popularity": getattr(artist, 'popularity', 0), + "follower_count": getattr(artist, 'follower_count', 0), + "image_url": getattr(artist, 'image_url', None), + "spotify_url": getattr(artist, 'spotify_url', None), + } + artists_json.append(artist_data) + + print(f"✅ Found {len(artists_json)} artists for '{query}'") + return jsonify({ + "success": True, + "artists": artists_json, + "query": query + }) + + except Exception as e: + import traceback + traceback.print_exc() + print(f"❌ Spotify artist search failed: {e}") + return jsonify({"error": f"Artist search failed: {str(e)}"}), 500 + +@app.route('/api/spotify/search-album', methods=['POST']) +def spotify_search_album(): + """ + Search for albums by a specific artist using Spotify API. + This matches the GUI's AlbumSearchThread functionality. + """ + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"error": "Spotify client not available or not authenticated"}), 500 + + data = request.get_json() + artist_id = data.get('artist_id') + query = data.get('query', '').strip() + + if not artist_id: + return jsonify({"error": "No artist ID provided"}), 400 + + try: + print(f"💿 Searching albums for artist ID: {artist_id}") + + # Get albums by artist using spotify_client + albums = spotify_client.get_artist_albums(artist_id, limit=10) + + # If query is provided, filter albums by query + if query: + filtered_albums = [] + query_lower = query.lower() + for album in albums: + if query_lower in album.name.lower(): + filtered_albums.append(album) + albums = filtered_albums + + # Convert albums to JSON format + albums_json = [] + for album in albums: + album_data = { + "id": album.id, + "name": album.name, + "release_date": getattr(album, 'release_date', ''), + "total_tracks": getattr(album, 'total_tracks', 0), + "album_type": getattr(album, 'album_type', 'album'), + "image_url": getattr(album, 'image_url', None), + "spotify_url": getattr(album, 'spotify_url', None), + "artist": { + "id": artist_id, + "name": getattr(album, 'artist_name', 'Unknown Artist') + } + } + albums_json.append(album_data) + + print(f"✅ Found {len(albums_json)} albums for artist {artist_id}") + return jsonify({ + "success": True, + "albums": albums_json, + "artist_id": artist_id, + "query": query + }) + + except Exception as e: + import traceback + traceback.print_exc() + print(f"❌ Spotify album search failed: {e}") + return jsonify({"error": f"Album search failed: {str(e)}"}), 500 + +@app.route('/api/spotify/suggestions', methods=['POST']) +def spotify_generate_suggestions(): + """ + Generate artist suggestions for a search result using Spotify API. + This matches the GUI's generate_auto_artist_suggestions functionality. + """ + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"error": "Spotify client not available or not authenticated"}), 500 + + data = request.get_json() + original_title = data.get('title', '').strip() + original_artist = data.get('artist', '').strip() + + if not original_title and not original_artist: + return jsonify({"error": "No title or artist provided for suggestions"}), 400 + + try: + print(f"🎯 Generating Spotify suggestions for: '{original_title}' by '{original_artist}'") + + suggestions = [] + + # Strategy 1: Search by artist name if available + if original_artist and original_artist.lower() != 'unknown artist': + try: + artist_results = spotify_client.search_artists(original_artist, limit=3) + suggestions.extend(artist_results) + print(f" Found {len(artist_results)} artist matches") + except Exception as e: + print(f" Artist search failed: {e}") + + # Strategy 2: Search by track title to find artist + if original_title and len(suggestions) < 3: + try: + track_results = spotify_client.search_tracks(original_title, limit=5) + for track in track_results: + if hasattr(track, 'artist') and track.artist not in [s for s in suggestions]: + suggestions.append(track.artist) + if len(suggestions) >= 6: # Limit to 6 total suggestions + break + print(f" Found {len(suggestions)} total suggestions from track search") + except Exception as e: + print(f" Track search for suggestions failed: {e}") + + # Strategy 3: Combined search if we still need more + if len(suggestions) < 3 and original_artist and original_title: + try: + combined_query = f"{original_artist} {original_title}" + combined_results = spotify_client.search_artists(combined_query, limit=3) + suggestions.extend(combined_results) + print(f" Added {len(combined_results)} from combined search") + except Exception as e: + print(f" Combined search failed: {e}") + + # Remove duplicates and convert to JSON + seen_ids = set() + unique_suggestions = [] + for artist in suggestions[:6]: # Limit to 6 suggestions + if artist.id not in seen_ids: + seen_ids.add(artist.id) + artist_data = { + "id": artist.id, + "name": artist.name, + "genres": getattr(artist, 'genres', []), + "popularity": getattr(artist, 'popularity', 0), + "follower_count": getattr(artist, 'follower_count', 0), + "image_url": getattr(artist, 'image_url', None), + "confidence_score": 0.8 if artist.name.lower() == original_artist.lower() else 0.6, + "match_reason": "Direct name match" if artist.name.lower() == original_artist.lower() else "Related artist" + } + unique_suggestions.append(artist_data) + + print(f"✅ Generated {len(unique_suggestions)} unique suggestions") + return jsonify({ + "success": True, + "suggestions": unique_suggestions, + "original_title": original_title, + "original_artist": original_artist + }) + + except Exception as e: + import traceback + traceback.print_exc() + print(f"❌ Spotify suggestions failed: {e}") + return jsonify({"error": f"Failed to generate suggestions: {str(e)}"}), 500 @app.route('/api/artists') def get_artists(): diff --git a/webui/index.html b/webui/index.html index fbdfcde1..27acefab 100644 --- a/webui/index.html +++ b/webui/index.html @@ -183,21 +183,77 @@ +
+ -
-
- - + + +
+ +
+ + + + + + + +
+ + Ready to search • Enter artist, song, or album name +
+ + +
+
+ +
+ Your search results will appear here. +
+
+
-
- + + +
+

Download Manager

+
+ Active: 0 + Finished: 0 +
+ + + +
+ + +
+
+
+
No active downloads.
+
+
+
No finished downloads.
+
+
+
@@ -486,6 +542,54 @@
+ + + \ No newline at end of file diff --git a/webui/static/script.js b/webui/static/script.js index df57bcd5..2747ece5 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -44,7 +44,12 @@ document.addEventListener('DOMContentLoaded', function() { initializeMediaPlayer(); initializeDonationWidget(); initializeSettings(); - initializeSearch(); + + // Only initialize search if on downloads page + const currentPage = getCurrentPage(); + if (currentPage === 'downloads') { + initializeSearch(); + } // Start periodic updates updateServiceStatus(); @@ -397,8 +402,13 @@ function initializeSettings() { const saveButton = document.getElementById('save-settings'); const mediaServerType = document.getElementById('media-server-type'); - saveButton.addEventListener('click', saveSettings); - mediaServerType.addEventListener('change', updateMediaServerFields); + if (saveButton) { + saveButton.addEventListener('click', saveSettings); + } + + if (mediaServerType) { + mediaServerType.addEventListener('change', updateMediaServerFields); + } } async function loadSettingsData() { @@ -699,580 +709,1184 @@ function browsePath(pathType) { // =============================== function initializeSearch() { - const searchInput = document.getElementById('search-input'); - const searchButton = document.getElementById('search-button'); + console.log('Initializing search functionality...'); - searchButton.addEventListener('click', performSearch); - searchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') performSearch(); + const searchInput = document.getElementById('search-input'); + const searchButton = document.getElementById('search-btn'); + const cancelButton = document.getElementById('search-cancel-btn'); + const filterToggle = document.getElementById('filter-toggle-btn'); + + console.log('Search elements found:', { + searchInput: !!searchInput, + searchButton: !!searchButton, + cancelButton: !!cancelButton, + filterToggle: !!filterToggle }); + + // Search event handlers + if (searchButton) { + searchButton.addEventListener('click', performSearch); + console.log('Search button click handler added'); + } + + if (cancelButton) { + cancelButton.addEventListener('click', cancelSearch); + console.log('Cancel button click handler added'); + } + + if (searchInput) { + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + console.log('Enter key pressed in search input'); + performSearch(); + } + }); + console.log('Search input keypress handler added'); + } + + // Filter toggle handler + if (filterToggle) { + filterToggle.addEventListener('click', toggleFilters); + console.log('Filter toggle handler added'); + } + + // Initialize download queue updates only when on downloads page + const downloadsPage = document.getElementById('downloads-page'); + if (downloadsPage && !downloadsPage.classList.contains('hidden')) { + startDownloadQueueUpdates(); + initializeDownloadTabs(); + console.log('Download queue updates started'); + } } +// Global search state +let currentSearchQuery = ''; +let currentSearchResults = { tracks: [], albums: [] }; +let isSearching = false; +let searchAbortController = null; + async function performSearch() { - const query = document.getElementById('search-input').value.trim(); + const searchInput = document.getElementById('search-input'); + const query = searchInput ? searchInput.value.trim() : ''; + if (!query) { - showToast('Please enter a search term', 'error'); + updateSearchStatus('⚠️ Please enter a search term', '#ffa500'); return; } + if (isSearching) { + console.log('Search already in progress, ignoring new search request'); + return; + } + + console.log(`🔍 Starting search for: "${query}"`); + + // Cancel any existing search + if (searchAbortController) { + searchAbortController.abort(); + } + + // Create new abort controller for this search + searchAbortController = new AbortController(); + try { - showLoadingOverlay('Searching...'); - displaySearchResults([]); // Clear previous results + // Update UI for searching state + isSearching = true; + currentSearchQuery = query; + startSearchAnimations(); + clearSearchResults(); + const searchBtn = document.getElementById('search-btn'); + const cancelBtn = document.getElementById('search-cancel-btn'); + + if (searchBtn) { + searchBtn.style.display = 'none'; + } + if (cancelBtn) { + cancelBtn.classList.remove('hidden'); + } + + updateSearchStatus(`🔍 Searching for "${query}"... Results will appear as they are found`, '#1db954'); + + // Perform the actual search const response = await fetch(API.search, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) + body: JSON.stringify({ query }), + signal: searchAbortController.signal }); + if (!response.ok) { + throw new Error(`Search failed: ${response.status} ${response.statusText}`); + } + const data = await response.json(); if (data.error) { - showToast(`Search error: ${data.error}`, 'error'); - return; + throw new Error(data.error); } - searchResults = data.results || []; - displaySearchResults(searchResults); - - if (searchResults.length === 0) { - showToast('No results found', 'error'); + // Process search results + if (data.success && data.results) { + currentSearchResults = { + tracks: data.results.tracks || [], + albums: data.results.albums || [] + }; + + const totalResults = currentSearchResults.tracks.length + currentSearchResults.albums.length; + + console.log(`✅ Search completed: ${currentSearchResults.tracks.length} tracks, ${currentSearchResults.albums.length} albums (${totalResults} total)`); + + displaySearchResults(currentSearchResults); + + if (totalResults === 0) { + updateSearchStatus('😞 No results found. Try different search terms.', '#ffa500'); + } else { + updateSearchStatus(`✅ Found ${totalResults} results (${currentSearchResults.tracks.length} tracks, ${currentSearchResults.albums.length} albums)`, '#1db954'); + + // Show filter controls if we have results + const filterContainer = document.getElementById('filter-container'); + if (filterContainer && totalResults > 5) { + filterContainer.classList.remove('hidden'); + initializeFilters(); + } + } } else { - showToast(`Found ${searchResults.length} results`, 'success'); + throw new Error('Invalid response format'); } } catch (error) { - console.error('Error performing search:', error); - showToast('Search failed', 'error'); + if (error.name === 'AbortError') { + console.log('🛑 Search was cancelled'); + updateSearchStatus('Search cancelled', '#ffa500'); + } else { + console.error('❌ Search failed:', error); + updateSearchStatus(`❌ Search failed: ${error.message}`, '#e22134'); + showToast(`Search failed: ${error.message}`, 'error'); + } } finally { - hideLoadingOverlay(); + // Reset search state + isSearching = false; + searchAbortController = null; + stopSearchAnimations(); + + const searchBtn = document.getElementById('search-btn'); + const cancelBtn = document.getElementById('search-cancel-btn'); + + if (searchBtn) { + searchBtn.style.display = 'inline-block'; + } + if (cancelBtn) { + cancelBtn.classList.add('hidden'); + } + } +} + +async function cancelSearch() { + console.log('🛑 Cancelling search...'); + + if (searchAbortController) { + searchAbortController.abort(); + } + + // Also try to cancel on the backend + try { + await fetch('/api/search/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error cancelling search on backend:', error); + } + + updateSearchStatus('Search cancelled', '#ffa500'); +} + +function updateSearchStatus(message, color = '#ffffff') { + const statusElement = document.getElementById('search-status-text'); + if (statusElement) { + statusElement.textContent = message; + statusElement.style.color = color; + } +} + +function startSearchAnimations() { + const spinner = document.getElementById('search-spinner'); + if (spinner) { + spinner.classList.remove('hidden'); + } +} + +function stopSearchAnimations() { + const spinner = document.getElementById('search-spinner'); + if (spinner) { + spinner.classList.add('hidden'); + } +} + +function clearSearchResults() { + const resultsContainer = document.getElementById('search-results-container'); + if (resultsContainer) { + resultsContainer.innerHTML = '
Your search results will appear here.
'; } } function displaySearchResults(results) { - const resultsContainer = document.getElementById('search-results'); - - if (!results.length) { - resultsContainer.innerHTML = '
No search results
'; + const resultsContainer = document.getElementById('search-results-container'); + if (!resultsContainer) { + console.error('Search results container not found'); return; } - resultsContainer.innerHTML = results.map((result, index) => { - const isAlbum = result.type === 'album'; - const sizeText = isAlbum ? - `${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` : - `${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`; - - return ` -
-
-
-
${escapeHtml(result.title)}
-
${escapeHtml(result.artist)}
- ${result.album ? `
${escapeHtml(result.album)}
` : ''} -
-
- - -
-
-
- ${sizeText} - by ${escapeHtml(result.username)} - ${result.quality ? `${escapeHtml(result.quality)}` : ''} -
+ // Clear existing results + resultsContainer.innerHTML = ''; + + if (!results || (!results.tracks && !results.albums)) { + resultsContainer.innerHTML = '
No search results to display.
'; + return; + } + + const tracks = results.tracks || []; + const albums = results.albums || []; + + // Display tracks first + tracks.forEach((track, index) => { + const trackElement = createSearchResultElement(track, index, 'track'); + resultsContainer.appendChild(trackElement); + }); + + // Display albums + albums.forEach((album, index) => { + const albumElement = createSearchResultElement(album, tracks.length + index, 'album'); + resultsContainer.appendChild(albumElement); + }); + + if (tracks.length === 0 && albums.length === 0) { + resultsContainer.innerHTML = '
No results found for your search.
'; + } +} + +function createSearchResultElement(result, index, type) { + const resultDiv = document.createElement('div'); + resultDiv.className = 'search-result-item'; + resultDiv.setAttribute('data-index', index); + resultDiv.setAttribute('data-type', type); + + // Format file size + const fileSizeFormatted = result.file_size ? formatFileSize(result.file_size) : 'Unknown size'; + const bitrate = result.bitrate ? `${result.bitrate} kbps` : ''; + const duration = result.duration ? formatDuration(result.duration) : ''; + + resultDiv.innerHTML = ` +
+

${escapeHtml(result.title || 'Unknown Title')}

+ ${type.toUpperCase()} +
+
+
+ 🎤 ${escapeHtml(result.artist || 'Unknown Artist')}
- `; - }).join(''); -} - -function selectResult(index) { - const result = searchResults[index]; - if (!result) return; + ${result.album ? `
💿 ${escapeHtml(result.album)}
` : ''} + ${result.quality ? `
🎵 ${result.quality}
` : ''} + ${bitrate ? `
⚡ ${bitrate}
` : ''} + ${duration ? `
⏱️ ${duration}
` : ''} +
+ 📂 ${fileSizeFormatted} +
+
+ 👤 ${escapeHtml(result.username || 'Unknown User')} +
+ ${type === 'album' && result.track_count ? `
🎼 ${result.track_count} tracks
` : ''} +
+
+ + ${type === 'track' ? `` : ''} +
+ `; - console.log('Selected result:', result); - // Could show detailed view or additional actions here + return resultDiv; } -async function startStream(index) { - const result = searchResults[index]; - if (!result || result.type === 'album') { - showToast('Cannot stream albums (yet)', 'error'); +// Utility functions for search results +function formatFileSize(bytes) { + if (!bytes || bytes === 0) return 'Unknown size'; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; +} + +function formatDuration(seconds) { + if (!seconds || seconds === 0) return ''; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; +} + +function toggleFilters() { + const filterContent = document.getElementById('filter-content'); + const filterToggle = document.getElementById('filter-toggle-btn'); + + if (filterContent && filterToggle) { + if (filterContent.classList.contains('hidden')) { + filterContent.classList.remove('hidden'); + filterToggle.textContent = '⏶ Filters'; + } else { + filterContent.classList.add('hidden'); + filterToggle.textContent = '⏷ Filters'; + } + } +} + +function initializeFilters() { + // This would implement filter functionality + // For now, it's a placeholder + console.log('Initializing filters...'); +} + +// =============================== +// DOWNLOAD FUNCTIONALITY +// =============================== + +// Global download state +let selectedSearchResult = null; +let currentDownloadModal = null; + +async function startDownloadWithModal(index, type) { + console.log(`⬇️ Starting download with modal for index ${index}, type ${type}`); + + // Get the search result based on type and index + let searchResult; + if (type === 'track') { + searchResult = currentSearchResults.tracks[index]; + } else if (type === 'album') { + searchResult = currentSearchResults.albums[index]; + } + + if (!searchResult) { + showToast('Search result not found', 'error'); + return; + } + + selectedSearchResult = searchResult; + + // Show the Spotify matching modal + openSpotifyMatchingModal(searchResult, type); +} + +async function startDirectDownload(searchResult) { + + if (!searchResult) { + showToast('No search result provided for download', 'error'); return; } try { - showLoadingAnimation(); + console.log(`⬇️ Starting direct download: ${searchResult.title}`); - const streamData = { - username: result.username, - filename: result.filename, - title: result.title, - artist: result.artist, - album: result.album, - quality: result.quality, - bitrate: result.bitrate, - duration: result.duration, - size_mb: result.file_size / 1024 / 1024 - }; - - const response = await fetch(API.stream.start, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(streamData) - }); - - const data = await response.json(); - - if (data.error) { - hideLoadingAnimation(); - showToast(`Stream error: ${data.error}`, 'error'); - } else { - setTrackInfo(data.track); - currentStream.status = 'loading'; - showToast('Starting stream...', 'success'); - } - } catch (error) { - hideLoadingAnimation(); - console.error('Error starting stream:', error); - showToast('Failed to start stream', 'error'); - } -} - -async function startDownload(index) { - const result = searchResults[index]; - if (!result) return; - - try { const response = await fetch('/api/downloads/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(result) + body: JSON.stringify(searchResult) }); const data = await response.json(); if (data.success) { - showToast('Download started', 'success'); + showToast(`Download started: ${searchResult.title}`, 'success'); + console.log(`✅ Download started: ${data.download_id}`); } else { - showToast(`Download failed: ${data.error}`, 'error'); + throw new Error(data.error || 'Unknown error'); } - } catch (error) { - console.error('Error starting download:', error); - showToast('Failed to start download', 'error'); - } -} - -// =============================== -// PAGE DATA LOADING -// =============================== - -async function loadInitialData() { - try { - // Load dashboard data by default - await loadDashboardData(); - } catch (error) { - console.error('Error loading initial data:', error); - } -} - -async function loadDashboardData() { - try { - const response = await fetch(API.activity); - const data = await response.json(); - const activityFeed = document.getElementById('activity-feed'); - if (data.activities && data.activities.length) { - activityFeed.innerHTML = data.activities.map(activity => ` -
- ${activity.time} - ${escapeHtml(activity.text)} -
- `).join(''); - } } catch (error) { - console.error('Error loading dashboard data:', error); + console.error('❌ Download failed:', error); + showToast(`Download failed: ${error.message}`, 'error'); } } -async function loadSyncData() { - try { - const response = await fetch(API.playlists); - const data = await response.json(); - - const playlistSelector = document.getElementById('playlist-selector'); - if (data.playlists && data.playlists.length) { - playlistSelector.innerHTML = [ - '', - ...data.playlists.map(playlist => - `` - ) - ].join(''); - } else { - playlistSelector.innerHTML = ''; - } - } catch (error) { - console.error('Error loading sync data:', error); - document.getElementById('playlist-selector').innerHTML = ''; - } -} - -async function loadDownloadsData() { - // Downloads page loads search results dynamically - console.log('Downloads page loaded'); -} - -async function loadArtistsData() { - try { - const response = await fetch(API.artists); - const data = await response.json(); - - const artistsGrid = document.getElementById('artists-grid'); - if (data.artists && data.artists.length) { - artistsGrid.innerHTML = data.artists.map(artist => ` -
-
- ${artist.image ? - `${escapeHtml(artist.name)}` : - '
🎵
' - } -
-
-
${escapeHtml(artist.name)}
-
${artist.album_count || 0} albums
-
-
- `).join(''); - } else { - artistsGrid.innerHTML = '
No artists found
'; - } - } catch (error) { - console.error('Error loading artists data:', error); - document.getElementById('artists-grid').innerHTML = '
Error loading artists
'; - } -} - -// =============================== -// UTILITY FUNCTIONS -// =============================== - -function showLoadingOverlay(message = 'Loading...') { - const overlay = document.getElementById('loading-overlay'); - const messageElement = overlay.querySelector('.loading-message'); - messageElement.textContent = message; - overlay.classList.remove('hidden'); -} - -function hideLoadingOverlay() { - document.getElementById('loading-overlay').classList.add('hidden'); -} - -function showToast(message, type = 'success') { - const container = document.getElementById('toast-container'); - const toast = document.createElement('div'); - toast.className = `toast ${type}`; - toast.textContent = message; +async function startMatchedDownload(searchResult, spotifyMatch) { - container.appendChild(toast); + if (!searchResult || !spotifyMatch) { + showToast('Missing search result or Spotify match for download', 'error'); + return; + } - // Auto-remove after 3 seconds - setTimeout(() => { - if (container.contains(toast)) { - container.removeChild(toast); + try { + console.log(`⬇️🎵 Starting matched download: ${searchResult.title}`); + console.log(` 🎤 Matched to: ${spotifyMatch.artist.name} - ${spotifyMatch.album ? spotifyMatch.album.name : 'Single'}`); + + const downloadData = { + ...searchResult, + spotify_match: spotifyMatch + }; + + const response = await fetch('/api/downloads/start-matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(downloadData) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`Matched download started: ${searchResult.title}`, 'success'); + console.log(`✅ Matched download started: ${data.download_id}`); + } else { + throw new Error(data.error || 'Unknown error'); } - }, 3000); + + } catch (error) { + console.error('❌ Matched download failed:', error); + showToast(`Matched download failed: ${error.message}`, 'error'); + } } +// =============================== +// DOWNLOAD QUEUE MANAGEMENT +// =============================== + +let downloadQueueUpdateInterval = null; + +function startDownloadQueueUpdates() { + // Update immediately + updateDownloadQueues(); + + // Update every 2 seconds + downloadQueueUpdateInterval = setInterval(updateDownloadQueues, 2000); +} + +function stopDownloadQueueUpdates() { + if (downloadQueueUpdateInterval) { + clearInterval(downloadQueueUpdateInterval); + downloadQueueUpdateInterval = null; + } +} + +async function updateDownloadQueues() { + try { + const response = await fetch('/api/downloads/status'); + const data = await response.json(); + + if (data.success) { + updateDownloadQueueDisplay(data.downloads); + updateDownloadCounts(data.downloads); + } + + } catch (error) { + console.error('Error updating download queues:', error); + } +} + +function updateDownloadQueueDisplay(downloads) { + const activeQueue = document.getElementById('active-queue'); + const finishedQueue = document.getElementById('finished-queue'); + + if (activeQueue) { + displayDownloadQueue(activeQueue, downloads.active, 'active'); + } + + if (finishedQueue) { + displayDownloadQueue(finishedQueue, downloads.completed, 'completed'); + } +} + +function displayDownloadQueue(container, downloads, queueType) { + if (!container) return; + + // Clear existing content + container.innerHTML = ''; + + if (!downloads || downloads.length === 0) { + const emptyMessage = document.createElement('div'); + emptyMessage.className = 'empty-queue-message'; + emptyMessage.textContent = queueType === 'active' ? 'No active downloads.' : 'No finished downloads.'; + container.appendChild(emptyMessage); + return; + } + + downloads.forEach(download => { + const downloadElement = createDownloadQueueItem(download, queueType); + container.appendChild(downloadElement); + }); +} + +function createDownloadQueueItem(download, queueType) { + const itemDiv = document.createElement('div'); + itemDiv.className = 'download-queue-item'; + itemDiv.setAttribute('data-download-id', download.id); + + const progress = download.progress || 0; + const status = download.status || 'unknown'; + const speedFormatted = download.download_speed ? formatSpeed(download.download_speed) : ''; + + itemDiv.innerHTML = ` +
+
${escapeHtml(download.title || 'Unknown Title')}
+
${status.toUpperCase()}
+
+
+ 🎤 ${escapeHtml(download.artist || 'Unknown Artist')} • 👤 ${escapeHtml(download.username || 'Unknown User')} + ${speedFormatted ? ` • ⚡ ${speedFormatted}` : ''} + ${download.spotify_matched ? ' • 🎵 Spotify Matched' : ''} +
+ ${queueType === 'active' && progress !== undefined ? ` +
+
+
+
${progress}% Complete
+ ` : ''} + ${queueType === 'active' ? ` + + ` : ''} + `; + + return itemDiv; +} + +function updateDownloadCounts(downloads) { + const activeCount = document.getElementById('active-downloads-count'); + const finishedCount = document.getElementById('finished-downloads-count'); + + if (activeCount) { + activeCount.textContent = downloads.active_count || 0; + } + + if (finishedCount) { + finishedCount.textContent = downloads.completed_count || 0; + } +} + +async function cancelDownload(downloadId) { + try { + const response = await fetch(`/api/downloads/cancel/${downloadId}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + showToast('Download cancelled', 'success'); + } else { + throw new Error(data.error || 'Failed to cancel download'); + } + + } catch (error) { + console.error('Error cancelling download:', error); + showToast(`Failed to cancel download: ${error.message}`, 'error'); + } +} + +async function clearCompletedDownloads() { + try { + const response = await fetch('/api/downloads/clear-completed', { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + showToast('Completed downloads cleared', 'success'); + updateDownloadQueues(); // Refresh the display + } else { + throw new Error(data.error || 'Failed to clear completed downloads'); + } + + } catch (error) { + console.error('Error clearing completed downloads:', error); + showToast(`Failed to clear downloads: ${error.message}`, 'error'); + } +} + +function initializeDownloadTabs() { + console.log('Initializing download tabs...'); + const tabButtons = document.querySelectorAll('.tab-btn'); + const clearButton = document.getElementById('clear-completed-btn'); + + console.log(`Found ${tabButtons.length} tab buttons`); + + tabButtons.forEach((button, index) => { + const targetTab = button.getAttribute('data-tab'); + console.log(`Tab button ${index}: data-tab="${targetTab}"`); + + button.addEventListener('click', () => { + console.log(`Tab clicked: ${targetTab}`); + switchDownloadTab(targetTab); + + // Update tab button states + tabButtons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + }); + }); + + if (clearButton) { + clearButton.addEventListener('click', clearCompletedDownloads); + console.log('Clear completed button handler added'); + } +} + +function switchDownloadTab(tabName) { + console.log(`Switching to tab: ${tabName}`); + const allQueues = document.querySelectorAll('.download-queue'); + console.log(`Found ${allQueues.length} download queues`); + + allQueues.forEach(queue => { + console.log(`Removing active from: ${queue.id}`); + queue.classList.remove('active'); + }); + + const targetQueue = document.getElementById(tabName); + if (targetQueue) { + console.log(`Adding active to: ${tabName}`); + targetQueue.classList.add('active'); + } else { + console.log(`Target queue not found: ${tabName}`); + } +} + +function formatSpeed(bytesPerSecond) { + if (!bytesPerSecond || bytesPerSecond === 0) return ''; + const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']; + const i = Math.floor(Math.log(bytesPerSecond) / Math.log(1024)); + return Math.round(bytesPerSecond / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; +} + +// =============================== +// SPOTIFY MATCHING MODAL +// =============================== + +// Global Spotify modal state +let selectedArtist = null; +let selectedAlbum = null; +let modalStage = 'artist'; // 'artist' or 'album' +let isForAlbumDownload = false; + +async function openSpotifyMatchingModal(searchResult, type) { + console.log(`🎵 Opening Spotify matching modal for: ${searchResult.title} by ${searchResult.artist}`); + + selectedSearchResult = searchResult; + isForAlbumDownload = (type === 'album'); + selectedArtist = null; + selectedAlbum = null; + modalStage = 'artist'; + + // Show the modal + const modalOverlay = document.getElementById('spotify-matching-modal-overlay'); + if (modalOverlay) { + modalOverlay.classList.remove('hidden'); + } + + // Update modal title and subtitle + updateModalHeader(); + + // Generate artist suggestions + await generateSpotifyArtistSuggestions(searchResult); + + // Setup manual search + setupManualSearch(); +} + +function closeSpotifyMatchingModal() { + const modalOverlay = document.getElementById('spotify-matching-modal-overlay'); + if (modalOverlay) { + modalOverlay.classList.add('hidden'); + } + + // Reset modal state + selectedArtist = null; + selectedAlbum = null; + modalStage = 'artist'; + selectedSearchResult = null; + isForAlbumDownload = false; + + // Clear suggestions + clearModalSuggestions(); + clearManualSearchResults(); +} + +function skipSpotifyMatching() { + console.log('⏭️ Skipping Spotify matching, starting direct download'); + + if (selectedSearchResult) { + startDirectDownload(selectedSearchResult); + } + + closeSpotifyMatchingModal(); +} + +function updateModalHeader() { + const titleElement = document.getElementById('spotify-modal-title'); + const subtitleElement = document.getElementById('spotify-modal-subtitle'); + + if (isForAlbumDownload) { + if (modalStage === 'artist') { + if (titleElement) titleElement.textContent = 'Match Album to Spotify'; + if (subtitleElement) subtitleElement.textContent = 'Step 1: Select the correct Artist'; + } else { + if (titleElement) titleElement.textContent = 'Match Album to Spotify'; + if (subtitleElement) subtitleElement.textContent = 'Step 2: Select the correct Album'; + } + } else { + if (titleElement) titleElement.textContent = 'Match Track to Spotify'; + if (subtitleElement) subtitleElement.textContent = 'Select the correct Artist for this Track'; + } +} + +async function generateSpotifyArtistSuggestions(searchResult) { + const suggestionsGrid = document.getElementById('auto-suggestions-grid'); + if (!suggestionsGrid) return; + + // Show loading state + suggestionsGrid.innerHTML = ` +
+
+ Generating suggestions... +
+ `; + + try { + const response = await fetch('/api/spotify/suggestions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: searchResult.title, + artist: searchResult.artist + }) + }); + + const data = await response.json(); + + if (data.success && data.suggestions) { + displayArtistSuggestions(data.suggestions); + } else { + throw new Error(data.error || 'Failed to generate suggestions'); + } + + } catch (error) { + console.error('Error generating artist suggestions:', error); + suggestionsGrid.innerHTML = ` +
+ ⚠️ Failed to load suggestions +
+ `; + } +} + +function displayArtistSuggestions(artists) { + const suggestionsGrid = document.getElementById('auto-suggestions-grid'); + if (!suggestionsGrid) return; + + suggestionsGrid.innerHTML = ''; + + artists.forEach(artist => { + const artistCard = createSpotifyCard(artist, 'artist'); + suggestionsGrid.appendChild(artistCard); + }); +} + +function displayAlbumSuggestions(albums) { + const suggestionsGrid = document.getElementById('auto-suggestions-grid'); + if (!suggestionsGrid) return; + + suggestionsGrid.innerHTML = ''; + + albums.forEach(album => { + const albumCard = createSpotifyCard(album, 'album'); + suggestionsGrid.appendChild(albumCard); + }); +} + +function createSpotifyCard(item, type) { + const cardDiv = document.createElement('div'); + cardDiv.className = 'spotify-card'; + cardDiv.setAttribute('data-id', item.id); + cardDiv.setAttribute('data-type', type); + + // Add click handler + cardDiv.addEventListener('click', () => selectSpotifyItem(item, type, cardDiv)); + + let details = ''; + if (type === 'artist') { + const genres = item.genres && item.genres.length > 0 ? item.genres.slice(0, 2).join(', ') : 'No genres'; + const followers = item.follower_count ? formatNumber(item.follower_count) + ' followers' : ''; + details = `${genres}${followers ? ' • ' + followers : ''}`; + } else if (type === 'album') { + const releaseYear = item.release_date ? new Date(item.release_date).getFullYear() : ''; + const trackCount = item.total_tracks ? `${item.total_tracks} tracks` : ''; + details = `${releaseYear ? releaseYear + ' • ' : ''}${trackCount}`; + } + + cardDiv.innerHTML = ` +
+ ${item.image_url ? + `${escapeHtml(item.name)} + ` : + `
🎵
` + } +
+
${escapeHtml(item.name)}
+
${details}
+ `; + + return cardDiv; +} + +function selectSpotifyItem(item, type, cardElement) { + console.log(`🎯 Selected ${type}: ${item.name}`); + + // Update selection state + if (type === 'artist') { + selectedArtist = item; + + // Update visual selection + document.querySelectorAll('.spotify-card').forEach(card => card.classList.remove('selected')); + cardElement.classList.add('selected'); + + // Enable confirm button or move to album selection + if (isForAlbumDownload) { + // For albums, proceed to album selection + proceedToAlbumSelection(); + } else { + // For tracks, enable confirm button + enableConfirmButton(); + } + } else if (type === 'album') { + selectedAlbum = item; + + // Update visual selection + document.querySelectorAll('.spotify-card').forEach(card => card.classList.remove('selected')); + cardElement.classList.add('selected'); + + // Enable confirm button + enableConfirmButton(); + } +} + +async function proceedToAlbumSelection() { + console.log(`🎵 Proceeding to album selection for artist: ${selectedArtist.name}`); + + modalStage = 'album'; + updateModalHeader(); + + // Clear manual search + clearManualSearchResults(); + const manualSearch = document.getElementById('spotify-manual-search'); + if (manualSearch) { + manualSearch.value = ''; + manualSearch.placeholder = 'Manually search for an album...'; + } + + // Load albums for selected artist + await loadArtistAlbums(selectedArtist.id); +} + +async function loadArtistAlbums(artistId) { + const suggestionsGrid = document.getElementById('auto-suggestions-grid'); + if (!suggestionsGrid) return; + + // Show loading state + suggestionsGrid.innerHTML = ` +
+
+ Loading albums... +
+ `; + + try { + const response = await fetch('/api/spotify/search-album', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + artist_id: artistId + }) + }); + + const data = await response.json(); + + if (data.success && data.albums) { + displayAlbumSuggestions(data.albums); + } else { + throw new Error(data.error || 'Failed to load albums'); + } + + } catch (error) { + console.error('Error loading artist albums:', error); + suggestionsGrid.innerHTML = ` +
+ ⚠️ Failed to load albums +
+ `; + } +} + +function setupManualSearch() { + const manualSearch = document.getElementById('spotify-manual-search'); + if (!manualSearch) return; + + // Clear previous listeners + manualSearch.replaceWith(manualSearch.cloneNode(true)); + const newManualSearch = document.getElementById('spotify-manual-search'); + + let searchTimeout; + newManualSearch.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + performManualSpotifySearch(e.target.value.trim()); + }, 500); // Debounce search + }); +} + +async function performManualSpotifySearch(query) { + if (!query || query.length < 2) { + clearManualSearchResults(); + return; + } + + const resultsContainer = document.getElementById('manual-search-results'); + if (!resultsContainer) return; + + try { + let endpoint, searchData; + + if (modalStage === 'artist') { + endpoint = '/api/spotify/search-artist'; + searchData = { query }; + } else { + endpoint = '/api/spotify/search-album'; + searchData = { artist_id: selectedArtist.id, query }; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(searchData) + }); + + const data = await response.json(); + + if (data.success) { + const items = modalStage === 'artist' ? data.artists : data.albums; + displayManualSearchResults(items, modalStage); + } else { + throw new Error(data.error || 'Search failed'); + } + + } catch (error) { + console.error('Manual search error:', error); + clearManualSearchResults(); + } +} + +function displayManualSearchResults(items, type) { + const resultsContainer = document.getElementById('manual-search-results'); + if (!resultsContainer) return; + + resultsContainer.innerHTML = ''; + + items.slice(0, 4).forEach(item => { // Limit to 4 results + const card = createSpotifyCard(item, type); + resultsContainer.appendChild(card); + }); +} + +function clearModalSuggestions() { + const suggestionsGrid = document.getElementById('auto-suggestions-grid'); + if (suggestionsGrid) { + suggestionsGrid.innerHTML = ''; + } +} + +function clearManualSearchResults() { + const resultsContainer = document.getElementById('manual-search-results'); + if (resultsContainer) { + resultsContainer.innerHTML = ''; + } +} + +function enableConfirmButton() { + const confirmButton = document.getElementById('confirm-spotify-match'); + if (confirmButton) { + confirmButton.disabled = false; + } +} + +function disableConfirmButton() { + const confirmButton = document.getElementById('confirm-spotify-match'); + if (confirmButton) { + confirmButton.disabled = true; + } +} + +async function confirmSpotifyMatch() { + if (!selectedArtist) { + showToast('Please select an artist first', 'error'); + return; + } + + if (isForAlbumDownload && !selectedAlbum) { + showToast('Please select an album first', 'error'); + return; + } + + console.log(`✅ Confirming Spotify match:`); + console.log(` 🎤 Artist: ${selectedArtist.name}`); + if (selectedAlbum) { + console.log(` 💿 Album: ${selectedAlbum.name}`); + } + + const spotifyMatch = { + artist: selectedArtist, + album: selectedAlbum + }; + + // Start the matched download + await startMatchedDownload(selectedSearchResult, spotifyMatch); + + // Close the modal + closeSpotifyMatchingModal(); +} + +function formatNumber(num) { + if (num >= 1000000) { + return Math.round(num / 100000) / 10 + 'M'; + } else if (num >= 1000) { + return Math.round(num / 100) / 10 + 'K'; + } + return num.toString(); +} + +// =============================== +// GLOBAL FUNCTION DECLARATIONS +// =============================== + +// Make all functions globally available for onclick handlers +window.startDownloadWithModal = startDownloadWithModal; +window.cancelDownload = cancelDownload; +window.clearCompletedDownloads = clearCompletedDownloads; +window.openSpotifyMatchingModal = openSpotifyMatchingModal; +window.closeSpotifyMatchingModal = closeSpotifyMatchingModal; +window.skipSpotifyMatching = skipSpotifyMatching; +window.confirmSpotifyMatch = confirmSpotifyMatch; +window.showToast = showToast; + +// =============================== +// MISSING UTILITY FUNCTIONS +// =============================== + function escapeHtml(text) { + if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } -async function showVersionInfo() { - try { - console.log('Fetching version info...'); - - // Fetch version data from API - const response = await fetch('/api/version-info'); - if (!response.ok) { - throw new Error('Failed to fetch version info'); - } - - const versionData = await response.json(); - console.log('Version data received:', versionData); - - // Populate modal content - populateVersionModal(versionData); - - // Show modal - const modalOverlay = document.getElementById('version-modal-overlay'); - modalOverlay.classList.remove('hidden'); - - console.log('Version modal opened'); - - } catch (error) { - console.error('Error showing version info:', error); - showToast('Failed to load version information', 'error'); - } -} - -function closeVersionModal() { - const modalOverlay = document.getElementById('version-modal-overlay'); - modalOverlay.classList.add('hidden'); - console.log('Version modal closed'); -} - -function populateVersionModal(versionData) { - const container = document.getElementById('version-content-container'); - if (!container) { - console.error('Version content container not found'); - return; +function showToast(message, type = 'info') { + console.log(`Toast [${type}]: ${message}`); + + // Create toast container if it doesn't exist + let toastContainer = document.getElementById('toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'toast-container'; + toastContainer.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 10001; + display: flex; + flex-direction: column; + gap: 10px; + `; + document.body.appendChild(toastContainer); } - // Update header with dynamic data - const titleElement = document.querySelector('.version-modal-title'); - const subtitleElement = document.querySelector('.version-modal-subtitle'); + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + toast.style.cssText = ` + padding: 12px 16px; + border-radius: 6px; + color: white; + font-size: 14px; + max-width: 300px; + opacity: 0; + transform: translateX(100%); + transition: all 0.3s ease; + cursor: pointer; + ${type === 'success' ? 'background: #1db954;' : ''} + ${type === 'error' ? 'background: #e22134;' : ''} + ${type === 'info' ? 'background: #1ed760;' : ''} + `; - if (titleElement) titleElement.textContent = versionData.title; - if (subtitleElement) subtitleElement.textContent = versionData.subtitle; - - // Clear existing content - container.innerHTML = ''; - - // Create sections - versionData.sections.forEach(section => { - const sectionDiv = document.createElement('div'); - sectionDiv.className = 'version-feature-section'; - - // Section title - const titleDiv = document.createElement('div'); - titleDiv.className = 'version-section-title'; - titleDiv.textContent = section.title; - sectionDiv.appendChild(titleDiv); - - // Section description - const descDiv = document.createElement('div'); - descDiv.className = 'version-section-description'; - descDiv.textContent = section.description; - sectionDiv.appendChild(descDiv); - - // Features list - const featuresList = document.createElement('ul'); - featuresList.className = 'version-feature-list'; - - section.features.forEach(feature => { - const featureItem = document.createElement('li'); - featureItem.className = 'version-feature-item'; - featureItem.textContent = feature; - featuresList.appendChild(featureItem); - }); - - sectionDiv.appendChild(featuresList); - - // Usage note (if present) - if (section.usage_note) { - const usageDiv = document.createElement('div'); - usageDiv.className = 'version-usage-note'; - usageDiv.textContent = `💡 ${section.usage_note}`; - sectionDiv.appendChild(usageDiv); - } - - container.appendChild(sectionDiv); + // Add click to dismiss + toast.addEventListener('click', () => { + removeToast(toast); }); - console.log('Version modal content populated'); + // Add to container + toastContainer.appendChild(toast); + + // Animate in + setTimeout(() => { + toast.style.opacity = '1'; + toast.style.transform = 'translateX(0)'; + }, 10); + + // Auto remove after 4 seconds + setTimeout(() => { + removeToast(toast); + }, 4000); +} + +function removeToast(toast) { + if (toast && toast.parentNode) { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); + } } // =============================== -// ADDITIONAL STYLES FOR SEARCH RESULTS +// MISSING PAGE LOAD FUNCTIONS // =============================== -// Add dynamic styles for search results (since they're created dynamically) -const additionalStyles = ` - -`; - -// Inject additional styles -document.head.insertAdjacentHTML('beforeend', additionalStyles); - -// Global functions (for onclick handlers) -window.navigateToPage = navigateToPage; -window.openKofi = openKofi; -window.copyAddress = copyAddress; -window.showVersionInfo = showVersionInfo; -window.closeVersionModal = closeVersionModal; -window.testConnection = testConnection; -window.autoDetectPlex = autoDetectPlex; -window.autoDetectJellyfin = autoDetectJellyfin; -window.autoDetectSlskd = autoDetectSlskd; -window.toggleServer = toggleServer; -window.authenticateTidal = authenticateTidal; -window.browsePath = browsePath; -window.selectResult = selectResult; -window.startStream = startStream; -window.startDownload = startDownload; \ No newline at end of file diff --git a/webui/static/style.css b/webui/static/style.css index 06b29aca..edd74db8 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -618,83 +618,211 @@ body { letter-spacing: -0.5px; } -/* Dashboard Page Styling */ -.dashboard-content { +/* ===================================== + DOWNLOADS (SEARCH) PAGE STYLES + ===================================== */ + +#downloads-page .page-header p { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + margin-top: 4px; +} + +.downloads-content-splitter { display: flex; - gap: 40px; - height: calc(100vh - 150px); + gap: 16px; + height: calc(100% - 100px); /* Adjust based on header height */ } -.activity-section { - flex: 1; +.search-results-panel { + flex: 3; /* Takes up more space */ + display: flex; + flex-direction: column; + gap: 12px; background: rgba(255, 255, 255, 0.02); - border-radius: 12px; - padding: 24px; border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 16px; } -.activity-section h3 { - font-family: 'SF Pro Text', -apple-system, sans-serif; +.download-manager-panel { + flex: 2; /* Takes up less space */ + display: flex; + flex-direction: column; + gap: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 16px; +} + +.download-manager-panel h4 { font-size: 16px; font-weight: 600; - color: rgba(255, 255, 255, 0.9); - margin-bottom: 16px; + color: #ffffff; + margin-bottom: 4px; } -.activity-feed { - max-height: 400px; - overflow-y: auto; -} - -.activity-item { +/* Elegant Search Bar */ +.elegant-search-bar { display: flex; - gap: 12px; - padding: 12px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + gap: 10px; + align-items: center; + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 8px; } -.activity-time { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - min-width: 80px; +#search-input { + flex-grow: 1; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 6px; + color: #fff; + padding: 10px 14px; + font-size: 14px; +} +#search-input:focus { + outline: none; + border-color: #1ed760; } -.activity-text { - font-size: 12px; - color: rgba(255, 255, 255, 0.8); - flex: 1; +.search-btn, .cancel-search-btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; } -.stats-grid { - display: grid; - grid-template-columns: 1fr; - gap: 16px; - width: 300px; +.search-btn { + background-color: #1db954; + color: #000; +} +.search-btn:hover { + background-color: #1ed760; } -.stat-card { - background: linear-gradient(135deg, rgba(29, 185, 84, 0.1) 0%, rgba(255, 255, 255, 0.02) 100%); +.cancel-search-btn { + background-color: #d32f2f; + color: #fff; +} +.cancel-search-btn:hover { + background-color: #f44336; +} + +/* Search Status */ +.search-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(29, 185, 84, 0.08); border: 1px solid rgba(29, 185, 84, 0.2); - border-radius: 12px; - padding: 24px; - text-align: center; -} - -.stat-value { - font-family: 'SF Pro Display', -apple-system, sans-serif; - font-size: 32px; - font-weight: 700; - color: #1ed760; - line-height: 1; - margin-bottom: 8px; -} - -.stat-label { + border-radius: 6px; font-size: 12px; color: rgba(255, 255, 255, 0.7); - font-weight: 500; } +.spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: #1ed760; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Search Results Area */ +.search-results-area { + flex-grow: 1; + overflow-y: auto; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 8px; +} + +.no-results-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: rgba(255, 255, 255, 0.4); + font-style: italic; +} + +/* Download Manager */ +.download-stats { + display: flex; + gap: 16px; + font-size: 12px; + color: #b3b3b3; +} +.download-stats b { + color: #fff; +} + +.clear-completed-btn { + background: rgba(220, 53, 69, 0.2); + color: #dc3545; + border: 1px solid rgba(220, 53, 69, 0.4); + padding: 6px 12px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + align-self: flex-start; +} +.clear-completed-btn:hover { + background: rgba(220, 53, 69, 0.3); +} + +.download-tabs { + display: flex; + border-bottom: 1px solid #444; +} + +.tab-btn { + padding: 8px 16px; + background: transparent; + border: none; + color: #b3b3b3; + cursor: pointer; + border-bottom: 2px solid transparent; + font-size: 13px; +} + +.tab-btn.active { + color: #1ed760; + border-bottom-color: #1ed760; +} + +.download-queue-container { + flex-grow: 1; + overflow-y: auto; + position: relative; +} + +.download-queue { + display: none; + flex-direction: column; + gap: 8px; + padding: 8px; +} + +.download-queue.active { + display: flex; +} + +.empty-queue-message { + text-align: center; + padding: 20px; + color: rgba(255, 255, 255, 0.4); + font-style: italic; +} + + /* Settings Page Styling - Two Column Layout */ .settings-content { max-width: 100%; @@ -1628,4 +1756,588 @@ body { .version-modal-overlay:not(.hidden) .version-modal { animation: modalFadeIn 0.3s ease-out; +} + +/* ===== SPOTIFY MATCHING MODAL STYLES ===== */ + +.spotify-matching-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(8px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.3s ease-in-out; +} + +.spotify-matching-modal-overlay.hidden { + opacity: 0; + pointer-events: none; +} + +.spotify-matching-modal { + background: #121212; + border-radius: 12px; + border: 1px solid rgba(29, 185, 84, 0.2); + width: 1100px; + max-width: 95vw; + height: 750px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8); + transform: scale(1); + transition: transform 0.3s ease-in-out; +} + +.spotify-matching-modal-overlay.hidden .spotify-matching-modal { + transform: scale(0.9); +} + +/* Modal Header */ +.spotify-modal-header { + padding: 20px 20px 15px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: #121212; +} + +.spotify-modal-title { + color: #1DB954; + font-size: 22px; + font-weight: 700; + margin: 0 0 8px 0; + font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.spotify-modal-subtitle { + color: #B3B3B3; + font-size: 16px; + font-weight: 400; + margin: 0; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* Modal Content */ +.spotify-modal-content { + flex: 1; + overflow-y: auto; + background: #121212; + padding: 20px; +} + +.spotify-modal-content::-webkit-scrollbar { + width: 8px; +} + +.spotify-modal-content::-webkit-scrollbar-track { + background: #2A2A2A; + border-radius: 4px; +} + +.spotify-modal-content::-webkit-scrollbar-thumb { + background: #535353; + border-radius: 4px; +} + +.spotify-modal-content::-webkit-scrollbar-thumb:hover { + background: #6A6A6A; +} + +.spotify-content-container { + display: flex; + flex-direction: column; + gap: 30px; +} + +/* Suggestions Section */ +.suggestions-section { + display: flex; + flex-direction: column; + gap: 15px; +} + +.suggestions-title, .manual-search-title { + color: #B3B3B3; + font-size: 16px; + font-weight: 500; + margin: 0; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.suggestions-grid { + display: flex; + gap: 20px; + flex-wrap: wrap; + min-height: 120px; + align-items: flex-start; +} + +.loading-suggestions { + display: flex; + align-items: center; + gap: 12px; + color: #B3B3B3; + font-size: 14px; + padding: 20px; +} + +.suggestion-loading-spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(29, 185, 84, 0.2); + border-top-color: #1DB954; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Artist/Album Cards */ +.spotify-card { + background: #1E1E1E; + border: 2px solid #2A2A2A; + border-radius: 12px; + padding: 15px; + width: 180px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + position: relative; +} + +.spotify-card:hover { + border-color: #1DB954; + background: #282828; + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(29, 185, 84, 0.2); +} + +.spotify-card.selected { + border-color: #1DB954; + background: rgba(29, 185, 84, 0.1); + box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.3); +} + +.spotify-card-image { + width: 120px; + height: 120px; + border-radius: 8px; + background: #2A2A2A; + margin-bottom: 12px; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.spotify-card-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.spotify-card-image-placeholder { + color: #535353; + font-size: 24px; +} + +.spotify-card-name { + color: #FFFFFF; + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; + line-height: 1.2; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.spotify-card-details { + color: #B3B3B3; + font-size: 12px; + font-weight: 400; + line-height: 1.3; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* Manual Search Section */ +.manual-search-section { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.manual-search-bar { + display: flex; + flex-direction: column; + gap: 15px; +} + +#spotify-manual-search { + background: #2A2A2A; + border: 2px solid #535353; + border-radius: 15px; + color: white; + padding: 12px 16px; + font-size: 14px; + transition: border-color 0.3s ease; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +#spotify-manual-search:focus { + outline: none; + border-color: #1DB954; +} + +.manual-search-results { + display: flex; + gap: 20px; + flex-wrap: wrap; + min-height: 60px; + align-items: flex-start; +} + +/* Modal Footer */ +.spotify-modal-footer { + padding: 15px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.02); + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.spotify-modal-btn { + border: none; + border-radius: 15px; + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s ease; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; +} + +.spotify-modal-btn.primary { + background: #1DB954; + color: #FFFFFF; +} + +.spotify-modal-btn.primary:hover:not(:disabled) { + background: #1ED760; +} + +.spotify-modal-btn.primary:disabled { + background: #2A2A2A; + color: #535353; + cursor: not-allowed; +} + +.spotify-modal-btn.secondary { + background: #535353; + color: #FFFFFF; +} + +.spotify-modal-btn.secondary:hover { + background: #6A6A6A; +} + +/* ===== SEARCH RESULTS & DOWNLOAD QUEUE STYLES ===== */ + +/* Search Result Items */ +.search-result-item { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; + transition: all 0.2s ease; +} + +.search-result-item:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(29, 185, 84, 0.3); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.result-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.result-title { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 12px; +} + +.result-type-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.result-type-badge.track { + background: rgba(29, 185, 84, 0.2); + color: #1ed760; + border: 1px solid rgba(29, 185, 84, 0.3); +} + +.result-type-badge.album { + background: rgba(255, 165, 0, 0.2); + color: #ffa500; + border: 1px solid rgba(255, 165, 0, 0.3); +} + +.result-details { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 12px; + font-size: 12px; + color: #b3b3b3; +} + +.result-detail-item { + display: flex; + align-items: center; + gap: 4px; +} + +.result-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.result-btn { + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.result-btn.download { + background: #1db954; + color: #000; +} + +.result-btn.download:hover { + background: #1ed760; +} + +.result-btn.stream { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.result-btn.stream:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Download Queue Items */ +.download-queue-item { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 10px; + margin-bottom: 6px; + transition: all 0.2s ease; +} + +.download-queue-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.download-item-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.download-item-title { + font-size: 13px; + font-weight: 500; + color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 8px; +} + +.download-item-status { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; +} + +.download-item-status.downloading { + background: rgba(29, 185, 84, 0.2); + color: #1ed760; +} + +.download-item-status.queued { + background: rgba(255, 165, 0, 0.2); + color: #ffa500; +} + +.download-item-status.completed { + background: rgba(72, 191, 227, 0.2); + color: #48bfe3; +} + +.download-item-status.failed { + background: rgba(220, 53, 69, 0.2); + color: #dc3545; +} + +.download-item-details { + font-size: 11px; + color: #b3b3b3; + margin-bottom: 6px; +} + +.download-progress { + height: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + overflow: hidden; + margin-bottom: 4px; +} + +.download-progress-fill { + height: 100%; + background: linear-gradient(90deg, #1db954, #1ed760); + transition: width 0.3s ease; + border-radius: 2px; +} + +.download-progress-text { + font-size: 10px; + color: #b3b3b3; + text-align: right; +} + +/* Filter Controls (for search results) */ +.filter-controls { + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + padding: 8px; +} + +.filter-toggle-btn { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + color: #b3b3b3; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} + +.filter-toggle-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: #fff; +} + +.filter-content { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + gap: 12px; + align-items: center; +} + +.filter-group { + display: flex; + gap: 4px; + align-items: center; +} + +.filter-label { + font-size: 11px; + color: #b3b3b3; + margin-right: 6px; +} + +.filter-btn { + padding: 4px 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: #b3b3b3; + font-size: 10px; + cursor: pointer; + transition: all 0.2s ease; +} + +.filter-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.filter-btn.active { + background: rgba(29, 185, 84, 0.2); + border-color: rgba(29, 185, 84, 0.4); + color: #1ed760; +} + +/* Loading and Empty States */ +.loading-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: #b3b3b3; + gap: 12px; +} + +.loading-results-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(29, 185, 84, 0.2); + border-top-color: #1db954; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Animations */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } \ No newline at end of file