refactor: Zeitauswahl nur noch über -z, -s/-e entfallen

-z 00:05:10 (ab), -z 0-00:20 (vom Anfang bis), -z 00:05-00:06 und mehrere
Bereiche, mit oder ohne Anführungszeichen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 20:20:54 +02:00
commit 8edef11c78
7 changed files with 93 additions and 108 deletions

View file

@ -68,8 +68,8 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
# → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt # → John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30.txt
./extract_text.py -u URL -l de -o testdaten/rede.txt # Sprache und Ziel ./extract_text.py -u URL -l de -o testdaten/rede.txt # Sprache und Ziel
./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext ./extract_text.py -u URL -f -o rede.txt # lesbarer Fließtext
./extract_text.py -u URL -s 0:03:17 -e 0:05:25 # nur ein Ausschnitt ./extract_text.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt
./extract_text.py -u URL -z "00:05-00:06 00:10-00:12 00:25" # mehrere Bereiche ./extract_text.py -u URL -z 00:05-00:06 00:10-00:12 00:25 # mehrere Bereiche
./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt ./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt
``` ```
@ -78,7 +78,7 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei
| `-u`, `--url` | YouTube-URL, Pflicht | | `-u`, `--url` | YouTube-URL, Pflicht |
| `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`<sprache>-orig`) | | `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`<sprache>-orig`) |
| `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `<Kanal>_<Titel>_<Datum>.txt`, siehe unten | | `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `<Kanal>_<Titel>_<Datum>.txt`, siehe unten |
| `-s`, `--start` / `-e`, `--ende` / `-z`, `--zeitraeume` | nur Untertitel in diesen Zeiträumen, siehe [Zeitauswahl](#zeitauswahl--s--e--z) | | `-z`, `--zeitraeume` | nur Untertitel in diesen Bereichen, siehe [Zeitauswahl](#zeitauswahl--z) |
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen | | `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
| `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `rede_korrigieren.py` | | `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `rede_korrigieren.py` |
@ -95,8 +95,8 @@ Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und
Wörter werden mit `_` verbunden. Bindestriche und Punkte im Wort bleiben Wörter werden mit `_` verbunden. Bindestriche und Punkte im Wort bleiben
(„Krone-Schmalz“), andere Zeichen fallen weg. Der Kanal wird auf 40, der Titel auf („Krone-Schmalz“), andere Zeichen fallen weg. Der Kanal wird auf 40, der Titel auf
60 Zeichen an einer Wortgrenze gekürzt. Ohne verwertbaren Kanal und Titel gilt die 60 Zeichen an einer Wortgrenze gekürzt. Ohne verwertbaren Kanal und Titel gilt die
Video-ID. Mit `-s`/`-e`/`-z` kommen die Zeiträume dazu, z. B. Video-ID. Mit `-z` kommen die Bereiche dazu, z. B.
`…_2026-07-30_00-03-17_bis_00-05-25` (nur `-s`: `_ab_…`, nur `-e`: `_bis_…`; `…_2026-07-30_00-03-17_bis_00-05-25` (`-z A`: `_ab_…`, `-z 0-B`: `_bis_…`;
mehrere Bereiche mit `+` verbunden: `…_00-05-00_bis_00-06-00+ab_00-25-00`). mehrere Bereiche mit `+` verbunden: `…_00-05-00_bis_00-06-00+ab_00-25-00`).
Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird
überschrieben (ein mit `-u` gespeicherter, abweichender Rohtext aber nie). überschrieben (ein mit `-u` gespeicherter, abweichender Rohtext aber nie).
@ -147,20 +147,18 @@ zweimal, bleibt sie zweimal erhalten.
./vtt2txt.py -i "ramstein.de*.vtt" # Muster löst das Programm auf (Windows) ./vtt2txt.py -i "ramstein.de*.vtt" # Muster löst das Programm auf (Windows)
./vtt2txt.py -i rede.de.vtt -o out/rede.txt # eigene Ausgabedatei ./vtt2txt.py -i rede.de.vtt -o out/rede.txt # eigene Ausgabedatei
./vtt2txt.py -i *.vtt -o out/ # alle in ein Verzeichnis ./vtt2txt.py -i *.vtt -o out/ # alle in ein Verzeichnis
./vtt2txt.py -i rede.de.vtt -s 0:03:17 -e 0:05:25 # Ausschnitt → rede.de_00-03-17_bis_00-05-25.txt ./vtt2txt.py -i rede.de.vtt -z 0:03:17-0:05:25 # Ausschnitt → rede.de_00-03-17_bis_00-05-25.txt
./vtt2txt.py -i rede.de.vtt -z "00:05-00:06 00:25" # mehrere Bereiche in einer Datei ./vtt2txt.py -i rede.de.vtt -z 00:05-00:06 00:25 # mehrere Bereiche in einer Datei
``` ```
| Option | Bedeutung | | Option | Bedeutung |
|---|---| |---|---|
| `-i`, `--input` | eine oder mehrere VTT-Dateien oder Muster, Pflicht | | `-i`, `--input` | eine oder mehrere VTT-Dateien oder Muster, Pflicht |
| `-o`, `--output` | Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis (vorhanden oder mit `/` am Ende); Standard: `<Eingabe ohne Endung>.txt` neben der Eingabe, mit `-s`/`-e`/`-z` zusätzlich die Zeiträume im Namen | | `-o`, `--output` | Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis (vorhanden oder mit `/` am Ende); Standard: `<Eingabe ohne Endung>.txt` neben der Eingabe, mit `-z` zusätzlich die Bereiche im Namen |
| `-s`, `--start` | nur Untertitel ab dieser Zeit | | `-z`, `--zeitraeume` | nur Untertitel in diesen Bereichen, siehe unten |
| `-e`, `--ende` | nur Untertitel vor dieser Zeit |
| `-z`, `--zeitraeume` | mehrere Bereiche statt `-s`/`-e` |
| `--exakt` | genau an den Zeiten schneiden, auch mitten im Satz | | `--exakt` | genau an den Zeiten schneiden, auch mitten im Satz |
#### Zeitauswahl (`-s`, `-e`, `-z`) #### Zeitauswahl (`-z`)
Gilt gleich für `vtt2txt.py`, `extract_text.py` und `rede_korrigieren.py`. Gilt gleich für `vtt2txt.py`, `extract_text.py` und `rede_korrigieren.py`.
@ -172,14 +170,16 @@ Gilt gleich für `vtt2txt.py`, `extract_text.py` und `rede_korrigieren.py`.
| Aufruf | Text | | Aufruf | Text |
|---|---| |---|---|
| `-s 00:05:10` | ab 5:10 bis zum Ende des Videos | | `-z 00:05:10` | ab 5:10 bis zum Ende des Videos |
| `-e 00:20` | vom Anfang bis Minute 20 | | `-z 0-00:20` | vom Anfang bis Minute 20 |
| `-s 00:05 -e 00:06` | von Minute 5 bis Minute 6 | | `-z 00:05-00:06` | von Minute 5 bis Minute 6 |
| `-z "00:5:00-00:6:00 00:10-00:12:00 00:25"` | Minute 5–6, Minute 10–12 und alles ab Minute 25 | | `-z 00:5:00-00:6:00 00:10-00:12:00 00:25` | Minute 5–6, Minute 10–12 und alles ab Minute 25 |
Bei `-z` werden die Bereiche durch Leerzeichen getrennt: `A-B`, `A` (ab A bis Ein Bereich ist `A-B`, `A` (ab A bis zum Ende) oder `0-B` (vom Anfang bis B).
zum Ende) oder `-B` (vom Anfang bis B). Sie dürfen sich nicht überschneiden und Mehrere Bereiche werden durch Leerzeichen getrennt, mit oder ohne
werden nach der Zeit sortiert. Anführungszeichen (`-z "00:05-00:06 00:25"` ist dasselbe). Sie dürfen sich
nicht überschneiden und werden nach der Zeit sortiert. `-B` geht nicht, weil
die Kommandozeile es für eine Option hält.
Alle Bereiche landen in **einer** Datei. Vor jedem Bereich steht eine Überschrift Alle Bereiche landen in **einer** Datei. Vor jedem Bereich steht eine Überschrift
mit seinen tatsächlichen Zeiten: mit seinen tatsächlichen Zeiten:
@ -217,8 +217,8 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
./rede_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt ./rede_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt
./rede_korrigieren.py -u URL # YouTube → <Kanal>_<Titel>_<Datum>.korrigiert.txt ./rede_korrigieren.py -u URL # YouTube → <Kanal>_<Titel>_<Datum>.korrigiert.txt
./rede_korrigieren.py -i speech.txt -l en # englische Rede ./rede_korrigieren.py -i speech.txt -l en # englische Rede
./rede_korrigieren.py -u URL -s 0:03:17 -e 0:05:25 # nur ein Ausschnitt des Videos ./rede_korrigieren.py -u URL -z 0:03:17-0:05:25 # nur ein Ausschnitt des Videos
./rede_korrigieren.py -u URL -z "00:05-00:06 00:25" # mehrere Bereiche, eine Datei ./rede_korrigieren.py -u URL -z 00:05-00:06 00:25 # mehrere Bereiche, eine Datei
``` ```
| Option | Bedeutung | | Option | Bedeutung |
@ -226,8 +226,8 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1.
| `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt) | | `-i`, `--input` | Eingabedatei: Rohabschrift (`.txt`) oder Untertitel (`.vtt`, wird wie mit `vtt2txt.py` umgewandelt) |
| `-u`, `--url` | statt `-i`: YouTube-URL; Untertitel wie bei `extract_text.py`. Der Rohtext wird neben der Ausgabe gespeichert (`rede.korrigiert.txt` → `rede.txt`, sonst `<Name>.roh.txt`), eine vorhandene, abweichende Datei aber nie überschrieben | | `-u`, `--url` | statt `-i`: YouTube-URL; Untertitel wie bei `extract_text.py`. Der Rohtext wird neben der Ausgabe gespeichert (`rede.korrigiert.txt` → `rede.txt`, sonst `<Name>.roh.txt`), eine vorhandene, abweichende Datei aber nie überschrieben |
| `-l`, `--lang` | Sprache der Rede: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios | | `-l`, `--lang` | Sprache der Rede: `de` oder `en` (auch mit Region, z. B. `en-US`); wählt Prompts und Prüfmarke. Mit `-u` zugleich die Sprache der Untertitel. Standard: `de`, mit `-u` die Sprache des Original-Audios |
| `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe, mit `-u` `<Kanal>_<Titel>_<Datum>.korrigiert.txt`; mit `-s`/`-e`/`-z` zusätzlich die Zeiträume im Namen | | `-o`, `--output` | Ausgabedatei; Standard: `<Eingabe ohne Endung>.korrigiert.txt` neben der Eingabe, mit `-u` `<Kanal>_<Titel>_<Datum>.korrigiert.txt`; mit `-z` zusätzlich die Bereiche im Namen |
| `-s`, `--start` / `-e`, `--ende` / `-z`, `--zeitraeume` | nur diese Zeiträume korrigieren, siehe [Zeitauswahl](#zeitauswahl--s--e--z); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat | | `-z`, `--zeitraeume` | nur diese Bereiche korrigieren, siehe [Zeitauswahl](#zeitauswahl--z); nur mit `.vtt`-Eingabe oder `-u`, da reiner Text keine Zeitstempel hat |
| `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen | | `--exakt` | genau an den Zeiten schneiden statt an Satzgrenzen |
Mit Zeitauswahl wird jeder Bereich als eigener Block korrigiert und gegliedert; Mit Zeitauswahl wird jeder Bereich als eigener Block korrigiert und gegliedert;
@ -330,7 +330,7 @@ falsche feste Ersetzung verdirbt den Text unbemerkt. Zum Übernehmen `# ? ` entf
Einträge aus den mit `-g` angegebenen Glossaren werden vorher angewendet und nicht Einträge aus den mit `-g` angegebenen Glossaren werden vorher angewendet und nicht
erneut vorgeschlagen. Zeit-Überschriften von Ausschnitten erneut vorgeschlagen. Zeit-Überschriften von Ausschnitten
(`[00:04:57 - 00:06:04]`, siehe [Zeitauswahl](#zeitauswahl--s--e--z)) werden aus (`[00:04:57 - 00:06:04]`, siehe [Zeitauswahl](#zeitauswahl--z)) werden aus
Roh- und korrigiertem Text entfernt; das Modell bekommt sie nicht zu sehen. Roh- und korrigiertem Text entfernt; das Modell bekommt sie nicht zu sehen.
```bash ```bash

View file

@ -31,7 +31,7 @@ def parse_args():
parser.add_argument( parser.add_argument(
"-o", "--output", type=Path, "-o", "--output", type=Path,
help="Ausgabedatei (Standard: <Kanal>_<Titel>_<Datum>.txt, " help="Ausgabedatei (Standard: <Kanal>_<Titel>_<Datum>.txt, "
"mit -s/-e/-z zusätzlich die Zeiträume)", "mit -z zusätzlich die Bereiche)",
) )
parser.add_argument( parser.add_argument(
"-f", "--fliesstext", action="store_true", "-f", "--fliesstext", action="store_true",

View file

@ -493,8 +493,8 @@ def parse_args():
parser.add_argument( parser.add_argument(
"-o", "--output", type=Path, "-o", "--output", type=Path,
help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt, " help="Ausgabedatei (Standard: <Eingabe ohne Endung>.korrigiert.txt, "
"mit -u <Kanal>_<Titel>_<Datum>.korrigiert.txt; mit -s/-e zusätzlich " "mit -u <Kanal>_<Titel>_<Datum>.korrigiert.txt; mit -z zusätzlich "
"der Zeitraum)", "die Bereiche)",
) )
parser.add_argument( parser.add_argument(
"-g", "--glossar", type=Path, action="append", default=[], "-g", "--glossar", type=Path, action="append", default=[],
@ -528,7 +528,7 @@ def parse_args():
if not args.input.is_file(): if not args.input.is_file():
parser.error(f"Eingabedatei nicht gefunden: {args.input}") parser.error(f"Eingabedatei nicht gefunden: {args.input}")
if args.bereiche and args.input.suffix.lower() != ".vtt": if args.bereiche and args.input.suffix.lower() != ".vtt":
parser.error("-s/-e/-z brauchen Zeitstempel: nur mit .vtt-Eingabe oder -u.") parser.error("-z braucht Zeitstempel: nur mit .vtt-Eingabe oder -u.")
if args.output is None: if args.output is None:
suffix = time_suffix(args.bereiche) suffix = time_suffix(args.bereiche)
args.output = args.input.with_name(f"{args.input.stem}{suffix}.korrigiert.txt") args.output = args.input.with_name(f"{args.input.stem}{suffix}.korrigiert.txt")

View file

@ -130,7 +130,7 @@ def test_cli_errors(tmp_path, monkeypatch, capsys, args, message):
def test_time_range_default_name(fake_yt_dlp, tmp_path, monkeypatch): def test_time_range_default_name(fake_yt_dlp, tmp_path, monkeypatch):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=VTT)) monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=VTT))
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-s", "0:00:02", "-e", "0:00:03") run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03")
name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt" name = "Kanal_Eine_Rede_2026-03-28_00-00-02_bis_00-00-03.txt"
assert (tmp_path / name).read_text(encoding="utf-8") == ( assert (tmp_path / name).read_text(encoding="utf-8") == (
@ -141,14 +141,14 @@ def test_time_range_default_name(fake_yt_dlp, tmp_path, monkeypatch):
def test_time_range_without_text(fake_yt_dlp, tmp_path, monkeypatch): def test_time_range_without_text(fake_yt_dlp, tmp_path, monkeypatch):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=VTT)) monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=VTT))
with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"): with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"):
run_main(monkeypatch, "-u", URL, "-s", "1:00", "-o", tmp_path / "out.txt") run_main(monkeypatch, "-u", URL, "-z", "1:00", "-o", tmp_path / "out.txt")
def test_time_range_order_error(monkeypatch, capsys): def test_time_range_order_error(monkeypatch, capsys):
with pytest.raises(SystemExit) as exc: with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, "-u", URL, "-s", "2:00", "-e", "1:00") run_main(monkeypatch, "-u", URL, "-z", "2:00-1:00")
assert exc.value.code == 2 assert exc.value.code == 2
assert "muss vor -e/--ende liegen" in capsys.readouterr().err assert "Start muss vor Ende" in capsys.readouterr().err
CUT_VTT = ( CUT_VTT = (
@ -165,7 +165,7 @@ CUT_VTT = (
def test_time_range_sentence_bounds(fake_yt_dlp, tmp_path, monkeypatch, exact, text): def test_time_range_sentence_bounds(fake_yt_dlp, tmp_path, monkeypatch, exact, text):
monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=CUT_VTT)) monkeypatch.setattr(yt.subprocess, "run", fake_yt_dlp(GERMAN_VIDEO, vtt=CUT_VTT))
target = tmp_path / "out.txt" target = tmp_path / "out.txt"
run_main(monkeypatch, "-u", URL, "-s", "0:00:02", "-e", "0:00:03", "-o", target, *exact) run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03", "-o", target, *exact)
assert target.read_text(encoding="utf-8") == text assert target.read_text(encoding="utf-8") == text
@ -182,7 +182,7 @@ def test_several_ranges_flow_text_keeps_headers(fake_yt_dlp, tmp_path, monkeypat
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-f") run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-f")
name = "Kanal_Eine_Rede_2026-03-28_00-00-00_bis_00-00-30+ab_00-02-00.txt" name = "Kanal_Eine_Rede_2026-03-28_bis_00-00-30+ab_00-02-00.txt"
assert (tmp_path / name).read_text(encoding="utf-8") == ( assert (tmp_path / name).read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\nErster Satz der\nRede.\n\n" "[00:00:01 - 00:00:02]\nErster Satz der\nRede.\n\n"
"[00:02:00 - 00:02:01]\nDritter Satz, der\netwas länger ist.\n" "[00:02:00 - 00:02:01]\nDritter Satz, der\netwas länger ist.\n"

View file

@ -523,7 +523,7 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message):
def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch): def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-s", "0:00:02") run_main(monkeypatch, "-u", URL, "-z", "0:00:02")
name = "Kanal_Rede_2026-03-28_ab_00-00-02" name = "Kanal_Rede_2026-03-28_ab_00-00-02"
raw = (tmp_path / f"{name}.txt").read_text(encoding="utf-8") raw = (tmp_path / f"{name}.txt").read_text(encoding="utf-8")
@ -534,7 +534,7 @@ def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
def test_main_url_time_range_without_text(from_url, tmp_path, monkeypatch): def test_main_url_time_range_without_text(from_url, tmp_path, monkeypatch):
with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"): with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"):
run_main(monkeypatch, "-u", URL, "-s", "1:00", "-o", tmp_path / "x.korrigiert.txt") run_main(monkeypatch, "-u", URL, "-z", "1:00", "-o", tmp_path / "x.korrigiert.txt")
def test_main_vtt_time_range(tmp_path, monkeypatch): def test_main_vtt_time_range(tmp_path, monkeypatch):
@ -542,7 +542,7 @@ def test_main_vtt_time_range(tmp_path, monkeypatch):
vtt.write_text(VTT, encoding="utf-8") vtt.write_text(VTT, encoding="utf-8")
monkeypatch.setattr(rk, "correct_chunk", identity_correct) monkeypatch.setattr(rk, "correct_chunk", identity_correct)
monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5)) monkeypatch.setattr(rk, "find_paragraph_starts", every_nth_start(5))
run_main(monkeypatch, "-i", vtt, "-e", "0:00:02") run_main(monkeypatch, "-i", vtt, "-z", "0-0:00:02")
result = tmp_path / "rede.de_bis_00-00-02.korrigiert.txt" result = tmp_path / "rede.de_bis_00-00-02.korrigiert.txt"
assert result.read_text(encoding="utf-8") == ( assert result.read_text(encoding="utf-8") == (
@ -555,13 +555,13 @@ def test_main_vtt_time_range_without_text(tmp_path, monkeypatch):
vtt.write_text(VTT, encoding="utf-8") vtt.write_text(VTT, encoding="utf-8")
monkeypatch.setattr(rk, "correct_chunk", no_model_calls) monkeypatch.setattr(rk, "correct_chunk", no_model_calls)
with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"): with pytest.raises(SystemExit, match="im gewählten Zeitraum keinen Text"):
run_main(monkeypatch, "-i", vtt, "-s", "1:00") run_main(monkeypatch, "-i", vtt, "-z", "1:00")
@pytest.mark.parametrize("args, message", [ @pytest.mark.parametrize("args, message", [
(["-i", "{sample}", "-s", "1:00"], "nur mit .vtt-Eingabe oder -u"), (["-i", "{sample}", "-z", "1:00"], "nur mit .vtt-Eingabe oder -u"),
(["-i", "{sample}", "-s", "x"], "ungültige Zeit"), (["-i", "{sample}", "-z", "x"], "ungültiger Bereich"),
(["-i", "{sample}", "-s", "2:00", "-e", "1:00"], "muss vor -e/--ende liegen"), (["-i", "{sample}", "-z", "2:00-1:00"], "Start muss vor Ende"),
(["-i", "{sample}", "-z", "0:05 0:10"], "überschneiden"), (["-i", "{sample}", "-z", "0:05 0:10"], "überschneiden"),
]) ])
def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message): def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
@ -648,7 +648,7 @@ def test_main_url_excerpt(fake_yt_dlp, yt_dlp_installed, tmp_path, monkeypatch,
monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=CUT_VTT)) monkeypatch.setattr(subprocess, "run", fake_yt_dlp(VIDEO, vtt=CUT_VTT))
systems = recording_model(monkeypatch) systems = recording_model(monkeypatch)
target = tmp_path / "rede.korrigiert.txt" target = tmp_path / "rede.korrigiert.txt"
run_main(monkeypatch, "-u", URL, "-s", "0:00:02", "-e", "0:00:03", "-o", target, *exact) run_main(monkeypatch, "-u", URL, "-z", "0:00:02-0:00:03", "-o", target, *exact)
assert (tmp_path / "rede.txt").read_text(encoding="utf-8") == raw assert (tmp_path / "rede.txt").read_text(encoding="utf-8") == raw
assert rk.PROMPTS["de"]["excerpt"] in systems[0] assert rk.PROMPTS["de"]["excerpt"] in systems[0]
@ -682,7 +682,7 @@ def test_main_url_several_ranges(fake_yt_dlp, yt_dlp_installed, tmp_path, monkey
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", SHARED_GLOSSARY) run_main(monkeypatch, "-u", URL, "-z", "0:00:00-0:00:30 0:02", "-g", SHARED_GLOSSARY)
name = "Kanal_Rede_2026-03-28_00-00-00_bis_00-00-30+ab_00-02-00" name = "Kanal_Rede_2026-03-28_bis_00-00-30+ab_00-02-00"
assert (tmp_path / f"{name}.txt").read_text(encoding="utf-8") == ( assert (tmp_path / f"{name}.txt").read_text(encoding="utf-8") == (
"[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n\n" "[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n\n"
"[00:02:00 - 00:02:01]\nDas ist bekannt.\n" "[00:02:00 - 00:02:01]\nDas ist bekannt.\n"

View file

@ -189,8 +189,9 @@ def test_parse_cue_time_keeps_minutes_seconds(text, seconds):
@pytest.mark.parametrize("text, ranges", [ @pytest.mark.parametrize("text, ranges", [
("00:5:00-00:6:00 00:10-00:12:00 00:25", [(300, 360), (600, 720), (1500, None)]), ("00:5:00-00:6:00 00:10-00:12:00 00:25", [(300, 360), (600, 720), (1500, None)]),
("-00:20", [(None, 1200)]), ("0-00:20", [(None, 1200)]),
("00:25- 00:10-00:12 -00:05", [(None, 300), (600, 720), (1500, None)]), ("0:00:00-0:01", [(None, 60)]),
("00:25- 00:10-00:12 0-00:05", [(None, 300), (600, 720), (1500, None)]),
("00:05-00:06 00:06-00:07", [(300, 360), (360, 420)]), ("00:05-00:06 00:06-00:07", [(300, 360), (360, 420)]),
]) ])
def test_parse_ranges(text, ranges): def test_parse_ranges(text, ranges):
@ -199,11 +200,14 @@ def test_parse_ranges(text, ranges):
@pytest.mark.parametrize("text, message", [ @pytest.mark.parametrize("text, message", [
("", "keine Bereiche"), ("", "keine Bereiche"),
("-", "ungültiger Bereich"), ("-", "vom Anfang: 0-B"),
("-00:20", "vom Anfang: 0-B"),
("0", "das ganze Video"),
("0-", "das ganze Video"),
("00:06-00:05", "Start muss vor Ende"), ("00:06-00:05", "Start muss vor Ende"),
("00:05-00:10 00:08-00:12", "überschneiden"), ("00:05-00:10 00:08-00:12", "überschneiden"),
("00:05 00:10", "überschneiden"), ("00:05 00:10", "überschneiden"),
("-00:05 -00:10", "überschneiden"), ("0-00:05 0-00:10", "überschneiden"),
("00:05-x", "ungültiger Bereich"), ("00:05-x", "ungültiger Bereich"),
]) ])
def test_parse_ranges_rejects(text, message): def test_parse_ranges_rejects(text, message):
@ -356,7 +360,7 @@ def test_split_segments_inverts_join_segments():
# === Command line === # === Command line ===
def test_main_time_range_default_name(sample_copy, monkeypatch): def test_main_time_range_default_name(sample_copy, monkeypatch):
run_main(monkeypatch, "-i", sample_copy, "-s", "0:00:30", "-e", "0:01") run_main(monkeypatch, "-i", sample_copy, "-z", "0:00:30-0:01")
target = sample_copy.with_name("ramstein.de_00-00-30_bis_00-01-00.txt") target = sample_copy.with_name("ramstein.de_00-00-30_bis_00-01-00.txt")
full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines() full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines()
part = target.read_text(encoding="utf-8").splitlines() part = target.read_text(encoding="utf-8").splitlines()
@ -373,19 +377,28 @@ def test_main_several_ranges(sample_copy, monkeypatch):
] ]
@pytest.mark.parametrize("ranges", [["0:01-0:02 0:05"], ["0:01-0:02", "0:05"]])
def test_main_ranges_with_or_without_quotes(sample_copy, monkeypatch, ranges):
run_main(monkeypatch, "-i", sample_copy, "-z", *ranges)
target = sample_copy.with_name("ramstein.de_00-01-00_bis_00-02-00+ab_00-05-00.txt")
assert target.is_file()
def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys): def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
with pytest.raises(SystemExit) as exc: with pytest.raises(SystemExit) as exc:
run_main(monkeypatch, "-i", sample_copy, "-s", "99:00:00") run_main(monkeypatch, "-i", sample_copy, "-z", "99:00:00")
assert exc.value.code == 1 assert exc.value.code == 1
assert "im gewählten Zeitraum" in capsys.readouterr().err assert "im gewählten Zeitraum" in capsys.readouterr().err
@pytest.mark.parametrize("args, message", [ @pytest.mark.parametrize("args, message", [
(["-s", "abc"], "ungültige Zeit"), (["-z", "abc"], "ungültiger Bereich"),
(["-s", "1:00", "-e", "0:30"], "muss vor -e/--ende liegen"), (["-z", "1:00-0:30"], "Start muss vor Ende"),
(["-z", "0:05-0:04"], "Start muss vor Ende"),
(["-z", "0:05 0:10"], "überschneiden"), (["-z", "0:05 0:10"], "überschneiden"),
(["-z", "0:05", "-s", "0:01"], "nicht zusammen mit -s/-e"), (["-z", "0:05", "0:10"], "überschneiden"),
(["-z=-0:20"], "vom Anfang: 0-B"),
(["-z", "0"], "das ganze Video"),
(["-s", "0:01"], "unrecognized arguments: -s"),
]) ])
def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message): def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
with pytest.raises(SystemExit) as exc: with pytest.raises(SystemExit) as exc:
@ -397,10 +410,10 @@ def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message):
def test_main_exact_option(tmp_path, monkeypatch): def test_main_exact_option(tmp_path, monkeypatch):
source = tmp_path / "rede.vtt" source = tmp_path / "rede.vtt"
source.write_text(SPEECH, encoding="utf-8") source.write_text(SPEECH, encoding="utf-8")
run_main(monkeypatch, "-i", source, "-s", "0:01:46", "-e", "0:01:50", "--exakt") run_main(monkeypatch, "-i", source, "-z", "0:01:46-0:01:50", "--exakt")
target = tmp_path / "rede_00-01-46_bis_00-01-50.txt" target = tmp_path / "rede_00-01-46_bis_00-01-50.txt"
assert target.read_text(encoding="utf-8") == ( assert target.read_text(encoding="utf-8") == (
"[00:01:46 - 00:01:49]\nkommt der dritte\nund endet. Der vierte\n" "[00:01:46 - 00:01:49]\nkommt der dritte\nund endet. Der vierte\n"
) )
run_main(monkeypatch, "-i", source, "-s", "0:01:46", "-e", "0:01:50") run_main(monkeypatch, "-i", source, "-z", "0:01:46-0:01:50")
assert target.read_text(encoding="utf-8").endswith("und endet auch.\n") assert target.read_text(encoding="utf-8").endswith("und endet auch.\n")

