mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
feat(dl): add tag_rules to conditionally swap the group tag
This commit is contained in:
@@ -102,6 +102,7 @@ Every variable below is valid in both output and folder templates. Values are de
|
|||||||
| Variable | Meaning | Example |
|
| Variable | Meaning | Example |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `title` | Title name (movie/show/song name; `$` is rendered as `S`) | `The Show` |
|
| `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` |
|
| `year` | Release year | `2024` |
|
||||||
| `source` | Service tag / class name (empty with `--no-source`) | `EXAMPLE` |
|
| `source` | Service tag / class name (empty with `--no-source`) | `EXAMPLE` |
|
||||||
| `quality` | Resolution with scan suffix | `1080p`, `2160p`, `576i` |
|
| `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
|
## Tags & group naming
|
||||||
|
|
||||||
The release-group tag is the `-TAG` portion at the end of scene-style names, produced by the `{tag}` variable.
|
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"
|
```yaml title="unshackle.yaml"
|
||||||
tag: "MYGROUP"
|
tag: "MYGROUP"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ This page assumes you already know the basics and want precise details.
|
|||||||
| [Directories](#directories) | `directories`, `filenames` |
|
| [Directories](#directories) | `directories`, `filenames` |
|
||||||
| [Services & authentication](#services-auth) | `services`, `credentials`, `firefox_cookies`, `remote_services`, `serve` |
|
| [Services & authentication](#services-auth) | `services`, `credentials`, `firefox_cookies`, `remote_services`, `serve` |
|
||||||
| [Download & processing](#download-processing) | `dl`, `subtitle`, `audio`, `muxing`, `language_tags`, `dual_multi_mode` |
|
| [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` |
|
| [DRM & CDM](#drm-cdm) | `cdm`, `remote_cdm`, `decryption` |
|
||||||
| [Network & proxy](#network-proxy) | `network`, `headers`, `proxy_providers` |
|
| [Network & proxy](#network-proxy) | `network`, `headers`, `proxy_providers` |
|
||||||
| [Key vaults](#key-vaults) | `key_vaults`, `vault_timeout` |
|
| [Key vaults](#key-vaults) | `key_vaults`, `vault_timeout` |
|
||||||
@@ -584,7 +584,8 @@ folder-kind keys.
|
|||||||
`quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`,
|
`quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`,
|
||||||
`album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`,
|
`album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`,
|
||||||
`isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`, `dual`,
|
`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
|
### Tagging & naming keys
|
||||||
|
|
||||||
@@ -596,6 +597,103 @@ folder-kind keys.
|
|||||||
| `chapter_fallback_name` | str | `""` | Fallback chapter-name template, e.g. `"Chapter {i:02}"`. |
|
| `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. |
|
| `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 }
|
## DRM & CDM { #drm-cdm }
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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.",
|
help="Proxy URI to use. If a 2-letter country is provided, it will try to get a proxy from the config.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@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("--repack", is_flag=True, default=False, help="Add REPACK tag to the output filename.")
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -1252,6 +1255,7 @@ class dl:
|
|||||||
|
|
||||||
if tag:
|
if tag:
|
||||||
config.tag = tag
|
config.tag = tag
|
||||||
|
config.tag_rules = []
|
||||||
|
|
||||||
# needs to be added this way instead of @cli.result_callback to be
|
# needs to be added this way instead of @cli.result_callback to be
|
||||||
# able to keep `self` as the first positional
|
# able to keep `self` as the first positional
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ class Config:
|
|||||||
self.redact_paths: bool = kwargs.get("redact_paths", True)
|
self.redact_paths: bool = kwargs.get("redact_paths", True)
|
||||||
|
|
||||||
self.language_tags: dict = kwargs.get("language_tags") or {}
|
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.dual_multi_mode: str = (kwargs.get("dual_multi_mode") or "strict").lower()
|
||||||
self.output_template: dict = kwargs.get("output_template") or {}
|
self.output_template: dict = kwargs.get("output_template") or {}
|
||||||
folder_cfg = self.output_template.pop("folder", "")
|
folder_cfg = self.output_template.pop("folder", "")
|
||||||
@@ -203,6 +204,7 @@ class Config:
|
|||||||
"edition",
|
"edition",
|
||||||
"repack",
|
"repack",
|
||||||
"lang_tag",
|
"lang_tag",
|
||||||
|
"title_type",
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe_chars = r'[<>:"/\\|?*]'
|
unsafe_chars = r'[<>:"/\\|?*]'
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class Title:
|
|||||||
"hfr": "",
|
"hfr": "",
|
||||||
"edition": "",
|
"edition": "",
|
||||||
"lang_tag": "",
|
"lang_tag": "",
|
||||||
|
"title_type": {"Movie": "movie", "Episode": "series", "Song": "music"}.get(
|
||||||
|
type(self).__name__, type(self).__name__.lower()
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.tracks:
|
if self.tracks:
|
||||||
@@ -199,6 +202,13 @@ class Title:
|
|||||||
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)
|
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
|
return context
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -9,6 +9,12 @@
|
|||||||
# Group or username appended to download filenames after a dash.
|
# Group or username appended to download filenames after a dash.
|
||||||
tag: "NOGRP"
|
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
|
# --- Output filename & folder templates --- docs/guide/output-and-naming.md
|
||||||
# The full variable list and all naming options are documented there.
|
# The full variable list and all naming options are documented there.
|
||||||
# movies/series have no built-in fallback (define them to download those kinds);
|
# movies/series have no built-in fallback (define them to download those kinds);
|
||||||
|
|||||||
Reference in New Issue
Block a user