feat: -z -00:20 als Kurzform für 0-00:20 (vom Anfang bis)

Wörter hinter -z, die mit - und einer Ziffer beginnen, werden vor argparse
zu 0-… umgeschrieben; parse_ranges nimmt -B auch in Anführungszeichen an.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-11 20:29:34 +02:00
commit 955bdc2659
6 changed files with 74 additions and 22 deletions

View file

@ -96,7 +96,7 @@ 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 `-z` kommen die Bereiche dazu, z. B. Video-ID. Mit `-z` kommen die Bereiche dazu, z. B.
`…_2026-07-30_00-03-17_bis_00-05-25` (`-z A`: `_ab_…`, `-z 0-B`: `_bis_…`; `…_2026-07-30_00-03-17_bis_00-05-25` (`-z A`: `_ab_…`, `-z 0-B` bzw. `-z -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).
@ -171,15 +171,14 @@ Gilt gleich für `vtt2txt.py`, `extract_text.py` und `rede_korrigieren.py`.
| Aufruf | Text | | Aufruf | Text |
|---|---| |---|---|
| `-z 00:05:10` | ab 5:10 bis zum Ende des Videos | | `-z 00:05:10` | ab 5:10 bis zum Ende des Videos |
| `-z 0-00:20` | vom Anfang bis Minute 20 | | `-z 0-00:20` oder `-z -00:20` | vom Anfang bis Minute 20 |
| `-z 00:05-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 |
Ein Bereich ist `A-B`, `A` (ab A bis zum Ende) oder `0-B` (vom Anfang bis B). Ein Bereich ist `A-B`, `A` (ab A bis zum Ende) oder `0-B` bzw. kurz `-B` (vom
Mehrere Bereiche werden durch Leerzeichen getrennt, mit oder ohne Anfang bis B). Mehrere Bereiche werden durch Leerzeichen getrennt, mit oder ohne
Anführungszeichen (`-z "00:05-00:06 00:25"` ist dasselbe). Sie dürfen sich 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 nicht überschneiden und werden nach der Zeit sortiert.
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:

View file

@ -4,12 +4,13 @@
from pathlib import Path from pathlib import Path
import argparse import argparse
import shutil import shutil
import sys
import tempfile import tempfile
import textwrap import textwrap
from vtt2txt import ( from vtt2txt import (
add_time_options, join_segments, resolve_time_ranges, split_segments, time_suffix, add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
vtt_to_text, time_suffix, vtt_to_text,
) )
from yt_untertitel import ( from yt_untertitel import (
YT_DLP, choose_track, default_name, download, fetch_info, no_track_message, YT_DLP, choose_track, default_name, download, fetch_info, no_track_message,
@ -38,7 +39,7 @@ def parse_args():
help=f"Fließtext, auf {FLOW_WIDTH} Zeichen umbrochen, statt Untertitelzeilen", help=f"Fließtext, auf {FLOW_WIDTH} Zeichen umbrochen, statt Untertitelzeilen",
) )
add_time_options(parser) add_time_options(parser)
args = parser.parse_args() args = parser.parse_args(range_argv(sys.argv[1:]))
resolve_time_ranges(parser, args) resolve_time_ranges(parser, args)
if args.output is not None and args.output.is_dir(): if args.output is not None and args.output.is_dir():

View file

@ -14,8 +14,8 @@ import urllib.request
from aenderungen import REVIEW_MARK, print_summary, report_path, write_report from aenderungen import REVIEW_MARK, print_summary, report_path, write_report
from extract_text import fetch_text from extract_text import fetch_text
from vtt2txt import ( from vtt2txt import (
add_time_options, join_segments, resolve_time_ranges, split_segments, time_suffix, add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
vtt_to_text, time_suffix, vtt_to_text,
) )
from yt_untertitel import YT_DLP, base_language, default_name, original_language from yt_untertitel import YT_DLP, base_language, default_name, original_language
@ -509,7 +509,7 @@ def parse_args():
help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben", help="keinen Änderungsbericht <Ausgabe>.aenderungen.html schreiben",
) )
add_time_options(parser) add_time_options(parser)
args = parser.parse_args() args = parser.parse_args(range_argv(sys.argv[1:]))
resolve_time_ranges(parser, args) resolve_time_ranges(parser, args)
for glossary in args.glossar: for glossary in args.glossar:

View file

@ -532,6 +532,13 @@ def test_main_url_time_range_default_names(from_url, tmp_path, monkeypatch):
assert result == "[00:00:02 - 00:00:03]\n\nDas ist bekannt.\n" assert result == "[00:00:02 - 00:00:03]\n\nDas ist bekannt.\n"
def test_main_url_range_from_beginning(from_url, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
run_main(monkeypatch, "-u", URL, "-z", "-0:00:02")
raw = (tmp_path / "Kanal_Rede_2026-03-28_bis_00-00-02.txt").read_text(encoding="utf-8")
assert raw == "[00:00:01 - 00:00:02]\nDer Kremmel ist in Moskau.\n"
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, "-z", "1:00", "-o", tmp_path / "x.korrigiert.txt") run_main(monkeypatch, "-u", URL, "-z", "1:00", "-o", tmp_path / "x.korrigiert.txt")

View file

