#!/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.")