"""旧版 TMDB 分类配置到新版分类策略的纯迁移。""" from __future__ import annotations import hashlib import re 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, ClassificationCategory, ClassificationCondition, ClassificationConditionGroup, ClassificationConditionNode, ClassificationFieldDefinition, ClassificationMediaType, ClassificationPolicy, ClassificationRule, ClassificationRuleKind, ClassificationTarget, ) LegacyMediaKey: TypeAlias = Literal["movie", "tv"] """旧分类配置支持的一级媒体类型键。""" LegacyDiagnosticSeverity: TypeAlias = Literal["error", "warning"] """旧配置迁移诊断的严重级别。""" LegacyDiagnosticPathPart: TypeAlias = Union[str, int] """旧配置迁移诊断路径允许的段类型。""" _TMDB_SOURCE: Final[str] = "themoviedb" _EXTENSION_PREFIX: Final[str] = f"extensions.{_TMDB_SOURCE}." _ARCHIVED_CATEGORY_LABEL_PREFIX: Final[str] = "legacy-category:" _SAFE_FIELD_SEGMENT = re.compile(r"^[a-z][a-z0-9_]*$") _ILLEGAL_PATH_CHARACTERS: Final[frozenset[str]] = frozenset('<>:"/\\|?*') _MEDIA_TYPES: Final[dict[LegacyMediaKey, ClassificationMediaType]] = { "movie": "电影", "tv": "电视剧", } _MEDIA_KEYS: Final[dict[ClassificationMediaType, LegacyMediaKey]] = { "电影": "movie", "电视剧": "tv", } _COMMON_FALLBACKS: Final[dict[ClassificationMediaType, str]] = { "电影": "movie.uncategorized", "电视剧": "tv.uncategorized", "音乐": "music.uncategorized", } _LEGACY_FIELD_PRESENTATION: Final[dict[str, tuple[str, str]]] = { "genre_ids": ("风格(旧规则)", "media.genre_keys"), "origin_country": ("原产国家/地区(旧规则)", "media.countries"), } @dataclass(frozen=True, slots=True) class LegacyClassificationDiagnostic: """描述旧配置迁移或兼容投影中的结构化错误与警告。""" severity: LegacyDiagnosticSeverity code: str message: str path: tuple[LegacyDiagnosticPathPart, ...] = () @dataclass(frozen=True, slots=True) class LegacyClassificationMigrationResult: """承载旧配置迁移后的策略、动态字段目录和全部诊断。""" policy: ClassificationPolicy extra_fields: tuple[ClassificationFieldDefinition, ...] issues: tuple[LegacyClassificationDiagnostic, ...] @property def valid(self) -> bool: """返回迁移结果是否不存在阻止自动发布的错误。""" return not any(item.severity == "error" for item in self.issues) @property def field_definitions(self) -> tuple[ClassificationFieldDefinition, ...]: """兼容返回迁移生成的动态字段声明。""" return self.extra_fields @property def diagnostics(self) -> tuple[LegacyClassificationDiagnostic, ...]: """兼容返回迁移生成的结构化诊断。""" return self.issues @property def publishable(self) -> bool: """兼容返回迁移结果是否允许自动发布。""" return self.valid @dataclass(frozen=True, slots=True) class _LegacyToken: """保留旧字段展开后的同向值集合及其排除语义。""" negative: bool values: tuple[str, ...] @dataclass(slots=True) class _MigrationContext: """在一次纯迁移中收集动态字段、值别名和结构化诊断。""" field_media_types: dict[str, list[ClassificationMediaType]] = field(default_factory=dict) field_aliases: dict[str, dict[str, str]] = field(default_factory=dict) diagnostics: list[LegacyClassificationDiagnostic] = field(default_factory=list) def add_diagnostic( self, severity: LegacyDiagnosticSeverity, code: str, message: str, path: Sequence[LegacyDiagnosticPathPart], ) -> None: """追加一条保持稳定路径的迁移诊断。""" self.diagnostics.append( LegacyClassificationDiagnostic( severity=severity, code=code, message=message, path=tuple(path), ) ) def register_extension_field( self, field_name: str, media_type: ClassificationMediaType, ) -> str: """登记一个受控 TMDB 旧比较字段并合并适用影视类型。""" field_id = f"{_EXTENSION_PREFIX}{field_name}" media_types = self.field_media_types.setdefault(field_id, []) if media_type not in media_types: media_types.append(media_type) return field_id def register_aliases(self, field_id: str, values: Iterable[str]) -> None: """把旧配置原始大小写映射到旧算法使用的大写比较值。""" aliases = self.field_aliases.setdefault(field_id, {}) for value in values: normalized = value.upper() if value != normalized: aliases[value] = normalized def build_field_definitions(self) -> tuple[ClassificationFieldDefinition, ...]: """按首次出现顺序构造仅供已有规则解析的退役字段声明。""" return tuple( _legacy_field_definition(field_id, media_types) for field_id, media_types in self.field_media_types.items() ) def migrate_legacy_category_config( config: Union[CategoryConfig, Mapping[str, object]], ) -> LegacyClassificationMigrationResult: """ 把内存中的旧分类配置转换为按媒体类型匹配的新版策略草稿 :param config: 已校验的 CategoryConfig 或保持 YAML 顺序的映射 :return: 包含策略、动态字段声明和结构化诊断的纯迁移结果 """ context = _MigrationContext() root = _legacy_root_mapping(config) _diagnose_unknown_top_level_keys(root, context) categories: list[ClassificationCategory] = [] rules: list[ClassificationRule] = [] fallbacks = dict(_COMMON_FALLBACKS) for media_key, media_type in _MEDIA_TYPES.items(): _migrate_media_categories( media_key=media_key, media_type=media_type, raw_categories=root.get(media_key), categories=categories, rules=rules, fallbacks=fallbacks, context=context, ) categories.extend(_common_fallback_categories(categories, fallbacks)) policy_payload: dict[str, object] = { "schema_version": 2, "revision": 1, "mode": "first_match", "categories": categories, "rules": rules, "fallbacks": fallbacks, "field_aliases": {field_id: aliases for field_id, aliases in context.field_aliases.items() if aliases}, } policy = ClassificationPolicy.model_validate(policy_payload) return LegacyClassificationMigrationResult( policy=policy, extra_fields=context.build_field_definitions(), issues=tuple(context.diagnostics), ) def legacy_extension_fields_from_policy( policy: ClassificationPolicy, ) -> tuple[ClassificationFieldDefinition, ...]: """按策略条件顺序重建可直接注册的 TMDB 旧比较扩展字段声明。""" context = _MigrationContext() for rule in policy.rules: for field_id in _condition_field_ids(rule.when): if not field_id.startswith(_EXTENSION_PREFIX): continue field_name = field_id.removeprefix(_EXTENSION_PREFIX) if not _SAFE_FIELD_SEGMENT.fullmatch(field_name): continue for media_type in rule.media_types: context.register_extension_field(field_name, media_type) return context.build_field_definitions() def _legacy_root_mapping( config: Union[CategoryConfig, Mapping[str, object]], ) -> Mapping[str, object]: """把强类型旧配置投影为不访问文件的有序根映射。""" if isinstance(config, CategoryConfig): return { "movie": config.movie or {}, "tv": config.tv or {}, } return config def _diagnose_unknown_top_level_keys( root: Mapping[str, object], context: _MigrationContext, ) -> None: """拒绝旧契约未定义的一级媒体类型,避免静默丢失配置。""" for key in root: if key in _MEDIA_TYPES: continue context.add_diagnostic( "error", "unsupported_legacy_media_type", f"旧分类配置一级键 {key!s} 不受支持", [str(key)], ) def _migrate_media_categories( *, media_key: LegacyMediaKey, media_type: ClassificationMediaType, raw_categories: object, categories: list[ClassificationCategory], rules: list[ClassificationRule], fallbacks: dict[ClassificationMediaType, str], context: _MigrationContext, ) -> None: """按单个旧媒体类型的原始顺序迁移分类、规则和全局兜底。""" if raw_categories is None: return if not isinstance(raw_categories, Mapping): context.add_diagnostic( "error", "invalid_legacy_category_map", f"旧分类配置 {media_key} 必须是分类名称映射", [media_key], ) return fallback_seen = False for category_index, (raw_name, raw_rule) in enumerate(raw_categories.items()): name = str(raw_name) base_path: list[LegacyDiagnosticPathPart] = [media_key, name] category_id = _stable_category_id(media_key, name) unreachable = fallback_seen _diagnose_category_name(raw_name, name, base_path, context) categories.append( ClassificationCategory( id=category_id, media_type=media_type, name=name, path=_legacy_category_path(name), enabled=not unreachable, ) ) if unreachable: context.add_diagnostic( "warning", "unreachable_legacy_category", f"分类 {name} 位于首个空兜底之后,按旧顺序永远不可达,已保留并禁用", base_path, ) rule_mapping = _legacy_rule_mapping(raw_rule) if _is_legacy_fallback(raw_rule, rule_mapping): if not fallback_seen: fallbacks[media_type] = category_id fallback_seen = True if rule_mapping is not None: rules.append( _fallback_metadata_rule( category_id=category_id, category_name=name, media_type=media_type, priority=len(rules), rule_mapping=rule_mapping, path=base_path, archived=unreachable, context=context, ) ) elif unreachable: rules.append( _disabled_placeholder_rule( category_id=category_id, category_name=name, media_type=media_type, priority=len(rules), archived=True, ) ) continue if rule_mapping is None: context.add_diagnostic( "error", "invalid_legacy_category_rule", f"分类 {name} 的规则必须是字段映射或空值", base_path, ) rules.append( _disabled_placeholder_rule( category_id=category_id, category_name=name, media_type=media_type, priority=len(rules), archived=unreachable, ) ) continue nodes: list[ClassificationConditionNode] = [] category_has_error = False for raw_field, raw_value in rule_mapping.items(): if not raw_value: continue field_path = [*base_path, str(raw_field)] before_errors = _error_count(context.diagnostics) node = _migrate_legacy_field( raw_field=raw_field, raw_value=raw_value, media_type=media_type, path=field_path, context=context, ) category_has_error = category_has_error or _error_count(context.diagnostics) > before_errors if node is not None: nodes.append(node) if not nodes: nodes.append(_tmdb_identity_condition()) category_has_error = True rule_kind, target = _retained_rule_output(category_id, unreachable) rules.append( ClassificationRule( id=f"{category_id}.rule", name=name, kind=rule_kind, enabled=not unreachable and not category_has_error, priority=category_index, media_types=[media_type], sources=[_TMDB_SOURCE], when=_all_or_single(nodes), target=target, ) ) def _legacy_rule_mapping(raw_rule: object) -> Optional[Mapping[object, object]]: """把 CategoryRule 或原始映射统一为只读字段映射。""" if isinstance(raw_rule, CategoryRule): return cast( Mapping[object, object], raw_rule.model_dump(exclude_none=False, exclude_unset=True), ) if isinstance(raw_rule, Mapping): return raw_rule return None def _is_legacy_fallback( raw_rule: object, rule_mapping: Optional[Mapping[object, object]], ) -> bool: """复现旧实现对空项及所有字段均为空映射的立即命中语义。""" if not raw_rule: return True return rule_mapping is not None and all(not value for value in rule_mapping.values()) def _diagnose_category_name( raw_name: object, name: str, path: Sequence[LegacyDiagnosticPathPart], context: _MigrationContext, ) -> None: """在仍保留分类的同时标记无法安全投影为目录路径的名称。""" invalid = ( not isinstance(raw_name, str) or not name or name != name.strip() or any(_legacy_path_segment_is_invalid(segment) for segment in _legacy_category_path(name)) ) if invalid: context.add_diagnostic( "error", "invalid_legacy_category_name", f"分类名称 {name!r} 不能安全投影为目录路径", path, ) def _legacy_category_path(name: str) -> list[str]: """把旧分类名中的斜杠还原为目录层级,同时保留原始显示名称。""" return name.split("/") def _legacy_path_segment_is_invalid(segment: str) -> bool: """判断旧分类名拆出的目录段是否违反跨平台路径安全约束。""" illegal_characters = _ILLEGAL_PATH_CHARACTERS - frozenset({"/"}) return ( not segment or segment in {".", ".."} or segment != segment.strip() or segment.endswith((".", " ")) or any(character in illegal_characters or ord(character) < 32 for character in segment) ) def _migrate_legacy_field( *, raw_field: object, raw_value: object, media_type: ClassificationMediaType, path: Sequence[LegacyDiagnosticPathPart], context: _MigrationContext, ) -> Optional[ClassificationConditionNode]: """把一个旧字段编译为保持正值 OR、排除值 AND 的条件树。""" if not isinstance(raw_field, str) or not _SAFE_FIELD_SEGMENT.fullmatch(raw_field): context.add_diagnostic( "error", "invalid_legacy_field", f"旧字段 {raw_field!s} 不是安全的小写 TMDB 一级字段段", path, ) return None if not isinstance(raw_value, str): context.add_diagnostic( "error", "unsupported_legacy_value", f"旧字段 {raw_field} 的值必须是逗号分隔字符串", path, ) return None tokens, requires_exists = _parse_legacy_tokens(raw_value) if raw_field == "genre_ids": return _migrate_genre_tokens( tokens=tokens, requires_exists=requires_exists, media_type=media_type, context=context, ) field_id = context.register_extension_field(raw_field, media_type) context.register_aliases( field_id, (value for token in tokens for value in token.values), ) return _legacy_list_condition(field_id, tokens, requires_exists) def _legacy_field_definition( field_id: str, media_types: list[ClassificationMediaType], ) -> ClassificationFieldDefinition: """构造不会出现在新规则选择器中的旧 TMDB 字段说明。""" field_name = field_id.removeprefix(_EXTENSION_PREFIX) 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";新增条件请使用{presentation[0].replace('(旧规则)', '')}" if presentation else "" return ClassificationFieldDefinition( id=field_id, label=label, group="旧规则", 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, ) def _parse_legacy_tokens(value: str) -> tuple[tuple[_LegacyToken, ...], bool]: """沿用旧范围展开语义,并合并同向枚举,避免值数量膨胀为叶子数量。""" raw_tokens = [item for item in value.split(",") if item] values_by_sign: dict[bool, list[str]] = {} requires_exists = not raw_tokens for raw_token in raw_tokens: expanded = _expand_legacy_token(raw_token) if not expanded: requires_exists = True continue for expanded_value in expanded: negative = expanded_value.startswith("!") plain_value = expanded_value[1:] if negative else expanded_value values_by_sign.setdefault(negative, []).append(plain_value) return tuple(_LegacyToken(negative, tuple(values)) for negative, values in values_by_sign.items()), requires_exists def _expand_legacy_token(value: str) -> tuple[str, ...]: """复现旧代码对数字闭区间和非数字连字符端点的展开。""" if "-" not in value: return (value,) value_begin, value_end = value.split("-", 1) prefix = "" if value_begin.startswith("!"): prefix = "!" value_begin = value_begin[1:] if value_begin.isdigit() and value_end.isdigit(): return tuple(f"{prefix}{item}" for item in range(int(value_begin), int(value_end) + 1)) return (f"{prefix}{value_begin}", f"{prefix}{value_end}") def _legacy_list_condition( field_id: str, tokens: Sequence[_LegacyToken], requires_exists: bool, ) -> ClassificationConditionNode: """把旧列表成员条件编译为正项 OR、负项逐组排除的条件树。""" positives = [ ClassificationCondition( field=field_id, operator="contains_any", value=list(token.values), ) for token in tokens if not token.negative ] negatives = [ ClassificationCondition( field=field_id, operator="contains_none", value=list(token.values), ) for token in tokens if token.negative ] nodes: list[ClassificationConditionNode] = [] if positives: nodes.append(_any_or_single(positives)) nodes.extend(negatives) if not nodes and requires_exists: return ClassificationCondition(field=field_id, operator="exists") return _all_or_single(nodes) def _migrate_genre_tokens( *, tokens: Sequence[_LegacyToken], requires_exists: bool, media_type: ClassificationMediaType, context: _MigrationContext, ) -> ClassificationConditionNode: """已知正向 Genre ID 使用规范风格,其余条件保留原始视图。""" positive_nodes: list[ClassificationConditionNode] = [] negative_nodes: list[ClassificationConditionNode] = [] extension_field_id: Optional[str] = None for token in tokens: if token.negative: if extension_field_id is None: extension_field_id = context.register_extension_field("genre_ids", media_type) context.register_aliases(extension_field_id, token.values) negative_nodes.append( ClassificationCondition( field=extension_field_id, operator="contains_none", value=list(token.values), ) ) continue known_keys: list[str] = [] unknown_ids: list[str] = [] for value in token.values: if genre_key := _TMDB_GENRE_KEYS.get(value.upper()): _append_unique(known_keys, genre_key) else: _append_unique(unknown_ids, value) token_nodes: list[ClassificationConditionNode] = [] if known_keys: token_nodes.append( ClassificationCondition( field="media.genre_keys", operator="contains_any", value=known_keys, ) ) if unknown_ids: if extension_field_id is None: extension_field_id = context.register_extension_field("genre_ids", media_type) context.register_aliases(extension_field_id, unknown_ids) token_nodes.append( ClassificationCondition( field=extension_field_id, operator="contains_any", value=unknown_ids, ) ) if token_nodes: positive_nodes.append(_any_or_single(token_nodes)) nodes: list[ClassificationConditionNode] = [] if positive_nodes: nodes.append(_any_or_single(positive_nodes)) nodes.extend(negative_nodes) if not nodes and requires_exists: extension_field_id = context.register_extension_field("genre_ids", media_type) return ClassificationCondition(field=extension_field_id, operator="exists") return _all_or_single(nodes) def _all_or_single( nodes: Sequence[ClassificationConditionNode], ) -> ClassificationConditionNode: """合并相邻 all 组并避免为单节点额外增加条件树深度。""" flattened: list[ClassificationConditionNode] = [] for node in nodes: if isinstance(node, ClassificationConditionGroup) and node.all is not None: flattened.extend(node.all) else: flattened.append(node) if len(flattened) == 1: return flattened[0] return ClassificationConditionGroup(all=flattened) def _any_or_single( nodes: Sequence[ClassificationConditionNode], ) -> ClassificationConditionNode: """合并相邻 any 组并避免为单节点额外增加条件树深度。""" flattened: list[ClassificationConditionNode] = [] for node in nodes: if isinstance(node, ClassificationConditionGroup) and node.any is not None: flattened.extend(node.any) else: flattened.append(node) if len(flattened) == 1: return flattened[0] return ClassificationConditionGroup(any=flattened) def _tmdb_identity_condition() -> ClassificationCondition: """为不可迁移但需保留的禁用规则构造稳定占位条件。""" return ClassificationCondition( field="identity.media_source", operator="equals", value=_TMDB_SOURCE, ) def _disabled_placeholder_rule( *, category_id: str, category_name: str, media_type: ClassificationMediaType, priority: int, archived: bool, ) -> ClassificationRule: """保留不可达或非法旧分类的稳定规则身份,同时禁止执行。""" rule_kind, target = _retained_rule_output(category_id, archived) return ClassificationRule( id=f"{category_id}.rule", name=category_name, kind=rule_kind, enabled=False, priority=priority, media_types=[media_type], sources=[_TMDB_SOURCE], when=_tmdb_identity_condition(), target=target, ) def _fallback_metadata_rule( *, category_id: str, category_name: str, media_type: ClassificationMediaType, priority: int, rule_mapping: Mapping[object, object], path: Sequence[LegacyDiagnosticPathPart], archived: bool, context: _MigrationContext, ) -> ClassificationRule: """用禁用规则保留全空字段映射,运行时由全局兜底处理。""" nodes: list[ClassificationConditionNode] = [] for raw_field in rule_mapping: field_path = [*path, str(raw_field)] if not isinstance(raw_field, str) or not _SAFE_FIELD_SEGMENT.fullmatch(raw_field): context.add_diagnostic( "error", "invalid_legacy_field", f"旧字段 {raw_field!s} 不是安全的小写 TMDB 一级字段段", field_path, ) continue field_id = context.register_extension_field(raw_field, media_type) nodes.append(ClassificationCondition(field=field_id, operator="not_exists")) rule_kind, target = _retained_rule_output(category_id, archived) return ClassificationRule( id=f"{category_id}.fallback", name=category_name, kind=rule_kind, enabled=False, priority=priority, media_types=[media_type], sources=[_TMDB_SOURCE], when=_all_or_single(nodes) if nodes else _tmdb_identity_condition(), target=target, ) def _retained_rule_output( category_id: str, archived: bool, ) -> tuple[ClassificationRuleKind, ClassificationTarget]: """为不可达规则生成不引用 disabled 分类的稳定归档输出。""" if archived: return ( "label", ClassificationTarget(labels=[f"{_ARCHIVED_CATEGORY_LABEL_PREFIX}{category_id}"]), ) return "category", ClassificationTarget(category_id=category_id) def _stable_category_id(media_key: LegacyMediaKey, name: str) -> str: """以媒体类型和原始名称生成不依赖本地字符集的稳定 ASCII ID。""" digest = hashlib.sha256(f"{media_key}\0{name}".encode("utf-8")).hexdigest()[:16] return f"legacy.{media_key}.{digest}" 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("通用") categories.append( ClassificationCategory( id=category_id, media_type=media_type, name="未分类", path=path, ) ) return categories def _error_count(diagnostics: Sequence[LegacyClassificationDiagnostic]) -> int: """返回当前迁移诊断中的错误数量。""" return sum(item.severity == "error" for item in diagnostics) def _condition_field_ids(node: ClassificationConditionNode) -> list[str]: """按条件树顺序提取全部叶子字段 ID。""" if isinstance(node, ClassificationCondition): return [node.field] if node.all is not None: children = node.all elif node.any is not None: children = node.any elif node.not_ is not None: children = [node.not_] else: children = [] return [field_id for child in children for field_id in _condition_field_ids(child)] def _append_unique(values: list[str], value: str) -> None: """按首次出现顺序追加非重复字符串。""" if value not in values: values.append(value)