From d4abcb51d909a3caff089f272804fb92fa3410ef Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 29 Oct 2026 14:41:06 -0600 Subject: [PATCH] feat(naming): add audio_contain_all language tag condition An empty audio_contain_all or subs_contain_all list now never matches; before this, an empty subs_contain_all list matched every release. --- docs/reference/configuration/download.md | 33 +++++++++++++++++++----- tests/core/test_config_bool_languages.py | 6 +++++ tests/core/test_language_tag_rules.py | 17 ++++++++++++ unshackle/core/config.py | 1 + unshackle/core/utils/language_tags.py | 32 ++++++++++------------- 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index d3e6a199..89dda0d6 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -312,17 +312,18 @@ Language-tag remapping and the rule engine behind the `{lang_tag}` filename vari | Condition | Meaning | |-----------|---------| | `audio` | At least one audio language matches. | +| `audio_contain_all` | Every listed language must be present in the audio. | | `subs_contain` | At least one subtitle language matches. | -| `subs_contain_all` | Every listed language must be present. | +| `subs_contain_all` | Every listed language must be present in the subtitles. | | `dual` / `multi` / `dubbed` | `true` matches when that filename variable is set, `false` when it is not. | Each language condition takes one language tag or a list of them. A list matches when **any** -entry matches, except `subs_contain_all`, which needs them all. An entry that is not a valid -language tag (for example `japanese`) never matches. A tag with a region matches only that -variant, so `audio: es-419` does not match `es-ES` audio. `dual`, `multi` and `dubbed` take a bare -`true` or `false`, not a quoted string. unshackle skips a rule with a condition name it does -not know, or with any other value in a boolean condition. It logs a warning in each of these -cases and tries the next rule. +entry matches, except `audio_contain_all` and `subs_contain_all`, which need them all. An empty +list never matches. An entry that is not a valid language tag (for example `japanese`) never +matches. A tag with a region matches only that variant, so `audio: es-419` does not match +`es-ES` audio. `dual`, `multi` and `dubbed` take a bare `true` or `false`, not a quoted string. +unshackle skips a rule with a condition name it does not know, or with any other value in a +boolean condition. It logs a warning in each of these cases and tries the next rule. `dual`, `multi` and `dubbed` carry the same meaning as the [filename variables of those names](#dual_multi_mode). Pair one with a language to name a @@ -343,6 +344,24 @@ language_tags: tag: "THAIDUAL" ``` +unshackle examines the rules in order, so put a rule that needs more languages before a rule that +needs fewer. Here, a release with both French dubs gets `VF2`, not `VFQ`: + +```yaml +language_tags: + rules: + - audio_contain_all: [fr-FR, fr-CA] + tag: "VF2" + - audio: fr-CA + tag: "VFQ" + - audio: fr-FR + tag: "VFF" +``` + +!!! tip "Give regional tags in an `_all` list" + A tag without a region also matches a regional track, so one `fr-CA` track satisfies both + entries of `[fr, fr-CA]`. Use `[fr-FR, fr-CA]` to require two different tracks. + !!! note "Put `{lang_tag}` where you want the name" These rules fill `{lang_tag}`. To use your own name in place of the fixed text, keep `{lang_tag?}` in the template and leave out `{dual?}`, `{multi?}` and `{dubbed?}`. diff --git a/tests/core/test_config_bool_languages.py b/tests/core/test_config_bool_languages.py index d7a82ff5..df02f94f 100644 --- a/tests/core/test_config_bool_languages.py +++ b/tests/core/test_config_bool_languages.py @@ -26,6 +26,8 @@ language_tags: rules: - audio: no tag: NORDIC + - audio_contain_all: [no, da] + tag: NORDIC2 update_checks: no """ @@ -57,6 +59,10 @@ def test_a_no_rule_tags_a_norwegian_track(tmp_path: Path) -> None: assert evaluate_language_tag(rules, [Language.get("no")], []) == "NORDIC" +def test_a_no_entry_survives_in_an_audio_contain_all_list(tmp_path: Path) -> None: + assert _load(tmp_path).language_tags["rules"][1]["audio_contain_all"] == ["no", "da"] + + def test_tag_rules_keep_their_own_booleans(tmp_path: Path) -> None: """tag_rules matches text and warns about a bare bool itself.""" path = tmp_path / "unshackle.yaml" diff --git a/tests/core/test_language_tag_rules.py b/tests/core/test_language_tag_rules.py index cba6f003..a4bf5913 100644 --- a/tests/core/test_language_tag_rules.py +++ b/tests/core/test_language_tag_rules.py @@ -31,6 +31,23 @@ def test_subs_contain_all_needs_every_entry() -> None: assert evaluate_language_tag(rules, JA, EN_ES) == "" +def test_audio_contain_all_tags_a_release_with_every_listed_language() -> None: + rules = [ + {"audio_contain_all": ["fr-FR", "fr-CA"], "tag": "VF2"}, + {"audio": "fr-CA", "tag": "VFQ"}, + {"audio": "fr-FR", "tag": "VFF"}, + ] + fr_fr, fr_ca, en = Language.get("fr-FR"), Language.get("fr-CA"), Language.get("en-US") + assert evaluate_language_tag(rules, [en, fr_fr, fr_ca], []) == "VF2" + assert evaluate_language_tag(rules, [en, fr_ca], []) == "VFQ" + assert evaluate_language_tag(rules, [en, fr_fr], []) == "VFF" + + +@pytest.mark.parametrize("condition", ["audio_contain_all", "subs_contain_all"]) +def test_an_empty_contain_all_list_matches_nothing(condition) -> None: + assert evaluate_language_tag([{condition: [], "tag": "ALL"}], JA, EN_ES) == "" + + @pytest.mark.parametrize("bad", ["japanese", 1, [["ja"]], {"ja": 1}]) def test_an_invalid_rule_value_is_no_match_not_a_crash(bad, caplog) -> None: rules = [{"audio": bad, "tag": "BAD"}, {"audio": "ja", "tag": "OK"}] diff --git a/unshackle/core/config.py b/unshackle/core/config.py index bbca1549..3f3a0fcf 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -30,6 +30,7 @@ LANGUAGE_KEYS = frozenset( "default_language", "video", "audio", + "audio_contain_all", "subtitle", "subs_contain", "subs_contain_all", diff --git a/unshackle/core/utils/language_tags.py b/unshackle/core/utils/language_tags.py index f1c861f1..4f7678ae 100644 --- a/unshackle/core/utils/language_tags.py +++ b/unshackle/core/utils/language_tags.py @@ -14,7 +14,13 @@ log = logging.getLogger(__name__) # Conditions that test a filename variable of the same name rather than a language. STATE_CONDITIONS = ("dual", "multi", "dubbed") -LANGUAGE_CONDITIONS = ("audio", "subs_contain", "subs_contain_all") +# Each language condition: whether it tests the subtitles, and whether any or all of its entries must match. +LANGUAGE_CONDITIONS = { + "audio": (False, any), + "audio_contain_all": (False, all), + "subs_contain": (True, any), + "subs_contain_all": (True, all), +} def evaluate_language_tag( @@ -73,23 +79,13 @@ def rule_matches( has_condition = False - audio_lang = rule.get("audio") - if audio_lang is not None: - has_condition = True - if not any(_matches(lang, audio_languages) for lang in _as_list(audio_lang)): - return False - - subs_contain = rule.get("subs_contain") - if subs_contain is not None: - has_condition = True - if not any(_matches(lang, subtitle_languages) for lang in _as_list(subs_contain)): - return False - - subs_contain_all = rule.get("subs_contain_all") - if subs_contain_all is not None: - has_condition = True - for lang in _as_list(subs_contain_all): - if not _matches(lang, subtitle_languages): + for name, (subs, quantifier) in LANGUAGE_CONDITIONS.items(): + wanted = rule.get(name) + if wanted is not None: + has_condition = True + wanted_langs = _as_list(wanted) + languages = subtitle_languages if subs else audio_languages + if not wanted_langs or not quantifier(_matches(lang, languages) for lang in wanted_langs): return False for name in STATE_CONDITIONS: