Files
MoviePilot/app/chain/search/facade.py

630 lines
25 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.
"""
搜索处理链稳定 Facade。
"""
import threading
from collections.abc import AsyncIterator, Callable
from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast
from app.application.subscription.sitebudget import (
SubscriptionSiteBudget,
SubscriptionSiteBudgetDeferral,
)
from app.chain.base import ChainBase
from app.chain.search.cache import SearchCacheOwner
from app.chain.search.contract import _SearchOwnerBase
from app.chain.search.manual import SearchManualOwner
from app.chain.search.media import SearchMediaOwner
from app.chain.search.music import SearchMusicOwner
from app.chain.search.pagination import SearchPaginationOwner
from app.chain.search.plan import SearchPlanOwner
from app.chain.search.provider import SearchProviderOwner
from app.chain.search.recommend import SearchRecommendOwner
from app.chain.search.result import SearchResultOwner
from app.chain.search.site import SearchSiteOwner
from app.chain.search.subtitle import SearchSubtitleOwner
from app.chain.search.title import SearchTitleOwner
from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo
from app.runtime.events import Event, eventmanager
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import EventType, MediaSource, MediaType
if TYPE_CHECKING:
from app.schemas.search import SearchLastContextData
_PUBLIC_MODULE = "app.chain.search"
_Handler = TypeVar("_Handler", bound=Callable[..., Any])
def _public_handler(handler: _Handler) -> _Handler:
"""
在事件注册前恢复插件可见的稳定模块身份。
"""
handler.__module__ = _PUBLIC_MODULE
return handler
class SearchChain(ChainBase):
"""
组合搜索计划、provider、结果、音乐、字幕与推荐的稳定门面。
"""
__module__ = _PUBLIC_MODULE
_RESULT_CACHE_KEY = "__search_result__"
_SUBTITLE_RESULT_CACHE_KEY = "__subtitle_search_result__"
_SEARCH_PARAMS_CACHE_KEY = "__search_params__"
_AI_INDICES_CACHE_KEY = "__ai_recommend_indices__"
async def search_page_events(self, *, params: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
"""
执行客户端指定的单页请求,不持有客户端分页进度。
"""
async for event in SearchManualOwner.events(cast(_SearchOwnerBase, self), params=params):
yield event
def configure_subscription_site_budget(
self,
budget: Optional[SubscriptionSiteBudget],
) -> None:
"""
仅为订阅搜索启用或清除站点预算,不影响其它搜索入口。
"""
self._subscription_site_budget = budget
self._subscription_site_budget_failures: list[str] = []
self._subscription_site_budget_deferrals: list[SubscriptionSiteBudgetDeferral] = []
self._subscription_site_budget_failure_lock = threading.Lock()
def record_subscription_site_budget_failure(self, error: str) -> None:
"""
线程安全地记录一个站点执行失败,供订阅任务判断轮次终态。
"""
lock = getattr(self, "_subscription_site_budget_failure_lock", None)
if lock is None:
return
with lock:
self._subscription_site_budget_failures.append(error)
def consume_subscription_site_budget_failures(self, *, has_results: bool = False) -> tuple[str, ...]:
"""
仅在没有成功搜索源时返回并清空本轮站点聚合失败。
"""
lock = getattr(self, "_subscription_site_budget_failure_lock", None)
if lock is None:
return ()
with lock:
failures = tuple(self._subscription_site_budget_failures)
self._subscription_site_budget_failures.clear()
budget = getattr(self, "_subscription_site_budget", None)
has_successful_site = isinstance(budget, SubscriptionSiteBudget) and budget.has_successful_site()
return () if has_results or has_successful_site else failures
def record_subscription_site_budget_deferred(
self,
deferral: SubscriptionSiteBudgetDeferral,
) -> None:
"""
线程安全地记录临时站点冲突,供订阅任务重新入队。
"""
lock = getattr(self, "_subscription_site_budget_failure_lock", None)
if lock is None:
return
with lock:
self._subscription_site_budget_deferrals.append(deferral)
def consume_subscription_site_budget_deferrals(self) -> tuple[SubscriptionSiteBudgetDeferral, ...]:
"""
读取并清空当前订阅搜索积累的站点预算延后事实。
"""
lock = getattr(self, "_subscription_site_budget_failure_lock", None)
if lock is None:
return ()
with lock:
deferrals = tuple(self._subscription_site_budget_deferrals)
self._subscription_site_budget_deferrals.clear()
return deferrals
# 方法描述符经类访问后被 mypy 视为普通 Callable;运行时仍需取回原始
# classmethod 函数,才能保持 SearchChain 的直接 MRO 与既有绑定语义。
music_site_keywords = classmethod( # type: ignore[var-annotated]
# 类型检查将类方法视为普通可调用对象,但运行时仍保留 __func__。
SearchMusicOwner.music_site_keywords.__func__ # type: ignore[attr-defined]
)
# 直接绑定原类方法,保留现有签名与方法解析顺序。
matches_music_resource = classmethod( # type: ignore[var-annotated]
# 从类方法描述符取回原函数,类型检查无法推导该属性。
SearchMusicOwner.matches_music_resource.__func__ # type: ignore[attr-defined]
)
_normalize_music_match_text = staticmethod(SearchMusicOwner._normalize_music_match_text)
_unique_music_texts = staticmethod(SearchMusicOwner._unique_music_texts)
_get_search_resource_pages = staticmethod(SearchPaginationOwner._get_search_resource_pages)
# 直接绑定分页类方法,保留现有签名与方法解析顺序。
_build_search_pages = classmethod( # type: ignore[var-annotated]
# 从类方法描述符取回原函数,类型检查无法推导该属性。
SearchPaginationOwner._build_search_pages.__func__ # type: ignore[attr-defined]
)
_should_continue_search_pages = SearchPaginationOwner._should_continue_search_pages
_should_continue_subtitle_search_pages = staticmethod(SearchPaginationOwner._should_continue_subtitle_search_pages)
@property
def is_ai_recommend_enabled(self) -> bool:
"""
经推荐 owner 的原始描述符读取启用状态,避免 Facade 属性递归。
"""
descriptor = cast(
property,
SearchRecommendOwner.__dict__["is_ai_recommend_enabled"],
)
return cast(bool, descriptor.__get__(self, type(self)))
_calculate_recommend_request_hash = staticmethod(SearchRecommendOwner._calculate_recommend_request_hash)
_build_ai_recommend_status = SearchRecommendOwner._build_ai_recommend_status
def get_current_recommend_status_only(self) -> dict[str, Any]:
"""
返回当前推荐状态,不改变推荐请求代际。
"""
return SearchRecommendOwner.get_current_recommend_status_only(
cast(SearchRecommendOwner, self)
)
def get_recommend_status(
self,
filtered_indices: Optional[list[int]],
search_results_count: int,
) -> dict[str, Any]:
"""
按当前筛选条件返回推荐状态。
"""
return SearchRecommendOwner.get_recommend_status(
cast(SearchRecommendOwner, self),
filtered_indices=filtered_indices,
search_results_count=search_results_count,
)
def cancel_ai_recommend(self) -> None:
"""
委托推荐 owner 取消当前任务并清理推荐缓存。
"""
SearchRecommendOwner.cancel_ai_recommend(
cast(SearchRecommendOwner, self)
)
_build_search_keyword = staticmethod(SearchPlanOwner._build_search_keyword)
_media_recognize_kwargs = staticmethod(SearchPlanOwner._media_recognize_kwargs)
_stringify_sites = staticmethod(SearchCacheOwner._stringify_sites)
_normalize_search_params = staticmethod(SearchCacheOwner._normalize_search_params)
_search_state = SearchCacheOwner._search_state
_save_results = SearchCacheOwner._save_results
_async_save_results = SearchCacheOwner._async_save_results
_async_save_subtitles = SearchCacheOwner._async_save_subtitles
save_last_search_params = SearchCacheOwner.save_last_search_params
async_save_last_search_params = SearchCacheOwner.async_save_last_search_params
async def async_last_search_context(self, user_id: int) -> "SearchLastContextData":
"""
读取当前认证用户的完整搜索快照,不读取全局散键。
:param user_id: 当前认证用户主键
:return: 最近完成的搜索上下文;尚无记录时为空
"""
return await SearchCacheOwner.async_last_search_context(cast(SearchCacheOwner, self), user_id)
async def async_save_search_context(self, user_id: int, context: "SearchLastContextData") -> None:
"""
向状态 owner 委托发布当前用户的完整搜索快照。
:param user_id: 当前认证用户主键
:param context: 本轮正常完成的参数、结果与诊断
"""
await SearchCacheOwner.async_save_search_context(cast(SearchCacheOwner, self), user_id, context)
def last_search_params(self) -> Optional[dict[str, str]]:
"""
返回最近一次搜索参数。
"""
return SearchCacheOwner.last_search_params(cast(SearchCacheOwner, self))
async def async_last_search_params(self) -> Optional[dict[str, str]]:
"""
异步返回最近一次搜索参数。
"""
return await SearchCacheOwner.async_last_search_params(
cast(SearchCacheOwner, self)
)
_normalize_ai_indices = staticmethod(SearchRecommendOwner._normalize_ai_indices)
_extract_recommend_items = staticmethod(SearchRecommendOwner._extract_recommend_items)
_restore_original_indices = staticmethod(SearchRecommendOwner._restore_original_indices)
_invoke_recommend_llm = staticmethod(SearchRecommendOwner._invoke_recommend_llm)
def start_recommend_task(
self,
filtered_indices: Optional[list[int]],
search_results_count: int,
results: list[Any],
) -> None:
"""
委托推荐 owner 启动受代际保护的推荐任务。
"""
SearchRecommendOwner.start_recommend_task(
cast(SearchRecommendOwner, self),
filtered_indices=filtered_indices,
search_results_count=search_results_count,
results=results,
)
# 复用 owner 的方法描述符和完整运行时签名,避免重复维护身份搜索参数。
search_by_id = cast(Callable[..., list[Context]], SearchMediaOwner.search_by_id)
def search_by_title(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
mtype: Optional[MediaType] = None,
rule_groups: Optional[list[str]] = None,
) -> list[Context]:
"""
通过稳定 Facade 调用标题搜索 owner,保留精确的公开类型合同。
"""
return SearchTitleOwner.search_by_title(
cast(SearchTitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
mtype=mtype,
rule_groups=rule_groups,
)
def last_search_results(self) -> Optional[list[Context]]:
"""
返回最近一次资源搜索结果。
"""
return SearchCacheOwner.last_search_results(cast(SearchCacheOwner, self))
async def async_last_search_results(self) -> Optional[list[Context]]:
"""
异步返回最近一次资源搜索结果。
"""
return await SearchCacheOwner.async_last_search_results(
cast(SearchCacheOwner, self)
)
async def async_last_subtitle_search_results(
self,
) -> Optional[list[SubtitleInfo]]:
"""
异步返回最近一次字幕搜索结果。
"""
return await SearchCacheOwner.async_last_subtitle_search_results(
cast(SearchCacheOwner, self)
)
async def async_search_subtitles_by_title(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
) -> list[SubtitleInfo]:
"""
通过稳定 Facade 执行异步标题字幕搜索。
"""
return await SearchSubtitleOwner.async_search_subtitles_by_title(
cast(SearchSubtitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
)
async def async_search_subtitles_by_title_stream(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
) -> AsyncIterator[dict[str, Any]]:
"""
通过稳定 Facade 流式执行标题字幕搜索。
"""
async for event in SearchSubtitleOwner.async_search_subtitles_by_title_stream(
cast(SearchSubtitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
):
yield event
async def async_search_subtitles_by_id(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
) -> list[SubtitleInfo]:
"""
通过稳定 Facade 执行异步精确字幕搜索。
"""
return await SearchSubtitleOwner.async_search_subtitles_by_id(
cast(SearchSubtitleOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
season=season,
episode=episode,
sites=sites,
cache_local=cache_local,
)
async def async_search_subtitles_by_id_stream(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
) -> AsyncIterator[dict[str, Any]]:
"""
通过稳定 Facade 流式执行精确字幕搜索。
"""
async for event in SearchSubtitleOwner.async_search_subtitles_by_id_stream(
cast(SearchSubtitleOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
season=season,
episode=episode,
sites=sites,
cache_local=cache_local,
):
yield event
async def async_search_by_id(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
season: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
music_type: Optional[str] = None,
include_candidates: bool = False,
) -> list[Context]:
"""
通过稳定 Facade 执行异步精确媒体搜索。
"""
return await SearchMediaOwner.async_search_by_id(
cast(SearchMediaOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
cache_local=cache_local,
music_type=music_type,
include_candidates=include_candidates,
)
async def async_search_by_title(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
mtype: Optional[MediaType] = None,
rule_groups: Optional[list[str]] = None,
) -> list[Context]:
"""
通过稳定 Facade 执行异步标题资源搜索。
"""
return await SearchTitleOwner.async_search_by_title(
cast(SearchTitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
mtype=mtype,
rule_groups=rule_groups,
)
async def async_search_by_title_stream(
self,
title: str,
page: Optional[int] = 0,
sites: Optional[list[int]] = None,
cache_local: Optional[bool] = False,
mtype: Optional[MediaType] = None,
rule_groups: Optional[list[str]] = None,
) -> AsyncIterator[dict[str, Any]]:
"""
通过稳定 Facade 流式执行标题资源搜索。
"""
async for event in SearchTitleOwner.async_search_by_title_stream(
cast(SearchTitleOwner, self),
title=title,
page=page,
sites=sites,
cache_local=cache_local,
mtype=mtype,
rule_groups=rule_groups,
):
yield event
_build_title_search_meta = staticmethod(SearchTitleOwner._build_title_search_meta)
_filter_title_search_torrents = SearchTitleOwner._filter_title_search_torrents
async def async_search_by_id_stream(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
season: Optional[int] = None,
sites: Optional[list[int]] = None,
cache_local: bool = False,
music_type: Optional[str] = None,
include_candidates: bool = False,
) -> AsyncIterator[dict[str, Any]]:
"""
通过稳定 Facade 流式执行精确媒体搜索。
"""
async for event in SearchMediaOwner.async_search_by_id_stream(
cast(SearchMediaOwner, self),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
cache_local=cache_local,
music_type=music_type,
include_candidates=include_candidates,
):
yield event
_prepare_params = staticmethod(SearchPlanOwner._prepare_params)
_copy_media_input = staticmethod(SearchPlanOwner._copy_media_input)
_parse_result = SearchResultOwner._parse_result
_filter_identified_contexts = SearchResultOwner._filter_identified_contexts
_remove_duplicate = staticmethod(SearchResultOwner._remove_duplicate)
_build_music_contexts = SearchMusicOwner._build_music_contexts
_matching_music_torrents = staticmethod(SearchMusicOwner._matching_music_torrents)
_process_music = SearchMusicOwner._process_music
_async_process_music = SearchMusicOwner._async_process_music
_async_process_music_stream = SearchMusicOwner._async_process_music_stream
def process(
self,
mediainfo: MediaInfo | MusicInfo,
keyword: Optional[str] = None,
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
sites: Optional[list[int]] = None,
rule_groups: Optional[list[str]] = None,
area: Optional[str] = "title",
custom_words: Optional[list[str]] = None,
filter_params: Optional[dict[str, str]] = None,
include_candidates: bool = False,
candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None,
) -> list[Context]:
"""
通过稳定 Facade 调用精确媒体搜索 owner,保留公开类型合同。
"""
return SearchMediaOwner.process(
cast(SearchMediaOwner, self),
mediainfo=mediainfo,
keyword=keyword,
no_exists=no_exists,
sites=sites,
rule_groups=rule_groups,
area=area,
custom_words=custom_words,
filter_params=filter_params,
include_candidates=include_candidates,
candidate_filter=candidate_filter,
)
async def async_process(
self,
mediainfo: MediaInfo | MusicInfo,
keyword: Optional[str] = None,
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
sites: Optional[list[int]] = None,
rule_groups: Optional[list[str]] = None,
area: Optional[str] = "title",
custom_words: Optional[list[str]] = None,
filter_params: Optional[dict[str, str]] = None,
include_candidates: bool = False,
candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None,
) -> list[Context]:
"""
通过稳定 Facade 执行异步媒体搜索编排。
"""
return await SearchMediaOwner.async_process(
cast(SearchMediaOwner, self),
mediainfo=mediainfo,
keyword=keyword,
no_exists=no_exists,
sites=sites,
rule_groups=rule_groups,
area=area,
custom_words=custom_words,
filter_params=filter_params,
include_candidates=include_candidates,
candidate_filter=candidate_filter,
)
async def async_process_stream(
self,
mediainfo: MediaInfo | MusicInfo,
keyword: Optional[str] = None,
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
sites: Optional[list[int]] = None,
rule_groups: Optional[list[str]] = None,
area: Optional[str] = "title",
custom_words: Optional[list[str]] = None,
filter_params: Optional[dict[str, str]] = None,
include_candidates: bool = False,
candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None,
) -> AsyncIterator[dict[str, Any]]:
"""
通过稳定 Facade 流式执行媒体搜索编排。
"""
async for event in SearchMediaOwner.async_process_stream(
cast(SearchMediaOwner, self),
mediainfo=mediainfo,
keyword=keyword,
no_exists=no_exists,
sites=sites,
rule_groups=rule_groups,
area=area,
custom_words=custom_words,
filter_params=filter_params,
include_candidates=include_candidates,
candidate_filter=candidate_filter,
):
yield event
_build_subtitle_season_episodes = staticmethod(SearchSubtitleOwner._build_subtitle_season_episodes)
_build_subtitle_torrent = staticmethod(SearchSubtitleOwner._build_subtitle_torrent)
_build_subtitle_names = staticmethod(SearchSubtitleOwner._build_subtitle_names)
_build_subtitle_meta = staticmethod(SearchSubtitleOwner._build_subtitle_meta)
_match_subtitle_episode = staticmethod(SearchSubtitleOwner._match_subtitle_episode)
_parse_subtitle_result = SearchSubtitleOwner._parse_subtitle_result
_remove_duplicate_subtitles = staticmethod(SearchSubtitleOwner._remove_duplicate_subtitles)
_async_search_subtitles_for_media = SearchSubtitleOwner._async_search_subtitles_for_media
_async_search_subtitles_for_media_stream = SearchSubtitleOwner._async_search_subtitles_for_media_stream
_SearchChain__search_all_sites = SearchProviderOwner._search_all_sites
_selected_site_ids = staticmethod(SearchProviderOwner._selected_site_ids)
_select_indexers = staticmethod(SearchProviderOwner._select_indexers)
_sync_indexers = SearchProviderOwner._sync_indexers
_async_indexers = SearchProviderOwner._async_indexers
_torrent_keyword = staticmethod(SearchProviderOwner._torrent_keyword)
_torrent_type = staticmethod(SearchProviderOwner._torrent_type)
_search_site_torrents_with_budget = SearchProviderOwner._search_site_torrents_with_budget
_settle_site_search_result = SearchProviderOwner._settle_site_search_result
_iter_provider_batches = SearchProviderOwner._iter_provider_batches
_iter_provider_events = SearchProviderOwner._iter_provider_events
_iter_torrent_events = SearchProviderOwner._iter_torrent_events
_iter_subtitle_events = SearchProviderOwner._iter_subtitle_events
_iter_site_page_results = SearchPaginationOwner._iter_site_page_results
_SearchChain__async_search_all_sites = SearchProviderOwner._async_search_all_sites
_SearchChain__async_search_all_sites_stream = SearchProviderOwner._async_search_all_sites_stream
_async_search_subtitles_all_sites = SearchProviderOwner._async_search_subtitles_all_sites
_async_search_subtitles_all_sites_stream = SearchProviderOwner._async_search_subtitles_all_sites_stream
@eventmanager.register(EventType.SiteDeleted)
@_public_handler
def remove_site(self, event: Event) -> None:
"""
通过稳定 SearchChain 身份清理已删除站点的搜索配置。
"""
SearchSiteOwner._remove_site(cast(SearchSiteOwner, self), event)