Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions snatch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

__version__ = VERSION

from .cli import main as main_app
from .manager import DownloadManager
from .config import load_config

__all__ = ["main_app", "DownloadManager", "load_config", "__version__"]
__all__ = ["DownloadManager", "load_config", "__version__"]
5 changes: 5 additions & 0 deletions snatch/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Allow running snatch as `python -m snatch`."""
from snatch.cli import main

if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion snatch/audio_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
import pyloudnorm as pyln
ENHANCED_PROCESSING_AVAILABLE = True
except ImportError as e:
logging.warning(f"Enhanced audio processing libraries not available: {e}")
logging.debug(f"Enhanced audio processing libraries not available: {e}")
ENHANCED_PROCESSING_AVAILABLE = False

# Configure logging
Expand Down
45 changes: 8 additions & 37 deletions snatch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ def __getattr__(self, name):
console = _LazyConsole()

# Constants for duplicate strings
FALLBACK_INTERACTIVE_MSG = "[yellow]Falling back to enhanced interactive mode.[/]"
FALLBACK_SIMPLE_MSG = "[yellow]Falling back to simple interactive mode...[/]"
SKIP_CONFIRMATION_HELP = "Skip confirmation prompt"
SETTING_MODIFY_HELP = "Setting to modify"
Expand Down Expand Up @@ -319,7 +318,9 @@ def setup_argparse(self) -> typer.Typer:
app = typer.Typer(
name=APP_NAME,
help=f"{APP_NAME} - A powerful media downloader",
epilog=EXAMPLES
epilog=EXAMPLES,
invoke_without_command=True,
no_args_is_help=True,
)
# Download command
@app.command("download", help="Download media from URLs")
Expand Down Expand Up @@ -431,45 +432,11 @@ def download(
return self.execute_download(all_urls, options) # Interactive mode command
@app.command("interactive", help="Run in interactive mode")
def interactive():
"""Run in enhanced interactive mode with cyberpunk interface"""
"""Launch the interactive TUI"""
from .interactive_mode import launch_enhanced_interactive_mode
launch_enhanced_interactive_mode(self.config)
return 0

# Modern interactive interface command
@app.command("modern", help="Run with modern interactive interface")
def modern():
"""Run with modern beautiful interactive interface"""
try:
from .theme.modern_interactive import run_modern_interactive
run_modern_interactive(self.config)
except ImportError as e:
console.print(f"[bold red]Modern interface not available: {str(e)}[/]")
console.print(FALLBACK_INTERACTIVE_MSG)
from .interactive_mode import launch_enhanced_interactive_mode
launch_enhanced_interactive_mode(self.config)
except Exception as e:
console.print(f"[bold red]Error launching modern interface: {str(e)}[/]")
console.print(FALLBACK_INTERACTIVE_MSG)
from .interactive_mode import launch_enhanced_interactive_mode
launch_enhanced_interactive_mode(self.config)
return 0

# New textual interface command
@app.command("textual", help="Run with modern Textual interface")
def textual():
"""Run with modern Textual interface"""
try:
# Import at runtime to avoid dependencies if not used
from .theme.textual_interface import start_textual_interface
# Pass the current CLI instance to maintain state
start_textual_interface(self.config)
except ImportError as e:
console.print(f"[bold yellow]Textual interface not available: {str(e)}[/]")
console.print("[yellow]Falling back to enhanced interactive mode.[/]")
from .interactive_mode import launch_enhanced_interactive_mode
launch_enhanced_interactive_mode(self.config)

# List supported sites command
@app.command("list-sites", help="List all supported sites")
def list_sites():
Expand Down Expand Up @@ -2217,6 +2184,10 @@ async def _p2p_library_command(self, action: str, library_name: str, directory:
def main():
"""Main entry point for the CLI application"""
try:
# Suppress noisy INFO/DEBUG logs unless --verbose is passed
if "--verbose" not in sys.argv and "-v" not in sys.argv:
logging.getLogger().setLevel(logging.WARNING)

# Enable Rich traceback formatting (deferred from module level)
from rich.traceback import install
install(show_locals=True)
Expand Down
245 changes: 11 additions & 234 deletions snatch/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import json
import logging
import os
import subprocess
import tempfile
import re
import threading
import time
import asyncio
import yt_dlp
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from typing import Dict, Any, Optional, List
from colorama import Fore, Style, init

from .defaults import (
Expand All @@ -19,6 +18,7 @@
)
from .common_utils import is_windows
from .manager import DownloadManager
from .ffmpeg_helper import locate_ffmpeg, get_ffmpeg_version, validate_ffmpeg_installation

# Initialize colorama
init(autoreset=True)
Expand All @@ -34,67 +34,6 @@
_update_messages: List[str] = []
_config_lock = threading.Lock()

def _validate_ffmpeg_version(ffmpeg_path: str) -> Tuple[bool, Optional[float]]:
"""Validate FFmpeg version, returns (is_valid, version_number)"""
if not ffmpeg_path:
logger.warning("FFmpeg path is not specified")
return False, None

# Handle the case when path is a directory (like C:\ffmpeg\bin)
if os.path.isdir(ffmpeg_path):
# Try to find ffmpeg executable in the directory
ffmpeg_exe = "ffmpeg.exe" if is_windows() else "ffmpeg"
possible_paths = [
os.path.join(ffmpeg_path, ffmpeg_exe),
os.path.join(ffmpeg_path, "bin", ffmpeg_exe)
]

for path in possible_paths:
if os.path.isfile(path):
logger.info(f"Found FFmpeg executable at: {path}")
ffmpeg_path = path
break
else:
logger.warning(f"FFmpeg executable not found in directory: {ffmpeg_path}")
return False, None
elif not os.path.exists(ffmpeg_path):
logger.warning(f"FFmpeg not found at path: {ffmpeg_path}")
return False, None

if not os.path.isfile(ffmpeg_path):
logger.warning(f"FFmpeg path is not a file: {ffmpeg_path}")
return False, None

try:
result = subprocess.run(
[ffmpeg_path, "-version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False, # Don't raise exception on non-zero exit
timeout=5 # Add timeout to prevent hanging
)

if result.returncode != 0:
logger.warning(f"FFmpeg validation failed with return code: {result.returncode}")
return False, None

version_info = (
result.stdout.splitlines()[0] if result.stdout else ""
)
import re
match = re.search(r"version\s+(\d+\.\d+)", version_info)
if match:
version = float(match.group(1))
logger.info(f"FFmpeg version {version} found at: {ffmpeg_path}")
return True, version
else:
logger.warning(f"Could not determine FFmpeg version from: {version_info}")
except (subprocess.SubprocessError, OSError, ValueError) as e:
logger.error(f"Error validating FFmpeg: {str(e)}")

return False, None

def _get_default_directory(key: str) -> str:
"""Get default directory path for config keys"""
base_dir = os.getcwd()
Expand Down Expand Up @@ -224,11 +163,13 @@ def _run_background_init(config: dict) -> None:

# Validate FFmpeg version if path exists
if ffmpeg_path := config.get("ffmpeg_location"):
is_valid, version = _validate_ffmpeg_version(ffmpeg_path)
if is_valid and version and version < 4.0:
_update_messages.append(
f"FFmpeg version {version} detected. Consider updating to version 4.0 or newer."
)
version_str = get_ffmpeg_version(ffmpeg_path)
if version_str:
match = re.search(r"version\s+(\d+\.\d+)", version_str)
if match and float(match.group(1)) < 4.0:
_update_messages.append(
f"FFmpeg version {match.group(1)} detected. Consider updating to version 4.0 or newer."
)
any_updates_found = True

# Check for missing optional fields with defaults
Expand Down Expand Up @@ -443,9 +384,7 @@ def test_functionality() -> bool:
config = initialize_config_async(force_validation=True)

# Check if FFmpeg is available in the config
if not config.get("ffmpeg_location") or not validate_ffmpeg_path(
config["ffmpeg_location"]
):
if not config.get("ffmpeg_location") or not validate_ffmpeg_installation():
print(f"{Fore.RED}FFmpeg not found or invalid!{Style.RESET_ALL}")
return False
# Wait briefly for background validation to complete
Expand Down Expand Up @@ -482,168 +421,6 @@ def test_functionality() -> bool:
print(f"{Fore.RED}Test failed: {str(e)}{Style.RESET_ALL}")
return False

def find_ffmpeg() -> Optional[str]:
"""Find FFmpeg in common locations or PATH with improved cross-platform support"""
# Platform specific locations
common_locations = []

if is_windows():
common_locations = [
r"C:\ffmpeg\bin",
r"C:\Program Files\ffmpeg\bin",
r"C:\ffmpeg\ffmpeg-master-latest-win64-gpl\bin",
r".\ffmpeg\bin", # Relative to script location
]

# Check if ffmpeg is in PATH on Windows
try:
result = subprocess.run(
["where", "ffmpeg"], capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
path = result.stdout.strip().split("\n")[0]
return os.path.dirname(path)
except (subprocess.SubprocessError, FileNotFoundError):
pass
else:
common_locations = [
"/usr/bin",
"/usr/local/bin",
"/opt/local/bin",
"/opt/homebrew/bin",
]

# Check if ffmpeg is in PATH on Unix-like systems
try:
result = subprocess.run(
["which", "ffmpeg"], capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
path = result.stdout.strip()
return os.path.dirname(path)
except (subprocess.SubprocessError, FileNotFoundError):
pass

# Check common locations for ffmpeg binary
ffmpeg_exec = "ffmpeg.exe" if is_windows() else "ffmpeg"
for location in common_locations:
ffmpeg_path = os.path.join(location, ffmpeg_exec)
if os.path.exists(ffmpeg_path):
return location

return None

def _find_ffmpeg_executable(ffmpeg_location: str) -> Optional[str]:
"""Find the FFmpeg executable path from a given location"""
# Handle direct path to executable
if not os.path.isdir(ffmpeg_location):
return ffmpeg_location if os.path.exists(ffmpeg_location) else None

# If it's a directory, search for the executable
ffmpeg_exec = "ffmpeg.exe" if is_windows() else "ffmpeg"

# Check common locations
possible_paths = [
os.path.join(ffmpeg_location, "bin", ffmpeg_exec), # /path/to/ffmpeg/bin/ffmpeg[.exe]
os.path.join(ffmpeg_location, ffmpeg_exec), # /path/to/ffmpeg/ffmpeg[.exe]
]

# Return the first valid path
for path in possible_paths:
if os.path.exists(path):
return path

# Not found
logger.debug(f"FFmpeg executable not found in {ffmpeg_location}")
return None

def _test_ffmpeg_executable(ffmpeg_path: str) -> bool:
"""Test if the FFmpeg executable works"""
try:
# Run with increased timeout and capture output
result = subprocess.run(
[ffmpeg_path, "-version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
text=True,
)

if result.returncode == 0 and "ffmpeg version" in result.stdout.lower():
# Log the version for debugging
version_line = next((line for line in result.stdout.splitlines()
if "ffmpeg version" in line.lower()), "")
if version_line:
logger.info(f"FFmpeg version: {version_line.strip()}")
return True
else:
logger.warning(f"FFmpeg validation failed. Return code: {result.returncode}")
if result.stderr:
logger.debug(f"FFmpeg error output: {result.stderr}")
return False

except subprocess.TimeoutExpired:
logger.warning("FFmpeg validation timed out after 10 seconds")
return False
except (subprocess.SubprocessError, OSError) as e:
logger.warning(f"Error validating FFmpeg: {str(e)}")
return False

def validate_ffmpeg_path(ffmpeg_location: str) -> bool:
"""
Validate that the specified ffmpeg_location contains valid FFmpeg binaries.

