import eventlet eventlet.monkey_patch() import logging import os import subprocess import yt_dlp from flask import Flask, request from flask_socketio import SocketIO from flask_cors import CORS from spotipy import Spotify from spotipy.oauth2 import SpotifyClientCredentials from eventlet.semaphore import Semaphore # Configure logging logging.basicConfig( level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Initialize Flask app = Flask(__name__) CORS(app) # Initialize SocketIO socketio = SocketIO( app, cors_allowed_origins="*", async_mode='eventlet', logger=True, engineio_logger=True ) # Initialize Spotify client spotify = Spotify(client_credentials_manager=SpotifyClientCredentials()) # Store active downloads active_downloads = {} # Optional: limit concurrent downloads (set to None to disable cap) MAX_CONCURRENT_DOWNLOADS = 20 download_semaphore = Semaphore(MAX_CONCURRENT_DOWNLOADS) if MAX_CONCURRENT_DOWNLOADS else None class DownloadLogger: def debug(self, msg): if msg.startswith('[download]'): progress = msg.split('%')[0] if '[download]' in progress: progress = progress.split('[download]')[1] try: progress = float(progress.strip()) return progress except: pass return None def warning(self, msg): logger.warning(msg) def error(self, msg): logger.error(msg) def download_progress_hook(d): if d['status'] == 'downloading': progress = float(d['downloaded_bytes'] / d['total_bytes'] * 100) url = d['info_dict']['webpage_url'] if url in active_downloads: active_downloads[url]['progress'] = progress socketio.emit('update_status', { 'url': url, 'progress': progress, 'status': 'Downloading...' }) elif d['status'] == 'finished': url = d['info_dict']['webpage_url'] if url in active_downloads: active_downloads[url]['status'] = 'Processing...' socketio.emit('update_status', { 'url': url, 'progress': 100, 'status': 'Complete' }) @socketio.on('connect') def handle_connect(): logger.info(f'Client connected from {request.remote_addr}') socketio.emit('connection_status', {'status': 'connected'}) @socketio.on('search') def handle_search(data): logger.info(f'Search request received: {data}') query = data.get('query', '').strip() search_type = data.get('type', 'track') try: # Search YouTube ydl_opts = { 'quiet': True, 'no_warnings': True, 'extract_flat': True } with yt_dlp.YoutubeDL(ydl_opts) as ydl: youtube_results = ydl.extract_info(f'ytsearch5:{query}', download=False) tracks = [] for entry in youtube_results['entries']: if entry: tracks.append({ 'id': entry['id'], 'name': entry['title'], 'artist': entry.get('uploader', 'Unknown'), 'type': 'track', 'url': f'https://youtube.com/watch?v={entry["id"]}', 'image': entry.get('thumbnail', ''), 'duration': entry.get('duration', 0) * 1000 }) # Also search Spotify if credentials are available try: spotify_results = spotify.search(q=query, type='track', limit=5) for track in spotify_results['tracks']['items']: tracks.append({ 'id': track['id'], 'name': track['name'], 'artist': track['artists'][0]['name'], 'type': 'track', 'url': track['external_urls']['spotify'], 'image': track['album']['images'][0]['url'] if track['album']['images'] else '', 'duration': track['duration_ms'] }) except Exception as e: logger.warning(f'Spotify search failed: {e}') logger.info(f'Sending {len(tracks)} results') socketio.emit('search_results', { 'query': query, 'type': search_type, 'results': tracks }) except Exception as e: logger.error(f'Search error: {e}') socketio.emit('search_error', {'message': str(e)}) @socketio.on('download') def handle_download(data): logger.info(f'Download request received: {data}') url = data.get('url') item_name = data.get('name', '') artist_name = data.get('artist', '') target_path = data.get('target_path', 'downloads') preferred_format = data.get('format', 'mp3') # for Spotify via spotDL preferred_bitrate = str(data.get('bitrate', '320')) # kbps for spotDL # Normalize bitrate for spotDL which expects values like '320k' or words def normalize_bitrate(br: str) -> str: try: b = br.strip().lower() if b in {'auto', 'disable'}: return b if b.endswith('k'): return b # Map integers or strings like '320' to '320k' if b.isdigit(): return f"{b}k" except Exception: pass return br spotdl_bitrate = normalize_bitrate(preferred_bitrate) if not url: socketio.emit('toast', {'title': 'Error', 'body': 'No URL provided'}) return # Ensure output directory exists try: os.makedirs(target_path, exist_ok=True) except Exception as e: logger.error(f'Failed to create output directory {target_path}: {e}') socketio.emit('update_status', { 'url': url, 'progress': 0, 'status': f'Error: cannot create output folder' }) return # Store download info active_downloads[url] = { 'name': item_name, 'status': 'Starting...', 'progress': 0 } def do_download(): try: # Spotify links: use spotDL for robust resolution and tagging if 'open.spotify.com/track' in url: socketio.emit('update_status', { 'url': url, 'progress': 0, 'status': 'Resolving via spotDL' }) # Prefix filename with track number to ensure proper ordering in file browsers # spotDL template keys: {track-number}, {title}, {ext}; pad to 2 digits for lexicographic order # We keep foldering via target_path (Artist/Album provided by frontend) cmd = [ 'python3', '-m', 'spotdl', url, '--output', os.path.join(target_path, '{track-number:02} - {title}.{ext}'), '--bitrate', spotdl_bitrate, '--format', preferred_format, ] logger.info(f'Running spotDL: {" ".join(cmd)}') # We don’t have granular progress from spotDL; stream logs and set end states proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout or []: line = line.strip() if not line: continue # Emit occasional status updates if 'Downloading' in line or 'Converting' in line or 'Found' in line: socketio.emit('update_status', { 'url': url, 'progress': active_downloads.get(url, {}).get('progress', 0), 'status': line[:160] }) code = proc.wait() if code != 0: logger.warning(f'spotDL failed with exit code {code}, falling back to yt-dlp search') # Fallback: yt-dlp YouTube search using name + artist search_query = item_name or url if artist_name: search_query = f"{item_name} {artist_name} audio" ydl_opts = { 'format': 'bestaudio/best', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '192', }], 'progress_hooks': [download_progress_hook], 'logger': DownloadLogger(), 'outtmpl': os.path.join(target_path, '%(title)s.%(ext)s') } with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([f"ytsearch1:{search_query}"]) else: # Generic flow: yt-dlp bestaudio -> ffmpeg extract ydl_opts = { 'format': 'bestaudio/best', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '192', }], 'progress_hooks': [download_progress_hook], 'logger': DownloadLogger(), 'outtmpl': os.path.join(target_path, '%(title)s.%(ext)s') } with yt_dlp.YoutubeDL(ydl_opts) as ydl: try: ydl.download([url]) except Exception as yerr: logger.warning(f'yt-dlp direct failed ({yerr}); attempting spotDL search fallback') # Fallback to spotDL search if not a Spotify URL if item_name: cmd = [ 'python3', '-m', 'spotdl', f"{item_name} {artist_name}".strip(), '--output', os.path.join(target_path, '{track-number:02} - {title}.{ext}'), '--bitrate', spotdl_bitrate, '--format', preferred_format, ] logger.info(f'Running spotDL search: {" ".join(cmd)}') proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) for line in proc.stdout or []: line = (line or '').strip() if not line: continue if 'Downloading' in line or 'Converting' in line or 'Found' in line: socketio.emit('update_status', { 'url': url, 'progress': active_downloads.get(url, {}).get('progress', 0), 'status': line[:160] }) code = proc.wait() if code != 0: raise RuntimeError(f'spotDL search failed with exit code {code}') # Update final status active_downloads[url]['status'] = 'Complete' active_downloads[url]['progress'] = 100 socketio.emit('update_status', { 'url': url, 'progress': 100, 'status': 'Complete' }) except Exception as e: logger.error(f'Download error: {e}') active_downloads[url]['status'] = 'Error' socketio.emit('update_status', { 'url': url, 'progress': 0, 'status': f'Error: {str(e)}' }) finally: # Release semaphore if used if download_semaphore: try: download_semaphore.release() except Exception: pass # Start download in background, respecting optional concurrency cap if download_semaphore: download_semaphore.acquire() eventlet.spawn(do_download) if __name__ == '__main__': # Create downloads directory os.makedirs('downloads', exist_ok=True) # Start server logger.info('Starting server on port 5140...') socketio.run(app, host='0.0.0.0', port=5140, debug=True)