252 lines
8.9 KiB
Python
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()
|