Args:
ffmpeg_location: Path to directory or direct path to FFmpeg executable

Returns:
bool: True if valid FFmpeg binaries found, False otherwise
"""
# Early validation for empty path
if not ffmpeg_location:
logger.debug("Empty FFmpeg location provided")
return False

# Find the executable
ffmpeg_path = _find_ffmpeg_executable(ffmpeg_location)
if not ffmpeg_path:
return False

# Check if the path is actually executable (skip on Windows for .exe files)
is_exe_on_windows = is_windows() and ffmpeg_path.lower().endswith('.exe')
if not is_exe_on_windows and not os.access(ffmpeg_path, os.X_OK):
logger.debug(f"FFmpeg at {ffmpeg_path} is not executable")
return False

# Test if executable works
if _test_ffmpeg_executable(ffmpeg_path):
logger.info(f"Valid FFmpeg found at: {ffmpeg_path}")
return True

return False

def print_ffmpeg_instructions():
"""Print instructions for installing FFmpeg with platform-specific guidance"""
print(
f"{Fore.YELLOW}FFmpeg not found! Please follow these steps to install FFmpeg:{Style.RESET_ALL}"
)
print("\n1. Download FFmpeg:")
print(" - Visit: https://github.com/BtbN/FFmpeg-Builds/releases")
print(" - Download: ffmpeg-master-latest-win64-gpl.zip")
print("\n2. Install FFmpeg:")
print(" - Extract the downloaded zip file")
print(" - Copy the extracted folder to C:\\ffmpeg")
print(" - Ensure ffmpeg.exe is in C:\\ffmpeg\\bin")
print("\nAlternatively:")
print("- Use chocolatey: choco install ffmpeg")
print("- Use winget: winget install ffmpeg")
print("\nAfter installation, either:")
print("1. Add FFmpeg to your system PATH, or")
print("2. Update config.json with the correct ffmpeg_location")
print(
"\nFor detailed instructions, visit: https://www.wikihow.com/Install-FFmpeg-on-Windows"
)

async def check_for_updates() -> None:
"""
Check if any configuration updates were detected in the background thread.
Expand Down
Loading
Loading