From 0d059bc1c0178663bcca34b515a2e2411df290cd Mon Sep 17 00:00:00 2001 From: Lucas Faber Date: Sat, 26 Sep 2026 14:12:13 +0200 Subject: [PATCH] first version of old dvd extraction script --- media handling/extract_dvds.py | 459 +++++++++++++++++++++++++++++++++ 1 file changed, 459 insertions(+) diff --git a/media handling/extract_dvds.py b/media handling/extract_dvds.py index e69de29..b233b86 100644 --- a/media handling/extract_dvds.py +++ b/media handling/extract_dvds.py @@ -0,0 +1,459 @@ +#!/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): + print("=" * 50) + print("Scanning DVD") + print("=" * 50) + print(dvd) + print() + + result = subprocess.run( + ["lsdvd", dvd], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + output = result.stdout + print(output) + + # Example: + # Title: 07, Length: 01:50:34.560 Chapters: 17, ... + 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)) + + total_seconds = ( + hours * 3600 + + minutes * 60 + + seconds + ) + + titles.append({ + "title": title, + "duration": total_seconds, + "duration_string": ( + f"{hours:02d}:{minutes:02d}:{seconds:06.3f}" + ), + }) + + if not titles: + print("ERROR: No DVD titles found.", file=sys.stderr) + sys.exit(1) + + # Longest title is assumed to be the combined episode track. + combined = max(titles, key=lambda x: x["duration"]) + + return combined, 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, + }) + + return tracks + + +def choose_audio_tracks(tracks): + """ + Select German first, English second. + + Returns a list of HandBrake track numbers. + """ + + german = None + english = None + + for track in tracks: + text = track["description"].lower() + + # 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) + + return selected +def find_episode_count(dvd): + """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. + """ + + result = subprocess.run( + ["lsdvd", dvd], + 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") + + # Longest title = combined episode track + combined = max(titles, key=lambda x: x["duration"]) + + combined_title = combined["title"] + combined_duration = combined["duration"] + + # 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 <= 40 * 60: + episode_titles.append(title) + else: + break + + if not episode_titles: + raise RuntimeError( + f"Could not find episodes after combined title " + f"{combined_title}" + ) + + print(f"Combined title: {combined_title}") + print(f"Combined duration: {combined_duration:.1f}s") + print(f"Found {len(episode_titles)} episodes:") + print(f" Titles: {episode_titles}") + + return len(episode_titles), episode_titles + +def process_dvd(): + dvd_path = Path(dvd) + + dvd_name = dvd_path.name + parent = dvd_path.parent + output_dir = dvd.parent / f"{dvd.parent.name} - MP4" + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / f"{dvd_title} - {episode:02d}.mp4" + output_file = output_dir / f"{dvd_title} - {episode:02d}.mp4" + + if output_file.exists(): + size_mb = output_file.stat().st_size / (1024 * 1024) + print(f"{output_file} ({size_mb:.1f} MB)") + combined, titles = scan_dvd(dvd) + + combined_title = combined["title"] + + print("=" * 50) + print("Combined title") + print("=" * 50) + print(f"Title: {combined_title}") + print(f"Duration: {combined['duration_string']}") + print() + + first_episode = combined_title + 1 + last_episode = combined_title + args.episodes + + available_titles = {t["title"] for t in titles} + + missing = [ + title + for title in range(first_episode, last_episode + 1) + 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, args.episodes + 1): + title = first_episode + number - 1 + print(f" Episode {number}: DVD title {title}") + + print() + + # Output directory: + # + # Pippi L... DVD - 1/ + # Pippi L... DVD - 1 - MP4/ + dvd_name = os.path.basename(os.path.normpath(dvd)) + parent = os.path.dirname(dvd) + + output_dir = os.path.join( + parent, + f"{dvd_name} - MP4", + ) + + os.makedirs(output_dir, exist_ok=True) + + print("Output directory:") + print(f" {output_dir}") + print() + + for episode in range(1, args.episodes + 1): + title = first_episode + episode - 1 + + output_file = os.path.join( + output_dir, + f"Pippi Langstrumpf - {episode:02d}.mp4", + ) + audio_tracks = choose_audio_tracks(get_audio_tracks(dvd, title)) + + if not audio_tracks: + raise RuntimeError( + f"No German or English audio track found for title {title}" + ) + + + print("=" * 50) + print(f"Episode {episode} / {args.episodes}") + print(f"DVD title: {title}") + print(f"Output: {output_file}") + print("=" * 50) + + command = [ + "HandBrakeCLI", + "-i", dvd, + "-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) + + for episode in range(1, args.episodes + 1): + output_file = os.path.join( + output_dir, + f"Pippi Langstrumpf - {episode:02d}.mp4", + ) + + if os.path.exists(output_file): + size_mb = os.path.getsize(output_file) / (1024 * 1024) + print(f"{output_file} ({size_mb:.1f} MB)") + + + +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)", + ) + + args = parser.parse_args() + + dvd_name = os.path.abspath(args.dvd) + + if args.episodes < 1: + parser.error("Number of episodes must be a positive integer.") + + + 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 = str(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}") + episode_count, episode_titles = find_episode_count(dvd_name) + process_dvd( + dvd_source, + dvd_title, + episode_count, + ) + + # 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}") + episode_count, episode_titles = find_episode_count(dvd_name) + process_dvd( + dvd_source, + dvd_title, + episode_count, + ) + +if __name__ == "__main__": + main() \ No newline at end of file