import sys import shutil from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) SCRIPT_DIR = Path(__file__).parent FFMPEG_ZIP_URL = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.0.1-essentials_build.zip" FFMPEG_EXES = {"ffmpeg.exe", "ffprobe.exe"} def _install_ffmpeg_windows(): import urllib.request import zipfile import io dest = SCRIPT_DIR / "ffmpeg" dest.mkdir(exist_ok=True) print("Downloading ffmpeg...", flush=True) with urllib.request.urlopen(FFMPEG_ZIP_URL) as r: total = int(r.headers.get("Content-Length", 0)) buf = io.BytesIO() done = 0 chunk = 65536 while True: block = r.read(chunk) if not block: break buf.write(block) done += len(block) if total: pct = done * 100 // total print(f"\r {pct}% {done // (1024*1024)} MB / {total // (1024*1024)} MB", end="", flush=True) print("\nExtracting...", flush=True) buf.seek(0) with zipfile.ZipFile(buf) as zf: for entry in zf.namelist(): name = Path(entry).name if name in FFMPEG_EXES: data = zf.read(entry) (dest / name).write_bytes(data) print(f" extracted {name}") print(f"ffmpeg ready in {dest}") def _check_env() -> list[tuple[str, str]]: issues = [] exe = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg" local_ffmpeg = SCRIPT_DIR / "ffmpeg" / exe if not local_ffmpeg.exists() and not shutil.which("ffmpeg"): if sys.platform == "win32": issues.append(( "ffmpeg not found", f"ffmpeg is required for audio conversion.\n" f" Run: python nodex_dl.py setup-ffmpeg\n" f" This will download and install it automatically." )) else: issues.append(( "ffmpeg not found", f"Install ffmpeg:\n" f" Ubuntu/Debian: sudo apt install ffmpeg\n" f" macOS: brew install ffmpeg\n" f" Or place ffmpeg binary in: {SCRIPT_DIR / 'ffmpeg' / 'ffmpeg'}" )) missing_pkgs = [] for mod, pkg in [("yt_dlp", "yt-dlp"), ("mutagen", "mutagen"), ("PIL", "Pillow"), ("rich", "rich"), ("click", "click"), ("requests", "requests"), ("dotenv", "python-dotenv")]: try: __import__(mod) except ImportError: missing_pkgs.append(pkg) if missing_pkgs: issues.append(( "Python packages missing", f"Run: pip install {' '.join(missing_pkgs)}\n" f" Or: pip install -r \"{SCRIPT_DIR / 'requirements.txt'}\"" )) return issues _ENV_ISSUES = _check_env() try: import click from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.prompt import Prompt from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, DownloadColumn, TransferSpeedColumn from dotenv import load_dotenv RICH_OK = True except ImportError: RICH_OK = False if RICH_OK: load_dotenv(SCRIPT_DIR / ".env") console = Console() BANNER = """\ [bold cyan] ███╗ ██╗ ██████╗ ██████╗ ███████╗██╗ ██╗ ██████╗ ██╗ ██╗ ████╗ ██║██╔═══██╗██╔══██╗██╔════╝╚██╗██╔╝ ██╔══██╗██║ ██║ ██╔██╗ ██║██║ ██║██║ ██║█████╗ ╚███╔╝ ██████╔╝██║ █╗ ██║ ██║╚██╗██║██║ ██║██║ ██║██╔══╝ ██╔██╗ ██╔═══╝ ██║███╗██║ ██║ ╚████║╚██████╔╝██████╔╝███████╗██╔╝ ██╗██╗██║ ╚███╔███╔╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝╚═╝ ╚══╝╚══╝ [/bold cyan] [bold yellow] ♪ Song Downloader — nodex.pw ♪[/bold yellow] """ FORMATS = { "1": ("mp3", "320", "MP3 320 kbps"), "2": ("mp3", "256", "MP3 256 kbps"), "3": ("mp3", "128", "MP3 128 kbps"), "4": ("flac", "lossless", "FLAC Lossless"), "5": ("wav", "lossless", "WAV Lossless"), "6": ("m4a", "best", "M4A / AAC Best"), "7": ("alac", "lossless", "ALAC Lossless"), "8": ("ogg", "best", "OGG Vorbis"), "9": ("opus", "best", "OPUS"), "10": ("mp4", "best", "MP4 Video + Audio"), } PLATFORM_COLORS = { "youtube": "[bold red]YouTube[/bold red]", "spotify": "[bold green]Spotify[/bold green]", "apple_music": "[bold white]Apple Music[/bold white]", } def print_banner(): console.print(BANNER) def print_env_issues(): if not _ENV_ISSUES: return for title, detail in _ENV_ISSUES: console.print(Panel( detail, title=f"[bold red] {title} [/bold red]", border_style="red", expand=False, )) console.print() def print_format_menu(): table = Table(border_style="cyan", show_header=True, header_style="bold cyan") table.add_column("#", style="bold yellow", width=4, justify="right") table.add_column("Format", style="bold white", min_width=22) table.add_column("Type", style="dim") for key, (fmt, quality, label) in FORMATS.items(): t = "Lossless" if quality == "lossless" else ("Video" if fmt == "mp4" else f"{quality} kbps" if quality.isdigit() else "Best quality") table.add_row(key, label, t) console.print(table) def pick_format() -> tuple[str, str]: print_format_menu() console.print() choice = Prompt.ask("[bold cyan]Choose format[/bold cyan]", choices=list(FORMATS.keys()), default="1") fmt, quality, label = FORMATS[choice] console.print(f"[dim]→[/dim] [green]{label}[/green]") return fmt, quality def _make_progress_hook(progress: Progress, task_id): def hook(d): if d["status"] == "downloading": total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0 done = d.get("downloaded_bytes") or 0 fname = Path(d.get("filename", "")).name[:50] if total > 0: progress.update(task_id, completed=done, total=total, description=f"[cyan]{fname}[/cyan]") elif d["status"] == "finished": progress.update(task_id, description="[green]Processing...[/green]") return hook def run_download(url: str, fmt: str, quality: str, output_dir: str): if _ENV_ISSUES: print_env_issues() console.print("[yellow]Fix the issues above before downloading.[/yellow]") return from platforms import download_track, detect_platform platform = detect_platform(url) console.print(f" Platform : {PLATFORM_COLORS.get(platform, platform)}") console.print(f" Format : [bold]{fmt.upper()}[/bold] Quality: [bold]{quality}[/bold]") console.print(f" Output : [dim]{output_dir}[/dim]") console.print() downloaded = [] with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), DownloadColumn(), TransferSpeedColumn(), TimeElapsedColumn(), console=console, transient=False, ) as progress: task_id = progress.add_task("Waiting...", total=None) hook = _make_progress_hook(progress, task_id) try: result = download_track(url, fmt, quality, output_dir, progress_callback=hook) downloaded.extend(result) progress.update(task_id, completed=progress.tasks[task_id].total or 100, description="[bold green]Done[/bold green]") except (ValueError, RuntimeError) as e: progress.stop() console.print(f"\n[bold red]Error:[/bold red] {e}") return except Exception as e: progress.stop() console.print(f"\n[bold red]Unexpected error:[/bold red] {e}") return console.print() if downloaded: lines = "\n".join(f"[green]✓[/green] {Path(f).name}" for f in downloaded) console.print(Panel(lines, title="[bold green]Downloaded[/bold green]", border_style="green", expand=False)) else: console.print("[yellow]Nothing was downloaded.[/yellow]") def interactive_loop(): print_env_issues() while True: console.print("[bold]Paste URL[/bold] [dim](YouTube / Spotify / Apple Music)[/dim] or [bold red]q[/bold red] to quit") url = Prompt.ask("[bold cyan]URL[/bold cyan]") if url.strip().lower() in ("q", "quit", "exit", ""): console.print("\n[bold cyan]Bye![/bold cyan]") break output_dir = Prompt.ask( "[bold]Output directory[/bold]", default=str(Path.home() / "Music"), ) console.print() fmt, quality = pick_format() console.print() run_download(url.strip(), fmt, quality, output_dir) console.print() again = Prompt.ask("[bold]Download another?[/bold]", choices=["y", "n"], default="y") if again == "n": console.print("\n[bold cyan]Bye![/bold cyan]") break console.print() # --- fallback for missing rich/click --- def _no_rich_main(): for title, detail in _ENV_ISSUES: print(f"\n[!] {title}") print(f" {detail}\n") sys.exit(1) if not RICH_OK: _no_rich_main() @click.group(invoke_without_command=True) @click.pass_context def cli(ctx): if ctx.invoked_subcommand is None: print_banner() interactive_loop() @cli.command("download") @click.argument("url") @click.option("--format", "-f", "fmt", type=click.Choice(["mp3", "flac", "wav", "m4a", "alac", "ogg", "opus", "mp4"]), default=None, help="Output format") @click.option("--quality", "-q", type=click.Choice(["320", "256", "192", "128", "best"]), default="320", show_default=True, help="Bitrate for lossy formats") @click.option("--output", "-o", default=str(Path.home() / "Music"), show_default=True, help="Destination directory") def cmd_download(url: str, fmt: str | None, quality: str, output: str): """Download a track, album or playlist from URL.""" print_banner() if fmt is None: console.print() fmt, quality = pick_format() console.print() run_download(url, fmt, quality, output) @cli.command("setup-ffmpeg") def cmd_setup_ffmpeg(): """Download and install ffmpeg into the ffmpeg\\ folder (Windows).""" if sys.platform != "win32": console.print("[yellow]Use your package manager: apt install ffmpeg / brew install ffmpeg[/yellow]") return exe = SCRIPT_DIR / "ffmpeg" / "ffmpeg.exe" if exe.exists(): console.print("[green]ffmpeg already installed.[/green]") return try: _install_ffmpeg_windows() console.print("\n[bold green]ffmpeg installed successfully.[/bold green]") console.print("Restart nodex_dl.py — everything should work now.") except Exception as e: console.print(f"[red]Failed: {e}[/red]") @cli.command("search") @click.argument("query") @click.option("--format", "-f", "fmt", type=click.Choice(["mp3", "flac", "wav", "m4a", "alac", "ogg", "opus", "mp4"]), default=None) @click.option("--quality", "-q", type=click.Choice(["320", "256", "192", "128", "best"]), default="320") @click.option("--output", "-o", default=str(Path.home() / "Music")) def cmd_search(query: str, fmt: str | None, quality: str, output: str): """Search YouTube and download the first result.""" print_banner() if fmt is None: console.print() fmt, quality = pick_format() console.print() run_download(f"ytsearch1:{query}", fmt, quality, output) if __name__ == "__main__": cli()