Files
MoviePilot/app/startup/composition/classification.py

309 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""媒体分类策略的启动迁移、持久化适配和运行时组合。"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import Any, Literal, Mapping, cast
import ruamel.yaml
from pydantic import ValidationError
from app.application.classification.configuration import (
ClassificationPolicyConfigurationService,
ClassificationPolicyValidationError,
is_untouched_legacy_default_policy,
with_default_music_classification,
)
from app.application.classification.contract import (
ClassificationPolicyConflictError,
ClassificationPolicyStateCorruptError,
)
from app.application.classification.execution import (
ClassificationEnrichmentPort,
ClassificationExecutionService,
ClassificationExtensionFactsProvider,
)
from app.application.classification.legacy import (
LegacyClassificationDiagnostic,
LegacyClassificationMigrationResult,
legacy_extension_fields_from_policy,
migrate_legacy_category_config,
)
from app.application.classification.reference import (
DirectoryClassificationReferenceValidator,
validate_directory_classification_references,
)
from app.application.classification.runtime import ClassificationRuntime
from app.application.database import AsyncDatabaseExecutor
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
from app.runtime.log import logger
from app.schemas.category import (
CategoryConfig,
ClassificationFieldDefinition,
ClassificationPolicyState,
ClassificationValidationIssue,
)
from app.schemas.types import SystemConfigKey
@dataclass(frozen=True, slots=True)
class ClassificationComposition:
"""保存启动阶段构造的分类运行时及其一次性迁移状态。"""
runtime: ClassificationRuntime
execution: ClassificationExecutionService
migrated: bool
def _composition(
runtime: ClassificationRuntime,
*,
migrated: bool,
extension_facts_provider: ClassificationExtensionFactsProvider | None = None,
enrichment: ClassificationEnrichmentPort | None = None,
) -> ClassificationComposition:
"""为同一分类运行时构造唯一执行服务和组合结果。"""
return ClassificationComposition(
runtime=runtime,
execution=ClassificationExecutionService(
runtime,
extension_facts_provider=extension_facts_provider,
enrichment=enrichment,
),
migrated=migrated,
)
async def compose_classification(
*,
executor: AsyncDatabaseExecutor,
settings: Settings,
system_config: SystemConfigOper,
extra_fields_provider: Callable[
[], Iterable[ClassificationFieldDefinition]
] | None = None,
extension_facts_provider: ClassificationExtensionFactsProvider | None = None,
enrichment: ClassificationEnrichmentPort | None = None,
) -> ClassificationComposition:
"""
构造分类策略服务,并仅在新配置键不存在时读取一次 legacy YAML
已存在但损坏的新策略不会重新读取 YAML 或覆盖数据库事实,运行时保持不可用并暴露诊断。
"""
finish = partial(
_composition,
extension_facts_provider=extension_facts_provider,
enrichment=enrichment,
)
values = system_config.all()
policy_key = SystemConfigKey.MediaClassificationPolicy.value
stored_value = values.get(policy_key)
stored_state: ClassificationPolicyState | None = None
extra_fields: tuple[ClassificationFieldDefinition, ...] = ()
existing_issue: tuple[ClassificationValidationIssue, ...] = ()
if policy_key in values:
try:
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 = (
_issue(
severity="error",
code="classification_policy_corrupt",
message="MediaClassificationPolicy 配置结构无效,已停止自动分类并保留数据库原值",
path=[policy_key],
),
)
reference_validator = DirectoryClassificationReferenceValidator(
lambda: system_config.get(SystemConfigKey.Directories)
)
service = ClassificationPolicyConfigurationService(
SystemConfigClassificationPolicyStore(
SessionFactory,
system_config.publish_many,
validate_directory_classification_references,
),
extra_fields=extra_fields,
extra_fields_provider=extra_fields_provider,
async_executor=executor,
reference_validator=reference_validator,
)
if policy_key in values:
if existing_issue:
return finish(
ClassificationRuntime(service, diagnostics=existing_issue),
migrated=False,
)
try:
await service.async_reload()
except ClassificationPolicyStateCorruptError as error:
issue = _issue(
severity="error",
code="classification_policy_corrupt",
message=str(error),
path=[policy_key],
)
return finish(
ClassificationRuntime(service, diagnostics=(issue,)),
migrated=False,
)
if stored_state is not None and is_untouched_legacy_default_policy(
stored_state
):
try:
await service.async_publish(
with_default_music_classification(stored_state.active),
expected_revision=stored_state.active.revision,
)
except ClassificationPolicyConflictError:
# 多进程同时启动时由首个成功 CAS 的进程完成升级,其余进程刷新事实源。
await service.async_reload()
except ClassificationPolicyValidationError as error:
logger.error("默认音乐分类策略升级未通过校验,保留原策略")
return finish(
ClassificationRuntime(service, diagnostics=tuple(error.result.issues)),
migrated=False,
)
else:
logger.info("已为未编辑的默认分类策略补充常用音乐分类 revision 2")
return finish(
ClassificationRuntime(service),
migrated=True,
)
return finish(
ClassificationRuntime(service),
migrated=False,
)
legacy_path = Path(settings.CONFIG_PATH) / "category.yaml"
if not await executor.run(legacy_path.exists):
await service.async_initialize()
return finish(
ClassificationRuntime(service),
migrated=False,
)
try:
raw_legacy = await executor.run(partial(_load_legacy_yaml, legacy_path))
except Exception as error:
issue = _issue(
severity="error",
code="legacy_category_yaml_invalid",
message=f"旧分类配置读取失败:{error}",
path=[str(legacy_path)],
)
logger.error(f"旧分类策略自动迁移失败:{error}")
return finish(
ClassificationRuntime(service, diagnostics=(issue,)),
migrated=False,
)
migration = migrate_legacy_category_config(raw_legacy)
legacy_config = _legacy_config_snapshot(raw_legacy)
if not migration.valid:
logger.error("旧分类策略无法无损迁移,继续保留 legacy 只读兼容行为")
return finish(
ClassificationRuntime(
service,
legacy_config=legacy_config,
diagnostics=_migration_issues(migration.issues),
),
migrated=False,
)
service.register_extra_fields(migration.extra_fields)
try:
await service.async_initialize(
with_default_music_classification(migration.policy)
)
except ClassificationPolicyValidationError as error:
logger.error("旧分类策略未通过发布校验,继续保留 legacy 只读兼容行为")
return finish(
ClassificationRuntime(
service,
legacy_config=legacy_config,
diagnostics=(*_migration_issues(migration.issues), *error.result.issues),
),
migrated=False,
)
_log_migration_issues(migration)
logger.info("已将 category.yaml 无损迁移为 MediaClassificationPolicy revision 1")
return finish(
ClassificationRuntime(
service,
legacy_config=legacy_config,
diagnostics=_migration_issues(migration.issues),
),
migrated=True,
)
def _load_legacy_yaml(path: Path) -> Mapping[str, Any]:
"""读取保持声明顺序的 legacy YAML 映射,禁止在迁移期间写回文件。"""
yaml_loader = ruamel.yaml.YAML(typ="safe")
with path.open("r", encoding="utf-8", errors="replace") as file:
value = yaml_loader.load(file)
if value is None:
return {}
if not isinstance(value, Mapping):
raise ValueError("category.yaml 顶层必须是映射")
return cast(Mapping[str, Any], value)
def _legacy_config_snapshot(value: Mapping[str, Any]) -> CategoryConfig:
"""为兼容 GET 保存可解析的旧配置快照;异常结构由迁移诊断负责说明。"""
try:
return cast(CategoryConfig, CategoryConfig.model_validate(value))
except ValidationError:
return CategoryConfig()
def _issue(
*,
severity: Literal["error", "warning"],
code: str,
message: str,
path: list[str],
) -> ClassificationValidationIssue:
"""构造启动组合层统一使用的分类诊断。"""
return ClassificationValidationIssue(
severity=severity,
code=code,
message=message,
path=path,
)
def _log_migration_issues(migration: LegacyClassificationMigrationResult) -> None:
"""记录不阻止发布的 legacy 迁移提示。"""
for issue in migration.issues:
if issue.severity == "warning":
logger.warning(f"旧分类策略迁移提示 [{issue.code}]{issue.message}")
def _migration_issues(
diagnostics: tuple[LegacyClassificationDiagnostic, ...],
) -> tuple[ClassificationValidationIssue, ...]:
"""把纯迁移诊断转换为运行时统一诊断 schema。"""
return tuple(
ClassificationValidationIssue(
severity=diagnostic.severity,
code=diagnostic.code,
message=diagnostic.message,
path=list(diagnostic.path),
)
for diagnostic in diagnostics
)