From 1668a8dcadaaa02b2d64d870c873149d3d8cb0f3 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 11 Oct 2026 19:06:36 +0200 Subject: [PATCH] feat: Zeitraum-Auswahl (-s/-e) in vtt2txt.py Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- tests/test_vtt2txt.py | 63 +++++++++++++++++++++++++ vtt2txt.py | 106 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 159 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d91beeb..3c445fb 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,15 @@ zweimal, bleibt sie zweimal erhalten. ./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 *.vtt -o out/ # alle in ein Verzeichnis +./vtt2txt.py -i rede.de.vtt -s 3:17 -e 5:25 # nur ein Ausschnitt → rede.de_00-03-17_bis_00-05-25.txt ``` | Option | Bedeutung | |---|---| | `-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: `.txt` neben der Eingabe | +| `-o`, `--output` | Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis (vorhanden oder mit `/` am Ende); Standard: `.txt` neben der Eingabe, mit `-s`/`-e` zusätzlich der Zeitraum im Namen | +| `-s`, `--start` | nur Untertitel ab dieser Zeit (`00:03:17`, `3:17` oder `197` Sekunden) | +| `-e`, `--ende` | nur Untertitel vor dieser Zeit (gleiches Format) | Eine fehlerhafte Datei (keine Untertitel, nicht UTF-8) wird gemeldet, die übrigen werden trotzdem umgewandelt; der Exit-Code ist dann 1. diff --git a/tests/test_vtt2txt.py b/tests/test_vtt2txt.py index 51ab3d9..27cd30e 100644 --- a/tests/test_vtt2txt.py +++ b/tests/test_vtt2txt.py @@ -162,3 +162,66 @@ def test_cli_errors(sample_copy, monkeypatch, capsys, args, message): run_main(monkeypatch, *args) assert exc.value.code == 2 assert message in capsys.readouterr().err + + +@pytest.mark.parametrize("text, seconds", [ + ("00:03:17", 197), ("3:17", 197), ("197", 197), ("00:03:17.5", 197.5), + ("00:03:17,5", 197.5), ("1:02:03", 3723), +]) +def test_parse_time(text, seconds): + assert vtt2txt.parse_time(text) == seconds + + +@pytest.mark.parametrize("text", ["", "abc", "1:2:3:4", "-5"]) +def test_parse_time_rejects_invalid(text): + with pytest.raises(ValueError): + vtt2txt.parse_time(text) + + +@pytest.mark.parametrize("start, end, suffix", [ + (None, None, ""), + (197, 325, "_00-03-17_bis_00-05-25"), + (197, None, "_ab_00-03-17"), + (None, 325, "_bis_00-05-25"), +]) +def test_time_suffix(start, end, suffix): + assert vtt2txt.time_suffix(start, end) == suffix + + +def test_vtt_to_lines_time_range(): + vtt = "WEBVTT\n\n" + cue(1, "A") + cue(2, "A", "B") + cue(3, "B", "C") + cue(4, "C", "D") + assert vtt2txt.vtt_to_lines(vtt, start=2, end=4) == ["B", "C"] + assert vtt2txt.vtt_to_lines(vtt, start=3) == ["C", "D"] + assert vtt2txt.vtt_to_lines(vtt, end=2) == ["A"] + + +def test_vtt_to_lines_time_range_skips_window_from_before_start(): + # "B" was said before the range; its repetition in the first cue must not count + vtt = "WEBVTT\n\n" + cue(1, "A", "B") + cue(2, "B", "C") + assert vtt2txt.vtt_to_lines(vtt, start=2) == ["C"] + + +def test_main_time_range_default_name(sample_copy, monkeypatch): + run_main(monkeypatch, "-i", sample_copy, "-s", "0:30", "-e", "1:00") + target = sample_copy.with_name("ramstein.de_00-00-30_bis_00-01-00.txt") + full = SAMPLE_TXT.read_text(encoding="utf-8").splitlines() + part = target.read_text(encoding="utf-8").splitlines() + assert 0 < len(part) < len(full) + + +def test_main_time_range_without_captions(sample_copy, monkeypatch, capsys): + with pytest.raises(SystemExit) as exc: + run_main(monkeypatch, "-i", sample_copy, "-s", "99:00:00") + assert exc.value.code == 1 + assert "im gewählten Zeitraum" in capsys.readouterr().err + + +@pytest.mark.parametrize("args, message", [ + (["-s", "abc"], "ungültige Zeit"), + (["-s", "1:00", "-e", "0:30"], "muss vor -e/--ende liegen"), +]) +def test_cli_time_errors(sample_copy, monkeypatch, capsys, args, message): + with pytest.raises(SystemExit) as exc: + run_main(monkeypatch, "-i", sample_copy, *args) + assert exc.value.code == 2 + assert message in capsys.readouterr().err diff --git a/vtt2txt.py b/vtt2txt.py index 157ad74..5336192 100755 --- a/vtt2txt.py +++ b/vtt2txt.py @@ -11,23 +11,84 @@ import sys TAG = re.compile(r"<[^>]*>") GLOB_CHARS = re.compile(r"[*?\[]") +TIME = re.compile(r"(?:(\d+):)??(?:(\d+):)?(\d+(?:[.,]\d+)?)") +CUE_START = re.compile(r"^\s*([\d:.,]+)\s*-->") + + +def parse_time(text): + """Seconds from "00:03:17", "3:17", "197" or "00:03:17.5".""" + match = TIME.fullmatch(text.strip()) + if not match: + raise ValueError(f"Ungültige Zeit: {text}") + hours, minutes, seconds = match.groups() + return int(hours or 0) * 3600 + int(minutes or 0) * 60 + float(seconds.replace(",", ".")) + + +def time_option(text): + """argparse type for -s/--start and -e/--ende.""" + try: + return parse_time(text) + except ValueError: + raise argparse.ArgumentTypeError( + f"ungültige Zeit „{text}“ (z. B. 00:03:17, 3:17 oder 197)" + ) from None + + +def format_time(seconds, separator=":"): + seconds = int(seconds) + return separator.join( + f"{value:02d}" for value in (seconds // 3600, seconds // 60 % 60, seconds % 60) + ) + + +def time_suffix(start, end): + """Part of a default file name for a time range: "_00-03-17_bis_00-05-25".""" + if start is None and end is None: + return "" + if end is None: + return f"_ab_{format_time(start, '-')}" + if start is None: + return f"_bis_{format_time(end, '-')}" + return f"_{format_time(start, '-')}_bis_{format_time(end, '-')}" + + +def add_time_options(parser): + parser.add_argument( + "-s", "--start", type=time_option, metavar="ZEIT", + help="nur Untertitel ab dieser Zeit, z. B. 00:03:17", + ) + parser.add_argument( + "-e", "--ende", type=time_option, metavar="ZEIT", + help="nur Untertitel vor dieser Zeit, z. B. 00:05:25", + ) + + +def check_time_range(parser, args): + 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.") def read_cues(vtt): - """Yield the non-empty, tag-free text lines of each cue.""" + """Yield (start in seconds, non-empty tag-free text lines) of each cue.""" cue = None + start = None for line in vtt.splitlines(): if "-->" in line: if cue is not None: - yield cue + yield start, cue + match = CUE_START.match(line) + try: + start = parse_time(match.group(1)) if match else None + except ValueError: + start = None cue = [] continue # Only a truly empty line ends a cue; YouTube puts lines with a single # space inside cues, followed by more text if not line: if cue is not None: - yield cue + yield start, cue cue = None continue # Header, NOTE/STYLE blocks and cue identifiers @@ -39,7 +100,7 @@ def read_cues(vtt): cue.append(text) if cue is not None: - yield cue + yield start, cue def overlap(previous, cue): @@ -50,36 +111,48 @@ def overlap(previous, cue): return 0 -def vtt_to_lines(vtt): +def in_range(time, start, end): + if time is None: + return start is None and end is None + return (start is None or time >= start) and (end is None or time < end) + + +def vtt_to_lines(vtt, start=None, end=None): """Return the caption text lines of a VTT document without rolling duplicates. Auto captions show a rolling window: each cue repeats the end of the previous cue before adding new text. Only the lines after that overlap are new, so a line the speaker really says twice in a row is kept. + + With `start`/`end` (seconds) only the new lines of cues beginning in that + range are kept. The window is followed through all cues, so a line repeated + from before `start` does not slip in. """ lines = [] previous = [] - for cue in read_cues(vtt): + for time, cue in read_cues(vtt): # Empty cues (YouTube transitions) say nothing about the window if not cue: continue - lines.extend(cue[overlap(previous, cue):]) + if in_range(time, start, end): + lines.extend(cue[overlap(previous, cue):]) previous = cue return lines -def convert(source, target): +def convert(source, target, start=None, end=None): """Convert one file; return an error message or None on success.""" try: vtt = source.read_text(encoding="utf-8-sig") except UnicodeDecodeError: return f"Keine UTF-8-Datei: {source}" - lines = vtt_to_lines(vtt) + lines = vtt_to_lines(vtt, start, end) if not lines: - return f"Keine Untertitel gefunden: {source}" + where = "" if start is None and end is None else " im gewählten Zeitraum" + return f"Keine Untertitel{where} gefunden: {source}" target.parent.mkdir(parents=True, exist_ok=True) target.write_text("\n".join(lines) + "\n", encoding="utf-8") @@ -119,9 +192,13 @@ def parse_args(): parser.add_argument( "-o", "--output", help="Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis " - "(vorhanden oder mit / am Ende); Standard: .txt", + "(vorhanden oder mit / am Ende); Standard: .txt, " + "mit -s/-e zusätzlich der Zeitraum im Namen", ) + add_time_options(parser) args = parser.parse_args() + check_time_range(parser, args) + suffix = time_suffix(args.start, args.ende) sources, unmatched = expand_inputs(args.input) if unmatched is not None: @@ -141,10 +218,11 @@ def parse_args(): args.jobs = [] for source in sources: + name = f"{source.stem}{suffix}.txt" if output is None: - target = source.with_suffix(".txt") + target = source.with_name(name) elif to_dir: - target = Path(output) / source.with_suffix(".txt").name + target = Path(output) / name else: target = Path(output) if target.resolve() == source.resolve(): @@ -160,7 +238,7 @@ def main(): failed = 0 # Keep going on errors so one bad file does not stop a batch for source, target in args.jobs: - error = convert(source, target) + error = convert(source, target, args.start, args.ende) if error: print(f"FEHLER: {error}", file=sys.stderr) failed += 1