feat(dl): add tag_rules to conditionally swap the group tag

This commit is contained in:
imSp4rky
2026-08-04 09:42:42 -06:00
parent 051f1e668f
commit 3e0d7b5f75
9 changed files with 598 additions and 3 deletions
+5
View File
@@ -102,6 +102,7 @@ Every variable below is valid in both output and folder templates. Values are de
| Variable | Meaning | Example |
|---|---|---|
| `title` | Title name (movie/show/song name; `$` is rendered as `S`) | `The Show` |
| `title_type` | Media kind of the title | `movie`, `series`, `music` |
| `year` | Release year | `2024` |
| `source` | Service tag / class name (empty with `--no-source`) | `EXAMPLE` |
| `quality` | Resolution with scan suffix | `1080p`, `2160p`, `576i` |
@@ -234,6 +235,10 @@ The `--no-mux` flag skips muxing entirely and writes the individual track files,
## Tags & group naming
The release-group tag is the `-TAG` portion at the end of scene-style names, produced by the `{tag}` variable.
It can be swapped per release with [`tag_rules`](../reference/configuration.md#tag_rules): ordered rules that
match on the release-attribute variables only (`quality`, `resolution`, `hdr`, `source`, `lang_tag`,
`title_type`, and so on), first match wins. The title's own fields, such as `title` and `season_episode`,
cannot be matched. See the [reference page](../reference/configuration.md#tag_rules) for the exact list.
```yaml title="unshackle.yaml"
tag: "MYGROUP"
+100 -2
View File
@@ -35,7 +35,7 @@ This page assumes you already know the basics and want precise details.
| [Directories](#directories) | `directories`, `filenames` |
| [Services & authentication](#services-auth) | `services`, `credentials`, `firefox_cookies`, `remote_services`, `serve` |
| [Download & processing](#download-processing) | `dl`, `subtitle`, `audio`, `muxing`, `language_tags`, `dual_multi_mode` |
| [Output & naming](#output-naming) | `output_template`, `tag`, `tag_group_name`, `tag_imdb_tmdb`, `chapter_fallback_name`, `unicode_filenames` |
| [Output & naming](#output-naming) | `output_template`, `tag`, `tag_rules`, `tag_group_name`, `tag_imdb_tmdb`, `chapter_fallback_name`, `unicode_filenames` |
| [DRM & CDM](#drm-cdm) | `cdm`, `remote_cdm`, `decryption` |
| [Network & proxy](#network-proxy) | `network`, `headers`, `proxy_providers` |
| [Key vaults](#key-vaults) | `key_vaults`, `vault_timeout` |
@@ -584,7 +584,8 @@ folder-kind keys.
`quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`,
`album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`,
`isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`, `dual`,
`multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`.
`multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`,
`title_type`.
### Tagging & naming keys
@@ -596,6 +597,103 @@ folder-kind keys.
| `chapter_fallback_name` | str | `""` | Fallback chapter-name template, e.g. `"Chapter {i:02}"`. |
| `unicode_filenames` | bool | `false` | Allow Unicode in filenames; when `false`, names are ASCII-sanitised. |
### `tag_rules`
- **Type:** `list`  ·  **Default:** `[]`
Conditionally replaces [`tag`](#output-naming) for a given download. Each rule pairs a `when`
dict of conditions with the `tag` to use when they hold. Rules are evaluated in order and the
first match wins; if no rule matches, the global `tag` is kept.
```yaml title="unshackle.yaml"
tag_rules:
- when: { title_type: movie, lang_tag: SUBBED }
tag: SUBMOVIES
- when: { quality: 2160p, hdr: [DV, DV.HDR, DV.HDR10P, HDR, HDR10P] }
tag: UHDGROUP
```
Rule semantics:
- Every condition in `when` must hold (they are AND-ed).
- A value may be a scalar or a list; a list matches if **any** entry matches.
- Comparison is case-insensitive string equality.
- The keys of `when` are the filename template variables that describe the release itself:
`title_type`, `quality`, `resolution`, `source`, `video`, `hdr`, `hfr`, `audio`,
`audio_channels`, `audio_full`, `atmos`, `dual`, `multi`, `dubbed`, `edition`, `repack`,
and `lang_tag`. Only these are available. The title's own fields (`title`, `year`, `season`,
`episode`, and so on) are added after the rules run and cannot be matched.
- An unknown key in `when` is a mistake: unshackle logs a warning that names the key and skips
that rule. A rule without conditions or without a `tag` is also warned about and skipped.
Put the more specific rules first, because the first match ends the search.
!!! warning "Match `hdr` on the exact value"
Matching is exact, and `hdr` is a composite label for Dolby Vision titles: a DV title with
an HDR10 base layer gives `DV.HDR`, and with an HDR10+ base layer `DV.HDR10P`. Plain `DV`
does **not** match those. List every variant you want, as the example above does. Note
also that HDR10 gives `HDR`, not `HDR10`. The full set of values is `HDR`, `HDR10P`, `DV`,
`DV.HDR`, `DV.HDR10P`, and `HLG`, as listed in the
[variable table](../guide/output-and-naming.md#template-variables).
#### Operators
A condition value may start with a comparison operator, which is applied to the rest of the
value:
```yaml title="unshackle.yaml"
tag_rules:
- when: { resolution: "<1080" }
tag: SDGROUP
- when: { quality: ">=2160" }
tag: UHDGROUP
- when: { video: "!=H.265" }
tag: AVCGROUP
```
- `>`, `<`, `>=`, `<=` compare numbers. unshackle reads the first number out of the actual
value, so `">=2160"` matches a quality of `2160p`. If the actual value has no number, the
condition fails.
- `=`, `==`, and `!=` compare text, case-insensitively, when the operand is not a number.
With a numeric operand they compare numbers like the operators above. A value with no number
in it is never equal to a number, so `"!=2160"` holds for it and `"==2160"` does not.
- An ordering operator with a text operand (for example `">DV"`) is a mistake: unshackle logs
a warning and the condition fails. So does an operator with nothing after it (`">="`).
- A value with no operator keeps the plain equality behaviour described above.
!!! warning "Quote operator values in YAML"
Two of these operators break an unquoted YAML value, and both stop the config from
loading:
- `>` opens a folded block scalar, so `quality: >=2160` fails with a "while scanning a
block scalar" error.
- `!` starts a tag, so `hdr: !=DV` fails with a "could not determine a constructor"
error.
Quote every operator value: `quality: ">=2160"`, `resolution: "<1080"`, `video: "!=H.265"`.
!!! note "Keep a negation out of lists"
A list matches if any entry matches, so two or more `!=` entries in one list are always
true. Give a negation as a single value, not as a list entry.
`title_type` is the media kind of the title: `movie`, `series`, or `music`. It is useful to
give each kind its own group:
```yaml
tag_rules:
- when: { title_type: movie }
tag: MOVIEGRP
- when: { title_type: series }
tag: TVGRP
```
The resolved tag applies to the `{tag}` filename variable only. The `Group` metadata tag
written into MKV and music files always uses [`tag`](#output-naming).
!!! note "`--tag` wins"
An explicit `dl --tag` on the command line disables the rules completely and uses the
given tag.
---
## DRM & CDM { #drm-cdm }
+223
View File
@@ -0,0 +1,223 @@
"""Tests for the tag_rules engine behind conditional release-group tags."""
from __future__ import annotations
from typing import Any
import pytest
from unshackle.core.utils.tag_rules import evaluate_tag_rules
CONTEXT: dict[str, Any] = {
"tag": "NOGRP",
"title_type": "movie",
"quality": "2160p",
"resolution": "2160",
"hdr": "DV",
"lang_tag": "SUBBED",
"edition": "",
}
def test_first_match_wins() -> None:
rules = [
{"when": {"quality": "2160p"}, "tag": "FIRST"},
{"when": {"title_type": "movie"}, "tag": "SECOND"},
]
assert evaluate_tag_rules(rules, CONTEXT) == "FIRST"
def test_list_matches_any_entry() -> None:
rules = [{"when": {"hdr": ["HDR10P", "DV"]}, "tag": "UHDGROUP"}]
assert evaluate_tag_rules(rules, CONTEXT) == "UHDGROUP"
def test_list_with_no_matching_entry() -> None:
rules = [{"when": {"hdr": ["HDR10P", "HLG"]}, "tag": "UHDGROUP"}]
assert evaluate_tag_rules(rules, CONTEXT) is None
def test_case_insensitive() -> None:
rules = [{"when": {"lang_tag": "subbed", "quality": "2160P"}, "tag": "SUBMOVIES"}]
assert evaluate_tag_rules(rules, CONTEXT) == "SUBMOVIES"
def test_conditions_are_anded() -> None:
rules = [{"when": {"title_type": "movie", "quality": "1080p"}, "tag": "NOPE"}]
assert evaluate_tag_rules(rules, CONTEXT) is None
def test_unknown_key_skips_rule_but_later_rules_match(caplog: pytest.LogCaptureFixture) -> None:
rules = [
{"when": {"bogus": "value"}, "tag": "SKIPPED"},
{"when": {"title_type": "movie"}, "tag": "MATCHED"},
]
assert evaluate_tag_rules(rules, CONTEXT) == "MATCHED"
assert "bogus" in caplog.text
@pytest.mark.parametrize(
"rule",
[
{"when": {"title_type": "movie"}},
{"tag": "NOCONDITIONS"},
{"when": {}, "tag": "EMPTY"},
],
)
def test_malformed_rule_skipped(rule: dict[str, Any]) -> None:
assert evaluate_tag_rules([rule], CONTEXT) is None
def test_no_rules_and_no_match() -> None:
assert evaluate_tag_rules([], CONTEXT) is None
assert evaluate_tag_rules([{"when": {"quality": "720p"}, "tag": "SD"}], CONTEXT) is None
def test_empty_context_value_matches_empty_condition() -> None:
rules = [{"when": {"edition": ""}, "tag": "PLAIN"}]
assert evaluate_tag_rules(rules, CONTEXT) == "PLAIN"
@pytest.mark.parametrize(
("condition", "expected"),
[
(">=2160", True),
(">=4320", False),
("<=2160", True),
("<1080", False),
(">1080", True),
("==2160", True),
("=2160", True),
("!=1080", True),
("!=2160", False),
(">= 2160", True),
],
)
def test_numeric_operators_extract_first_number(condition: str, expected: bool) -> None:
"""The actual value is "2160p", so the suffix must not defeat the comparison."""
rules = [{"when": {"quality": condition}, "tag": "MATCHED"}]
assert (evaluate_tag_rules(rules, CONTEXT) == "MATCHED") is expected
def test_string_inequality() -> None:
assert evaluate_tag_rules([{"when": {"hdr": "!=DV"}, "tag": "NODV"}], CONTEXT) is None
assert evaluate_tag_rules([{"when": {"hdr": "!=HDR10P"}, "tag": "NOHDR10P"}], CONTEXT) == "NOHDR10P"
def test_string_equality_operator_is_case_insensitive() -> None:
assert evaluate_tag_rules([{"when": {"lang_tag": "==subbed"}, "tag": "SUBGRP"}], CONTEXT) == "SUBGRP"
def test_plain_equality_unchanged() -> None:
assert evaluate_tag_rules([{"when": {"quality": "2160p"}, "tag": "UHD"}], CONTEXT) == "UHD"
assert evaluate_tag_rules([{"when": {"quality": "2160"}, "tag": "UHD"}], CONTEXT) is None
def test_numeric_operator_against_value_without_a_number() -> None:
rules = [{"when": {"hdr": ">=1080"}, "tag": "NOPE"}]
assert evaluate_tag_rules(rules, CONTEXT) is None
def test_ordering_operator_with_non_numeric_operand(caplog: pytest.LogCaptureFixture) -> None:
rules = [{"when": {"hdr": ">DV"}, "tag": "NOPE"}]
assert evaluate_tag_rules(rules, CONTEXT) is None
assert "numeric operand" in caplog.text
def test_operators_inside_lists() -> None:
rules = [{"when": {"quality": ["<720", ">=2160"]}, "tag": "MATCHED"}]
assert evaluate_tag_rules(rules, CONTEXT) == "MATCHED"
rules = [{"when": {"quality": ["<720", "1080p"]}, "tag": "NOPE"}]
assert evaluate_tag_rules(rules, CONTEXT) is None
@pytest.mark.parametrize("rules", ["UHDGROUP", {"when": {"quality": "2160p"}, "tag": "UHD"}, None, 42])
def test_rules_that_are_not_a_list(rules: Any) -> None:
"""A mapping or string here iterates keys/characters, so it must be rejected outright."""
assert evaluate_tag_rules(rules, CONTEXT) is None
@pytest.mark.parametrize("rule", [None, "UHDGROUP", 42, ["when", "tag"]])
def test_rule_entry_that_is_not_a_mapping_skipped(rule: Any) -> None:
assert evaluate_tag_rules([rule], CONTEXT) is None
assert evaluate_tag_rules([rule, {"when": {"title_type": "movie"}, "tag": "MATCHED"}], CONTEXT) == "MATCHED"
@pytest.mark.parametrize("when", [["title_type", "movie"], "title_type", 42])
def test_when_that_is_not_a_mapping_skipped(when: Any) -> None:
assert evaluate_tag_rules([{"when": when, "tag": "NOPE"}], CONTEXT) is None
@pytest.mark.parametrize(
"rule",
[
{"when": {"atmos": True}, "tag": "ATMOS"},
{"when": {"atmos": [True, "Atmos"]}, "tag": "ATMOS"},
{"when": {"title_type": "movie"}, "tag": True},
],
)
def test_yaml_boolean_skipped(rule: dict[str, Any], caplog: pytest.LogCaptureFixture) -> None:
"""`atmos: true` / `tag: yes` are YAML coercions the user meant as text."""
assert evaluate_tag_rules([rule], CONTEXT) is None
assert "boolean" in caplog.text
def test_numbers_stay_valid() -> None:
assert evaluate_tag_rules([{"when": {"resolution": 2160}, "tag": "UHD"}], CONTEXT) == "UHD"
assert evaluate_tag_rules([{"when": {"resolution": 1080}, "tag": "NOPE"}], CONTEXT) is None
def test_numeric_tag_becomes_text() -> None:
assert evaluate_tag_rules([{"when": {"title_type": "movie"}, "tag": 123}], CONTEXT) == "123"
@pytest.mark.parametrize("tag", [["A", "B"], {"x": 1}, ("A",), {"A"}])
def test_non_scalar_tag_skipped(tag: Any, caplog: pytest.LogCaptureFixture) -> None:
"""A list or dict here used to reach the filename as its repr."""
assert evaluate_tag_rules([{"when": {"title_type": "movie"}, "tag": tag}], CONTEXT) is None
assert "must be text or a number" in caplog.text
@pytest.mark.parametrize("tag", ["", 0])
def test_empty_tag_skipped(tag: Any) -> None:
assert evaluate_tag_rules([{"when": {"title_type": "movie"}, "tag": tag}], CONTEXT) is None
def test_skipped_rule_falls_through_to_a_later_one() -> None:
rules = [
{"when": {"atmos": True}, "tag": "SKIPPED"},
{"when": {"title_type": "movie"}, "tag": "MATCHED"},
]
assert evaluate_tag_rules(rules, CONTEXT) == "MATCHED"
def test_several_negations_in_a_list_always_match() -> None:
"""Any-entry matching makes a list of negations useless, which the docs warn about."""
rules = [{"when": {"hdr": ["!=DV", "!=HLG"]}, "tag": "ALWAYS"}]
assert evaluate_tag_rules(rules, CONTEXT) == "ALWAYS"
def test_leading_space_before_an_operator_is_literal() -> None:
"""Current behaviour: the operator is only found at the very start of the value."""
rules = [{"when": {"quality": " >=2160"}, "tag": "NOPE"}]
assert evaluate_tag_rules(rules, CONTEXT) is None
def test_inequality_holds_for_a_value_with_no_number() -> None:
""" "DV" really is not equal to 2160, so != holds while the others fail."""
assert evaluate_tag_rules([{"when": {"hdr": "!=2160"}, "tag": "MATCHED"}], CONTEXT) == "MATCHED"
assert evaluate_tag_rules([{"when": {"hdr": ">=2160"}, "tag": "NOPE"}], CONTEXT) is None
assert evaluate_tag_rules([{"when": {"hdr": "==2160"}, "tag": "NOPE"}], CONTEXT) is None
@pytest.mark.parametrize("condition", ["!=", ">=", "<", "=", "=="])
def test_bare_operator_fails(condition: str, caplog: pytest.LogCaptureFixture) -> None:
assert evaluate_tag_rules([{"when": {"hdr": condition}, "tag": "NOPE"}], CONTEXT) is None
assert "no value to compare against" in caplog.text
def test_composite_hdr_does_not_match_plain_dv() -> None:
"""DV titles carry a base-layer suffix, which the docs tell users to list explicitly."""
context = {**CONTEXT, "hdr": "DV.HDR10P"}
assert evaluate_tag_rules([{"when": {"hdr": "DV"}, "tag": "NOPE"}], context) is None
assert evaluate_tag_rules([{"when": {"hdr": ["DV", "DV.HDR10P"]}, "tag": "DOLBY"}], context) == "DOLBY"
+131
View File
@@ -0,0 +1,131 @@
"""Tests for tag_rules reaching the filename context through Title."""
from __future__ import annotations
import inspect
import types
from typing import Any, Iterator
import pytest
from langcodes import Language
from unshackle.core.config import config
from unshackle.core.titles.movie import Movie
class DummyService:
pass
class TrackStub:
"""Stand-in for Tracks: truthy and iterable, with the two lists lang_tag reads."""
def __init__(self, audio: list[Any], subtitles: list[Any]) -> None:
self.audio = audio
self.subtitles = subtitles
def __iter__(self) -> Iterator[Any]:
return iter([])
def __bool__(self) -> bool:
return True
def make_audio(language: str) -> types.SimpleNamespace:
return types.SimpleNamespace(
language=language,
format="AAC",
channel_layout=None,
channellayout_original=None,
channel_s=2,
channels=2,
format_additionalfeatures=None,
joc=None,
)
def make_media_info(languages: list[str]) -> types.SimpleNamespace:
return types.SimpleNamespace(video_tracks=[], audio_tracks=[make_audio(lang) for lang in languages])
def make_movie() -> Movie:
return Movie(id_="movie-0001", service=DummyService, name="The Film", year=2024, language="en")
@pytest.fixture(autouse=True)
def clean_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "tag", "NOGRP")
monkeypatch.setattr(config, "tag_rules", [])
monkeypatch.setattr(config, "language_tags", {})
def context_for(rules: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch) -> dict:
monkeypatch.setattr(config, "tag_rules", rules)
return make_movie()._build_base_template_context(make_media_info(["en"]))
def test_matching_rule_replaces_the_tag(monkeypatch: pytest.MonkeyPatch) -> None:
context = context_for([{"when": {"title_type": "movie"}, "tag": "MOVIEGRP"}], monkeypatch)
assert context["tag"] == "MOVIEGRP"
def test_no_matching_rule_keeps_the_config_tag(monkeypatch: pytest.MonkeyPatch) -> None:
context = context_for([{"when": {"title_type": "series"}, "tag": "TVGRP"}], monkeypatch)
assert context["tag"] == "NOGRP"
def test_no_rules_keeps_the_config_tag(monkeypatch: pytest.MonkeyPatch) -> None:
context = context_for([], monkeypatch)
assert context["tag"] == "NOGRP"
def test_title_type_is_in_the_context(monkeypatch: pytest.MonkeyPatch) -> None:
assert context_for([], monkeypatch)["title_type"] == "movie"
def test_rules_run_after_lang_tag(monkeypatch: pytest.MonkeyPatch) -> None:
"""A rule matching on lang_tag only works if lang_tag is resolved first."""
monkeypatch.setattr(config, "language_tags", {"rules": [{"audio": "ja", "tag": "SUBBED"}]})
monkeypatch.setattr(config, "tag_rules", [{"when": {"lang_tag": "SUBBED"}, "tag": "SUBGRP"}])
movie = make_movie()
movie.tracks = TrackStub(audio=[types.SimpleNamespace(language=Language.get("ja"))], subtitles=[]) # type: ignore[assignment]
context = movie._build_base_template_context(make_media_info(["ja"]))
assert context["lang_tag"] == "SUBBED"
assert context["tag"] == "SUBGRP"
def test_a_quality_rule_matches_the_built_context(monkeypatch: pytest.MonkeyPatch) -> None:
"""Guards the context keys the docs tell users to match on."""
monkeypatch.setattr(config, "tag_rules", [{"when": {"quality": "2160p", "video": "H.265"}, "tag": "UHDGROUP"}])
video = types.SimpleNamespace(
width=3840,
height=2160,
format="HEVC",
scan_type="Progressive",
other_display_aspect_ratio=["16:9"],
hdr_format_commercial=None,
hdr_format=None,
transfer_characteristics=None,
transfer_characteristics_original=None,
frame_rate="23.976",
)
media_info = types.SimpleNamespace(video_tracks=[video], audio_tracks=[make_audio("en")])
context = make_movie()._build_base_template_context(media_info)
assert context["quality"] == "2160p"
assert context["video"] == "H.265"
assert context["tag"] == "UHDGROUP"
def test_dl_clears_tag_rules_when_tag_is_given() -> None:
"""Source-level pin: the block lives inside the click-driven dl.__init__."""
from unshackle.commands.dl import dl
source = inspect.getsource(dl.__init__)
block = source.split("if tag:")[1].split("\n\n")[0]
assert "config.tag = tag" in block
assert "config.tag_rules = []" in block
+5 -1
View File
@@ -574,7 +574,10 @@ class dl:
help="Proxy URI to use. If a 2-letter country is provided, it will try to get a proxy from the config.",
)
@click.option(
"--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any."
"--tag",
type=str,
default=None,
help="Set the Group Tag to be used, overriding the config tag and any tag rules.",
)
@click.option("--repack", is_flag=True, default=False, help="Add REPACK tag to the output filename.")
@click.option(
@@ -1252,6 +1255,7 @@ class dl:
if tag:
config.tag = tag
config.tag_rules = []
# needs to be added this way instead of @cli.result_callback to be
# able to keep `self` as the first positional
+2
View File
@@ -130,6 +130,7 @@ class Config:
self.redact_paths: bool = kwargs.get("redact_paths", True)
self.language_tags: dict = kwargs.get("language_tags") or {}
self.tag_rules: list = kwargs.get("tag_rules") or []
self.dual_multi_mode: str = (kwargs.get("dual_multi_mode") or "strict").lower()
self.output_template: dict = kwargs.get("output_template") or {}
folder_cfg = self.output_template.pop("folder", "")
@@ -203,6 +204,7 @@ class Config:
"edition",
"repack",
"lang_tag",
"title_type",
}
unsafe_chars = r'[<>:"/\\|?*]'
+10
View File
@@ -95,6 +95,9 @@ class Title:
"hfr": "",
"edition": "",
"lang_tag": "",
"title_type": {"Movie": "movie", "Episode": "series", "Song": "music"}.get(
type(self).__name__, type(self).__name__.lower()
),
}
if self.tracks:
@@ -199,6 +202,13 @@ class Title:
sub_langs = [s.language for s in self.tracks.subtitles]
context["lang_tag"] = evaluate_language_tag(lang_tag_rules, audio_langs, sub_langs)
if config.tag_rules:
from unshackle.core.utils.tag_rules import evaluate_tag_rules
override = evaluate_tag_rules(config.tag_rules, context)
if override:
context["tag"] = override
return context
@abstractmethod
+116
View File
@@ -0,0 +1,116 @@
"""Release-group tag rule engine for output filename templates."""
from __future__ import annotations
import logging
import operator
import re
from collections.abc import Mapping
from typing import Any, Callable, Optional
log = logging.getLogger(__name__)
_OPS: dict[str, Callable[[Any, Any], bool]] = {
">=": operator.ge,
"<=": operator.le,
"!=": operator.ne,
"==": operator.eq,
">": operator.gt,
"<": operator.lt,
"=": operator.eq,
}
_ORDERING = (">=", "<=", ">", "<")
_NUMBER = re.compile(r"\d+(?:\.\d+)?")
def evaluate_tag_rules(rules: Any, context: dict[str, Any]) -> Optional[str]:
"""Evaluate tag rules against a filename template context.
Rules are evaluated in order; the first matching rule's tag is returned.
Conditions are AND-ed, compared case-insensitively, and a list matches if
any entry matches. A value may lead with a comparison operator, e.g.
">=2160" or "!=DV".
Args:
rules: Rules straight from the user's YAML, so any shape is possible.
Each valid rule is a mapping with a ``when`` mapping and a ``tag``.
context: The already-built filename template context.
Returns:
The tag string from the first matching rule, or ``None`` if none match.
"""
if not isinstance(rules, list):
log.warning("tag_rules must be a list of rules, ignoring: %r", rules)
return None
for rule in rules:
if not isinstance(rule, Mapping):
log.warning("Tag rule must be a mapping of 'when' and 'tag', skipping: %r", rule)
continue
when = rule.get("when")
tag = rule.get("tag")
if when is not None and not isinstance(when, Mapping):
log.warning("Tag rule 'when' must be a mapping of conditions, skipping: %s", rule)
continue
if not when or not tag:
log.warning("Tag rule needs both 'when' conditions and a 'tag', skipping: %s", rule)
continue
if isinstance(tag, bool) or any(_has_bool(value) for value in when.values()):
log.warning("Tag rule has a YAML boolean, quote the value to use it as text, skipping: %s", rule)
continue
if not isinstance(tag, (str, int, float)):
log.warning("Tag rule 'tag' must be text or a number, skipping: %s", rule)
continue
unknown = [key for key in when if key not in context]
if unknown:
log.warning("Tag rule has unknown 'when' key(s) %s, skipping: %s", ", ".join(sorted(unknown)), rule)
continue
if all(_matches(expected, context[key]) for key, expected in when.items()):
log.debug("Tag rule matched: %s -> %s", rule, tag)
return str(tag)
return None
def _has_bool(expected: Any) -> bool:
"""Check for a YAML boolean, which is an unquoted word the user meant as text."""
values = expected if isinstance(expected, list) else [expected]
return any(isinstance(x, bool) for x in values)
def _matches(expected: Any, actual: Any) -> bool:
"""Check one condition, where a list matches on any entry."""
values = expected if isinstance(expected, list) else [expected]
return any(_matches_one(x, actual) for x in values)
def _matches_one(expected: Any, actual: Any) -> bool:
"""Check a single value, which may carry a leading comparison operator."""
text = "" if expected is None else str(expected)
for op in sorted(_OPS, key=len, reverse=True):
if text.startswith(op):
return _compare(op, text[len(op) :].strip(), actual)
return str(actual or "").lower() == text.lower()
def _compare(op: str, operand: str, actual: Any) -> bool:
"""Compare against an operand, numerically when the operand is a number."""
if not operand:
log.warning("Tag rule condition '%s' has no value to compare against, condition failed", op)
return False
try:
wanted = float(operand)
except ValueError:
if op in _ORDERING:
log.warning("Tag rule condition '%s%s' needs a numeric operand, condition failed", op, operand)
return False
return _OPS[op](str(actual or "").lower(), operand.lower())
found = _NUMBER.search(str(actual or ""))
if not found:
return op == "!="
return _OPS[op](float(found.group()), wanted)
+6
View File
@@ -9,6 +9,12 @@
# Group or username appended to download filenames after a dash.
tag: "NOGRP"
# Conditionally swap the tag above based on filename variables (first match wins).
# See docs/reference/configuration.md#tag_rules for conditions and semantics.
# tag_rules:
# - when: {lang_tag: SUBBED}
# tag: SUBGRP
# --- Output filename & folder templates --- docs/guide/output-and-naming.md
# The full variable list and all naming options are documented there.
# movies/series have no built-in fallback (define them to download those kinds);