From 7ccb1d9dee424b9076b04a75fa7a3f4b7647aee5 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 7 Sep 2026 23:21:25 +0800 Subject: [PATCH] feat(classification): provide source-aware condition dictionaries --- app/application/classification/migration.py | 40 +- app/application/classification/projection.py | 4 +- app/domain/classification/facts.py | 181 +---- app/domain/classification/fields.py | 10 +- app/domain/classification/vocabulary.py | 683 ++++++++++++++++++ app/schemas/category.py | 4 + docs/architecture-overview.md | 4 +- .../media-classification-design.md | 11 + docs/architecture/optimization-checklist.md | 2 +- docs/mcp-api.md | 8 + docs/rules/05-architecture.md | 2 + skills/moviepilot-api/SKILL.md | 2 + .../architecture/dependency-baseline.json | 18 +- .../startup-performance-baseline.json | 6 +- tests/test_architecture_dependencies.py | 1 + tests/test_media_classification_catalog.py | 31 + tests/test_media_classification_legacy.py | 19 + 17 files changed, 815 insertions(+), 211 deletions(-) create mode 100644 app/domain/classification/vocabulary.py diff --git a/app/application/classification/migration.py b/app/application/classification/migration.py index b29aa81b0..d91f9079d 100644 --- a/app/application/classification/migration.py +++ b/app/application/classification/migration.py @@ -8,6 +8,12 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field 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 ( CategoryConfig, CategoryRule, @@ -50,30 +56,6 @@ _COMMON_FALLBACKS: Final[dict[ClassificationMediaType, str]] = { "电视剧": "tv.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]]] = { "genre_ids": ("风格(旧规则)", "media.genre_keys"), "origin_country": ("原产国家/地区(旧规则)", "media.countries"), @@ -206,7 +188,7 @@ def migrate_legacy_category_config( context=context, ) - categories.extend(_common_fallback_categories(categories)) + categories.extend(_common_fallback_categories(categories, fallbacks)) policy_payload: dict[str, object] = { "schema_version": 2, "revision": 1, @@ -514,16 +496,17 @@ def _legacy_field_definition( 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";新规则请使用 {replacement_field}" if replacement_field else "" + replacement_hint = f";新增条件请使用{presentation[0].replace('(旧规则)', '')}" if presentation else "" return ClassificationFieldDefinition( id=field_id, label=label, group="旧规则", - description=(f"仅用于保持已迁移 category.yaml 的原始比较语义{replacement_hint}"), + 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, ) @@ -779,11 +762,14 @@ def _stable_category_id(media_key: LegacyMediaKey, name: str) -> str: def _common_fallback_categories( legacy_categories: Sequence[ClassificationCategory], + fallbacks: Mapping[ClassificationMediaType, str], ) -> list[ClassificationCategory]: """构造不受来源限制且不与同类型旧目录冲突的稳定未分类目录。""" occupied = {(category.media_type, tuple(category.path)) for category in legacy_categories} categories: list[ClassificationCategory] = [] for media_type, category_id in _COMMON_FALLBACKS.items(): + if fallbacks.get(media_type) != category_id: + continue path = ["未分类"] if (media_type, tuple(path)) in occupied: path.append("通用") diff --git a/app/application/classification/projection.py b/app/application/classification/projection.py index 212ed92ca..ca383e587 100644 --- a/app/application/classification/projection.py +++ b/app/application/classification/projection.py @@ -11,12 +11,12 @@ from app.application.classification.migration import ( _EXTENSION_PREFIX, _MEDIA_KEYS, _SAFE_FIELD_SEGMENT, - _TMDB_GENRE_KEYS, _TMDB_SOURCE, LegacyClassificationDiagnostic, LegacyDiagnosticPathPart, LegacyMediaKey, ) +from app.domain.classification.vocabulary import TMDB_GENRE_KEYS from app.schemas.category import ( CategoryConfig, CategoryRule, @@ -29,7 +29,7 @@ from app.schemas.category import ( 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] """受控 TMDB 扩展事实可以从策略或字段声明中发现。""" diff --git a/app/domain/classification/facts.py b/app/domain/classification/facts.py index 94a394ae6..8301829af 100644 --- a/app/domain/classification/facts.py +++ b/app/domain/classification/facts.py @@ -5,6 +5,15 @@ from collections.abc import Mapping from enum import Enum 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.schemas.category import ( ClassificationFacts, @@ -18,168 +27,6 @@ from app.schemas.category import ( 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( media: ClassificationMedia, @@ -239,9 +86,7 @@ def _music_facts(media: ClassificationMedia) -> ClassificationMusicFacts: genres=_genre_names(getattr(media, "genres", None)), tags=_optional_string_list(getattr(media, "tags", None)), artists=_optional_string_list(getattr(media, "artists", None)), - artist_country=_country_code( - getattr(media, "artist_country", None) or getattr(media, "country", None) - ), + artist_country=_country_code(getattr(media, "artist_country", None) or getattr(media, "country", 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: """把来源 Genre ID、类型名和音乐流派投影为稳定跨来源键。""" values: list[str] = [] - for item in _optional_string_list( - getattr(media, "classification_genre_keys", None) - ) or []: + for item in _optional_string_list(getattr(media, "classification_genre_keys", None)) or []: _append_unique(values, item) for item in getattr(media, "genre_ids", None) or []: if key := _TMDB_GENRE_KEYS.get(str(item)): @@ -388,7 +231,7 @@ def _optional_int(value: object) -> int | None: return None try: return int(str(value)) - except (TypeError, ValueError): + except TypeError, ValueError: return None diff --git a/app/domain/classification/fields.py b/app/domain/classification/fields.py index ca29f9dcf..2445e0f1b 100644 --- a/app/domain/classification/fields.py +++ b/app/domain/classification/fields.py @@ -3,6 +3,7 @@ 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, @@ -91,6 +92,8 @@ VALUE_TYPE_OPERATORS: Final[dict[str, tuple[str, ...]]] = { class _FieldSpec(TypedDict): + """标准字段的只读声明。""" + id: str label: 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"]) ], media_types=[cast(ClassificationMediaType, media_type) for media_type in values["media_types"]], - options=[ - ClassificationFieldOption(value=option, label=option) - for option in values.get("options", ()) - ], + 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"]), ) diff --git a/app/domain/classification/vocabulary.py b/app/domain/classification/vocabulary.py new file mode 100644 index 000000000..4e3133250 --- /dev/null +++ b/app/domain/classification/vocabulary.py @@ -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() + } diff --git a/app/schemas/category.py b/app/schemas/category.py index 3ff72efef..09ba201f7 100644 --- a/app/schemas/category.py +++ b/app/schemas/category.py @@ -422,6 +422,10 @@ class ClassificationFieldDefinition(_ClassificationModel): operators: list[ClassificationOperator] = Field(default_factory=list, description="字段允许的操作符") media_types: list[ClassificationMediaType] = 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( default=True, description="前端是否允许输入选项目录之外的值", diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index fe6b9f7f3..ad762e0fa 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -754,8 +754,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 980 | -| 内部导入边 | 8,302 | +| Python 模块 | 981 | +| 内部导入边 | 8,313 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/media-classification-design.md b/docs/architecture/media-classification-design.md index 666ef2a7e..70910d23a 100644 --- a/docs/architecture/media-classification-design.md +++ b/docs/architecture/media-classification-design.md @@ -1120,3 +1120,14 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择 | 默认跨源行为 | `primary_only`,可选 `enrich_missing` 后续提供 | | 可解释性 | 保存 rule ID/revision,预览按条件返回 trace | | 兼容策略 | 自动迁移 YAML;旧 GET 保留只读投影,旧 POST 在统一编辑器上线后移除 | + + +### 分类配置的字典与命名约定 + +- `domain/classification/vocabulary.py` 拥有离线词表,供事实归一化、字段目录与旧字段展示复用;不访问数据源、配置或数据库。 +- 国家、语言和规范风格使用可搜索的中文名称与稳定值;来源原始字段使用按来源区分的开放候选,不将示例目录当作封闭枚举。 +- 分类名称、分类路径和用途分开表达。规则命中后“归入分类”,全部未命中才使用“默认分类”;两者引用同一份分类定义。 +- 旧迁移中的国家条件仍读取原字段,新增条件使用标准字段。迁移兼容提示不代表错误,也不自动改变既有规则语义。 +- 新迁移只创建实际需要的默认未分类目录。早期生成的 `未分类/通用` 在 UI 标为“备用未分类”,保留 ID、路径、目录引用和历史配置。 + +共享离线词表新增一个纯领域模块,启动导入模块数按实际依赖变化更新:`app.startup.lifecycle` 539 → 540、`app.factory` 551 → 552、`app.main` 553 → 554;保留原有耗时预算、采样和生命周期约束。三次本地采样中位数约 1.2 秒,低于既有预算。 diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 881dba8f0..f574c0af2 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -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 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index bd7712560..179904bb0 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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` 缺失等价于空目录;候选是录入辅助,不改变来源支持等级或规则校验范围。公司、平台和用户标签等开放字段应使用媒体预览中的原值。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index e92740654..7373f11c3 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -1150,3 +1150,5 @@ modules only through `run_module` dispatch), and downloader SDK (`qbittorrentapi`, `transmission_rpc`) imports inside `app/chain`. *Last Updated: 2026-08-29* + +分类词表由 `app/domain/classification/vocabulary.py` 拥有,供 `facts.py`、`fields.py` 和旧配置迁移复用;只含离线词表及纯选项投影,不读取运行时配置、数据库或具体来源模块。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index eed08663f..76879d0b3 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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. 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. + +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. diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index ad55e6b6e..cd01e7ee8 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1074,8 +1074,8 @@ "runtime_only": true } }, - "edge_count": 8302, - "edge_sha256": "49000047cece20aa2bd7f1d06916072d06b832f0f2d929693f995206f9a29b84", + "edge_count": 8313, + "edge_sha256": "93fe54d658dcf90129d84cb2f61c2db39630a5ebfc7538fe589912fc540fda21", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3108,11 +3108,17 @@ "app.application.classification.legacy -> app.application.classification", "app.application.classification.legacy -> app.application.classification.migration", "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.category", "app.application.classification.projection -> app.application", "app.application.classification.projection -> app.application.classification", "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.category", "app.application.classification.reference -> app.domain", @@ -5853,12 +5859,15 @@ "app.domain.classification.evaluator -> app.schemas", "app.domain.classification.evaluator -> app.schemas.category", "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.schemas", "app.domain.classification.facts -> app.schemas.category", "app.domain.classification.fields -> app.domain", "app.domain.classification.fields -> app.domain.classification", "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.category", "app.domain.classification.sources -> app.schemas", @@ -5869,6 +5878,8 @@ "app.domain.classification.validation -> app.domain.classification.fields", "app.domain.classification.validation -> app.schemas", "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.meta", "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.actions" ], - "module_count": 980, + "module_count": 981, "modules": [ "app", "app.adapters", @@ -9926,6 +9937,7 @@ "app.domain.classification.fields", "app.domain.classification.sources", "app.domain.classification.validation", + "app.domain.classification.vocabulary", "app.domain.context", "app.domain.episode", "app.domain.media", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 2a922e542..6526f9936 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 539, + "loaded_app_module_count": 540, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 551, + "loaded_app_module_count": 552, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 553, + "loaded_app_module_count": 554, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 97dad6c7b..25ad9981c 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -397,6 +397,7 @@ def test_domain_classification_is_a_pure_direct_import_package() -> None: "fields.py", "sources.py", "validation.py", + "vocabulary.py", } init_path = package / "__init__.py" diff --git a/tests/test_media_classification_catalog.py b/tests/test_media_classification_catalog.py index 110359008..1aac05ca5 100644 --- a/tests/test_media_classification_catalog.py +++ b/tests/test_media_classification_catalog.py @@ -112,3 +112,34 @@ def test_discover_only_builtin_sources_are_explicitly_unavailable() -> None: assert set(builtin_source_field_support(media_source).values()) == { "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"] diff --git a/tests/test_media_classification_legacy.py b/tests/test_media_classification_legacy.py index 24feb3bca..d9d28fcce 100644 --- a/tests/test_media_classification_legacy.py +++ b/tests/test_media_classification_legacy.py @@ -530,3 +530,22 @@ def test_migrated_policy_round_trips_to_category_config() -> None: expected.movie["组合"].release_year = "2020,2021,2022,2024" assert projected.config == expected 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