2026-09-26 14:12:13 +02:00
|
|
|
#!/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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
def scan_dvd(dvd_name: Path):
|
2026-09-26 14:12:13 +02:00
|
|
|
print("Scanning DVD")
|
2026-09-30 17:20:39 +02:00
|
|
|
print(dvd_name)
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
result = subprocess.run(
|
2026-09-30 17:20:39 +02:00
|
|
|
["lsdvd", dvd_name],
|
2026-09-26 14:12:13 +02:00
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
text=True,
|
2026-09-30 17:20:39 +02:00
|
|
|
check=True,
|
2026-09-26 14:12:13 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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))
|
2026-09-30 17:20:39 +02:00
|
|
|
|
2026-09-26 14:12:13 +02:00
|
|
|
hours = int(match.group(2))
|
|
|
|
|
minutes = int(match.group(3))
|
|
|
|
|
seconds = float(match.group(4))
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
duration = (
|
2026-09-26 14:12:13 +02:00
|
|
|
hours * 3600
|
|
|
|
|
+ minutes * 60
|
|
|
|
|
+ seconds
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
titles.append({
|
|
|
|
|
"title": title,
|
2026-09-30 17:20:39 +02:00
|
|
|
"duration": duration,
|
2026-09-26 14:12:13 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if not titles:
|
2026-09-30 17:20:39 +02:00
|
|
|
raise RuntimeError("lsdvd found no DVD titles")
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
|
|
|
|
|
return titles
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
})
|
2026-09-30 17:20:39 +02:00
|
|
|
if not tracks:
|
|
|
|
|
tracks.append({
|
|
|
|
|
"number": 0,
|
|
|
|
|
"description": "",
|
|
|
|
|
})
|
2026-09-26 14:12:13 +02:00
|
|
|
return tracks
|
|
|
|
|
|
|
|
|
|
def choose_audio_tracks(tracks):
|
|
|
|
|
"""
|
|
|
|
|
Select German first, English second.
|
|
|
|
|
|
|
|
|
|
Returns a list of HandBrake track numbers.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
german = None
|
|
|
|
|
english = None
|
2026-09-30 17:20:39 +02:00
|
|
|
unkown = None
|
2026-09-26 14:12:13 +02:00
|
|
|
for track in tracks:
|
|
|
|
|
text = track["description"].lower()
|
2026-09-30 17:20:39 +02:00
|
|
|
if not text:
|
|
|
|
|
track["description"] = "Unknown"
|
|
|
|
|
unkown = track["number"]
|
2026-09-26 14:12:13 +02:00
|
|
|
# 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)
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
if english is None and german is None:
|
|
|
|
|
selected.append(unkown)
|
|
|
|
|
|
2026-09-26 14:12:13 +02:00
|
|
|
return selected
|
2026-09-30 17:20:39 +02:00
|
|
|
|
|
|
|
|
def find_episode_count(dvd_name: Path, max_dvd_length: int=70):
|
2026-09-26 14:12:13 +02:00
|
|
|
"""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.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
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"]
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
# 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.
|
2026-09-30 17:20:39 +02:00
|
|
|
if 15 * 60 <= duration <= max_dvd_length * 60:
|
2026-09-26 14:12:13 +02:00
|
|
|
episode_titles.append(title)
|
|
|
|
|
else:
|
2026-09-30 17:20:39 +02:00
|
|
|
continue
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
if not episode_titles:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Could not find episodes after combined title "
|
2026-09-30 17:20:39 +02:00
|
|
|
f"{first_episode}"
|
2026-09-26 14:12:13 +02:00
|
|
|
)
|
2026-09-30 17:20:39 +02:00
|
|
|
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")
|
2026-09-26 14:12:13 +02:00
|
|
|
print(f"Found {len(episode_titles)} episodes:")
|
|
|
|
|
print(f" Titles: {episode_titles}")
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
return first_episode, len(episode_titles), episode_titles
|
2026-09-26 14:12:13 +02:00
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
def process_dvd(dvd_source: Path,dvd_title: str,num_of_episodes: int, first_episode: int, args):
|
2026-09-26 14:12:13 +02:00
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
output_dir = Path("~/Downloads").expanduser() / dvd_source.parent.name
|
2026-09-26 14:12:13 +02:00
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
titles = scan_dvd(dvd_source)
|
|
|
|
|
last_episode = first_episode + num_of_episodes
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
available_titles = {t["title"] for t in titles}
|
|
|
|
|
|
|
|
|
|
missing = [
|
|
|
|
|
title
|
2026-09-30 17:20:39 +02:00
|
|
|
for title in range(first_episode, last_episode )
|
2026-09-26 14:12:13 +02:00
|
|
|
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:")
|
2026-09-30 17:20:39 +02:00
|
|
|
for number in range(1, num_of_episodes + 1):
|
2026-09-26 14:12:13 +02:00
|
|
|
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()
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
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
|
2026-09-26 14:12:13 +02:00
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
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))
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
if not audio_tracks:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"No German or English audio track found for title {title}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
print("=" * 50)
|
2026-09-30 17:20:39 +02:00
|
|
|
print(f"Episode {episode-first_episode +1} / {num_of_episodes}")
|
2026-09-26 14:12:13 +02:00
|
|
|
print(f"DVD title: {title}")
|
|
|
|
|
print(f"Output: {output_file}")
|
|
|
|
|
print("=" * 50)
|
|
|
|
|
|
|
|
|
|
command = [
|
|
|
|
|
"HandBrakeCLI",
|
2026-09-30 17:20:39 +02:00
|
|
|
"-i", dvd_source,
|
2026-09-26 14:12:13 +02:00
|
|
|
"-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)",
|
|
|
|
|
)
|
2026-09-30 17:20:39 +02:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--skip-episodes",
|
|
|
|
|
type=int,nargs="+",
|
|
|
|
|
default=[],
|
|
|
|
|
help="List of episodes to skip",
|
|
|
|
|
)
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
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:
|
2026-09-30 17:20:39 +02:00
|
|
|
dvd_source = video_ts
|
2026-09-26 14:12:13 +02:00
|
|
|
|
|
|
|
|
# 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}")
|
2026-09-30 17:20:39 +02:00
|
|
|
first_episode, episode_count, episode_titles = find_episode_count(dvd_source)
|
2026-09-26 14:12:13 +02:00
|
|
|
process_dvd(
|
|
|
|
|
dvd_source,
|
|
|
|
|
dvd_title,
|
|
|
|
|
episode_count,
|
2026-09-30 17:20:39 +02:00
|
|
|
first_episode,
|
|
|
|
|
args
|
2026-09-26 14:12:13 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 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}")
|
2026-09-30 17:20:39 +02:00
|
|
|
first_episode, episode_count, episode_titles = find_episode_count(dvd_source)
|
2026-09-26 14:12:13 +02:00
|
|
|
process_dvd(
|
|
|
|
|
dvd_source,
|
|
|
|
|
dvd_title,
|
|
|
|
|
episode_count,
|
2026-09-30 17:20:39 +02:00
|
|
|
first_episode,
|
|
|
|
|
args
|
2026-09-26 14:12:13 +02:00
|
|
|
)
|
|
|
|
|
|
2026-09-30 17:20:39 +02:00
|
|
|
|
2026-09-26 14:12:13 +02:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|