From ecf17baffe0195f274c72a0f31f65dea784c3ffb Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sat, 24 Oct 2026 12:43:50 -0600 Subject: [PATCH] feat(tracks): add native track names with muxing.track_names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set muxing.track_names to native to name an unnamed track in its own language (中文(繁體), Português (Portugal)) in place of English. The same script and region rules apply. --list, the muxed names and the REST API track_name follow the option. A test fixture resets the muxing options, so a local unshackle.yaml cannot change test results. --- docs/dev/rest-api/endpoints.md | 2 +- docs/guide/output-and-naming.md | 23 +++++++++++++++ docs/reference/configuration/download.md | 1 + tests/conftest.py | 7 +++++ tests/tracks/test_track_language_names.py | 36 +++++++++++++++++++++++ unshackle/core/tracks/track.py | 2 +- unshackle/core/utilities.py | 22 +++++++++++--- 7 files changed, 87 insertions(+), 6 deletions(-) diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 69e5de78..dae819fb 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -377,7 +377,7 @@ The server sorts tracks by bitrate, descending. Episodes the server cannot get g The `drm` array on a track is a list of `{type, pssh?, kids?, content_keys?, license_url?}` objects. -Every track carries the same fields as a local track: `is_original_lang`, `name`, `needs_repack`, `edition`, and `data`. `name` is the name the service set, or null. `track_name` is the name `--list` shows and the mux writes to an audio or subtitle track: `name` when the service set one, else the English name of the language, with the SDH, CC, Forced or Descriptive label. `bitrate` is in kb/s, and `bitrate_bps` gives the exact bits/s. `data` holds the part of the service-set `track.data` that fits in JSON, without the `hls`, `dash`, and `ism` parser keys. Audio keeps `is_original` and `atmos` for clients that read them, and adds the exact `joc` object count. +Every track carries the same fields as a local track: `is_original_lang`, `name`, `needs_repack`, `edition`, and `data`. `name` is the name the service set, or null. `track_name` is the name `--list` shows and the mux writes to an audio or subtitle track: `name` when the service set one, else the name of the language (English, or in the language itself when the server sets `muxing.track_names: native`), with the SDH, CC, Forced or Descriptive label. `bitrate` is in kb/s, and `bitrate_bps` gives the exact bits/s. `data` holds the part of the service-set `track.data` that fits in JSON, without the `hls`, `dash`, and `ism` parser keys. Audio keeps `is_original` and `atmos` for clients that read them, and adds the exact `joc` object count. | Status | Error code | Meaning | | --- | --- | --- | diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 74a857c7..08de68b9 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -305,6 +305,29 @@ 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. +Set `muxing.track_names` to `native` to name each track in its own language. The same rules apply +to the script and the region: + +```yaml title="unshackle.yaml" +muxing: + track_names: native +``` + +| Service tag | Track name | +|-------------|------------| +| `zh-TW` subtitle | 中文(繁體) | +| `zh-HK` subtitle | 中文(繁體,香港) | +| `pt-PT` | Português (Portugal) | +| `es-419` | Español (Latinoamérica) | +| `ja-JP` | 日本語 | + +The option also changes the names in `--list` and in the `track_name` field of the REST API. + +!!! note "Players that show the language next to the name" + Some players, such as Jellyfin, show the track name and the language name together. They leave + out the language when the name already contains it, so an English name shows once. A native name + such as `日本語` shows next to `Japanese` in an English interface. + ### DTS:X Profile 2 output A title whose audio holds a `dtsx` or `dtsy` sample entry (DTS:X Profile 2, DTS-UHD) muxes diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index 37984972..d896f4c1 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -288,6 +288,7 @@ Matroska (MKV) muxing options. | `default_language` | dict | `{}` | Force which language is flagged *default* per track type, e.g. `{audio: en, subtitle: en}`. A tag with a region, such as `en-US`, matches only that variant. | | `merge_audio` | bool | `true` | Merge all audio into one file. `--split-audio` on the CLI flips this off. | | `merge_video` | bool | `false` | Merge video tracks that share height, range, and codec into one file, so only language varies inside a file. `--merge-video` on the CLI flips this on. | +| `track_names` | str | `english` | The language of the name unshackle gives a track that the service left unnamed: `english` (`Chinese (Traditional)`) or `native`, the name in the language itself (`中文(繁體)`). Read [Track names and language tags](../../guide/output-and-naming.md#track-names-and-language-tags). | | `concurrency` | int | `4` | How many mkvmerge processes run at the same time when a title muxes more than one output file (one per video variant or audio codec). Each process uses one CPU core, so this shortens the mux step on an SSD. Set it to `1` on a hard disk, where parallel writes are slower. | ```yaml diff --git a/tests/conftest.py b/tests/conftest.py index 6126e457..5d03a24b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from typing import Any import pytest +from unshackle.core.config import config from unshackle.core.update_checker import UpdateChecker # shutdown() blocks until the serve loop polls again, and the 0.5s default is paid by every @@ -38,3 +39,9 @@ def no_update_check(monkeypatch: pytest.MonkeyPatch) -> None: return None monkeypatch.setattr(UpdateChecker, "check_for_updates", no_release) + + +@pytest.fixture(autouse=True) +def default_muxing(monkeypatch: pytest.MonkeyPatch) -> None: + """Mux with the default options, whatever the muxing block of the local unshackle.yaml sets.""" + monkeypatch.setattr(config, "muxing", {}) diff --git a/tests/tracks/test_track_language_names.py b/tests/tracks/test_track_language_names.py index ba914e37..80c91300 100644 --- a/tests/tracks/test_track_language_names.py +++ b/tests/tracks/test_track_language_names.py @@ -5,6 +5,7 @@ import pytest from langcodes import Language from unshackle.core import binaries +from unshackle.core.config import config from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.utilities import language_name, multi_script_languages, with_script @@ -72,6 +73,41 @@ def test_language_name(tag: str, expected: Optional[str]) -> None: assert language_name(Language.get(tag)) == expected +@pytest.mark.parametrize( + ("tag", "expected"), + [ + ("zh-Hant-TW", "中文(繁體)"), + ("zh-Hans-CN", "中文(简体)"), + ("zh-Hant-HK", "中文(繁體,香港)"), + ("zh-Hans-MO", "中文(简体,澳门)"), + ("zh-TW", "中文(台灣)"), + ("zh-HK", "中文(香港)"), + ("cmn-Hant", "中文(繁體)"), + ("yue-Hant", "粵語(繁體)"), + ("sr-ME", "Srpski (Crna Gora)"), + ("ka", "ქართული"), + ("hy", "Հայերեն"), + ("pt-BR", "Português"), + ("pt-PT", "Português (Portugal)"), + ("es-419", "Español (Latinoamérica)"), + ("sr-Latn-ME", "Srpski (latinica, Crna Gora)"), + ("ja-JP", "日本語"), + ("en-GB", "English (United Kingdom)"), + ("en-HK", "English (Hong Kong)"), + ("qaa", None), + ], +) +def test_native_language_name(tag: str, expected: Optional[str], monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config, "muxing", {"track_names": "native"}) + assert language_name(Language.get(tag)) == expected + + +def test_native_subtitle_name_keeps_the_flag(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config, "muxing", {"track_names": "native"}) + assert sub("zh-TW", sdh=True).get_track_name() == "中文(繁體) (SDH)" + assert sub("zh", name="Mandarin").get_track_name() == "Mandarin" + + def test_subtitle_names_and_tags_the_script_but_keeps_its_language() -> None: track = sub("zh-TW") assert track.name is None diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index 16277a50..f174d09c 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -1013,7 +1013,7 @@ class Track: return self.language def get_track_name(self) -> Optional[str]: - """Get the Track Name, or the English name of its language when the service set none.""" + """Get the Track Name, or the name of its language when the service set none.""" return self.name if self.name is not None else language_name(self.mux_language) def get_drm_for_cdm(self, cdm: Optional[object]) -> Optional[DRM_T]: diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index edb8a472..932c461c 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -196,10 +196,11 @@ def with_script(language: Language) -> Language: def language_name(language: Language) -> Optional[str]: """ - Return the English name of a track language, or None for an unknown or private-use language. + Return the name of a track language, or None for an unknown or private-use language. - It leaves out a script or region that adds nothing: private use (but XK, Kosovo), 001, the CLDR default - region of the language and script (pt-BR, zh-Hant-TW, but not pt-PT), a script named like the language. + The name is English, or in the language itself when `muxing.track_names` is `native`. It leaves out a + script or region that adds nothing: private use (but XK, Kosovo), 001, the CLDR default region of the + language and script (pt-BR, zh-Hant-TW, but not pt-PT), a script named like the language. """ code = language.language if not code or not Language.make(language=code).has_name_data(): @@ -215,7 +216,20 @@ def language_name(language: Language) -> Optional[str]: or Language.make(language=code, script=script).maximize().territory == region ): region = None - return language.update_dict({"script": script, "territory": region}).display_name().replace(" SAR China", "") + shown = language.update_dict({"script": script, "territory": region}) + native = config.muxing.get("track_names") == "native" + # names are filed under the full macrolanguage tag (zh-Hant): zh-TW or cmn-Hant named in itself falls back to English + full = language.maximize() + name = shown.prefer_macrolanguage().display_name(full.prefer_macrolanguage()) if native else shown.display_name() + name = name.replace(" SAR China", "") + if not native: + return name + name = re.sub(r"中[国國](香港|澳[门門])特[别別]行政[区區]", r"\1", name) + if "(" in name: + # the Traditional Chinese data separates with an ASCII comma, the Simplified with a full-width one + name = name.replace(", ", ",") + # Georgian has capitals (Mtavruli) but never starts a name with one + return name[:1].upper() + name[1:] if full.script in ("Latn", "Cyrl", "Grek", "Armn") else name def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool: