fix(titles): compare dub languages under their macrolanguage

A cmn track on a zh title no longer gets DUBBED, and nb and nn count as no; yue stays separate.
This commit is contained in:
imSp4rky
2026-10-29 21:23:45 -06:00
parent d4abcb51d9
commit 17d4905577
5 changed files with 78 additions and 18 deletions
+17
View File
@@ -306,6 +306,23 @@ A tag without a region, such as `zh`, gets no script because the region is what
unshackle writes audio tags as the service gives them, because a script applies only to written text.
Track selection, `--list` tags and file names use the service tag.
A service that knows the spoken language, such as Mandarin or Cantonese, gives the title language and
the audio a region and no script, and gives the subtitles a script:
| Tracks | Mandarin, Taiwan | Cantonese, Hong Kong |
|--------|------------------|----------------------|
| Title language and audio | `cmn-TW` | `yue-HK` |
| Subtitles | `zh-Hant` or `cmn-Hant` | `yue-Hant` |
With these tags, `-l orig` selects the audio and `-sl orig` selects the Traditional subtitles. Do not
mix the forms. A bare `cmn` implies Simplified Chinese, so a `cmn` title language makes `-sl orig`
select Simplified subtitles. A `zh-TW` title language with `cmn` audio selects no audio, because
`zh-TW` implies Traditional Chinese. Cantonese and Chinese never match, so `-sl orig` on a `yue-HK`
title does not select `zh-Hant` subtitles; select them with `-sl zh-Hant`.
The `{dual}`, `{multi}` and `{dubbed}` file name tags count `cmn` as `zh` and keep `yue` separate
(see [`dual_multi_mode`](../reference/configuration/download.md#dual_multi_mode)).
Set `muxing.track_names` to `native` to name each track in its own language. The same rules apply
to the script and the region:
+4 -1
View File
@@ -394,7 +394,10 @@ dual_multi_mode: strict
!!! note "Dialects count as one language"
Regional variants of the same base language (e.g. `en-US` and `en-GB`) collapse to a single
language in both modes, so they never trigger `{dual}` on their own.
language in both modes, so they never trigger `{dual}` on their own. An individual language
counts as its macrolanguage: `cmn` and `zh` are one language, and so are `arb` and `ar`.
Norwegian `nb` (Bokmål) and `nn` (Nynorsk) count as `no`.
Cantonese (`yue`) stays separate, so `cmn` and `yue` audio give `{dual}`.
## `hdr10plus_probe`
+27
View File
@@ -99,6 +99,33 @@ def test_strict_single_unknown_original_no_dubbed(strict_mode):
assert c["multi"] == ""
@pytest.mark.parametrize(
("language", "languages", "tags"),
[
("zh", ["cmn"], set()),
("zh-TW", ["cmn-TW"], set()),
("cmn", ["zh-CN"], set()),
("zh", ["zh", "cmn"], set()),
("zh", ["cmn", "yue"], {"dual"}),
("cmn", ["yue"], {"dubbed"}),
("yue", ["cmn"], {"dubbed"}),
("ar", ["arb"], set()),
("no", ["nb-NO"], set()),
("nb", ["nn"], set()),
("no", ["nb", "da"], {"dual"}),
],
)
def test_strict_counts_an_individual_language_as_its_macrolanguage(strict_mode, language, languages, tags):
c = ctx(language, languages)
assert {name for name in ("dual", "multi", "dubbed") if c[name]} == tags
def test_the_primary_audio_is_the_original_language_under_its_macrolanguage(strict_mode):
media_info = make_media_info(["yue", "cmn"])
media_info.audio_tracks[1].channel_s = 6
assert make_movie("zh").build_base_template_context(media_info)["audio_channels"] == "6.0"
def test_count_dual_ignores_original(count_mode):
c = ctx(None, ["en", "es"])
assert c["dual"] == "DUAL"
+15 -17
View File
@@ -14,6 +14,7 @@ from unshackle.core.constants import (
VIDEO_CODEC_MAP,
)
from unshackle.core.tracks import Tracks
from unshackle.core.utilities import spoken_language
def rename(name: Optional[str]) -> str:
@@ -74,21 +75,18 @@ class Title:
returned dict with their specific fields (e.g., season/episode).
"""
primary_video_track = next(iter(media_info.video_tracks), None)
original_lang_tag = str(self.language).split("-")[0].lower() if self.language else ""
primary_audio_track = None
if original_lang_tag:
primary_audio_track = next(
(
t
for t in media_info.audio_tracks
if t.language and t.language.split("-")[0].lower() == original_lang_tag
),
None,
)
if primary_audio_track is None:
primary_audio_track = next(iter(media_info.audio_tracks), None)
audio_lang_bases = {x.language.split("-")[0].lower() for x in media_info.audio_tracks if x.language}
unique_audio_languages = len(audio_lang_bases)
original_lang_tag = spoken_language(self.language)
spoken_audio = [spoken_language(t.language) for t in media_info.audio_tracks]
primary_audio_track = next(
(
t
for t, lang in zip(media_info.audio_tracks, spoken_audio)
if original_lang_tag and lang == original_lang_tag
),
next(iter(media_info.audio_tracks), None),
)
audio_languages = set(spoken_audio) - {""}
unique_audio_languages = len(audio_languages)
context: dict[str, Any] = {
"source": self.service.__name__ if show_service else "",
@@ -215,10 +213,10 @@ class Title:
if unique_audio_languages > 2:
context["multi"] = "MULTi"
elif unique_audio_languages == 2:
if not strict or (original_lang_tag and original_lang_tag in audio_lang_bases):
if not strict or (original_lang_tag and original_lang_tag in audio_languages):
context["dual"] = "DUAL"
elif unique_audio_languages == 1 and strict:
if original_lang_tag and original_lang_tag not in audio_lang_bases:
if original_lang_tag and original_lang_tag not in audio_languages:
context["dubbed"] = "DUBBED"
language_tags = config.language_tags if isinstance(config.language_tags, dict) else {}
+15
View File
@@ -172,6 +172,21 @@ def valid_language(language: Any) -> Optional[str]:
return str(language)
def spoken_language(tag: Any) -> str:
"""
Return the language a tag names, under its macrolanguage: cmn and zh-TW give zh, yue stays yue.
nb and nn give no by hand, because CLDR makes no their parent outside its macrolanguage data.
"""
if not tag:
return ""
try:
language = Language.get(str(tag)).prefer_macrolanguage().language or ""
except LanguageTagError:
return ""
return "no" if language in ("nb", "nn") else language
@cache
def multi_script_languages() -> frozenset[str]:
"""Return the languages whose likely script changes with the region in CLDR (zh, yue, sr, ...)."""