mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -163,9 +163,9 @@ For custom templates there is also a standalone [`{part}`](../reference/configur
|
|||||||
|
|
||||||
| Variable | Meaning | Example |
|
| 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_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` |
|
| `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` |
|
| `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` |
|
| `multi` | `MULTi` for more than two audio languages | `MULTi` |
|
||||||
|
|||||||
@@ -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` |
|
| [Directories](directories.md) | `directories`, `filenames`, `services_repo_force` |
|
||||||
| [Services & authentication](services.md) | `services`, `credentials`, `firefox_cookies`, `remote_services`, `serve` |
|
| [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` |
|
| [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` |
|
| [DRM & CDM](drm.md) | `cdm`, `remote_cdm`, `decryption` |
|
||||||
| [Network & proxy](network.md) | `network`, `headers`, `proxy_providers` |
|
| [Network & proxy](network.md) | `network`, `headers`, `proxy_providers` |
|
||||||
| [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` |
|
| [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` |
|
||||||
|
|||||||
@@ -69,6 +69,32 @@ folder-kind keys.
|
|||||||
| `chapter_fallback_name` | str | `""` | Fallback template for chapter names, e.g. `"Chapter {i:02}"`. |
|
| `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. |
|
| `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`
|
## `tag_rules`
|
||||||
|
|
||||||
- **Type:** `list` · **Default:** `[]`
|
- **Type:** `list` · **Default:** `[]`
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ def default_muxing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr(config, "muxing", {})
|
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
|
@pytest.fixture
|
||||||
def mux_args(monkeypatch: pytest.MonkeyPatch) -> Callable[..., list[str]]:
|
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."""
|
"""Run Tracks.mux up to mkvmerge and return its arguments; ``ids`` is what the track ID probe reports."""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import types
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from unshackle.core.config import config
|
||||||
from unshackle.core.titles.movie import Movie
|
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:
|
def test_layouts_without_heights_are_unchanged(layout: str | None, expected: str) -> None:
|
||||||
assert named("E-AC-3", layout)["audio_channels"] == expected
|
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]
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ class Config:
|
|||||||
|
|
||||||
self.language_tags: dict = kwargs.get("language_tags") or {}
|
self.language_tags: dict = kwargs.get("language_tags") or {}
|
||||||
self.tag_rules: list = kwargs.get("tag_rules") 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.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.generate_dv_mode: str = str(kwargs.get("generate_dv_mode") or "v29").lower()
|
||||||
self.hdr10plus_probe: bool = kwargs.get("hdr10plus_probe", True)
|
self.hdr10plus_probe: bool = kwargs.get("hdr10plus_probe", True)
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ from unshackle.core.constants import (
|
|||||||
from unshackle.core.tracks import Tracks
|
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:
|
class Title:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, id_: Any, service: type, language: Optional[Union[str, Language]] = None, data: Optional[Any] = None
|
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}",
|
"quality": f"{resolution}{scan_suffix}",
|
||||||
"resolution": str(resolution),
|
"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"
|
context["hdr"] = "HLG"
|
||||||
else:
|
else:
|
||||||
context["hdr"] = ""
|
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
|
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 ""
|
context["hfr"] = "HFR" if frame_rate > 30 else ""
|
||||||
@@ -191,8 +198,9 @@ class Title:
|
|||||||
channels_str = f"{len(positions) - heights - lfe}.{lfe}.{heights}"
|
channels_str = f"{len(positions) - heights - lfe}.{lfe}.{heights}"
|
||||||
else:
|
else:
|
||||||
channels_str = f"{channels:.1f}"
|
channels_str = f"{channels:.1f}"
|
||||||
audio = AUDIO_CODEC_MAP.get(codec, codec)
|
builtin = AUDIO_CODEC_MAP.get(codec, codec)
|
||||||
spacer = " " if audio in SPACED_AUDIO_CODECS else ""
|
audio = rename(builtin)
|
||||||
|
spacer = " " if builtin in SPACED_AUDIO_CODECS or audio[-1:].isdigit() else ""
|
||||||
|
|
||||||
context.update(
|
context.update(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user