mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"] == ""
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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?})"
|
||||
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user