From b231ad415f784753ae5f2085a780869905214715 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 5 Jul 2026 17:02:19 +0800 Subject: [PATCH] fix title search filter rules --- app/chain/search.py | 43 +++++++++- app/core/context.py | 4 + tests/test_search_title_filter.py | 130 ++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tests/test_search_title_filter.py diff --git a/app/chain/search.py b/app/chain/search.py index 6a2d52dc8..6bc0306ce 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -530,7 +530,7 @@ class SearchChain(ChainBase): def search_by_title(self, title: str, page: Optional[int] = 0, sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]: """ - 根据标题搜索资源,不识别不过滤,直接返回站点内容 + 根据标题搜索资源,不识别媒体信息,按默认搜索过滤规则返回站点内容 :param title: 标题,为空时返回所有站点首页内容 :param page: 页码 :param sites: 站点ID列表 @@ -552,6 +552,10 @@ class SearchChain(ChainBase): if not torrents: logger.warn(f'{title} 未搜索到资源') return [] + torrents = self.__filter_title_search_torrents(torrents=torrents) + if not torrents: + logger.warn(f'{title} 没有符合过滤规则的资源') + return [] # 组装上下文 contexts = [ Context( @@ -791,7 +795,7 @@ class SearchChain(ChainBase): async def async_search_by_title(self, title: str, page: Optional[int] = 0, sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]: """ - 根据标题异步搜索资源,不识别不过滤,直接返回站点内容 + 根据标题异步搜索资源,不识别媒体信息,按默认搜索过滤规则返回站点内容 :param title: 标题,为空时返回所有站点首页内容 :param page: 页码 :param sites: 站点ID列表 @@ -813,6 +817,10 @@ class SearchChain(ChainBase): if not torrents: logger.warn(f'{title} 未搜索到资源') return [] + torrents = await run_in_threadpool(self.__filter_title_search_torrents, torrents=torrents) + if not torrents: + logger.warn(f'{title} 没有符合过滤规则的资源') + return [] # 组装上下文 contexts = [ Context( @@ -830,7 +838,7 @@ class SearchChain(ChainBase): sites: List[int] = None, cache_local: Optional[bool] = False) -> AsyncIterator[dict]: """ - 根据标题渐进式搜索资源,不识别不过滤,按站点完成顺序返回结果 + 根据标题渐进式搜索资源,不识别媒体信息,按默认搜索过滤规则返回结果 """ if cache_local: self.cancel_ai_recommend() @@ -845,8 +853,14 @@ class SearchChain(ChainBase): logger.info(f'开始渐进式浏览资源,站点:{sites} ...') contexts: List[Context] = [] + rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] async for event in self.__async_search_all_sites_stream(keyword=title, sites=sites, page=page): result = event.pop("items", []) or [] + result = await run_in_threadpool( + self.__filter_title_search_torrents, + torrents=result, + rule_groups=rule_groups, + ) batch_contexts = [ Context( meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), @@ -876,6 +890,29 @@ class SearchChain(ChainBase): "total_items": len(contexts) } + def __filter_title_search_torrents(self, + torrents: List[TorrentInfo], + rule_groups: Optional[List[str]] = None) -> List[TorrentInfo]: + """ + 对标题搜索结果应用默认搜索过滤规则,不执行媒体识别和标题精确匹配。 + """ + if not torrents: + return [] + + if rule_groups is None: + rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] + if not rule_groups: + return torrents + + logger.info(f'开始过滤标题搜索结果,使用规则组:{rule_groups} ...') + filtered_torrents = self.filter_torrents( + rule_groups=rule_groups, + torrent_list=torrents, + mediainfo=None, + ) or [] + logger.info(f'标题搜索过滤完成,剩余 {len(filtered_torrents)} 个资源') + return filtered_torrents + async def async_search_by_id_stream(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None, sites: List[int] = None, diff --git a/app/core/context.py b/app/core/context.py index 827c6b10f..8f31e334b 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -12,6 +12,10 @@ from app.utils.string import StringUtils @dataclass class TorrentInfo: + """ + 种子搜索结果信息。 + """ + # 站点ID site: int = None # 站点名称 diff --git a/tests/test_search_title_filter.py b/tests/test_search_title_filter.py new file mode 100644 index 000000000..bbbe8c9a0 --- /dev/null +++ b/tests/test_search_title_filter.py @@ -0,0 +1,130 @@ +import asyncio +import importlib.machinery +from types import SimpleNamespace + +from app.testing.bootstrap import ensure_optional_stub + +ensure_optional_stub("qbittorrentapi", TorrentFilesList=list) +ensure_optional_stub("transmission_rpc", File=object) +ensure_optional_stub("psutil", __spec__=importlib.machinery.ModuleSpec("psutil", loader=None)) + +from app.chain import search as search_module +from app.chain.search import SearchChain +from app.core.context import TorrentInfo +from app.schemas.types import SystemConfigKey + + +def _make_chain() -> SearchChain: + """ + 构造不触发外部依赖初始化的搜索链实例。 + """ + chain = object.__new__(SearchChain) + chain.cancel_ai_recommend = lambda: None + chain.save_last_search_params = lambda **_kwargs: None + chain.save_cache = lambda _cache, _filename: None + chain.async_save_last_search_params = lambda **_kwargs: None + chain.async_save_cache = lambda _cache, _filename: None + return chain + + +def _patch_search_filter_rule_groups(monkeypatch, rule_groups: list[str]) -> None: + """ + 固定搜索默认过滤规则组,避免读取真实系统配置。 + """ + oper = SimpleNamespace( + get=lambda key: rule_groups if key == SystemConfigKey.SearchFilterRuleGroups else None + ) + monkeypatch.setattr(search_module, "SystemConfigOper", lambda: oper) + + +def test_search_by_title_applies_default_search_filter_rule_groups(monkeypatch): + """ + 标题搜索应在组装上下文前应用默认搜索过滤规则。 + """ + chain = _make_chain() + keep = TorrentInfo(title="Movie 2026 1080p WEB-DL", description="") + drop = TorrentInfo(title="Movie 2026 2160p REMUX", description="") + filter_calls = [] + + chain._SearchChain__search_all_sites = lambda **_kwargs: [keep, drop] + + def filter_torrents(**kwargs): + """ + 记录过滤参数并模拟排除 REMUX 资源。 + """ + filter_calls.append(kwargs) + return [keep] + + chain.filter_torrents = filter_torrents + _patch_search_filter_rule_groups(monkeypatch, ["exclude-remux"]) + + contexts = chain.search_by_title(title="Movie") + + assert [context.torrent_info for context in contexts] == [keep] + assert len(filter_calls) == 1 + assert filter_calls[0]["rule_groups"] == ["exclude-remux"] + assert filter_calls[0]["torrent_list"] == [keep, drop] + assert filter_calls[0]["mediainfo"] is None + + +def test_async_search_by_title_stream_filters_batches_before_yield(monkeypatch): + """ + 标题搜索流应只向前端输出过滤后的批次和最终结果。 + """ + chain = _make_chain() + keep = TorrentInfo(title="Movie 2026 1080p WEB-DL", description="") + drop = TorrentInfo(title="Movie 2026 2160p REMUX", description="") + filter_calls = [] + + async def search_stream(**_kwargs): + """ + 模拟站点页完成后返回一批混合资源。 + """ + yield { + "type": "append", + "stage": "searching", + "value": 100, + "text": "done", + "items": [keep, drop], + "site": "测试站点", + "site_id": 1, + "page": 0, + "finished": 1, + "total": 1, + "total_items": 2, + } + + def filter_torrents(**kwargs): + """ + 记录过滤参数并模拟排除 REMUX 资源。 + """ + filter_calls.append(kwargs) + return [keep] + + async def collect_events(): + """ + 收集标题搜索流全部事件。 + """ + return [ + event + async for event in chain.async_search_by_title_stream(title="Movie") + ] + + chain._SearchChain__async_search_all_sites_stream = search_stream + chain.filter_torrents = filter_torrents + _patch_search_filter_rule_groups(monkeypatch, ["exclude-remux"]) + + events = asyncio.run(collect_events()) + + append_event = events[0] + done_event = events[-1] + assert append_event["type"] == "append" + assert append_event["total_items"] == 1 + assert [item["torrent_info"]["title"] for item in append_event["items"]] == [keep.title] + assert done_event["type"] == "done" + assert done_event["total_items"] == 1 + assert [item["torrent_info"]["title"] for item in done_event["items"]] == [keep.title] + assert len(filter_calls) == 1 + assert filter_calls[0]["rule_groups"] == ["exclude-remux"] + assert filter_calls[0]["torrent_list"] == [keep, drop] + assert filter_calls[0]["mediainfo"] is None