mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
feat(classification): provide source-aware condition dictionaries
This commit is contained in:
@@ -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("通用")
|
||||
|
||||
@@ -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 扩展事实可以从策略或字段声明中发现。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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="前端是否允许输入选项目录之外的值",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 秒,低于既有预算。
|
||||
|
||||
@@ -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 等基线均为零 |
|
||||
|
||||
@@ -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` 缺失等价于空目录;候选是录入辅助,不改变来源支持等级或规则校验范围。公司、平台和用户标签等开放字段应使用媒体预览中的原值。
|
||||
|
||||
@@ -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` 和旧配置迁移复用;只含离线词表及纯选项投影,不读取运行时配置、数据库或具体来源模块。
|
||||
|
||||
@@ -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.
|
||||
|
||||
+15
-3
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user