Files
smithpc/disc-scan.py
2026-06-29 05:04:05 -05:00

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.")