feat: original-language aware DUAL/MULTi tagging and new DUBBED variable

DUAL now requires the original language plus one dub (strict default);
dual_multi_mode: count restores the old pure-count behavior.
This commit is contained in:
imSp4rky
2026-07-18 11:02:02 -06:00
parent 094c598ef5
commit 2c8a98f337
6 changed files with 288 additions and 23 deletions
+115 -5
View File
@@ -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` | | `quality` | Resolution with scan suffix | `1080p`, `2160p`, `576i` |
| `resolution` | Resolution number only | `1080` | | `resolution` | Resolution number only | `1080` |
| `video` | Video codec | `H.264`, `H.265` | | `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` | | `hfr` | High frame rate marker (frame rate > 30) | `HFR` |
| `edition` | Edition label from the track, if any | `Directors Cut` | | `edition` | Edition label from the track, if any | `Directors Cut` |
| `tag` | Release-group tag (from `config.tag` or `--tag`) | `TAG` | | `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_channels` | Channel layout | `5.1`, `2.0` |
| `audio_full` | Codec + channels combined | `DDP5.1` | | `audio_full` | Codec + channels combined | `DDP5.1` |
| `atmos` | `Atmos` when any track carries JOC | `Atmos` | | `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` | | `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 ### Music-specific
@@ -339,6 +340,9 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an
## Worked examples ## 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" === "Movies"
```yaml ```yaml
@@ -347,9 +351,27 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an
folder: folder:
movies: "{title} ({year})" movies: "{title} ({year})"
``` ```
A 4K Atmos HDR download fills every placeholder:
```text ```text
The Movie (2024)/ 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" === "TV series"
@@ -361,10 +383,80 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an
series: "{title} ({year})/Season {season}" series: "{title} ({year})/Season {season}"
``` ```
```text ```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 └─ 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" === "Music"
```yaml ```yaml
@@ -375,7 +467,25 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an
``` ```
```text ```text
The Artist - The Album (2024)/ 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 ## See also
+31 -1
View File
@@ -459,6 +459,36 @@ language_tags:
tag: "SUBBED" 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 } ## Output & naming { #output-naming }
@@ -500,7 +530,7 @@ folder-kind keys.
`quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`, `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`,
`album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`, `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`,
`isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`, `dual`, `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 ### Tagging & naming keys
+120
View File
@@ -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"] == ""
+2
View File
@@ -126,6 +126,7 @@ class Config:
self.redact_paths: bool = kwargs.get("redact_paths", True) self.redact_paths: bool = kwargs.get("redact_paths", True)
self.language_tags: dict = kwargs.get("language_tags") or {} 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 {} self.output_template: dict = kwargs.get("output_template") or {}
folder_cfg = self.output_template.pop("folder", "") folder_cfg = self.output_template.pop("folder", "")
self.folder_template: str = "" self.folder_template: str = ""
@@ -191,6 +192,7 @@ class Config:
"atmos", "atmos",
"dual", "dual",
"multi", "multi",
"dubbed",
"video", "video",
"hdr", "hdr",
"hfr", "hfr",
+11 -9
View File
@@ -74,7 +74,8 @@ class Title:
) )
if primary_audio_track is None: if primary_audio_track is None:
primary_audio_track = next(iter(media_info.audio_tracks), 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] = { context: dict[str, Any] = {
"source": self.service.__name__ if show_service else "", "source": self.service.__name__ if show_service else "",
@@ -88,6 +89,7 @@ class Title:
"atmos": "", "atmos": "",
"dual": "", "dual": "",
"multi": "", "multi": "",
"dubbed": "",
"video": "", "video": "",
"hdr": "", "hdr": "",
"hfr": "", "hfr": "",
@@ -179,15 +181,15 @@ class Title:
} }
) )
if unique_audio_languages == 2: strict = config.dual_multi_mode != "count"
context["dual"] = "DUAL" if unique_audio_languages > 2:
context["multi"] = ""
elif unique_audio_languages > 2:
context["dual"] = ""
context["multi"] = "MULTi" context["multi"] = "MULTi"
else: elif unique_audio_languages == 2:
context["dual"] = "" if not strict or (original_lang_tag and original_lang_tag in audio_lang_bases):
context["multi"] = "" 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 lang_tag_rules = config.language_tags.get("rules") if config.language_tags else None
if lang_tag_rules and self.tracks: if lang_tag_rules and self.tracks:
+9 -8
View File
@@ -9,18 +9,19 @@
# Group or username appended to download filenames after a dash. # Group or username appended to download filenames after a dash.
tag: "NOGRP" tag: "NOGRP"
# --- Output filename & folder templates --- docs/OUTPUT_CONFIG.md # --- Output filename & folder templates --- docs/guide/output-and-naming.md
# Full variable list (35 vars incl. music) and all naming options are # The full variable list and all naming options are documented there.
# documented in docs/OUTPUT_CONFIG.md. If omitted, scene-style defaults are # movies/series have no built-in fallback (define them to download those kinds);
# used (with a warning). Conditional variables take a ? suffix, e.g. {year?}. # songs falls back to "{track_number}. {title}". Conditional variables take a
# ? suffix, e.g. {year?}.
output_template: output_template:
# Scene-style naming (dot-separated) # 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}" 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?}.{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}" songs: "{track_number}.{title}.{repack?}.{edition?}.{lang_tag?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}"
folder: folder:
movies: "{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?}.{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?})" albums: "{album_artist} - {album} ({year?})"
# #