mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
fix(search): include plugin resources without indexers (#6399)
This commit is contained in:
@@ -613,6 +613,29 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
|
|||||||
"search_torrents", site=site, keyword=keyword, mtype=mtype, page=page
|
"search_torrents", site=site, keyword=keyword, mtype=mtype, page=page
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def search_plugin_torrents(
|
||||||
|
self,
|
||||||
|
keyword: str,
|
||||||
|
mtype: Optional[MediaType] = None,
|
||||||
|
page: Optional[int] = 0,
|
||||||
|
) -> List[TorrentInfo]:
|
||||||
|
"""仅搜索插件提供的资源源,避免依赖或重复绑定站点索引器。"""
|
||||||
|
return self._module_dispatcher.execute_plugin_modules(
|
||||||
|
"search_torrents", None, site={}, keyword=keyword, mtype=mtype, page=page
|
||||||
|
) or []
|
||||||
|
|
||||||
|
def search_site_torrents(
|
||||||
|
self,
|
||||||
|
site: dict,
|
||||||
|
keyword: str,
|
||||||
|
mtype: Optional[MediaType] = None,
|
||||||
|
page: Optional[int] = 0,
|
||||||
|
) -> List[TorrentInfo]:
|
||||||
|
"""仅搜索指定站点索引器;插件资源源由搜索链统一调用一次。"""
|
||||||
|
return self._module_dispatcher.execute_system_modules(
|
||||||
|
"search_torrents", None, site=site, keyword=keyword, mtype=mtype, page=page
|
||||||
|
) or []
|
||||||
|
|
||||||
def search_subtitles(
|
def search_subtitles(
|
||||||
self,
|
self,
|
||||||
site: dict,
|
site: dict,
|
||||||
@@ -649,6 +672,31 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
|
|||||||
"async_search_torrents", site=site, keyword=keyword, mtype=mtype, page=page
|
"async_search_torrents", site=site, keyword=keyword, mtype=mtype, page=page
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_search_plugin_torrents(
|
||||||
|
self,
|
||||||
|
keyword: str,
|
||||||
|
mtype: Optional[MediaType] = None,
|
||||||
|
page: Optional[int] = 0,
|
||||||
|
) -> List[TorrentInfo]:
|
||||||
|
"""异步搜索插件提供的资源源。"""
|
||||||
|
return await self._module_dispatcher.async_execute_plugin_modules(
|
||||||
|
"async_search_torrents", None,
|
||||||
|
site={}, keyword=keyword, mtype=mtype, page=page
|
||||||
|
) or []
|
||||||
|
|
||||||
|
async def async_search_site_torrents(
|
||||||
|
self,
|
||||||
|
site: dict,
|
||||||
|
keyword: str,
|
||||||
|
mtype: Optional[MediaType] = None,
|
||||||
|
page: Optional[int] = 0,
|
||||||
|
) -> List[TorrentInfo]:
|
||||||
|
"""异步搜索指定站点索引器。"""
|
||||||
|
return await self._module_dispatcher.async_execute_system_modules(
|
||||||
|
"async_search_torrents", None,
|
||||||
|
site=site, keyword=keyword, mtype=mtype, page=page
|
||||||
|
) or []
|
||||||
|
|
||||||
async def async_search_subtitles(
|
async def async_search_subtitles(
|
||||||
self,
|
self,
|
||||||
site: dict,
|
site: dict,
|
||||||
|
|||||||
+49
-16
@@ -2321,9 +2321,15 @@ class SearchChain(ChainBase):
|
|||||||
# 检查站点索引开关
|
# 检查站点索引开关
|
||||||
if not sites or indexer.get("id") in sites:
|
if not sites or indexer.get("id") in sites:
|
||||||
indexer_sites.append(indexer)
|
indexer_sites.append(indexer)
|
||||||
|
|
||||||
|
plugin_results = self.search_plugin_torrents(
|
||||||
|
keyword=keyword,
|
||||||
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
if not indexer_sites:
|
if not indexer_sites:
|
||||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
logger.info(f'未开启有效站点,插件资源源返回 {len(plugin_results)} 条资源')
|
||||||
return []
|
return plugin_results
|
||||||
|
|
||||||
# 开始进度
|
# 开始进度
|
||||||
progress = ProgressHelper(ProgressKey.Search)
|
progress = ProgressHelper(ProgressKey.Search)
|
||||||
@@ -2339,7 +2345,7 @@ class SearchChain(ChainBase):
|
|||||||
progress.update(value=0,
|
progress.update(value=0,
|
||||||
text=f"开始搜索,共 {len(indexer_sites)} 个站点,{len(search_pages)} 页 ...")
|
text=f"开始搜索,共 {len(indexer_sites)} 个站点,{len(search_pages)} 页 ...")
|
||||||
# 结果集
|
# 结果集
|
||||||
results = []
|
results = list(plugin_results)
|
||||||
# 同一站点按页顺序抓取,避免空页后仍继续请求该站点的后续页。
|
# 同一站点按页顺序抓取,避免空页后仍继续请求该站点的后续页。
|
||||||
max_workers = min(
|
max_workers = min(
|
||||||
len(indexer_sites),
|
len(indexer_sites),
|
||||||
@@ -2356,13 +2362,13 @@ class SearchChain(ChainBase):
|
|||||||
search_keyword = mediainfo.imdb_id if area == "imdbid" and mediainfo else keyword
|
search_keyword = mediainfo.imdb_id if area == "imdbid" and mediainfo else keyword
|
||||||
if area == "imdbid":
|
if area == "imdbid":
|
||||||
# 搜索IMDBID
|
# 搜索IMDBID
|
||||||
task = executor.submit(self.search_torrents, site=site,
|
task = executor.submit(self.search_site_torrents, site=site,
|
||||||
keyword=search_keyword,
|
keyword=search_keyword,
|
||||||
mtype=mediainfo.type if mediainfo else mtype,
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
page=search_page)
|
page=search_page)
|
||||||
else:
|
else:
|
||||||
# 搜索标题
|
# 搜索标题
|
||||||
task = executor.submit(self.search_torrents, site=site,
|
task = executor.submit(self.search_site_torrents, site=site,
|
||||||
keyword=search_keyword,
|
keyword=search_keyword,
|
||||||
mtype=mediainfo.type if mediainfo else mtype,
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
page=search_page)
|
page=search_page)
|
||||||
@@ -2438,9 +2444,15 @@ class SearchChain(ChainBase):
|
|||||||
# 检查站点索引开关
|
# 检查站点索引开关
|
||||||
if not sites or indexer.get("id") in sites:
|
if not sites or indexer.get("id") in sites:
|
||||||
indexer_sites.append(indexer)
|
indexer_sites.append(indexer)
|
||||||
|
|
||||||
|
plugin_results = await self.async_search_plugin_torrents(
|
||||||
|
keyword=keyword,
|
||||||
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
if not indexer_sites:
|
if not indexer_sites:
|
||||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
logger.info(f'未开启有效站点,插件资源源返回 {len(plugin_results)} 条资源')
|
||||||
return []
|
return plugin_results
|
||||||
|
|
||||||
# 开始进度(异步后端,避免同步 Redis 在事件循环上阻塞)
|
# 开始进度(异步后端,避免同步 Redis 在事件循环上阻塞)
|
||||||
progress = AsyncProgressHelper(ProgressKey.Search)
|
progress = AsyncProgressHelper(ProgressKey.Search)
|
||||||
@@ -2456,7 +2468,7 @@ class SearchChain(ChainBase):
|
|||||||
await progress.update(value=0,
|
await progress.update(value=0,
|
||||||
text=f"开始搜索,共 {len(indexer_sites)} 个站点,{len(search_pages)} 页 ...")
|
text=f"开始搜索,共 {len(indexer_sites)} 个站点,{len(search_pages)} 页 ...")
|
||||||
# 结果集
|
# 结果集
|
||||||
results = []
|
results = list(plugin_results)
|
||||||
semaphore = asyncio.Semaphore(
|
semaphore = asyncio.Semaphore(
|
||||||
self.runtime_config.search_threadpool_size or total_num
|
self.runtime_config.search_threadpool_size or total_num
|
||||||
)
|
)
|
||||||
@@ -2468,12 +2480,12 @@ class SearchChain(ChainBase):
|
|||||||
async with semaphore:
|
async with semaphore:
|
||||||
if area == "imdbid":
|
if area == "imdbid":
|
||||||
# 搜索IMDBID
|
# 搜索IMDBID
|
||||||
return await self.async_search_torrents(site=site,
|
return await self.async_search_site_torrents(site=site,
|
||||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||||
mtype=mediainfo.type if mediainfo else mtype,
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
page=search_page)
|
page=search_page)
|
||||||
# 搜索标题
|
# 搜索标题
|
||||||
return await self.async_search_torrents(site=site,
|
return await self.async_search_site_torrents(site=site,
|
||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
mtype=mediainfo.type if mediainfo else mtype,
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
page=search_page)
|
page=search_page)
|
||||||
@@ -2562,16 +2574,37 @@ class SearchChain(ChainBase):
|
|||||||
for indexer in await SitesHelper().async_get_indexers():
|
for indexer in await SitesHelper().async_get_indexers():
|
||||||
if not sites or indexer.get("id") in sites:
|
if not sites or indexer.get("id") in sites:
|
||||||
indexer_sites.append(indexer)
|
indexer_sites.append(indexer)
|
||||||
|
|
||||||
|
plugin_results = await self.async_search_plugin_torrents(
|
||||||
|
keyword=keyword,
|
||||||
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
if plugin_results:
|
||||||
|
yield {
|
||||||
|
"type": "append",
|
||||||
|
"stage": "searching",
|
||||||
|
"value": 100 if not indexer_sites else 0,
|
||||||
|
"text": f"插件资源源返回 {len(plugin_results)} 条资源",
|
||||||
|
"items": plugin_results,
|
||||||
|
"site": "插件资源源",
|
||||||
|
"site_id": None,
|
||||||
|
"page": page,
|
||||||
|
"finished": 0,
|
||||||
|
"total": len(indexer_sites),
|
||||||
|
"total_items": len(plugin_results),
|
||||||
|
}
|
||||||
if not indexer_sites:
|
if not indexer_sites:
|
||||||
logger.warn('未开启任何有效站点,无法搜索资源')
|
logger.info(f'未开启有效站点,插件资源源返回 {len(plugin_results)} 条资源')
|
||||||
yield {
|
yield {
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"stage": "searching",
|
"stage": "searching",
|
||||||
"value": 100,
|
"value": 100,
|
||||||
"text": "未开启任何有效站点,无法搜索资源",
|
"text": f"搜索完成,共 {len(plugin_results)} 条资源",
|
||||||
"items": [],
|
"items": [],
|
||||||
"finished": 0,
|
"finished": 0,
|
||||||
"total": 0
|
"total": 0,
|
||||||
|
"total_items": len(plugin_results),
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2604,12 +2637,12 @@ class SearchChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
if area == "imdbid":
|
if area == "imdbid":
|
||||||
site_result = await self.async_search_torrents(site=site,
|
site_result = await self.async_search_site_torrents(site=site,
|
||||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||||
mtype=mediainfo.type if mediainfo else mtype,
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
page=search_page)
|
page=search_page)
|
||||||
else:
|
else:
|
||||||
site_result = await self.async_search_torrents(site=site,
|
site_result = await self.async_search_site_torrents(site=site,
|
||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
mtype=mediainfo.type if mediainfo else mtype,
|
mtype=mediainfo.type if mediainfo else mtype,
|
||||||
page=search_page)
|
page=search_page)
|
||||||
@@ -2629,7 +2662,7 @@ class SearchChain(ChainBase):
|
|||||||
for site in indexer_sites:
|
for site in indexer_sites:
|
||||||
submit_site_page(site=site, page_index=0)
|
submit_site_page(site=site, page_index=0)
|
||||||
|
|
||||||
results_count = 0
|
results_count = len(plugin_results)
|
||||||
try:
|
try:
|
||||||
while tasks:
|
while tasks:
|
||||||
if global_vars.is_system_stopped:
|
if global_vars.is_system_stopped:
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
chain.save_cache = lambda _cache, _filename: None
|
chain.save_cache = lambda _cache, _filename: None
|
||||||
chain.remove_cache = lambda _filename: None
|
chain.remove_cache = lambda _filename: None
|
||||||
chain.get_search_page_size = IndexerModule.get_search_page_size
|
chain.get_search_page_size = IndexerModule.get_search_page_size
|
||||||
|
chain.search_plugin_torrents = lambda **_kwargs: []
|
||||||
|
|
||||||
|
async def no_plugin_results(**_kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
chain.async_search_plugin_torrents = no_plugin_results
|
||||||
return chain
|
return chain
|
||||||
|
|
||||||
async def test_start_recommend_task_restores_original_indices(self):
|
async def test_start_recommend_task_restores_original_indices(self):
|
||||||
@@ -185,7 +191,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
for index in range(count)
|
for index in range(count)
|
||||||
]
|
]
|
||||||
|
|
||||||
chain.search_torrents = search_torrents
|
chain.search_site_torrents = search_torrents
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True),
|
patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True),
|
||||||
@@ -231,7 +237,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
for index in range(count)
|
for index in range(count)
|
||||||
]
|
]
|
||||||
|
|
||||||
chain.search_torrents = search_torrents
|
chain.search_site_torrents = search_torrents
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
||||||
@@ -277,7 +283,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
for index in range(count)
|
for index in range(count)
|
||||||
]
|
]
|
||||||
|
|
||||||
chain.search_torrents = search_torrents
|
chain.search_site_torrents = search_torrents
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
||||||
@@ -342,7 +348,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
for index in range(count)
|
for index in range(count)
|
||||||
]
|
]
|
||||||
|
|
||||||
chain.async_search_torrents = async_search_torrents
|
chain.async_search_site_torrents = async_search_torrents
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True),
|
patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True),
|
||||||
@@ -388,7 +394,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
for index in range(count)
|
for index in range(count)
|
||||||
]
|
]
|
||||||
|
|
||||||
chain.async_search_torrents = async_search_torrents
|
chain.async_search_site_torrents = async_search_torrents
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.chain.search import SearchChain
|
||||||
|
from app.modules.indexer import IndexerModule
|
||||||
|
from app.runtime.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def make_chain() -> SearchChain:
|
||||||
|
"""构造不触发完整启动流程的搜索链。"""
|
||||||
|
chain = object.__new__(SearchChain)
|
||||||
|
chain.get_search_page_size = IndexerModule.get_search_page_size
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_plugin_results_without_indexer_sites():
|
||||||
|
"""未配置 PT 站点时,插件资源仍应进入原生资源搜索。"""
|
||||||
|
chain = make_chain()
|
||||||
|
plugin_item = SimpleNamespace(title="Plugin Result", description="")
|
||||||
|
calls = []
|
||||||
|
chain.search_plugin_torrents = lambda **kwargs: calls.append(kwargs) or [plugin_item]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||||
|
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||||
|
):
|
||||||
|
system_config_oper.return_value.get.return_value = []
|
||||||
|
sites_helper.return_value.get_indexers.return_value = []
|
||||||
|
results = chain._SearchChain__search_all_sites(keyword="keyword")
|
||||||
|
|
||||||
|
assert results == [plugin_item]
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0]["keyword"] == "keyword"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_invokes_plugin_once_with_multiple_indexers():
|
||||||
|
"""多个 PT 站点不应导致插件资源源被重复搜索。"""
|
||||||
|
chain = make_chain()
|
||||||
|
plugin_calls = []
|
||||||
|
site_calls = []
|
||||||
|
chain.search_plugin_torrents = lambda **kwargs: plugin_calls.append(kwargs) or [
|
||||||
|
SimpleNamespace(title="Plugin Result", description="")
|
||||||
|
]
|
||||||
|
chain.search_site_torrents = lambda **kwargs: site_calls.append(kwargs) or [
|
||||||
|
SimpleNamespace(title=f"Site {kwargs['site']['id']}", description="")
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "SEARCH_RESOURCE_PAGES", 1, create=True),
|
||||||
|
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||||
|
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||||
|
patch("app.chain.search.ProgressHelper") as progress_helper,
|
||||||
|
):
|
||||||
|
system_config_oper.return_value.get.return_value = [1, 2]
|
||||||
|
sites_helper.return_value.get_indexers.return_value = [
|
||||||
|
{"id": 1, "name": "站点一"},
|
||||||
|
{"id": 2, "name": "站点二"},
|
||||||
|
]
|
||||||
|
progress_helper.return_value = SimpleNamespace(
|
||||||
|
start=lambda: None, update=lambda **_kwargs: None, end=lambda: None
|
||||||
|
)
|
||||||
|
results = chain._SearchChain__search_all_sites(keyword="keyword")
|
||||||
|
|
||||||
|
assert len(plugin_calls) == 1
|
||||||
|
assert sorted(call["site"]["id"] for call in site_calls) == [1, 2]
|
||||||
|
assert len(results) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_search_returns_plugin_results_without_indexers():
|
||||||
|
"""异步搜索应支持只有插件资源源的部署方式。"""
|
||||||
|
chain = make_chain()
|
||||||
|
plugin_item = SimpleNamespace(title="Plugin Result", description="")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def plugin_search(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return [plugin_item]
|
||||||
|
|
||||||
|
chain.async_search_plugin_torrents = plugin_search
|
||||||
|
async def run_search():
|
||||||
|
with (
|
||||||
|
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||||
|
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||||
|
):
|
||||||
|
system_config_oper.return_value.get.return_value = []
|
||||||
|
sites_helper.return_value.async_get_indexers = AsyncMock(return_value=[])
|
||||||
|
return await chain._SearchChain__async_search_all_sites(keyword="keyword")
|
||||||
|
|
||||||
|
results = asyncio.run(run_search())
|
||||||
|
|
||||||
|
assert results == [plugin_item]
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_search_stream_emits_plugin_results_once_without_indexers():
|
||||||
|
"""流式搜索完成事件不应重复发送插件资源。"""
|
||||||
|
chain = make_chain()
|
||||||
|
plugin_item = SimpleNamespace(title="Plugin Result", description="")
|
||||||
|
|
||||||
|
async def plugin_search(**_kwargs):
|
||||||
|
return [plugin_item]
|
||||||
|
|
||||||
|
chain.async_search_plugin_torrents = plugin_search
|
||||||
|
async def collect_events():
|
||||||
|
with (
|
||||||
|
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||||
|
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||||
|
):
|
||||||
|
system_config_oper.return_value.get.return_value = []
|
||||||
|
sites_helper.return_value.async_get_indexers = AsyncMock(return_value=[])
|
||||||
|
return [
|
||||||
|
event
|
||||||
|
async for event in chain._SearchChain__async_search_all_sites_stream(
|
||||||
|
keyword="keyword"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
events = asyncio.run(collect_events())
|
||||||
|
|
||||||
|
assert [event["type"] for event in events] == ["append", "done"]
|
||||||
|
assert events[0]["items"] == [plugin_item]
|
||||||
|
assert events[1]["items"] == []
|
||||||
|
assert events[1]["total_items"] == 1
|
||||||
Reference in New Issue
Block a user