@ -190,6 +190,8 @@ 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)]),
("0-00:20", [(None, 1200)]), ("0-00:20", [(None, 1200)]),
("-00:20", [(None, 1200)]),
("-00:05 00:10-00:12", [(None, 300), (600, 720)]),
("0:00:00-0:01", [(None, 60)]), ("0:00:00-0:01", [(None, 60)]),
("00:25- 00:10-00:12 0-00:05", [(None, 300), (600, 720), (1500, None)]), ("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)]),
@ -200,8 +202,9 @@ def test_parse_ranges(text, ranges):
@pytest.mark.parametrize("text, message", [ @pytest.mark.parametrize("text, message", [
("", "keine Bereiche"), ("", "keine Bereiche"),
("-", "vom Anfang: 0-B"), ("-", "ungültiger Bereich"),
("-00:20", "vom Anfang: 0-B"), ("-0", "Start muss vor Ende"),
("-00:05 -00:10", "überschneiden"),
("0", "das ganze Video"), ("0", "das ganze Video"),
("0-", "das ganze Video"), ("0-", "das ganze Video"),
("00:06-00:05", "Start muss vor Ende"), ("00:06-00:05", "Start muss vor Ende"),
@ -384,6 +387,26 @@ def test_main_ranges_with_or_without_quotes(sample_copy, monkeypatch, ranges):
assert target.is_file() assert target.is_file()
@pytest.mark.parametrize("argv, expected", [
(["-i", "x", "-z", "-00:20"], ["-i", "x", "-z", "0-00:20"]),
(["-z", "00:05-00:06", "-00:20", "-o", "f"], ["-z", "00:05-00:06", "0-00:20", "-o", "f"]),
(["--zeitraeume", "-0:01:30"], ["--zeitraeume", "0-0:01:30"]),
(["-z", "0:05", "--exakt", "-1"], ["-z", "0:05", "--exakt", "-1"]),
(["-i", "-5.vtt"], ["-i", "-5.vtt"]),
(["-z=-00:20"], ["-z=-00:20"]),
])
def test_range_argv(argv, expected):
assert vtt2txt.range_argv(argv) == expected
@pytest.mark.parametrize("ranges", [["-0:01"], ["0-0:01"], ["-z=-0:01"]])
def test_main_range_from_beginning(sample_copy, monkeypatch, ranges):
args = ranges if ranges[0].startswith("-z") else ["-z", *ranges]
run_main(monkeypatch, "-i", sample_copy, *args)
target = sample_copy.with_name("ramstein.de_bis_00-01-00.txt")
assert target.read_text(encoding="utf-8").startswith("[00:00:09 - ")
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, "-z", "99:00:00") run_main(monkeypatch, "-i", sample_copy, "-z", "99:00:00")
@ -396,7 +419,7 @@ def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys):
(["-z", "1:00-0:30"], "Start muss vor Ende"), (["-z", "1:00-0:30"], "Start muss vor Ende"),
(["-z", "0:05 0:10"], "überschneiden"), (["-z", "0:05 0:10"], "überschneiden"),
(["-z", "0:05", "0:10"], "überschneiden"), (["-z", "0:05", "0:10"], "überschneiden"),
(["-z=-0:20"], "vom Anfang: 0-B"), (["-z", "-"], "ungültiger Bereich"),
(["-z", "0"], "das ganze Video"), (["-z", "0"], "das ganze Video"),
(["-s", "0:01"], "unrecognized arguments: -s"), (["-s", "0:01"], "unrecognized arguments: -s"),
]) ])

View file

@ -48,20 +48,24 @@ def parse_user_time(text):
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, "0-B" from the beginning; None marks an open side. "A" or "A-" runs to the end, "0-B" or "-B" from the beginning; None marks an
open side.
""" """
ranges = [] ranges = []
for item in text.split(): for item in text.split():
if item.startswith("-"):
raise ValueError(f"ungültiger Bereich „{item}“, vom Anfang: 0-B schreiben")
start, _, end = item.partition("-") start, _, end = item.partition("-")
if item == "-":
raise ValueError(f"ungültiger Bereich „{item}“")
try: try:
span = (parse_user_time(start) or None, parse_user_time(end) if end else None) span = (
parse_user_time(start) or None 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"Bereich „{item}“ ist das ganze Video: -z weglassen") raise ValueError(f"Bereich „{item}“ ist das ganze Video: -z weglassen")
if None not in span and span[0] >= span[1]: if span[1] is not None and (span[0] or 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)
if not ranges: if not ranges:
@ -103,7 +107,7 @@ def time_suffix(ranges):
RANGE_FORMS = ( RANGE_FORMS = (
"A-B (von A bis B), A (ab A bis zum Ende) oder 0-B (vom Anfang bis B); " "A-B (von A bis B), A (ab A bis zum Ende) oder 0-B bzw. -B (vom Anfang bis B); "
f"Zeiten {TIME_EXAMPLES}" f"Zeiten {TIME_EXAMPLES}"
) )
@ -121,6 +125,24 @@ def add_time_options(parser):
) )
def range_argv(argv):
"""Copy of `argv` with "-00:20" after -z written as "0-00:20".
argparse takes a word starting with "-" for an option and would reject it;
no option starts with "-" and a digit, so such a word can only be a range.
"""
result = []
after_ranges = False
for word in argv:
if re.match(r"-\d", word):
if after_ranges:
word = f"0{word}"
elif word.startswith("-"):
after_ranges = word in ("-z", "--zeitraeume")
result.append(word)
return result
def resolve_time_ranges(parser, args): def resolve_time_ranges(parser, args):
"""Check -z and set args.bereiche: sorted list of (start, end), maybe empty.""" """Check -z and set args.bereiche: sorted list of (start, end), maybe empty."""
if args.zeitraeume is None: if args.zeitraeume is None:
@ -398,7 +420,7 @@ def parse_args():
"mit -z zusätzlich die Bereiche 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(range_argv(sys.argv[1:]))
resolve_time_ranges(parser, args) resolve_time_ranges(parser, args)
suffix = time_suffix(args.bereiche) suffix = time_suffix(args.bereiche)