mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
598 lines
21 KiB
Python
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)
|