mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
fix(classification): remove source defaults and clarify policy docs
This commit is contained in:
@@ -511,15 +511,9 @@ def _build_impact_analysis(
|
||||
elif "rule_id" in changed_fields:
|
||||
rule_changed_only_count += 1
|
||||
proposed_selection = proposed.effective or proposed.recommended
|
||||
if proposed_selection and proposed_selection.source in {
|
||||
"fallback",
|
||||
"source_fallback",
|
||||
}:
|
||||
if proposed_selection and proposed_selection.source == "fallback":
|
||||
previous_selection = previous.effective or previous.recommended
|
||||
if not previous_selection or previous_selection.source not in {
|
||||
"fallback",
|
||||
"source_fallback",
|
||||
}:
|
||||
if not previous_selection or previous_selection.source != "fallback":
|
||||
became_fallback_count += 1
|
||||
group_key = (facts.media.type, facts.identity.media_source)
|
||||
group = group_counts.setdefault(group_key, [0, 0, 0])
|
||||
|
||||
@@ -183,7 +183,7 @@ def migrate_legacy_category_config(
|
||||
config: Union[CategoryConfig, Mapping[str, object]],
|
||||
) -> LegacyClassificationMigrationResult:
|
||||
"""
|
||||
把内存中的旧分类配置转换为来源受限的新版策略草稿
|
||||
把内存中的旧分类配置转换为按媒体类型匹配的新版策略草稿
|
||||
|
||||
:param config: 已校验的 CategoryConfig 或保持 YAML 顺序的映射
|
||||
:return: 包含策略、动态字段声明和结构化诊断的纯迁移结果
|
||||
@@ -193,7 +193,7 @@ def migrate_legacy_category_config(
|
||||
_diagnose_unknown_top_level_keys(root, context)
|
||||
categories: list[ClassificationCategory] = []
|
||||
rules: list[ClassificationRule] = []
|
||||
source_fallbacks: dict[str, dict[ClassificationMediaType, str]] = {}
|
||||
fallbacks = dict(_COMMON_FALLBACKS)
|
||||
|
||||
for media_key, media_type in _MEDIA_TYPES.items():
|
||||
_migrate_media_categories(
|
||||
@@ -202,7 +202,7 @@ def migrate_legacy_category_config(
|
||||
raw_categories=root.get(media_key),
|
||||
categories=categories,
|
||||
rules=rules,
|
||||
source_fallbacks=source_fallbacks,
|
||||
fallbacks=fallbacks,
|
||||
context=context,
|
||||
)
|
||||
|
||||
@@ -213,8 +213,7 @@ def migrate_legacy_category_config(
|
||||
"mode": "first_match",
|
||||
"categories": categories,
|
||||
"rules": rules,
|
||||
"fallbacks": dict(_COMMON_FALLBACKS),
|
||||
"source_fallbacks": source_fallbacks,
|
||||
"fallbacks": fallbacks,
|
||||
"field_aliases": {field_id: aliases for field_id, aliases in context.field_aliases.items() if aliases},
|
||||
}
|
||||
policy = ClassificationPolicy.model_validate(policy_payload)
|
||||
@@ -277,10 +276,10 @@ def _migrate_media_categories(
|
||||
raw_categories: object,
|
||||
categories: list[ClassificationCategory],
|
||||
rules: list[ClassificationRule],
|
||||
source_fallbacks: dict[str, dict[ClassificationMediaType, str]],
|
||||
fallbacks: dict[ClassificationMediaType, str],
|
||||
context: _MigrationContext,
|
||||
) -> None:
|
||||
"""按单个旧媒体类型的原始顺序迁移分类、规则和来源兜底。"""
|
||||
"""按单个旧媒体类型的原始顺序迁移分类、规则和全局兜底。"""
|
||||
if raw_categories is None:
|
||||
return
|
||||
if not isinstance(raw_categories, Mapping):
|
||||
@@ -320,7 +319,7 @@ def _migrate_media_categories(
|
||||
rule_mapping = _legacy_rule_mapping(raw_rule)
|
||||
if _is_legacy_fallback(raw_rule, rule_mapping):
|
||||
if not fallback_seen:
|
||||
source_fallbacks.setdefault(_TMDB_SOURCE, {})[media_type] = category_id
|
||||
fallbacks[media_type] = category_id
|
||||
fallback_seen = True
|
||||
if rule_mapping is not None:
|
||||
rules.append(
|
||||
@@ -737,7 +736,7 @@ def _fallback_metadata_rule(
|
||||
archived: bool,
|
||||
context: _MigrationContext,
|
||||
) -> ClassificationRule:
|
||||
"""用禁用规则保留全空字段映射,运行时仍只通过来源兜底命中。"""
|
||||
"""用禁用规则保留全空字段映射,运行时由全局兜底处理。"""
|
||||
nodes: list[ClassificationConditionNode] = []
|
||||
for raw_field in rule_mapping:
|
||||
field_path = [*path, str(raw_field)]
|
||||
|
||||
@@ -25,7 +25,6 @@ from app.schemas.category import (
|
||||
ClassificationFactScalar,
|
||||
ClassificationFactValue,
|
||||
ClassificationFieldDefinition,
|
||||
ClassificationMediaType,
|
||||
ClassificationPolicy,
|
||||
ClassificationRule,
|
||||
)
|
||||
@@ -55,13 +54,12 @@ def project_policy_to_legacy_category_projection(
|
||||
"""
|
||||
把新版策略尽可能投影为旧 CategoryConfig
|
||||
|
||||
本迁移器生成的分类、规则和来源兜底可精确恢复;新版独有结构会保留可表达部分并返回警告。
|
||||
本迁移器生成的分类、规则和全局兜底可精确恢复;新版独有结构会保留可表达部分并返回警告。
|
||||
|
||||
:param policy: 待兼容投影的新版策略
|
||||
:return: 旧配置和无法精确表达的结构化诊断
|
||||
"""
|
||||
diagnostics: list[LegacyClassificationDiagnostic] = []
|
||||
source_fallbacks = _policy_source_fallbacks(policy)
|
||||
rules_by_category = _category_rules(policy)
|
||||
projected: dict[LegacyMediaKey, dict[str, Optional[CategoryRule]]] = {
|
||||
"movie": {},
|
||||
@@ -73,7 +71,7 @@ def project_policy_to_legacy_category_projection(
|
||||
if media_key is None or not category.id.startswith(f"legacy.{media_key}."):
|
||||
continue
|
||||
category_path: list[LegacyDiagnosticPathPart] = ["categories", category_index]
|
||||
if source_fallbacks.get(_TMDB_SOURCE, {}).get(category.media_type) == category.id:
|
||||
if policy.fallbacks.get(category.media_type) == category.id:
|
||||
rule = rules_by_category.get(category.id)
|
||||
if rule is not None and rule.id.endswith(".fallback"):
|
||||
projected[media_key][category.name] = CategoryRule.model_validate(_project_fallback_metadata(rule))
|
||||
@@ -158,14 +156,6 @@ def resolve_legacy_tmdb_category(
|
||||
return _resolve_legacy_category_mapping(categories, tmdb_info)
|
||||
|
||||
|
||||
def _policy_source_fallbacks(
|
||||
policy: ClassificationPolicy,
|
||||
) -> Mapping[str, Mapping[ClassificationMediaType, str]]:
|
||||
"""读取新版来源专属兜底映射,并兼容并行 schema 合入前的空状态。"""
|
||||
value = getattr(policy, "source_fallbacks", {})
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _category_rules(policy: ClassificationPolicy) -> dict[str, ClassificationRule]:
|
||||
"""按目标分类 ID 索引 TMDB 主分类规则,保持首条规则优先。"""
|
||||
result: dict[str, ClassificationRule] = {}
|
||||
|
||||
@@ -32,6 +32,24 @@ DirectoryConfigurationNormalizer = Callable[
|
||||
]
|
||||
"""使用事务内活动策略规范化目录配置的纯函数。"""
|
||||
|
||||
|
||||
def discard_removed_source_fallbacks(value: Any) -> Any:
|
||||
"""读取持久化策略时丢弃已删除的来源级默认分类字段,不再恢复其行为。"""
|
||||
if not isinstance(value, Mapping):
|
||||
return value
|
||||
|
||||
state = copy.deepcopy(dict(value))
|
||||
policies = []
|
||||
active = state.get("active")
|
||||
if isinstance(active, Mapping):
|
||||
policies.append(active)
|
||||
history = state.get("history")
|
||||
if isinstance(history, list):
|
||||
policies.extend(item for item in history if isinstance(item, Mapping))
|
||||
for policy in policies:
|
||||
policy.pop("source_fallbacks", None)
|
||||
return state
|
||||
|
||||
_CONFIGURATION_LOCK_KEYS = (
|
||||
SystemConfigKey.MediaClassificationPolicy.value,
|
||||
SystemConfigKey.Directories.value,
|
||||
@@ -142,7 +160,9 @@ class SystemConfigClassificationPolicyStore:
|
||||
try:
|
||||
return cast(
|
||||
ClassificationPolicyState,
|
||||
ClassificationPolicyState.model_validate(value),
|
||||
ClassificationPolicyState.model_validate(
|
||||
discard_removed_source_fallbacks(value)
|
||||
),
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise ClassificationPolicyStateCorruptError(
|
||||
|
||||
@@ -135,17 +135,10 @@ class ClassificationEvaluator:
|
||||
|
||||
selection_source = "automatic"
|
||||
if not selected_category_id:
|
||||
source_fallbacks = policy.source_fallbacks.get(normalized_media_source, {})
|
||||
selected_category_id = source_fallbacks.get(
|
||||
selected_category_id = policy.fallbacks.get(
|
||||
cast(ClassificationMediaType, normalized_media_type)
|
||||
)
|
||||
if selected_category_id:
|
||||
selection_source = "source_fallback"
|
||||
else:
|
||||
selected_category_id = policy.fallbacks.get(
|
||||
cast(ClassificationMediaType, normalized_media_type)
|
||||
)
|
||||
selection_source = "fallback"
|
||||
selection_source = "fallback"
|
||||
|
||||
category = categories.get(selected_category_id or "")
|
||||
if category:
|
||||
|
||||
@@ -596,7 +596,7 @@ class ClassificationPolicyValidator:
|
||||
categories: Mapping[str, Any],
|
||||
collector: _ValidationCollector,
|
||||
) -> None:
|
||||
"""确保通用和来源级兜底均引用同类型的可用分类。"""
|
||||
"""确保每种媒体类型的全局兜底引用同类型的可用分类。"""
|
||||
for media_type in ALL_MEDIA_TYPES:
|
||||
category_id = policy.fallbacks.get(cast(ClassificationMediaType, media_type))
|
||||
path: list[str | int] = ["fallbacks", media_type]
|
||||
@@ -626,35 +626,6 @@ class ClassificationPolicyValidator:
|
||||
f"兜底分类 {category_id} 已禁用",
|
||||
path,
|
||||
)
|
||||
for source, source_fallbacks in policy.source_fallbacks.items():
|
||||
source_path: list[str | int] = ["source_fallbacks", source]
|
||||
if not _SOURCE_ID_PATTERN.fullmatch(source):
|
||||
collector.error(
|
||||
"invalid_fallback_source",
|
||||
f"来源级兜底的数据源 {source} 不是合法标识",
|
||||
source_path,
|
||||
)
|
||||
for media_type, category_id in source_fallbacks.items():
|
||||
path = [*source_path, media_type]
|
||||
category = categories.get(category_id)
|
||||
if not category:
|
||||
collector.error(
|
||||
"unknown_source_fallback_category",
|
||||
f"来源级兜底分类 {category_id} 不存在",
|
||||
path,
|
||||
)
|
||||
elif category.media_type != media_type:
|
||||
collector.error(
|
||||
"source_fallback_media_type_mismatch",
|
||||
f"来源级兜底分类 {category_id} 不属于媒体类型 {media_type}",
|
||||
path,
|
||||
)
|
||||
elif not category.enabled:
|
||||
collector.error(
|
||||
"disabled_source_fallback_category",
|
||||
f"来源级兜底分类 {category_id} 已禁用",
|
||||
path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_aliases(
|
||||
|
||||
@@ -198,10 +198,6 @@ class ClassificationPolicy(_ClassificationModel):
|
||||
categories: list[ClassificationCategory] = Field(default_factory=list, description="稳定分类定义列表")
|
||||
rules: list[ClassificationRule] = Field(default_factory=list, description="全局有序规则列表")
|
||||
fallbacks: dict[ClassificationMediaType, str] = Field(default_factory=dict, description="各媒体类型的兜底分类 ID")
|
||||
source_fallbacks: dict[str, dict[ClassificationMediaType, str]] = Field(
|
||||
default_factory=dict,
|
||||
description="按数据源覆盖的媒体类型兜底分类 ID",
|
||||
)
|
||||
field_aliases: dict[str, dict[str, str]] = Field(default_factory=dict, description="字段值别名到规范值的映射")
|
||||
updated_at: Optional[datetime] = Field(default=None, description="策略最后发布时间")
|
||||
|
||||
@@ -641,7 +637,7 @@ class ClassificationImpactAnalysis(_ClassificationModel):
|
||||
category_changed_count: int = Field(ge=0, description="稳定分类 ID 发生变化的样本数量")
|
||||
path_only_changed_count: int = Field(ge=0, description="分类 ID 不变但路径变化的样本数量")
|
||||
rule_changed_only_count: int = Field(ge=0, description="分类与路径不变但命中规则变化的样本数量")
|
||||
became_fallback_count: int = Field(ge=0, description="候选策略改为通用或来源级兜底的样本数量")
|
||||
became_fallback_count: int = Field(ge=0, description="候选策略改为媒体类型默认分类的样本数量")
|
||||
partial_count: int = Field(ge=0, description="任一策略因事实缺失产生 partial 的样本数量")
|
||||
degraded_count: int = Field(
|
||||
ge=0,
|
||||
|
||||
@@ -34,7 +34,10 @@ from app.application.classification.reference import (
|
||||
)
|
||||
from app.application.classification.runtime import ClassificationRuntime
|
||||
from app.application.database import AsyncDatabaseExecutor
|
||||
from app.db.adapters.classification import SystemConfigClassificationPolicyStore
|
||||
from app.db.adapters.classification import (
|
||||
SystemConfigClassificationPolicyStore,
|
||||
discard_removed_source_fallbacks,
|
||||
)
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.session import SessionFactory
|
||||
from app.runtime.config import Settings
|
||||
@@ -104,7 +107,9 @@ async def compose_classification(
|
||||
existing_issue: tuple[ClassificationValidationIssue, ...] = ()
|
||||
if policy_key in values:
|
||||
try:
|
||||
stored_state = ClassificationPolicyState.model_validate(stored_value)
|
||||
stored_state = ClassificationPolicyState.model_validate(
|
||||
discard_removed_source_fallbacks(stored_value)
|
||||
)
|
||||
extra_fields = legacy_extension_fields_from_policy(stored_state.active)
|
||||
except ValidationError:
|
||||
existing_issue = (
|
||||
|
||||
@@ -227,7 +227,6 @@ TheMovieDb 模块内部的专用能力:
|
||||
"categories": [],
|
||||
"rules": [],
|
||||
"fallbacks": {},
|
||||
"source_fallbacks": {},
|
||||
"field_aliases": {},
|
||||
"updated_at": "2026-09-02T12:00:00+08:00"
|
||||
}
|
||||
@@ -243,7 +242,6 @@ TheMovieDb 模块内部的专用能力:
|
||||
| `categories` | 稳定分类定义 |
|
||||
| `rules` | 全局有序的主分类规则和附加标签规则 |
|
||||
| `fallbacks` | 每种媒体类型的兜底分类 ID |
|
||||
| `source_fallbacks` | 可选的数据源级媒体类型兜底;优先于通用兜底,用于兼容来源特有的历史语义 |
|
||||
| `field_aliases` | 可选值别名,例如音乐流派同义词,不改变字段 ID |
|
||||
|
||||
### 5.2 分类定义
|
||||
@@ -552,8 +550,7 @@ UI 根据用户选择的媒体类型和来源过滤字段,并显示覆盖提
|
||||
2. 订阅、下载历史或人工整理持久化的分类覆盖。
|
||||
3. 用户显式选定目录时,该目录绑定的固定分类。
|
||||
4. 当前策略自动推荐分类。
|
||||
5. 当前数据源声明的媒体类型兜底分类。
|
||||
6. 媒体类型通用兜底分类。
|
||||
5. 媒体类型默认分类。
|
||||
|
||||
来源 `metadata_category` 永远不自动升级为 `library_category`。用户确实希望按 `Live`、`Album`、`Rock`
|
||||
分类时,应通过显式规则引用 `music.secondary_types`、`music.album_type`、`music.genres`。
|
||||
@@ -725,15 +722,15 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择
|
||||
### 11.1 信息架构
|
||||
|
||||
分类策略从“设置 -> 目录”中的“自动分类策略”入口进入,点击后打开全屏、可滚动的编辑窗口;分类编辑器在窗口内工作。
|
||||
窗口内按职责分为“分类树、规则、来源、检查与发布”四个工作区标签;最后一个工作区再分为“结果预览、
|
||||
影响分析、版本发布与历史”三个标签。移动端保持同样的分层顺序,不把所有表单压缩到同一屏:
|
||||
窗口内按职责分为“分类树、规则、检查与发布”三个工作区标签;最后一个工作区再分为“结果预览、影响分析、
|
||||
版本发布与历史”三个标签。移动端保持同样的分层顺序,不把所有表单压缩到同一屏:
|
||||
|
||||
```text
|
||||
+------------------+------------------+------------------+----------------------+
|
||||
| 分类树 | 规则 | 来源 | 检查与发布 |
|
||||
| 电影 / 电视剧 | 有序规则列表 | 来源默认分类 | 结果预览 |
|
||||
| 音乐 | 条件和输出 | 影视 / 音乐 | 影响分析 / 版本历史 |
|
||||
+------------------+------------------+------------------+----------------------+
|
||||
+------------------+------------------+----------------------+
|
||||
| 分类树 | 规则 | 检查与发布 |
|
||||
| 电影 / 电视剧 | 有序规则列表 | 结果预览 |
|
||||
| 音乐 | 条件和输出 | 影响分析 / 版本历史 |
|
||||
+------------------+------------------+----------------------+
|
||||
```
|
||||
|
||||
不得继续在每条规则中堆叠固定的 TMDB 表单控件。
|
||||
@@ -934,9 +931,8 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择
|
||||
类型转换或假值处理可能改变旧结果,必须保留为受控 TMDB 扩展事实,不能为了使用标准字段牺牲等价性。
|
||||
7. 其它 TMDB 一级字段转换到受控 `extensions.themoviedb.*` 字段;无法登记的字段阻止自动发布,保留
|
||||
legacy 运行并提示管理员处理。
|
||||
8. 首个空规则分类转换为 `source_fallbacks.themoviedb` 下该媒体类型的来源级兜底,禁止污染豆瓣、
|
||||
IMDb 等其它来源;其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。
|
||||
9. 新策略仍为电影、电视剧和音乐配置通用兜底,用于没有来源级兜底或来源级规则未命中的情况。
|
||||
8. 首个空规则分类转换为该媒体类型的全局 `fallbacks`,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。
|
||||
9. 新策略只按媒体类型配置通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。
|
||||
10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。
|
||||
|
||||
### 13.2 行为兼容
|
||||
|
||||
@@ -105,20 +105,20 @@ V3 将通用媒体身份统一为:
|
||||
V2 自动分类主要由 TMDB 详情和 `category.yaml` 驱动,只覆盖电影、电视剧。V3 将自动分类独立为统一能力,不再属于某一个元数据来源:
|
||||
|
||||
- 电影、电视剧和音乐在“设置 → 目录 → 自动分类策略”打开的全屏窗口中维护分类树和规则。
|
||||
- 规则可以使用媒体类型、年份、国家、类型、音乐实体、专辑类型、来源范围等标准事实。
|
||||
- 规则可以使用媒体类型、年份、国家、类型、音乐实体、专辑类型和数据源范围等媒体信息。
|
||||
- TMDB、豆瓣、Bangumi、AniList、IMDb、TVDB、MusicBrainz、TheAudioDB、豆瓣音乐和已登记插件来源都可以进入同一分类流程。
|
||||
- 来源专用字段由宿主或插件在受控命名空间中声明,规则编辑器根据后端字段目录动态生成,不再把 TMDB 字段硬编码到前端。
|
||||
- 默认只使用主来源已经取得的事实;管理员显式开启“补充缺失事实”后,宿主才会在身份可证明一致的前提下向其它来源补充缺失字段。
|
||||
- 默认只使用主来源已经取得的媒体信息;管理员显式开启“补充缺失信息”后,宿主才会在确认是同一媒体的前提下向其它来源补充缺失内容。
|
||||
|
||||
分类策略按 revision 版本化保存。发布前必须先通过服务端校验,并可以使用近期历史样本执行有界影响分析;发布后可以查看历史版本和回滚。并发编辑会提示 revision 冲突,不会静默覆盖另一位管理员的修改。
|
||||
|
||||
使用时不需要手工填写分类事实:在“结果预览”中输入关键词并选择媒体,系统直接使用搜索结果中的标题、年份、风格、
|
||||
使用时不需要手工填写条件信息:在“结果预览”中输入关键词并选择媒体,系统直接使用搜索结果中的标题、年份、风格、
|
||||
国家/地区、分级以及音乐流派、标签和艺术家等信息。影响分析会读取近期下载和整理记录,再按记录中的数据源和编号
|
||||
重新获取完整媒体详情;无法获取详情的记录会单独统计,不会被当成“没有变化”。
|
||||
|
||||
目录、订阅、下载和整理历史不再只保存易变的分类名称,而是同时记录稳定 `category_id` 和当时的路径快照。分类改名或调整路径后,既有目录和订阅仍能解析到同一个分类;已经建立的整理计划会继续使用计划创建时冻结的分类目标,不会被后续策略修改。
|
||||
目录、订阅、下载和整理历史不再只保存易变的分类名称,而是同时记录内部分类编号 `category_id` 和当时的路径快照。分类改名或调整路径后,既有目录和订阅仍能找到同一个分类;已经建立的整理计划会继续使用计划创建时保存的分类目标,不会被后续策略修改。
|
||||
|
||||
升级时,如果尚未存在 V3 分类策略,系统会读取现有 `category.yaml` 并自动迁移;旧 TMDB 规则的顺序、排除条件、年份范围和兜底语义会保留。迁移完成后不再继续写入 YAML。旧 `GET /api/v1/media/category` 和 `GET /api/v1/media/category/config` 暂时保留为只读投影,旧 `POST /api/v1/media/category/config` 已移除,所有新写入都通过带 revision 校验的策略接口完成。
|
||||
升级时,如果尚未存在 V3 分类策略,系统会读取现有 `category.yaml` 并自动迁移;旧 TMDB 规则的顺序、排除条件、年份范围和“没有规则命中时使用的默认分类”会保留。迁移完成后不再继续写入 YAML。旧 `GET /api/v1/media/category` 和 `GET /api/v1/media/category/config` 暂时保留为只读投影,旧 `POST /api/v1/media/category/config` 已移除,所有新写入都通过带版本号校验的策略接口完成。
|
||||
|
||||
旧分类名称中用于表示目录层级的 `/` 会在迁移时转换为多个安全路径段,分类名称和目录显示结果保持不变;已经完成迁移的策略会在数据库升级时自动修复。
|
||||
|
||||
|
||||
@@ -349,28 +349,6 @@ def test_media_type_fallback_is_used_when_no_category_rule_matches() -> None:
|
||||
assert evaluation.result.effective.source == "fallback"
|
||||
|
||||
|
||||
def test_source_fallback_overrides_only_its_declared_media_source() -> None:
|
||||
payload = _base_policy_payload()
|
||||
payload["source_fallbacks"] = {
|
||||
"themoviedb": {"电影": "movie.hit"},
|
||||
}
|
||||
policy = ClassificationPolicy.model_validate(payload)
|
||||
|
||||
tmdb_evaluation = _evaluate(
|
||||
policy,
|
||||
_facts(media_source="themoviedb"),
|
||||
)
|
||||
douban_evaluation = _evaluate(
|
||||
policy,
|
||||
_facts(media_source="douban"),
|
||||
)
|
||||
|
||||
assert tmdb_evaluation.result.effective.category_id == "movie.hit"
|
||||
assert tmdb_evaluation.result.effective.source == "source_fallback"
|
||||
assert douban_evaluation.result.effective.category_id == "movie.fallback"
|
||||
assert douban_evaluation.result.effective.source == "fallback"
|
||||
|
||||
|
||||
@pytest.mark.parametrize( # type: ignore[misc]
|
||||
("media_type", "media_source", "expected_category"),
|
||||
[
|
||||
@@ -619,24 +597,6 @@ def test_every_enabled_media_type_requires_a_fallback() -> None:
|
||||
_assert_validation_error(payload, "missing_fallback")
|
||||
|
||||
|
||||
@pytest.mark.parametrize( # type: ignore[misc]
|
||||
("source_fallbacks", "error_code"),
|
||||
[
|
||||
({"TheMovieDB": {"电影": "movie.hit"}}, "invalid_fallback_source"),
|
||||
({"themoviedb": {"电影": "missing"}}, "unknown_source_fallback_category"),
|
||||
({"themoviedb": {"电影": "tv.fallback"}}, "source_fallback_media_type_mismatch"),
|
||||
],
|
||||
)
|
||||
def test_source_fallbacks_must_reference_valid_same_type_categories(
|
||||
source_fallbacks: dict[str, dict[str, str]],
|
||||
error_code: str,
|
||||
) -> None:
|
||||
payload = _base_policy_payload()
|
||||
payload["source_fallbacks"] = source_fallbacks
|
||||
|
||||
_assert_validation_error(payload, error_code)
|
||||
|
||||
|
||||
def test_extension_field_namespace_must_match_the_restricted_source() -> None:
|
||||
standard_fields = list(get_standard_classification_fields())
|
||||
field_model = type(standard_fields[0])
|
||||
|
||||
@@ -208,8 +208,8 @@ def test_default_style_config_preserves_order_and_uses_safe_standard_fields() ->
|
||||
assert origin_country.replacement_field == "media.countries"
|
||||
|
||||
|
||||
def test_first_empty_rule_becomes_source_fallback_and_later_entries_are_disabled() -> None:
|
||||
"""首个全空项应成为 TMDB 兜底,后续分类和规则保留但永远禁用。"""
|
||||
def test_first_empty_rule_becomes_global_fallback_and_later_entries_are_disabled() -> None:
|
||||
"""首个全空项应成为媒体类型全局兜底,后续分类和规则保留但永远禁用。"""
|
||||
result = migrate_legacy_category_config(
|
||||
{
|
||||
"movie": {
|
||||
@@ -222,7 +222,7 @@ def test_first_empty_rule_becomes_source_fallback_and_later_entries_are_disabled
|
||||
)
|
||||
legacy_categories = [category for category in result.policy.categories if category.id.startswith("legacy.movie.")]
|
||||
|
||||
assert result.policy.source_fallbacks["themoviedb"]["电影"] == legacy_categories[0].id
|
||||
assert result.policy.fallbacks["电影"] == legacy_categories[0].id
|
||||
assert [category.enabled for category in legacy_categories] == [True, False, False]
|
||||
assert [rule.enabled for rule in result.policy.rules] == [False, False, False]
|
||||
assert [issue.code for issue in result.issues].count("unreachable_legacy_category") == 2
|
||||
@@ -433,8 +433,8 @@ def test_tmdb_fixtures_match_category_helper_directory_classification(
|
||||
assert current_category == legacy_category
|
||||
|
||||
|
||||
def test_non_tmdb_source_uses_common_fallback_instead_of_legacy_source_fallback() -> None:
|
||||
"""非 TMDB 身份不得进入只为旧 category.yaml 保留的来源级兜底。"""
|
||||
def test_non_tmdb_source_uses_the_same_media_type_fallback() -> None:
|
||||
"""媒体类型全局兜底对不同数据源保持一致。"""
|
||||
result = migrate_legacy_category_config(_legacy_config())
|
||||
facts = ClassificationFacts.model_validate(
|
||||
{
|
||||
@@ -447,7 +447,6 @@ def test_non_tmdb_source_uses_common_fallback_instead_of_legacy_source_fallback(
|
||||
evaluation = ClassificationEvaluator.evaluate(result.policy, facts)
|
||||
|
||||
assert evaluation.result.recommended.category_id == result.policy.fallbacks["电影"]
|
||||
assert evaluation.result.recommended.category_id != result.policy.source_fallbacks["themoviedb"]["电影"]
|
||||
assert evaluation.result.recommended.source == "fallback"
|
||||
|
||||
|
||||
@@ -463,7 +462,6 @@ def test_config_without_empty_entry_remains_valid_and_uses_common_fallback() ->
|
||||
evaluation = ClassificationEvaluator.evaluate(result.policy, facts)
|
||||
|
||||
assert result.valid
|
||||
assert result.policy.source_fallbacks == {}
|
||||
assert ClassificationPolicyValidator.validate(result.policy, result.extra_fields).valid
|
||||
assert evaluation.result.recommended.category_id == result.policy.fallbacks["电影"]
|
||||
assert evaluation.result.recommended.source == "fallback"
|
||||
|
||||
@@ -63,7 +63,6 @@ def _policy_state_payload() -> dict[str, object]:
|
||||
"categories": [legacy_category, unchanged_category],
|
||||
"rules": [],
|
||||
"fallbacks": {},
|
||||
"source_fallbacks": {},
|
||||
"field_aliases": {},
|
||||
}
|
||||
history = {
|
||||
|
||||
Reference in New Issue
Block a user