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
60 Zeichen an einer Wortgrenze gekürzt. Ohne verwertbaren Kanal und Titel gilt die
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`).
Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird
ü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 |
|---|---|
| `-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: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).
Mehrere Bereiche werden durch Leerzeichen getrennt, mit oder ohne
Ein Bereich ist `A-B`, `A` (ab A bis zum Ende) oder `0-B` bzw. kurz `-B` (vom
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
nicht überschneiden und werden nach der Zeit sortiert. `-B` geht nicht, weil
die Kommandozeile es für eine Option hält.
nicht überschneiden und werden nach der Zeit sortiert.
Alle Bereiche landen in **einer** Datei. Vor jedem Bereich steht eine Überschrift
mit seinen tatsächlichen Zeiten:

View file

@ -4,12 +4,13 @@
from pathlib import Path
import argparse
import shutil
import sys
import tempfile
import textwrap
from vtt2txt import (
add_time_options, join_segments, resolve_time_ranges, split_segments, time_suffix,
vtt_to_text,
add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
time_suffix, vtt_to_text,
)
from yt_untertitel import (
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",
)
add_time_options(parser)
args = parser.parse_args()
args = parser.parse_args(range_argv(sys.argv[1:]))
resolve_time_ranges(parser, args)
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 extract_text import fetch_text
from vtt2txt import (
add_time_options, join_segments, resolve_time_ranges, split_segments, time_suffix,
vtt_to_text,
add_time_options, join_segments, range_argv, resolve_time_ranges, split_segments,
time_suffix, vtt_to_text,
)
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",
)
add_time_options(parser)
args = parser.parse_args()
args = parser.parse_args(range_argv(sys.argv[1:]))
resolve_time_ranges(parser, args)
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"
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):
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")

View file

@ -190,6 +190,8 @@ def test_parse_cue_time_keeps_minutes_seconds(text, seconds):
@pytest.mark.parametrize("text, ranges", [
("00:5:00-00:6:00 00:10-00:12:00 00:25", [(300, 360), (600, 720), (1500, None)]),
("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)]),
("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)]),
@ -200,8 +202,9 @@ def test_parse_ranges(text, ranges):
@pytest.mark.parametrize("text, message", [
("", "keine Bereiche"),
("-", "vom Anfang: 0-B"),
("-00:20", "vom Anfang: 0-B"),
("-", "ungültiger Bereich"),
("-0", "Start muss vor Ende"),
("-00:05 -00:10", "überschneiden"),
("0", "das ganze Video"),
("0-", "das ganze Video"),
("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()
@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):
with pytest.raises(SystemExit) as exc:
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", "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"),
(["-s", "0:01"], "unrecognized arguments: -s"),
])

View file

@ -48,20 +48,24 @@ def parse_user_time(text):
def parse_ranges(text):
"""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 = []
for item in text.split():
if item.startswith("-"):
raise ValueError(f"ungültiger Bereich „{item}“, vom Anfang: 0-B schreiben")
start, _, end = item.partition("-")
if item == "-":
raise ValueError(f"ungültiger Bereich „{item}“")
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:
raise ValueError(f"ungültiger Bereich „{item}“") from None
if span == (None, None):
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")
ranges.append(span)
if not ranges:
@ -103,7 +107,7 @@ def time_suffix(ranges):
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}"
)
@ -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):
"""Check -z and set args.bereiche: sorted list of (start, end), maybe empty."""
if args.zeitraeume is None:
@ -398,7 +420,7 @@ def parse_args():
"mit -z zusätzlich die Bereiche im Namen",
)
add_time_options(parser)
args = parser.parse_args()
args = parser.parse_args(range_argv(sys.argv[1:]))
resolve_time_ranges(parser, args)
suffix = time_suffix(args.bereiche)