feat(tracks): add native track names with muxing.track_names

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.
This commit is contained in:
imSp4rky
2026-10-24 12:43:50 -06:00
parent 1175035317
commit ecf17baffe
7 changed files with 87 additions and 6 deletions
+1 -1
View File
@@ -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 |
| --- | --- | --- |
+23
View File
@@ -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
+1
View File
@@ -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
+7
View File
@@ -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", {})
+36
View File
@@ -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
+1 -1
View File
@@ -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]:
+18 -4
View File
@@ -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: