diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 9706023e..977dff58 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -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` | diff --git a/docs/reference/configuration/index.md b/docs/reference/configuration/index.md index 5848264e..1141e4e0 100644 --- a/docs/reference/configuration/index.md +++ b/docs/reference/configuration/index.md @@ -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` | diff --git a/docs/reference/configuration/output.md b/docs/reference/configuration/output.md index 4ec5f02b..148ac617 100644 --- a/docs/reference/configuration/output.md +++ b/docs/reference/configuration/output.md @@ -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:** `[]` diff --git a/tests/conftest.py b/tests/conftest.py index 9f2f616f..ae36af88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/core/test_audio_channel_naming.py b/tests/core/test_audio_channel_naming.py index 700146bd..945b5ed6 100644 --- a/tests/core/test_audio_channel_naming.py +++ b/tests/core/test_audio_channel_naming.py @@ -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] diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 82646661..bbca1549 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -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) diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index d98be289..31c52617 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -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( {