231 lines
7.6 KiB
Python
231 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Scan an optical disc, prompt for rip settings, and optionally execute HandBrakeCLI."""
|
|
|
|
import datetime
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
DEVICE = sys.argv[1] if len(sys.argv) > 1 else "/dev/sr0"
|
|
|
|
# ── Scan ──────────────────────────────────────────────────────────────────────
|
|
|
|
print(f"Scanning {DEVICE} (takes ~20-30 s)...\n", flush=True)
|
|
|
|
proc = subprocess.run(
|
|
["HandBrakeCLI", "-i", DEVICE, "--scan", "-t", "0"],
|
|
capture_output=True, text=True, stdin=subprocess.DEVNULL
|
|
)
|
|
lines = proc.stderr.splitlines()
|
|
|
|
titles = {}
|
|
|
|
# Pass 1: log lines → duration, audio, subtitles
|
|
current = None
|
|
checking_audio = checking_sub = False
|
|
|
|
for line in lines:
|
|
m = re.search(r'scan: scanning title (\d+)', line)
|
|
if m:
|
|
current = m.group(1)
|
|
titles.setdefault(current, {"num": current, "duration": "", "video": "", "audio": [], "subs": []})
|
|
checking_audio = checking_sub = False
|
|
continue
|
|
|
|
if current is None:
|
|
continue
|
|
|
|
t = titles[current]
|
|
|
|
m = re.search(r'duration is (\d+:\d+:\d+)', line)
|
|
if m:
|
|
t["duration"] = m.group(1)
|
|
continue
|
|
|
|
if re.search(r'scan: ignoring title', line):
|
|
current = None
|
|
continue
|
|
|
|
if re.search(r'checking audio', line):
|
|
checking_audio, checking_sub = True, False
|
|
continue
|
|
if re.search(r'checking subtitle', line):
|
|
checking_audio, checking_sub = False, True
|
|
continue
|
|
|
|
m = re.search(r'id=0x[0-9a-f]+, lang=(.+?),\s*3cc=(\w+)', line)
|
|
if m:
|
|
lang_full = m.group(1).strip()
|
|
code = m.group(2)
|
|
if checking_audio:
|
|
codec_m = re.search(r'\(([^)]+)\)', lang_full)
|
|
codec = codec_m.group(1) if codec_m else "?"
|
|
name = re.sub(r'\s*\([^)]*\)', '', lang_full).strip()
|
|
t["audio"].append(f"{name} ({codec}) [{code}]")
|
|
elif checking_sub:
|
|
t["subs"].append(code)
|
|
|
|
# Pass 2: summary block → video size
|
|
current = None
|
|
for line in lines:
|
|
m = re.match(r'^\s*\+ title (\d+):', line)
|
|
if m:
|
|
current = m.group(1)
|
|
continue
|
|
if current and current in titles:
|
|
m = re.search(r'\+ size: (\d+x\d+)', line)
|
|
if m:
|
|
titles[current]["video"] = m.group(1)
|
|
|
|
titles = [t for t in titles.values() if t["duration"] and t["duration"] != "00:00:00"]
|
|
|
|
if not titles:
|
|
print("No titles found. Is a disc inserted and HandBrakeCLI installed?")
|
|
sys.exit(1)
|
|
|
|
# ── Display ───────────────────────────────────────────────────────────────────
|
|
|
|
def fmt_duration(hms):
|
|
try:
|
|
h, m, s = (int(x) for x in hms.split(":"))
|
|
total_min = h * 60 + m + round(s / 60)
|
|
return f"{hms} (ca {total_min} min.)"
|
|
except ValueError:
|
|
return hms
|
|
|
|
longest = max(titles, key=lambda x: x["duration"])
|
|
titles_by_num = {t["num"]: t for t in titles}
|
|
|
|
col_dur, col_vid, col_a = 26, 10, 50
|
|
print(f"{'#':<5} {'Duration':<{col_dur}} {'Video':<{col_vid}} {'Audio Tracks':<{col_a}} Subtitles")
|
|
print("─" * (5 + col_dur + col_vid + col_a + 20))
|
|
|
|
for t in titles:
|
|
marker = " ◀ likely main feature" if t is longest and len(titles) > 1 else ""
|
|
audio_str = " / ".join(t["audio"]) if t["audio"] else "—"
|
|
subs_str = " ".join(t["subs"]) if t["subs"] else "—"
|
|
print(f"{t['num']:<5} {fmt_duration(t['duration']):<{col_dur}} {t['video']:<{col_vid}} {audio_str:<{col_a}} {subs_str}{marker}")
|
|
|
|
# ── Interactive setup ─────────────────────────────────────────────────────────
|
|
|
|
def ask(question, default=""):
|
|
suffix = f" [{default}]" if default else ""
|
|
try:
|
|
answer = input(f"\n{question}{suffix}: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nAborted.")
|
|
sys.exit(0)
|
|
return answer if answer else default
|
|
|
|
def get_disc_label():
|
|
r = subprocess.run(["blkid", "-o", "value", "-s", "LABEL", DEVICE],
|
|
capture_output=True, text=True, stdin=subprocess.DEVNULL)
|
|
label = r.stdout.strip()
|
|
if not label:
|
|
return ""
|
|
# TRANSFORMERS_RISE_OF_THE_BEASTS → Transformers Rise of the Beasts
|
|
words = label.replace("_", " ").split()
|
|
small = {"of", "the", "a", "an", "and", "in", "on", "at", "to", "for", "with", "from"}
|
|
return " ".join(
|
|
w.capitalize() if i == 0 or w.lower() not in small else w.lower()
|
|
for i, w in enumerate(words)
|
|
)
|
|
|
|
def get_disc_year():
|
|
r = subprocess.run(["findmnt", "-o", "TARGET", "--noheadings", "-S", DEVICE],
|
|
capture_output=True, text=True, stdin=subprocess.DEVNULL)
|
|
mount_point = r.stdout.strip()
|
|
if mount_point:
|
|
try:
|
|
mtime = os.path.getmtime(mount_point)
|
|
return str(datetime.datetime.fromtimestamp(mtime).year)
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
print()
|
|
print("─" * 40)
|
|
print("Setup")
|
|
print("─" * 40)
|
|
|
|
# Title number
|
|
while True:
|
|
num = ask("Title number", longest["num"])
|
|
if num in titles_by_num:
|
|
selected = titles_by_num[num]
|
|
break
|
|
print(f" Title {num!r} not found. Valid: {', '.join(t['num'] for t in titles)}")
|
|
|
|
# Confirm the selected title
|
|
t = selected
|
|
audio_str = " / ".join(t["audio"]) if t["audio"] else "—"
|
|
subs_str = " ".join(t["subs"]) if t["subs"] else "—"
|
|
print(f"\n Selected: title #{t['num']} {fmt_duration(t['duration'])} {t['video']}")
|
|
print(f" Audio: {audio_str}")
|
|
print(f" Subs: {subs_str}")
|
|
|
|
# Movie name + year
|
|
default_name = get_disc_label()
|
|
default_year = get_disc_year()
|
|
movie_name = ask("Movie name", default_name)
|
|
year = ask("Year", default_year)
|
|
|
|
# Audio / output
|
|
audio_langs = ask("Audio languages (comma-separated, order = priority)", "deu,eng")
|
|
out_base = ask("Output directory", "/mnt/media/jellyfin/movies")
|
|
|
|
# ── Confirmation ──────────────────────────────────────────────────────────────
|
|
|
|
full_name = f"{movie_name} ({year})" if year else movie_name
|
|
out_dir = os.path.join(out_base, full_name)
|
|
out_file = os.path.join(out_dir, f"{full_name}.mkv")
|
|
|
|
cmd = [
|
|
"HandBrakeCLI",
|
|
"-i", DEVICE,
|
|
"-t", num,
|
|
"-o", out_file,
|
|
"--preset=H.265 MKV 1080p30",
|
|
"--audio-lang-list", audio_langs,
|
|
"--all-audio",
|
|
"--aencoder", "copy",
|
|
"--audio-fallback", "aac",
|
|
"--all-subtitles",
|
|
]
|
|
|
|
print()
|
|
print("─" * 40)
|
|
print("Ready to rip")
|
|
print("─" * 40)
|
|
print(f" Title: #{num} {fmt_duration(t['duration'])} {t['video']}")
|
|
print(f" Movie: {full_name}")
|
|
print(f" Output: {out_file}")
|
|
print(f" Audio: {audio_langs} (all matching tracks, pass-through → AAC fallback)")
|
|
print(f" Subs: all tracks ({len(t['subs'])} found on this title)")
|
|
cmd_display = (
|
|
f"HandBrakeCLI \\\n"
|
|
f" -i {DEVICE} -t {num} \\\n"
|
|
f" -o \"{out_file}\" \\\n"
|
|
f" --preset=\"H.265 MKV 1080p30\" \\\n"
|
|
f" --audio-lang-list {audio_langs} --all-audio --aencoder copy --audio-fallback aac \\\n"
|
|
f" --all-subtitles"
|
|
)
|
|
print()
|
|
print(" Command:")
|
|
print(" " + cmd_display)
|
|
print()
|
|
|
|
try:
|
|
answer = input("Begin ripping? [Y/n]: ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nAborted.")
|
|
sys.exit(0)
|
|
|
|
if answer in ("", "y", "yes"):
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
print()
|
|
subprocess.run(cmd)
|
|
else:
|
|
print("Aborted.")
|