feat(naming): add codec_names to rename codec and HDR labels in filenames

A codec name that ends in a digit now gets a separator before the channels, so AC-4 2.0 gives AC-4.2.0 and no longer AC-42.0.
This commit is contained in:
imSp4rky
2026-10-28 14:19:23 -06:00
parent 2e75cc35a1
commit b0c006d7f6
7 changed files with 89 additions and 6 deletions
+2 -2
View File
@@ -163,9 +163,9 @@ For custom templates there is also a standalone [`{part}`](../reference/configur
| Variable | Meaning | Example |
|---|---|---|
| `audio` | Audio codec | `DDP`, `DD`, `AAC`, `DTS-X` |
| `audio` | Audio codec; rename it with [`codec_names`](../reference/configuration/output.md#codec_names) | `DDP`, `DD`, `AAC`, `DTS-X` |
| `audio_channels` | Channel layout; bed.LFE.heights when the track has height channels | `5.1`, `2.0`, `5.1.4` |
| `audio_full` | Codec + channels combined | `DDP5.1`, `DTS-X.5.1.4` |
| `audio_full` | Codec + channels combined; a name that ends in a digit gets a separator | `DDP5.1`, `DTS-X.5.1.4`, `AC-4.2.0` |
| `atmos` | `Atmos` when any track carries JOC | `Atmos` |
| `dual` | `DUAL` for two audio languages (see [`dual_multi_mode`](../reference/configuration/download.md#dual_multi_mode)) | `DUAL` |
| `multi` | `MULTi` for more than two audio languages | `MULTi` |
+1 -1
View File
@@ -32,7 +32,7 @@ Where the file lives, how unshackle discovers it, and how to edit it:
| [Directories](directories.md) | `directories`, `filenames`, `services_repo_force` |
| [Services & authentication](services.md) | `services`, `credentials`, `firefox_cookies`, `remote_services`, `serve` |
| [Download & processing](download.md) | `dl`, `continue_downloads`, `merge_segments`, `subtitle`, `audio`, `muxing`, `language_tags`, `dual_multi_mode`, `generate_dv_mode`, `hdr10plus_probe` |
| [Output & naming](output.md) | `output_template`, `tag`, `tag_rules`, `tag_group_name`, `tag_imdb_tmdb`, `chapter_fallback_name`, `unicode_filenames` |
| [Output & naming](output.md) | `output_template`, `tag`, `tag_rules`, `codec_names`, `tag_group_name`, `tag_imdb_tmdb`, `chapter_fallback_name`, `unicode_filenames` |
| [DRM & CDM](drm.md) | `cdm`, `remote_cdm`, `decryption` |
| [Network & proxy](network.md) | `network`, `headers`, `proxy_providers` |
| [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` |
+26
View File
@@ -69,6 +69,32 @@ folder-kind keys.
| `chapter_fallback_name` | str | `""` | Fallback template for chapter names, e.g. `"Chapter {i:02}"`. |
| `unicode_filenames` | bool | `false` | Allow Unicode in filenames; when `false`, names are ASCII-sanitised. |
## `codec_names`
- **Type:** `dict`  ·  **Default:** `{}`
Renames the codec and HDR names in the filename. Each map key is a name that unshackle writes by
default, and its value is the name to write instead. The map applies to the `video`, `audio`,
and `hdr` [template variables](../../guide/output-and-naming.md#template-variables), and so
also to `audio_full`. A name that is not in the map stays as it is, and an empty value removes
the name. You can also set `codec_names` for one service under `services`.
```yaml title="unshackle.yaml"
codec_names:
DDP: EAC3 # DDP5.1 -> EAC3.5.1
DD: AC3 # DD5.1 -> AC3.5.1
H.265: HEVC # H.265 -> HEVC
HDR10P: HDR10+ # DV.HDR10P -> DV.HDR10+
```
For `hdr`, unshackle renames each part of a Dolby Vision label separately, so `HDR10P` also
changes `DV.HDR10P` to `DV.HDR10+`. When an audio name ends in a digit, `audio_full` puts a
separator before the channels: `EAC3.5.1`, not `EAC35.1`.
!!! note "`tag_rules` see the new names"
unshackle evaluates [`tag_rules`](#tag_rules) after the rename. With the example above, a
rule must match `audio: EAC3`, not `audio: DDP`.
## `tag_rules`
- **Type:** `list`  ·  **Default:** `[]`
+6
View File
@@ -46,6 +46,12 @@ def default_muxing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "muxing", {})
@pytest.fixture(autouse=True)
def default_codec_names(monkeypatch: pytest.MonkeyPatch) -> None:
"""Name codecs with the built-in labels, whatever codec_names the local unshackle.yaml sets."""
monkeypatch.setattr(config, "codec_names", {})
@pytest.fixture
def mux_args(monkeypatch: pytest.MonkeyPatch) -> Callable[..., list[str]]:
"""Run Tracks.mux up to mkvmerge and return its arguments; ``ids`` is what the track ID probe reports."""
+40
View File
@@ -6,6 +6,7 @@ import types
import pytest
from unshackle.core.config import config
from unshackle.core.titles.movie import Movie
@@ -51,3 +52,42 @@ def test_a_height_layout_is_named_bed_lfe_heights() -> None:
)
def test_layouts_without_heights_are_unchanged(layout: str | None, expected: str) -> None:
assert named("E-AC-3", layout)["audio_channels"] == expected
@pytest.mark.parametrize(
("fmt", "expected"),
[("E-AC-3", "DDP5.1"), ("AC-3", "DD5.1"), ("AC-4", "AC-4 5.1"), ("AAC", "AAC5.1")],
)
def test_a_name_ending_in_a_digit_is_spaced_from_the_channels(fmt: str, expected: str) -> None:
assert named(fmt, "L R C LFE Ls Rs")["audio_full"] == expected
def test_codec_names_overrides_the_built_in_name(monkeypatch: pytest.MonkeyPatch) -> None:
"""The override is keyed by the name the filename would carry, and AC3 still gets its separator."""
monkeypatch.setattr(config, "codec_names", {"DDP": "EAC3", "DD": "AC3"})
assert named("E-AC-3", "L R C LFE Ls Rs")["audio_full"] == "EAC3 5.1"
assert named("AC-3", "L R C LFE Ls Rs")["audio"] == "AC3"
def test_codec_names_renames_video_and_each_hdr_part(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "codec_names", {"H.265": "HEVC", "HDR10P": "HDR10+", "DTS-X": "DTSX"})
video = types.SimpleNamespace(
width=3840,
height=2160,
format="HEVC",
scan_type="Progressive",
other_display_aspect_ratio=["16:9"],
hdr_format_commercial="HDR10+",
hdr_format="Dolby Vision, Version 1.0, Profile 8.1",
transfer_characteristics=None,
transfer_characteristics_original=None,
frame_rate="23.976",
)
movie = Movie(id_="movie-0001", service=DummyService, name="The Film", year=2024, language="en")
context = movie.build_base_template_context(types.SimpleNamespace(video_tracks=[video], audio_tracks=[]))
assert (context["video"], context["hdr"]) == ("HEVC", "DV.HDR10+")
assert named("DTS-UHD", "L C R LFE Ls Rs")["audio_full"] == "DTSX 5.1", "a renamed DTS-X keeps its separator"
def test_a_missing_audio_format_does_not_break_naming() -> None:
assert named(None, "L R", channel_s=2)["audio_full"] == "2.0" # type: ignore[arg-type]
+3
View File
@@ -199,6 +199,9 @@ class Config:
self.language_tags: dict = kwargs.get("language_tags") or {}
self.tag_rules: list = kwargs.get("tag_rules") or []
self.codec_names: dict = kwargs.get("codec_names") or {}
if not isinstance(self.codec_names, dict):
raise SystemExit(f"ERROR: codec_names must be a mapping, not {self.codec_names!r}")
self.dual_multi_mode: str = (kwargs.get("dual_multi_mode") or "strict").lower()
self.generate_dv_mode: str = str(kwargs.get("generate_dv_mode") or "v29").lower()
self.hdr10plus_probe: bool = kwargs.get("hdr10plus_probe", True)
+11 -3
View File
@@ -16,6 +16,12 @@ from unshackle.core.constants import (
from unshackle.core.tracks import Tracks
def rename(name: Optional[str]) -> str:
"""Return the user's `codec_names` override for a filename label, read here so per-service overrides are covered."""
value = config.codec_names.get(name, name) if name else name
return "" if value is None else str(value)
class Title:
def __init__(
self, id_: Any, service: type, language: Optional[Union[str, Language]] = None, data: Optional[Any] = None
@@ -147,7 +153,7 @@ class Title:
{
"quality": f"{resolution}{scan_suffix}",
"resolution": str(resolution),
"video": VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format),
"video": rename(VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format)),
}
)
@@ -167,6 +173,7 @@ class Title:
context["hdr"] = "HLG"
else:
context["hdr"] = ""
context["hdr"] = ".".join(filter(None, map(rename, context["hdr"].split("."))))
frame_rate = float(primary_video_track.frame_rate) if primary_video_track.frame_rate else 0.0
context["hfr"] = "HFR" if frame_rate > 30 else ""
@@ -191,8 +198,9 @@ class Title:
channels_str = f"{len(positions) - heights - lfe}.{lfe}.{heights}"
else:
channels_str = f"{channels:.1f}"
audio = AUDIO_CODEC_MAP.get(codec, codec)
spacer = " " if audio in SPACED_AUDIO_CODECS else ""
builtin = AUDIO_CODEC_MAP.get(codec, codec)
audio = rename(builtin)
spacer = " " if builtin in SPACED_AUDIO_CODECS or audio[-1:].isdigit() else ""
context.update(
{