diff --git a/snatch/cli.py b/snatch/cli.py index d9b304f..709220b 100644 --- a/snatch/cli.py +++ b/snatch/cli.py @@ -153,11 +153,11 @@ async def run_download(self, urls: List[str], options: Dict[str, Any], non_inter """Run download(s) with proper context management""" async with self.download_manager: try: - console = Console() - console.print(f"[bold cyan]Processing {len(urls)} URLs[/]") + con = get_console() + con.print(f"[bold cyan]Processing {len(urls)} URLs[/]") result = await self.download_manager.download_with_options(urls, options) if result: - console.print(f"[bold green]Successfully downloaded {len(result)} files[/]") + con.print(f"[bold green]Successfully downloaded {len(result)} files[/]") self.error_handler.log_error( f"Successfully downloaded {len(result)} files", ErrorCategory.DOWNLOAD, @@ -165,8 +165,7 @@ async def run_download(self, urls: List[str], options: Dict[str, Any], non_inter context={"downloaded_files": len(result), "urls": urls} ) except asyncio.CancelledError: - console = Console() - console.print("[bold yellow]Download was cancelled.[/]") + get_console().print("[bold yellow]Download was cancelled.[/]") self.error_handler.log_error( "Download process was cancelled", ErrorCategory.DOWNLOAD, @@ -181,8 +180,7 @@ async def run_download(self, urls: List[str], options: Dict[str, Any], non_inter ErrorSeverity.ERROR, context={"urls": urls, "options": options} ) - console = Console() - console.print(f"[bold red]Error: {str(e)}[/]") + get_console().print(f"[bold red]Error: {str(e)}[/]") raise def run_async(self, coro: Any) -> Any: @@ -228,20 +226,33 @@ def execute_download(self, urls: List[str], options: Dict[str, Any]) -> int: def _log_download_mode(self, options: Dict[str, Any]) -> None: """Log download mode information""" if options.get("audio_only"): - console.print(f"[bold cyan]Audio mode:[/] Downloading as {options.get('audio_format', 'mp3')}") + fmt = options.get('audio_format', 'mp3') + quality = options.get('audio_quality', 'best') + console.print(f"[bold cyan]Audio mode:[/] {fmt} (quality: {quality})") if options.get("upmix_71"): console.print("[bold cyan]Audio processing:[/] Upmixing to 7.1 surround") if options.get("denoise"): console.print("[bold cyan]Audio processing:[/] Applying noise reduction") + if options.get("enhance_audio"): + preset = options.get("audio_enhancement_preset") + level = options.get("audio_enhancement_level", "medium") + label = f"preset={preset}" if preset else f"level={level}" + console.print(f"[bold cyan]Audio enhancement:[/] {label}") elif options.get("resolution"): resolution = options.get('resolution') - console.print(f"[bold cyan]Video mode:[/] Setting resolution to {resolution}") - + codec = options.get('video_codec', 'auto') + codec_info = f" ({codec})" if codec != "auto" else "" + console.print(f"[bold cyan]Video mode:[/] {resolution}p{codec_info}") + else: + codec = options.get('video_codec', 'auto') + if codec != 'auto': + console.print(f"[bold cyan]Video mode:[/] best quality ({codec})") + # Log upscaling information if options.get("upscale_video"): method = options.get("upscale_method", "lanczos") factor = options.get("upscale_factor", 2) - console.print(f"[bold cyan]Video upscaling:[/] {method} {factor}x enhancement enabled") + console.print(f"[bold cyan]Video upscaling:[/] {method} {factor}x") @handle_errors(ErrorCategory.DOWNLOAD, ErrorSeverity.WARNING) def _run_download_safely(self, urls: List[str], options: Dict[str, Any]) -> int: @@ -353,7 +364,8 @@ def download( target_lufs: float = typer.Option(-16.0, "--target-lufs", help="Target loudness in LUFS (-23.0 for broadcast)"), declipping: bool = typer.Option(False, "--declipping", help="Remove audio clipping artifacts"), - # Video upscaling options + # Video codec and upscaling options + video_codec: str = typer.Option("auto", "--video-codec", help="Preferred video codec (h264, h265, vp9, av1, auto)"), upscale_video: bool = typer.Option(False, "--upscale", "-u", help="Enable video upscaling"), upscale_method: str = typer.Option("lanczos", "--upscale-method", help="Upscaling method (realesrgan, lanczos, bicubic)"), upscale_factor: int = typer.Option(2, "--upscale-factor", help="Upscaling factor (2x, 4x)"), @@ -415,7 +427,8 @@ def download( "target_lufs": target_lufs, "declipping": declipping, - # Video upscaling + # Video codec and upscaling + "video_codec": video_codec, "upscale_video": upscale_video, "upscale_method": upscale_method, "upscale_factor": upscale_factor, @@ -759,7 +772,7 @@ async def _show_active_downloads_async(self) -> None: """Show active downloads asynchronously""" async with AsyncSessionManager(self.config["session_file"]) as sm: sessions = sm.get_active_sessions() - console_obj = Console() + console_obj = get_console() if sessions: console_obj.print("[bold green]Active downloads:[/]") for session in sessions: @@ -769,7 +782,7 @@ async def _show_active_downloads_async(self) -> None: async def interactive_mode(self) -> None: """Rich interactive mode with command history""" - console_obj = Console() + console_obj = get_console() console_obj.print("[bold cyan]Starting interactive mode[/]") # Try textual interface first, fallback to simple mode @@ -791,57 +804,211 @@ async def _try_textual_interface(self, console_obj: Console) -> bool: async def _run_simple_interactive_mode(self, console_obj: Console) -> None: """Run simple fallback interactive mode""" + from rich.panel import Panel + + console_obj.print(Panel( + "[bold cyan]Snatch Interactive Mode[/]\n\n" + "Paste a URL to download. Add modifiers for format/quality:\n\n" + " [green][/] Best quality video\n" + " [green] 1080[/] 1080p video\n" + " [green] flac[/] Audio as FLAC\n" + " [green] mp3[/] Audio as MP3\n" + " [green] upscale[/] Download + AI upscale\n\n" + "Type [green]help[/] for all commands, [green]q[/] to quit.", + title="Snatch", + border_style="cyan", + )) + while True: try: - command = console_obj.input("[bold cyan]Enter command (or 'help', 'exit'):[/] ") - command = command.strip().lower() - - if await self._handle_command(command, console_obj): - break # Exit was requested - + command = console_obj.input("\n[bold cyan]snatch>[/] ") + command = command.strip() + # Don't lowercase the whole thing — URLs are case-sensitive + command_lower = command.lower() + + if await self._handle_command(command_lower if not command_lower.startswith(('http', 'www.')) else command, console_obj): + break + except KeyboardInterrupt: console_obj.print(INTERRUPTED_MSG) continue + except EOFError: + break except Exception as error: console_obj.print(f"[bold red]Command error:[/] {str(error)}") continue async def _handle_command(self, command: str, console_obj: Console) -> bool: """Handle interactive command. Returns True if exit was requested.""" - if command in ['exit', 'quit']: + cmd_lower = command.strip().lower() if command else "" + + if cmd_lower in ['exit', 'quit', 'q']: console_obj.print("[bold cyan]Exiting interactive mode[/]") return True - - elif command == 'help': + + elif cmd_lower in ['help', '?']: self._show_help(console_obj) - - elif command.startswith('download '): - await self._handle_download_command(command, console_obj) - elif command: + + elif cmd_lower in ['clear', 'cls']: + console_obj.clear() + + elif cmd_lower.startswith('download ') or cmd_lower.startswith('dl '): + parts = command.split(None, 1) + if len(parts) > 1: + await self._handle_smart_download(parts[1], console_obj) + + elif cmd_lower.startswith(('audio ', 'flac ', 'mp3 ', 'opus ', 'wav ', 'm4a ', 'aac ')): + await self._handle_smart_download(command, console_obj) + + elif command.startswith(('http://', 'https://', 'www.', 'HTTP://', 'HTTPS://')): + # Bare URL or "URL format/resolution" syntax — preserve original case for URL + await self._handle_smart_download(command, console_obj) + + elif cmd_lower: console_obj.print(f"{UNKNOWN_COMMAND_MSG} {command}") console_obj.print(HELP_AVAILABLE_MSG) - + return False - + def _show_help(self, console_obj: Console) -> None: """Show help text for interactive mode""" - console_obj.print("[bold cyan]Available commands:[/]") - console_obj.print(" [green]download [URL][/] - Download media from URL") - console_obj.print(" [green]help[/] - Show this help") - console_obj.print(" [green]exit[/] or [green]quit[/] - Exit interactive mode") - - async def _handle_download_command(self, command: str, console_obj: Console) -> None: - """Handle download command in interactive mode""" - url = command[9:].strip() + from rich.table import Table + table = Table(title="Interactive Mode Commands", border_style="cyan", show_lines=False) + table.add_column("Command", style="green", min_width=28) + table.add_column("Description", style="white") + + table.add_row("", "Download in best quality") + table.add_row(" 1080", "Download at 1080p") + table.add_row(" 720 / 4k / hd", "Download at 720p / 4K / 1080p") + table.add_row(" flac", "Download audio as FLAC (lossless)") + table.add_row(" mp3", "Download audio as MP3") + table.add_row(" opus / wav / aac", "Download audio in other formats") + table.add_row(" upscale", "Download + AI video upscaling (2x)") + table.add_row(" upscale 4x", "Download + AI upscaling (4x)") + table.add_row(" enhance", "Download + audio enhancement") + table.add_row(" denoise", "Download + noise reduction") + table.add_row("audio ", "Download audio (default format)") + table.add_row("flac ", "Download audio as FLAC") + table.add_row("download / dl ", "Download in best quality") + table.add_row("", "") + table.add_row("help / ?", "Show this help") + table.add_row("clear / cls", "Clear screen") + table.add_row("exit / quit / q", "Exit interactive mode") + console_obj.print(table) + + def _parse_interactive_input(self, raw_input: str) -> tuple: + """Parse freeform interactive input into (url, options). + + Supports patterns like: + → best quality video + 1080 → 1080p video + flac → audio as FLAC + audio → audio default format + flac → audio as FLAC + upscale → download + AI upscale (2x) + upscale 4x → download + AI upscale (4x) + enhance → download + audio enhancement + denoise → download + noise reduction + """ + import re as _re + + audio_formats = {'flac', 'mp3', 'opus', 'wav', 'm4a', 'aac'} + resolution_names = {'4k', '2k', 'hd', 'sd'} + resolution_pattern = _re.compile(r'^(\d{3,4})p?$') + upscale_factor_pattern = _re.compile(r'^(\d)x$') + + parts = raw_input.strip().split() + url = None + options: Dict[str, Any] = {} + + for part in parts: + lower = part.lower() + + # Is this a URL? + if part.startswith(('http://', 'https://', 'www.')): + url = part + continue + + # Audio format keyword + if lower in audio_formats: + options['audio_only'] = True + options['audio_format'] = lower + options['audio_quality'] = 'best' + continue + + # "audio" keyword + if lower == 'audio': + options['audio_only'] = True + options['audio_format'] = options.get('audio_format', 'mp3') + options['audio_quality'] = 'best' + continue + + # Resolution number (e.g., "1080", "720p") + m = resolution_pattern.match(lower) + if m: + options['resolution'] = m.group(1) + continue + + # Named resolution + if lower in resolution_names: + options['resolution'] = lower + continue + + # Upscale keyword + if lower == 'upscale': + options['upscale_video'] = True + options.setdefault('upscale_factor', 2) + options.setdefault('upscale_method', 'lanczos') + continue + + # Upscale factor (e.g., "2x", "4x") + m = upscale_factor_pattern.match(lower) + if m: + options['upscale_video'] = True + options['upscale_factor'] = int(m.group(1)) + continue + + # Audio enhancement keywords + if lower == 'enhance': + options['enhance_audio'] = True + continue + + if lower == 'denoise': + options['denoise'] = True + continue + + return url, options + + async def _handle_smart_download(self, raw_input: str, console_obj: Console) -> None: + """Parse freeform input and start a download with detected options.""" + url, options = self._parse_interactive_input(raw_input) + if not url: - console_obj.print("[yellow]Please provide a URL to download[/]") + console_obj.print(PROVIDE_URL_MSG) return - - console_obj.print(f"[cyan]Starting download of:[/] {url}") + + # Show a concise summary of what we're about to do + mode_parts = [] + if options.get('audio_only'): + mode_parts.append(f"audio/{options.get('audio_format', 'mp3')}") + if options.get('resolution'): + mode_parts.append(f"{options['resolution']}p") + if options.get('upscale_video'): + factor = options.get('upscale_factor', 2) + mode_parts.append(f"upscale {factor}x") + if options.get('enhance_audio'): + mode_parts.append("audio enhance") + if options.get('denoise'): + mode_parts.append("denoise") + mode = ", ".join(mode_parts) if mode_parts else "best quality" + console_obj.print(f"[cyan]Downloading:[/] {url} [dim]({mode})[/]") + try: - options = {} # Default options - await self.download_manager.download(url, **options) - console_obj.print(f"[bold green]Download complete:[/] {url}") + result = await self.download_manager.download_with_options([url], options) + if result: + console_obj.print(f"[bold green]Download complete[/]") + else: + console_obj.print(f"[bold yellow]Download finished with no output files[/]") except Exception as e: console_obj.print(f"[bold red]Download error:[/] {str(e)}") diff --git a/snatch/config.py b/snatch/config.py index 2c4c75d..4f59b3a 100644 --- a/snatch/config.py +++ b/snatch/config.py @@ -2,6 +2,7 @@ import logging import os import re +import tempfile import threading import time import asyncio @@ -379,9 +380,20 @@ def test_functionality() -> bool: """Run basic tests to verify functionality""" print(f"{Fore.CYAN}Running basic tests...{Style.RESET_ALL}") try: - # Test configuration initialization + # Test configuration initialization (async function — must be run properly) print(f"{Fore.CYAN}Testing configuration...{Style.RESET_ALL}") - config = initialize_config_async(force_validation=True) + try: + loop = asyncio.get_running_loop() + # Already in async context — can't use asyncio.run() + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as pool: + config = pool.submit(lambda: asyncio.run(initialize_config_async(force_validation=True))).result() + except RuntimeError: + config = asyncio.run(initialize_config_async(force_validation=True)) + + if not config: + print(f"{Fore.RED}Failed to initialize configuration!{Style.RESET_ALL}") + return False # Check if FFmpeg is available in the config if not config.get("ffmpeg_location") or not validate_ffmpeg_installation(): diff --git a/snatch/defaults.py b/snatch/defaults.py index e4ed5cb..e3551e6 100644 --- a/snatch/defaults.py +++ b/snatch/defaults.py @@ -609,4 +609,25 @@ def signal_handler(sig, frame): "multiband_processing": False, "chunked_processing": True, "chunk_size_seconds": 30, # Process in 30-second chunks for large files +} + +# Audio quality mapping: human-readable names → yt-dlp FFmpeg quality values +# For FFmpegExtractAudio: "0" = best, "10" = worst; for bitrate-based codecs these map to kbps +AUDIO_QUALITY_MAP = { + "best": "0", # Highest quality (VBR best for mp3/opus, lossless for flac) + "high": "192", # 192 kbps + "medium": "128", # 128 kbps + "low": "96", # 96 kbps + "tiny": "64", # 64 kbps (voice/podcast) + # Also accept raw numeric strings +} + +# Video codec preference filters for yt-dlp format selection +VIDEO_CODEC_PREFERENCE = { + "h264": "[vcodec^=avc1]", + "h265": "[vcodec^=hev1]", + "hevc": "[vcodec^=hev1]", + "vp9": "[vcodec=vp9]", + "av1": "[vcodec^=av01]", + "auto": "", # No codec filter — let yt-dlp pick the best } \ No newline at end of file diff --git a/snatch/ffmpeg_helper.py b/snatch/ffmpeg_helper.py index 34ad65a..becbc4e 100644 --- a/snatch/ffmpeg_helper.py +++ b/snatch/ffmpeg_helper.py @@ -128,41 +128,46 @@ async def upscale_video(self, input_path: str, output_path: str, logging.error(f"Video upscaling failed: {str(e)}") return False - async def _upscale_with_realesrgan(self, input_path: str, output_path: str, + async def _upscale_with_realesrgan(self, input_path: str, output_path: str, config: Dict[str, Any]) -> bool: """Upscale video using Real-ESRGAN AI upscaling""" + frames_dir = None + upscaled_dir = None try: scale_factor = config.get("scale_factor", 2) model_name = f"RealESRGAN_x{scale_factor}plus" - + + # Detect source framerate instead of hardcoding + video_info = await self._get_video_info(input_path) + source_fps = video_info.get("r_frame_rate", "30/1") if video_info else "30/1" + # Extract frames from video frames_dir = self.temp_dir / f"frames_{Path(input_path).stem}" frames_dir.mkdir(exist_ok=True) - - # Extract frames using FFmpeg + extract_cmd = [ self.ffmpeg_path, "-i", input_path, - "-vf", "fps=30", # Limit to 30fps for processing + "-vf", f"fps={source_fps}", str(frames_dir / "frame_%06d.png") ] - - logging.info("Extracting video frames...") + + logging.info(f"Extracting video frames at {source_fps} fps...") result = await asyncio.create_subprocess_exec( *extract_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - _, _ = await result.communicate() - + _, stderr = await result.communicate() + if result.returncode != 0: - logging.error("Failed to extract video frames") + logging.error(f"Failed to extract video frames: {stderr.decode()[:200]}") return False - + # Upscale frames with Real-ESRGAN upscaled_dir = self.temp_dir / f"upscaled_{Path(input_path).stem}" upscaled_dir.mkdir(exist_ok=True) - + realesrgan_cmd = [ "realesrgan-ncnn-vulkan", "-i", str(frames_dir), @@ -171,30 +176,32 @@ async def _upscale_with_realesrgan(self, input_path: str, output_path: str, "-s", str(scale_factor), "-f", "png" ] - - logging.info(f"Upscaling frames with Real-ESRGAN {model_name}...") + + logging.info(f"Upscaling frames with Real-ESRGAN {model_name}...") result = await asyncio.create_subprocess_exec( *realesrgan_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - _, _ = await result.communicate() - + _, stderr = await result.communicate() + if result.returncode != 0: - logging.error("Real-ESRGAN upscaling failed, falling back to traditional method") - return await self._upscale_with_ffmpeg(input_path, output_path, + logging.error(f"Real-ESRGAN failed ({stderr.decode()[:200]}), falling back to lanczos") + return await self._upscale_with_ffmpeg(input_path, output_path, {**config, "method": "lanczos"}) - + # Reconstruct video from upscaled frames return await self._reconstruct_video(input_path, output_path, upscaled_dir) - + except Exception as e: logging.error(f"Real-ESRGAN upscaling error: {str(e)}") - return await self._upscale_with_ffmpeg(input_path, output_path, + return await self._upscale_with_ffmpeg(input_path, output_path, {**config, "method": "lanczos"}) finally: - # Cleanup temporary directories - self._cleanup_temp_dirs([frames_dir, upscaled_dir]) + # Cleanup temporary directories (only if they were created) + dirs_to_clean = [d for d in [frames_dir, upscaled_dir] if d is not None] + if dirs_to_clean: + self._cleanup_temp_dirs(dirs_to_clean) async def _upscale_with_ffmpeg(self, input_path: str, output_path: str, config: Dict[str, Any]) -> bool: @@ -226,21 +233,29 @@ async def _upscale_with_ffmpeg(self, input_path: str, output_path: str, new_height = 2160 new_width = int(2160 * aspect_ratio) + # Map quality setting to CRF value + quality = config.get("quality", "high") + crf_map = {"low": "28", "medium": "23", "high": "18"} + crf = crf_map.get(quality, "18") + + preset_map = {"low": "fast", "medium": "medium", "high": "slow"} + preset = preset_map.get(quality, "slow") + # Build FFmpeg command for upscaling upscale_filter = f"scale={new_width}:{new_height}:flags={method}" - + cmd = [ self.ffmpeg_path, "-i", input_path, "-vf", upscale_filter, "-c:v", "libx264", - "-crf", "18", # High quality - "-preset", "slow", + "-crf", crf, + "-preset", preset, "-c:a", "copy", # Copy audio without re-encoding "-y", # Overwrite output file output_path ] - logging.info(f"Upscaling video with FFmpeg {method} to {new_width}x{new_height}") + logging.info(f"Upscaling video with FFmpeg {method} to {new_width}x{new_height} (quality={quality}, CRF={crf})") result = await asyncio.create_subprocess_exec( *cmd, diff --git a/snatch/manager.py b/snatch/manager.py index 36a3a84..472b842 100644 --- a/snatch/manager.py +++ b/snatch/manager.py @@ -49,22 +49,9 @@ FORMAT_720P = "bestvideo[height<=720]+bestaudio/best[height<=720]" FORMAT_480P = "bestvideo[height<=480]+bestaudio/best[height<=480]" -# Define custom exception classes -class DownloadError(Exception): - """Base exception for download errors""" - pass - -class NetworkError(DownloadError): - """Network-related errors""" - pass - -class ResourceError(DownloadError): - """Resource-related errors (file not found, etc.)""" - pass - -class AudioConversionError(DownloadError): - """Raised when audio conversion fails""" - pass +# Note: Exception classes (DownloadError, NetworkError, ResourceError, etc.) +# are defined further below with the full hierarchy. AudioConversionError +# is added there as well to avoid duplicate class definitions. # Import local utils to avoid circular imports try: @@ -120,6 +107,8 @@ def format_size(bytes_value: float, precision: int = 2) -> str: MAX_MEMORY_PERCENT, AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, + AUDIO_QUALITY_MAP, + VIDEO_CODEC_PREFERENCE, ) from .session import SessionManager from .cache import DownloadCache @@ -220,6 +209,10 @@ class AuthenticationError(DownloadError): """Raised when authentication fails""" pass +class AudioConversionError(DownloadError): + """Raised when audio conversion fails""" + pass + @dataclass class DownloadChunk: """Represents a chunk of a download file""" @@ -891,20 +884,25 @@ def __init__(self, config: Dict[str, Any], # Initialize advanced systems if not provided if not self.performance_monitor or not self.advanced_scheduler: - self._initialize_advanced_systems() # Create audio processor instance - self.audio_processor = EnhancedAudioProcessor(config) if 'EnhancedAudioProcessor' in globals() else None + self._initialize_advanced_systems() + + # Create audio processor instance (graceful if FFmpeg missing) + self.audio_processor = None + try: + self.audio_processor = EnhancedAudioProcessor(config) + except Exception as e: + logging.debug(f"Audio processor init deferred: {e}") - # Initialize video upscaler + # Initialize video upscaler (always create so --upscale CLI flag works) self.video_upscaler = None - if config.get("upscaling", {}).get("enabled", False): - try: - from .ffmpeg_helper import create_video_upscaler - self.video_upscaler = create_video_upscaler(config) - logging.info("Video upscaler initialized successfully") - except ImportError as e: - logging.warning(f"Video upscaling not available: {e}") - except Exception as e: - logging.error(f"Failed to initialize video upscaler: {e}") + try: + from .ffmpeg_helper import create_video_upscaler + self.video_upscaler = create_video_upscaler(config) + logging.info("Video upscaler initialized successfully") + except ImportError as e: + logging.debug(f"Video upscaling not available: {e}") + except Exception as e: + logging.debug(f"Failed to initialize video upscaler: {e}") # Initialize P2P manager if enabled self.p2p_manager = None @@ -1170,9 +1168,20 @@ def _normalize_audio_options(self, options: Dict[str, Any]) -> None: options['upmix_audio'] = True if options.get('denoise'): options['denoise_audio'] = True - - # Set enhancement flag if any audio processing is requested - if any(options.get(key, False) for key in ['upmix_audio', 'denoise_audio', 'normalize_audio']): + + # Map individual enhancement flags into the enhance_audio umbrella + enhancement_flags = [ + 'noise_reduction', 'upscale_sample_rate', 'frequency_extension', + 'stereo_widening', 'dynamic_compression', 'declipping', + 'audio_normalization', + ] + if any(options.get(key, False) for key in enhancement_flags): + options['enhance_audio'] = True + + # Set process_audio flag if any audio processing is requested + if any(options.get(key, False) for key in [ + 'upmix_audio', 'denoise_audio', 'normalize_audio', 'enhance_audio' + ]): options['process_audio'] = True async def _process_downloads(self, urls: List[str], options: Dict[str, Any]) -> List[str]: """Process all downloads with advanced scheduling and performance monitoring""" @@ -1231,19 +1240,23 @@ async def _process_downloads_sequentially(self, urls: List[str], options: Dict[s ydl_opts = self._setup_download_options(options) downloaded_files = [] console = Console() - - with console.status("[bold green]Downloading...") as _: - for url in urls: - try: + + total = len(urls) + for idx, url in enumerate(urls, 1): + try: + if total > 1: + console.print(f"\n[bold cyan][{idx}/{total}][/] {url}") + else: console.print(f"[cyan]Downloading:[/] {url}") - file_path = await self._download_single_file(url, ydl_opts, options, console) - - if file_path: - downloaded_files.append(file_path) - except Exception as e: - console.print(f"[bold red]Error downloading {url}: {str(e)}[/]") - logging.error(f"Download error for {url}: {str(e)}") - + + file_path = await self._download_single_file(url, ydl_opts, options, console) + + if file_path: + downloaded_files.append(file_path) + except Exception as e: + console.print(f"[bold red]Error downloading {url}: {str(e)}[/]") + logging.error(f"Download error for {url}: {str(e)}") + self._report_download_results(downloaded_files, console) return downloaded_files @@ -1262,7 +1275,15 @@ def _report_download_results(self, downloaded_files: List[str], console: Console if not downloaded_files: console.print("[bold yellow]No files were successfully downloaded.[/]") else: - console.print(f"[bold green]Successfully downloaded {len(downloaded_files)} files.[/]") + console.print(f"\n[bold green]Successfully downloaded {len(downloaded_files)} file(s):[/]") + for fp in downloaded_files: + size_str = "" + try: + size_bytes = os.path.getsize(fp) + size_str = f" ({format_size(size_bytes)})" + except OSError: + pass + console.print(f" [dim]{os.path.basename(fp)}{size_str}[/]") async def _download_single_file(self, url: str, ydl_opts: Dict[str, Any], options: Dict[str, Any], console: Console) -> Optional[str]: """Download a single file with all processing options""" @@ -1323,7 +1344,13 @@ def _needs_audio_processing(self, options: Dict[str, Any]) -> bool: options.get('upmix_audio', False), options.get('enhance_audio', False), options.get('denoise_audio', False), - options.get('normalize_audio', False) + options.get('normalize_audio', False), + options.get('noise_reduction', False), + options.get('upscale_sample_rate', False), + options.get('frequency_extension', False), + options.get('stereo_widening', False), + options.get('dynamic_compression', False), + options.get('declipping', False), ]) def _validate_download_requirements(self, options: Dict[str, Any]) -> None: @@ -1352,152 +1379,251 @@ def _setup_download_options(self, options: Dict[str, Any]) -> Dict[str, Any]: try: from .common_utils import sanitize_filename except ImportError: - # Fall back to local implementation if import fails def sanitize_filename(filename: str) -> str: - """Sanitize filename by removing invalid characters""" invalid_chars = r'[<>:"/\\|?*\x00-\x1F]' return re.sub(invalid_chars, '_', filename).strip('. ') - + ydl_opts = {} - + # Set output directory based on audio_only flag output_dir = self.config.get( - "audio_output" if options.get("audio_only") else "video_output", + "audio_output" if options.get("audio_only") else "video_output", "downloads" ) os.makedirs(output_dir, exist_ok=True) - + # Set output template if options.get("filename"): filename = sanitize_filename(options.get("filename")) ydl_opts["outtmpl"] = os.path.join(output_dir, f"{filename}.%(ext)s") else: ydl_opts["outtmpl"] = os.path.join(output_dir, "%(title)s.%(ext)s") - + + # Merge format: ensure combined video+audio gets a proper container + if not options.get("audio_only"): + ydl_opts["merge_output_format"] = "mp4" + + # Robust common options for all downloads + ffmpeg_path = self.config.get("ffmpeg_location") or locate_ffmpeg() + if ffmpeg_path: + ydl_opts["ffmpeg_location"] = os.path.dirname(ffmpeg_path) + + ydl_opts.update({ + "concurrent_fragment_downloads": self.config.get("concurrent_fragment_downloads", 8), + "retries": self.config.get("max_retries", 5), + "fragment_retries": self.config.get("max_retries", 5), + "socket_timeout": 30, + "extractor_retries": 3, + "file_access_retries": 3, + "ignoreerrors": False, # Fail fast so we can report errors + "no_warnings": True, + "quiet": options.get("quiet", False), + }) + # Configure format-specific options self._configure_format_options(ydl_opts, options) - + return ydl_opts def _configure_format_options(self, ydl_opts: Dict[str, Any], options: Dict[str, Any]) -> None: """Configure format-specific download options.""" # Handle audio-only downloads if options.get("audio_only"): - ydl_opts["format"] = "bestaudio/best" - ydl_opts["postprocessors"] = [{ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': options.get("audio_format", "mp3"), - 'preferredquality': options.get("audio_quality", "192"), - }] - logging.info(f"Audio download configured: format={options.get('audio_format', 'mp3')}, quality={options.get('audio_quality', '192')}") # Handle resolution-specific downloads + audio_format = options.get("audio_format", "mp3") + raw_quality = options.get("audio_quality", "best") + + # For lossless formats, always use best quality source + lossless_formats = {"flac", "wav", "alac"} + if audio_format in lossless_formats: + quality = "0" # Best source quality for lossless + else: + quality = AUDIO_QUALITY_MAP.get(raw_quality, raw_quality) + + ydl_opts["format"] = COMPREHENSIVE_BESTAUDIO_FORMAT + ydl_opts["postprocessors"] = [ + { + 'key': 'FFmpegExtractAudio', + 'preferredcodec': audio_format, + 'preferredquality': quality, + }, + { + 'key': 'FFmpegMetadata', # Embed metadata (title, artist, etc.) + }, + { + 'key': 'EmbedThumbnail', # Embed album art when available + 'already_have_thumbnail': False, + }, + ] + ydl_opts["writethumbnail"] = True # Download thumbnail for embedding + + logging.info(f"Audio download configured: format={audio_format}, quality={quality} (from '{raw_quality}')") + + # Handle resolution-specific downloads elif options.get("resolution"): res = options.get("resolution") # Convert resolution to height value if isinstance(res, str) and res.endswith("p"): res = res[:-1] + # Handle named resolutions + res_aliases = {"4k": "2160", "2k": "1440", "hd": "1080", "sd": "480"} + if isinstance(res, str): + res = res_aliases.get(res.lower(), res) try: height = int(res) - # Use a more compatible format string approach - # This format string works better with yt-dlp's actual format selection + + # Get codec preference filter (e.g., "[vcodec^=avc1]") + codec = options.get("video_codec", "auto") + codec_filter = VIDEO_CODEC_PREFERENCE.get(codec, "") + + # Build format strings with codec preference and fallbacks format_parts = [] - - if height >= 2160: # 4K - format_parts.extend([ - FORMAT_4K, - FORMAT_1440P, - FORMAT_1080P, - DEFAULT_VIDEO_FORMAT - ]) - elif height >= 1440: # 1440p - format_parts.extend([ - FORMAT_1440P, - FORMAT_1080P, - DEFAULT_VIDEO_FORMAT - ]) - elif height >= 1080: # 1080p - format_parts.extend([ - FORMAT_1080P, - FORMAT_720P, - DEFAULT_VIDEO_FORMAT - ]) - elif height >= 720: # 720p - format_parts.extend([ - FORMAT_720P, - FORMAT_480P, - DEFAULT_VIDEO_FORMAT - ]) - else: # 480p and below + + # Primary: codec-preferred format at target resolution + if codec_filter: + format_parts.append( + f"bestvideo[height<={height}]{codec_filter}+bestaudio/best[height<={height}]" + ) + + # Fallback chain: target resolution (any codec), then lower resolutions + if height >= 2160: + format_parts.extend([FORMAT_4K, FORMAT_1440P, FORMAT_1080P, DEFAULT_VIDEO_FORMAT]) + elif height >= 1440: + format_parts.extend([FORMAT_1440P, FORMAT_1080P, DEFAULT_VIDEO_FORMAT]) + elif height >= 1080: + format_parts.extend([FORMAT_1080P, FORMAT_720P, DEFAULT_VIDEO_FORMAT]) + elif height >= 720: + format_parts.extend([FORMAT_720P, FORMAT_480P, DEFAULT_VIDEO_FORMAT]) + else: format_parts.extend([ f"bestvideo[height<={height}]+bestaudio/best[height<={height}]", DEFAULT_VIDEO_FORMAT ]) - - # Combine format options + format_string = "/".join(format_parts) ydl_opts["format"] = format_string - logging.info(f"Video download configured: target resolution={height}p with fallbacks") - logging.debug(f"Generated format string: {format_string}") - + codec_info = f", codec={codec}" if codec != "auto" else "" + logging.info(f"Video download configured: target={height}p{codec_info} with fallbacks") + except ValueError: logging.warning(f"Invalid resolution format: {res}, using default") ydl_opts["format"] = DEFAULT_VIDEO_FORMAT - + # Handle specific format ID elif options.get("format_id"): ydl_opts["format"] = options.get("format_id") - logging.info(f"Using specific format: {options.get('format_id')}") # Default to best quality + logging.info(f"Using specific format: {options.get('format_id')}") + + # Default to best quality else: - ydl_opts["format"] = DEFAULT_VIDEO_FORMAT + # Apply codec preference even without resolution target + codec = options.get("video_codec", "auto") + codec_filter = VIDEO_CODEC_PREFERENCE.get(codec, "") + if codec_filter: + ydl_opts["format"] = f"bestvideo{codec_filter}+bestaudio/best" + else: + ydl_opts["format"] = DEFAULT_VIDEO_FORMAT async def _download_single_url(self, url: str, ydl_opts: Dict[str, Any], console: Console, options: Dict[str, Any]) -> Optional[str]: """Download a single URL with the given options.""" try: import yt_dlp - - # Add progress hook + + # Use Rich progress bar instead of flooding console with print statements + progress = Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.description}"), + BarColumn(bar_width=30), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TextColumn("•"), + TransferSpeedColumn(), + TextColumn("•"), + TimeElapsedColumn(), + console=console, + transient=True, + ) + + _task_id = [None] + def progress_hook(d): if d['status'] == 'downloading': - percent = d.get('_percent_str', 'N/A') - speed = d.get('_speed_str', 'N/A') - console.print(f"[cyan]Downloading:[/] {percent} at {speed}") + total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0 + downloaded = d.get('downloaded_bytes', 0) + + if _task_id[0] is None and total > 0: + _task_id[0] = progress.add_task("Downloading", total=total) + if _task_id[0] is not None: + progress.update(_task_id[0], completed=downloaded) + elif d['status'] == 'finished': - console.print(f"[green]Download completed:[/] {d['filename']}") - + if _task_id[0] is not None: + progress.update(_task_id[0], completed=progress.tasks[_task_id[0]].total) + filename = os.path.basename(d.get('filename', 'unknown')) + console.print(f"[green]Download completed:[/] {filename}") + ydl_opts["progress_hooks"] = [progress_hook] - - # Setup error handling - def error_hook(error_msg): - self.error_handler.log_error( - f"yt-dlp error: {error_msg}", - ErrorCategory.DOWNLOAD, - ErrorSeverity.ERROR, - context={"url": url, "ydl_error": True} - ) - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - try: - # Extract info first to get filename - info = ydl.extract_info(url, download=False) - if not info: - raise DownloadError("Failed to extract video information") - # Download the video - ydl.download([url]) - - # Get the downloaded filename - downloaded_file = ydl.prepare_filename(info) - - # Check if upscaling is requested and applicable - if self._should_upscale_video(options, downloaded_file): - upscaled_file = await self._apply_video_upscaling(downloaded_file, options, console) - if upscaled_file: - return upscaled_file - - return downloaded_file - - except Exception as e: - error_hook(str(e)) - raise DownloadError(f"Download failed for {url}: {str(e)}") from e - + + with progress: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + try: + # Extract info first to get filename and validate URL + info = ydl.extract_info(url, download=False) + if not info: + raise DownloadError("Failed to extract media information. The URL may be invalid or unsupported.") + + title = info.get('title', 'Unknown') + console.print(f"[cyan]Title:[/] {title}") + + # Show selected format info + fmt = info.get('format', info.get('format_id', 'N/A')) + console.print(f"[dim]Format: {fmt}[/]") + + # Download the media + ydl.download([url]) + + # Get the downloaded filename + downloaded_file = ydl.prepare_filename(info) + + # For audio extractions, the extension may have changed + if options.get("audio_only"): + audio_ext = options.get("audio_format", "mp3") + alt_path = os.path.splitext(downloaded_file)[0] + f".{audio_ext}" + if os.path.exists(alt_path): + downloaded_file = alt_path + + # Check if upscaling is requested and applicable + if self._should_upscale_video(options, downloaded_file): + upscaled_file = await self._apply_video_upscaling(downloaded_file, options, console) + if upscaled_file: + return upscaled_file + + return downloaded_file + + except yt_dlp.utils.DownloadError as e: + error_msg = str(e) + # Provide user-friendly error messages for common failures + if "Unsupported URL" in error_msg: + console.print(f"[red]Unsupported URL:[/] This site may not be supported") + elif "Video unavailable" in error_msg or "Private video" in error_msg: + console.print(f"[red]Video unavailable:[/] It may be private, deleted, or region-locked") + elif "HTTP Error 403" in error_msg: + console.print(f"[red]Access denied:[/] The content requires authentication or is geo-restricted") + elif "format" in error_msg.lower(): + console.print(f"[red]Format error:[/] The requested quality may not be available. Try without --resolution") + else: + console.print(f"[red]Download error:[/] {error_msg[:200]}") + return None + + except Exception as e: + self.error_handler.log_error( + f"yt-dlp error: {str(e)}", + ErrorCategory.DOWNLOAD, + ErrorSeverity.ERROR, + context={"url": url, "ydl_error": True} + ) + raise DownloadError(f"Download failed for {url}: {str(e)}") from e + + except DownloadError: + return None # Already reported except Exception as e: self.error_handler.log_error( f"Failed to download {url}: {str(e)}", @@ -1642,11 +1768,43 @@ async def _process_audio_async(self, file_path: str, options: Dict[str, Any]) -> except Exception as e: logging.error(f"Error during {process_name}: {e}") - # Apply complete enhancement chain if requested + # Apply comprehensive enhancement if requested if options.get('enhance_audio', False): - logging.info(f"Applying complete audio enhancement chain to {file_path}") - await self.audio_processor.apply_all_enhancements(file_path) - + logging.info(f"Applying comprehensive audio enhancement to {file_path}") + + # Build settings from options or preset + preset_name = options.get('audio_enhancement_preset') + if preset_name and preset_name in AUDIO_ENHANCEMENT_PRESETS: + settings = AUDIO_ENHANCEMENT_PRESETS[preset_name].settings + else: + settings = AudioEnhancementSettings( + level=options.get('audio_enhancement_level', 'medium'), + noise_reduction=options.get('noise_reduction', True), + noise_reduction_strength=options.get('noise_reduction_strength', 0.6), + upscale_sample_rate=options.get('upscale_sample_rate', False), + target_sample_rate=options.get('target_sample_rate', 48000), + frequency_extension=options.get('frequency_extension', False), + stereo_widening=options.get('stereo_widening', False), + normalization=options.get('audio_normalization', True), + target_lufs=options.get('target_lufs', -16.0), + dynamic_compression=options.get('dynamic_compression', False), + declipping=options.get('declipping', False), + ) + + # Generate output path + base, ext = os.path.splitext(file_path) + output_file = f"{base}_enhanced{ext}" + + success = await self.audio_processor.enhance_audio_comprehensive( + file_path, output_file, settings + ) + if success and os.path.exists(output_file): + # Replace original with enhanced version + os.replace(output_file, file_path) + logging.info(f"Audio enhancement completed: {file_path}") + else: + logging.warning(f"Audio enhancement produced no output for {file_path}") + return True except Exception as e: diff --git a/snatch/session.py b/snatch/session.py index 57d4940..6b0d264 100644 --- a/snatch/session.py +++ b/snatch/session.py @@ -363,7 +363,7 @@ def _recover_from_backup(self) -> bool: except (TypeError, ValueError) as e: logging.error("Failed to load session from backup: %s", str(e)) continue # Skip invalid sessions - logging.info(f"Successfully recovered sessions from backup: {backup}") + logging.info(f"Successfully recovered sessions from backup: {backup}") return True except Exception as e: logging.warning(f"Failed to load backup {backup}: {e}") @@ -507,7 +507,8 @@ def create_session(self, url: str, file_path: str, total_size: int, metadata: Di metadata=metadata, resume_data=resume_data ) - with self.lock:self._sessions[url] = session + with self.lock: + self._sessions[url] = session @handle_errors(ErrorCategory.DOWNLOAD, ErrorSeverity.WARNING) def update_session(self, url: str, bytes_downloaded: int, status: Optional[str] = None, @@ -789,26 +790,41 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: class SessionManager: """Synchronous wrapper around AsyncSessionManager with enhanced backwards compatibility.""" - + def __init__(self, session_file: Optional[str] = None): """Initialize session manager with sync interface. - + Args: session_file: Path to the sessions data file. If None, uses default path. - """ # Use config directory for sessions file if no path provided + """ + # Use config directory for sessions file if no path provided if session_file is None: session_file = os.path.join( - os.path.dirname(os.path.dirname(__file__)), + os.path.dirname(os.path.dirname(__file__)), 'sessions', DOWNLOAD_SESSIONS_FILE ) os.makedirs(os.path.dirname(session_file), exist_ok=True) - + self._async_manager = AsyncSessionManager(session_file) - + # Initialize error handler - error_log_path = "logs/snatch_errors.log" + error_log_path = "logs/snatch_errors.log" self.error_handler = EnhancedErrorHandler(log_file=error_log_path) + + def _run_async_save(self) -> None: + """Safely run async save from sync context without crashing if an event loop is already running.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # Already in an async context — schedule save as a fire-and-forget task + loop.create_task(self._async_manager._save_sessions_async()) + else: + # No event loop running — safe to use asyncio.run() + self._run_async_save() def update_session(self, url: str, percentage: float, **metadata) -> None: """Update session state synchronously. @@ -857,7 +873,7 @@ def update_session(self, url: str, percentage: float, **metadata) -> None: self._async_manager._sessions[url] = session # Trigger sync save - asyncio.run(self._async_manager._save_sessions_async()) + self._run_async_save() def get_session(self, url: str) -> Optional[Dict[str, Any]]: """Get session data synchronously. @@ -879,7 +895,7 @@ def remove_session(self, url: str) -> None: with self._async_manager.lock: if url in self._async_manager._sessions: del self._async_manager._sessions[url] - asyncio.run(self._async_manager._save_sessions_async()) + self._run_async_save() def cancel_session(self, url: str) -> bool: """Cancel a download session synchronously. @@ -891,7 +907,7 @@ def cancel_session(self, url: str) -> bool: bool: True if session was cancelled, False otherwise. """ result = self._async_manager.cancel_session(url) - asyncio.run(self._async_manager._save_sessions_async()) + self._run_async_save() return result def resume_session(self, url: str) -> bool: @@ -904,7 +920,7 @@ def resume_session(self, url: str) -> bool: bool: True if session was resumed, False otherwise. """ result = self._async_manager.resume_session(url) - asyncio.run(self._async_manager._save_sessions_async()) + self._run_async_save() return result def list_sessions(self, filter_status: Optional[str] = None) -> List[Dict[str, Any]]: @@ -940,5 +956,5 @@ def create_session(self, url: str, file_path: str, total_size: int, metadata: Di str: The session URL (used as ID). """ self._async_manager.create_session(url, file_path, total_size, metadata) - asyncio.run(self._async_manager._save_sessions_async()) + self._run_async_save() return url