Files
Server-scripts/media handling/extract_dvds.py
T

392 lines
9.8 KiB
Python
Raw Normal View History

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()