Files
MoviePilot/app/domain/classification/fields.py
T

352 lines
10 KiB
Python

from collections.abc import Iterable
from math import isfinite
from typing import Final, NotRequired, TypedDict, cast
from app.domain.classification.sources import builtin_field_source_support
from app.domain.classification.vocabulary import classification_field_options, classification_source_options
from app.schemas.category import (
ClassificationFactScalar,
ClassificationFieldDefinition,
ClassificationFieldOption,
ClassificationMediaType,
ClassificationOperator,
)
ALL_MEDIA_TYPES: Final[tuple[str, ...]] = ("电影", "电视剧", "音乐")
VIDEO_MEDIA_TYPES: Final[tuple[str, ...]] = ("电影", "电视剧")
MUSIC_MEDIA_TYPES: Final[tuple[str, ...]] = ("音乐",)
VALUE_TYPE_OPERATORS: Final[dict[str, tuple[str, ...]]] = {
"string": (
"equals",
"not_equals",
"in",
"not_in",
"contains",
"starts_with",
"ends_with",
"exists",
"not_exists",
),
"enum": (
"equals",
"not_equals",
"in",
"not_in",
"exists",
"not_exists",
),
"integer": (
"equals",
"not_equals",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"between",
"exists",
"not_exists",
),
"number": (
"equals",
"not_equals",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"between",
"exists",
"not_exists",
),
"year": (
"equals",
"not_equals",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"between",
"exists",
"not_exists",
),
"string_list": (
"contains_any",
"contains_all",
"contains_none",
"exists",
"not_exists",
),
"boolean": (
"is_true",
"is_false",
"exists",
"not_exists",
),
}
class _FieldSpec(TypedDict):
"""标准字段的只读声明。"""
id: str
label: str
group: NotRequired[str]
description: NotRequired[str]
value_type: str
media_types: tuple[str, ...]
options: NotRequired[tuple[str, ...]]
_STANDARD_FIELD_SPECS: Final[tuple[_FieldSpec, ...]] = (
{
"id": "identity.media_source",
"label": "媒体来源",
"value_type": "string",
"media_types": ALL_MEDIA_TYPES,
},
{
"id": "media.type",
"label": "媒体类型",
"value_type": "enum",
"media_types": ALL_MEDIA_TYPES,
"options": ALL_MEDIA_TYPES,
},
{
"id": "media.year",
"label": "发行年份",
"value_type": "year",
"media_types": ALL_MEDIA_TYPES,
},
{
"id": "media.language",
"label": "原始语言",
"value_type": "string",
"media_types": ALL_MEDIA_TYPES,
},
{
"id": "media.countries",
"label": "原产国家/地区",
"description": "跨数据源统一的原产国家或地区代码",
"value_type": "string_list",
"media_types": ALL_MEDIA_TYPES,
},
{
"id": "media.genre_keys",
"label": "风格",
"description": "跨数据源统一的 MoviePilot 规范风格",
"value_type": "string_list",
"media_types": ALL_MEDIA_TYPES,
},
{
"id": "media.genre_names",
"label": "来源风格",
"description": "媒体数据源返回的原始风格名称",
"value_type": "string_list",
"media_types": ALL_MEDIA_TYPES,
},
{
"id": "media.adult",
"label": "成人内容",
"group": "影视",
"value_type": "boolean",
"media_types": VIDEO_MEDIA_TYPES,
},
{
"id": "media.runtime",
"label": "片长",
"group": "影视",
"value_type": "integer",
"media_types": VIDEO_MEDIA_TYPES,
},
{
"id": "media.content_rating",
"label": "内容分级",
"group": "影视",
"value_type": "string",
"media_types": VIDEO_MEDIA_TYPES,
},
{
"id": "media.companies",
"label": "出品公司",
"group": "影视",
"value_type": "string_list",
"media_types": VIDEO_MEDIA_TYPES,
},
{
"id": "media.networks",
"label": "电视台或平台",
"group": "影视",
"value_type": "string_list",
"media_types": ("电视剧",),
},
{
"id": "music.entity_type",
"label": "音乐实体类型",
"group": "音乐",
"value_type": "enum",
"media_types": MUSIC_MEDIA_TYPES,
"options": ("recording", "album", "artist"),
},
{
"id": "music.album_type",
"label": "专辑类型",
"group": "音乐",
"value_type": "string",
"media_types": MUSIC_MEDIA_TYPES,
},
{
"id": "music.secondary_types",
"label": "专辑附加类型",
"group": "音乐",
"value_type": "string_list",
"media_types": MUSIC_MEDIA_TYPES,
},
{
"id": "music.genres",
"label": "音乐流派",
"group": "音乐",
"value_type": "string_list",
"media_types": MUSIC_MEDIA_TYPES,
},
{
"id": "music.tags",
"label": "音乐标签",
"group": "音乐",
"value_type": "string_list",
"media_types": MUSIC_MEDIA_TYPES,
},
{
"id": "music.artist_country",
"label": "艺术家国家或地区",
"group": "音乐",
"value_type": "string",
"media_types": MUSIC_MEDIA_TYPES,
},
{
"id": "music.release_status",
"label": "发行状态",
"group": "音乐",
"value_type": "string",
"media_types": MUSIC_MEDIA_TYPES,
},
)
def operators_for_value_type(value_type: str) -> tuple[str, ...]:
"""
返回值类型允许使用的确定性操作符集合
:param value_type: 字段值类型
:return: 按 UI 展示顺序排列的操作符;未知类型返回空元组
"""
return VALUE_TYPE_OPERATORS.get(value_type, ())
def get_standard_classification_fields() -> tuple[ClassificationFieldDefinition, ...]:
"""返回前端与校验器共享的标准分类字段目录。"""
return standard_field_definitions()
def standard_field_definitions() -> tuple[ClassificationFieldDefinition, ...]:
"""构造包含内置来源覆盖信息的标准分类字段目录。"""
definitions: list[ClassificationFieldDefinition] = []
for spec in _STANDARD_FIELD_SPECS:
definitions.append(_build_field_definition(spec))
return tuple(definitions)
def merge_field_definitions(
extra_fields: Iterable[ClassificationFieldDefinition] = (),
) -> tuple[ClassificationFieldDefinition, ...]:
"""
合并标准字段和扩展字段,字段 ID 重复时保留标准字段或先出现的扩展字段
重复字段的错误归属于策略校验器;目录合并保持稳定顺序,避免前端字段列表抖动。
"""
merged: list[ClassificationFieldDefinition] = []
seen_ids: set[str] = set()
for definition in (*standard_field_definitions(), *tuple(extra_fields)):
field_id = str(getattr(definition, "id", ""))
if field_id in seen_ids:
continue
seen_ids.add(field_id)
merged.append(definition)
return tuple(merged)
def field_definition_map(
extra_fields: Iterable[ClassificationFieldDefinition] = (),
) -> dict[str, ClassificationFieldDefinition]:
"""返回按字段 ID 索引的标准与扩展字段定义。"""
return {str(getattr(definition, "id", "")): definition for definition in merge_field_definitions(extra_fields)}
def classification_fact_matches_definition(
value: object,
definition: ClassificationFieldDefinition,
) -> bool:
"""按字段值类型和封闭枚举目录校验一个有限 JSON 事实。"""
if value is None:
return True
if definition.value_type == "string":
valid = isinstance(value, str)
elif definition.value_type == "enum":
valid = _is_json_scalar(value)
elif definition.value_type in {"integer", "year"}:
valid = isinstance(value, int) and not isinstance(value, bool)
elif definition.value_type == "number":
valid = (
isinstance(value, (int, float))
and not isinstance(value, bool)
and (not isinstance(value, float) or isfinite(value))
)
elif definition.value_type == "boolean":
valid = isinstance(value, bool)
else:
valid = isinstance(value, list) and all(
isinstance(item, str) for item in value
)
if not valid:
return False
if definition.allow_custom_values or not definition.options:
return True
allowed: set[ClassificationFactScalar] = {
option.value for option in definition.options
}
if isinstance(value, list):
return all(item in allowed for item in value)
return cast(ClassificationFactScalar, value) in allowed
def _is_json_scalar(value: object) -> bool:
"""判断值是否为协议允许且可稳定序列化的有限 JSON 标量。"""
if isinstance(value, float):
return isfinite(value)
return value is None or isinstance(value, (str, int, bool))
def _build_field_definition(values: _FieldSpec) -> ClassificationFieldDefinition:
"""将只读字段规格转换为严格 schema。"""
return ClassificationFieldDefinition(
id=values["id"],
label=values["label"],
group=values.get("group", "通用"),
description=values.get("description"),
value_type=values["value_type"],
operators=[
cast(ClassificationOperator, operator) for operator in operators_for_value_type(values["value_type"])
],
media_types=[cast(ClassificationMediaType, media_type) for media_type in values["media_types"]],
options=classification_field_options(values["id"])
or [ClassificationFieldOption(value=option, label=option) for option in values.get("options", ())],
source_options=classification_source_options(values["id"]),
allow_custom_values=values["value_type"] != "enum",
source_support=builtin_field_source_support(values["id"]),
)