Files
MoviePilot/app/startup/composition/enrichment.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

200 lines
6.7 KiB
Python
Raw Permalink 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.
"""
分类事实补充 provider、线程预算和 TTL 缓存的运行时组合。
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from enum import Enum
from typing import Any
from pydantic import ValidationError
from app.application.classification.enrichment import (
ClassificationEnrichmentCacheEntry,
ClassificationEnrichmentProvider,
ClassificationEnrichmentService,
)
from app.runtime.cache import TTLCache
from app.runtime.log import logger
from app.runtime.thread import ThreadHelper
from app.schemas.category import ClassificationEnrichmentResponse
_METHOD = "get_media_classification_facts"
_CACHE_VERSION = 1
_CACHE_REGION = "media_classification_enrichment"
_CACHE_MAX_SIZE = 2048
_CACHE_TTL_SECONDS = 300
RuntimeManagerProvider = Callable[[], Any | None]
"""延迟返回已存在模块或插件运行时管理器的组合端口。"""
class RuntimeClassificationEnrichmentProviderCatalog:
"""
从注入的宿主模块和插件运行态构造稳定 provider 快照。
"""
def __init__(
self,
*,
module_manager: RuntimeManagerProvider,
plugin_manager: RuntimeManagerProvider,
) -> None:
"""
保存由允许访问 concrete 运行时的组合根提供的延迟端口。
"""
self._module_manager = module_manager
self._plugin_manager = plugin_manager
def providers(self) -> tuple[ClassificationEnrichmentProvider, ...]:
"""
返回先宿主、后插件且各自保持运行目录顺序的 provider。
"""
return (*self._host_providers(), *self._plugin_providers())
def _host_providers(self) -> tuple[ClassificationEnrichmentProvider, ...]:
"""
读取已发布宿主模块,不触发能力物化或启动。
"""
manager = self._module_manager()
if manager is None:
return ()
providers: list[ClassificationEnrichmentProvider] = []
for spec in manager.list_specs():
module = manager.get_running_module(spec.id)
callback = getattr(module, _METHOD, None) if module is not None else None
if not callable(callback):
continue
media_sources = _declared_sources(module)
if not media_sources:
continue
providers.append(
ClassificationEnrichmentProvider(
provider_id=f"host:{spec.id}",
provider_name=str(spec.metadata.get("name") or spec.id),
media_sources=media_sources,
callback=callback,
)
)
return tuple(providers)
def _plugin_providers(self) -> tuple[ClassificationEnrichmentProvider, ...]:
"""
读取运行中插件模块及其已登记媒体来源所有权。
"""
manager = self._plugin_manager()
if manager is None:
return ()
providers: list[ClassificationEnrichmentProvider] = []
for identity, methods in manager.get_plugin_modules().items():
if not isinstance(methods, Mapping):
continue
callback = methods.get(_METHOD)
if not callable(callback):
continue
plugin_id, plugin_name = identity
source_ids = tuple(
source_id
for item in manager.get_media_sources(plugin_id)
if (source_id := _enum_text(item.get("media_source")))
)
if not source_ids:
continue
providers.append(
ClassificationEnrichmentProvider(
provider_id=f"plugin:{plugin_id}",
provider_name=str(plugin_name or plugin_id),
media_sources=source_ids,
callback=callback,
)
)
return tuple(providers)
class RuntimeClassificationEnrichmentCache:
"""
把项目 TTLCache 适配为可缓存确定性空响应的补充端口。
"""
def __init__(self) -> None:
"""
创建独立 region,策略 revision 已包含在每个缓存键中。
"""
self._cache = TTLCache(
region=_CACHE_REGION,
maxsize=_CACHE_MAX_SIZE,
ttl=_CACHE_TTL_SECONDS,
)
def get(self, key: str) -> ClassificationEnrichmentCacheEntry | None:
"""
读取并重新校验缓存载荷,损坏条目按未命中处理。
"""
value = self._cache.get(key)
if not isinstance(value, Mapping) or value.get("version") != _CACHE_VERSION:
return None
if value.get("empty") is True:
return ClassificationEnrichmentCacheEntry(response=None)
try:
response = ClassificationEnrichmentResponse.model_validate(value.get("response"))
except (TypeError, ValidationError):
return None
return ClassificationEnrichmentCacheEntry(response=response)
def set(self, key: str, entry: ClassificationEnrichmentCacheEntry) -> None:
"""
以 JSON 兼容结构写入有效或空响应。
"""
self._cache.set(
key,
{
"version": _CACHE_VERSION,
"empty": entry.response is None,
"response": (entry.response.model_dump(mode="json") if entry.response is not None else None),
},
)
def compose_classification_enrichment(
*,
module_manager: RuntimeManagerProvider,
plugin_manager: RuntimeManagerProvider,
) -> ClassificationEnrichmentService:
"""
构造一个 lifespan 内共享的缺失事实补充服务。
"""
return ClassificationEnrichmentService(
RuntimeClassificationEnrichmentProviderCatalog(
module_manager=module_manager,
plugin_manager=plugin_manager,
),
submit=ThreadHelper().submit,
cache=RuntimeClassificationEnrichmentCache(),
logger=logger.warning,
)
def _declared_sources(module: object) -> tuple[str, ...]:
"""
读取宿主 provider 的显式来源清单,异常声明按无能力处理。
"""
declaration = getattr(module, "get_classification_enrichment_sources", None)
if not callable(declaration):
return ()
try:
value = declaration()
except Exception: # noqa: BLE001 单 provider 声明失败必须隔离
return ()
values: Sequence[Any] = tuple(value) if isinstance(value, (list, tuple, set)) else (value,)
return tuple(source for item in values if (source := _enum_text(item)))
def _enum_text(value: object) -> str:
"""
把字符串枚举或普通值转换为稳定文本。
"""
if isinstance(value, Enum):
value = value.value
return str(value or "").strip()