mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
170 lines
5.7 KiB
Python
170 lines
5.7 KiB
Python
"""
|
||
旧版分类迁移的值编译和条件树基础工具。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Sequence
|
||
from dataclasses import dataclass
|
||
from typing import Final
|
||
|
||
from app.domain.classification.vocabulary import classification_field_options
|
||
from app.schemas.category import (
|
||
ClassificationCondition,
|
||
ClassificationConditionGroup,
|
||
ClassificationConditionNode,
|
||
ClassificationFieldDefinition,
|
||
ClassificationMediaType,
|
||
)
|
||
|
||
_TMDB_SOURCE: Final[str] = "themoviedb"
|
||
_EXTENSION_PREFIX: Final[str] = f"extensions.{_TMDB_SOURCE}."
|
||
_LEGACY_FIELD_PRESENTATION: Final[dict[str, tuple[str, str]]] = {
|
||
"genre_ids": ("风格(旧规则)", "media.genre_keys"),
|
||
"origin_country": ("原产国家/地区(旧规则)", "media.countries"),
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class _LegacyToken:
|
||
"""
|
||
保留旧字段展开后的同向值集合及其排除语义。
|
||
|
||
:param negative: 是否排除这些值;False 表示包含条件
|
||
:param values: 旧配置单个条件中的候选值,保持解析后的顺序
|
||
"""
|
||
|
||
negative: bool
|
||
values: tuple[str, ...]
|
||
|
||
|
||
def _legacy_field_definition(
|
||
field_id: str,
|
||
media_types: list[ClassificationMediaType],
|
||
) -> ClassificationFieldDefinition:
|
||
"""
|
||
构造不会出现在新规则选择器中的旧 TMDB 字段说明。
|
||
"""
|
||
field_name = field_id.removeprefix(_EXTENSION_PREFIX)
|
||
presentation = _LEGACY_FIELD_PRESENTATION.get(field_name)
|
||
label = presentation[0] if presentation else f"TMDB {field_name}"
|
||
replacement_field = presentation[1] if presentation else None
|
||
replacement_hint = f";新增条件请使用{presentation[0].replace('(旧规则)', '')}" if presentation else ""
|
||
return ClassificationFieldDefinition(
|
||
id=field_id,
|
||
label=label,
|
||
group="旧规则",
|
||
description=(f"从旧分类配置迁移,保留原有匹配方式{replacement_hint}"),
|
||
value_type="string_list",
|
||
operators=["contains_any", "contains_none", "exists", "not_exists"],
|
||
media_types=media_types,
|
||
source_support={_TMDB_SOURCE: "extension"},
|
||
options=classification_field_options("media.countries") if field_name == "origin_country" else [],
|
||
selectable=False,
|
||
replacement_field=replacement_field,
|
||
)
|
||
|
||
|
||
def _parse_legacy_tokens(value: str) -> tuple[tuple[_LegacyToken, ...], bool]:
|
||
"""
|
||
沿用旧范围展开语义,并合并同向枚举,避免值数量膨胀为叶子数量。
|
||
"""
|
||
raw_tokens = [item for item in value.split(",") if item]
|
||
values_by_sign: dict[bool, list[str]] = {}
|
||
requires_exists = not raw_tokens
|
||
for raw_token in raw_tokens:
|
||
expanded = _expand_legacy_token(raw_token)
|
||
if not expanded:
|
||
requires_exists = True
|
||
continue
|
||
for expanded_value in expanded:
|
||
negative = expanded_value.startswith("!")
|
||
plain_value = expanded_value[1:] if negative else expanded_value
|
||
values_by_sign.setdefault(negative, []).append(plain_value)
|
||
return tuple(_LegacyToken(negative, tuple(values)) for negative, values in values_by_sign.items()), requires_exists
|
||
|
||
|
||
def _expand_legacy_token(value: str) -> tuple[str, ...]:
|
||
"""
|
||
复现旧代码对数字闭区间和非数字连字符端点的展开。
|
||
"""
|
||
if "-" not in value:
|
||
return (value,)
|
||
value_begin, value_end = value.split("-", 1)
|
||
prefix = ""
|
||
if value_begin.startswith("!"):
|
||
prefix = "!"
|
||
value_begin = value_begin[1:]
|
||
if value_begin.isdigit() and value_end.isdigit():
|
||
return tuple(f"{prefix}{item}" for item in range(int(value_begin), int(value_end) + 1))
|
||
return (f"{prefix}{value_begin}", f"{prefix}{value_end}")
|
||
|
||
|
||
def _legacy_list_condition(
|
||
field_id: str,
|
||
tokens: Sequence[_LegacyToken],
|
||
requires_exists: bool,
|
||
) -> ClassificationConditionNode:
|
||
"""
|
||
把旧列表成员条件编译为正项 OR、负项逐组排除的条件树。
|
||
"""
|
||
positives = [
|
||
ClassificationCondition(
|
||
field=field_id,
|
||
operator="contains_any",
|
||
value=list(token.values),
|
||
)
|
||
for token in tokens
|
||
if not token.negative
|
||
]
|
||
negatives = [
|
||
ClassificationCondition(
|
||
field=field_id,
|
||
operator="contains_none",
|
||
value=list(token.values),
|
||
)
|
||
for token in tokens
|
||
if token.negative
|
||
]
|
||
nodes: list[ClassificationConditionNode] = []
|
||
if positives:
|
||
nodes.append(_any_or_single(positives))
|
||
nodes.extend(negatives)
|
||
if not nodes and requires_exists:
|
||
return ClassificationCondition(field=field_id, operator="exists")
|
||
return _all_or_single(nodes)
|
||
|
||
|
||
def _all_or_single(
|
||
nodes: Sequence[ClassificationConditionNode],
|
||
) -> ClassificationConditionNode:
|
||
"""
|
||
合并相邻 all 组并避免为单节点额外增加条件树深度。
|
||
"""
|
||
flattened: list[ClassificationConditionNode] = []
|
||
for node in nodes:
|
||
if isinstance(node, ClassificationConditionGroup) and node.all is not None:
|
||
flattened.extend(node.all)
|
||
else:
|
||
flattened.append(node)
|
||
if len(flattened) == 1:
|
||
return flattened[0]
|
||
return ClassificationConditionGroup(all=flattened)
|
||
|
||
|
||
def _any_or_single(
|
||
nodes: Sequence[ClassificationConditionNode],
|
||
) -> ClassificationConditionNode:
|
||
"""
|
||
合并相邻 any 组并避免为单节点额外增加条件树深度。
|
||
"""
|
||
flattened: list[ClassificationConditionNode] = []
|
||
for node in nodes:
|
||
if isinstance(node, ClassificationConditionGroup) and node.any is not None:
|
||
flattened.extend(node.any)
|
||
else:
|
||
flattened.append(node)
|
||
if len(flattened) == 1:
|
||
return flattened[0]
|
||
return ClassificationConditionGroup(any=flattened)
|