mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix(search): 支持单次搜索启用多个媒体数据源,合并显示所有源结果 (#6270)
- 新增 is_media_source_enabled/is_media_source_selected,请求级 source 支持逗号分隔多数据源,缺省回退全局 SEARCH_SOURCE 配置 - 四个媒体模块的搜索/人物/合集方法统一接入多源判断 - 补充多数据源搜索相关测试
This commit is contained in:
@@ -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 []
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user