diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index aece4fc5..8ecd6965 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -107,7 +107,7 @@ Every variable below is valid in both output and folder templates. Values are de | `quality` | Resolution with scan suffix | `1080p`, `2160p`, `576i` | | `resolution` | Resolution number only | `1080` | | `video` | Video codec | `H.264`, `H.265` | -| `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR10`, `HLG` | +| `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR`, `DV.HDR10P`, `HLG` | | `hfr` | High frame rate marker (frame rate > 30) | `HFR` | | `edition` | Edition label from the track, if any | `Directors Cut` | | `tag` | Release-group tag (from `config.tag` or `--tag`) | `TAG` | @@ -138,8 +138,9 @@ Every variable below is valid in both output and folder templates. Values are de | `audio_channels` | Channel layout | `5.1`, `2.0` | | `audio_full` | Codec + channels combined | `DDP5.1` | | `atmos` | `Atmos` when any track carries JOC | `Atmos` | -| `dual` | `DUAL` for exactly two distinct audio languages | `DUAL` | +| `dual` | `DUAL` for two audio languages (see [`dual_multi_mode`](../reference/configuration.md#dual_multi_mode)) | `DUAL` | | `multi` | `MULTi` for more than two audio languages | `MULTi` | +| `dubbed` | `DUBBED` for a single audio language that is not the original (strict mode only) | `DUBBED` | ### Music-specific @@ -339,6 +340,9 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an ## Worked examples +Each tab shows a template and what it produces for a few different downloads, so you can see +how the optional (`?`) variables appear and disappear. + === "Movies" ```yaml @@ -347,9 +351,27 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an folder: movies: "{title} ({year})" ``` + + A 4K Atmos HDR download fills every placeholder: + ```text The Movie (2024)/ - └─ The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.HDR-TAG.mkv + └─ The.Movie.2024.2160p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.HDR-TAG.mkv + ``` + + The same template on a plain 1080p SDR stereo download, where `{edition?}`, `{atmos?}` + and `{hdr?}` collapse along with their dots: + + ```text + The Movie (2024)/ + └─ The.Movie.2024.1080p.EXAMPLE.WEB-DL.AAC2.0.H.264-TAG.mkv + ``` + + A director's cut fills `{edition?}`: + + ```text + The Movie (2024)/ + └─ The.Movie.2024.Directors.Cut.2160p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.DV.HDR-TAG.mkv ``` === "TV series" @@ -361,10 +383,80 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an series: "{title} ({year})/Season {season}" ``` ```text - The Show (2023)/Season S02/ + The Show (2023)/Season.S02/ └─ The.Show.S02E04.The.Reckoning.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv ``` + Each folder segment picks its separator style independently: `{title} ({year})` has + spaces around its variables, but `Season {season}` alone counts as dot style, so its + space becomes a dot. + + When an episode has no on-screen name, `{episode_name?}` disappears cleanly: + + ```text + The Show (2023)/Season.S02/ + └─ The.Show.S02E05.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv + ``` + +=== "Multi-language" + + Place `{dual?}`, `{multi?}` and `{dubbed?}` next to each other. At most one of them is + ever set (see [`dual_multi_mode`](../reference/configuration.md#dual_multi_mode)), and the + empty ones collapse: + + ```yaml + output_template: + movies: "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{video}-{tag}" + ``` + + Original English audio plus a French dub (`-l en,fr`): + + ```text + The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUAL.DDP5.1.H.264-TAG.mkv + ``` + + Original plus several dubs (`-l en,fr,de,es`): + + ```text + The.Movie.2024.1080p.EXAMPLE.WEB-DL.MULTi.DDP5.1.H.264-TAG.mkv + ``` + + Only a dub, original audio left out (`-l de` on an English-original title): + + ```text + The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUBBED.DDP5.1.H.264-TAG.mkv + ``` + + Two dialects of one language (`en-US` + `en-GB`) count as a single language, so none of the + three variables is set: + + ```text + The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv + ``` + + For finer control (e.g. a `SUBBED` tag driven by subtitle languages), use `{lang_tag?}` + with [`language_tags` rules](../reference/configuration.md#language_tags) instead. + +=== "Daily shows" + + Dated content needs no special template. When an episode carries an air date, + `{season_episode}` becomes the date automatically and `{episode}` and `{year}` clear: + + ```yaml + output_template: + series: "{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}" + ``` + + A normal episode and a dated one, same template: + + ```text + The.Daily.Show.S28E101.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv + The.Daily.Show.2024.06.01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv + ``` + + The date separator follows your template style: dots here, spaces if your `series` + template uses spaces. + === "Music" ```yaml @@ -375,7 +467,25 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an ``` ```text The Artist - The Album (2024)/ - └─ 01. Opening Track.mka + └─ 01.Opening.Track.mka + ``` + + In `"{track_number}. {title}"` dots and spaces are tied, and ties go to dot style; add + more spaces between variables (as below) if you want space-separated names. + + A multi-disc album with explicit tracks, using more of the music variables. `{disc}` is + empty on disc 1, so `{disc?}` and its separator only appear from disc 2 onward: + + ```yaml + output_template: + songs: "{disc?}-{track_number}. {title} {explicit?}" + folder: + albums: "{album_artist} - {album} ({year}) [{label?}]" + ``` + ```text + The Artist - The Album (2024) [The Label]/ + ├─ 01. Opening Track.mka + └─ 02-03. Closing Track Explicit.mka ``` ## See also diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index af0e422e..681df844 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -459,6 +459,36 @@ language_tags: tag: "SUBBED" ``` +### `dual_multi_mode` + +- **Type:** `str`  ·  **Default:** `strict` + +Rule set for the `{dual}`, `{multi}`, and `{dubbed}` filename variables. + +=== "`strict` (default)" + + | Variable | Set when | + |----------|----------| + | `{dual}` | Exactly two audio languages **and one is the title's original language**. | + | `{multi}` | Three or more audio languages, even without the original. | + | `{dubbed}` | A single audio language that is not the title's original language (requires a known original). | + +=== "`count` (legacy)" + + | Variable | Set when | + |----------|----------| + | `{dual}` | Exactly two audio languages (original language ignored). | + | `{multi}` | Three or more audio languages. | + | `{dubbed}` | Never set. | + +```yaml +dual_multi_mode: strict +``` + +!!! note "Dialects count as one language" + Regional variants of the same base language (e.g. `en-US` and `en-GB`) collapse to a single + language in both modes, so they never trigger `{dual}` on their own. + --- ## Output & naming { #output-naming } @@ -500,7 +530,7 @@ folder-kind keys. `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`, `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`, `isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`, `dual`, - `multi`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`. + `multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`. ### Tagging & naming keys diff --git a/tests/core/test_dual_multi_naming.py b/tests/core/test_dual_multi_naming.py new file mode 100644 index 00000000..6e385275 --- /dev/null +++ b/tests/core/test_dual_multi_naming.py @@ -0,0 +1,120 @@ +"""Tests for {dual}/{multi}/{dubbed} filename variables and dual_multi_mode.""" + +from __future__ import annotations + +import types + +import pytest + +from unshackle.core.config import config +from unshackle.core.titles.movie import Movie + + +class DummyService: + pass + + +def make_audio(language: str) -> types.SimpleNamespace: + """Audio track stub carrying just the attributes the context builder reads.""" + return types.SimpleNamespace( + language=language, + format="AAC", + channel_layout=None, + channellayout_original=None, + channel_s=2, + channels=2, + format_additionalfeatures=None, + joc=None, + ) + + +def make_media_info(languages: list[str]) -> types.SimpleNamespace: + return types.SimpleNamespace(video_tracks=[], audio_tracks=[make_audio(lang) for lang in languages]) + + +def make_movie(language) -> Movie: + return Movie(id_="movie-0001", service=DummyService, name="The Film", year=2024, language=language) + + +@pytest.fixture +def strict_mode(monkeypatch): + monkeypatch.setattr(config, "dual_multi_mode", "strict") + + +@pytest.fixture +def count_mode(monkeypatch): + monkeypatch.setattr(config, "dual_multi_mode", "count") + + +def ctx(language, languages) -> dict: + return make_movie(language)._build_base_template_context(make_media_info(languages)) + + +def test_strict_dual_with_original(strict_mode): + c = ctx("en", ["en", "es"]) + assert c["dual"] == "DUAL" + assert c["multi"] == "" + assert c["dubbed"] == "" + + +def test_strict_two_dubs_no_original(strict_mode): + c = ctx("en", ["es", "fr"]) + assert c["dual"] == "" + assert c["multi"] == "" + assert c["dubbed"] == "" + + +def test_strict_regional_variants_collapse(strict_mode): + c = ctx("en", ["en-US", "en-GB"]) + assert c["dual"] == "" + assert c["multi"] == "" + assert c["dubbed"] == "" + + +def test_strict_multi(strict_mode): + c = ctx("en", ["es", "fr", "de"]) + assert c["multi"] == "MULTi" + assert c["dual"] == "" + assert c["dubbed"] == "" + + +def test_strict_two_unknown_original(strict_mode): + c = ctx(None, ["en", "es"]) + assert c["dual"] == "" + assert c["multi"] == "" + assert c["dubbed"] == "" + + +def test_strict_dubbed(strict_mode): + c = ctx("en", ["es"]) + assert c["dubbed"] == "DUBBED" + assert c["dual"] == "" + assert c["multi"] == "" + + +def test_strict_single_unknown_original_no_dubbed(strict_mode): + c = ctx(None, ["es"]) + assert c["dubbed"] == "" + assert c["dual"] == "" + assert c["multi"] == "" + + +def test_count_dual_ignores_original(count_mode): + c = ctx(None, ["en", "es"]) + assert c["dual"] == "DUAL" + assert c["multi"] == "" + assert c["dubbed"] == "" + + +def test_count_multi(count_mode): + c = ctx(None, ["es", "fr", "de"]) + assert c["multi"] == "MULTi" + assert c["dual"] == "" + assert c["dubbed"] == "" + + +def test_count_never_dubbed(count_mode): + c = ctx("en", ["es"]) + assert c["dubbed"] == "" + assert c["dual"] == "" + assert c["multi"] == "" diff --git a/unshackle/core/config.py b/unshackle/core/config.py index f8cf4973..92ee1b97 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -126,6 +126,7 @@ class Config: self.redact_paths: bool = kwargs.get("redact_paths", True) self.language_tags: dict = kwargs.get("language_tags") or {} + self.dual_multi_mode: str = (kwargs.get("dual_multi_mode") or "strict").lower() self.output_template: dict = kwargs.get("output_template") or {} folder_cfg = self.output_template.pop("folder", "") self.folder_template: str = "" @@ -191,6 +192,7 @@ class Config: "atmos", "dual", "multi", + "dubbed", "video", "hdr", "hfr", diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index 9aeb30c0..b205cce0 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -74,7 +74,8 @@ class Title: ) if primary_audio_track is None: primary_audio_track = next(iter(media_info.audio_tracks), None) - unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language}) + audio_lang_bases = {x.language.split("-")[0].lower() for x in media_info.audio_tracks if x.language} + unique_audio_languages = len(audio_lang_bases) context: dict[str, Any] = { "source": self.service.__name__ if show_service else "", @@ -88,6 +89,7 @@ class Title: "atmos": "", "dual": "", "multi": "", + "dubbed": "", "video": "", "hdr": "", "hfr": "", @@ -179,15 +181,15 @@ class Title: } ) - if unique_audio_languages == 2: - context["dual"] = "DUAL" - context["multi"] = "" - elif unique_audio_languages > 2: - context["dual"] = "" + strict = config.dual_multi_mode != "count" + if unique_audio_languages > 2: context["multi"] = "MULTi" - else: - context["dual"] = "" - context["multi"] = "" + elif unique_audio_languages == 2: + if not strict or (original_lang_tag and original_lang_tag in audio_lang_bases): + context["dual"] = "DUAL" + elif unique_audio_languages == 1 and strict: + if original_lang_tag and original_lang_tag not in audio_lang_bases: + context["dubbed"] = "DUBBED" lang_tag_rules = config.language_tags.get("rules") if config.language_tags else None if lang_tag_rules and self.tracks: diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 4db3e1a9..e0150f20 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -9,18 +9,19 @@ # Group or username appended to download filenames after a dash. tag: "NOGRP" -# --- Output filename & folder templates --- docs/OUTPUT_CONFIG.md -# Full variable list (35 vars incl. music) and all naming options are -# documented in docs/OUTPUT_CONFIG.md. If omitted, scene-style defaults are -# used (with a warning). Conditional variables take a ? suffix, e.g. {year?}. +# --- Output filename & folder templates --- docs/guide/output-and-naming.md +# The full variable list and all naming options are documented there. +# movies/series have no built-in fallback (define them to download those kinds); +# songs falls back to "{track_number}. {title}". Conditional variables take a +# ? suffix, e.g. {year?}. output_template: # Scene-style naming (dot-separated) - movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" - series: "{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" + movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" + series: "{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" songs: "{track_number}.{title}.{repack?}.{edition?}.{lang_tag?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}" folder: - movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" - series: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" + movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" + series: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" albums: "{album_artist} - {album} ({year?})" #