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 Sprache und werden nie genommen. Gibt es keine passende Spur, nennt das Skript die
vorhandenen Sprachen. 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 ```bash
./yt_untertitel.py -u URL # → <Video-ID>.de-orig.vtt ./yt_untertitel.py -u URL # → <Video-ID>.de-orig.vtt
./yt_untertitel.py -u URL -n Lahnstein -d testdaten # → testdaten/Lahnstein.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"] 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): def test_fliesstext(fake_yt_dlp, tmp_path, monkeypatch, capsys):
long_vtt = "WEBVTT\n\n" + "".join( 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" 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 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(): def test_available_tracks():
info = {"subtitles": {"fr": []}, "automatic_captions": {"de-orig": [], "de": []}} info = {"subtitles": {"fr": []}, "automatic_captions": {"de-orig": [], "de": []}}
assert yt.available_tracks(info) == (["fr"], ["de"]) assert yt.available_tracks(info) == (["fr"], ["de"])

View file

@ -31,39 +31,66 @@ def fetch_info(url):
return json.loads(result.stdout) return json.loads(result.stdout)
def choose_track(info, lang): def base_language(code):
"""Pick the subtitle track for `lang`: (track name, is_auto) or None. """"en-US" -> "en", "de-orig" -> "de"."""
return code.removesuffix("-orig").split("-")[0].lower()
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 original_language(info): 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 info.get("language"):
if name.endswith("-orig"): return info["language"]
return name.removesuffix("-orig") for fmt in info.get("formats") or []:
return info.get("language") 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): def available_tracks(info):
"""Manual tracks and the automatic ones usable for this program."""
manual = sorted((info.get("subtitles") or {}).keys()) manual = sorted((info.get("subtitles") or {}).keys())
original = sorted( original = original_language(info)
automatic = sorted(
name.removesuffix("-orig") name.removesuffix("-orig")
for name in (info.get("automatic_captions") or {}) for name in (info.get("automatic_captions") or {})
if name.endswith("-orig") 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): def no_track_message(info, lang):