View file

@ -45,33 +45,22 @@ def parse_user_time(text):
return int(first) * 3600 + int(minutes) * 60 + seconds return int(first) * 3600 + int(minutes) * 60 + seconds
def time_option(text):
"""argparse type for -s/--start and -e/--ende."""
try:
return parse_user_time(text)
except ValueError:
raise argparse.ArgumentTypeError(
f"ungültige Zeit „{text}“ ({TIME_EXAMPLES})"
) from None
def parse_ranges(text): def parse_ranges(text):
"""Sorted (start, end) seconds from "00:05:00-00:06:00 00:10-00:12 00:25". """Sorted (start, end) seconds from "00:05:00-00:06:00 00:10-00:12 00:25".
"A" or "A-" runs to the end, "-B" from the beginning; None marks an open side. "A" or "A-" runs to the end, "0-B" from the beginning; None marks an open side.
""" """
ranges = [] ranges = []
for item in text.split(): for item in text.split():
start, dash, end = item.partition("-") if item.startswith("-"):
raise ValueError(f"ungültiger Bereich „{item}“, vom Anfang: 0-B schreiben")
start, _, end = item.partition("-")
try: try:
span = ( span = (parse_user_time(start) or None, parse_user_time(end) if end else None)
parse_user_time(start) if start else None,
parse_user_time(end) if end else None,
)
except ValueError: except ValueError:
raise ValueError(f"ungültiger Bereich „{item}“") from None raise ValueError(f"ungültiger Bereich „{item}“") from None
if span == (None, None): if span == (None, None):
raise ValueError(f"ungültiger Bereich „{item}“") raise ValueError(f"Bereich „{item}“ ist das ganze Video: -z weglassen")
if None not in span and span[0] >= span[1]: if None not in span and span[0] >= span[1]:
raise ValueError(f"Bereich „{item}“: Start muss vor Ende liegen") raise ValueError(f"Bereich „{item}“: Start muss vor Ende liegen")
ranges.append(span) ranges.append(span)
@ -86,17 +75,6 @@ def parse_ranges(text):
return ranges return ranges
def ranges_option(text):
"""argparse type for -z/--zeitraeume."""
try:
return parse_ranges(text)
except ValueError as error:
raise argparse.ArgumentTypeError(
f"{error} (Bereiche durch Leerzeichen getrennt, je A-B, A = ab A, "
f"-B = bis B; Zeiten {TIME_EXAMPLES})"
) from None
def format_time(seconds, separator=":"): def format_time(seconds, separator=":"):
seconds = int(seconds) seconds = int(seconds)
return separator.join( return separator.join(
@ -124,41 +102,35 @@ def time_suffix(ranges):
return "_" + "+".join(range_name(start, end) for start, end in ranges) return "_" + "+".join(range_name(start, end) for start, end in ranges)
RANGE_FORMS = (
"A-B (von A bis B), A (ab A bis zum Ende) oder 0-B (vom Anfang bis B); "
f"Zeiten {TIME_EXAMPLES}"
)
def add_time_options(parser): def add_time_options(parser):
parser.add_argument( parser.add_argument(
"-s", "--start", type=time_option, metavar="ZEIT", "-z", "--zeitraeume", nargs="+", metavar="BEREICH",
help=f"nur Untertitel ab dieser Zeit, {TIME_EXAMPLES}", help=f"nur Untertitel in diesen Bereichen: {RANGE_FORMS}; mehrere durch "
) "Leerzeichen getrennt, z. B. -z 00:05-00:06 00:10-00:12 00:25",
parser.add_argument(
"-e", "--ende", type=time_option, metavar="ZEIT",
help="nur Untertitel vor dieser Zeit (Format wie -s)",
)
parser.add_argument(
"-z", "--zeitraeume", type=ranges_option, metavar="BEREICHE",
help="mehrere Bereiche statt -s/-e, durch Leerzeichen getrennt: A-B, "
"A (ab A bis zum Ende) oder -B (vom Anfang bis B), "
"z. B. \"00:05:00-00:06:00 00:10-00:12 00:25\"",
) )
parser.add_argument( parser.add_argument(
"--exakt", action="store_true", "--exakt", action="store_true",
help="genau an den Zeiten schneiden, statt angeschnittene Sätze vollständig " help="genau an den Zeiten schneiden, statt angeschnittene Sätze vollständig "
f"aufzunehmen (bis {MAX_SNAP_SECONDS} s über den Zeitraum hinaus)", f"aufzunehmen (bis {MAX_SNAP_SECONDS} s über den Bereich hinaus)",
) )
def resolve_time_ranges(parser, args): def resolve_time_ranges(parser, args):
"""Check the time options and set args.bereiche: list of (start, end), maybe empty.""" """Check -z and set args.bereiche: sorted list of (start, end), maybe empty."""
single = args.start is not None or args.ende is not None if args.zeitraeume is None:
if args.zeitraeume is not None:
if single:
parser.error("-z/--zeitraeume nicht zusammen mit -s/-e angeben.")
args.bereiche = args.zeitraeume
elif single:
if args.start is not None and args.ende is not None and args.start >= args.ende:
parser.error("-s/--start muss vor -e/--ende liegen.")
args.bereiche = [(args.start, args.ende)]
else:
args.bereiche = [] args.bereiche = []
return
# Parts with and without quotes alike: "-z '0:05-0:06 0:25'" or "-z 0:05-0:06 0:25"
try:
args.bereiche = parse_ranges(" ".join(args.zeitraeume))
except ValueError as error:
parser.error(f"-z/--zeitraeume: {error} ({RANGE_FORMS})")
def read_cues(vtt): def read_cues(vtt):
@ -423,7 +395,7 @@ def parse_args():
"-o", "--output", "-o", "--output",
help="Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis " help="Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis "
"(vorhanden oder mit / am Ende); Standard: <Eingabe ohne Endung>.txt, " "(vorhanden oder mit / am Ende); Standard: <Eingabe ohne Endung>.txt, "
"mit -s/-e/-z zusätzlich die Zeiträume im Namen", "mit -z zusätzlich die Bereiche im Namen",
) )
add_time_options(parser) add_time_options(parser)
args = parser.parse_args() args = parser.parse_args()