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. 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. 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 Set `muxing.track_names` to `native` to name each track in its own language. The same rules apply
to the script and the region: to the script and the region:
+4 -1
View File
@@ -394,7 +394,10 @@ dual_multi_mode: strict
!!! note "Dialects count as one language" !!! note "Dialects count as one language"
Regional variants of the same base language (e.g. `en-US` and `en-GB`) collapse to a single 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` ## `hdr10plus_probe`
+27
View File
@@ -99,6 +99,33 @@ def test_strict_single_unknown_original_no_dubbed(strict_mode):
assert c["multi"] == "" 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): def test_count_dual_ignores_original(count_mode):
c = ctx(None, ["en", "es"]) c = ctx(None, ["en", "es"])
assert c["dual"] == "DUAL" assert c["dual"] == "DUAL"
+15 -17
View File
@@ -14,6 +14,7 @@ from unshackle.core.constants import (
VIDEO_CODEC_MAP, VIDEO_CODEC_MAP,
) )
from unshackle.core.tracks import Tracks from unshackle.core.tracks import Tracks
from unshackle.core.utilities import spoken_language
def rename(name: Optional[str]) -> str: def rename(name: Optional[str]) -> str:
@@ -74,21 +75,18 @@ class Title:
returned dict with their specific fields (e.g., season/episode). returned dict with their specific fields (e.g., season/episode).
""" """
primary_video_track = next(iter(media_info.video_tracks), None) primary_video_track = next(iter(media_info.video_tracks), None)
original_lang_tag = str(self.language).split("-")[0].lower() if self.language else "" original_lang_tag = spoken_language(self.language)
primary_audio_track = None spoken_audio = [spoken_language(t.language) for t in media_info.audio_tracks]
if original_lang_tag: primary_audio_track = next(
primary_audio_track = next( (
( t
t for t, lang in zip(media_info.audio_tracks, spoken_audio)
for t in media_info.audio_tracks if original_lang_tag and lang == original_lang_tag
if t.language and t.language.split("-")[0].lower() == original_lang_tag ),
), next(iter(media_info.audio_tracks), None),
None, )
) audio_languages = set(spoken_audio) - {""}
if primary_audio_track is None: unique_audio_languages = len(audio_languages)
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)
context: dict[str, Any] = { context: dict[str, Any] = {
"source": self.service.__name__ if show_service else "", "source": self.service.__name__ if show_service else "",
@@ -215,10 +213,10 @@ class Title:
if unique_audio_languages > 2: if unique_audio_languages > 2:
context["multi"] = "MULTi" context["multi"] = "MULTi"
elif unique_audio_languages == 2: 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" context["dual"] = "DUAL"
elif unique_audio_languages == 1 and strict: 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" context["dubbed"] = "DUBBED"
language_tags = config.language_tags if isinstance(config.language_tags, dict) else {} 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) 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 @cache
def multi_script_languages() -> frozenset[str]: def multi_script_languages() -> frozenset[str]:
"""Return the languages whose likely script changes with the region in CLDR (zh, yue, sr, ...).""" """Return the languages whose likely script changes with the region in CLDR (zh, yue, sr, ...)."""