From 3d9a5037c27dd87727334074371e94284912d539 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 10 Oct 2026 21:03:14 +0200 Subject: [PATCH] feat: Standard-Dateiname aus Kanal, Titel und Datum des Videos - yt_untertitel.default_name(): __, z. B. John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30 - Bindestriche/Punkte im Wort bleiben, Kanal max. 40, Titel max. 60 Zeichen an Wortgrenze; ohne Kanal und Titel die Video-ID - Gilt ohne -o/-n in extract_text.py (statt output.txt), yt_untertitel.py und rede_korrigieren.py -u (statt Video-ID) Co-Authored-By: Claude Opus 5.5 --- README.md | 31 ++++++++++++++++++++++------- extract_text.py | 18 +++++++++-------- rede_korrigieren.py | 6 +++--- tests/test_extract_text.py | 8 +++++--- tests/test_rede_korrigieren.py | 11 +++++++---- tests/test_yt_untertitel.py | 24 +++++++++++++++++++++-- yt_untertitel.py | 36 ++++++++++++++++++++++++++++++++-- 7 files changed, 105 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0f769ca..d91beeb 100644 --- a/README.md +++ b/README.md @@ -62,19 +62,36 @@ Zwischenschritt im temporären Verzeichnis. Die Spurwahl ist dieselbe wie bei `yt_untertitel.py`, nur ist die Standardsprache die des Original-Audios. ```bash -./extract_text.py -u 'https://www.youtube.com/watch?v=rm6lADgf6GQ' # → output.txt +./extract_text.py -u 'https://www.youtube.com/watch?v=0LmCveTM788' +# → 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 -f -o rede.txt # lesbarer Fließtext -./rede_korrigieren.py -i output.txt # → output.korrigiert.txt +./rede_korrigieren.py -i rede.txt # → rede.korrigiert.txt ``` | Option | Bedeutung | |---|---| | `-u`, `--url` | YouTube-URL, Pflicht | | `-l`, `--lang` | Sprache; Standard: Sprache des Original-Audios (`-orig`) | -| `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `output.txt` | +| `-o`, `--output` | Ausgabedatei (Verzeichnisse werden angelegt, vorhandene Datei überschrieben); Standard: `__.txt`, siehe unten | | `-f`, `--fliesstext` | Fließtext, auf 80 Zeichen umbrochen (Wörter werden nie getrennt), statt der kurzen Untertitelzeilen; beides versteht `rede_korrigieren.py` | +### Standard-Dateinamen für YouTube-Videos + +Ohne `-o` bzw. `-n` bilden `extract_text.py`, `yt_untertitel.py` und +`rede_korrigieren.py -u` den Namen aus Kanal, Titel und Hochladedatum: + +| Video | Name | +|---|---| +| John Mearsheimer, „Episode 1: Origins of the War In Ukraine“ | `John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30` | +| GGBLahnstein, „Prof. Gabriele Krone-Schmalz – Respekt geht anders – …“ | `GGBLahnstein_Prof_Gabriele_Krone-Schmalz_Respekt_geht_anders_2026-07-30` | + +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. Gleiches Video ergibt immer denselben Namen; eine vorhandene Datei wird +überschrieben (ein mit `-u` gespeicherter, abweichender Rohtext aber nie). + ### Untertitel herunterladen: `yt_untertitel.py` Lädt die Untertitel eines YouTube-Videos über `yt-dlp` als VTT-Datei, ohne das @@ -94,14 +111,14 @@ gehören zur Synchronisation und werden nicht genommen. Sprachen werden auch mit Region gefunden: `-l en` findet manuelle Untertitel `en-US`. ```bash -./yt_untertitel.py -u URL # → .de-orig.vtt +./yt_untertitel.py -u URL # → __.de-orig.vtt ./yt_untertitel.py -u URL -n Lahnstein -d testdaten # → testdaten/Lahnstein.de-orig.vtt ``` | Option | Bedeutung | |---|---| | `-u`, `--url` | YouTube-URL, Pflicht | -| `-n`, `--name` | Dateiname ohne Endung; Standard: Video-ID | +| `-n`, `--name` | Dateiname ohne Endung; Standard: `__` | | `-l`, `--lang` | Sprache; Standard: `de` | | `-d`, `--dir` | Zielverzeichnis (wird angelegt); Standard: aktuelles Verzeichnis | @@ -139,7 +156,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1. ./rede_korrigieren.py -i rede.txt -g rede.glossar.txt # mit Glossar ./rede_korrigieren.py -i rede.txt -g allgemein.glossar.txt -g rede.glossar.txt # kombiniert ./rede_korrigieren.py -i rede.de.vtt # VTT direkt → rede.de.korrigiert.txt -./rede_korrigieren.py -u URL # YouTube → .korrigiert.txt +./rede_korrigieren.py -u URL # YouTube → __.korrigiert.txt ./rede_korrigieren.py -i speech.txt -l en # englische Rede ``` @@ -148,7 +165,7 @@ werden trotzdem umgewandelt; der Exit-Code ist dann 1. | `-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 `.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 | -| `-o`, `--output` | Ausgabedatei; Standard: `.korrigiert.txt` neben der Eingabe, mit `-u` `.korrigiert.txt` | +| `-o`, `--output` | Ausgabedatei; Standard: `.korrigiert.txt` neben der Eingabe, mit `-u` `__.korrigiert.txt` | | `-g`, `--glossar` | Glossar mit festen Ersetzungen und Schreibweisen; mehrfach angeben zum Kombinieren (Ersetzungen in Reihenfolge der Angabe) | | `-m`, `--model` | Ollama-Modell; Standard: `qwen3:32b` (Konstante `MODEL`) | | `--kein-bericht` | keinen Änderungsbericht schreiben | diff --git a/extract_text.py b/extract_text.py index 85b7cc5..8b29f67 100755 --- a/extract_text.py +++ b/extract_text.py @@ -9,7 +9,8 @@ import textwrap from vtt2txt import vtt_to_lines from yt_untertitel import ( - YT_DLP, choose_track, download, fetch_info, no_track_message, original_language, + YT_DLP, choose_track, default_name, download, fetch_info, no_track_message, + original_language, ) FLOW_WIDTH = 80 # Zeilenbreite für --fliesstext @@ -25,8 +26,8 @@ def parse_args(): help="Sprache der Untertitel, z. B. de (Standard: Sprache des Original-Audios)", ) parser.add_argument( - "-o", "--output", type=Path, default=Path("output.txt"), - help="Ausgabedatei (Standard: output.txt)", + "-o", "--output", type=Path, + help="Ausgabedatei (Standard: __.txt)", ) parser.add_argument( "-f", "--fliesstext", action="store_true", @@ -34,7 +35,7 @@ def parse_args(): ) args = parser.parse_args() - if args.output.is_dir(): + if args.output is not None and args.output.is_dir(): parser.error(f"-o/--output ist ein Verzeichnis: {args.output}") if shutil.which(YT_DLP) is None: parser.error(f"{YT_DLP} nicht gefunden (Installation: pip install yt-dlp).") @@ -73,7 +74,8 @@ def fetch_text(url, lang=None): def main(): args = parse_args() - _, lines = fetch_text(args.url, args.lang) + info, lines = fetch_text(args.url, args.lang) + output = args.output or Path(f"{default_name(info)}.txt") if args.fliesstext: # Never split words, not even at hyphens ("EU-Gipfel") @@ -84,10 +86,10 @@ def main(): else: text = "\n".join(lines) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(text + "\n", encoding="utf-8") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(text + "\n", encoding="utf-8") print( - f"Gespeichert: {args.output} " + f"Gespeichert: {output} " f"({len(text.splitlines())} Zeilen, {len(text.split())} Wörter)" ) diff --git a/rede_korrigieren.py b/rede_korrigieren.py index 42d7292..5eaa859 100755 --- a/rede_korrigieren.py +++ b/rede_korrigieren.py @@ -13,7 +13,7 @@ import urllib.request from aenderungen import REVIEW_MARK, print_summary, report_path, write_report from extract_text import fetch_text from vtt2txt import vtt_to_lines -from yt_untertitel import YT_DLP, base_language, original_language +from yt_untertitel import YT_DLP, base_language, default_name, original_language MODEL = "qwen3:32b" OLLAMA_URL = "http://127.0.0.1:11434/api/chat" @@ -452,7 +452,7 @@ def parse_args(): parser.add_argument( "-o", "--output", type=Path, help="Ausgabedatei (Standard: .korrigiert.txt, " - "mit -u .korrigiert.txt)", + "mit -u __.korrigiert.txt)", ) parser.add_argument( "-g", "--glossar", type=Path, action="append", default=[], @@ -516,7 +516,7 @@ def read_source(args): raise SystemExit( f"Die Rede ist auf „{lang}“; unterstützt: {', '.join(PROMPTS)} (-l)." ) - target = args.output or Path(f"{info['id']}.korrigiert.txt") + target = args.output or Path(f"{default_name(info)}.korrigiert.txt") raw = "\n".join(lines) + "\n" save_raw_text(raw_text_path(target), raw) return raw, target, lang diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py index cb4f18b..a993081 100644 --- a/tests/test_extract_text.py +++ b/tests/test_extract_text.py @@ -17,6 +17,8 @@ VTT = ( GERMAN_VIDEO = { "id": "abc123", "title": "Eine Rede", + "channel": "Kanal", + "upload_date": "20260328", "subtitles": {"en": []}, "automatic_captions": {"de-orig": [], "de": [], "en": []}, } @@ -36,14 +38,14 @@ def test_default_language_is_original_audio(fake_yt_dlp, tmp_path, monkeypatch, monkeypatch.chdir(tmp_path) run_main(monkeypatch, "-u", URL) - text = (tmp_path / "output.txt").read_text(encoding="utf-8") + text = (tmp_path / "Kanal_Eine_Rede_2026-03-28.txt").read_text(encoding="utf-8") assert text == "Guten Abend.\nSchön, dass Sie da sind.\n" assert "de-orig" in run.calls[1] assert "--write-auto-subs" in run.calls[1] out = capsys.readouterr().out - assert "output.txt (2 Zeilen, 7 Wörter)" in out + assert "Kanal_Eine_Rede_2026-03-28.txt (2 Zeilen, 7 Wörter)" in out # Only the text file remains, no VTT - assert [p.name for p in tmp_path.iterdir()] == ["output.txt"] + assert [p.name for p in tmp_path.iterdir()] == ["Kanal_Eine_Rede_2026-03-28.txt"] def test_default_language_ignores_dubbed_tracks(fake_yt_dlp, tmp_path, monkeypatch): diff --git a/tests/test_rede_korrigieren.py b/tests/test_rede_korrigieren.py index 939c6fa..b406d95 100644 --- a/tests/test_rede_korrigieren.py +++ b/tests/test_rede_korrigieren.py @@ -378,7 +378,10 @@ def test_correct_chunk_sends_model(monkeypatch): URL = "https://www.youtube.com/watch?v=abc123" -VIDEO = {"id": "abc123", "title": "Rede", "subtitles": {}, "automatic_captions": {"de-orig": []}} +VIDEO = { + "id": "abc123", "title": "Rede", "channel": "Kanal", "upload_date": "20260328", + "subtitles": {}, "automatic_captions": {"de-orig": []}, +} VTT = ( "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nDer Kremmel ist in Moskau.\n\n" "00:00:02.000 --> 00:00:03.000\nDer Kremmel ist in Moskau.\nDas ist bekannt.\n" @@ -397,11 +400,11 @@ def test_main_url_default_names(from_url, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) run_main(monkeypatch, "-u", URL, "-g", SHARED_GLOSSARY) - raw = (tmp_path / "abc123.txt").read_text(encoding="utf-8") + raw = (tmp_path / "Kanal_Rede_2026-03-28.txt").read_text(encoding="utf-8") assert raw == "Der Kremmel ist in Moskau.\nDas ist bekannt.\n" - result = (tmp_path / "abc123.korrigiert.txt").read_text(encoding="utf-8") + result = (tmp_path / "Kanal_Rede_2026-03-28.korrigiert.txt").read_text(encoding="utf-8") assert result == "Der Kreml ist in Moskau. Das ist bekannt.\n" - assert (tmp_path / "abc123.aenderungen.html").is_file() + assert (tmp_path / "Kanal_Rede_2026-03-28.aenderungen.html").is_file() def test_main_url_output_option_and_kept_raw_text(from_url, tmp_path, monkeypatch, capsys): diff --git a/tests/test_yt_untertitel.py b/tests/test_yt_untertitel.py index 701430d..7bd446e 100644 --- a/tests/test_yt_untertitel.py +++ b/tests/test_yt_untertitel.py @@ -12,6 +12,8 @@ URL = "https://www.youtube.com/watch?v=abc123" AUTO_ONLY = { "id": "abc123", "title": "Eine Rede", + "channel": "Kanal", + "upload_date": "20260328", "subtitles": {}, "automatic_captions": {"de-orig": [], "de": [], "en": []}, } @@ -86,6 +88,24 @@ def test_original_language_with_dubbed_audio(info, expected): assert yt.original_language(info) == expected +@pytest.mark.parametrize("info, expected", [ + ({"channel": "John Mearsheimer - Offensive Realism", + "title": "Episode 1: Origins of the War In Ukraine", "upload_date": "20260730"}, + "John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30"), + # Title cut at a word boundary after 60 characters; hyphens and dots in words stay + ({"channel": "GGBLahnstein", "upload_date": "20260730", + "title": "Prof. Gabriele Krone-Schmalz – Respekt geht anders – " + "GGB-Gesundheitstage, Lahnstein 28.03.2026"}, + "GGBLahnstein_Prof_Gabriele_Krone-Schmalz_Respekt_geht_anders_2026-07-30"), + ({"uploader": "Ein Kanal", "title": "A/B: Krieg & Frieden 🎤?"}, "Ein_Kanal_A_B_Krieg_Frieden"), + ({"title": "Nur Titel", "upload_date": "kaputt"}, "Nur_Titel"), + ({"id": "abc123", "title": "🎤 – …"}, "abc123"), + ({"title": "x" * 80}, "x" * 60), +]) +def test_default_name(info, expected): + assert yt.default_name(info) == expected + + def test_available_tracks_hides_dubs(): assert yt.available_tracks(DUBBED) == (["en-US"], ["en"]) @@ -124,13 +144,13 @@ def test_yt_dlp_runs_in_temporary_directory(fake_yt_dlp, tmp_path, monkeypatch): assert (tmp_path / "Rede.de-orig.vtt").is_file() -def test_main_prefers_manual_and_defaults_to_video_id(fake_yt_dlp, tmp_path, monkeypatch): +def test_main_prefers_manual_and_default_name(fake_yt_dlp, tmp_path, monkeypatch): info = dict(AUTO_ONLY, subtitles={"de": []}) run = fake_yt_dlp(info) monkeypatch.setattr(yt.subprocess, "run", run) run_main(monkeypatch, "-u", URL, "-d", tmp_path) - assert (tmp_path / "abc123.de.vtt").is_file() + assert (tmp_path / "Kanal_Eine_Rede_2026-03-28.de.vtt").is_file() assert "--write-subs" in run.calls[1] diff --git a/yt_untertitel.py b/yt_untertitel.py index dcd2cce..9a90b32 100755 --- a/yt_untertitel.py +++ b/yt_untertitel.py @@ -4,12 +4,17 @@ als Eingabe für vtt2txt.py bzw. rede_korrigieren.py.""" from pathlib import Path import argparse import json +import re import shutil import subprocess import tempfile YT_DLP = "yt-dlp" +NAME_WORD = re.compile(r"\w+(?:[-.]\w+)*") # keeps "Krone-Schmalz", "28.03.2026" +MAX_CHANNEL_CHARS = 40 +MAX_TITLE_CHARS = 60 + def run_yt_dlp(arguments): """Run yt-dlp in a temporary working directory. @@ -80,6 +85,33 @@ def choose_track(info, lang): return None +def name_part(text, limit): + """Words of `text` joined by "_", cut at a word boundary after `limit` chars.""" + words = NAME_WORD.findall(text or "") + part = "" + for word in words: + candidate = f"{part}_{word}" if part else word + if len(candidate) > limit: + break + part = candidate + # A single overlong word is cut rather than dropped + return part or (words[0][:limit] if words else "") + + +def default_name(info): + """File name without extension from channel, title and upload date. + + "John_Mearsheimer_Offensive_Realism_Episode_1_Origins_of_the_War_In_Ukraine_2026-07-30" + """ + date = info.get("upload_date") or "" + parts = [ + name_part(info.get("channel") or info.get("uploader"), MAX_CHANNEL_CHARS), + name_part(info.get("title"), MAX_TITLE_CHARS), + f"{date[:4]}-{date[4:6]}-{date[6:]}" if re.fullmatch(r"\d{8}", date) else "", + ] + return "_".join(p for p in parts if p) or info.get("id") or "video" + + def available_tracks(info): """Manual tracks and the automatic ones usable for this program.""" manual = sorted((info.get("subtitles") or {}).keys()) @@ -126,7 +158,7 @@ def parse_args(): parser.add_argument("-u", "--url", required=True, help="YouTube-URL des Videos") parser.add_argument( "-n", "--name", - help="Dateiname ohne Endung, z. B. Lahnstein (Standard: Video-ID)", + help="Dateiname ohne Endung, z. B. Lahnstein (Standard: __)", ) parser.add_argument( "-l", "--lang", default="de", help="Sprache der Untertitel (Standard: de)" @@ -158,7 +190,7 @@ def main(): print(f"Lade {kind} Untertitel „{track}“ …", flush=True) args.dir.mkdir(parents=True, exist_ok=True) - target_base = args.dir / (args.name or info["id"]) + target_base = args.dir / (args.name or default_name(info)) download(args.url, track, is_auto, target_base) target = Path(f"{target_base}.{track}.vtt")