feat(tracks): add muxing.original_label to mark original-language tracks

Adds the label to audio, subtitle and embedded audio names unless the name already says it, and escapes track lines in the --list tree.
This commit is contained in:
imSp4rky
2026-10-24 22:51:35 -06:00
parent 80bda08f36
commit cc33600cc7
6 changed files with 105 additions and 13 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 name of the language (English, or in the language itself when the server sets `muxing.track_names: native`, or no name when it sets `none`), 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`, or no name when it sets `none`), then the server's `muxing.original_label` on an original-language track, then 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 |
| --- | --- | --- |
+28
View File
@@ -326,6 +326,34 @@ SDH, CC, Forced or Descriptive track then has only that label as its name.
The option also changes the names in `--list` and in the `track_name` field of the REST API.
Set `muxing.original_label` to add a label after the name of each audio and subtitle track in the
title's original language. unshackle also sets the Matroska original flag on these tracks, but some
players show only the name. MP4 output has no original flag, so there the label is the only marker:
```yaml title="unshackle.yaml"
muxing:
original_label: "[Original]"
```
| Track | Track name |
|-------|------------|
| Japanese audio, original language | Japanese [Original] |
| Japanese SDH subtitle, original language | Japanese [Original] (SDH) |
| Korean subtitle | Korean |
The label can be any text, for example `"| Original"` or a label in another language. Put it in
quotes: YAML reads an unquoted `[Original]` as a list, and an unquoted `| Original` stops the config
from loading. unshackle ignores a value that is not text.
The label also applies to a name that the service sets, and to audio that a service keeps inside the
video file. unshackle does not add the label when the name already contains its words, so a service
name `Original` or `Japanese (original)` stays as it is.
!!! tip "Use an English label with Jellyfin 12"
Jellyfin 12 adds its own "Original" to an original-language audio track from the flag, unless the
name already contains the word. It adds none to a subtitle. A label in another language, such as `[원어]`, then shows next to Jellyfin's
"Original".
!!! 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
+1
View File
@@ -289,6 +289,7 @@ Matroska (MKV) muxing options.
| `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)`), `native`, the name in the language itself (`中文(繁體)`), or `none`, which leaves the track unnamed. Read [Track names and language tags](../../guide/output-and-naming.md#track-names-and-language-tags). |
| `original_label` | str | unset | Text that unshackle adds after the name of an audio or subtitle track in the title's original language, such as `[Original]`. 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
+52 -2
View File
@@ -3,14 +3,23 @@ from typing import Callable, Optional
import pytest
from langcodes import Language
from rich.text import Text
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
def sub(lang: str, name: Optional[str] = None, sdh: bool = False) -> Subtitle:
return Subtitle(id_="s", url="https://x/s", codec=Subtitle.Codec.WebVTT, language=lang, name=name, sdh=sdh)
def sub(lang: str, name: Optional[str] = None, sdh: bool = False, original: bool = False) -> Subtitle:
return Subtitle(
id_="s",
url="https://x/s",
codec=Subtitle.Codec.WebVTT,
language=lang,
name=name,
sdh=sdh,
is_original_lang=original,
)
def test_multi_script_languages_come_from_cldr() -> None:
@@ -117,6 +126,47 @@ def test_no_language_name_when_track_names_is_none(monkeypatch: pytest.MonkeyPat
assert video.embedded_audio_name() == ""
@pytest.mark.parametrize(
("muxing", "track", "expected"),
[
({"original_label": "[Original]"}, sub("ja", sdh=True, original=True), "Japanese [Original] (SDH)"),
({"original_label": "[Original]"}, sub("ko"), "Korean"),
({"original_label": "| Original", "track_names": "none"}, sub("ja", original=True), "| Original"),
],
)
def test_original_label_follows_the_name_of_an_original_language_track(
muxing: dict, track: Subtitle, expected: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(config, "muxing", muxing)
assert track.get_track_name() == expected
@pytest.mark.parametrize("name", ["Original", "Japanese [Original]", "Japanese (original)"])
def test_original_label_is_not_added_twice(name: str, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "muxing", {"original_label": "[Original]"})
assert sub("ja", name=name, original=True).get_track_name() == name
@pytest.mark.parametrize("label", [True, 1, ["Original"], " "])
def test_original_label_that_is_not_text_is_ignored(label: object, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "muxing", {"original_label": label, "track_names": "none"})
assert sub("ja", sdh=True, original=True).get_track_name() == "SDH"
def test_embedded_audio_takes_the_original_label(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "muxing", {"original_label": "[Original]"})
video = Video(id_="v", url="https://x/v", codec=Video.Codec.AVC, language="ja", is_original_lang=True)
video.data["audio_language"] = "ja"
assert video.embedded_audio_name() == "Japanese [Original]"
def test_track_list_shows_a_bracketed_label_as_text(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "muxing", {"original_label": "[/]"})
tree = Tracks([sub("ja", original=True)]).tree()[0]
labels = [Text.from_markup(node.label).plain for node in tree.children[0].children]
assert labels == ["[VTT] | ja | Japanese [/]"]
def test_subtitle_names_and_tags_the_script_but_keeps_its_language() -> None:
track = sub("zh-TW")
assert track.name is None
+20 -8
View File
@@ -1019,17 +1019,29 @@ class Track:
def embedded_audio_name(self) -> str:
"""Name the audio a service keeps inside this track's file, by its language when the service set none."""
lang = self.data["audio_language"]
if name := self.data.get("audio_language_name"):
return name
if config.muxing.get("track_names") == "none":
return ""
return language_name(Language.get(lang)) or lang
name = self.data.get("audio_language_name")
if not name and config.muxing.get("track_names") != "none":
name = language_name(Language.get(lang)) or lang
return self.with_original_label(name) or ""
def get_track_name(self) -> Optional[str]:
"""Get the Track Name, or the name of its language when the service set none."""
if self.name is not None or config.muxing.get("track_names") == "none":
return self.name
return language_name(self.mux_language)
name = self.name
if name is None and config.muxing.get("track_names") != "none":
name = language_name(self.mux_language)
return self.with_original_label(name)
def with_original_label(self, name: Optional[str]) -> Optional[str]:
"""Add `muxing.original_label` to the name of a track in the title's original language,
unless the name already contains the label's words."""
label = config.muxing.get("original_label")
label = label.strip() if isinstance(label, str) else ""
if not label or not self.is_original_lang:
return name
words = re.sub(r"[\W_]+", " ", label).strip() or label
if name and words.casefold() in name.casefold():
return name
return f"{name} {label}" if name else label
def get_drm_for_cdm(self, cdm: Optional[object]) -> Optional[DRM_T]:
"""Return the DRM matching the provided CDM, if available."""
+3 -2
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any, Callable, Iterator, Optional, Sequence, Union
from langcodes import Language
from rich.markup import escape
from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeRemainingColumn
from rich.table import Table
from rich.tree import Tree
@@ -190,11 +191,11 @@ class Tracks:
progress_callables.append(update_track_progress)
track_table = Table.grid()
track_table.add_row(str(track)[6:], style="text2")
track_table.add_row(escape(str(track)[6:]), style="text2")
track_table.add_row(progress)
tracks_tree.add(track_table)
else:
tracks_tree.add(str(track)[6:], style="text2")
tracks_tree.add(escape(str(track)[6:]), style="text2")
# Show Closed Captions right after Subtitles (even if no subtitle tracks exist)
if track_type is Subtitle: