mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
fix(config): keep no and yes as language tags
This commit is contained in:
@@ -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)
|
||||||
@@ -11,6 +11,54 @@ from appdirs import AppDirs
|
|||||||
from unshackle.core.service_repo import is_repo_spec
|
from unshackle.core.service_repo import is_repo_spec
|
||||||
from unshackle.core.utils.collections import ci_get
|
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:
|
def resolve_decryption(decryption_map: dict, default: str, service: str) -> str:
|
||||||
"""Pick the decryption backend for a service (case-insensitive), falling back to default."""
|
"""Pick the decryption backend for a service (case-insensitive), falling back to default."""
|
||||||
@@ -215,6 +263,27 @@ class Config:
|
|||||||
"title_type",
|
"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'[<>:"/\\|?*]'
|
unsafe_chars = r'[<>:"/\\|?*]'
|
||||||
|
|
||||||
all_templates = dict(self.output_template)
|
all_templates = dict(self.output_template)
|
||||||
@@ -233,10 +302,14 @@ class Config:
|
|||||||
|
|
||||||
variables = re.findall(r"\{([^}]+)\}", template_str)
|
variables = re.findall(r"\{([^}]+)\}", template_str)
|
||||||
|
|
||||||
|
kind = template_type.split(".")[-1]
|
||||||
|
allowed = shared | type_only.get(kind, set())
|
||||||
for var in variables:
|
for var in variables:
|
||||||
var_clean = var.rstrip("?")
|
var_clean = var.rstrip("?")
|
||||||
if var_clean not in valid_variables:
|
if var_clean not in valid_variables:
|
||||||
warnings.warn(f"Unknown template variable '{var}' in {template_type} template")
|
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)
|
test_template = re.sub(r"\{[^}]+\}", "TEST", template_str)
|
||||||
if template_type.startswith("folder"):
|
if template_type.startswith("folder"):
|
||||||
@@ -284,7 +357,7 @@ class Config:
|
|||||||
raise FileNotFoundError(f"Config file path ({path}) was not found")
|
raise FileNotFoundError(f"Config file path ({path}) was not found")
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise FileNotFoundError(f"Config file path ({path}) is not to a 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
|
# noinspection PyProtectedMember
|
||||||
|
|||||||
Reference in New Issue
Block a user