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 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 .scanner import detect_encoder, scan_disc, select_title
from .tracks import best_tracks_per_language, print_track_tables from .tracks import best_tracks_per_language, print_track_tables
from .naming import build_scene_name, get_source_tag, get_volume_label 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 --name 'Game Night' --year 2018\n"
" %(prog)s --scan-only\n" " %(prog)s --scan-only\n"
" %(prog)s --list\n" " %(prog)s --list\n"
" %(prog)s --name Inception --year 2010 --input /dev/sr0\n" " %(prog)s --init-config\n"
), ),
) )
parser.add_argument( parser.add_argument(
@@ -37,7 +37,7 @@ def main():
parser.add_argument( parser.add_argument(
"--imdb", "--imdb",
help="IMDB ID (e.g. tt6977338) — fetches name and year from TMDB. " 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( parser.add_argument(
"--input", "-i", "--input", "-i",
@@ -59,8 +59,18 @@ def main():
action="store_true", action="store_true",
help="List all ripped movies in the output directory.", 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() args = parser.parse_args()
# ── Generate config file ─────────────────────────────────────────────
if args.init_config:
init_config()
return
# ── Library listing (no disc needed) ───────────────────────────────── # ── Library listing (no disc needed) ─────────────────────────────────
if args.list: if args.list:
list_library(args.output_base) 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 os
import sys
import tomllib
from pathlib import Path
from rich.console import Console from rich.console import Console
console = Console(stderr=True) console = Console(stderr=True)
# ── Paths ──────────────────────────────────────────────────────────────────── # ── Config directory ─────────────────────────────────────────────────────────
INPUT_PATH = "/mnt/dvd" def _config_dir() -> Path:
OUTPUT_BASE = "/mnt/shared/ripped" """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" # Default TOML template (written when user runs --init-config)
ENCODER_FALLBACK = "x265_10bit" 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 = { AUDIO_CODEC_SCENE = {
"truehd": "TrueHD", "truehd": "TrueHD",

View File

@@ -13,7 +13,7 @@ from rich.progress import (
TimeRemainingColumn, TimeRemainingColumn,
) )
from .config import console from .config import ENCODER_PRESET, QUALITY, console
def build_handbrake_cmd( def build_handbrake_cmd(
@@ -36,11 +36,11 @@ def build_handbrake_cmd(
# Video # Video
"--encoder", encoder, "--encoder", encoder,
"--enable-hw-decoding", "--enable-hw-decoding",
"--quality", "22", "--quality", str(QUALITY),
"--rate", "30", "--rate", "30",
"--pfr", "--pfr",
"--color-range", "limited", "--color-range", "limited",
"--encoder-preset", "speed", "--encoder-preset", ENCODER_PRESET,
"--encoder-profile", "main10", "--encoder-profile", "main10",
"--encoder-level", "auto", "--encoder-level", "auto",
] ]

View File

@@ -3,6 +3,7 @@
import os import os
import re import re
import subprocess import subprocess
import sys
from .config import AUDIO_CODEC_SCENE, CHANNEL_SCENE from .config import AUDIO_CODEC_SCENE, CHANNEL_SCENE
@@ -28,8 +29,8 @@ def get_source_tag(input_path: str) -> str:
return "DVD" return "DVD"
def get_volume_label(input_path: str) -> str | None: def _get_volume_label_linux(input_path: str) -> str | None:
"""Try to read the disc volume label.""" """Read volume label on Linux using blkid/findmnt."""
# Try blkid first # Try blkid first
try: try:
result = subprocess.run( result = subprocess.run(
@@ -63,6 +64,51 @@ def get_volume_label(input_path: str) -> str | None:
return 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: def scene_audio_tag(audio_track: dict) -> str:
"""Build the primary audio scene tag like DTS-HD.MA.5.1""" """Build the primary audio scene tag like DTS-HD.MA.5.1"""
codec = audio_track.get("CodecName", "unknown").lower() codec = audio_track.get("CodecName", "unknown").lower()