diff --git a/README.md b/README.md index 1219376..1086f4d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Hörfehler, Absätze – möglichst wortgetreu, ohne Zusammenfassung oder Glätt ## Voraussetzungen -- Python 3 (nur Standardbibliothek) +- Python 3.9+ (nur Standardbibliothek) +- für den Download: [yt-dlp](https://github.com/yt-dlp/yt-dlp) (`pip install yt-dlp`) - Ollama auf `127.0.0.1:11434` mit dem Modell `qwen3:32b` (`ollama pull qwen3:32b`) @@ -17,6 +18,40 @@ der Lauf wird etwa 12-mal langsamer. ## Aufruf +Ablauf von der YouTube-URL zum korrigierten Text: + +```bash +./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten +./vtt2txt.py -i testdaten/Lahnstein.de-orig.vtt +./rede_korrigieren.py -i testdaten/Lahnstein.de-orig.txt +``` + +### Untertitel herunterladen: `yt_untertitel.py` + +Lädt die Untertitel eines YouTube-Videos über `yt-dlp` als VTT-Datei, ohne das +Video selbst. Gewählt wird: + +1. manuelle Untertitel in der gewünschten Sprache, sonst +2. automatische Untertitel in der Originalsprache des Videos (`de-orig`). + +Automatische Untertitel ohne `-orig` sind Maschinenübersetzungen aus einer anderen +Sprache und werden nie genommen. Gibt es keine passende Spur, nennt das Skript die +vorhandenen Sprachen. + +```bash +./yt_untertitel.py -u URL # → .de-orig.vtt +./yt_untertitel.py -u URL -n Lahnstein -d testdaten # → testdaten/Lahnstein.de-orig.vtt +``` + +| Option | Bedeutung | +|---|---| +| `-u`, `--url` | YouTube-URL, Pflicht | +| `-n`, `--name` | Dateiname ohne Endung; Standard: Video-ID | +| `-l`, `--lang` | Sprache; Standard: `de` | +| `-d`, `--dir` | Zielverzeichnis (wird angelegt); Standard: aktuelles Verzeichnis | + +Eine vorhandene Datei gleichen Namens wird überschrieben. + ### Untertitel umwandeln: `vtt2txt.py` Macht aus VTT-Untertiteln (z. B. von `yt-dlp --write-auto-subs`) die Rohabschrift @@ -90,12 +125,14 @@ Ersetzungen ohne Treffer werden gemeldet (Tippfehler im Glossar). ## Testdaten -`testdaten/` enthält zwei Reden, jeweils als automatische YouTube-Untertitel +`testdaten/` enthält drei Reden, jeweils als automatische YouTube-Untertitel (`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe für das Programm): - `ramstein.de.*`: Rede über die US Air Base Ramstein (mit Glossar `ramstein.glossar.txt`) - `magdeburg.de-orig.*`: Rede in Magdeburg (mit Glossar `magdeburg.glossar.txt`) +- `Lahnstein.de-orig.*`: Rede „Respekt geht anders“ in Lahnstein (noch ohne Glossar, + geladen mit `yt_untertitel.py`) ```bash ./vtt2txt.py -i "testdaten/*.vtt" # erzeugt alle .txt neu @@ -115,6 +152,8 @@ Die Tests brauchen kein Ollama: Modellaufrufe werden durch Fakes ersetzt. Geprü werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in den Beispiel-Glossaren trifft ihre Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile. +`tests/test_yt_untertitel.py` prüft Spurauswahl, Aufruf von `yt-dlp` und +Fehlerfälle (ohne Netz, `yt-dlp` wird durch Fakes ersetzt). `tests/test_vtt2txt.py` prüft die VTT-Umwandlung: jede `.vtt` in `testdaten/` ergibt genau die zugehörige `.txt`, echte Wiederholungen bleiben erhalten, dazu Muster, Ausgabeverzeichnis und Fehlerfälle. diff --git a/tests/test_vtt2txt.py b/tests/test_vtt2txt.py index 6dbdc38..51ab3d9 100644 --- a/tests/test_vtt2txt.py +++ b/tests/test_vtt2txt.py @@ -82,7 +82,7 @@ def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys): assert f"Erstellt: {target}" in capsys.readouterr().out -@pytest.mark.parametrize("name", ["ramstein.de", "magdeburg.de-orig"]) +@pytest.mark.parametrize("name", ["ramstein.de", "magdeburg.de-orig", "Lahnstein.de-orig"]) def test_sample_vtt_gives_sample_txt(name): vtt = (REPO / "testdaten" / f"{name}.vtt").read_text(encoding="utf-8-sig") txt = (REPO / "testdaten" / f"{name}.txt").read_text(encoding="utf-8") diff --git a/tests/test_yt_untertitel.py b/tests/test_yt_untertitel.py new file mode 100644 index 0000000..2f9c2a3 --- /dev/null +++ b/tests/test_yt_untertitel.py @@ -0,0 +1,138 @@ +"""Tests for yt_untertitel.py; yt-dlp is always replaced by fakes (no network).""" +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +import yt_untertitel as yt + +URL = "https://www.youtube.com/watch?v=abc123" + +AUTO_ONLY = { + "id": "abc123", + "title": "Eine Rede", + "subtitles": {}, + "automatic_captions": {"de-orig": [], "de": [], "en": []}, +} + + +def fake_yt_dlp(info, write=True, fail_download=False): + """Stand-in for subprocess.run: answers the info request, writes the VTT file.""" + calls = [] + + def run(command, capture_output, text): + calls.append(command) + if "--dump-single-json" in command: + return subprocess.CompletedProcess(command, 0, json.dumps(info), "") + if fail_download: + return subprocess.CompletedProcess(command, 1, "", "HTTP Error 429") + track = command[command.index("--sub-langs") + 1] + template = command[command.index("-o") + 1] + if write: + Path(template.replace("%(ext)s", f"{track}.vtt")).write_text( + "WEBVTT\n", encoding="utf-8" + ) + return subprocess.CompletedProcess(command, 0, "", "") + + run.calls = calls + return run + + +@pytest.fixture(autouse=True) +def yt_dlp_installed(monkeypatch): + monkeypatch.setattr(yt.shutil, "which", lambda name: f"/usr/bin/{name}") + + +def run_main(monkeypatch, *args): + monkeypatch.setattr(sys, "argv", ["yt_untertitel.py", *map(str, args)]) + yt.main() + + +# === Track selection === + +@pytest.mark.parametrize("info, lang, expected", [ + ({"subtitles": {"de": []}, "automatic_captions": {"de-orig": []}}, "de", ("de", False)), + (AUTO_ONLY, "de", ("de-orig", True)), + # "en" exists only as machine translation of the German original + (AUTO_ONLY, "en", None), + ({"subtitles": None, "automatic_captions": None}, "de", None), +]) +def test_choose_track(info, lang, expected): + assert yt.choose_track(info, lang) == expected + + +def test_available_tracks(): + info = {"subtitles": {"fr": []}, "automatic_captions": {"de-orig": [], "de": []}} + assert yt.available_tracks(info) == (["fr"], ["de"]) + + +# === Command line === + +def test_main_downloads_auto_original(tmp_path, monkeypatch, capsys): + run = fake_yt_dlp(AUTO_ONLY) + monkeypatch.setattr(yt.subprocess, "run", run) + run_main(monkeypatch, "-u", URL, "-n", "Rede", "-d", tmp_path / "neu") + + assert (tmp_path / "neu" / "Rede.de-orig.vtt").is_file() + download = run.calls[1] + assert "--write-auto-subs" in download and "--write-subs" not in download + assert download[-1] == URL + out = capsys.readouterr().out + assert "automatische Untertitel „de-orig“" in out + assert "vtt2txt.py -i" in out + + +def test_main_prefers_manual_and_defaults_to_video_id(tmp_path, monkeypatch): + info = dict(AUTO_ONLY, subtitles={"de": []}) + run = fake_yt_dlp(info) + monkeypatch.setattr(yt.subprocess, "run", run) + run_main(monkeypatch, "-u", URL, "-d", tmp_path) + + assert (tmp_path / "abc123.de.vtt").is_file() + assert "--write-subs" in run.calls[1] + + +def test_main_no_matching_track(tmp_path, monkeypatch): + monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(AUTO_ONLY)) + with pytest.raises(SystemExit, match="Automatisch \\(Originalsprache\\): de"): + run_main(monkeypatch, "-u", URL, "-l", "en", "-d", tmp_path) + + +def test_main_info_error(tmp_path, monkeypatch): + def run(command, capture_output, text): + return subprocess.CompletedProcess(command, 1, "", "ERROR: Video unavailable") + + monkeypatch.setattr(yt.subprocess, "run", run) + with pytest.raises(SystemExit, match="Video unavailable"): + run_main(monkeypatch, "-u", URL, "-d", tmp_path) + + +@pytest.mark.parametrize("kwargs, message", [ + ({"fail_download": True}, "HTTP Error 429"), + ({"write": False}, "keine Datei"), +]) +def test_main_download_errors(tmp_path, monkeypatch, kwargs, message): + monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(AUTO_ONLY, **kwargs)) + with pytest.raises(SystemExit, match=message): + run_main(monkeypatch, "-u", URL, "-d", tmp_path) + + +@pytest.mark.parametrize("args, message", [ + ([], "required"), + (["-u", URL, "-n", "sub/Rede"], "ohne Verzeichnis"), + (["-u", URL, "-n", ""], "ohne Verzeichnis"), +]) +def test_cli_errors(monkeypatch, capsys, args, message): + with pytest.raises(SystemExit) as exc: + run_main(monkeypatch, *args) + assert exc.value.code == 2 + assert message in capsys.readouterr().err + + +def test_cli_without_yt_dlp(monkeypatch, capsys): + monkeypatch.setattr(yt.shutil, "which", lambda name: None) + with pytest.raises(SystemExit): + run_main(monkeypatch, "-u", URL) + assert "pip install yt-dlp" in capsys.readouterr().err diff --git a/yt_untertitel.py b/yt_untertitel.py new file mode 100755 index 0000000..54f90c1 --- /dev/null +++ b/yt_untertitel.py @@ -0,0 +1,118 @@ +#!/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 +import shutil +import subprocess + +YT_DLP = "yt-dlp" + + +def fetch_info(url): + """Return the video metadata from yt-dlp, without downloading anything.""" + result = subprocess.run( + [YT_DLP, "--dump-single-json", "--skip-download", "--no-playlist", url], + capture_output=True, text=True, + ) + if result.returncode != 0: + raise SystemExit(f"yt-dlp konnte das Video nicht abrufen:\n{result.stderr.strip()}") + return json.loads(result.stdout) + + +def choose_track(info, lang): + """Pick the subtitle track for `lang`: (track name, is_auto) or None. + + Manual subtitles come first. Of the automatic captions only the original + language ("-orig") is used: plain "" there is a machine + translation whenever the video is in another language. + """ + if lang in (info.get("subtitles") or {}): + return lang, False + if f"{lang}-orig" in (info.get("automatic_captions") or {}): + return f"{lang}-orig", True + return None + + +def available_tracks(info): + manual = sorted((info.get("subtitles") or {}).keys()) + original = sorted( + name.removesuffix("-orig") + for name in (info.get("automatic_captions") or {}) + if name.endswith("-orig") + ) + return manual, original + + +def download(url, track, is_auto, target_base): + """Download one subtitle track; yt-dlp writes ..vtt.""" + command = [ + YT_DLP, "--skip-download", "--no-playlist", "--force-overwrites", + "--write-auto-subs" if is_auto else "--write-subs", + "--sub-langs", track, "--sub-format", "vtt", + "-o", f"{target_base}.%(ext)s", + url, + ] + result = subprocess.run(command, capture_output=True, text=True) + 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", + help="Dateiname ohne Endung, z. B. Lahnstein (Standard: Video-ID)", + ) + 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: + manual, original = available_tracks(info) + raise SystemExit( + f"Keine Untertitel in „{args.lang}“ (weder manuell noch automatisch in " + f"Originalsprache).\n" + f"Manuell: {', '.join(manual) or '–'}\n" + f"Automatisch (Originalsprache): {', '.join(original) or '–'}" + ) + 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) + target_base = args.dir / (args.name or info["id"]) + 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()