From d7cf853bc8f2adc3bb523d763d0ffe373b46d960 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 11 Aug 2026 21:06:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(search):=20=E6=94=AF=E6=8C=81=E5=8D=95?= =?UTF-8?q?=E6=AC=A1=E6=90=9C=E7=B4=A2=E5=90=AF=E7=94=A8=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E6=95=B0=E6=8D=AE=E6=BA=90=EF=BC=8C=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E6=98=BE=E7=A4=BA=E6=89=80=E6=9C=89=E6=BA=90=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=20(#6270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 is_media_source_enabled/is_media_source_selected,请求级 source 支持逗号分隔多数据源,缺省回退全局 SEARCH_SOURCE 配置 - 四个媒体模块的搜索/人物/合集方法统一接入多源判断 - 补充多数据源搜索相关测试 --- app/modules/anilist/__init__.py | 9 +- app/modules/bangumi/__init__.py | 9 +- app/modules/douban/__init__.py | 17 ++-- app/modules/themoviedb/__init__.py | 21 ++--- app/utils/media.py | 34 ++++++++ tests/test_media_search_source_selection.py | 91 +++++++++++++++++++++ 6 files changed, 143 insertions(+), 38 deletions(-) diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index 37a1222fa..c0e990b30 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -9,6 +9,7 @@ from app.log import logger from app.modules import _ModuleBase from app.modules.anilist.anilist import AniListApi from app.schemas.types import MediaRecognizeType, MediaType, ModuleType +from app.utils.media import is_media_source_enabled class AniListModule(_ModuleBase): @@ -309,9 +310,7 @@ class AniListModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 统一媒体信息列表 """ - if source and source != "anilist": - return None - if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "anilist"): return None if not meta or not meta.name: return [] @@ -331,9 +330,7 @@ class AniListModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 统一媒体信息列表 """ - if source and source != "anilist": - return None - if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "anilist"): return None if not meta or not meta.name: return [] diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index 4cc3d4944..c98db9b49 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -10,6 +10,7 @@ from app.modules import _ModuleBase from app.modules.bangumi.bangumi import BangumiApi from app.schemas.types import MediaRecognizeType, MediaType, ModuleType from app.utils.http import RequestUtils +from app.utils.media import is_media_source_enabled class BangumiModule(_ModuleBase): @@ -202,9 +203,7 @@ class BangumiModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 媒体信息 """ - if source and source != "bangumi": - return None - if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "bangumi"): return None if not meta.name: return [] @@ -224,9 +223,7 @@ class BangumiModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 媒体信息 """ - if source and source != "bangumi": - return None - if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "bangumi"): return None if not meta.name: return [] diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 7c75aa4af..734b30399 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -17,6 +17,7 @@ from app.schemas.types import MediaType, ModuleType, MediaRecognizeType from app.utils.common import retry from app.utils.http import RequestUtils from app.utils.limit import rate_limit_exponential +from app.utils.media import is_media_source_enabled from app.utils.zhconv import convert as zhconv_convert @@ -844,9 +845,7 @@ class DoubanModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 媒体信息 """ - if source and source != "douban": - return None - if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "douban"): return None if not meta.name: return [] @@ -865,9 +864,7 @@ class DoubanModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 媒体信息 """ - if source and source != "douban": - return None - if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "douban"): return None if not meta.name: return [] @@ -886,9 +883,7 @@ class DoubanModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 人物信息列表 """ - if source and source != "douban": - return None - if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "douban"): return None if not name: return [] @@ -913,9 +908,7 @@ class DoubanModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 人物信息列表 """ - if source and source != "douban": - return None - if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "douban"): return None if not name: return [] diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 3dcd30428..0b7fbdf6b 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -16,6 +16,7 @@ from app.modules.themoviedb.tmdbapi import TmdbApi from app.schemas.category import CategoryConfig from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType from app.utils.http import RequestUtils +from app.utils.media import is_media_source_enabled, is_media_source_selected from app.utils.zhconv import convert as zhconv_convert @@ -744,9 +745,7 @@ class TheMovieDbModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 媒体信息列表 """ - if source and source != "themoviedb": - return None - if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "themoviedb"): return None if not meta.name: return [] @@ -778,9 +777,7 @@ class TheMovieDbModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 人物信息列表 """ - if source and source != "themoviedb": - return None - if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "themoviedb"): return None if not name: return [] @@ -798,9 +795,7 @@ class TheMovieDbModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 人物信息列表 """ - if source and source != "themoviedb": - return None - if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "themoviedb"): return None if not name: return [] @@ -818,7 +813,7 @@ class TheMovieDbModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 合集信息列表 """ - if source and source != "themoviedb": + if source and not is_media_source_selected(source, "themoviedb"): return None if not name: return [] @@ -836,7 +831,7 @@ class TheMovieDbModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 合集信息列表 """ - if source and source != "themoviedb": + if source and not is_media_source_selected(source, "themoviedb"): return None if not name: return [] @@ -1232,9 +1227,7 @@ class TheMovieDbModule(_ModuleBase): :param source: 请求级搜索数据源 :return: 媒体信息列表 """ - if source and source != "themoviedb": - return None - if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: + if not is_media_source_enabled(source, "themoviedb"): return None if not meta.name: return [] diff --git a/app/utils/media.py b/app/utils/media.py index 4e60caaf2..ce914e215 100644 --- a/app/utils/media.py +++ b/app/utils/media.py @@ -1,5 +1,7 @@ from typing import Any, Optional, Tuple +from app.core.config import settings + MEDIA_SOURCE_ALIASES = { "tmdb": "themoviedb", @@ -32,6 +34,38 @@ def normalize_media_source(source: Optional[str]) -> Optional[str]: return MEDIA_SOURCE_ALIASES.get(normalized, normalized or None) +def is_media_source_selected(source: Optional[str], source_key: str) -> bool: + """ + 判断请求级搜索数据源列表(逗号分隔,可为多个)中是否包含指定数据源。 + + :param source: 请求级搜索数据源,逗号分隔多个来源,空表示不作限制 + :param source_key: 当前模块对应的数据源标识 + :return: 是否包含 + """ + if not source: + return True + normalized_key = normalize_media_source(source_key) or source_key + return normalized_key in [ + normalize_media_source(item) for item in str(source).split(",") + ] + + +def is_media_source_enabled(source: Optional[str], source_key: str) -> bool: + """ + 判断媒体搜索时数据源是否启用:请求级 source(逗号分隔多数据源)优先, + 未指定时回退到全局 SEARCH_SOURCE 配置,两者均未配置时全部启用。 + + :param source: 请求级搜索数据源,逗号分隔多个来源 + :param source_key: 当前模块对应的数据源标识 + :return: 是否启用 + """ + if source: + return is_media_source_selected(source, source_key) + if settings.SEARCH_SOURCE: + return is_media_source_selected(settings.SEARCH_SOURCE, source_key) + return True + + def parse_media_key(media_key: Optional[str]) -> Tuple[Optional[str], Optional[str]]: """解析带来源前缀的媒体键,返回规范化数据源与原生 ID。""" if not media_key or ":" not in str(media_key): diff --git a/tests/test_media_search_source_selection.py b/tests/test_media_search_source_selection.py index 00dad0e03..204d2a371 100644 --- a/tests/test_media_search_source_selection.py +++ b/tests/test_media_search_source_selection.py @@ -7,6 +7,7 @@ from app.api.endpoints.media import search from app.chain import ChainBase from app.modules.douban import DoubanModule from app.modules.themoviedb import TheMovieDbModule +from app.schemas.types import MediaType @pytest.mark.parametrize( @@ -38,6 +39,27 @@ def test_media_search_endpoint_forwards_source( search_method.assert_awaited_once_with(name="测试", source=source) +def test_media_search_endpoint_forwards_multi_source() -> None: + """媒体搜索接口应将逗号分隔的多数据源原样下传到处理链。""" + chain = Mock() + chain.async_search = AsyncMock(return_value=(Mock(), [])) + + with patch("app.api.endpoints.media.MediaChain", return_value=chain): + result = asyncio.run( + search( + title="测试", + type="media", + source="themoviedb,douban", + _=Mock(), + ) + ) + + assert result == [] + chain.async_search.assert_awaited_once_with( + title="测试", source="themoviedb,douban" + ) + + @pytest.mark.parametrize( ("method_name", "module_method_name"), [ @@ -120,3 +142,72 @@ def test_tmdb_collection_search_rejects_unsupported_source() -> None: assert result is None module.tmdb.async_search_collections.assert_not_awaited() + + +def test_tmdb_collection_search_supports_multi_source_request() -> None: + """TMDB合集搜索应支持请求级逗号分隔多数据源。""" + module = TheMovieDbModule() + module.tmdb = Mock() + module.tmdb.async_search_collections = AsyncMock(return_value=[]) + + result = asyncio.run( + module.async_search_collections( + name="测试", source="themoviedb,douban" + ) + ) + + assert result == [] + module.tmdb.async_search_collections.assert_awaited_once_with("测试") + + +def test_tmdb_media_search_supports_multi_source_request(monkeypatch) -> None: + """TMDB媒体搜索应支持请求级逗号分隔多数据源,不被其他来源请求阻断。""" + monkeypatch.setattr("app.modules.themoviedb.settings.SEARCH_SOURCE", "douban") + module = TheMovieDbModule() + module.tmdb = Mock() + module.tmdb.search_multiis = Mock(return_value=[]) + meta = Mock() + meta.name = "测试" + meta.type = MediaType.UNKNOWN + meta.year = None + + skipped = module.search_medias(meta=meta, source="douban") + result = module.search_medias(meta=meta, source="themoviedb,douban") + + assert skipped is None + assert result == [] + module.tmdb.search_multiis.assert_called_once_with("测试") + + +def test_douban_media_search_supports_multi_source_request(monkeypatch) -> None: + """豆瓣媒体搜索应支持请求级逗号分隔多数据源,并跟随全局配置参与搜索。""" + monkeypatch.setattr("app.modules.douban.settings.SEARCH_SOURCE", "themoviedb") + module = DoubanModule() + module.doubanapi = Mock() + module.doubanapi.async_search = AsyncMock(return_value={"items": []}) + meta = Mock() + meta.name = "测试" + + result = asyncio.run( + module.async_search_medias(meta=meta, source="themoviedb,douban") + ) + + assert result == [] + module.doubanapi.async_search.assert_awaited_once_with("测试") + + +def test_multi_source_request_keeps_missing_module_skipped(monkeypatch) -> None: + """请求级多数据源未包含的模块应跳过,避免无关模块参与搜索。""" + monkeypatch.setattr("app.modules.douban.settings.SEARCH_SOURCE", "themoviedb") + module = DoubanModule() + module.doubanapi = Mock() + module.doubanapi.async_search = AsyncMock(return_value={"items": []}) + meta = Mock() + meta.name = "测试" + + skipped = asyncio.run( + module.async_search_medias(meta=meta, source="themoviedb") + ) + + assert skipped is None + module.doubanapi.async_search.assert_not_awaited()