2026-10-10 18:19:14 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""Lädt die Untertitel eines YouTube-Videos als VTT-Datei herunter (über yt-dlp),
|
|
|
|
|
|
als Eingabe für vtt2txt.py bzw. rede_korrigieren.py."""
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import json
|
2026-10-10 21:03:14 +02:00
|
|
|
|
import re
|
2026-10-10 18:19:14 +02:00
|
|
|
|
import shutil
|
|
|
|
|
|
import subprocess
|
2026-10-10 20:12:08 +02:00
|
|
|
|
import tempfile
|
2026-10-10 18:19:14 +02:00
|
|
|
|
|
|
|
|
|
|
YT_DLP = "yt-dlp"
|
|
|
|
|
|
|
2026-10-10 21:03:14 +02:00
|
|
|
|
NAME_WORD = re.compile(r"\w+(?:[-.]\w+)*") # keeps "Krone-Schmalz", "28.03.2026"
|
|
|
|
|
|
MAX_CHANNEL_CHARS = 40
|
|
|
|
|
|
MAX_TITLE_CHARS = 60
|
|
|
|
|
|
|
2026-10-10 18:19:14 +02:00
|
|
|
|
|
2026-10-10 20:12:08 +02:00
|
|
|
|
def run_yt_dlp(arguments):
|
|
|
|
|
|
"""Run yt-dlp in a temporary working directory.
|
|
|
|
|
|
|
|
|
|
|
|
yt-dlp calls "ffmpeg -bsfs" to check ffmpeg; some ffmpeg builds (Ubuntu
|
|
|
|
|
|
6.1.1) then leave an empty file "3" in the working directory.
|
|
|
|
|
|
"""
|
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
|
|
return subprocess.run(
|
|
|
|
|
|
[YT_DLP, *arguments], capture_output=True, text=True, cwd=tmp
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 18:19:14 +02:00
|
|
|
|
def fetch_info(url):
|
|
|
|
|
|
"""Return the video metadata from yt-dlp, without downloading anything."""
|
2026-10-10 20:12:08 +02:00
|
|
|
|
result = run_yt_dlp(["--dump-single-json", "--skip-download", "--no-playlist", url])
|
2026-10-10 18:19:14 +02:00
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
|
raise SystemExit(f"yt-dlp konnte das Video nicht abrufen:\n{result.stderr.strip()}")
|
|
|
|
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 20:28:05 +02:00
|
|
|
|
def base_language(code):
|
|
|
|
|
|
""""en-US" -> "en", "de-orig" -> "de"."""
|
|
|
|
|
|
return code.removesuffix("-orig").split("-")[0].lower()
|
2026-10-10 18:19:14 +02:00
|
|
|
|
|
2026-10-10 20:28:05 +02:00
|
|
|
|
|
|
|
|
|
|
def original_language(info):
|
|
|
|
|
|
"""Language of the video's original audio, or None if unknown.
|
|
|
|
|
|
|
|
|
|
|
|
Not simply the first "<lang>-orig" caption track: videos with AI-dubbed
|
|
|
|
|
|
audio tracks have such a track for every dubbed language.
|
2026-10-10 18:19:14 +02:00
|
|
|
|
"""
|
2026-10-10 20:28:05 +02:00
|
|
|
|
if info.get("language"):
|
|
|
|
|
|
return info["language"]
|
|
|
|
|
|
for fmt in info.get("formats") or []:
|
|
|
|
|
|
if "original" in (fmt.get("format_note") or "") and fmt.get("language"):
|
|
|
|
|
|
return fmt["language"]
|
|
|
|
|
|
originals = [n for n in info.get("automatic_captions") or {} if n.endswith("-orig")]
|
|
|
|
|
|
return originals[0].removesuffix("-orig") if len(originals) == 1 else None
|
2026-10-10 18:19:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 20:28:05 +02:00
|
|
|
|
def choose_track(info, lang):
|
|
|
|
|
|
"""Pick the subtitle track for `lang`: (track name, is_auto) or None.
|
2026-10-10 19:51:32 +02:00
|
|
|
|
|
2026-10-10 20:28:05 +02:00
|
|
|
|
Manual subtitles come first, also with a region ("en" finds "en-US").
|
|
|
|
|
|
Automatic captions are used only in the language of the original audio
|
|
|
|
|
|
("<lang>-orig"): plain "<lang>" is a machine translation, and "-orig"
|
|
|
|
|
|
tracks of other languages belong to AI-dubbed audio tracks.
|
2026-10-10 19:51:32 +02:00
|
|
|
|
"""
|
2026-10-10 20:28:05 +02:00
|
|
|
|
manual = info.get("subtitles") or {}
|
|
|
|
|
|
if lang in manual:
|
|
|
|
|
|
return lang, False
|
|
|
|
|
|
same_language = sorted(n for n in manual if base_language(n) == base_language(lang))
|
|
|
|
|
|
if same_language:
|
|
|
|
|
|
return same_language[0], False
|
|
|
|
|
|
|
|
|
|
|
|
original = original_language(info)
|
|
|
|
|
|
if original and base_language(original) != base_language(lang):
|
|
|
|
|
|
return None
|
|
|
|
|
|
auto = info.get("automatic_captions") or {}
|
|
|
|
|
|
candidates = [f"{lang}-orig", f"{base_language(lang)}-orig"] + sorted(
|
|
|
|
|
|
n for n in auto
|
|
|
|
|
|
if n.endswith("-orig") and base_language(n) == base_language(lang)
|
|
|
|
|
|
)
|
|
|
|
|
|
for name in candidates:
|
|
|
|
|
|
if name in auto:
|
|
|
|
|
|
return name, True
|
|
|
|
|
|
return None
|
2026-10-10 19:51:32 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 21:03:14 +02:00
|
|
|
|
def name_part(text, limit):
|
|
|
|
|
|
"""Words of `text` joined by "_", cut at a word boundary after `limit` chars."""
|
|
|
|
|
|
words = NAME_WORD.findall(text or "")
|
|
|
|
|
|
part = ""
|
|
|
|
|
|
for word in words:
|
|
|
|
|
|
candidate = f"{part}_{word}" if part else word
|
|
|
|
|
|
if len(candidate) > limit:
|
|
|
|
|
|
break
|
|
|
|
|
|
part = candidate
|
|
|
|
|
|
# A single overlong word is cut rather than dropped
|
|
|
|
|
|
return part or (words[0][:limit] if words else "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def default_name(info):
|
|
|
|
|
|
"""File name without extension from channel, title and upload date.
|
|
|
|
|
|
|
|
|
|
|
|
"John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30"
|
|
|
|
|
|
"""
|
|
|
|
|
|
date = info.get("upload_date") or ""
|
|
|
|
|
|
parts = [
|
|
|
|
|
|
name_part(info.get("channel") or info.get("uploader"), MAX_CHANNEL_CHARS),
|
|
|
|
|
|
name_part(info.get("title"), MAX_TITLE_CHARS),
|
|
|
|
|
|
f"{date[:4]}-{date[4:6]}-{date[6:]}" if re.fullmatch(r"\d{8}", date) else "",
|
|
|
|
|
|
]
|
|
|
|
|
|
return "_".join(p for p in parts if p) or info.get("id") or "video"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 18:19:14 +02:00
|
|
|
|
def available_tracks(info):
|
2026-10-10 20:28:05 +02:00
|
|
|
|
"""Manual tracks and the automatic ones usable for this program."""
|
2026-10-10 18:19:14 +02:00
|
|
|
|
manual = sorted((info.get("subtitles") or {}).keys())
|
2026-10-10 20:28:05 +02:00
|
|
|
|
original = original_language(info)
|
|
|
|
|
|
automatic = sorted(
|
2026-10-10 18:19:14 +02:00
|
|
|
|
name.removesuffix("-orig")
|
|
|
|
|
|
for name in (info.get("automatic_captions") or {})
|
|
|
|
|
|
if name.endswith("-orig")
|
2026-10-10 20:28:05 +02:00
|
|
|
|
and (original is None or base_language(name) == base_language(original))
|
2026-10-10 18:19:14 +02:00
|
|
|
|
)
|
2026-10-10 20:28:05 +02:00
|
|
|
|
return manual, automatic
|
2026-10-10 18:19:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 19:52:20 +02:00
|
|
|
|
def no_track_message(info, lang):
|
|
|
|
|
|
"""Explain that no usable track exists for `lang` (None: original language)."""
|
|
|
|
|
|
manual, original = available_tracks(info)
|
|
|
|
|
|
wanted = f"„{lang}“" if lang else "der Originalsprache"
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"Keine Untertitel in {wanted} (weder manuell noch automatisch in "
|
|
|
|
|
|
f"Originalsprache).\n"
|
|
|
|
|
|
f"Manuell: {', '.join(manual) or '–'}\n"
|
|
|
|
|
|
f"Automatisch (Originalsprache): {', '.join(original) or '–'}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 18:19:14 +02:00
|
|
|
|
def download(url, track, is_auto, target_base):
|
|
|
|
|
|
"""Download one subtitle track; yt-dlp writes <target_base>.<track>.vtt."""
|
2026-10-10 20:12:08 +02:00
|
|
|
|
result = run_yt_dlp([
|
|
|
|
|
|
"--skip-download", "--no-playlist", "--force-overwrites",
|
2026-10-10 18:19:14 +02:00
|
|
|
|
"--write-auto-subs" if is_auto else "--write-subs",
|
|
|
|
|
|
"--sub-langs", track, "--sub-format", "vtt",
|
2026-10-10 20:12:08 +02:00
|
|
|
|
# Absolute, because yt-dlp runs in a temporary directory
|
|
|
|
|
|
"-o", f"{Path(target_base).resolve()}.%(ext)s",
|
2026-10-10 18:19:14 +02:00
|
|
|
|
url,
|
2026-10-10 20:12:08 +02:00
|
|
|
|
])
|
2026-10-10 18:19:14 +02:00
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
|
raise SystemExit(f"Download fehlgeschlagen:\n{result.stderr.strip()}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args():
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description="Lädt die Untertitel eines YouTube-Videos als VTT-Datei herunter."
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument("-u", "--url", required=True, help="YouTube-URL des Videos")
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"-n", "--name",
|
2026-10-10 21:03:14 +02:00
|
|
|
|
help="Dateiname ohne Endung, z. B. Lahnstein (Standard: <Kanal>_<Titel>_<Datum>)",
|
2026-10-10 18:19:14 +02:00
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"-l", "--lang", default="de", help="Sprache der Untertitel (Standard: de)"
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"-d", "--dir", type=Path, default=Path("."),
|
|
|
|
|
|
help="Zielverzeichnis (Standard: aktuelles Verzeichnis)",
|
|
|
|
|
|
)
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
if args.name is not None and (not args.name or Path(args.name).name != args.name):
|
|
|
|
|
|
parser.error("-n/--name ist ein Dateiname ohne Verzeichnis; dafür gibt es -d/--dir.")
|
|
|
|
|
|
if shutil.which(YT_DLP) is None:
|
|
|
|
|
|
parser.error(f"{YT_DLP} nicht gefunden (Installation: pip install yt-dlp).")
|
|
|
|
|
|
return args
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
args = parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
info = fetch_info(args.url)
|
|
|
|
|
|
print(f"Video: {info.get('title', '?')}")
|
|
|
|
|
|
|
|
|
|
|
|
chosen = choose_track(info, args.lang)
|
|
|
|
|
|
if chosen is None:
|
2026-10-10 19:52:20 +02:00
|
|
|
|
raise SystemExit(no_track_message(info, args.lang))
|
2026-10-10 18:19:14 +02:00
|
|
|
|
track, is_auto = chosen
|
|
|
|
|
|
kind = "automatische" if is_auto else "manuelle"
|
|
|
|
|
|
print(f"Lade {kind} Untertitel „{track}“ …", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
args.dir.mkdir(parents=True, exist_ok=True)
|
2026-10-10 21:03:14 +02:00
|
|
|
|
target_base = args.dir / (args.name or default_name(info))
|
2026-10-10 18:19:14 +02:00
|
|
|
|
download(args.url, track, is_auto, target_base)
|
|
|
|
|
|
|
|
|
|
|
|
target = Path(f"{target_base}.{track}.vtt")
|
|
|
|
|
|
if not target.is_file():
|
|
|
|
|
|
raise SystemExit(f"yt-dlp hat keine Datei {target} geschrieben.")
|
|
|
|
|
|
print(f"Gespeichert: {target}")
|
|
|
|
|
|
print(f"Weiter mit: ./vtt2txt.py -i {target}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|