mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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.
|
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 |
|
| Status | Error code | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -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.
|
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"
|
!!! 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
|
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
|
out the language when the name already contains it, so an English name shows once. A native name
|
||||||
|
|||||||
@@ -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_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. |
|
| `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). |
|
| `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. |
|
| `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
|
```yaml
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ from typing import Callable, Optional
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||||
from unshackle.core.utilities import language_name, multi_script_languages, with_script
|
from unshackle.core.utilities import language_name, multi_script_languages, with_script
|
||||||
|
|
||||||
|
|
||||||
def sub(lang: str, name: Optional[str] = None, sdh: bool = False) -> Subtitle:
|
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)
|
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:
|
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() == ""
|
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:
|
def test_subtitle_names_and_tags_the_script_but_keeps_its_language() -> None:
|
||||||
track = sub("zh-TW")
|
track = sub("zh-TW")
|
||||||
assert track.name is None
|
assert track.name is None
|
||||||
|
|||||||
@@ -1019,17 +1019,29 @@ class Track:
|
|||||||
def embedded_audio_name(self) -> str:
|
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."""
|
"""Name the audio a service keeps inside this track's file, by its language when the service set none."""
|
||||||
lang = self.data["audio_language"]
|
lang = self.data["audio_language"]
|
||||||
if name := self.data.get("audio_language_name"):
|
name = self.data.get("audio_language_name")
|
||||||
return name
|
if not name and config.muxing.get("track_names") != "none":
|
||||||
if config.muxing.get("track_names") == "none":
|
name = language_name(Language.get(lang)) or lang
|
||||||
return ""
|
return self.with_original_label(name) or ""
|
||||||
return language_name(Language.get(lang)) or lang
|
|
||||||
|
|
||||||
def get_track_name(self) -> Optional[str]:
|
def get_track_name(self) -> Optional[str]:
|
||||||
"""Get the Track Name, or the name of its language when the service set none."""
|
"""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":
|
name = self.name
|
||||||
return self.name
|
if name is None and config.muxing.get("track_names") != "none":
|
||||||
return language_name(self.mux_language)
|
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]:
|
def get_drm_for_cdm(self, cdm: Optional[object]) -> Optional[DRM_T]:
|
||||||
"""Return the DRM matching the provided CDM, if available."""
|
"""Return the DRM matching the provided CDM, if available."""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable, Iterator, Optional, Sequence, Union
|
from typing import Any, Callable, Iterator, Optional, Sequence, Union
|
||||||
|
|
||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
|
from rich.markup import escape
|
||||||
from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeRemainingColumn
|
from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeRemainingColumn
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
@@ -190,11 +191,11 @@ class Tracks:
|
|||||||
|
|
||||||
progress_callables.append(update_track_progress)
|
progress_callables.append(update_track_progress)
|
||||||
track_table = Table.grid()
|
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)
|
track_table.add_row(progress)
|
||||||
tracks_tree.add(track_table)
|
tracks_tree.add(track_table)
|
||||||
else:
|
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)
|
# Show Closed Captions right after Subtitles (even if no subtitle tracks exist)
|
||||||
if track_type is Subtitle:
|
if track_type is Subtitle:
|
||||||
|
|||||||
Reference in New Issue
Block a user