352 lines
12 KiB
Python
352 lines
12 KiB
Python
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()
|