#!/usr/bin/env python3 import argparse import os import re import shutil import subprocess import sys from pathlib import Path def check_command(command): if shutil.which(command) is None: print(f"ERROR: '{command}' is not installed.", file=sys.stderr) sys.exit(1) def scan_dvd(dvd_name: Path): print("Scanning DVD") print(dvd_name) result = subprocess.run( ["lsdvd", dvd_name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=True, ) output = result.stdout pattern = re.compile( r"Title:\s*(\d+),\s*Length:\s*(\d+):(\d+):(\d+(?:\.\d+)?)" ) titles = [] for match in pattern.finditer(output): title = int(match.group(1)) hours = int(match.group(2)) minutes = int(match.group(3)) seconds = float(match.group(4)) duration = ( hours * 3600 + minutes * 60 + seconds ) titles.append({ "title": title, "duration": duration, }) if not titles: raise RuntimeError("lsdvd found no DVD titles") return titles def get_audio_tracks(dvd, title): """Return HandBrake audio track information for a DVD title.""" result = subprocess.run( [ "HandBrakeCLI", "-i", dvd, "-t", str(title), "--scan", ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=True, ) output = result.stdout # Examples HandBrake may produce: # # + 1, Deutsch (AC3, 2.0 ch, 192 kbps) # + 2, English (AC3, 2.0 ch, 224 kbps) # # Also handles German/Englisch/deu/eng where present. tracks = [] pattern = re.compile( r"\+\s+(\d+),\s+(.+?)\s+\((?:AC3|AAC|MP3|E-AC-3|DTS)" ) for match in pattern.finditer(output): track_number = int(match.group(1)) description = match.group(2).strip() tracks.append({ "number": track_number, "description": description, }) if not tracks: tracks.append({ "number": 0, "description": "", }) return tracks def choose_audio_tracks(tracks): """ Select German first, English second. Returns a list of HandBrake track numbers. """ german = None english = None unkown = None for track in tracks: text = track["description"].lower() if not text: track["description"] = "Unknown" unkown = track["number"] # German if any(x in text for x in ( "german", "deutsch", "deu", )): german = track["number"] # English if any(x in text for x in ( "english", "englisch", "eng", )): english = track["number"] selected = [] # German FIRST -> becomes default audio if german is not None: selected.append(german) # English SECOND if english is not None and english != german: selected.append(english) if english is None and german is None: selected.append(unkown) return selected def find_episode_count(dvd_name: Path, max_dvd_length: int=70): """Find the number of episodes using lsdvd. Assumes: - The longest title is a combined track containing all episodes. - Individual episode titles immediately follow the longest title. - Episode titles have approximately similar durations. """ titles = scan_dvd(dvd_name) sorted_items = sorted(titles, key=lambda x: x["duration"], reverse=True) combined_title = 0 if sorted_items[0]["duration"] > sorted_items[1]["duration"] * 2: # Longest title = combined episode track combined = max(titles, key=lambda x: x["duration"]) combined_title = combined["title"] # Episode titles must immediately follow the combined title. title_map = { t["title"]: t["duration"] for t in titles } first_episode = combined_title + 1 episode_titles = [] for title in range(first_episode, max(title_map) + 1): if title not in title_map: break duration = title_map[title] # Typical episode is ~20-35 minutes. # Adjust these limits if your DVDs contain longer/shorter episodes. if 15 * 60 <= duration <= max_dvd_length * 60: episode_titles.append(title) else: continue if not episode_titles: raise RuntimeError( f"Could not find episodes after combined title " f"{first_episode}" ) first_episode = episode_titles[0] if combined_title > 0: print(f"Combined title: {titles[combined_title]["title"]}") print(f"Combined duration: {titles[combined_title]["duration"]:.1f}s") print(f"Found {len(episode_titles)} episodes:") print(f" Titles: {episode_titles}") return first_episode, len(episode_titles), episode_titles def process_dvd(dvd_source: Path,dvd_title: str,num_of_episodes: int, first_episode: int, args): output_dir = Path("~/Downloads").expanduser() / dvd_source.parent.name output_dir.mkdir(parents=True, exist_ok=True) titles = scan_dvd(dvd_source) last_episode = first_episode + num_of_episodes available_titles = {t["title"] for t in titles} missing = [ title for title in range(first_episode, last_episode ) if title not in available_titles ] if missing: print( "ERROR: Expected episode titles were not found:", ", ".join(map(str, missing)), file=sys.stderr, ) sys.exit(1) print("Episodes to extract:") for number in range(1, num_of_episodes + 1): title = first_episode + number - 1 print(f" Episode {number}: DVD title {title}") print() os.makedirs(output_dir, exist_ok=True) print("Output directory:") print(f" {output_dir}") print() for episode in range(first_episode, first_episode + num_of_episodes ): title = episode title_from_zero = episode - first_episode +1 if title_from_zero in args.skip_episodes: continue output_file = output_dir / f"{dvd_title} - {title_from_zero:02d}.mp4" if output_file.exists(): size_mb = output_file.stat().st_size / (1024 * 1024) print(f"{output_file} ({size_mb:.1f} MB)") audio_tracks = choose_audio_tracks(get_audio_tracks(dvd_source, title)) if not audio_tracks: raise RuntimeError( f"No German or English audio track found for title {title}" ) print("=" * 50) print(f"Episode {episode-first_episode +1} / {num_of_episodes}") print(f"DVD title: {title}") print(f"Output: {output_file}") print("=" * 50) command = [ "HandBrakeCLI", "-i", dvd_source, "-t", str(title), "-o", output_file, "-e", "x264", "-q", "18", "-a", ",".join(map(str, audio_tracks)), "-E", "copy:ac3", ] with open("handbrake.log", "w") as logfile: try: subprocess.run( command, stdout=logfile, stderr=subprocess.STDOUT, text=True, check=True, ) except subprocess.CalledProcessError as exc: print( f"ERROR: HandBrakeCLI failed for episode {episode}.", file=sys.stderr, ) sys.exit(exc.returncode) print() print("=" * 50) print("Finished!") print("=" * 50) def main(): parser = argparse.ArgumentParser( description="Extract episodes from a DVD using lsdvd and HandBrakeCLI." ) parser.add_argument( "dvd", help="Path to DVD directory containing VIDEO_TS", ) parser.add_argument( "--episodes", type=int, default=0, help="Number of episodes to extract (0 = auto-detect)", ) parser.add_argument( "--skip-episodes", type=int,nargs="+", default=[], help="List of episodes to skip", ) args = parser.parse_args() dvd_name = os.path.abspath(args.dvd) check_command("lsdvd") check_command("HandBrakeCLI") root = Path(dvd_name).resolve() check_command("lsdvd") check_command("HandBrakeCLI") # Find all VIDEO_TS directories video_ts_dirs = [ p for p in root.rglob("VIDEO_TS") if p.is_dir() ] # Find all ISO files iso_files = [ p for p in root.rglob("*") if p.is_file() and p.suffix.lower() == ".iso" ] # Process VIDEO_TS directories for video_ts in video_ts_dirs: dvd_source = video_ts # Parent of VIDEO_TS, e.g. # /movies/Pippi DVD 1/VIDEO_TS # -> Pippi DVD 1 dvd_title = video_ts.parent.name print(f"Found DVD: {dvd_title}") print(f" Source: {dvd_source}") first_episode, episode_count, episode_titles = find_episode_count(dvd_source) process_dvd( dvd_source, dvd_title, episode_count, first_episode, args ) # Process ISO files for iso in iso_files: dvd_source = str(iso) # Filename without .iso dvd_title = iso.stem print(f"Found ISO: {dvd_title}") print(f" Source: {dvd_source}") first_episode, episode_count, episode_titles = find_episode_count(dvd_source) process_dvd( dvd_source, dvd_title, episode_count, first_episode, args ) if __name__ == "__main__": main()