feat: add crossplattform implementation
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
137
ripper/config.py
137
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 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",
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user