fix: Originalsprache bei Videos mit KI-synchronisierten Tonspuren

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-10 20:28:05 +02:00
commit 547dcb358d
4 changed files with 108 additions and 19 deletions

View file

@ -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 # → <Video-ID>.de-orig.vtt
./yt_untertitel.py -u URL -n Lahnstein -d testdaten # → testdaten/Lahnstein.de-orig.vtt

View file

@ -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"

View file

@ -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"])

View file

@ -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 ("<lang>-orig") is used: plain "<lang>" 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 "<lang>-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
("<lang>-orig"): plain "<lang>" 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):