#!/usr/bin/env python3 """Wandelt WebVTT-Untertitel (z. B. YouTube-Auto-Untertitel) in eine Rohabschrift um, die anschließend mit rede_korrigieren.py bearbeitet werden kann.""" from pathlib import Path import argparse import glob import html import os import re import sys TAG = re.compile(r"<[^>]*>") GLOB_CHARS = re.compile(r"[*?\[]") def read_cues(vtt): """Yield the non-empty, tag-free text lines of each cue.""" cue = None for line in vtt.splitlines(): if "-->" in line: if cue is not None: yield cue 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 cue = None continue # Header, NOTE/STYLE blocks and cue identifiers if cue is None: continue text = " ".join(html.unescape(TAG.sub("", line)).split()) if text: cue.append(text) if cue is not None: yield cue def overlap(previous, cue): """Length of the longest end of `previous` that starts `cue`.""" for size in range(min(len(previous), len(cue)), 0, -1): if previous[-size:] == cue[:size]: return size return 0 def vtt_to_lines(vtt): """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. """ lines = [] previous = [] for cue in read_cues(vtt): # Empty cues (YouTube transitions) say nothing about the window if not cue: continue lines.extend(cue[overlap(previous, cue):]) previous = cue return lines def convert(source, target): """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) if not lines: return f"Keine Untertitel gefunden: {source}" target.parent.mkdir(parents=True, exist_ok=True) target.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Erstellt: {target} ({len(lines)} Zeilen)") return None def expand_inputs(patterns): """Resolve glob patterns (for shells that do not, e.g. on Windows). Returns the files in order without duplicates, or the first pattern without match. """ sources = [] for pattern in patterns: if GLOB_CHARS.search(pattern): matches = sorted(glob.glob(pattern)) if not matches: return None, pattern else: matches = [pattern] for match in matches: path = Path(match) if path not in sources: sources.append(path) return sources, None def parse_args(): parser = argparse.ArgumentParser( description="Wandelt VTT-Untertitel in eine Textdatei für rede_korrigieren.py um." ) parser.add_argument( "-i", "--input", required=True, nargs="+", help="VTT-Datei(en) oder Muster, z. B. \"ramstein.de*.vtt\"", ) parser.add_argument( "-o", "--output", help="Ausgabedatei (nur bei einer Eingabedatei) oder Verzeichnis " "(vorhanden oder mit / am Ende); Standard: .txt", ) args = parser.parse_args() sources, unmatched = expand_inputs(args.input) if unmatched is not None: parser.error(f"Keine Datei passt zu: {unmatched}") for source in sources: if not source.is_file(): parser.error(f"Eingabedatei nicht gefunden: {source}") if source.suffix.lower() != ".vtt": parser.error(f"Keine VTT-Datei: {source}") output = args.output to_dir = output is not None and ( output.endswith(("/", os.sep)) or Path(output).is_dir() ) if output is not None and not to_dir and len(sources) > 1: parser.error("Bei mehreren Eingabedateien muss -o/--output ein Verzeichnis sein.") args.jobs = [] for source in sources: if output is None: target = source.with_suffix(".txt") elif to_dir: target = Path(output) / source.with_suffix(".txt").name else: target = Path(output) if target.resolve() == source.resolve(): parser.error("Ausgabedatei darf nicht die Eingabedatei sein.") if any(target.resolve() == other.resolve() for _, other in args.jobs): parser.error(f"Mehrere Eingabedateien ergeben dieselbe Ausgabedatei: {target}") args.jobs.append((source, target)) return args def main(): args = parse_args() 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) if error: print(f"FEHLER: {error}", file=sys.stderr) failed += 1 if failed: sys.exit(1) if __name__ == "__main__": main()