mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
refactor(search): unify sync async decisions
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional, cast
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, cast
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase
|
||||
@@ -19,6 +19,80 @@ from app.schemas.types import (
|
||||
)
|
||||
|
||||
|
||||
def _build_id_search_params(
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
mtype: Optional[MediaType],
|
||||
area: Optional[str],
|
||||
season: Optional[int],
|
||||
sites: Optional[List[int]],
|
||||
music_type: Optional[str],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""构造同步、异步和流式 ID 搜索共享的识别与缓存参数。"""
|
||||
recognition_params = {
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"mtype": mtype,
|
||||
"music_type": music_type,
|
||||
}
|
||||
cache_params = {
|
||||
**recognition_params,
|
||||
"area": area,
|
||||
"season": season,
|
||||
"sites": sites,
|
||||
}
|
||||
return recognition_params, cache_params
|
||||
|
||||
|
||||
def _build_missing_media_map(
|
||||
mediainfo: MediaInfo,
|
||||
season: Optional[int],
|
||||
) -> Optional[Dict[str, Dict[int, NotExistMediaInfo]]]:
|
||||
"""将指定季转换为统一的缺集搜索映射。"""
|
||||
if season is None:
|
||||
return None
|
||||
resolved_source, resolved_id = resolve_media_identity(media=mediainfo)
|
||||
return {
|
||||
build_media_key(resolved_source, resolved_id): {
|
||||
season: NotExistMediaInfo(episodes=[])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _normalize_media_search_input(mediainfo: MediaInfo) -> MediaInfo:
|
||||
"""归一化非 TMDB 输入标题,并保留调用方对象由外层复制的所有权约束。"""
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = cast(int, meta.begin_season)
|
||||
return mediainfo
|
||||
|
||||
|
||||
def _should_stop_keyword_search(
|
||||
search_multiple_name: bool,
|
||||
torrents: List[TorrentInfo],
|
||||
) -> bool:
|
||||
"""统一判断首个有效关键字结果是否终止后续搜索。"""
|
||||
return not search_multiple_name and bool(torrents)
|
||||
|
||||
|
||||
def _build_candidate_contexts(
|
||||
mediainfo: MediaInfo,
|
||||
torrents: List[TorrentInfo],
|
||||
) -> List[Context]:
|
||||
"""将流式站点候选映射为尚未精确过滤的搜索上下文。"""
|
||||
return [
|
||||
Context(
|
||||
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
media_info_is_target=True,
|
||||
)
|
||||
for torrent in torrents
|
||||
]
|
||||
|
||||
|
||||
class SearchMediaOwner(_SearchOwnerBase):
|
||||
"""精确媒体搜索与同步异步编排 owner。"""
|
||||
|
||||
@@ -44,31 +118,24 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
self.save_last_search_params(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
music_type=music_type,
|
||||
)
|
||||
# 音乐统一在 MediaChain.recognize_media 内按固定来源路由
|
||||
mediainfo = MediaChain().recognize_media(
|
||||
recognition_params, cache_params = _build_id_search_params(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
music_type=music_type,
|
||||
)
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
self.save_last_search_params(**cache_params)
|
||||
# 音乐统一在 MediaChain.recognize_media 内按固定来源路由
|
||||
mediainfo = MediaChain().recognize_media(**recognition_params)
|
||||
if not mediainfo:
|
||||
logger.error(f"{self._build_search_keyword(media_source, media_id)} 媒体信息识别失败!")
|
||||
return []
|
||||
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None
|
||||
if season is not None:
|
||||
resolved_source, resolved_id = resolve_media_identity(media=mediainfo)
|
||||
no_exists = {build_media_key(resolved_source, resolved_id): {season: NotExistMediaInfo(episodes=[])}}
|
||||
no_exists = _build_missing_media_map(mediainfo, season)
|
||||
results = self.process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
@@ -97,31 +164,24 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
"""
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
music_type=music_type,
|
||||
)
|
||||
# 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
recognition_params, cache_params = _build_id_search_params(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
music_type=music_type,
|
||||
)
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(**cache_params)
|
||||
# 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由
|
||||
mediainfo = await MediaChain().async_recognize_media(**recognition_params)
|
||||
if not mediainfo:
|
||||
logger.error(f"{self._build_search_keyword(media_source, media_id)} 媒体信息识别失败!")
|
||||
return []
|
||||
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None
|
||||
if season is not None:
|
||||
resolved_source, resolved_id = resolve_media_identity(media=mediainfo)
|
||||
no_exists = {build_media_key(resolved_source, resolved_id): {season: NotExistMediaInfo(episodes=[])}}
|
||||
no_exists = _build_missing_media_map(mediainfo, season)
|
||||
results = await self.async_process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
@@ -142,33 +202,26 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
"""
|
||||
根据数据源媒体 ID 渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果
|
||||
"""
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
music_type=music_type,
|
||||
)
|
||||
# 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
recognition_params, cache_params = _build_id_search_params(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
area=area,
|
||||
season=season,
|
||||
sites=sites,
|
||||
music_type=music_type,
|
||||
)
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(**cache_params)
|
||||
# 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由
|
||||
mediainfo = await MediaChain().async_recognize_media(**recognition_params)
|
||||
if not mediainfo:
|
||||
logger.error(f"{self._build_search_keyword(media_source, media_id)} 媒体信息识别失败!")
|
||||
yield {"type": "error", "success": False, "message": "媒体信息识别失败"}
|
||||
return
|
||||
|
||||
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None
|
||||
if season is not None:
|
||||
resolved_source, resolved_id = resolve_media_identity(media=mediainfo)
|
||||
no_exists = {build_media_key(resolved_source, resolved_id): {season: NotExistMediaInfo(episodes=[])}}
|
||||
no_exists = _build_missing_media_map(mediainfo, season)
|
||||
|
||||
contexts: List[Context] = []
|
||||
async for event in self.async_process_stream(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists):
|
||||
@@ -215,12 +268,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
),
|
||||
)
|
||||
|
||||
mediainfo = self._copy_media_input(mediainfo)
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = cast(int, meta.begin_season)
|
||||
mediainfo = _normalize_media_search_input(self._copy_media_input(mediainfo))
|
||||
logger.info(f"开始搜索资源,关键词:{keyword or mediainfo.title} ...")
|
||||
|
||||
# 补充媒体信息
|
||||
@@ -266,7 +314,9 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
torrents.extend(results)
|
||||
|
||||
# 有结果则停止
|
||||
if not self.runtime_config.search_multiple_name and torrents:
|
||||
if _should_stop_keyword_search(
|
||||
self.runtime_config.search_multiple_name, torrents
|
||||
):
|
||||
logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索")
|
||||
break
|
||||
|
||||
@@ -319,12 +369,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
),
|
||||
)
|
||||
|
||||
mediainfo = self._copy_media_input(mediainfo)
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = cast(int, meta.begin_season)
|
||||
mediainfo = _normalize_media_search_input(self._copy_media_input(mediainfo))
|
||||
logger.info(f"开始搜索资源,关键词:{keyword or mediainfo.title} ...")
|
||||
|
||||
# 补充媒体信息
|
||||
@@ -367,7 +412,9 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
)
|
||||
search_count += 1
|
||||
# 未开启多名称搜索时,有结果则停止
|
||||
if not self.runtime_config.search_multiple_name and torrents:
|
||||
if _should_stop_keyword_search(
|
||||
self.runtime_config.search_multiple_name, torrents
|
||||
):
|
||||
logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索")
|
||||
break
|
||||
|
||||
@@ -412,12 +459,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
yield event
|
||||
return
|
||||
|
||||
mediainfo = self._copy_media_input(mediainfo)
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = cast(int, meta.begin_season)
|
||||
mediainfo = _normalize_media_search_input(self._copy_media_input(mediainfo))
|
||||
logger.info(f"开始渐进式搜索资源,关键词:{keyword or mediainfo.title} ...")
|
||||
|
||||
# 补充媒体信息
|
||||
@@ -454,16 +496,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
):
|
||||
result = event.pop("items", []) or []
|
||||
torrents.extend(result)
|
||||
batch_contexts = [
|
||||
Context(
|
||||
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
media_info_is_target=True,
|
||||
)
|
||||
for torrent in result
|
||||
]
|
||||
batch_contexts = _build_candidate_contexts(mediainfo, result)
|
||||
candidate_contexts.extend(batch_contexts)
|
||||
yield {
|
||||
**event,
|
||||
@@ -474,7 +507,9 @@ class SearchMediaOwner(_SearchOwnerBase):
|
||||
}
|
||||
|
||||
search_count += 1
|
||||
if not self.runtime_config.search_multiple_name and torrents:
|
||||
if _should_stop_keyword_search(
|
||||
self.runtime_config.search_multiple_name, torrents
|
||||
):
|
||||
logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索")
|
||||
break
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""标题搜索入口与标题候选过滤 owner。"""
|
||||
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Optional, cast
|
||||
from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Tuple, cast
|
||||
|
||||
from app.application.configuration import (
|
||||
get_configured_system_config,
|
||||
@@ -17,6 +17,46 @@ from app.schemas.types import (
|
||||
)
|
||||
|
||||
|
||||
def _build_title_search_params(
|
||||
title: str,
|
||||
page: Optional[int],
|
||||
sites: Optional[List[int]],
|
||||
mtype: Optional[MediaType],
|
||||
include_empty_type: bool = False,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""构造标题搜索共享的 provider 与缓存参数。"""
|
||||
provider_params: Dict[str, Any] = {
|
||||
"keyword": title,
|
||||
"sites": sites,
|
||||
"page": page,
|
||||
}
|
||||
if mtype is not None or include_empty_type:
|
||||
provider_params["mtype"] = mtype
|
||||
cache_params = {
|
||||
"keyword": title,
|
||||
"mtype": mtype,
|
||||
"area": "title",
|
||||
"sites": sites,
|
||||
}
|
||||
return provider_params, cache_params
|
||||
|
||||
|
||||
def _build_title_contexts(
|
||||
torrents: List[TorrentInfo],
|
||||
mtype: Optional[MediaType],
|
||||
build_meta: Callable[[TorrentInfo, Optional[MediaType]], Any],
|
||||
) -> List[Context]:
|
||||
"""将过滤后的标题候选统一投影为搜索上下文。"""
|
||||
return [
|
||||
Context(
|
||||
meta_info=build_meta(torrent, mtype),
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
)
|
||||
for torrent in torrents
|
||||
]
|
||||
|
||||
|
||||
class SearchTitleOwner(_SearchOwnerBase):
|
||||
"""标题搜索入口与标题候选过滤 owner。"""
|
||||
|
||||
@@ -38,27 +78,21 @@ class SearchTitleOwner(_SearchOwnerBase):
|
||||
:param mtype: 限定站点资源分类
|
||||
:param rule_groups: 指定过滤规则组,为空时使用默认搜索过滤规则
|
||||
"""
|
||||
search_params, cache_params = _build_title_search_params(
|
||||
title=title,
|
||||
page=page,
|
||||
sites=sites,
|
||||
mtype=mtype,
|
||||
)
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
self.save_last_search_params(
|
||||
keyword=title,
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
sites=sites,
|
||||
)
|
||||
self.save_last_search_params(**cache_params)
|
||||
if title:
|
||||
logger.info(f"开始搜索资源,关键词:{title} ...")
|
||||
else:
|
||||
logger.info(f"开始浏览资源,站点:{sites} ...")
|
||||
# 搜索
|
||||
search_kwargs: Dict[str, Any] = {
|
||||
"keyword": title,
|
||||
"sites": sites,
|
||||
"page": page,
|
||||
}
|
||||
if mtype is not None:
|
||||
search_kwargs["mtype"] = mtype
|
||||
torrents = self._SearchChain__search_all_sites(**search_kwargs) or []
|
||||
torrents = self._SearchChain__search_all_sites(**search_params) or []
|
||||
if not torrents:
|
||||
logger.warning(f"{title} 未搜索到资源")
|
||||
return []
|
||||
@@ -70,14 +104,9 @@ class SearchTitleOwner(_SearchOwnerBase):
|
||||
logger.warning(f"{title} 没有符合过滤规则的资源")
|
||||
return []
|
||||
# 组装上下文
|
||||
contexts = [
|
||||
Context(
|
||||
meta_info=self._build_title_search_meta(torrent, mtype),
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
)
|
||||
for torrent in torrents
|
||||
]
|
||||
contexts = _build_title_contexts(
|
||||
torrents, mtype, self._build_title_search_meta
|
||||
)
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
self._save_results(contexts)
|
||||
@@ -101,27 +130,21 @@ class SearchTitleOwner(_SearchOwnerBase):
|
||||
:param mtype: 限定站点资源分类
|
||||
:param rule_groups: 指定过滤规则组,为空时使用默认搜索过滤规则
|
||||
"""
|
||||
search_params, cache_params = _build_title_search_params(
|
||||
title=title,
|
||||
page=page,
|
||||
sites=sites,
|
||||
mtype=mtype,
|
||||
)
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=title,
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
sites=sites,
|
||||
)
|
||||
await self.async_save_last_search_params(**cache_params)
|
||||
if title:
|
||||
logger.info(f"开始搜索资源,关键词:{title} ...")
|
||||
else:
|
||||
logger.info(f"开始浏览资源,站点:{sites} ...")
|
||||
# 搜索
|
||||
search_kwargs: Dict[str, Any] = {
|
||||
"keyword": title,
|
||||
"sites": sites,
|
||||
"page": page,
|
||||
}
|
||||
if mtype is not None:
|
||||
search_kwargs["mtype"] = mtype
|
||||
torrents = await self._SearchChain__async_search_all_sites(**search_kwargs) or []
|
||||
torrents = await self._SearchChain__async_search_all_sites(**search_params) or []
|
||||
if not torrents:
|
||||
logger.warning(f"{title} 未搜索到资源")
|
||||
return []
|
||||
@@ -134,14 +157,9 @@ class SearchTitleOwner(_SearchOwnerBase):
|
||||
logger.warning(f"{title} 没有符合过滤规则的资源")
|
||||
return []
|
||||
# 组装上下文
|
||||
contexts = [
|
||||
Context(
|
||||
meta_info=self._build_title_search_meta(torrent, mtype),
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
)
|
||||
for torrent in torrents
|
||||
]
|
||||
contexts = _build_title_contexts(
|
||||
torrents, mtype, self._build_title_search_meta
|
||||
)
|
||||
# 保存到本地文件
|
||||
if cache_local:
|
||||
await self._async_save_results(contexts)
|
||||
@@ -159,14 +177,16 @@ class SearchTitleOwner(_SearchOwnerBase):
|
||||
"""
|
||||
根据标题渐进式搜索资源,不识别媒体信息,按默认搜索过滤规则返回结果
|
||||
"""
|
||||
search_params, cache_params = _build_title_search_params(
|
||||
title=title,
|
||||
page=page,
|
||||
sites=sites,
|
||||
mtype=mtype,
|
||||
include_empty_type=True,
|
||||
)
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=title,
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
sites=sites,
|
||||
)
|
||||
await self.async_save_last_search_params(**cache_params)
|
||||
if title:
|
||||
logger.info(f"开始渐进式搜索资源,关键词:{title} ...")
|
||||
else:
|
||||
@@ -178,7 +198,7 @@ class SearchTitleOwner(_SearchOwnerBase):
|
||||
if rule_groups is None:
|
||||
rule_groups = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or []
|
||||
async for event in self._SearchChain__async_search_all_sites_stream(
|
||||
keyword=title, sites=sites, page=page, mtype=mtype
|
||||
**search_params
|
||||
):
|
||||
result = event.pop("items", []) or []
|
||||
candidate_count += len(result)
|
||||
@@ -187,14 +207,9 @@ class SearchTitleOwner(_SearchOwnerBase):
|
||||
torrents=result,
|
||||
rule_groups=rule_groups,
|
||||
)
|
||||
batch_contexts = [
|
||||
Context(
|
||||
meta_info=self._build_title_search_meta(torrent, mtype),
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
)
|
||||
for torrent in result
|
||||
]
|
||||
batch_contexts = _build_title_contexts(
|
||||
result, mtype, self._build_title_search_meta
|
||||
)
|
||||
if batch_contexts:
|
||||
contexts.extend(batch_contexts)
|
||||
yield {
|
||||
|
||||
324
tests/test_search_sync_async_parity.py
Normal file
324
tests/test_search_sync_async_parity.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""搜索链同步、异步与流式入口的共享业务决策回归测试。"""
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from app.chain.search import media as media_module
|
||||
from app.chain.search.facade import SearchChain
|
||||
from app.domain.context import MediaInfo, TorrentInfo
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def _media() -> MediaInfo:
|
||||
"""构造具备稳定来源身份和别名的测试媒体。"""
|
||||
return MediaInfo(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="100",
|
||||
tmdb_id=100,
|
||||
title="测试电影",
|
||||
names=["Test Movie"],
|
||||
type=MediaType.MOVIE,
|
||||
year="2026",
|
||||
)
|
||||
|
||||
|
||||
def _torrent(title: str) -> TorrentInfo:
|
||||
"""构造一条无需外部 provider 的测试资源。"""
|
||||
return TorrentInfo(title=title, description="description")
|
||||
|
||||
|
||||
def _chain() -> SearchChain:
|
||||
"""绕过运行时组合根构造可局部注入的搜索链。"""
|
||||
return object.__new__(SearchChain)
|
||||
|
||||
|
||||
def test_id_search_sync_async_share_request_and_missing_plan(monkeypatch):
|
||||
"""ID 搜索双入口应产生完全相同的识别、缓存和缺集参数。"""
|
||||
chain = _chain()
|
||||
target = _media()
|
||||
sync_calls: list[dict[str, Any]] = []
|
||||
async_calls: list[dict[str, Any]] = []
|
||||
saved_sync: list[dict[str, Any]] = []
|
||||
saved_async: list[dict[str, Any]] = []
|
||||
result = [SimpleNamespace(name="context")]
|
||||
|
||||
class FakeMediaChain:
|
||||
"""记录同步与异步识别参数并返回同一媒体快照。"""
|
||||
|
||||
def recognize_media(self, **kwargs):
|
||||
"""返回同步识别结果。"""
|
||||
sync_calls.append(kwargs)
|
||||
return target
|
||||
|
||||
async def async_recognize_media(self, **kwargs):
|
||||
"""返回异步识别结果。"""
|
||||
async_calls.append(kwargs)
|
||||
return target
|
||||
|
||||
def process(**kwargs):
|
||||
"""记录同步处理计划。"""
|
||||
sync_calls.append(kwargs)
|
||||
return result
|
||||
|
||||
async def async_process(**kwargs):
|
||||
"""记录异步处理计划。"""
|
||||
async_calls.append(kwargs)
|
||||
return result
|
||||
|
||||
async def async_save_params(**kwargs):
|
||||
"""记录异步缓存参数。"""
|
||||
saved_async.append(kwargs)
|
||||
|
||||
async def async_save_results(_contexts):
|
||||
"""模拟异步结果缓存。"""
|
||||
|
||||
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
||||
chain.cancel_ai_recommend = lambda: None
|
||||
chain.save_last_search_params = lambda **kwargs: saved_sync.append(kwargs)
|
||||
chain.async_save_last_search_params = async_save_params
|
||||
chain.process = process
|
||||
chain.async_process = async_process
|
||||
chain._save_results = lambda _contexts: None
|
||||
chain._async_save_results = async_save_results
|
||||
|
||||
kwargs = {
|
||||
"media_source": MediaSource.TMDB,
|
||||
"media_id": "100",
|
||||
"mtype": MediaType.TV,
|
||||
"area": "imdbid",
|
||||
"season": 2,
|
||||
"sites": [1, 3],
|
||||
"cache_local": True,
|
||||
"music_type": "recording",
|
||||
}
|
||||
sync_result = chain.search_by_id(**kwargs)
|
||||
async_result = asyncio.run(chain.async_search_by_id(**kwargs))
|
||||
|
||||
assert sync_result is result
|
||||
assert async_result is result
|
||||
assert sync_calls[0] == async_calls[0] == {
|
||||
"media_source": MediaSource.TMDB,
|
||||
"media_id": "100",
|
||||
"mtype": MediaType.TV,
|
||||
"music_type": "recording",
|
||||
}
|
||||
assert sync_calls[1] == async_calls[1]
|
||||
assert sync_calls[1]["sites"] == [1, 3]
|
||||
assert sync_calls[1]["area"] == "imdbid"
|
||||
missing = sync_calls[1]["no_exists"]
|
||||
assert list(missing) == ["tmdb:100"]
|
||||
assert list(missing["tmdb:100"]) == [2]
|
||||
assert saved_sync == saved_async
|
||||
|
||||
|
||||
def test_media_process_sync_async_share_keyword_stop_decision(monkeypatch):
|
||||
"""双入口应按相同关键字顺序搜索,并在首个有效结果后同时停止。"""
|
||||
chain = _chain()
|
||||
target = _media()
|
||||
found = _torrent("测试电影 2026 1080p")
|
||||
sync_keywords: list[str] = []
|
||||
async_keywords: list[str] = []
|
||||
sync_sleeps: list[int] = []
|
||||
async_sleeps: list[int] = []
|
||||
parsed: list[list[TorrentInfo]] = []
|
||||
|
||||
class FakeMediaChain:
|
||||
"""为同步与异步处理提供等价的附加信息结果。"""
|
||||
|
||||
def supplement_media_info(self, mediainfo):
|
||||
"""返回同步附加信息。"""
|
||||
return mediainfo
|
||||
|
||||
async def async_supplement_media_info(self, mediainfo):
|
||||
"""返回异步附加信息。"""
|
||||
return mediainfo
|
||||
|
||||
def search_all_sites(**kwargs):
|
||||
"""第二个关键字开始返回资源。"""
|
||||
keyword = kwargs["keyword"]
|
||||
sync_keywords.append(keyword)
|
||||
return [found] if keyword == "second" else []
|
||||
|
||||
async def async_search_all_sites(**kwargs):
|
||||
"""第二个关键字开始异步返回资源。"""
|
||||
keyword = kwargs["keyword"]
|
||||
async_keywords.append(keyword)
|
||||
return [found] if keyword == "second" else []
|
||||
|
||||
def parse_result(**kwargs):
|
||||
"""记录进入统一结果处理边界的资源顺序。"""
|
||||
parsed.append(list(kwargs["torrents"]))
|
||||
return kwargs["torrents"]
|
||||
|
||||
async def fake_async_sleep(delay):
|
||||
"""记录异步关键字退避但不实际等待。"""
|
||||
async_sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
||||
monkeypatch.setattr(media_module.random, "randint", lambda _start, _end: 1)
|
||||
monkeypatch.setattr(media_module.time, "sleep", sync_sleeps.append)
|
||||
monkeypatch.setattr(media_module.asyncio, "sleep", fake_async_sleep)
|
||||
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
||||
chain._copy_media_input = deepcopy
|
||||
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
||||
chain._SearchChain__search_all_sites = search_all_sites
|
||||
chain._SearchChain__async_search_all_sites = async_search_all_sites
|
||||
chain._parse_result = parse_result
|
||||
|
||||
sync_result = chain.process(target)
|
||||
async_result = asyncio.run(chain.async_process(target))
|
||||
|
||||
assert sync_keywords == async_keywords == ["first", "second"]
|
||||
assert sync_sleeps == async_sleeps == [1]
|
||||
assert parsed == [[found], [found]]
|
||||
assert sync_result == async_result == [found]
|
||||
|
||||
|
||||
def test_id_search_stream_failure_preserves_order_and_skips_result_cache(
|
||||
monkeypatch,
|
||||
):
|
||||
"""识别失败时流入口应先保存请求状态,只输出错误且不保存空结果。"""
|
||||
chain = _chain()
|
||||
order: list[str] = []
|
||||
|
||||
class FakeMediaChain:
|
||||
"""模拟异步识别失败。"""
|
||||
|
||||
async def async_recognize_media(self, **_kwargs):
|
||||
"""记录识别顺序并返回失败。"""
|
||||
order.append("recognize")
|
||||
return None
|
||||
|
||||
async def save_params(**_kwargs):
|
||||
"""记录请求状态保存顺序。"""
|
||||
order.append("params")
|
||||
|
||||
async def save_results(_contexts):
|
||||
"""若失败路径错误保存结果则留下可断言记录。"""
|
||||
order.append("results")
|
||||
|
||||
async def collect_events():
|
||||
"""完整消费识别失败的搜索流。"""
|
||||
return [
|
||||
event
|
||||
async for event in chain.async_search_by_id_stream(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="404",
|
||||
cache_local=True,
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
||||
chain.cancel_ai_recommend = lambda: order.append("cancel")
|
||||
chain.async_save_last_search_params = save_params
|
||||
chain._async_save_results = save_results
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
|
||||
assert order == ["cancel", "params", "recognize"]
|
||||
assert events == [
|
||||
{"type": "error", "success": False, "message": "媒体信息识别失败"}
|
||||
]
|
||||
|
||||
|
||||
def test_title_search_sync_async_share_plan_filter_and_projection():
|
||||
"""标题搜索双入口应共享 provider 参数、过滤决策和可替换元数据投影。"""
|
||||
chain = _chain()
|
||||
keep = _torrent("Movie 2026 1080p")
|
||||
drop = _torrent("Movie 2026 2160p")
|
||||
provider_calls: list[dict[str, Any]] = []
|
||||
filter_calls: list[list[TorrentInfo]] = []
|
||||
meta_calls: list[str] = []
|
||||
|
||||
def search_all_sites(**kwargs):
|
||||
"""记录同步 provider 参数。"""
|
||||
provider_calls.append(kwargs)
|
||||
return [keep, drop]
|
||||
|
||||
async def async_search_all_sites(**kwargs):
|
||||
"""记录异步 provider 参数。"""
|
||||
provider_calls.append(kwargs)
|
||||
return [keep, drop]
|
||||
|
||||
def filter_torrents(torrents, rule_groups=None):
|
||||
"""记录共享过滤输入并保留首条资源。"""
|
||||
assert rule_groups == ["quality"]
|
||||
filter_calls.append(list(torrents))
|
||||
return [keep]
|
||||
|
||||
def build_meta(torrent, _mtype):
|
||||
"""模拟插件或测试替换的稳定元数据构造点。"""
|
||||
meta_calls.append(torrent.title)
|
||||
return MetaInfo(title=f"mapped:{torrent.title}")
|
||||
|
||||
chain._SearchChain__search_all_sites = search_all_sites
|
||||
chain._SearchChain__async_search_all_sites = async_search_all_sites
|
||||
chain._filter_title_search_torrents = filter_torrents
|
||||
chain._build_title_search_meta = build_meta
|
||||
|
||||
sync_result = chain.search_by_title(
|
||||
title="Movie", page=2, sites=[1], mtype=MediaType.MOVIE, rule_groups=["quality"]
|
||||
)
|
||||
async_result = asyncio.run(
|
||||
chain.async_search_by_title(
|
||||
title="Movie",
|
||||
page=2,
|
||||
sites=[1],
|
||||
mtype=MediaType.MOVIE,
|
||||
rule_groups=["quality"],
|
||||
)
|
||||
)
|
||||
|
||||
assert provider_calls == [
|
||||
{"keyword": "Movie", "sites": [1], "page": 2, "mtype": MediaType.MOVIE},
|
||||
{"keyword": "Movie", "sites": [1], "page": 2, "mtype": MediaType.MOVIE},
|
||||
]
|
||||
assert filter_calls == [[keep, drop], [keep, drop]]
|
||||
assert meta_calls == [keep.title, keep.title]
|
||||
assert [context.meta_info.title for context in sync_result] == [
|
||||
context.meta_info.title for context in async_result
|
||||
] == [f"mapped:{keep.title}"]
|
||||
|
||||
|
||||
def test_title_stream_preserves_provider_and_result_order():
|
||||
"""标题流应按 provider 批次顺序追加,并以相同顺序发布累计完成结果。"""
|
||||
chain = _chain()
|
||||
first = _torrent("Movie 2026 1080p")
|
||||
second = _torrent("Movie 2026 2160p")
|
||||
provider_params: list[dict[str, Any]] = []
|
||||
|
||||
async def search_stream(**kwargs):
|
||||
"""按固定顺序输出两个 provider 批次。"""
|
||||
provider_params.append(kwargs)
|
||||
yield {"type": "append", "site_id": 1, "items": [first]}
|
||||
yield {"type": "append", "site_id": 2, "items": [second]}
|
||||
|
||||
async def collect_events():
|
||||
"""完整消费标题搜索流。"""
|
||||
return [
|
||||
event
|
||||
async for event in chain.async_search_by_title_stream(
|
||||
title="Movie", sites=[1, 2], rule_groups=[]
|
||||
)
|
||||
]
|
||||
|
||||
chain._SearchChain__async_search_all_sites_stream = search_stream
|
||||
chain._filter_title_search_torrents = lambda torrents, rule_groups=None: torrents
|
||||
chain._build_title_search_meta = (
|
||||
lambda torrent, _mtype: MetaInfo(title=torrent.title)
|
||||
)
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
|
||||
assert provider_params == [
|
||||
{"keyword": "Movie", "sites": [1, 2], "page": 0, "mtype": None}
|
||||
]
|
||||
assert [event["type"] for event in events] == ["append", "append", "done"]
|
||||
assert [event["total_items"] for event in events] == [1, 2, 2]
|
||||
assert [
|
||||
item["torrent_info"]["title"] for item in events[-1]["items"]
|
||||
] == [first.title, second.title]
|
||||
assert events[-1]["candidate_items"] == 2
|
||||
Reference in New Issue
Block a user