feat(classification): provide source-aware condition dictionaries

This commit is contained in:
jxxghp
2026-09-07 23:21:25 +08:00
parent b40eda2617
commit 7ccb1d9dee
17 changed files with 815 additions and 211 deletions
+13 -27
View File
@@ -8,6 +8,12 @@ from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Final, Literal, Optional, TypeAlias, Union, cast from typing import Final, Literal, Optional, TypeAlias, Union, cast
from app.domain.classification.vocabulary import (
TMDB_GENRE_KEYS as _TMDB_GENRE_KEYS,
)
from app.domain.classification.vocabulary import (
classification_field_options,
)
from app.schemas.category import ( from app.schemas.category import (
CategoryConfig, CategoryConfig,
CategoryRule, CategoryRule,
@@ -50,30 +56,6 @@ _COMMON_FALLBACKS: Final[dict[ClassificationMediaType, str]] = {
"电视剧": "tv.uncategorized", "电视剧": "tv.uncategorized",
"音乐": "music.uncategorized", "音乐": "music.uncategorized",
} }
_TMDB_GENRE_KEYS: Final[dict[str, str]] = {
"12": "adventure",
"14": "fantasy",
"16": "animation",
"18": "drama",
"27": "horror",
"28": "action",
"35": "comedy",
"36": "history",
"37": "western",
"53": "thriller",
"80": "crime",
"99": "documentary",
"878": "science_fiction",
"9648": "mystery",
"10402": "music",
"10749": "romance",
"10751": "family",
"10752": "war",
"10762": "kids",
"10764": "reality",
"10767": "talk",
"10770": "tv_movie",
}
_LEGACY_FIELD_PRESENTATION: Final[dict[str, tuple[str, str]]] = { _LEGACY_FIELD_PRESENTATION: Final[dict[str, tuple[str, str]]] = {
"genre_ids": ("风格(旧规则)", "media.genre_keys"), "genre_ids": ("风格(旧规则)", "media.genre_keys"),
"origin_country": ("原产国家/地区(旧规则)", "media.countries"), "origin_country": ("原产国家/地区(旧规则)", "media.countries"),
@@ -206,7 +188,7 @@ def migrate_legacy_category_config(
context=context, context=context,
) )
categories.extend(_common_fallback_categories(categories)) categories.extend(_common_fallback_categories(categories, fallbacks))
policy_payload: dict[str, object] = { policy_payload: dict[str, object] = {
"schema_version": 2, "schema_version": 2,
"revision": 1, "revision": 1,
@@ -514,16 +496,17 @@ def _legacy_field_definition(
presentation = _LEGACY_FIELD_PRESENTATION.get(field_name) presentation = _LEGACY_FIELD_PRESENTATION.get(field_name)
label = presentation[0] if presentation else f"TMDB {field_name}" label = presentation[0] if presentation else f"TMDB {field_name}"
replacement_field = presentation[1] if presentation else None replacement_field = presentation[1] if presentation else None
replacement_hint = f";新规则请使用 {replacement_field}" if replacement_field else "" replacement_hint = f";新增条件请使用{presentation[0].replace('(旧规则)', '')}" if presentation else ""
return ClassificationFieldDefinition( return ClassificationFieldDefinition(
id=field_id, id=field_id,
label=label, label=label,
group="旧规则", group="旧规则",
description=(f"仅用于保持已迁移 category.yaml 的原始比较语义{replacement_hint}"), description=(f"从旧分类配置迁移,保留原有匹配方式{replacement_hint}"),
value_type="string_list", value_type="string_list",
operators=["contains_any", "contains_none", "exists", "not_exists"], operators=["contains_any", "contains_none", "exists", "not_exists"],
media_types=media_types, media_types=media_types,
source_support={_TMDB_SOURCE: "extension"}, source_support={_TMDB_SOURCE: "extension"},
options=classification_field_options("media.countries") if field_name == "origin_country" else [],
selectable=False, selectable=False,
replacement_field=replacement_field, replacement_field=replacement_field,
) )
@@ -779,11 +762,14 @@ def _stable_category_id(media_key: LegacyMediaKey, name: str) -> str:
def _common_fallback_categories( def _common_fallback_categories(
legacy_categories: Sequence[ClassificationCategory], legacy_categories: Sequence[ClassificationCategory],
fallbacks: Mapping[ClassificationMediaType, str],
) -> list[ClassificationCategory]: ) -> list[ClassificationCategory]:
"""构造不受来源限制且不与同类型旧目录冲突的稳定未分类目录。""" """构造不受来源限制且不与同类型旧目录冲突的稳定未分类目录。"""
occupied = {(category.media_type, tuple(category.path)) for category in legacy_categories} occupied = {(category.media_type, tuple(category.path)) for category in legacy_categories}
categories: list[ClassificationCategory] = [] categories: list[ClassificationCategory] = []
for media_type, category_id in _COMMON_FALLBACKS.items(): for media_type, category_id in _COMMON_FALLBACKS.items():
if fallbacks.get(media_type) != category_id:
continue
path = ["未分类"] path = ["未分类"]
if (media_type, tuple(path)) in occupied: if (media_type, tuple(path)) in occupied:
path.append("通用") path.append("通用")
+2 -2
View File
@@ -11,12 +11,12 @@ from app.application.classification.migration import (
_EXTENSION_PREFIX, _EXTENSION_PREFIX,
_MEDIA_KEYS, _MEDIA_KEYS,
_SAFE_FIELD_SEGMENT, _SAFE_FIELD_SEGMENT,
_TMDB_GENRE_KEYS,
_TMDB_SOURCE, _TMDB_SOURCE,
LegacyClassificationDiagnostic, LegacyClassificationDiagnostic,
LegacyDiagnosticPathPart, LegacyDiagnosticPathPart,
LegacyMediaKey, LegacyMediaKey,
) )
from app.domain.classification.vocabulary import TMDB_GENRE_KEYS
from app.schemas.category import ( from app.schemas.category import (
CategoryConfig, CategoryConfig,
CategoryRule, CategoryRule,
@@ -29,7 +29,7 @@ from app.schemas.category import (
ClassificationRule, ClassificationRule,
) )
_TMDB_GENRE_IDS: Final[dict[str, str]] = {value: key for key, value in _TMDB_GENRE_KEYS.items()} _TMDB_GENRE_IDS: Final[dict[str, str]] = {value: key for key, value in TMDB_GENRE_KEYS.items()}
LegacyPolicyOrFields: TypeAlias = ClassificationPolicy | Iterable[ClassificationFieldDefinition] LegacyPolicyOrFields: TypeAlias = ClassificationPolicy | Iterable[ClassificationFieldDefinition]
"""受控 TMDB 扩展事实可以从策略或字段声明中发现。""" """受控 TMDB 扩展事实可以从策略或字段声明中发现。"""
+12 -169
View File
@@ -5,6 +5,15 @@ from collections.abc import Mapping
from enum import Enum from enum import Enum
from typing import TypeAlias, cast from typing import TypeAlias, cast
from app.domain.classification.vocabulary import (
COUNTRY_CODE_ALIASES as _COUNTRY_CODE_ALIASES,
)
from app.domain.classification.vocabulary import (
GENRE_KEY_ALIASES as _GENRE_KEY_ALIASES,
)
from app.domain.classification.vocabulary import (
TMDB_GENRE_KEYS as _TMDB_GENRE_KEYS,
)
from app.domain.context import MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.domain.context import MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.schemas.category import ( from app.schemas.category import (
ClassificationFacts, ClassificationFacts,
@@ -18,168 +27,6 @@ from app.schemas.category import (
ClassificationMedia: TypeAlias = MediaInfo | MusicInfo | MusicAlbumInfo | MusicArtistInfo ClassificationMedia: TypeAlias = MediaInfo | MusicInfo | MusicAlbumInfo | MusicArtistInfo
"""可以投影为标准分类事实的领域媒体对象。""" """可以投影为标准分类事实的领域媒体对象。"""
_TMDB_GENRE_KEYS = {
"12": "adventure",
"14": "fantasy",
"16": "animation",
"18": "drama",
"27": "horror",
"28": "action",
"35": "comedy",
"36": "history",
"37": "western",
"53": "thriller",
"80": "crime",
"99": "documentary",
"878": "science_fiction",
"9648": "mystery",
"10402": "music",
"10749": "romance",
"10751": "family",
"10752": "war",
"10762": "kids",
"10764": "reality",
"10767": "talk",
"10770": "tv_movie",
}
_GENRE_KEY_ALIASES = {
"action": "action",
"动作": "action",
"adventure": "adventure",
"冒险": "adventure",
"animation": "animation",
"anime": "animation",
"动画": "animation",
"动漫": "animation",
"comedy": "comedy",
"喜剧": "comedy",
"crime": "crime",
"犯罪": "crime",
"documentary": "documentary",
"纪录": "documentary",
"纪录片": "documentary",
"drama": "drama",
"剧情": "drama",
"family": "family",
"家庭": "family",
"fantasy": "fantasy",
"奇幻": "fantasy",
"history": "history",
"历史": "history",
"horror": "horror",
"恐怖": "horror",
"kids": "kids",
"children": "kids",
"儿童": "kids",
"music": "music",
"音乐": "music",
"mystery": "mystery",
"悬疑": "mystery",
"reality": "reality",
"reality tv": "reality",
"game show": "reality",
"真人秀": "reality",
"综艺": "reality",
"romance": "romance",
"爱情": "romance",
"science fiction": "science_fiction",
"sci fi": "science_fiction",
"科幻": "science_fiction",
"talk": "talk",
"talk show": "talk",
"脱口秀": "talk",
"thriller": "thriller",
"惊悚": "thriller",
"tv movie": "tv_movie",
"电视电影": "tv_movie",
"war": "war",
"战争": "war",
"western": "western",
"西部": "western",
"classical": "classical",
"古典": "classical",
"electronic": "electronic",
"电子": "electronic",
"folk": "folk",
"民谣": "folk",
"hip hop": "hip_hop",
"hip hop rap": "hip_hop",
"说唱": "hip_hop",
"jazz": "jazz",
"爵士": "jazz",
"pop": "pop",
"流行": "pop",
"rock": "rock",
"摇滚": "rock",
"soundtrack": "soundtrack",
"原声": "soundtrack",
}
_COUNTRY_CODE_ALIASES = {
"中国": "CN",
"中国大陆": "CN",
"内地": "CN",
"china": "CN",
"香港": "HK",
"中国香港": "HK",
"hong kong": "HK",
"台湾": "TW",
"中国台湾": "TW",
"taiwan": "TW",
"澳门": "MO",
"中国澳门": "MO",
"macao": "MO",
"日本": "JP",
"japan": "JP",
"韩国": "KR",
"南韩": "KR",
"south korea": "KR",
"republic of korea": "KR",
"朝鲜": "KP",
"north korea": "KP",
"美国": "US",
"united states": "US",
"united states of america": "US",
"英国": "GB",
"united kingdom": "GB",
"great britain": "GB",
"法国": "FR",
"france": "FR",
"德国": "DE",
"germany": "DE",
"意大利": "IT",
"italy": "IT",
"西班牙": "ES",
"spain": "ES",
"俄罗斯": "RU",
"russia": "RU",
"加拿大": "CA",
"canada": "CA",
"澳大利亚": "AU",
"australia": "AU",
"新西兰": "NZ",
"new zealand": "NZ",
"印度": "IN",
"india": "IN",
"泰国": "TH",
"thailand": "TH",
"新加坡": "SG",
"singapore": "SG",
"马来西亚": "MY",
"malaysia": "MY",
"越南": "VN",
"vietnam": "VN",
"印度尼西亚": "ID",
"indonesia": "ID",
"菲律宾": "PH",
"philippines": "PH",
"巴西": "BR",
"brazil": "BR",
"墨西哥": "MX",
"mexico": "MX",
}
def build_classification_facts( def build_classification_facts(
media: ClassificationMedia, media: ClassificationMedia,
@@ -239,9 +86,7 @@ def _music_facts(media: ClassificationMedia) -> ClassificationMusicFacts:
genres=_genre_names(getattr(media, "genres", None)), genres=_genre_names(getattr(media, "genres", None)),
tags=_optional_string_list(getattr(media, "tags", None)), tags=_optional_string_list(getattr(media, "tags", None)),
artists=_optional_string_list(getattr(media, "artists", None)), artists=_optional_string_list(getattr(media, "artists", None)),
artist_country=_country_code( artist_country=_country_code(getattr(media, "artist_country", None) or getattr(media, "country", None)),
getattr(media, "artist_country", None) or getattr(media, "country", None)
),
release_status=_optional_text(getattr(media, "release_status", None)), release_status=_optional_text(getattr(media, "release_status", None)),
) )
@@ -281,9 +126,7 @@ def _video_runtime(media: ClassificationMedia) -> int | None:
def _classification_genre_keys(media: ClassificationMedia) -> list[str] | None: def _classification_genre_keys(media: ClassificationMedia) -> list[str] | None:
"""把来源 Genre ID、类型名和音乐流派投影为稳定跨来源键。""" """把来源 Genre ID、类型名和音乐流派投影为稳定跨来源键。"""
values: list[str] = [] values: list[str] = []
for item in _optional_string_list( for item in _optional_string_list(getattr(media, "classification_genre_keys", None)) or []:
getattr(media, "classification_genre_keys", None)
) or []:
_append_unique(values, item) _append_unique(values, item)
for item in getattr(media, "genre_ids", None) or []: for item in getattr(media, "genre_ids", None) or []:
if key := _TMDB_GENRE_KEYS.get(str(item)): if key := _TMDB_GENRE_KEYS.get(str(item)):
@@ -388,7 +231,7 @@ def _optional_int(value: object) -> int | None:
return None return None
try: try:
return int(str(value)) return int(str(value))
except (TypeError, ValueError): except TypeError, ValueError:
return None return None
+6 -4
View File
@@ -3,6 +3,7 @@ from math import isfinite
from typing import Final, NotRequired, TypedDict, cast from typing import Final, NotRequired, TypedDict, cast
from app.domain.classification.sources import builtin_field_source_support 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 ( from app.schemas.category import (
ClassificationFactScalar, ClassificationFactScalar,
ClassificationFieldDefinition, ClassificationFieldDefinition,
@@ -91,6 +92,8 @@ VALUE_TYPE_OPERATORS: Final[dict[str, tuple[str, ...]]] = {
class _FieldSpec(TypedDict): class _FieldSpec(TypedDict):
"""标准字段的只读声明。"""
id: str id: str
label: str label: str
group: NotRequired[str] group: NotRequired[str]
@@ -340,10 +343,9 @@ def _build_field_definition(values: _FieldSpec) -> ClassificationFieldDefinition
cast(ClassificationOperator, operator) for operator in operators_for_value_type(values["value_type"]) 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"]], media_types=[cast(ClassificationMediaType, media_type) for media_type in values["media_types"]],
options=[ options=classification_field_options(values["id"])
ClassificationFieldOption(value=option, label=option) or [ClassificationFieldOption(value=option, label=option) for option in values.get("options", ())],
for option in values.get("options", ()) source_options=classification_source_options(values["id"]),
],
allow_custom_values=values["value_type"] != "enum", allow_custom_values=values["value_type"] != "enum",
source_support=builtin_field_source_support(values["id"]), source_support=builtin_field_source_support(values["id"]),
) )
+683
View File
@@ -0,0 +1,683 @@
"""分类事实归一化与编辑选项共享的离线词表。"""
from app.schemas.category import ClassificationFieldOption
TMDB_GENRE_KEYS = {
"12": "adventure",
"14": "fantasy",
"16": "animation",
"18": "drama",
"27": "horror",
"28": "action",
"35": "comedy",
"36": "history",
"37": "western",
"53": "thriller",
"80": "crime",
"99": "documentary",
"878": "science_fiction",
"9648": "mystery",
"10402": "music",
"10749": "romance",
"10751": "family",
"10752": "war",
"10762": "kids",
"10764": "reality",
"10767": "talk",
"10770": "tv_movie",
}
GENRE_KEY_ALIASES = {
"action": "action",
"动作": "action",
"adventure": "adventure",
"冒险": "adventure",
"animation": "animation",
"anime": "animation",
"动画": "animation",
"动漫": "animation",
"comedy": "comedy",
"喜剧": "comedy",
"crime": "crime",
"犯罪": "crime",
"documentary": "documentary",
"纪录": "documentary",
"纪录片": "documentary",
"drama": "drama",
"剧情": "drama",
"family": "family",
"家庭": "family",
"fantasy": "fantasy",
"奇幻": "fantasy",
"history": "history",
"历史": "history",
"horror": "horror",
"恐怖": "horror",
"kids": "kids",
"children": "kids",
"儿童": "kids",
"music": "music",
"音乐": "music",
"mystery": "mystery",
"悬疑": "mystery",
"reality": "reality",
"reality tv": "reality",
"game show": "reality",
"真人秀": "reality",
"综艺": "reality",
"romance": "romance",
"爱情": "romance",
"science fiction": "science_fiction",
"sci fi": "science_fiction",
"科幻": "science_fiction",
"talk": "talk",
"talk show": "talk",
"脱口秀": "talk",
"thriller": "thriller",
"惊悚": "thriller",
"tv movie": "tv_movie",
"电视电影": "tv_movie",
"war": "war",
"战争": "war",
"western": "western",
"西部": "western",
"classical": "classical",
"古典": "classical",
"electronic": "electronic",
"电子": "electronic",
"folk": "folk",
"民谣": "folk",
"hip hop": "hip_hop",
"hip hop rap": "hip_hop",
"说唱": "hip_hop",
"jazz": "jazz",
"爵士": "jazz",
"pop": "pop",
"流行": "pop",
"rock": "rock",
"摇滚": "rock",
"soundtrack": "soundtrack",
"原声": "soundtrack",
}
COUNTRY_CODE_ALIASES = {
"中国": "CN",
"中国大陆": "CN",
"内地": "CN",
"china": "CN",
"香港": "HK",
"中国香港": "HK",
"hong kong": "HK",
"台湾": "TW",
"中国台湾": "TW",
"taiwan": "TW",
"澳门": "MO",
"中国澳门": "MO",
"macao": "MO",
"日本": "JP",
"japan": "JP",
"韩国": "KR",
"南韩": "KR",
"south korea": "KR",
"republic of korea": "KR",
"朝鲜": "KP",
"north korea": "KP",
"美国": "US",
"united states": "US",
"united states of america": "US",
"英国": "GB",
"united kingdom": "GB",
"great britain": "GB",
"法国": "FR",
"france": "FR",
"德国": "DE",
"germany": "DE",
"意大利": "IT",
"italy": "IT",
"西班牙": "ES",
"spain": "ES",
"俄罗斯": "RU",
"russia": "RU",
"加拿大": "CA",
"canada": "CA",
"澳大利亚": "AU",
"australia": "AU",
"新西兰": "NZ",
"new zealand": "NZ",
"印度": "IN",
"india": "IN",
"泰国": "TH",
"thailand": "TH",
"新加坡": "SG",
"singapore": "SG",
"马来西亚": "MY",
"malaysia": "MY",
"越南": "VN",
"vietnam": "VN",
"印度尼西亚": "ID",
"indonesia": "ID",
"菲律宾": "PH",
"philippines": "PH",
"巴西": "BR",
"brazil": "BR",
"墨西哥": "MX",
"mexico": "MX",
}
# 中文标签随 ISO 代码离线发布(CLDR / Intl.DisplayNames),不依赖运行时联网。
COUNTRY_NAMES = {
"AD": "安道尔",
"AE": "阿拉伯联合酋长国",
"AF": "阿富汗",
"AG": "安提瓜和巴布达",
"AI": "安圭拉",
"AL": "阿尔巴尼亚",
"AM": "亚美尼亚",
"AO": "安哥拉",
"AQ": "南极洲",
"AR": "阿根廷",
"AS": "美属萨摩亚",
"AT": "奥地利",
"AU": "澳大利亚",
"AW": "阿鲁巴",
"AX": "奥兰群岛",
"AZ": "阿塞拜疆",
"BA": "波斯尼亚和黑塞哥维那",
"BB": "巴巴多斯",
"BD": "孟加拉国",
"BE": "比利时",
"BF": "布基纳法索",
"BG": "保加利亚",
"BH": "巴林",
"BI": "布隆迪",
"BJ": "贝宁",
"BL": "圣巴泰勒米",
"BM": "百慕大",
"BN": "文莱",
"BO": "玻利维亚",
"BQ": "荷属加勒比区",
"BR": "巴西",
"BS": "巴哈马",
"BT": "不丹",
"BV": "布韦岛",
"BW": "博茨瓦纳",
"BY": "白俄罗斯",
"BZ": "伯利兹",
"CA": "加拿大",
"CC": "科科斯(基林)群岛",
"CD": "刚果(金)",
"CF": "中非共和国",
"CG": "刚果(布)",
"CH": "瑞士",
"CI": "科特迪瓦",
"CK": "库克群岛",
"CL": "智利",
"CM": "喀麦隆",
"CN": "中国",
"CO": "哥伦比亚",
"CR": "哥斯达黎加",
"CU": "古巴",
"CV": "佛得角",
"CW": "库拉索",
"CX": "圣诞岛",
"CY": "塞浦路斯",
"CZ": "捷克",
"DE": "德国",
"DJ": "吉布提",
"DK": "丹麦",
"DM": "多米尼克",
"DO": "多米尼加共和国",
"DZ": "阿尔及利亚",
"EC": "厄瓜多尔",
"EE": "爱沙尼亚",
"EG": "埃及",
"EH": "西撒哈拉",
"ER": "厄立特里亚",
"ES": "西班牙",
"ET": "埃塞俄比亚",
"FI": "芬兰",
"FJ": "斐济",
"FK": "福克兰群岛",
"FM": "密克罗尼西亚",
"FO": "法罗群岛",
"FR": "法国",
"GA": "加蓬",
"GB": "英国",
"GD": "格林纳达",
"GE": "格鲁吉亚",
"GF": "法属圭亚那",
"GG": "根西岛",
"GH": "加纳",
"GI": "直布罗陀",
"GL": "格陵兰",
"GM": "冈比亚",
"GN": "几内亚",
"GP": "瓜德罗普",
"GQ": "赤道几内亚",
"GR": "希腊",
"GS": "南乔治亚和南桑威奇群岛",
"GT": "危地马拉",
"GU": "关岛",
"GW": "几内亚比绍",
"GY": "圭亚那",
"HK": "中国香港特别行政区",
"HM": "赫德岛和麦克唐纳群岛",
"HN": "洪都拉斯",
"HR": "克罗地亚",
"HT": "海地",
"HU": "匈牙利",
"ID": "印度尼西亚",
"IE": "爱尔兰",
"IL": "以色列",
"IM": "马恩岛",
"IN": "印度",
"IO": "英属印度洋领地",
"IQ": "伊拉克",
"IR": "伊朗",
"IS": "冰岛",
"IT": "意大利",
"JE": "泽西岛",
"JM": "牙买加",
"JO": "约旦",
"JP": "日本",
"KE": "肯尼亚",
"KG": "吉尔吉斯斯坦",
"KH": "柬埔寨",
"KI": "基里巴斯",
"KM": "科摩罗",
"KN": "圣基茨和尼维斯",
"KP": "朝鲜",
"KR": "韩国",
"KW": "科威特",
"KY": "开曼群岛",
"KZ": "哈萨克斯坦",
"LA": "老挝",
"LB": "黎巴嫩",
"LC": "圣卢西亚",
"LI": "列支敦士登",
"LK": "斯里兰卡",
"LR": "利比里亚",
"LS": "莱索托",
"LT": "立陶宛",
"LU": "卢森堡",
"LV": "拉脱维亚",
"LY": "利比亚",
"MA": "摩洛哥",
"MC": "摩纳哥",
"MD": "摩尔多瓦",
"ME": "黑山",
"MF": "法属圣马丁",
"MG": "马达加斯加",
"MH": "马绍尔群岛",
"MK": "北马其顿",
"ML": "马里",
"MM": "缅甸",
"MN": "蒙古",
"MO": "中国澳门特别行政区",
"MP": "北马里亚纳群岛",
"MQ": "马提尼克",
"MR": "毛里塔尼亚",
"MS": "蒙特塞拉特",
"MT": "马耳他",
"MU": "毛里求斯",
"MV": "马尔代夫",
"MW": "马拉维",
"MX": "墨西哥",
"MY": "马来西亚",
"MZ": "莫桑比克",
"NA": "纳米比亚",
"NC": "新喀里多尼亚",
"NE": "尼日尔",
"NF": "诺福克岛",
"NG": "尼日利亚",
"NI": "尼加拉瓜",
"NL": "荷兰",
"NO": "挪威",
"NP": "尼泊尔",
"NR": "瑙鲁",
"NU": "纽埃",
"NZ": "新西兰",
"OM": "阿曼",
"PA": "巴拿马",
"PE": "秘鲁",
"PF": "法属波利尼西亚",
"PG": "巴布亚新几内亚",
"PH": "菲律宾",
"PK": "巴基斯坦",
"PL": "波兰",
"PM": "圣皮埃尔和密克隆群岛",
"PN": "皮特凯恩群岛",
"PR": "波多黎各",
"PS": "巴勒斯坦领土",
"PT": "葡萄牙",
"PW": "帕劳",
"PY": "巴拉圭",
"QA": "卡塔尔",
"RE": "留尼汪",
"RO": "罗马尼亚",
"RS": "塞尔维亚",
"RU": "俄罗斯",
"RW": "卢旺达",
"SA": "沙特阿拉伯",
"SB": "所罗门群岛",
"SC": "塞舌尔",
"SD": "苏丹",
"SE": "瑞典",
"SG": "新加坡",
"SH": "圣赫勒拿",
"SI": "斯洛文尼亚",
"SJ": "斯瓦尔巴和扬马延",
"SK": "斯洛伐克",
"SL": "塞拉利昂",
"SM": "圣马力诺",
"SN": "塞内加尔",
"SO": "索马里",
"SR": "苏里南",
"SS": "南苏丹",
"ST": "圣多美和普林西比",
"SV": "萨尔瓦多",
"SX": "荷属圣马丁",
"SY": "叙利亚",
"SZ": "斯威士兰",
"TC": "特克斯和凯科斯群岛",
"TD": "乍得",
"TF": "法属南部领地",
"TG": "多哥",
"TH": "泰国",
"TJ": "塔吉克斯坦",
"TK": "托克劳",
"TL": "东帝汶",
"TM": "土库曼斯坦",
"TN": "突尼斯",
"TO": "汤加",
"TR": "土耳其",
"TT": "特立尼达和多巴哥",
"TV": "图瓦卢",
"TW": "台湾",
"TZ": "坦桑尼亚",
"UA": "乌克兰",
"UG": "乌干达",
"UM": "美国本土外小岛屿",
"US": "美国",
"UY": "乌拉圭",
"UZ": "乌兹别克斯坦",
"VA": "梵蒂冈",
"VC": "圣文森特和格林纳丁斯",
"VE": "委内瑞拉",
"VG": "英属维尔京群岛",
"VI": "美属维尔京群岛",
"VN": "越南",
"VU": "瓦努阿图",
"WF": "瓦利斯和富图纳",
"WS": "萨摩亚",
"YE": "也门",
"YT": "马约特",
"ZA": "南非",
"ZM": "赞比亚",
"ZW": "津巴布韦",
}
LANGUAGE_NAMES = {
"aa": "阿法尔语",
"ab": "阿布哈西亚语",
"ae": "阿维斯塔语",
"af": "南非荷兰语",
"ak": "阿肯语",
"am": "阿姆哈拉语",
"an": "阿拉贡语",
"ar": "阿拉伯语",
"as": "阿萨姆语",
"av": "阿瓦尔语",
"ay": "艾马拉语",
"az": "阿塞拜疆语",
"ba": "巴什基尔语",
"be": "白俄罗斯语",
"bg": "保加利亚语",
"bh": "博杰普尔语",
"bi": "比斯拉马语",
"bm": "班巴拉语",
"bn": "孟加拉语",
"bo": "藏语",
"br": "布列塔尼语",
"bs": "波斯尼亚语",
"ca": "加泰罗尼亚语",
"ce": "车臣语",
"ch": "查莫罗语",
"co": "科西嘉语",
"cr": "克里语",
"cs": "捷克语",
"cu": "教会斯拉夫语",
"cv": "楚瓦什语",
"cy": "威尔士语",
"da": "丹麦语",
"de": "德语",
"dv": "迪维希语",
"dz": "宗卡语",
"ee": "埃维语",
"el": "希腊语",
"en": "英语",
"eo": "世界语",
"es": "西班牙语",
"et": "爱沙尼亚语",
"eu": "巴斯克语",
"fa": "波斯语",
"ff": "富拉语",
"fi": "芬兰语",
"fj": "斐济语",
"fo": "法罗语",
"fr": "法语",
"fy": "西弗里西亚语",
"ga": "爱尔兰语",
"gd": "苏格兰盖尔语",
"gl": "加利西亚语",
"gn": "瓜拉尼语",
"gu": "古吉拉特语",
"gv": "马恩语",
"ha": "豪萨语",
"he": "希伯来语",
"hi": "印地语",
"ho": "希里莫图语",
"hr": "克罗地亚语",
"ht": "海地克里奥尔语",
"hu": "匈牙利语",
"hy": "亚美尼亚语",
"hz": "赫雷罗语",
"ia": "国际语",
"id": "印度尼西亚语",
"ie": "国际文字(E",
"ig": "伊博语",
"ii": "凉山彝语",
"ik": "伊努皮克语",
"io": "伊多语",
"is": "冰岛语",
"it": "意大利语",
"iu": "因纽特语",
"ja": "日语",
"jv": "爪哇语",
"ka": "格鲁吉亚语",
"kg": "刚果语",
"ki": "吉库尤语",
"kj": "宽亚玛语",
"kk": "哈萨克语",
"kl": "格陵兰语",
"km": "高棉语",
"kn": "卡纳达语",
"ko": "韩语",
"kr": "卡努里语",
"ks": "克什米尔语",
"ku": "库尔德语",
"kv": "科米语",
"kw": "康沃尔语",
"ky": "吉尔吉斯语",
"la": "拉丁语",
"lb": "卢森堡语",
"lg": "卢干达语",
"li": "林堡语",
"ln": "林加拉语",
"lo": "老挝语",
"lt": "立陶宛语",
"lu": "鲁巴加丹加语",
"lv": "拉脱维亚语",
"mg": "马拉加斯语",
"mh": "马绍尔语",
"mi": "毛利语",
"mk": "马其顿语",
"ml": "马拉雅拉姆语",
"mn": "蒙古语",
"mr": "马拉地语",
"ms": "马来语",
"mt": "马耳他语",
"my": "缅甸语",
"na": "瑙鲁语",
"nb": "书面挪威语",
"nd": "北恩德贝勒语",
"ne": "尼泊尔语",
"ng": "恩东加语",
"nl": "荷兰语",
"nn": "挪威尼诺斯克语",
"no": "挪威语",
"nr": "南恩德贝勒语",
"nv": "纳瓦霍语",
"ny": "齐切瓦语",
"oc": "奥克语",
"oj": "奥吉布瓦语",
"om": "奥罗莫语",
"or": "奥里亚语",
"os": "奥塞梯语",
"pa": "旁遮普语",
"pi": "巴利语",
"pl": "波兰语",
"ps": "普什图语",
"pt": "葡萄牙语",
"qu": "克丘亚语",
"rm": "罗曼什语",
"rn": "隆迪语",
"ro": "罗马尼亚语",
"ru": "俄语",
"rw": "卢旺达语",
"sa": "梵语",
"sc": "萨丁语",
"sd": "信德语",
"se": "北方萨米语",
"sg": "桑戈语",
"si": "僧伽罗语",
"sk": "斯洛伐克语",
"sl": "斯洛文尼亚语",
"sm": "萨摩亚语",
"sn": "绍纳语",
"so": "索马里语",
"sq": "阿尔巴尼亚语",
"sr": "塞尔维亚语",
"ss": "斯瓦蒂语",
"st": "南索托语",
"su": "巽他语",
"sv": "瑞典语",
"sw": "斯瓦希里语",
"ta": "泰米尔语",
"te": "泰卢固语",
"tg": "塔吉克语",
"th": "泰语",
"ti": "提格利尼亚语",
"tk": "土库曼语",
"tl": "菲律宾语",
"tn": "茨瓦纳语",
"to": "汤加语",
"tr": "土耳其语",
"ts": "聪加语",
"tt": "鞑靼语",
"tw": "阿肯语",
"ty": "塔希提语",
"ug": "维吾尔语",
"uk": "乌克兰语",
"ur": "乌尔都语",
"uz": "乌兹别克语",
"ve": "文达语",
"vi": "越南语",
"vo": "沃拉普克语",
"wa": "瓦隆语",
"wo": "沃洛夫语",
"xh": "科萨语",
"yi": "意第绪语",
"yo": "约鲁巴语",
"za": "壮语",
"zh": "中文",
"zu": "祖鲁语",
}
def classification_field_options(field_id: str) -> list[ClassificationFieldOption]:
"""返回可直接保存的规范值;开放字段的字典是建议,不限制未知来源值。"""
genre_names = {value: key for key, value in GENRE_KEY_ALIASES.items() if not key.isascii()}
catalogs = {
"media.countries": COUNTRY_NAMES,
"music.artist_country": COUNTRY_NAMES,
"media.language": LANGUAGE_NAMES,
"media.genre_keys": genre_names,
"music.entity_type": {"recording": "歌曲", "album": "专辑", "artist": "艺术家"},
}
return [ClassificationFieldOption(value=value, label=label) for value, label in catalogs.get(field_id, {}).items()]
def classification_source_options(field_id: str) -> dict[str, list[ClassificationFieldOption]]:
"""来源原始风格沿用发现页候选,开放标签不伪装成完整枚举。"""
music_catalogs = {
"music.album_type": {"Album": "专辑", "Single": "单曲", "EP": "迷你专辑", "Broadcast": "广播", "Other": "其他"},
"music.secondary_types": {
value: value
for value in (
"Compilation",
"Soundtrack",
"Spokenword",
"Interview",
"Audiobook",
"Audio drama",
"Live",
"Remix",
"DJ-mix",
"Mixtape/Street",
)
},
"music.release_status": {
"Official": "正式发行",
"Promotion": "宣传发行",
"Bootleg": "非官方发行",
"Pseudo-Release": "伪发行",
},
}
if field_id in music_catalogs:
return {
"musicbrainz": [
ClassificationFieldOption(value=value, label=label) for value, label in music_catalogs[field_id].items()
],
}
if field_id != "media.genre_names":
return {}
genres = {
"themoviedb": "动作 冒险 动画 喜剧 犯罪 纪录 剧情 家庭 奇幻 历史 恐怖 音乐 悬疑 爱情 科幻 电视电影 惊悚 战争 西部 儿童 新闻 真人秀 肥皂剧 脱口秀".split(),
"douban": "剧情 喜剧 爱情 动作 科幻 动画 悬疑 犯罪 惊悚 冒险 音乐 历史 奇幻 恐怖 战争 传记 歌舞 武侠 情色 灾难 西部 纪录片 短片".split(),
"anilist": [
"Action",
"Adventure",
"Comedy",
"Drama",
"Fantasy",
"Horror",
"Mahou Shoujo",
"Mecha",
"Music",
"Mystery",
"Psychological",
"Romance",
"Sci-Fi",
"Slice of Life",
"Sports",
"Supernatural",
"Thriller",
],
}
genres["themoviedb"].extend(["动作冒险", "Sci-Fi & Fantasy", "War & Politics"])
return {
source: [ClassificationFieldOption(value=value, label=value) for value in values]
for source, values in genres.items()
}
+4
View File
@@ -422,6 +422,10 @@ class ClassificationFieldDefinition(_ClassificationModel):
operators: list[ClassificationOperator] = Field(default_factory=list, description="字段允许的操作符") operators: list[ClassificationOperator] = Field(default_factory=list, description="字段允许的操作符")
media_types: list[ClassificationMediaType] = Field(default_factory=list, description="字段适用的媒体类型") media_types: list[ClassificationMediaType] = Field(default_factory=list, description="字段适用的媒体类型")
options: list[ClassificationFieldOption] = Field(default_factory=list, description="字段可选值目录") options: list[ClassificationFieldOption] = Field(default_factory=list, description="字段可选值目录")
source_options: dict[str, list[ClassificationFieldOption]] = Field(
default_factory=dict,
description="按数据源区分的开放候选值;与通用选项合并展示",
)
allow_custom_values: bool = Field( allow_custom_values: bool = Field(
default=True, default=True,
description="前端是否允许输入选项目录之外的值", description="前端是否允许输入选项目录之外的值",
+2 -2
View File
@@ -754,8 +754,8 @@ flowchart LR
| 指标 | 当前值 | | 指标 | 当前值 |
|---|---:| |---|---:|
| Python 模块 | 980 | | Python 模块 | 981 |
| 内部导入边 | 8,302 | | 内部导入边 | 8,313 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 | | Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment | | Direct egress | 53(债务已清零,53 条精确 containment |
@@ -1120,3 +1120,14 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择
| 默认跨源行为 | `primary_only`,可选 `enrich_missing` 后续提供 | | 默认跨源行为 | `primary_only`,可选 `enrich_missing` 后续提供 |
| 可解释性 | 保存 rule ID/revision,预览按条件返回 trace | | 可解释性 | 保存 rule ID/revision,预览按条件返回 trace |
| 兼容策略 | 自动迁移 YAML;旧 GET 保留只读投影,旧 POST 在统一编辑器上线后移除 | | 兼容策略 | 自动迁移 YAML;旧 GET 保留只读投影,旧 POST 在统一编辑器上线后移除 |
### 分类配置的字典与命名约定
- `domain/classification/vocabulary.py` 拥有离线词表,供事实归一化、字段目录与旧字段展示复用;不访问数据源、配置或数据库。
- 国家、语言和规范风格使用可搜索的中文名称与稳定值;来源原始字段使用按来源区分的开放候选,不将示例目录当作封闭枚举。
- 分类名称、分类路径和用途分开表达。规则命中后“归入分类”,全部未命中才使用“默认分类”;两者引用同一份分类定义。
- 旧迁移中的国家条件仍读取原字段,新增条件使用标准字段。迁移兼容提示不代表错误,也不自动改变既有规则语义。
- 新迁移只创建实际需要的默认未分类目录。早期生成的 `未分类/通用` 在 UI 标为“备用未分类”,保留 ID、路径、目录引用和历史配置。
共享离线词表新增一个纯领域模块,启动导入模块数按实际依赖变化更新:`app.startup.lifecycle` 539 → 540、`app.factory` 551 → 552、`app.main` 553 → 554;保留原有耗时预算、采样和生命周期约束。三次本地采样中位数约 1.2 秒,低于既有预算。
+1 -1
View File
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 | | 指标 | 当前值 | 解释 |
|---|---:|---| |---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 980 / 8,302 | `dependency-baseline.json` 当前快照 | | 宿主 Python 模块 / 内部依赖边 | 981 / 8,313 | `dependency-baseline.json` 当前快照;分类离线词表新增一个纯领域模块及其受控依赖 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
+8
View File
@@ -690,3 +690,11 @@ description、aliases、instructions,或通过 `append_instructions` 追加规
} }
} }
``` ```
### 分类条件字段字典
`GET /api/v1/classification/fields``fields``retired_fields` 使用同一字段目录 schema
`options` 提供来源无关的 `{value, label}``source_options` 按数据源 ID 提供开放候选。国家与语言显示中文名称,规则保存标准代码;风格保存与分类事实归一化共用的稳定键。来源风格和音乐枚举保留原始大小写。
客户端合并通用选项和所选来源的候选;未限制来源时展示全部候选并标注来源。`allow_custom_values` 为真时允许输入其他值,切换来源不得清空已有条件。`source_options` 缺失等价于空目录;候选是录入辅助,不改变来源支持等级或规则校验范围。公司、平台和用户标签等开放字段应使用媒体预览中的原值。
+2
View File
@@ -1150,3 +1150,5 @@ modules only through `run_module` dispatch), and downloader SDK
(`qbittorrentapi`, `transmission_rpc`) imports inside `app/chain`. (`qbittorrentapi`, `transmission_rpc`) imports inside `app/chain`.
*Last Updated: 2026-08-29* *Last Updated: 2026-08-29*
分类词表由 `app/domain/classification/vocabulary.py` 拥有,供 `facts.py``fields.py` 和旧配置迁移复用;只含离线词表及纯选项投影,不读取运行时配置、数据库或具体来源模块。
+2
View File
@@ -1811,3 +1811,5 @@ After every update, call `config.system.get` again with the exact setting_key an
3. Downloads, transfers, configuration/rule/plugin writes, scheduler/workflow runs, and deletions have side effects; obtain confirmation and inspect the result. 3. Downloads, transfers, configuration/rule/plugin writes, scheduler/workflow runs, and deletions have side effects; obtain confirmation and inspect the result.
4. `success=false`, HTTP errors, validation errors, and empty results are real outcomes. Never report them as success. 4. `success=false`, HTTP errors, validation errors, and empty results are real outcomes. Never report them as success.
5. Use `database-operation`, `downloader-operation`, or `mediaserver-operation` for their native capabilities. Never bypass the gateway with an arbitrary URL. 5. Use `database-operation`, `downloader-operation`, or `mediaserver-operation` for their native capabilities. Never bypass the gateway with an arbitrary URL.
Classification field catalogs expose source-independent `options` and source-specific open suggestions in `source_options`. Save the option `value`, never its display label or source annotation. Changing source scope must preserve existing conditions; keep values outside the catalog when `allow_custom_values` permits them.
+15 -3
View File
@@ -1074,8 +1074,8 @@
"runtime_only": true "runtime_only": true
} }
}, },
"edge_count": 8302, "edge_count": 8313,
"edge_sha256": "49000047cece20aa2bd7f1d06916072d06b832f0f2d929693f995206f9a29b84", "edge_sha256": "93fe54d658dcf90129d84cb2f61c2db39630a5ebfc7538fe589912fc540fda21",
"edges": [ "edges": [
"app -> app.foundation", "app -> app.foundation",
"app -> app.foundation.environment", "app -> app.foundation.environment",
@@ -3108,11 +3108,17 @@
"app.application.classification.legacy -> app.application.classification", "app.application.classification.legacy -> app.application.classification",
"app.application.classification.legacy -> app.application.classification.migration", "app.application.classification.legacy -> app.application.classification.migration",
"app.application.classification.legacy -> app.application.classification.projection", "app.application.classification.legacy -> app.application.classification.projection",
"app.application.classification.migration -> app.domain",
"app.application.classification.migration -> app.domain.classification",
"app.application.classification.migration -> app.domain.classification.vocabulary",
"app.application.classification.migration -> app.schemas", "app.application.classification.migration -> app.schemas",
"app.application.classification.migration -> app.schemas.category", "app.application.classification.migration -> app.schemas.category",
"app.application.classification.projection -> app.application", "app.application.classification.projection -> app.application",
"app.application.classification.projection -> app.application.classification", "app.application.classification.projection -> app.application.classification",
"app.application.classification.projection -> app.application.classification.migration", "app.application.classification.projection -> app.application.classification.migration",
"app.application.classification.projection -> app.domain",
"app.application.classification.projection -> app.domain.classification",
"app.application.classification.projection -> app.domain.classification.vocabulary",
"app.application.classification.projection -> app.schemas", "app.application.classification.projection -> app.schemas",
"app.application.classification.projection -> app.schemas.category", "app.application.classification.projection -> app.schemas.category",
"app.application.classification.reference -> app.domain", "app.application.classification.reference -> app.domain",
@@ -5853,12 +5859,15 @@
"app.domain.classification.evaluator -> app.schemas", "app.domain.classification.evaluator -> app.schemas",
"app.domain.classification.evaluator -> app.schemas.category", "app.domain.classification.evaluator -> app.schemas.category",
"app.domain.classification.facts -> app.domain", "app.domain.classification.facts -> app.domain",
"app.domain.classification.facts -> app.domain.classification",
"app.domain.classification.facts -> app.domain.classification.vocabulary",
"app.domain.classification.facts -> app.domain.context", "app.domain.classification.facts -> app.domain.context",
"app.domain.classification.facts -> app.schemas", "app.domain.classification.facts -> app.schemas",
"app.domain.classification.facts -> app.schemas.category", "app.domain.classification.facts -> app.schemas.category",
"app.domain.classification.fields -> app.domain", "app.domain.classification.fields -> app.domain",
"app.domain.classification.fields -> app.domain.classification", "app.domain.classification.fields -> app.domain.classification",
"app.domain.classification.fields -> app.domain.classification.sources", "app.domain.classification.fields -> app.domain.classification.sources",
"app.domain.classification.fields -> app.domain.classification.vocabulary",
"app.domain.classification.fields -> app.schemas", "app.domain.classification.fields -> app.schemas",
"app.domain.classification.fields -> app.schemas.category", "app.domain.classification.fields -> app.schemas.category",
"app.domain.classification.sources -> app.schemas", "app.domain.classification.sources -> app.schemas",
@@ -5869,6 +5878,8 @@
"app.domain.classification.validation -> app.domain.classification.fields", "app.domain.classification.validation -> app.domain.classification.fields",
"app.domain.classification.validation -> app.schemas", "app.domain.classification.validation -> app.schemas",
"app.domain.classification.validation -> app.schemas.category", "app.domain.classification.validation -> app.schemas.category",
"app.domain.classification.vocabulary -> app.schemas",
"app.domain.classification.vocabulary -> app.schemas.category",
"app.domain.context -> app.domain", "app.domain.context -> app.domain",
"app.domain.context -> app.domain.meta", "app.domain.context -> app.domain.meta",
"app.domain.context -> app.domain.meta.metabase", "app.domain.context -> app.domain.meta.metabase",
@@ -9380,7 +9391,7 @@
"app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions" "app.workflow.actions.transfer_file -> app.workflow.actions"
], ],
"module_count": 980, "module_count": 981,
"modules": [ "modules": [
"app", "app",
"app.adapters", "app.adapters",
@@ -9926,6 +9937,7 @@
"app.domain.classification.fields", "app.domain.classification.fields",
"app.domain.classification.sources", "app.domain.classification.sources",
"app.domain.classification.validation", "app.domain.classification.validation",
"app.domain.classification.vocabulary",
"app.domain.context", "app.domain.context",
"app.domain.episode", "app.domain.episode",
"app.domain.media", "app.domain.media",
@@ -6,7 +6,7 @@
"repeat": 3, "repeat": 3,
"targets": { "targets": {
"app.startup.lifecycle": { "app.startup.lifecycle": {
"loaded_app_module_count": 539, "loaded_app_module_count": 540,
"max_ms": 1293.338, "max_ms": 1293.338,
"median_ms": 1156.239, "median_ms": 1156.239,
"min_ms": 1102.806, "min_ms": 1102.806,
@@ -17,7 +17,7 @@
] ]
}, },
"app.factory": { "app.factory": {
"loaded_app_module_count": 551, "loaded_app_module_count": 552,
"max_ms": 1127.911, "max_ms": 1127.911,
"median_ms": 1122.382, "median_ms": 1122.382,
"min_ms": 1119.221, "min_ms": 1119.221,
@@ -28,7 +28,7 @@
] ]
}, },
"app.main": { "app.main": {
"loaded_app_module_count": 553, "loaded_app_module_count": 554,
"max_ms": 1188.652, "max_ms": 1188.652,
"median_ms": 1183.509, "median_ms": 1183.509,
"min_ms": 1174.522, "min_ms": 1174.522,
+1
View File
@@ -397,6 +397,7 @@ def test_domain_classification_is_a_pure_direct_import_package() -> None:
"fields.py", "fields.py",
"sources.py", "sources.py",
"validation.py", "validation.py",
"vocabulary.py",
} }
init_path = package / "__init__.py" init_path = package / "__init__.py"
@@ -112,3 +112,34 @@ def test_discover_only_builtin_sources_are_explicitly_unavailable() -> None:
assert set(builtin_source_field_support(media_source).values()) == { assert set(builtin_source_field_support(media_source).values()) == {
"unavailable" "unavailable"
} }
def test_catalog_dictionary_values_match_normalized_facts() -> None:
"""中文标签对应规则实际读取的稳定值,规范风格词表与事实映射保持一致。"""
from app.domain.classification.vocabulary import GENRE_KEY_ALIASES, TMDB_GENRE_KEYS
fields = {item.id: item for item in build_classification_field_catalog()}
countries = {item.value: item.label for item in fields["media.countries"].options}
assert len(countries) == 249
assert countries["JP"] == "日本"
assert countries["KR"] == "韩国"
assert {item.value for item in fields["media.genre_keys"].options} == set(GENRE_KEY_ALIASES.values()) | set(
TMDB_GENRE_KEYS.values()
)
assert any(item.value == "ja" and item.label == "日语" for item in fields["media.language"].options)
assert fields["media.countries"].allow_custom_values
assert fields["media.genre_keys"].allow_custom_values
def test_catalog_source_candidates_preserve_original_values_and_are_isolated() -> None:
"""来源风格只作为开放候选,序列化保留来源和原值且不共享可变状态。"""
fields = {item.id: item for item in build_classification_field_catalog()}
genres = fields["media.genre_names"]
assert genres.allow_custom_values
assert any(item.value == "动画" for item in genres.source_options["douban"])
assert any(item.value == "Action" for item in genres.source_options["anilist"])
assert "bangumi" not in genres.source_options
assert genres.model_dump()["source_options"]["anilist"][0]["value"] == "Action"
genres.source_options["anilist"].clear()
fresh = {item.id: item for item in build_classification_field_catalog()}
assert fresh["media.genre_names"].source_options["anilist"]
+19
View File
@@ -530,3 +530,22 @@ def test_migrated_policy_round_trips_to_category_config() -> None:
expected.movie["组合"].release_year = "2020,2021,2022,2024" expected.movie["组合"].release_year = "2020,2021,2022,2024"
assert projected.config == expected assert projected.config == expected
assert project_policy_to_legacy_category_config(migrated.policy) == projected.config assert project_policy_to_legacy_category_config(migrated.policy) == projected.config
def test_migration_does_not_create_unused_duplicate_fallback_categories() -> None:
"""已有旧默认分类时直接复用,不能再创建未分类/通用的无用目录。"""
result = migrate_legacy_category_config({"movie": {"未分类": None}, "tv": {"未分类": None}})
assert result.valid
assert [item.path for item in result.policy.categories if item.media_type == "电影"] == [["未分类"]]
assert [item.path for item in result.policy.categories if item.media_type == "电视剧"] == [["未分类"]]
assert result.policy.fallbacks["音乐"] == "music.uncategorized"
assert ClassificationPolicyValidator.validate(result.policy, result.extra_fields).valid
def test_legacy_country_dictionary_keeps_country_codes_and_genre_ids_distinct() -> None:
"""旧地区条件能直接选择代码,未知旧风格编号不能误填为标准风格键。"""
result = migrate_legacy_category_config({"tv": {"日韩剧": {"origin_country": "JP,KR", "genre_ids": "999"}}})
fields = {item.id: item for item in result.extra_fields}
countries = fields["extensions.themoviedb.origin_country"]
assert any(item.value == "JP" and item.label == "日本" for item in countries.options)
assert not fields["extensions.themoviedb.genre_ids"].options