diff --git a/tests/core/test_config_bool_languages.py b/tests/core/test_config_bool_languages.py new file mode 100644 index 00000000..44990bc6 --- /dev/null +++ b/tests/core/test_config_bool_languages.py @@ -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) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 5c1b2143..0ce9ef50 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -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