Files
ripper/ripper/cli.py
2026-02-10 19:16:11 +01:00

252 lines
8.9 KiB
Python

"""CLI entry point and main orchestration logic."""
import argparse
import os
import sys
from rich.panel import Panel
from .config import INPUT_PATH, OUTPUT_BASE, QUALITY, CODEC, CONFIG_FILE, init_config, console
from .scanner import (
discover_encoders, print_encoder_table, select_encoder,
scan_disc, select_title, CODEC_SCENE_TAG, CODEC_DISPLAY,
)
from .tracks import best_tracks_per_language, print_track_tables
from .naming import build_scene_name, get_source_tag, get_volume_label
from .encode import build_handbrake_cmd, run_encode
from .metadata import lookup_imdb, generate_nfo
from .library import list_library
from .disc import check_disc_readable, unstuck_disc
CODEC_CHOICES = ("hevc", "h264", "av1")
def main():
parser = argparse.ArgumentParser(
description="Rip DVD/Blu-ray to MKV using HandBrakeCLI.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" %(prog)s --imdb tt6977338\n"
" %(prog)s --name 'Game Night' --year 2018\n"
" %(prog)s --codec av1 --quality 30\n"
" %(prog)s --scan-only\n"
" %(prog)s --list\n"
" %(prog)s --encoders\n"
" %(prog)s --init-config\n"
),
)
parser.add_argument(
"--name", "-n",
help="Movie name (spaces OK, will be dotted). "
"If omitted, the disc volume label is used.",
)
parser.add_argument("--year", "-y", help="Release year for the filename.")
parser.add_argument(
"--imdb",
help="IMDB ID (e.g. tt6977338) — fetches name and year from TMDB. "
"Requires TMDB_API_KEY env var or config.",
)
parser.add_argument(
"--codec", "-c",
choices=CODEC_CHOICES,
default=CODEC,
help=f"Video codec family (default: {CODEC}).",
)
parser.add_argument(
"--quality", "-q",
type=int,
default=None,
help=f"Constant quality / rate factor (default: {QUALITY}). "
"Lower = better quality, bigger file.",
)
parser.add_argument(
"--input", "-i",
default=INPUT_PATH,
help=f"Input path (default: {INPUT_PATH}).",
)
parser.add_argument(
"--output-base",
default=OUTPUT_BASE,
help=f"Base output directory (default: {OUTPUT_BASE}).",
)
parser.add_argument(
"--scan-only",
action="store_true",
help="Only scan the disc and print track info, don't encode.",
)
parser.add_argument(
"--list", "-l",
action="store_true",
help="List all ripped movies in the output directory.",
)
parser.add_argument(
"--encoders",
action="store_true",
help="Show all available encoders and exit.",
)
parser.add_argument(
"--check-disc",
action="store_true",
help="Check disc readability and attempt recovery if stuck.",
)
parser.add_argument(
"--init-config",
action="store_true",
help="Create a default config.toml in the config directory.",
)
args = parser.parse_args()
# Quality: CLI flag > config default
quality = args.quality if args.quality is not None else QUALITY
# ── Generate config file ─────────────────────────────────────────────
if args.init_config:
init_config()
return
# ── Library listing (no disc needed) ─────────────────────────────────
if args.list:
list_library(args.output_base)
return
# ── Show available encoders ──────────────────────────────────────────
if args.encoders:
available = discover_encoders()
print_encoder_table(available)
return
# ── Disc health check ────────────────────────────────────────────────
if args.check_disc:
if not unstuck_disc(args.input):
sys.exit(1)
return
# ── Banner ───────────────────────────────────────────────────────────
codec_display = CODEC_DISPLAY.get(args.codec, args.codec.upper())
console.print()
console.print(
Panel(
f"[bold white]DVD / Blu-ray Ripper[/]\n"
f"[dim]{codec_display} · HandBrakeCLI · Scene Naming[/]",
border_style="cyan",
padding=(0, 2),
)
)
console.print()
try:
_run_pipeline(args, quality)
except KeyboardInterrupt:
console.print("\n [bold yellow]⚠ Interrupted[/] — exiting.\n")
sys.exit(130)
def _run_pipeline(args, quality: int) -> None:
"""Core ripping pipeline, separated for clean interrupt handling."""
# ── 1. Detect encoder ────────────────────────────────────────────────
available = discover_encoders()
print_encoder_table(available)
encoder = select_encoder(available, codec=args.codec)
# ── 2. Scan disc ─────────────────────────────────────────────────────
scan = scan_disc(args.input)
title = select_title(scan)
# ── 3. Select tracks ─────────────────────────────────────────────────
audio_all = title.get("AudioList", [])
subtitle_all = title.get("SubtitleList", [])
audio_sel = best_tracks_per_language(audio_all, "audio")
subtitle_sel = best_tracks_per_language(subtitle_all, "subtitle")
print_track_tables(audio_sel, subtitle_sel)
if args.scan_only:
console.print("\n [dim](scan-only mode, exiting)[/]\n")
return
# ── 4. Build filename ────────────────────────────────────────────────
movie_name = args.name
year = args.year
# IMDB lookup overrides --name and --year
if args.imdb:
movie_name, imdb_year = lookup_imdb(args.imdb)
if not year:
year = imdb_year
if not movie_name:
label = get_volume_label(args.input)
if label:
movie_name = label.replace("_", " ").title()
console.print(f" [green]✓[/] Using volume label: [bold]{movie_name}[/]")
else:
movie_name = console.input(" [cyan]Enter movie name:[/] ").strip()
if not movie_name:
console.print("[bold red]ERROR:[/] No movie name provided.")
sys.exit(1)
source_tag = get_source_tag(args.input)
# Determine codec tag and bit depth from the selected encoder
codec_tag = CODEC_SCENE_TAG.get(args.codec, args.codec.upper())
is_10bit = "10bit" in encoder or "10" in encoder
scene = build_scene_name(
movie_name, year, title, audio_sel, source_tag,
codec_tag=codec_tag, is_10bit=is_10bit,
)
# Create output directory
out_dir = os.path.join(args.output_base, scene)
os.makedirs(out_dir, exist_ok=True)
output_file = os.path.join(out_dir, f"{scene}.mkv")
console.print()
console.print(
Panel(
f"[bold]{scene}.mkv[/]\n"
f"[dim]{out_dir}[/]",
title="[bold cyan]Output[/]",
border_style="dim",
padding=(0, 2),
)
)
console.print()
# ── 5. Encode ────────────────────────────────────────────────────────
cmd = build_handbrake_cmd(
input_path=args.input,
output_path=output_file,
title=title,
audio_tracks=audio_sel,
subtitle_tracks=subtitle_sel,
encoder=encoder,
quality=quality,
)
returncode = run_encode(cmd, input_path=args.input)
if returncode == 130:
# User cancelled — re-raise so outer handler prints message
raise KeyboardInterrupt
if returncode != 0:
console.print(f"\n[bold red]ERROR:[/] HandBrakeCLI exited with code {returncode}")
sys.exit(returncode)
console.print(f"\n [green]✓[/] Encode complete")
# ── 6. Generate NFO ──────────────────────────────────────────────────
generate_nfo(output_file)
console.print()
console.print(
Panel(
f"[green]✓ All done![/]\n[dim]{out_dir}[/]",
border_style="green",
padding=(0, 2),
)
)
console.print()