Files
MoviePilot/app/application/classification/execution.py

598 lines
21 KiB
Python

"""完整媒体识别结果的分类执行与兼容回退服务。"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from copy import deepcopy
from enum import Enum
from typing import Final, Protocol, TypeAlias, cast
from app.application.classification.legacy import (
build_legacy_tmdb_extension_facts,
resolve_legacy_tmdb_category,
)
from app.domain.classification.evaluator import ClassificationEvaluator
from app.domain.classification.facts import build_classification_facts
from app.domain.context import MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.schemas.category import (
CategoryConfig,
ClassificationEvaluation,
ClassificationFacts,
ClassificationFactValue,
ClassificationPolicy,
ClassificationResult,
ClassificationSelection,
)
ClassificationSubject: TypeAlias = (
MediaInfo | MusicInfo | MusicAlbumInfo | MusicArtistInfo
)
"""可以在完整识别出口执行自动分类的标准媒体对象。"""
ClassificationExtensionFactsProvider: TypeAlias = Callable[
[ClassificationSubject],
Mapping[str, Mapping[str, ClassificationFactValue]],
]
"""按当前插件注册表校验并提供来源扩展分类事实的端口。"""
_LEGACY_TMDB_SOURCE: Final[str] = "themoviedb"
_LEGACY_RULE_PREFIX: Final[str] = "legacy."
class ClassificationRuntimePort(Protocol):
"""分类执行只需要的活动策略与 legacy 快照端口。"""
def active_policy(self) -> ClassificationPolicy | None:
"""返回活动策略;配置不可用时返回空值。"""
...
def legacy_config(self) -> CategoryConfig:
"""返回隔离的 legacy 配置快照。"""
...
class ClassificationExecutionPort(Protocol):
"""Chain、订阅和整理应用层共享的纯分类执行端口。"""
async def async_build_facts(
self,
media: ClassificationSubject,
*,
policy: ClassificationPolicy | None = None,
) -> ClassificationFacts | None:
"""按指定或活动策略异步准备事实,供预览和新旧策略比较复用。"""
...
def build_facts(
self,
media: ClassificationSubject,
*,
policy: ClassificationPolicy | None = None,
) -> ClassificationFacts | None:
"""同步构造与实际分类一致的完整事实快照,不写入媒体或策略。"""
...
def finalize(
self,
media: ClassificationSubject,
*,
extensions: Mapping[
str,
Mapping[str, ClassificationFactValue],
]
| None = None,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
allow_enrichment: bool = True,
) -> ClassificationSubject:
"""分类完整媒体;离线整理可禁止外部事实补充,仍执行当前用户分类策略。"""
...
async def async_finalize(
self,
media: ClassificationSubject,
*,
extensions: Mapping[
str,
Mapping[str, ClassificationFactValue],
]
| None = None,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
allow_enrichment: bool = True,
) -> ClassificationSubject:
"""异步分类完整媒体对象,并允许有界补充缺失标准事实。"""
...
class ClassificationEnrichmentPort(Protocol):
"""分类执行服务消费的同步与异步缺失事实补充端口。"""
def enrich(
self,
policy: ClassificationPolicy,
facts: ClassificationFacts,
media: object,
) -> ClassificationFacts:
"""同步补充缺失标准事实。"""
...
async def async_enrich(
self,
policy: ClassificationPolicy,
facts: ClassificationFacts,
media: object,
) -> ClassificationFacts:
"""异步补充缺失标准事实。"""
...
class ClassificationExecutionService:
"""按当前不可变策略分类完整媒体对象,并隔离来源缓存对象。"""
def __init__(
self,
runtime: ClassificationRuntimePort,
*,
extension_facts_provider: ClassificationExtensionFactsProvider | None = None,
enrichment: ClassificationEnrichmentPort | None = None,
) -> None:
"""保存策略运行时、扩展事实校验端口和可选跨来源补充服务。"""
self._runtime = runtime
self._extension_facts_provider = extension_facts_provider
self._enrichment = enrichment
async def async_build_facts(
self,
media: ClassificationSubject,
*,
policy: ClassificationPolicy | None = None,
) -> ClassificationFacts | None:
"""按指定或活动策略补充事实,隔离候选策略与原媒体对象。"""
finalized, policy, facts, _ = self._prepare(
media,
extensions=None,
effective_override=None,
refresh=False,
policy_override=policy,
)
if policy is None or facts is None:
return None
if self._enrichment is not None:
try:
facts = await self._enrichment.async_enrich(policy, facts, finalized)
except Exception: # noqa: BLE001 详情补充失败时保留主来源事实
pass
return facts
def build_facts(
self,
media: ClassificationSubject,
*,
policy: ClassificationPolicy | None = None,
) -> ClassificationFacts | None:
"""同步构造并按当前策略补充缺失事实,不修改媒体对象。"""
finalized, active_policy, facts, _ = self._prepare(
media,
extensions=None,
effective_override=None,
refresh=True,
policy_override=policy,
)
if active_policy is None or facts is None:
return None
policy = policy or active_policy
if self._enrichment is not None:
try:
facts = self._enrichment.enrich(policy, facts, finalized)
except Exception: # noqa: BLE001 详情补充失败时保留主来源事实
pass
return facts
def finalize(
self,
media: ClassificationSubject,
*,
extensions: Mapping[
str,
Mapping[str, ClassificationFactValue],
]
| None = None,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
allow_enrichment: bool = True,
) -> ClassificationSubject:
"""复制并分类完整结果;离线调用可禁用外部补充,但仍应用当前策略。"""
finalized, policy, facts, effective_override = self._prepare(
media,
extensions=extensions,
effective_override=effective_override,
refresh=refresh,
allow_local_music=not allow_enrichment,
)
if policy is None or facts is None:
return finalized
if allow_enrichment and self._enrichment is not None:
try:
facts = self._enrichment.enrich(policy, facts, finalized)
except Exception: # noqa: BLE001 分类补充不得成为识别硬依赖
pass
return self._apply_evaluation(
finalized,
policy,
facts,
effective_override=effective_override,
)
async def async_finalize(
self,
media: ClassificationSubject,
*,
extensions: Mapping[
str,
Mapping[str, ClassificationFactValue],
]
| None = None,
effective_override: ClassificationSelection | None = None,
refresh: bool = False,
allow_enrichment: bool = True,
) -> ClassificationSubject:
"""异步分类完整结果,支持只使用本地事实,补充失败时保留原始事实。"""
finalized, policy, facts, effective_override = self._prepare(
media,
extensions=extensions,
effective_override=effective_override,
refresh=refresh,
allow_local_music=not allow_enrichment,
)
if policy is None or facts is None:
return finalized
if allow_enrichment and self._enrichment is not None:
try:
facts = await self._enrichment.async_enrich(
policy,
facts,
finalized,
)
except Exception: # noqa: BLE001 分类补充不得成为识别硬依赖
pass
return self._apply_evaluation(
finalized,
policy,
facts,
effective_override=effective_override,
)
def _prepare(
self,
media: ClassificationSubject,
*,
extensions: Mapping[str, Mapping[str, ClassificationFactValue]] | None,
effective_override: ClassificationSelection | None,
refresh: bool,
policy_override: ClassificationPolicy | None = None,
allow_local_music: bool = False,
) -> tuple[
ClassificationSubject,
ClassificationPolicy | None,
ClassificationFacts | None,
ClassificationSelection | None,
]:
"""复制媒体、读取一次策略并构造同步异步共用的初始事实。"""
del refresh
effective_override = effective_override or _explicit_effective_override(media)
finalized = deepcopy(media)
policy = policy_override or self._runtime.active_policy()
if policy is None:
self._apply_invalid_policy_fallback(
finalized,
effective_override=effective_override,
)
return finalized, None, None, effective_override
registered_extensions = self._registered_extensions(finalized)
merged_extensions = _classification_extensions(
policy,
finalized,
_merge_supplied_extensions(registered_extensions, extensions),
)
try:
facts = build_classification_facts(
finalized,
extensions=merged_extensions,
allow_local_music=allow_local_music,
)
except ValueError:
finalized.set_library_category("")
finalized.classification = ClassificationResult(
effective=(
effective_override.model_copy(deep=True)
if effective_override
else None
),
policy_revision=policy.revision,
state="not_evaluated",
)
if effective_override:
finalized.set_library_category(
_category_path_snapshot(effective_override)
)
return finalized, policy, None, effective_override
return finalized, policy, facts, effective_override
@staticmethod
def _apply_evaluation(
finalized: ClassificationSubject,
policy: ClassificationPolicy,
facts: ClassificationFacts,
*,
effective_override: ClassificationSelection | None,
) -> ClassificationSubject:
"""应用纯求值结果、降级保护和人工覆盖,并更新兼容目录分类。"""
evaluation = evaluate_classification_facts(policy, facts)
result = evaluation.result.model_copy(deep=True)
carried = _degraded_automatic_selection(finalized, policy, result)
if carried is not None:
result.effective = carried
if effective_override:
result.effective = effective_override.model_copy(deep=True)
finalized.classification = result
selection = result.effective or result.recommended
finalized.set_library_category(
_category_path_snapshot(selection) if selection else ""
)
return finalized
def _registered_extensions(
self,
media: ClassificationSubject,
) -> Mapping[str, Mapping[str, ClassificationFactValue]]:
"""隔离可选插件事实端口故障,媒体识别结果仍按缺失字段继续分类。"""
if self._extension_facts_provider is None:
return {}
try:
return self._extension_facts_provider(media)
except Exception: # noqa: BLE001 扩展事实不得成为识别硬依赖
return {}
def finalize_many(
self,
media_items: Mapping[str, ClassificationSubject],
) -> dict[str, ClassificationSubject]:
"""逐项分类路径映射,保持键顺序并隔离每个来源结果。"""
return {
key: self.finalize(media)
for key, media in media_items.items()
}
def _apply_invalid_policy_fallback(
self,
media: ClassificationSubject,
*,
effective_override: ClassificationSelection | None,
) -> None:
"""策略不可用时仅恢复 legacy TMDB 目录分类并标记失效状态。"""
media.set_library_category("")
legacy_category = resolve_legacy_tmdb_category(
self._runtime.legacy_config(),
media_type=_enum_text(getattr(media, "type", None)),
media_source=_enum_text(getattr(media, "media_source", None)),
tmdb_info=getattr(media, "tmdb_info", None),
)
selection = (
ClassificationSelection(
category_path=[legacy_category],
source="legacy",
)
if legacy_category
else None
)
media.classification = ClassificationResult(
recommended=selection,
effective=(
effective_override.model_copy(deep=True)
if effective_override
else selection
),
state="invalid_policy",
)
effective = effective_override or selection
if effective:
media.set_library_category(_category_path_snapshot(effective))
def _evaluate_legacy_tmdb_compatibility(
policy: ClassificationPolicy,
facts: ClassificationFacts,
*,
trace: bool = False,
) -> ClassificationEvaluation | None:
"""让旧 TMDB 分类规则消费非 TMDB 来源已经拥有的标准事实。"""
if facts.identity.media_source == _LEGACY_TMDB_SOURCE:
return None
legacy_rules = [
rule.model_copy(deep=True, update={"sources": []})
for rule in policy.rules
if rule.id.startswith(_LEGACY_RULE_PREFIX)
]
if not legacy_rules:
return None
compatibility_policy = policy.model_copy(
deep=True,
update={"rules": legacy_rules},
)
compatibility_facts = _legacy_tmdb_compatibility_facts(policy, facts)
return ClassificationEvaluator.evaluate(
compatibility_policy,
compatibility_facts,
trace=trace,
)
def _legacy_tmdb_compatibility_facts(
policy: ClassificationPolicy,
facts: ClassificationFacts,
) -> ClassificationFacts:
"""把跨来源标准字段投影到旧规则的 TMDB 扩展命名空间。"""
extensions = {
str(source): {str(key): value for key, value in values.items()}
for source, values in facts.extensions.items()
}
legacy_info = _legacy_tmdb_info_from_standard_facts(facts)
for source, values in build_legacy_tmdb_extension_facts(
policy,
legacy_info,
).items():
target = extensions.setdefault(source, {})
for field, value in values.items():
target.setdefault(field, value)
return cast(
ClassificationFacts,
facts.model_copy(
deep=True,
update={"extensions": extensions},
),
)
def evaluate_classification_facts(
policy: ClassificationPolicy,
facts: ClassificationFacts,
*,
trace: bool = False,
) -> ClassificationEvaluation:
"""按真实执行路径求值事实,并兼容非 TMDB 来源的旧 TMDB 规则。"""
evaluation = ClassificationEvaluator.evaluate(policy, facts, trace=trace)
legacy_evaluation = _evaluate_legacy_tmdb_compatibility(policy, facts, trace=trace)
if legacy_evaluation is not None and _uses_fallback(evaluation.result):
return legacy_evaluation
return evaluation
def _legacy_tmdb_info_from_standard_facts(
facts: ClassificationFacts,
) -> dict[str, object]:
"""构造旧规则投影所需的有限 TMDB 字段,不伪造缺失事实。"""
media = facts.media
info: dict[str, object] = {}
if media.language:
info["original_language"] = media.language
if media.countries:
countries = [str(country) for country in media.countries if country]
if countries:
info["origin_country"] = countries
info["production_countries"] = [
{"iso_3166_1": country} for country in countries
]
if media.year is not None:
info["release_date"] = str(media.year)
for field in (
"adult",
"runtime",
"content_rating",
"companies",
"networks",
):
value = getattr(media, field, None)
if value not in (None, "", []):
info[field] = value
return info
def _uses_fallback(result: ClassificationResult) -> bool:
"""判断主来源求值是否没有命中具体分类规则。"""
selection = result.effective or result.recommended
return selection is None or selection.source in {None, "fallback", "source_fallback"}
def _classification_extensions(
policy: ClassificationPolicy,
media: ClassificationSubject,
supplied: Mapping[str, Mapping[str, ClassificationFactValue]] | None,
) -> dict[str, dict[str, ClassificationFactValue]]:
"""合并受控调用方扩展事实和策略实际需要的 legacy TMDB 事实。"""
merged = {
str(source): {str(key): value for key, value in values.items()}
for source, values in (supplied or {}).items()
}
tmdb_info = getattr(media, "tmdb_info", None)
if _enum_text(getattr(media, "media_source", None)) == _LEGACY_TMDB_SOURCE and isinstance(tmdb_info, Mapping):
for source, values in build_legacy_tmdb_extension_facts(
policy,
tmdb_info,
).items():
merged.setdefault(source, {}).update(values)
return merged
def _merge_supplied_extensions(
registered: Mapping[str, Mapping[str, ClassificationFactValue]],
supplied: Mapping[str, Mapping[str, ClassificationFactValue]] | None,
) -> dict[str, dict[str, ClassificationFactValue]]:
"""合并已校验插件事实和受信调用方事实,显式调用方值具有最终优先级。"""
merged = {
str(source): {str(key): value for key, value in values.items()}
for source, values in registered.items()
}
for source, values in (supplied or {}).items():
merged.setdefault(str(source), {}).update(
{str(key): value for key, value in values.items()}
)
return merged
def _explicit_effective_override(
media: ClassificationSubject,
) -> ClassificationSelection | None:
"""保留已装配的人工覆盖,避免补充元数据时被自动推荐覆盖。"""
classification = getattr(media, "classification", None)
if not isinstance(classification, ClassificationResult):
return None
effective = classification.effective
if effective is None or effective.source not in {"manual", "subscription"}:
return None
return deepcopy(effective)
def _degraded_automatic_selection(
media: ClassificationSubject,
policy: ClassificationPolicy,
result: ClassificationResult,
) -> ClassificationSelection | None:
"""事实缺失把同策略结果打成兜底时,返回可复用的既有自动分类选择。
补充元数据的入口会先裁剪媒体字段再重新分类,此时缺失事实只会让结果更差,
不会带来新的判断依据。因此仅在既有快照来自同一策略修订且命中过规则,而本次
求值同时出现事实缺失和兜底化时才复用;事实读齐后给出的兜底结果仍然生效,
策略调整和真正的重新分类不会被掩盖。
"""
if result.state != "partial":
return None
proposed = result.effective or result.recommended
if proposed is not None and proposed.source != "fallback":
return None
carried = getattr(media, "classification", None)
if not isinstance(carried, ClassificationResult):
return None
if carried.policy_revision != policy.revision:
return None
if carried.state not in {"complete", "partial"}:
return None
selection = carried.effective or carried.recommended
if selection is None or selection.source != "automatic":
return None
if not str(selection.category_id or "").strip():
return None
return cast(ClassificationSelection, selection.model_copy(deep=True))
def _enum_text(value: object) -> str:
"""把字符串枚举或普通值转换为去空白文本。"""
if isinstance(value, Enum):
value = value.value
return str(value or "").strip()
def _category_path_snapshot(selection: ClassificationSelection) -> str:
"""把已校验路径投影为过渡期 library_category 兼容字符串。"""
return "/".join(selection.category_path)