feat: YouTube-Untertitel herunterladen (yt_untertitel.py)
- yt_untertitel.py lädt über yt-dlp die Untertitel eines Videos als VTT: manuelle Untertitel in der Sprache, sonst automatische in Originalsprache (<lang>-orig); Maschinenübersetzungen werden nie genommen - Tests ohne Netz (yt-dlp durch Fakes ersetzt) - Testdaten: Lahnstein-Rede (VTT und daraus erzeugter Rohtext) - README: Ablauf URL → VTT → TXT → Korrektur Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8fe7c394f1
commit
903aa92b09
4 changed files with 298 additions and 3 deletions
43
README.md
43
README.md
|
|
@ -6,7 +6,8 @@ Hörfehler, Absätze – möglichst wortgetreu, ohne Zusammenfassung oder Glätt
|
||||||
|
|
||||||
## Voraussetzungen
|
## 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 auf `127.0.0.1:11434` mit dem Modell `qwen3:32b`
|
||||||
(`ollama pull qwen3:32b`)
|
(`ollama pull qwen3:32b`)
|
||||||
|
|
||||||
|
|
@ -17,6 +18,40 @@ der Lauf wird etwa 12-mal langsamer.
|
||||||
|
|
||||||
## Aufruf
|
## 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 # → <Video-ID>.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`
|
### Untertitel umwandeln: `vtt2txt.py`
|
||||||
|
|
||||||
Macht aus VTT-Untertiteln (z. B. von `yt-dlp --write-auto-subs`) die Rohabschrift
|
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
|
||||||
|
|
||||||
`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
|
(`.vtt`, Original) und daraus mit `vtt2txt.py` gewonnener Rohtext (`.txt`, Eingabe
|
||||||
für das Programm):
|
für das Programm):
|
||||||
|
|
||||||
- `ramstein.de.*`: Rede über die US Air Base Ramstein (mit Glossar `ramstein.glossar.txt`)
|
- `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`)
|
- `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
|
```bash
|
||||||
./vtt2txt.py -i "testdaten/*.vtt" # erzeugt alle .txt neu
|
./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
|
werden Abschnittsbildung, Glossar (inkl. „jede Ersetzung in den Beispiel-Glossaren
|
||||||
trifft ihre Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt
|
trifft ihre Testdaten“), Streaming-Auswertung, Absatzgliederung (Text bleibt
|
||||||
unverändert, 3–10 Sätze), Zwischenspeicher/Fortsetzen und die Kommandozeile.
|
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/`
|
`tests/test_vtt2txt.py` prüft die VTT-Umwandlung: jede `.vtt` in `testdaten/`
|
||||||
ergibt genau die zugehörige `.txt`, echte Wiederholungen bleiben erhalten,
|
ergibt genau die zugehörige `.txt`, echte Wiederholungen bleiben erhalten,
|
||||||
dazu Muster, Ausgabeverzeichnis und Fehlerfälle.
|
dazu Muster, Ausgabeverzeichnis und Fehlerfälle.
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ def test_main_default_output_matches_sample(sample_copy, monkeypatch, capsys):
|
||||||
assert f"Erstellt: {target}" in capsys.readouterr().out
|
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):
|
def test_sample_vtt_gives_sample_txt(name):
|
||||||
vtt = (REPO / "testdaten" / f"{name}.vtt").read_text(encoding="utf-8-sig")
|
vtt = (REPO / "testdaten" / f"{name}.vtt").read_text(encoding="utf-8-sig")
|
||||||
txt = (REPO / "testdaten" / f"{name}.txt").read_text(encoding="utf-8")
|
txt = (REPO / "testdaten" / f"{name}.txt").read_text(encoding="utf-8")
|
||||||
|
|
|
||||||
138
tests/test_yt_untertitel.py
Normal file
138
tests/test_yt_untertitel.py
Normal file
|
|
@ -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
|
||||||
118
yt_untertitel.py
Executable file
118
yt_untertitel.py
Executable file
|
|
@ -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 ("<lang>-orig") is used: plain "<lang>" 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 <target_base>.<track>.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()
|
||||||
Loading…
Reference in a new issue