mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +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 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("通用")
|
||||||
|
|||||||
@@ -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 扩展事实可以从策略或字段声明中发现。"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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="字段允许的操作符")
|
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="前端是否允许输入选项目录之外的值",
|
||||||
|
|||||||
@@ -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 秒,低于既有预算。
|
||||||
|
|||||||
@@ -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 等基线均为零 |
|
||||||
|
|||||||
@@ -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`.
|
(`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` 和旧配置迁移复用;只含离线词表及纯选项投影,不读取运行时配置、数据库或具体来源模块。
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user