fix(config): keep no and yes as language tags

This commit is contained in:
imSp4rky
2026-09-11 20:05:46 -06:00
parent c8a674d85d
commit 9b49299dd4
2 changed files with 145 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
"""YAML reads a bare `no` or `yes` as a boolean, which would drop those languages."""
from pathlib import Path
import pytest
from langcodes import Language
from unshackle.core.config import Config
from unshackle.core.utils.language_tags import evaluate_language_tag
CONFIG = """
dl:
lang: [no, en]
s_lang: no
a_lang: [yes]
require_subs: no
require_audio: [no]
AMZN:
lang: no
muxing:
default_language:
audio: no
audio:
language_priority: [no, da]
language_tags:
rules:
- audio: no
tag: NORDIC
update_checks: no
"""
def _load(tmp_path: Path) -> Config:
path = tmp_path / "unshackle.yaml"
path.write_text(CONFIG, encoding="utf8")
return Config.from_yaml(path)
def test_bool_shaped_tags_survive_every_language_key(tmp_path: Path) -> None:
config = _load(tmp_path)
assert config.dl["lang"] == ["no", "en"]
assert config.dl["s_lang"] == "no"
assert config.dl["a_lang"] == ["yes"]
assert config.dl["require_subs"] == "no"
assert config.dl["require_audio"] == ["no"]
assert config.dl["AMZN"]["lang"] == "no"
assert config.muxing["default_language"]["audio"] == "no"
assert config.audio["language_priority"] == ["no", "da"]
def test_a_real_boolean_option_stays_false(tmp_path: Path) -> None:
assert _load(tmp_path).update_checks is False
def test_a_no_rule_tags_a_norwegian_track(tmp_path: Path) -> None:
rules = _load(tmp_path).language_tags["rules"]
assert evaluate_language_tag(rules, [Language.get("no")], []) == "NORDIC"
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"
path.write_text("tag_rules:\n - when:\n video: no\n tag: T\n", encoding="utf8")
assert Config.from_yaml(path).tag_rules[0]["when"]["video"] is False
def test_a_cross_type_template_variable_warns_at_load(tmp_path: Path) -> None:
path = tmp_path / "unshackle.yaml"
path.write_text('output_template:\n movies: "{title}.{artist}"\n', encoding="utf8")
with pytest.warns(UserWarning, match="not available in the movies template"):
Config.from_yaml(path)
+74 -1
View File
@@ -11,6 +11,54 @@ from appdirs import AppDirs
from unshackle.core.service_repo import is_repo_spec
from unshackle.core.utils.collections import ci_get
# YAML reads a bare `no` (Norwegian) or `yes` (Nyankpa) as a boolean, so `lang: no` would
# silently drop the language. These two are the only valid language tags that YAML coerces,
# and no option under a LANGUAGE_KEYS name holds a boolean.
BOOL_LANGUAGE_TAGS = {False: "no", True: "yes"}
LANGUAGE_KEYS = frozenset(
{
"lang",
"v_lang",
"a_lang",
"s_lang",
"forced_s_lang",
"require_audio",
"require_video",
"require_subs",
"language_priority",
"default_language",
"video",
"audio",
"subtitle",
"subs_contain",
"subs_contain_all",
}
)
OPAQUE_KEYS = frozenset({"tag_rules"})
def restore_bool_languages(data: Any) -> Any:
"""Put back the language tags ``no`` and ``yes``, which YAML reads as booleans."""
if isinstance(data, list):
return [restore_bool_languages(item) for item in data]
if not isinstance(data, dict):
return data
fixed = {}
for key, value in data.items():
if key in OPAQUE_KEYS:
fixed[key] = value
continue
if key in LANGUAGE_KEYS:
if isinstance(value, bool):
value = BOOL_LANGUAGE_TAGS[value]
elif isinstance(value, list):
value = [BOOL_LANGUAGE_TAGS[i] if isinstance(i, bool) else i for i in value]
fixed[key] = restore_bool_languages(value)
return fixed
def resolve_decryption(decryption_map: dict, default: str, service: str) -> str:
"""Pick the decryption backend for a service (case-insensitive), falling back to default."""
@@ -215,6 +263,27 @@ class Config:
"title_type",
}
type_only = {
"series": {"season", "episode", "season_episode", "episode_name", "part", "absolute", "date"},
"songs": {
"track_number",
"artist",
"album_artist",
"album",
"disc",
"track_total",
"disc_total",
"release_type",
"genre",
"explicit",
"isrc",
"upc",
"label",
},
}
type_only["albums"] = type_only["songs"]
shared = valid_variables - set().union(*type_only.values())
unsafe_chars = r'[<>:"/\\|?*]'
all_templates = dict(self.output_template)
@@ -233,10 +302,14 @@ class Config:
variables = re.findall(r"\{([^}]+)\}", template_str)
kind = template_type.split(".")[-1]
allowed = shared | type_only.get(kind, set())
for var in variables:
var_clean = var.rstrip("?")
if var_clean not in valid_variables:
warnings.warn(f"Unknown template variable '{var}' in {template_type} template")
elif var_clean not in allowed:
warnings.warn(f"Template variable '{var}' is not available in the {template_type} template")
test_template = re.sub(r"\{[^}]+\}", "TEST", template_str)
if template_type.startswith("folder"):
@@ -284,7 +357,7 @@ class Config:
raise FileNotFoundError(f"Config file path ({path}) was not found")
if not path.is_file():
raise FileNotFoundError(f"Config file path ({path}) is not to a file.")
return cls(**yaml.safe_load(path.read_text(encoding="utf8")) or {})
return cls(**restore_bool_languages(yaml.safe_load(path.read_text(encoding="utf8")) or {}))
# noinspection PyProtectedMember