diff --git a/ripper/cli.py b/ripper/cli.py index bc42ffc..6e489b2 100644 --- a/ripper/cli.py +++ b/ripper/cli.py @@ -6,8 +6,11 @@ import sys from rich.panel import Panel -from .config import INPUT_PATH, OUTPUT_BASE, CONFIG_FILE, init_config, console -from .scanner import detect_encoder, scan_disc, select_title +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 @@ -16,16 +19,21 @@ 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 (H.265 10-bit).", + 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" ), ) @@ -40,6 +48,19 @@ def main(): 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, @@ -60,6 +81,11 @@ def main(): 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", @@ -72,6 +98,9 @@ def main(): ) 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() @@ -82,6 +111,12 @@ def main(): 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): @@ -89,11 +124,12 @@ def main(): return # ── Banner ─────────────────────────────────────────────────────────── + codec_display = CODEC_DISPLAY.get(args.codec, args.codec.upper()) console.print() console.print( Panel( - "[bold white]DVD / Blu-ray Ripper[/]\n" - "[dim]H.265 10-bit · HandBrakeCLI · Scene Naming[/]", + f"[bold white]DVD / Blu-ray Ripper[/]\n" + f"[dim]{codec_display} · HandBrakeCLI · Scene Naming[/]", border_style="cyan", padding=(0, 2), ) @@ -101,7 +137,9 @@ def main(): console.print() # ── 1. Detect encoder ──────────────────────────────────────────────── - encoder = detect_encoder() + available = discover_encoders() + print_encoder_table(available) + encoder = select_encoder(available, codec=args.codec) # ── 2. Scan disc ───────────────────────────────────────────────────── scan = scan_disc(args.input) @@ -142,7 +180,16 @@ def main(): sys.exit(1) source_tag = get_source_tag(args.input) - scene = build_scene_name(movie_name, year, title, audio_sel, source_tag) + + # Determine codec tag and bit depth from the selected encoder + codec_tag = CODEC_SCENE_TAG.get(args.codec, args.codec.upper()) + # Check if we're using a 10-bit encoder + 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) @@ -163,7 +210,13 @@ def main(): # ── 5. Encode ──────────────────────────────────────────────────────── cmd = build_handbrake_cmd( - args.input, output_file, title, audio_sel, subtitle_sel, encoder, + 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) diff --git a/ripper/config.py b/ripper/config.py index b31c7c2..46cc2aa 100644 --- a/ripper/config.py +++ b/ripper/config.py @@ -47,17 +47,15 @@ DEFAULT_CONFIG = """\ # output_base = "/mnt/shared/ripped" [encoder] -# Primary hardware encoder (set to your GPU's encoder) -# AMD: "vce_h265_10bit" NVIDIA: "nvenc_h265_10bit" Intel: "qsv_h265_10bit" -# primary = "vce_h265_10bit" - -# CPU fallback encoder -# fallback = "x265_10bit" +# Video codec: "hevc", "h264", or "av1" +# The best available encoder (HW-accelerated if possible) is auto-selected. +# codec = "hevc" # Encoder preset: "speed", "balanced", or "quality" # preset = "speed" -# Constant quality (lower = better quality, bigger file) +# Constant quality / rate factor (lower = better quality, bigger file) +# Typical ranges: HEVC 18-28, H.264 18-24, AV1 25-35 # quality = 22 # Seconds without progress before the disc is considered stuck (0 to disable) @@ -86,8 +84,7 @@ def _platform_defaults() -> dict: return { "input_path": input_path, "output_base": output_base, - "encoder_primary": "vce_h265_10bit", - "encoder_fallback": "x265_10bit", + "codec": "hevc", "encoder_preset": "speed", "quality": 22, "stall_timeout": 120, @@ -108,8 +105,7 @@ def _load_config() -> dict: defaults["input_path"] = paths.get("input", defaults["input_path"]) defaults["output_base"] = paths.get("output_base", defaults["output_base"]) - defaults["encoder_primary"] = encoder.get("primary", defaults["encoder_primary"]) - defaults["encoder_fallback"] = encoder.get("fallback", defaults["encoder_fallback"]) + defaults["codec"] = encoder.get("codec", defaults["codec"]) defaults["encoder_preset"] = encoder.get("preset", defaults["encoder_preset"]) defaults["quality"] = encoder.get("quality", defaults["quality"]) defaults["stall_timeout"] = encoder.get("stall_timeout", defaults["stall_timeout"]) @@ -139,8 +135,7 @@ _cfg = _load_config() INPUT_PATH: str = _cfg["input_path"] OUTPUT_BASE: str = _cfg["output_base"] -ENCODER_PRIMARY: str = _cfg["encoder_primary"] -ENCODER_FALLBACK: str = _cfg["encoder_fallback"] +CODEC: str = _cfg["codec"] ENCODER_PRESET: str = _cfg["encoder_preset"] QUALITY: int = _cfg["quality"] STALL_TIMEOUT: int = _cfg["stall_timeout"] diff --git a/ripper/encode.py b/ripper/encode.py index 5b16c05..1fd175b 100644 --- a/ripper/encode.py +++ b/ripper/encode.py @@ -13,7 +13,7 @@ from rich.progress import ( TimeRemainingColumn, ) -from .config import ENCODER_PRESET, QUALITY, STALL_TIMEOUT, console +from .config import ENCODER_PRESET, STALL_TIMEOUT, console def build_handbrake_cmd( @@ -23,6 +23,7 @@ def build_handbrake_cmd( audio_tracks: list[dict], subtitle_tracks: list[dict], encoder: str, + quality: int = 22, ) -> list[str]: """Build the full HandBrakeCLI command line.""" cmd = [ @@ -36,12 +37,11 @@ def build_handbrake_cmd( # Video "--encoder", encoder, "--enable-hw-decoding", - "--quality", str(QUALITY), + "--quality", str(quality), "--rate", "30", "--pfr", "--color-range", "limited", "--encoder-preset", ENCODER_PRESET, - "--encoder-profile", "main10", "--encoder-level", "auto", ] diff --git a/ripper/scanner.py b/ripper/scanner.py index 4128415..18ff0af 100644 --- a/ripper/scanner.py +++ b/ripper/scanner.py @@ -1,10 +1,71 @@ """Disc scanning and encoder detection.""" import json +import re import subprocess import sys -from .config import ENCODER_PRIMARY, ENCODER_FALLBACK, console +from rich.table import Table +from rich import box + +from .config import console + + +# ── Encoder knowledge base ─────────────────────────────────────────────────── + +# Maps HandBrakeCLI encoder names → (codec_family, bit_depth, hw_vendor | None) +# hw_vendor is None for software encoders. +ENCODER_INFO: dict[str, tuple[str, int, str | None]] = { + # HEVC / H.265 + "x265": ("hevc", 8, None), + "x265_10bit": ("hevc", 10, None), + "x265_12bit": ("hevc", 12, None), + "vce_h265": ("hevc", 8, "AMD"), + "vce_h265_10bit": ("hevc", 10, "AMD"), + "nvenc_h265": ("hevc", 8, "NVIDIA"), + "nvenc_h265_10bit": ("hevc", 10, "NVIDIA"), + "qsv_h265": ("hevc", 8, "Intel"), + "qsv_h265_10bit": ("hevc", 10, "Intel"), + "mf_h265": ("hevc", 8, "MediaFoundation"), + # H.264 / AVC + "x264": ("h264", 8, None), + "x264_10bit": ("h264", 10, None), + "vce_h264": ("h264", 8, "AMD"), + "nvenc_h264": ("h264", 8, "NVIDIA"), + "qsv_h264": ("h264", 8, "Intel"), + "mf_h264": ("h264", 8, "MediaFoundation"), + # AV1 + "svt_av1": ("av1", 8, None), + "svt_av1_10bit": ("av1", 10, None), + "qsv_av1": ("av1", 8, "Intel"), + "qsv_av1_10bit": ("av1", 10, "Intel"), + "nvenc_av1": ("av1", 8, "NVIDIA"), + "nvenc_av1_10bit": ("av1", 10, "NVIDIA"), + "vce_av1": ("av1", 8, "AMD"), + "vce_av1_10bit": ("av1", 10, "AMD"), + # VP9 + "vp9": ("vp9", 8, None), + "vp9_10bit": ("vp9", 10, None), + # MPEG-4 / MPEG-2 (legacy) + "mpeg4": ("mpeg4", 8, None), + "mpeg2": ("mpeg2", 8, None), +} + +# Scene-style codec tags for filenames +CODEC_SCENE_TAG = { + "hevc": "x265", + "h264": "x264", + "av1": "AV1", + "vp9": "VP9", +} + +# Friendly display names +CODEC_DISPLAY = { + "hevc": "HEVC (H.265)", + "h264": "H.264 (AVC)", + "av1": "AV1", + "vp9": "VP9", +} def run(cmd: list[str], capture: bool = True) -> subprocess.CompletedProcess: @@ -16,20 +77,126 @@ def run(cmd: list[str], capture: bool = True) -> subprocess.CompletedProcess: ) -def detect_encoder() -> str: - """Return the best available H.265 10-bit encoder.""" - with console.status("[bold cyan]Detecting encoder…"): +def discover_encoders() -> dict[str, list[dict]]: + """ + Query HandBrakeCLI for available encoders and group by codec family. + + Returns a dict like: + { + "hevc": [ + {"name": "x265_10bit", "bits": 10, "hw": None}, + {"name": "vce_h265_10bit", "bits": 10, "hw": "AMD"}, + ... + ], + "h264": [...], + "av1": [...], + } + Only includes encoders that are actually available. + """ + with console.status("[bold cyan]Detecting available encoders…"): result = run(["HandBrakeCLI", "--help"], capture=True) combined = result.stdout + result.stderr - if ENCODER_PRIMARY in combined: - console.print(f" [green]✓[/] Using hardware encoder: [bold]{ENCODER_PRIMARY}[/]") - return ENCODER_PRIMARY - console.print( - f" [yellow]⚠[/] {ENCODER_PRIMARY} not available, " - f"falling back to [bold]{ENCODER_FALLBACK}[/]" - ) - return ENCODER_FALLBACK + available: dict[str, list[dict]] = {} + + for enc_name, (family, bits, hw) in ENCODER_INFO.items(): + # Check if this encoder name appears in HandBrakeCLI's help output + if re.search(rf'\b{re.escape(enc_name)}\b', combined): + available.setdefault(family, []).append({ + "name": enc_name, + "bits": bits, + "hw": hw, + }) + + # Sort each family: HW first, then by bit depth descending + for family in available: + available[family].sort(key=lambda e: (e["hw"] is None, -e["bits"])) + + return available + + +def print_encoder_table(available: dict[str, list[dict]]) -> None: + """Display available encoders grouped by codec family in a rich table.""" + table = Table( + title="Available Encoders", + box=box.ROUNDED, + title_style="bold cyan", + header_style="bold", + show_lines=True, + padding=(0, 1), + ) + table.add_column("Codec", style="bold white", width=16) + table.add_column("Encoders", style="dim") + + # Display order + for family in ("hevc", "h264", "av1", "vp9"): + encoders = available.get(family, []) + if not encoders: + continue + + parts = [] + for e in encoders: + if e["hw"]: + parts.append(f"[green]{e['name']}[/] [bold green]⚡{e['hw']}[/]") + else: + parts.append(f"[dim]{e['name']}[/] [dim]CPU[/]") + + display = CODEC_DISPLAY.get(family, family.upper()) + table.add_row(display, " ".join(parts)) + + console.print() + console.print(table) + console.print() + + +def select_encoder( + available: dict[str, list[dict]], + codec: str = "hevc", + prefer_10bit: bool = True, +) -> str: + """ + Select the best encoder for a given codec family. + + Priority: HW 10-bit → HW 8-bit → SW 10-bit → SW 8-bit + Falls back across families if the requested codec has no encoders. + """ + encoders = available.get(codec, []) + + if not encoders: + console.print( + f" [yellow]⚠[/] No {CODEC_DISPLAY.get(codec, codec)} encoders available." + ) + # Fall back to HEVC, then H.264 + for fallback in ("hevc", "h264", "av1"): + if fallback != codec and fallback in available: + console.print(f" [dim]Falling back to {CODEC_DISPLAY.get(fallback, fallback)}[/]") + encoders = available[fallback] + codec = fallback + break + + if not encoders: + console.print("[bold red]ERROR:[/] No video encoders available.") + sys.exit(1) + + # The list is already sorted HW-first, highest bit depth first + # If we prefer 10-bit, just take the first one + if prefer_10bit: + selected = encoders[0] + else: + # Prefer 8-bit + eight_bit = [e for e in encoders if e["bits"] == 8] + selected = eight_bit[0] if eight_bit else encoders[0] + + enc = selected + hw_tag = f" [bold green]⚡{enc['hw']}[/]" if enc["hw"] else " [dim]CPU[/]" + console.print( + f" [green]✓[/] Encoder: [bold]{enc['name']}[/]{hw_tag} " + f"[dim]({enc['bits']}-bit {CODEC_DISPLAY.get(codec, codec)})[/]" + ) + return enc["name"] + + +# ── Disc scanning ──────────────────────────────────────────────────────────── def scan_disc(input_path: str) -> dict: """Scan the disc and return the parsed JSON structure.""" @@ -42,30 +209,19 @@ def scan_disc(input_path: str) -> dict: "--scan", "--previews", "1:0", ]) - # HandBrakeCLI mixes stderr log lines with JSON on stdout. - # JSON blocks are labeled, e.g.: - # Version: { ... } - # Progress: { ... } - # JSON Title Set: { "MainFeature": ..., "TitleList": [...] } - # We want the "JSON Title Set" block. Also, "HandBrake has exited." - # can appear mid-stream and must be stripped. combined = (result.stdout or "") + (result.stderr or "") - # Strip injected noise lines cleaned_lines = [ line for line in combined.splitlines() if "HandBrake has exited" not in line ] - # Find the "JSON Title Set:" block and extract the JSON from it capture = False depth = 0 buf: list[str] = [] for line in cleaned_lines: if not capture: - # Look for the label line if "JSON Title Set:" in line: - # The JSON starts after the label on the same line json_start = line.index("{") buf.append(line[json_start:]) depth += line[json_start:].count("{") - line[json_start:].count("}") @@ -73,7 +229,6 @@ def scan_disc(input_path: str) -> dict: if depth <= 0: break continue - # Inside JSON block buf.append(line) depth += line.count("{") - line.count("}") if depth <= 0: @@ -98,7 +253,6 @@ def select_title(scan: dict) -> dict: console.print("[bold red]ERROR:[/] No titles found on disc.") sys.exit(1) - # Prefer the one flagged MainFeature, else longest duration main = [t for t in titles if t.get("MainFeature")] if main: title = main[0]