diff --git a/ripper/cli.py b/ripper/cli.py index 3a08a88..ff14228 100644 --- a/ripper/cli.py +++ b/ripper/cli.py @@ -6,7 +6,7 @@ import sys from rich.panel import Panel -from .config import INPUT_PATH, OUTPUT_BASE, console +from .config import INPUT_PATH, OUTPUT_BASE, CONFIG_FILE, init_config, console from .scanner import detect_encoder, scan_disc, select_title from .tracks import best_tracks_per_language, print_track_tables from .naming import build_scene_name, get_source_tag, get_volume_label @@ -25,7 +25,7 @@ def main(): " %(prog)s --name 'Game Night' --year 2018\n" " %(prog)s --scan-only\n" " %(prog)s --list\n" - " %(prog)s --name Inception --year 2010 --input /dev/sr0\n" + " %(prog)s --init-config\n" ), ) parser.add_argument( @@ -37,7 +37,7 @@ def main(): parser.add_argument( "--imdb", help="IMDB ID (e.g. tt6977338) — fetches name and year from TMDB. " - "Requires TMDB_API_KEY env var.", + "Requires TMDB_API_KEY env var or config.", ) parser.add_argument( "--input", "-i", @@ -59,8 +59,18 @@ def main(): action="store_true", help="List all ripped movies in the output directory.", ) + parser.add_argument( + "--init-config", + action="store_true", + help="Create a default config.toml in the config directory.", + ) args = parser.parse_args() + # ── Generate config file ───────────────────────────────────────────── + if args.init_config: + init_config() + return + # ── Library listing (no disc needed) ───────────────────────────────── if args.list: list_library(args.output_base) diff --git a/ripper/config.py b/ripper/config.py index e982853..f51d680 100644 --- a/ripper/config.py +++ b/ripper/config.py @@ -1,30 +1,147 @@ -"""Shared configuration, constants, and rich Console.""" +"""Shared configuration, constants, and rich Console. + +Loads settings from a TOML config file: + Linux/macOS: $XDG_CONFIG_HOME/ripper/config.toml (default ~/.config/ripper/config.toml) + Windows: %APPDATA%\\ripper\\config.toml + +Missing keys fall back to platform-aware defaults. +Environment variables override config values where noted. +""" import os +import sys +import tomllib +from pathlib import Path from rich.console import Console console = Console(stderr=True) -# ── Paths ──────────────────────────────────────────────────────────────────── +# ── Config directory ───────────────────────────────────────────────────────── -INPUT_PATH = "/mnt/dvd" -OUTPUT_BASE = "/mnt/shared/ripped" +def _config_dir() -> Path: + """Return the platform-appropriate config directory for ripper.""" + if sys.platform == "win32": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return base / "ripper" -# ── Encoders ───────────────────────────────────────────────────────────────── +CONFIG_DIR = _config_dir() +CONFIG_FILE = CONFIG_DIR / "config.toml" -ENCODER_PRIMARY = "vce_h265_10bit" -ENCODER_FALLBACK = "x265_10bit" +# Default TOML template (written when user runs --init-config) +DEFAULT_CONFIG = """\ +# ripper configuration +# Generated automatically — edit as needed. + +[paths] +# Default disc input path +# Linux: "/mnt/dvd" or "/dev/sr0" +# Windows: "D:\\\\" (your optical drive letter) +# input = "/mnt/dvd" + +# Where ripped files are saved +# 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" + +# Encoder preset: "speed", "balanced", or "quality" +# preset = "speed" + +# Constant quality (lower = better quality, bigger file) +# quality = 22 + +[api] +# TMDB API key for --imdb lookups +# Get a free key at https://www.themoviedb.org/settings/api +# Can also be set via TMDB_API_KEY environment variable +# tmdb_api_key = "" +""" -# ── API keys ───────────────────────────────────────────────────────────────── +def _platform_defaults() -> dict: + """Return sensible defaults for the current platform.""" + if sys.platform == "win32": + input_path = "D:\\" + output_base = str(Path.home() / "Videos" / "Ripped") + elif sys.platform == "darwin": + input_path = "/dev/disk1" + output_base = str(Path.home() / "Movies" / "Ripped") + else: + input_path = "/mnt/dvd" + output_base = "/mnt/shared/ripped" -TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "") + return { + "input_path": input_path, + "output_base": output_base, + "encoder_primary": "vce_h265_10bit", + "encoder_fallback": "x265_10bit", + "encoder_preset": "speed", + "quality": 22, + "tmdb_api_key": "", + } -# ── Scene-naming maps ──────────────────────────────────────────────────────── +def _load_config() -> dict: + """Load config from TOML file, falling back to platform defaults.""" + defaults = _platform_defaults() + + if CONFIG_FILE.is_file(): + with open(CONFIG_FILE, "rb") as f: + toml = tomllib.load(f) + paths = toml.get("paths", {}) + encoder = toml.get("encoder", {}) + api = toml.get("api", {}) + + 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["encoder_preset"] = encoder.get("preset", defaults["encoder_preset"]) + defaults["quality"] = encoder.get("quality", defaults["quality"]) + defaults["tmdb_api_key"] = api.get("tmdb_api_key", defaults["tmdb_api_key"]) + + # Environment variables override config file + if env_key := os.environ.get("TMDB_API_KEY"): + defaults["tmdb_api_key"] = env_key + + return defaults + + +def init_config() -> None: + """Create a default config file if one doesn't exist.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + if CONFIG_FILE.exists(): + console.print(f" [yellow]⚠[/] Config already exists: [dim]{CONFIG_FILE}[/]") + else: + CONFIG_FILE.write_text(DEFAULT_CONFIG, encoding="utf-8") + console.print(f" [green]✓[/] Created config: [bold]{CONFIG_FILE}[/]") + console.print(f" [dim]Edit this file to set your paths, encoder, and API key.[/]") + + +# ── Load config at import time ─────────────────────────────────────────────── + +_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"] +ENCODER_PRESET: str = _cfg["encoder_preset"] +QUALITY: int = _cfg["quality"] +TMDB_API_KEY: str = _cfg["tmdb_api_key"] + + +# ── Scene-naming maps (not platform-specific) ─────────────────────────────── AUDIO_CODEC_SCENE = { "truehd": "TrueHD", diff --git a/ripper/encode.py b/ripper/encode.py index e22c95e..cf66352 100644 --- a/ripper/encode.py +++ b/ripper/encode.py @@ -13,7 +13,7 @@ from rich.progress import ( TimeRemainingColumn, ) -from .config import console +from .config import ENCODER_PRESET, QUALITY, console def build_handbrake_cmd( @@ -36,11 +36,11 @@ def build_handbrake_cmd( # Video "--encoder", encoder, "--enable-hw-decoding", - "--quality", "22", + "--quality", str(QUALITY), "--rate", "30", "--pfr", "--color-range", "limited", - "--encoder-preset", "speed", + "--encoder-preset", ENCODER_PRESET, "--encoder-profile", "main10", "--encoder-level", "auto", ] diff --git a/ripper/naming.py b/ripper/naming.py index 8281caa..4942c70 100644 --- a/ripper/naming.py +++ b/ripper/naming.py @@ -3,6 +3,7 @@ import os import re import subprocess +import sys from .config import AUDIO_CODEC_SCENE, CHANNEL_SCENE @@ -28,8 +29,8 @@ def get_source_tag(input_path: str) -> str: return "DVD" -def get_volume_label(input_path: str) -> str | None: - """Try to read the disc volume label.""" +def _get_volume_label_linux(input_path: str) -> str | None: + """Read volume label on Linux using blkid/findmnt.""" # Try blkid first try: result = subprocess.run( @@ -63,6 +64,51 @@ def get_volume_label(input_path: str) -> str | None: return None +def _get_volume_label_windows(input_path: str) -> str | None: + """Read volume label on Windows using ctypes.""" + import ctypes + + # Normalise to drive root (e.g. "D:\\") + drive = os.path.splitdrive(input_path)[0] + if not drive: + return None + root = drive + "\\" + + label_buf = ctypes.create_unicode_buffer(256) + ok = ctypes.windll.kernel32.GetVolumeInformationW( + root, label_buf, 256, None, None, None, None, 0, + ) + if ok and label_buf.value: + return label_buf.value + + return None + + +def _get_volume_label_macos(input_path: str) -> str | None: + """Read volume label on macOS using diskutil.""" + try: + result = subprocess.run( + ["diskutil", "info", "-plist", input_path], + capture_output=True, text=True, timeout=5, + ) + # Quick extraction from plist XML + import plistlib + info = plistlib.loads(result.stdout.encode()) + return info.get("VolumeName") or None + except (FileNotFoundError, subprocess.TimeoutExpired, Exception): + return None + + +def get_volume_label(input_path: str) -> str | None: + """Read the disc volume label (cross-platform).""" + if sys.platform == "win32": + return _get_volume_label_windows(input_path) + elif sys.platform == "darwin": + return _get_volume_label_macos(input_path) + else: + return _get_volume_label_linux(input_path) + + def scene_audio_tag(audio_track: dict) -> str: """Build the primary audio scene tag like DTS-HD.MA.5.1""" codec = audio_track.get("CodecName", "unknown").lower()