From 17d4905577dd0e63eb07a7399421a0933f0f62ae Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 29 Oct 2026 21:23:45 -0600 Subject: [PATCH] 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. --- docs/guide/output-and-naming.md | 17 +++++++++++++ docs/reference/configuration/download.md | 5 +++- tests/core/test_dual_multi_naming.py | 27 ++++++++++++++++++++ unshackle/core/titles/title.py | 32 +++++++++++------------- unshackle/core/utilities.py | 15 +++++++++++ 5 files changed, 78 insertions(+), 18 deletions(-) diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 977dff58..fe94af87 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -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: diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index 89dda0d6..783e903e 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -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` diff --git a/tests/core/test_dual_multi_naming.py b/tests/core/test_dual_multi_naming.py index 04b11cac..e5c58780 100644 --- a/tests/core/test_dual_multi_naming.py +++ b/tests/core/test_dual_multi_naming.py @@ -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" diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index 31c52617..48586ea3 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -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 {} diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 1a768d06..c0565a58 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -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, ...)."""