From 547dcb358dadb956594575f911581b311b432c31 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 10 Oct 2026 20:28:05 +0200 Subject: [PATCH] fix: Originalsprache bei Videos mit KI-synchronisierten Tonspuren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gefunden mit einem englischen Video (0LmCveTM788): ohne -l wurde Arabisch gewählt, denn jede der 20 Synchron-Tonspuren hat eine eigene „-orig“-Spur. - original_language(): aus „language“, sonst Tonspur mit „original“, sonst die einzige „-orig“-Spur - Automatische Untertitel nur in der Originalsprache (keine Synchronisation) - Sprachen auch mit Region: „en“ findet manuelle Untertitel „en-US“ Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++ tests/test_extract_text.py | 13 +++++++ tests/test_yt_untertitel.py | 43 ++++++++++++++++++++++++ yt_untertitel.py | 67 ++++++++++++++++++++++++++----------- 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8ff7c33..038802f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ Automatische Untertitel ohne `-orig` sind Maschinenübersetzungen aus einer ande Sprache und werden nie genommen. Gibt es keine passende Spur, nennt das Skript die vorhandenen Sprachen. +Videos mit KI-synchronisierten Tonspuren haben für *jede* Synchronsprache eine +`-orig`-Spur. Die Originalsprache kommt deshalb aus den Video-Infos (`language`, sonst +die Tonspur mit dem Vermerk „original“); automatische Untertitel anderer Sprachen +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 -n Lahnstein -d testdaten # → testdaten/Lahnstein.de-orig.vtt diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py index 35d4d61..cb4f18b 100644 --- a/tests/test_extract_text.py +++ b/tests/test_extract_text.py @@ -46,6 +46,19 @@ def test_default_language_is_original_audio(fake_yt_dlp, tmp_path, monkeypatch, assert [p.name for p in tmp_path.iterdir()] == ["output.txt"] +def test_default_language_ignores_dubbed_tracks(fake_yt_dlp, tmp_path, monkeypatch): + info = { + "id": "dub", "language": "en-US", "subtitles": {"en-US": []}, + "automatic_captions": {"ar-orig": [], "en-orig": []}, + } + run = fake_yt_dlp(info, vtt=VTT) + monkeypatch.setattr(yt.subprocess, "run", run) + run_main(monkeypatch, "-u", URL, "-o", tmp_path / "out.txt") + download = run.calls[1] + assert download[download.index("--sub-langs") + 1] == "en-US" + assert "--write-subs" in download + + def test_fliesstext(fake_yt_dlp, tmp_path, monkeypatch, capsys): long_vtt = "WEBVTT\n\n" + "".join( f"00:00:{i:02d}.000 --> 00:00:{i + 1:02d}.000\nSatz Nummer {i} zum EU-Gipfel.\n\n" diff --git a/tests/test_yt_untertitel.py b/tests/test_yt_untertitel.py index e422ae7..701430d 100644 --- a/tests/test_yt_untertitel.py +++ b/tests/test_yt_untertitel.py @@ -47,6 +47,49 @@ def test_original_language(info, expected): assert yt.original_language(info) == expected +# English original with AI-dubbed audio tracks: every dub has its own "-orig" +DUBBED = { + "id": "dub", + "language": "en-US", + "subtitles": {"en-US": []}, + "automatic_captions": {"ar-orig": [], "en-orig": [], "de-DE-orig": [], "de": []}, +} +DUBBED_NO_METADATA = { + "formats": [ + {"format_note": "Arabic, low", "language": "ar"}, + {"format_note": "English (US) original (default), low", "language": "en-US"}, + ], + "subtitles": {}, + "automatic_captions": {"ar-orig": [], "en-orig": [], "de-DE-orig": []}, +} + + +@pytest.mark.parametrize("info, lang, expected", [ + (DUBBED, "en", ("en-US", False)), # region variant of a manual track + (DUBBED, "en-US", ("en-US", False)), + (DUBBED, "de", None), # only the German AI dub + (DUBBED, "ar", None), + (DUBBED_NO_METADATA, "en-US", ("en-orig", True)), + (DUBBED_NO_METADATA, "en", ("en-orig", True)), + (DUBBED_NO_METADATA, "de", None), +]) +def test_choose_track_with_dubbed_audio(info, lang, expected): + assert yt.choose_track(info, lang) == expected + + +@pytest.mark.parametrize("info, expected", [ + (DUBBED, "en-US"), + (DUBBED_NO_METADATA, "en-US"), # from the audio track marked original + ({"automatic_captions": {"ar-orig": [], "en-orig": []}}, None), +]) +def test_original_language_with_dubbed_audio(info, expected): + assert yt.original_language(info) == expected + + +def test_available_tracks_hides_dubs(): + assert yt.available_tracks(DUBBED) == (["en-US"], ["en"]) + + def test_available_tracks(): info = {"subtitles": {"fr": []}, "automatic_captions": {"de-orig": [], "de": []}} assert yt.available_tracks(info) == (["fr"], ["de"]) diff --git a/yt_untertitel.py b/yt_untertitel.py index 14a6c0a..dcd2cce 100755 --- a/yt_untertitel.py +++ b/yt_untertitel.py @@ -31,39 +31,66 @@ def fetch_info(url): return json.loads(result.stdout) -def choose_track(info, lang): - """Pick the subtitle track for `lang`: (track name, is_auto) or None. - - Manual subtitles come first. Of the automatic captions only the original - language ("-orig") is used: plain "" there is a machine - translation whenever the video is in another language. - """ - if lang in (info.get("subtitles") or {}): - return lang, False - if f"{lang}-orig" in (info.get("automatic_captions") or {}): - return f"{lang}-orig", True - return None +def base_language(code): + """"en-US" -> "en", "de-orig" -> "de".""" + return code.removesuffix("-orig").split("-")[0].lower() def original_language(info): - """Language of the video's audio, or None if yt-dlp does not know it. + """Language of the video's original audio, or None if unknown. - YouTube marks the automatic captions in the spoken language with "-orig". + Not simply the first "-orig" caption track: videos with AI-dubbed + audio tracks have such a track for every dubbed language. """ - for name in info.get("automatic_captions") or {}: - if name.endswith("-orig"): - return name.removesuffix("-orig") - return info.get("language") + if info.get("language"): + return info["language"] + for fmt in info.get("formats") or []: + if "original" in (fmt.get("format_note") or "") and fmt.get("language"): + return fmt["language"] + originals = [n for n in info.get("automatic_captions") or {} if n.endswith("-orig")] + return originals[0].removesuffix("-orig") if len(originals) == 1 else None + + +def choose_track(info, lang): + """Pick the subtitle track for `lang`: (track name, is_auto) or None. + + Manual subtitles come first, also with a region ("en" finds "en-US"). + Automatic captions are used only in the language of the original audio + ("-orig"): plain "" is a machine translation, and "-orig" + tracks of other languages belong to AI-dubbed audio tracks. + """ + manual = info.get("subtitles") or {} + if lang in manual: + return lang, False + same_language = sorted(n for n in manual if base_language(n) == base_language(lang)) + if same_language: + return same_language[0], False + + original = original_language(info) + if original and base_language(original) != base_language(lang): + return None + auto = info.get("automatic_captions") or {} + candidates = [f"{lang}-orig", f"{base_language(lang)}-orig"] + sorted( + n for n in auto + if n.endswith("-orig") and base_language(n) == base_language(lang) + ) + for name in candidates: + if name in auto: + return name, True + return None def available_tracks(info): + """Manual tracks and the automatic ones usable for this program.""" manual = sorted((info.get("subtitles") or {}).keys()) - original = sorted( + original = original_language(info) + automatic = sorted( name.removesuffix("-orig") for name in (info.get("automatic_captions") or {}) if name.endswith("-orig") + and (original is None or base_language(name) == base_language(original)) ) - return manual, original + return manual, automatic def no_track_message(info, lang):