import queue import logging import os import threading import traceback from flask import Flask, render_template, jsonify, request from flask_socketio import SocketIO, emit from flask_cors import CORS # Import services from backend.services.config_service import ConfigService from backend.services.spotify_service import SpotifyService from backend.services.download_service import DownloadService from backend.services.playlist_manager import PlaylistManager from backend.services.jellyfin_service import JellyfinService # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) class SpotSpotApp: """Main application class for SpotSpot music downloader.""" def __init__(self): """Initialize the SpotSpot application.""" # Initialize Flask app self.app = Flask(__name__) self.app.secret_key = "SECRET_KEY" # Initialize Socket.IO with proper CORS and WebSocket settings self.socketio = SocketIO( self.app, cors_allowed_origins="*", logger=True, engineio_logger=True, async_mode='threading', # Using threading for better compatibility cors_credentials=True, allow_upgrades=True, ping_timeout=60, ping_interval=25, max_http_buffer_size=100000000 # 100MB max message size ) # Enable CORS for all routes CORS(self.app, resources={"*": {"origins": "*"}}, supports_credentials=True, expose_headers=['Content-Type', 'Authorization']) # Setup data structures self.download_queue = queue.Queue() self.download_history = {} self.active_downloads = {} # Initialize services self.config = ConfigService() self.spotify = SpotifyService(self.config) # Changed from spotify_service to spotify self.jellyfin_service = JellyfinService() self.playlist_manager = PlaylistManager(self.config) self.download_service = DownloadService( self.config, self.playlist_manager, self.socketio, self.download_queue, self.download_history ) # Set up routes and events self._setup_routes() self._setup_socket_events() # Start download thread self.start_download_thread() def _setup_routes(self) -> None: """Set up Flask routes.""" @self.app.route('/') def index(): """Serve the main application page.""" return render_template('index.html') @self.app.route('/status') def status(): """Health check endpoint.""" return jsonify({ 'status': 'ok', 'version': '1.0.0', 'downloads': self.download_service.get_status() }) @self.app.route('/search', methods=['GET']) def search(): """HTTP endpoint for search (fallback for WebSocket).""" query = request.args.get('q', '') search_type = request.args.get('type', 'track') limit = int(request.args.get('limit', '10')) if not query: return jsonify({'error': 'Query parameter is required'}), 400 results = self.spotify.search(query, search_type, limit) return jsonify({'results': results}) @self.app.route('/api/search', methods=['GET']) def api_search(): """API endpoint for search - matches frontend expectations.""" query = request.args.get('q', '') search_type = request.args.get('type', 'track') limit = int(request.args.get('limit', '50')) if not query: return jsonify({'error': 'Query parameter is required'}), 400 try: results = self.spotify.search(query, search_type, limit) return jsonify({'results': results}) except Exception as e: logger.error(f"Search error: {str(e)}") return jsonify({'error': 'Search failed'}), 500 @self.app.route('/api/jellyfin/auth', methods=['POST']) def jellyfin_auth(): """Authenticate with Jellyfin server.""" try: data = request.get_json() if not data: return jsonify({ 'success': False, 'error': 'No data provided' }), 400 server_url = data.get('server_url') username = data.get('username') password = data.get('password') if not all([server_url, username, password]): return jsonify({ 'success': False, 'error': 'Missing required fields' }), 400 # Authenticate with real Jellyfin server result = self.jellyfin_service.authenticate(server_url, username, password) return jsonify(result) except Exception as e: logger.error(f"Jellyfin auth error: {str(e)}") return jsonify({ 'success': False, 'error': 'Authentication failed' }), 500 @self.app.route('/api/jellyfin/library', methods=['GET']) def jellyfin_library(): """Get library items from Jellyfin.""" try: # Check for mock mode first (for development) if request.args.get('mock') == 'true': mock_items = [ { 'id': 'mock1', 'name': 'Bohemian Rhapsody', 'type': 'Audio', 'artist': 'Queen', 'album': 'A Night at the Opera', 'duration': 355, 'year': 1975, 'image_url': 'https://via.placeholder.com/150x150/ff6b6b/ffffff?text=♪', 'stream_url': None }, { 'id': 'mock2', 'name': 'Hotel California', 'type': 'Audio', 'artist': 'Eagles', 'album': 'Hotel California', 'duration': 391, 'year': 1976, 'image_url': 'https://via.placeholder.com/150x150/4ecdc4/ffffff?text=♪', 'stream_url': None }, { 'id': 'mock3', 'name': 'Stairway to Heaven', 'type': 'Audio', 'artist': 'Led Zeppelin', 'album': 'Led Zeppelin IV', 'duration': 482, 'year': 1971, 'image_url': 'https://via.placeholder.com/150x150/45b7d1/ffffff?text=♪', 'stream_url': None }, { 'id': 'mock4', 'name': 'Sweet Child O\' Mine', 'type': 'Audio', 'artist': 'Guns N\' Roses', 'album': 'Appetite for Destruction', 'duration': 356, 'year': 1987, 'image_url': 'https://via.placeholder.com/150x150/f7b731/ffffff?text=♪', 'stream_url': None }, { 'id': 'mock5', 'name': 'Billie Jean', 'type': 'Audio', 'artist': 'Michael Jackson', 'album': 'Thriller', 'duration': 294, 'year': 1982, 'image_url': 'https://via.placeholder.com/150x150/5f27cd/ffffff?text=♪', 'stream_url': None }, { 'id': 'mock_album1', 'name': 'A Night at the Opera', 'type': 'MusicAlbum', 'artist': 'Queen', 'album': None, 'duration': None, 'year': 1975, 'image_url': 'https://via.placeholder.com/150x150/ff6b6b/ffffff?text=♪', 'stream_url': None }, { 'id': 'mock_album2', 'name': 'Hotel California', 'type': 'MusicAlbum', 'artist': 'Eagles', 'album': None, 'duration': None, 'year': 1976, 'image_url': 'https://via.placeholder.com/150x150/4ecdc4/ffffff?text=♪', 'stream_url': None } ] return jsonify({ 'success': True, 'items': mock_items, 'total': len(mock_items) }) if not self.jellyfin_service.is_authenticated(): logger.warning("Jellyfin library request failed: Not authenticated") return jsonify({ 'success': False, 'error': 'Not authenticated with Jellyfin' }), 401 item_type = request.args.get('type', 'Audio') limit = int(request.args.get('limit', '100')) start_index = int(request.args.get('start', '0')) logger.info(f"Retrieving library items of type: {item_type}, limit: {limit}, start: {start_index}") # Get music libraries first to filter content music_libraries = self.jellyfin_service.get_music_libraries() logger.info(f"Found {len(music_libraries)} music libraries") if not music_libraries: logger.warning("No music libraries found on Jellyfin server") return jsonify({ 'success': True, 'items': [], 'total': 0, 'message': 'No music libraries found' }) # Get items from all music libraries all_items = [] for library in music_libraries: library_id = library.get('Id') library_name = library.get('Name', 'Unknown') logger.info(f"Fetching items from music library: {library_name} ({library_id})") result = self.jellyfin_service.get_library_items( library_id=library_id, item_type=item_type, limit=limit, start_index=start_index ) # Check for errors from the service if 'Error' in result: logger.error(f"Jellyfin service error for library {library_name}: {result.get('Error')}") continue library_items = result.get('Items', []) logger.info(f"Retrieved {len(library_items)} items from library {library_name}") all_items.extend(library_items) # Sort combined items by name all_items.sort(key=lambda x: x.get('SortName', x.get('Name', '')).lower()) # Transform data for frontend with safe error handling items = [] total_count = len(all_items) for item in all_items: try: # Safely handle artist names artists = item.get('Artists', []) artist_str = "" if isinstance(artists, list) and artists: # Handle list of artist names artist_names = [] for artist in artists: if isinstance(artist, dict) and 'Name' in artist: artist_names.append(artist.get('Name', '')) elif isinstance(artist, str): artist_names.append(artist) artist_str = ', '.join(filter(None, artist_names)) elif isinstance(artists, str): # Handle single artist as string artist_str = artists # If we still don't have an artist, try AlbumArtist if not artist_str and item.get('AlbumArtist'): artist_str = item.get('AlbumArtist') # If still no artist, try ArtistItems if not artist_str and item.get('ArtistItems'): artist_names = [artist.get('Name', '') for artist in item.get('ArtistItems', []) if isinstance(artist, dict)] artist_str = ', '.join(filter(None, artist_names)) # Final fallback for artist if not artist_str: artist_str = 'Unknown Artist' # Get album name safely album_name = item.get('Album', '') # Get proper duration from ticks duration = 0 run_time_ticks = item.get('RunTimeTicks') if run_time_ticks and isinstance(run_time_ticks, int): duration = run_time_ticks // 10000000 # Convert ticks to seconds # Create the transformed item transformed_item = { 'id': item.get('Id'), 'name': item.get('Name', 'Untitled'), 'type': item.get('Type', 'Unknown'), 'artist': artist_str, 'album': album_name, 'duration': duration, 'year': item.get('ProductionYear'), 'image_url': self.jellyfin_service.get_image_url(item.get('Id')) if item.get('Id') else None, 'stream_url': self.jellyfin_service.get_audio_stream_url(item.get('Id')) if item.get('Id') and item.get('Type') == 'Audio' else None } items.append(transformed_item) except Exception as e: logger.error(f"Error processing item {item.get('Id', 'unknown')}: {str(e)}") continue logger.info(f"Successfully processed {len(items)} items from Jellyfin") return jsonify({ 'success': True, 'items': items, 'total': total_count }) except Exception as e: logger.error(f"Jellyfin library error: {str(e)}", exc_info=True) return jsonify({ 'success': False, 'error': f'Failed to fetch library: {str(e)}' }), 500 @self.app.route('/api/downloads/stats', methods=['GET']) def downloads_stats(): """Get download statistics.""" try: stats = self.download_service.get_stats() return jsonify({ 'success': True, 'stats': stats }) except Exception as e: logger.error(f"Downloads stats error: {str(e)}") return jsonify({ 'success': False, 'error': 'Failed to get download stats' }), 500 def start_download_thread(self): """Start the download processing thread.""" download_thread = threading.Thread( target=self.download_service.process_downloads, daemon=True ) download_thread.start() def _setup_socket_events(self): """Set up Socket.IO event handlers with proper session management.""" @self.socketio.on('connect') def handle_connect(): """Handle client connection.""" logger.info(f"Client connected") @self.socketio.on('disconnect') def handle_disconnect(): """Handle client disconnection.""" logger.info(f"Client disconnected") @self.socketio.on("search") def handle_search(query_req): """Handle search requests.""" try: if not query_req or not isinstance(query_req, dict) or not query_req.get("query"): emit("toast", { "title": "Error", "message": "Invalid search query", "type": "error" }) return search_type = query_req.get('type', 'all') query = query_req.get('query', '') limit = int(query_req.get('limit', 20)) print(f"[DEBUG] Received search request - Query: '{query}', Type: '{search_type}', Limit: {limit}") # Perform the search using SpotifyService results = self.spotify.search(query, search_type, limit) print(f"[DEBUG] Raw search results type: {type(results)}, length: {len(results) if hasattr(results, '__len__') else 'N/A'}") if not results or not isinstance(results, list): print("[WARNING] No results or invalid format returned from spotify.search") results = [] # Ensure each result has the required fields valid_results = [] for item in results: if not isinstance(item, dict): print(f"[WARNING] Skipping invalid result item (not a dict): {item}") continue # Ensure required fields exist if 'id' not in item or 'name' not in item: print(f"[WARNING] Skipping result missing required fields: {item}") continue # Add missing fields with defaults if needed item.setdefault('type', search_type if search_type != 'all' else item.get('type', 'track')) item.setdefault('artist', item.get('artist', 'Unknown Artist')) item.setdefault('image', '') item.setdefault('url', '#') item.setdefault('duration_ms', 0) item.setdefault('explicit', False) valid_results.append(item) print(f"[DEBUG] Valid results: {len(valid_results)} items") # Format the results into the structure expected by the frontend response = { 'results': valid_results, # This should be a flat list of items 'query': query, 'type': search_type } print(f"[DEBUG] Sending {len(valid_results)} search results to client") # Emit the results back to the client - no need for room parameter emit('search_results', response) except Exception as e: error_msg = f"Error performing search: {str(e)}" print(f"[ERROR] {error_msg}") print(traceback.format_exc()) emit("search_error", {"message": error_msg}) @self.socketio.on("download_item") def handle_download(requested_item): """Handle download requests.""" try: logger.info(f"Download request: {requested_item}") if not requested_item or not isinstance(requested_item, dict): raise ValueError("Invalid download request format") # Add metadata with client info if not isinstance(requested_item.get('meta'), dict): requested_item['meta'] = {} self.download_service.add_item_to_queue(requested_item) except Exception as e: logger.error(f"Error handling download request: {str(e)}", exc_info=True) emit("download_error", { "message": "Failed to process download request", "details": str(e) }) @self.socketio.on("get_status") def handle_get_status(): """Handle status requests.""" try: emit("status", { "downloads": list(self.download_history.values()) }) except Exception as e: logger.error(f"Error getting status: {str(e)}", exc_info=True) @self.socketio.on("cancel_all") def cancel_all(): """Cancel all downloads.""" try: logger.info("Cancel all downloads request") self.download_service.cancel_active_download() self.download_service.cancel_pending_downloads() emit("cancellation_complete", {"all": True}) except Exception as e: logger.error(f"Error cancelling all downloads: {str(e)}", exc_info=True) emit("cancellation_error", { "message": "Failed to cancel all downloads", "details": str(e) }) @self.socketio.on("cancel_active") def cancel_active(): """Cancel the active download.""" try: logger.info("Cancel active download request") self.download_service.cancel_active_download() emit("cancellation_complete", {"active": True}) except Exception as e: logger.error(f"Error cancelling active download: {str(e)}", exc_info=True) emit("cancellation_error", { "message": "Failed to cancel active download", "details": str(e) }) @self.socketio.on("cancel_item") def cancel_item(data): """Cancel a specific download item.""" try: # Check if data is valid and has the URL field if not isinstance(data, dict) or 'url' not in data: emit("cancellation_error", { "message": "Invalid request: missing URL", "details": "URL is required to cancel a specific download" }) return url = data.get("url") logger.info(f"Cancel item request for URL: {url}") # Check if URL is valid before passing to the service if not url or not isinstance(url, str): emit("cancellation_error", { "message": "Invalid URL provided", "details": "URL must be a non-empty string" }) return self.download_service.cancel_specific_item(url) emit("cancellation_complete", {"url": url}) except Exception as e: # Get URL from data if possible url_str = str(data.get("url", "unknown")) if isinstance(data, dict) else "unknown" logger.error(f"Error cancelling item {url_str}: {str(e)}", exc_info=True) emit("cancellation_error", { "message": f"Failed to cancel item: {url_str}", "details": str(e), "url": url_str }) def run_app(self, host: str = '0.0.0.0', port: int | None = None, debug: bool = False) -> None: """Run the application. The port can be overridden by the PORT environment variable (e.g. via docker-compose). """ # Respect environment variable if provided if port is None: port = int(os.getenv("PORT", "6544")) logger.info(f"Starting SpotSpot server on {host}:{port}") self.socketio.run( self.app, host=host, port=port, debug=debug, use_reloader=False, allow_unsafe_werkzeug=True ) def get_app(self): """Get the Flask application instance.""" return self.app # Create a global instance of the application spotspot_web_app = SpotSpotApp() if __name__ == "__main__": spotspot_web_app.run_app(debug=True) else: app = spotspot_web_app.get_app()