feat: add crossplattform implementation

This commit is contained in:
Jan Meyer
2026-02-10 14:28:14 +01:00
parent f5f424d7ba
commit d3d244d2b0
4 changed files with 191 additions and 18 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",
]

View File

@@ -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()