Files
MoviePilot/app/application/classification/runtime.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

132 lines
4.4 KiB
Python

"""
分类策略运行时门面及旧格式只读投影。
"""
from __future__ import annotations
import threading
from typing import Optional
from app.application.classification.configuration import (
ClassificationPolicyConfigurationService,
ClassificationPolicyNotInitializedError,
)
from app.application.classification.contract import ClassificationPolicyConflictError
from app.application.classification.legacy import (
project_policy_to_legacy_category_config,
)
from app.schemas.category import (
CategoryConfig,
ClassificationPolicy,
ClassificationValidationIssue,
MediaCategoryMap,
)
class ClassificationRuntime:
"""
向 API 和业务链暴露当前分类策略,并封装旧格式迁移失败回退。
持久化和 revision 并发控制由配置服务负责;本门面只协调动态字段登记和兼容投影。
"""
def __init__(
self,
service: ClassificationPolicyConfigurationService,
*,
legacy_config: Optional[CategoryConfig] = None,
diagnostics: tuple[ClassificationValidationIssue, ...] = (),
) -> None:
"""
绑定策略服务,并保存未成功迁移时的只读旧格式快照。
"""
self._service = service
self._legacy_config = (legacy_config or CategoryConfig()).model_copy(deep=True)
self._diagnostics = tuple(issue.model_copy(deep=True) for issue in diagnostics)
self._lock = threading.RLock()
@property
def service(self) -> ClassificationPolicyConfigurationService:
"""
返回当前 lifespan 唯一的策略配置服务。
"""
return self._service
def active_policy(self) -> Optional[ClassificationPolicy]:
"""
返回活动策略;尚未成功迁移或初始化时返回 None。
"""
try:
return self._service.active()
except ClassificationPolicyNotInitializedError:
return None
def require_policy(self) -> ClassificationPolicy:
"""
返回活动策略;不可用时保留配置服务的明确未初始化异常。
"""
return self._service.active()
def diagnostics(self) -> tuple[ClassificationValidationIssue, ...]:
"""
返回与内部引用隔离的当前迁移或发布诊断。
"""
with self._lock:
return tuple(issue.model_copy(deep=True) for issue in self._diagnostics)
def legacy_config(self) -> CategoryConfig:
"""
把活动策略投影为旧配置;策略不可用时返回启动时旧格式快照。
"""
policy = self.active_policy()
if policy is not None:
return project_policy_to_legacy_category_config(policy)
with self._lock:
return self._legacy_config.model_copy(deep=True)
def media_categories(self) -> MediaCategoryMap:
"""
按策略顺序返回电影、电视剧和音乐的启用分类名称。
"""
categories: dict[str, list[str]] = {
"电影": [],
"电视剧": [],
"音乐": [],
}
policy = self.active_policy()
if policy is None:
legacy = self.legacy_config()
categories["电影"] = list((legacy.movie or {}).keys())
categories["电视剧"] = list((legacy.tv or {}).keys())
return MediaCategoryMap(categories)
for category in policy.categories:
if category.enabled and category.name not in categories[category.media_type]:
categories[category.media_type].append(category.name)
return MediaCategoryMap(categories)
async def publish_policy(
self,
policy: ClassificationPolicy,
*,
expected_revision: int,
) -> ClassificationPolicy:
"""
发布新版策略;未迁移的空存储允许以 revision 0 初始化恢复。
"""
current = self.active_policy()
if current is None:
if expected_revision != 0:
raise ClassificationPolicyConflictError(
expected_revision=expected_revision,
current_revision=0,
)
published = await self._service.async_initialize(policy)
else:
published = await self._service.async_publish(
policy,
expected_revision=expected_revision,
)
with self._lock:
self._diagnostics = ()
return published