fix(naming): correct language tag rule matching and add state conditions

Rules took only a scalar, so the documented list syntax raised LanguageTagError after the mux. Normalise every condition, treat an invalid tag as no match, guard the surrounding post-mux config reads, and let a rule match dual, multi and dubbed so a release can carry its own tag.
This commit is contained in:
imSp4rky
2026-09-11 20:00:08 -06:00
parent f43b90d6b6
commit c8a674d85d
9 changed files with 207 additions and 18 deletions
+32 -2
View File
@@ -52,6 +52,13 @@ Common config keys. This is a useful subset, and every `dl` flag works:
| `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. | | `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. |
| `no_folder` | bool | `false` | Do not create a per-title folder. | | `no_folder` | bool | `false` | Do not create a per-title folder. |
!!! note "`no` and `yes` are language tags"
YAML reads a bare `no` as `false` and a bare `yes` as `true`. unshackle puts both back to
the tags `no` (Norwegian) and `yes` (Nyankpa) in every config key that takes a language: `lang`,
`v_lang`, `a_lang`, `s_lang`, `forced_s_lang`, `require_audio`, `require_video`,
`require_subs`, [`language_priority`](#subtitle), [`muxing.default_language`](#muxing) and
the [`language_tags`](#language_tags) rules. Quotes (`lang: "no"`) work as well.
```yaml title="Global download defaults" ```yaml title="Global download defaults"
dl: dl:
lang: [en] lang: [en]
@@ -253,9 +260,22 @@ Language-tag remapping and the rule engine behind the `{lang_tag}` filename vari
| Condition | Meaning | | Condition | Meaning |
|-----------|---------| |-----------|---------|
| `audio` | Matches against the audio track languages. | | `audio` | At least one audio language matches. |
| `subs_contain` | At least one subtitle language matches. | | `subs_contain` | At least one subtitle language matches. |
| `subs_contain_all` | Every listed language must be present (scalar or list). | | `subs_contain_all` | Every listed language must be present. |
| `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. `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
release yourself, in place of the fixed `DUAL`, `MULTi` and `DUBBED` text. A rule that names
`dubbed: true` and a language tags a localized dub without tagging a native release the same way.
```yaml ```yaml
language_tags: language_tags:
@@ -263,8 +283,18 @@ language_tags:
- audio: [ja] - audio: [ja]
subs_contain: [en] subs_contain: [en]
tag: "SUBBED" tag: "SUBBED"
- audio: th
dubbed: true
tag: "THAIDUB"
- audio: th
dual: true
tag: "THAIDUAL"
``` ```
!!! 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?}`.
## `dual_multi_mode` ## `dual_multi_mode`
- **Type:** `str`  ·  **Default:** `strict` - **Type:** `str`  ·  **Default:** `strict`
+7
View File
@@ -118,3 +118,10 @@ def test_count_never_dubbed(count_mode):
assert c["dubbed"] == "" assert c["dubbed"] == ""
assert c["dual"] == "" assert c["dual"] == ""
assert c["multi"] == "" assert c["multi"] == ""
def test_a_missing_template_key_falls_back_instead_of_raising(monkeypatch):
"""dl only checks output_template is non-empty, so the title's own key can be absent."""
monkeypatch.setattr(config, "output_template", {"series": "{title}"})
name = make_movie("en").get_filename(make_media_info(["en"]))
assert name == "The Film (2024)"
+74
View File
@@ -0,0 +1,74 @@
"""Rules accept both the scalar and the list form of every condition."""
import pytest
from langcodes import Language
from unshackle.core.utils.language_tags import evaluate_language_tag
JA = [Language.get("ja")]
EN_ES = [Language.get("en-US"), Language.get("es")]
@pytest.mark.parametrize("audio, subs", [("ja", "en"), (["ja"], ["en"])])
def test_scalar_and_list_forms_both_match(audio, subs) -> None:
rules = [{"audio": audio, "subs_contain": subs, "tag": "SUBBED"}]
assert evaluate_language_tag(rules, JA, EN_ES) == "SUBBED"
def test_a_list_matches_any_of_its_entries() -> None:
rules = [{"audio": ["ko", "ja"], "tag": "ASIAN"}]
assert evaluate_language_tag(rules, JA, []) == "ASIAN"
def test_subs_contain_all_needs_every_entry() -> None:
rules = [{"subs_contain_all": ["en", "fr"], "tag": "MULTI"}]
assert evaluate_language_tag(rules, 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"}]
assert evaluate_language_tag(rules, JA, EN_ES) == "OK"
assert "not a valid language tag" in caplog.text
def test_dubbed_separates_a_localized_dub_from_a_native_release() -> None:
rules = [{"audio": "th", "dubbed": True, "tag": "THAIDUB"}, {"audio": "th", "tag": "THAI"}]
th = [Language.get("th")]
assert evaluate_language_tag(rules, th, [], {"dubbed": True}) == "THAIDUB"
assert evaluate_language_tag(rules, th, [], {"dubbed": False}) == "THAI"
@pytest.mark.parametrize("state", ["dual", "multi", "dubbed"])
def test_every_state_condition_matches_both_ways(state) -> None:
rules = [{state: True, "tag": "ON"}, {state: False, "tag": "OFF"}]
assert evaluate_language_tag(rules, JA, [], {state: True}) == "ON"
assert evaluate_language_tag(rules, JA, [], {state: False}) == "OFF"
assert evaluate_language_tag(rules, JA, [], None) == "OFF"
def test_a_state_condition_combines_with_a_language() -> None:
rules = [{"audio": "th", "dual": True, "tag": "THAIDUAL"}, {"dual": True, "tag": "DUAL"}]
states = {"dual": True}
assert evaluate_language_tag(rules, [Language.get("th")], [], states) == "THAIDUAL"
assert evaluate_language_tag(rules, JA, [], states) == "DUAL"
@pytest.mark.parametrize("bad", ["yes", "false", 1])
def test_a_non_boolean_state_value_skips_the_rule(bad, caplog) -> None:
rules = [{"dubbed": bad, "tag": "BAD"}, {"audio": "ja", "tag": "OK"}]
assert evaluate_language_tag(rules, JA, [], {"dubbed": True}) == "OK"
assert evaluate_language_tag(rules, JA, [], {"dubbed": False}) == "OK"
assert "must be true or false" in caplog.text
@pytest.mark.parametrize("typo", ["dubed", "subs_contains"])
def test_a_typo_in_a_condition_name_skips_the_rule(typo, caplog) -> None:
rules = [{"audio": "ja", typo: True, "tag": "BAD"}, {"audio": "ja", "tag": "OK"}]
assert evaluate_language_tag(rules, JA, [], {"dubbed": True}) == "OK"
assert f"unknown key(s) {typo}" in caplog.text
@pytest.mark.parametrize("rules", ["notalist", ["foo"], {"audio": "ja"}, [None]])
def test_a_malformed_rules_block_is_ignored_not_a_crash(rules) -> None:
assert evaluate_language_tag(rules, JA, []) == ""
+5 -1
View File
@@ -258,7 +258,11 @@ class Episode(Title):
name += f" {self.folder_season()}" name += f" {self.folder_season()}"
return sanitize_filename(name, " ") return sanitize_filename(name, " ")
formatter = TemplateFormatter(config.output_template["series"]) template = config.output_template.get("series")
if not template:
return sanitize_filename(str(self), " ")
formatter = TemplateFormatter(template)
context = self.build_template_context(media_info, show_service) context = self.build_template_context(media_info, show_service)
return formatter.format(context) return formatter.format(context)
+5 -1
View File
@@ -76,7 +76,11 @@ class Movie(Title):
name += f" ({self.year})" name += f" ({self.year})"
return sanitize_filename(name, " ") return sanitize_filename(name, " ")
formatter = TemplateFormatter(config.output_template["movies"]) template = config.output_template.get("movies")
if not template:
return sanitize_filename(str(self), " ")
formatter = TemplateFormatter(template)
context = self.build_template_context(media_info, show_service) context = self.build_template_context(media_info, show_service)
return formatter.format(context) return formatter.format(context)
+4 -2
View File
@@ -213,13 +213,15 @@ class Title:
if original_lang_tag and original_lang_tag not in audio_lang_bases: if original_lang_tag and original_lang_tag not in audio_lang_bases:
context["dubbed"] = "DUBBED" context["dubbed"] = "DUBBED"
lang_tag_rules = config.language_tags.get("rules") if config.language_tags else None language_tags = config.language_tags if isinstance(config.language_tags, dict) else {}
lang_tag_rules = language_tags.get("rules")
if lang_tag_rules and self.tracks: if lang_tag_rules and self.tracks:
from unshackle.core.utils.language_tags import evaluate_language_tag from unshackle.core.utils.language_tags import evaluate_language_tag
audio_langs = [a.language for a in self.tracks.audio] audio_langs = [a.language for a in self.tracks.audio]
sub_langs = [s.language for s in self.tracks.subtitles] sub_langs = [s.language for s in self.tracks.subtitles]
context["lang_tag"] = evaluate_language_tag(lang_tag_rules, audio_langs, sub_langs) states = {name: bool(context[name]) for name in ("dual", "multi", "dubbed")}
context["lang_tag"] = evaluate_language_tag(lang_tag_rules, audio_langs, sub_langs, states)
if config.tag_rules: if config.tag_rules:
from unshackle.core.utils.tag_rules import evaluate_tag_rules from unshackle.core.utils.tag_rules import evaluate_tag_rules
+11 -4
View File
@@ -26,7 +26,13 @@ from unshackle.core.tracks.chapters import Chapter, Chapters
from unshackle.core.tracks.subtitle import Subtitle from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.tracks.track import Track, has_dts_uhd_sample_entry, strip_duplicate_init_boxes from unshackle.core.tracks.track import Track, has_dts_uhd_sample_entry, strip_duplicate_init_boxes
from unshackle.core.tracks.video import Video from unshackle.core.tracks.video import Video
from unshackle.core.utilities import is_close_match, log_event, matching_languages, sanitize_filename from unshackle.core.utilities import (
is_close_match,
log_event,
matching_languages,
sanitize_filename,
valid_language,
)
from unshackle.core.utils.collections import as_list, flatten from unshackle.core.utils.collections import as_list, flatten
MP4BOX_PROGRESS = re.compile(r"\((\d{1,3})/100\)") MP4BOX_PROGRESS = re.compile(r"\((\d{1,3})/100\)")
@@ -727,9 +733,10 @@ class Tracks:
cl.extend(["--title", title]) cl.extend(["--title", title])
default_language = config.muxing.get("default_language") or {} default_language = config.muxing.get("default_language") or {}
preferred_video_lang = default_language.get("video") # mux() runs after every track is downloaded, so a typo here must not discard the title
preferred_audio_lang = default_language.get("audio") preferred_video_lang = valid_language(default_language.get("video"))
preferred_subtitle_lang = default_language.get("subtitle") preferred_audio_lang = valid_language(default_language.get("audio"))
preferred_subtitle_lang = valid_language(default_language.get("subtitle"))
preferred_video_idx: Optional[int] = None preferred_video_idx: Optional[int] = None
if preferred_video_lang: if preferred_video_lang:
+12
View File
@@ -153,6 +153,18 @@ def is_close_match(language: Union[str, Language], languages: Sequence[Union[str
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_MAX_DISTANCE return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_MAX_DISTANCE
def valid_language(language: Any) -> Optional[str]:
"""Return the language tag as text, or None when it is not a valid tag."""
if not language:
return None
try:
Language.get(str(language))
except LanguageTagError:
logging.getLogger("utilities").warning("%r is not a valid language tag, ignoring it", language)
return None
return str(language)
def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool: def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool:
"""Examine whether a language exactly matches any of the given languages.""" """Examine whether a language exactly matches any of the given languages."""
languages = [x for x in languages if x] languages = [x for x in languages if x]
+57 -8
View File
@@ -3,19 +3,26 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Sequence from collections.abc import Mapping as MappingABC
from typing import Any, Mapping, Optional, Sequence
from langcodes import Language from langcodes import Language
from langcodes.tag_parser import LanguageTagError
from unshackle.core.utilities import is_close_match from unshackle.core.utilities import is_close_match
log = logging.getLogger(__name__) 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")
def evaluate_language_tag( def evaluate_language_tag(
rules: list[dict[str, Any]], rules: list[dict[str, Any]],
audio_languages: Sequence[Language], audio_languages: Sequence[Language],
subtitle_languages: Sequence[Language], subtitle_languages: Sequence[Language],
states: Optional[Mapping[str, bool]] = None,
) -> str: ) -> str:
"""Evaluate language tag rules against selected tracks. """Evaluate language tag rules against selected tracks.
@@ -26,17 +33,27 @@ def evaluate_language_tag(
rules: List of rule dicts from config, each with conditions and a ``tag``. rules: List of rule dicts from config, each with conditions and a ``tag``.
audio_languages: Languages of the selected audio tracks. audio_languages: Languages of the selected audio tracks.
subtitle_languages: Languages of the selected subtitle tracks. subtitle_languages: Languages of the selected subtitle tracks.
states: Whether each of ``dual``, ``multi`` and ``dubbed`` is set for this release,
the same state as the filename variable of that name.
Returns: Returns:
The tag string from the first rule that matches, or ``""`` if none match. The tag string from the first rule that matches, or ``""`` if none match.
""" """
if not isinstance(rules, list):
log.warning("Language tag rules must be a list of rules, ignoring: %r", rules)
return ""
for rule in rules: for rule in rules:
if not isinstance(rule, MappingABC):
log.warning("Language tag rule must be a mapping of conditions and a 'tag', skipping: %r", rule)
continue
tag = rule.get("tag") tag = rule.get("tag")
if not tag: if not tag:
log.warning("Language tag rule missing 'tag' field, skipping: %s", rule) log.warning("Language tag rule missing 'tag' field, skipping: %s", rule)
continue continue
if rule_matches(rule, audio_languages, subtitle_languages): if rule_matches(rule, audio_languages, subtitle_languages, states):
log.debug("Language tag rule matched: %s -> %s", rule, tag) log.debug("Language tag rule matched: %s -> %s", rule, tag)
return str(tag) return str(tag)
@@ -47,29 +64,43 @@ def rule_matches(
rule: dict[str, Any], rule: dict[str, Any],
audio_languages: Sequence[Language], audio_languages: Sequence[Language],
subtitle_languages: Sequence[Language], subtitle_languages: Sequence[Language],
states: Optional[Mapping[str, bool]] = None,
) -> bool: ) -> bool:
"""Return True when every condition in the rule matches the tracks.""" """Return True when every condition in the rule matches the tracks."""
unknown = [key for key in rule if key not in (*LANGUAGE_CONDITIONS, *STATE_CONDITIONS, "tag")]
if unknown:
log.warning("Language tag rule has unknown key(s) %s, skipping: %s", ", ".join(sorted(unknown)), rule)
return False
has_condition = False has_condition = False
audio_lang = rule.get("audio") audio_lang = rule.get("audio")
if audio_lang is not None: if audio_lang is not None:
has_condition = True has_condition = True
if not is_close_match(audio_lang, list(audio_languages)): if not any(_matches(lang, audio_languages) for lang in _as_list(audio_lang)):
return False return False
subs_contain = rule.get("subs_contain") subs_contain = rule.get("subs_contain")
if subs_contain is not None: if subs_contain is not None:
has_condition = True has_condition = True
if not is_close_match(subs_contain, list(subtitle_languages)): if not any(_matches(lang, subtitle_languages) for lang in _as_list(subs_contain)):
return False return False
subs_contain_all = rule.get("subs_contain_all") subs_contain_all = rule.get("subs_contain_all")
if subs_contain_all is not None: if subs_contain_all is not None:
has_condition = True has_condition = True
if not isinstance(subs_contain_all, list): for lang in _as_list(subs_contain_all):
subs_contain_all = [subs_contain_all] if not _matches(lang, subtitle_languages):
for lang in subs_contain_all: return False
if not is_close_match(lang, list(subtitle_languages)):
for name in STATE_CONDITIONS:
wanted = rule.get(name)
if wanted is not None:
has_condition = True
if not isinstance(wanted, bool):
log.warning("Language tag rule condition %r must be true or false, skipping: %s", name, rule)
return False
if wanted is not bool((states or {}).get(name)):
return False return False
if not has_condition: if not has_condition:
@@ -77,3 +108,21 @@ def rule_matches(
return False return False
return True return True
def _as_list(value: Any) -> list[Any]:
"""Return the rule value as a list, so a scalar and a one-item list behave the same."""
return value if isinstance(value, list) else [value]
def _matches(lang: Any, languages: Sequence[Language]) -> bool:
"""Return True when the rule language closely matches one of the track languages.
A rule value that is not a valid language tag counts as no match. The filename is built
after the mux, so a typo in the config must not discard a finished download.
"""
try:
return is_close_match(lang, list(languages))
except LanguageTagError as e:
log.warning("Language tag rule value %r is not a valid language tag, treating as no match: %s", lang, e)
return False