fix(search): 支持单次搜索启用多个媒体数据源,合并显示所有源结果 (#6270)

- 新增 is_media_source_enabled/is_media_source_selected,请求级 source 支持逗号分隔多数据源,缺省回退全局 SEARCH_SOURCE 配置
- 四个媒体模块的搜索/人物/合集方法统一接入多源判断
- 补充多数据源搜索相关测试
This commit is contained in:
jxxghp
2026-08-11 21:06:52 +08:00
parent 9221f6e74a
commit d7cf853bc8
6 changed files with 143 additions and 38 deletions
+3 -6
View File
@@ -9,6 +9,7 @@ from app.log import logger
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.modules.anilist.anilist import AniListApi from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.media import is_media_source_enabled
class AniListModule(_ModuleBase): class AniListModule(_ModuleBase):
@@ -309,9 +310,7 @@ class AniListModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 统一媒体信息列表 :return: 统一媒体信息列表
""" """
if source and source != "anilist": if not is_media_source_enabled(source, "anilist"):
return None
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
return None return None
if not meta or not meta.name: if not meta or not meta.name:
return [] return []
@@ -331,9 +330,7 @@ class AniListModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 统一媒体信息列表 :return: 统一媒体信息列表
""" """
if source and source != "anilist": if not is_media_source_enabled(source, "anilist"):
return None
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
return None return None
if not meta or not meta.name: if not meta or not meta.name:
return [] return []
+3 -6
View File
@@ -10,6 +10,7 @@ from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi from app.modules.bangumi.bangumi import BangumiApi
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.media import is_media_source_enabled
class BangumiModule(_ModuleBase): class BangumiModule(_ModuleBase):
@@ -202,9 +203,7 @@ class BangumiModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 媒体信息 :return: 媒体信息
""" """
if source and source != "bangumi": if not is_media_source_enabled(source, "bangumi"):
return None
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
return None return None
if not meta.name: if not meta.name:
return [] return []
@@ -224,9 +223,7 @@ class BangumiModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 媒体信息 :return: 媒体信息
""" """
if source and source != "bangumi": if not is_media_source_enabled(source, "bangumi"):
return None
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
return None return None
if not meta.name: if not meta.name:
return [] return []
+5 -12
View File
@@ -17,6 +17,7 @@ from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
from app.utils.common import retry from app.utils.common import retry
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.limit import rate_limit_exponential 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 from app.utils.zhconv import convert as zhconv_convert
@@ -844,9 +845,7 @@ class DoubanModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 媒体信息 :return: 媒体信息
""" """
if source and source != "douban": if not is_media_source_enabled(source, "douban"):
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None return None
if not meta.name: if not meta.name:
return [] return []
@@ -865,9 +864,7 @@ class DoubanModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 媒体信息 :return: 媒体信息
""" """
if source and source != "douban": if not is_media_source_enabled(source, "douban"):
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None return None
if not meta.name: if not meta.name:
return [] return []
@@ -886,9 +883,7 @@ class DoubanModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 人物信息列表 :return: 人物信息列表
""" """
if source and source != "douban": if not is_media_source_enabled(source, "douban"):
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None return None
if not name: if not name:
return [] return []
@@ -913,9 +908,7 @@ class DoubanModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 人物信息列表 :return: 人物信息列表
""" """
if source and source != "douban": if not is_media_source_enabled(source, "douban"):
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None return None
if not name: if not name:
return [] return []
+7 -14
View File
@@ -16,6 +16,7 @@ from app.modules.themoviedb.tmdbapi import TmdbApi
from app.schemas.category import CategoryConfig from app.schemas.category import CategoryConfig
from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType
from app.utils.http import RequestUtils 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 from app.utils.zhconv import convert as zhconv_convert
@@ -744,9 +745,7 @@ class TheMovieDbModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 媒体信息列表 :return: 媒体信息列表
""" """
if source and source != "themoviedb": if not is_media_source_enabled(source, "themoviedb"):
return None
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
return None return None
if not meta.name: if not meta.name:
return [] return []
@@ -778,9 +777,7 @@ class TheMovieDbModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 人物信息列表 :return: 人物信息列表
""" """
if source and source != "themoviedb": if not is_media_source_enabled(source, "themoviedb"):
return None
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
return None return None
if not name: if not name:
return [] return []
@@ -798,9 +795,7 @@ class TheMovieDbModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 人物信息列表 :return: 人物信息列表
""" """
if source and source != "themoviedb": if not is_media_source_enabled(source, "themoviedb"):
return None
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
return None return None
if not name: if not name:
return [] return []
@@ -818,7 +813,7 @@ class TheMovieDbModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 合集信息列表 :return: 合集信息列表
""" """
if source and source != "themoviedb": if source and not is_media_source_selected(source, "themoviedb"):
return None return None
if not name: if not name:
return [] return []
@@ -836,7 +831,7 @@ class TheMovieDbModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 合集信息列表 :return: 合集信息列表
""" """
if source and source != "themoviedb": if source and not is_media_source_selected(source, "themoviedb"):
return None return None
if not name: if not name:
return [] return []
@@ -1232,9 +1227,7 @@ class TheMovieDbModule(_ModuleBase):
:param source: 请求级搜索数据源 :param source: 请求级搜索数据源
:return: 媒体信息列表 :return: 媒体信息列表
""" """
if source and source != "themoviedb": if not is_media_source_enabled(source, "themoviedb"):
return None
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
return None return None
if not meta.name: if not meta.name:
return [] return []
+34
View File
@@ -1,5 +1,7 @@
from typing import Any, Optional, Tuple from typing import Any, Optional, Tuple
from app.core.config import settings
MEDIA_SOURCE_ALIASES = { MEDIA_SOURCE_ALIASES = {
"tmdb": "themoviedb", "tmdb": "themoviedb",
@@ -32,6 +34,38 @@ def normalize_media_source(source: Optional[str]) -> Optional[str]:
return MEDIA_SOURCE_ALIASES.get(normalized, normalized or None) 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]]: def parse_media_key(media_key: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""解析带来源前缀的媒体键,返回规范化数据源与原生 ID。""" """解析带来源前缀的媒体键,返回规范化数据源与原生 ID。"""
if not media_key or ":" not in str(media_key): if not media_key or ":" not in str(media_key):
@@ -7,6 +7,7 @@ from app.api.endpoints.media import search
from app.chain import ChainBase from app.chain import ChainBase
from app.modules.douban import DoubanModule from app.modules.douban import DoubanModule
from app.modules.themoviedb import TheMovieDbModule from app.modules.themoviedb import TheMovieDbModule
from app.schemas.types import MediaType
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -38,6 +39,27 @@ def test_media_search_endpoint_forwards_source(
search_method.assert_awaited_once_with(name="测试", source=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( @pytest.mark.parametrize(
("method_name", "module_method_name"), ("method_name", "module_method_name"),
[ [
@@ -120,3 +142,72 @@ def test_tmdb_collection_search_rejects_unsupported_source() -> None:
assert result is None assert result is None
module.tmdb.async_search_collections.assert_not_awaited() 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()