applemusicdownloader/nodex_dl.py

353 lines
12 KiB
Python
Raw Normal View History

2026-06-04 05:06:06 +05:00
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()