diff --git a/README.md b/README.md index aa39ae9..57a0a26 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ Korrigiert automatische Abschriften deutscher Reden (z. B. YouTube-Untertitel al Text) mit einem lokalen Sprachmodell über Ollama: Rechtschreibung, Satzzeichen, Hörfehler, Absätze – möglichst wortgetreu, ohne Zusammenfassung oder Glättung. -Drei Werkzeuge, einzeln oder hintereinander nutzbar: +Die Werkzeuge sind einzeln oder hintereinander nutzbar: | Skript | Aufgabe | |---|---| +| `extract_text.py` | YouTube-URL → Textdatei in einem Schritt (die beiden folgenden zusammen) | | `yt_untertitel.py` | Untertitel eines YouTube-Videos als VTT herunterladen | | `vtt2txt.py` | VTT in eine Rohabschrift (Text) umwandeln | | `rede_korrigieren.py` | Rohabschrift (oder VTT direkt) mit dem Sprachmodell korrigieren | @@ -26,7 +27,14 @@ der Lauf wird etwa 12-mal langsamer. ## Aufruf -Ablauf von der YouTube-URL zum korrigierten Text: +Von der YouTube-URL zum korrigierten Text: + +```bash +./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -o lahnstein.txt +./rede_korrigieren.py -i lahnstein.txt # → lahnstein.korrigiert.txt +``` + +Dasselbe in Einzelschritten, wenn die VTT-Datei erhalten bleiben soll: ```bash ./yt_untertitel.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' -n Lahnstein -d testdaten @@ -34,9 +42,27 @@ Ablauf von der YouTube-URL zum korrigierten Text: ./rede_korrigieren.py -i testdaten/Lahnstein.de-orig.txt ``` -Der mittlere Schritt ist optional: `rede_korrigieren.py -i testdaten/Lahnstein.de-orig.vtt` -wandelt die Untertitel selbst um. Die `.txt` lohnt sich, um den Rohtext anzusehen -oder ein Glossar daraus zu erstellen. +Der Schritt über `vtt2txt.py` ist optional: `rede_korrigieren.py` nimmt auch die +`.vtt` direkt. Die `.txt` lohnt sich, um den Rohtext anzusehen oder ein Glossar +daraus zu erstellen. + +### URL → Text in einem Schritt: `extract_text.py` + +Lädt die Untertitel und speichert nur den Text; die VTT-Datei bleibt ein +Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei +`yt_untertitel.py`, nur ist die Standardsprache die des Original-Audios. + +```bash +./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' # → output.txt +./extract_text.py -u URL -l de -o testdaten/rede.txt # Sprache und Ziel +./rede_korrigieren.py -i output.txt # → output.korrigiert.txt +``` + +| Option | Bedeutung | +|---|---| +| `-u`, `--url` | YouTube-URL, Pflicht | +| `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`-orig`) | +| `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `output.txt` | ### Untertitel herunterladen: `yt_untertitel.py` @@ -165,6 +191,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_extract_text.py` prüft den Gesamtablauf URL → Text, Sprachwahl +(Original-Audio, manuell vor automatisch) und Fehlerfälle. `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/` diff --git a/extract_text.py b/extract_text.py new file mode 100755 index 0000000..c1ce5a2 --- /dev/null +++ b/extract_text.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Lädt die Untertitel eines YouTube-Videos und speichert ihren Text als Datei +(yt_untertitel.py + vtt2txt.py in einem Schritt).""" +from pathlib import Path +import argparse +import shutil +import tempfile + +from vtt2txt import vtt_to_lines +from yt_untertitel import ( + YT_DLP, available_tracks, choose_track, download, fetch_info, original_language, +) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Lädt die Untertitel eines YouTube-Videos und speichert den Text." + ) + parser.add_argument("-u", "--url", required=True, help="YouTube-URL des Videos") + parser.add_argument( + "-l", "--lang", + help="Sprache der Untertitel, z. B. de (Standard: Sprache des Original-Audios)", + ) + parser.add_argument( + "-o", "--output", type=Path, default=Path("output.txt"), + help="Ausgabedatei (Standard: output.txt)", + ) + args = parser.parse_args() + + if args.output.is_dir(): + parser.error(f"-o/--output ist ein Verzeichnis: {args.output}") + 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', '?')}") + + lang = args.lang or original_language(info) + chosen = choose_track(info, lang) if lang else None + if chosen is None: + manual, original = available_tracks(info) + wanted = f"„{lang}“" if lang else "der Originalsprache" + raise SystemExit( + 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 '–'}" + ) + track, is_auto = chosen + kind = "automatische" if is_auto else "manuelle" + print(f"Lade {kind} Untertitel „{track}“ …", flush=True) + + # The VTT file is only an intermediate step + with tempfile.TemporaryDirectory() as tmp: + target_base = Path(tmp) / "untertitel" + download(args.url, track, is_auto, target_base) + vtt = Path(f"{target_base}.{track}.vtt") + if not vtt.is_file(): + raise SystemExit("yt-dlp hat keine Untertitel-Datei geschrieben.") + lines = vtt_to_lines(vtt.read_text(encoding="utf-8-sig")) + + if not lines: + raise SystemExit("Die Untertitel enthalten keinen Text.") + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text("\n".join(lines) + "\n", encoding="utf-8") + words = sum(len(line.split()) for line in lines) + print(f"Gespeichert: {args.output} ({len(lines)} Zeilen, {words} Wörter)") + + +if __name__ == "__main__": + main() diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py new file mode 100644 index 0000000..363d6a2 --- /dev/null +++ b/tests/test_extract_text.py @@ -0,0 +1,116 @@ +"""Tests for extract_text.py; yt-dlp is always replaced by fakes (no network).""" +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +import extract_text +import yt_untertitel as yt + +URL = "https://www.youtube.com/watch?v=abc123" + +VTT = ( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:02.000\n \nGuten<00:00:01.500> Abend.\n\n" + "00:00:02.000 --> 00:00:03.000\nGuten Abend.\nSchön, dass Sie da sind.\n" +) + +GERMAN_VIDEO = { + "id": "abc123", + "title": "Eine Rede", + "subtitles": {"en": []}, + "automatic_captions": {"de-orig": [], "de": [], "en": []}, +} + + +def fake_yt_dlp(info, vtt=VTT): + """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), "") + track = command[command.index("--sub-langs") + 1] + template = command[command.index("-o") + 1] + Path(template.replace("%(ext)s", f"{track}.vtt")).write_text(vtt, encoding="utf-8") + return subprocess.CompletedProcess(command, 0, "", "") + + run.calls = calls + return run + + +@pytest.fixture(autouse=True) +def yt_dlp_installed(monkeypatch): + monkeypatch.setattr(extract_text.shutil, "which", lambda name: f"/usr/bin/{name}") + + +def run_main(monkeypatch, *args): + monkeypatch.setattr(sys, "argv", ["extract_text.py", *map(str, args)]) + extract_text.main() + + +def test_default_language_is_original_audio(tmp_path, monkeypatch, capsys): + run = fake_yt_dlp(GERMAN_VIDEO) + monkeypatch.setattr(yt.subprocess, "run", run) + monkeypatch.chdir(tmp_path) + run_main(monkeypatch, "-u", URL) + + text = (tmp_path / "output.txt").read_text(encoding="utf-8") + assert text == "Guten Abend.\nSchön, dass Sie da sind.\n" + assert "de-orig" in run.calls[1] + assert "--write-auto-subs" in run.calls[1] + out = capsys.readouterr().out + assert "output.txt (2 Zeilen, 7 Wörter)" in out + # Only the text file remains, no VTT + assert [p.name for p in tmp_path.iterdir()] == ["output.txt"] + + +def test_explicit_language_prefers_manual(tmp_path, monkeypatch): + run = fake_yt_dlp(GERMAN_VIDEO) + monkeypatch.setattr(yt.subprocess, "run", run) + target = tmp_path / "neu" / "rede.en.txt" + run_main(monkeypatch, "-u", URL, "-l", "en", "-o", target) + + assert target.is_file() + assert "--write-subs" in run.calls[1] + + +def test_original_language_from_metadata_without_auto_captions(tmp_path, monkeypatch): + info = {"id": "x", "language": "fr", "subtitles": {"fr": []}, "automatic_captions": {}} + run = fake_yt_dlp(info) + monkeypatch.setattr(yt.subprocess, "run", run) + run_main(monkeypatch, "-u", URL, "-o", tmp_path / "out.txt") + assert run.calls[1][run.calls[1].index("--sub-langs") + 1] == "fr" + + +@pytest.mark.parametrize("info, args, message", [ + # Translation only: no "en-orig", no manual "en" + (GERMAN_VIDEO, ["-l", "fr"], "Keine Untertitel in „fr“"), + ({"id": "x", "subtitles": {}, "automatic_captions": {}}, [], "Originalsprache"), +]) +def test_no_matching_track(tmp_path, monkeypatch, info, args, message): + monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(info)) + with pytest.raises(SystemExit, match=message): + run_main(monkeypatch, "-u", URL, "-o", tmp_path / "out.txt", *args) + assert not (tmp_path / "out.txt").exists() + + +def test_empty_subtitles(tmp_path, monkeypatch): + monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt="WEBVTT\n")) + with pytest.raises(SystemExit, match="keinen Text"): + run_main(monkeypatch, "-u", URL, "-o", tmp_path / "out.txt") + + +@pytest.mark.parametrize("args, message", [ + ([], "required"), + (["-u", URL, "-o", "{dir}"], "ist ein Verzeichnis"), +]) +def test_cli_errors(tmp_path, monkeypatch, capsys, args, message): + args = [a.format(dir=tmp_path) for a in args] + with pytest.raises(SystemExit) as exc: + run_main(monkeypatch, *args) + assert exc.value.code == 2 + assert message in capsys.readouterr().err diff --git a/tests/test_yt_untertitel.py b/tests/test_yt_untertitel.py index 2f9c2a3..9afc96a 100644 --- a/tests/test_yt_untertitel.py +++ b/tests/test_yt_untertitel.py @@ -63,6 +63,15 @@ def test_choose_track(info, lang, expected): assert yt.choose_track(info, lang) == expected +@pytest.mark.parametrize("info, expected", [ + (AUTO_ONLY, "de"), + ({"language": "fr", "automatic_captions": {}}, "fr"), + ({"subtitles": {"en": []}}, None), +]) +def test_original_language(info, expected): + assert yt.original_language(info) == expected + + def test_available_tracks(): info = {"subtitles": {"fr": []}, "automatic_captions": {"de-orig": [], "de": []}} assert yt.available_tracks(info) == (["fr"], ["de"]) diff --git a/yt_untertitel.py b/yt_untertitel.py index 54f90c1..11f0eb1 100755 --- a/yt_untertitel.py +++ b/yt_untertitel.py @@ -35,6 +35,17 @@ def choose_track(info, lang): return None +def original_language(info): + """Language of the video's audio, or None if yt-dlp does not know it. + + YouTube marks the automatic captions in the spoken language with "-orig". + """ + for name in info.get("automatic_captions") or {}: + if name.endswith("-orig"): + return name.removesuffix("-orig") + return info.get("language") + + def available_tracks(info): manual = sorted((info.get("subtitles") or {}).keys()) original = sorted(