feat: add support for different encondings
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user