mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
fix(indexer): 透传 mTorrent、HDDolby、Yema 的站点分类 (#7080)
* fix(indexer): 站点资源页页码归一化并让 mTorrent/HDDolby/Yema 支持分类透传 站点资源页传 1 起始页码,链路按 0 起始处理,mTorrent 再 +1 后请求 pageNumber=2, 导致单页关键词搜索返回空;同时站点页选择的分类没有透传到专站适配器, Movie/TV 预置分类互相覆盖,分类筛选不生效。 - site.py: page 传入前归一化为 max(page-1, 0) - indexer: mTorrent/HDDolby/Yema 专站参数补充 cat - mtorrent.py: search/async_search/__get_params 支持 cat,显式分类优先 - hddolby.py: search/async_search/__get_params 支持 cat,显式分类优先 - yema.py: search/async_search/_prepare_search_requests 支持 cat,显式分类优先 * fix(indexer): preserve browse paging and support category lists --------- Co-authored-by: GuanAllen <guanyelin@users.noreply.github.com> Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
@@ -687,7 +687,10 @@ async def site_resource(
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
浏览站点资源
|
||||
浏览站点资源,沿用前端和索引模块从 0 开始的分页约定。
|
||||
|
||||
:param cat: 逗号分隔的站点分类 ID,未指定时不限制分类
|
||||
:param page: 从 0 开始的页码,默认查询第一页
|
||||
"""
|
||||
site = await query.get(site_id)
|
||||
if not site:
|
||||
|
||||
@@ -80,11 +80,11 @@ SPIDER_PARSER_CLASSES: dict[str, type[_PagedSpider]] = {
|
||||
_SPECIALIZED_SEARCH_ARGUMENTS = {
|
||||
"TNodeSpider": ("keyword", "page"),
|
||||
"TorrentLeech": ("keyword", "mtype", "page"),
|
||||
"mTorrent": ("keyword", "mtype", "page"),
|
||||
"mTorrent": ("keyword", "mtype", "cat", "page"),
|
||||
"SunnyPT": ("keyword", "mtype", "cat", "page"),
|
||||
"Yema": ("keyword", "mtype", "page"),
|
||||
"Yema": ("keyword", "mtype", "cat", "page"),
|
||||
"Haidan": ("keyword", "mtype"),
|
||||
"HDDolby": ("keyword", "mtype", "page"),
|
||||
"HDDolby": ("keyword", "mtype", "cat", "page"),
|
||||
"RousiPro": ("keyword", "mtype", "cat", "page"),
|
||||
"Milkie": ("keyword", "mtype", "page"),
|
||||
}
|
||||
|
||||
@@ -92,11 +92,20 @@ class HddolbySpider:
|
||||
self._searchurl = f"https://api.{self._domain_host}/api/v1/torrent/search"
|
||||
self._pageurl = f"{self._domain}details.php?id=%s&hit=1"
|
||||
|
||||
def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> dict[str, object]:
|
||||
def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0,
|
||||
cat: Optional[str] = None) -> dict[str, object]:
|
||||
"""
|
||||
获取请求参数。
|
||||
构造请求参数,显式站点分类优先于媒体类型预置分类。
|
||||
|
||||
:param keyword: 搜索关键词
|
||||
:param mtype: 未指定站点分类时使用的媒体类型
|
||||
:param page: 从 0 开始的搜索页码
|
||||
:param cat: 逗号分隔的站点分类 ID,未指定时沿用媒体类型筛选
|
||||
:return: HDDolby 搜索请求体
|
||||
"""
|
||||
if mtype == MediaType.TV:
|
||||
if cat:
|
||||
categories = [int(category) for category in cat.split(",")]
|
||||
elif mtype == MediaType.TV:
|
||||
categories = self._tv_category
|
||||
elif mtype == MediaType.MOVIE:
|
||||
categories = self._movie_category
|
||||
@@ -200,17 +209,19 @@ class HddolbySpider:
|
||||
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
|
||||
return True, []
|
||||
|
||||
def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
|
||||
def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0,
|
||||
cat: Optional[str] = None) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
同步搜索 HDDolby 种子。
|
||||
|
||||
:param keyword: 搜索关键词;为空时按站点接口规则浏览资源
|
||||
:param mtype: 目标媒体类型,未指定时不额外限制媒体类型
|
||||
:param page: 从 0 开始的搜索页码
|
||||
:param cat: 逗号分隔的站点分类 ID,优先于媒体类型
|
||||
:return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源
|
||||
"""
|
||||
|
||||
params = self.__get_params(keyword, mtype, page)
|
||||
params = self.__get_params(keyword, mtype, page, cat)
|
||||
|
||||
res = RequestUtils(
|
||||
headers={
|
||||
@@ -225,16 +236,18 @@ class HddolbySpider:
|
||||
).post_res(url=self._searchurl, json=params)
|
||||
return self.__process_response(res)
|
||||
|
||||
async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
|
||||
async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0,
|
||||
cat: Optional[str] = None) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
异步搜索 HDDolby 种子。
|
||||
|
||||
:param keyword: 搜索关键词;为空时按站点接口规则浏览资源
|
||||
:param mtype: 目标媒体类型,未指定时不额外限制媒体类型
|
||||
:param page: 从 0 开始的搜索页码
|
||||
:param cat: 逗号分隔的站点分类 ID,优先于媒体类型
|
||||
:return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源
|
||||
"""
|
||||
params = self.__get_params(keyword, mtype, page)
|
||||
params = self.__get_params(keyword, mtype, page, cat)
|
||||
|
||||
res = await AsyncRequestUtils(
|
||||
headers={
|
||||
|
||||
@@ -121,11 +121,20 @@ class MTorrentSpider:
|
||||
registered_domain = site_rules.extract_domain(domain_url)
|
||||
return f"api.{registered_domain}" if registered_domain else host
|
||||
|
||||
def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0) -> dict[str, object]:
|
||||
def __get_params(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0,
|
||||
cat: Optional[str] = None) -> dict[str, object]:
|
||||
"""
|
||||
获取请求参数。
|
||||
构造请求参数,显式站点分类优先于媒体类型预置分类。
|
||||
|
||||
:param keyword: 搜索关键词,IMDb 标识会转换为完整链接
|
||||
:param mtype: 未指定站点分类时使用的媒体类型
|
||||
:param page: 从 0 开始的页码,提交到站点时转换为从 1 开始
|
||||
:param cat: 逗号分隔的站点分类 ID,未指定时沿用媒体类型筛选
|
||||
:return: M-Team 搜索请求体
|
||||
"""
|
||||
if not mtype:
|
||||
if cat:
|
||||
categories = [category.strip() for category in cat.split(",")]
|
||||
elif not mtype:
|
||||
categories = []
|
||||
elif mtype == MediaType.TV:
|
||||
categories = self._tv_category
|
||||
@@ -226,7 +235,7 @@ class MTorrentSpider:
|
||||
return True, []
|
||||
|
||||
def search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0,
|
||||
search_type: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]:
|
||||
search_type: Optional[str] = None, cat: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]:
|
||||
"""
|
||||
搜索种子或字幕。
|
||||
|
||||
@@ -234,6 +243,7 @@ class MTorrentSpider:
|
||||
:param mtype: 目标媒体类型,未指定时不额外限制媒体类型
|
||||
:param page: 从 0 开始的搜索页码
|
||||
:param search_type: 资源类型;subtitles 表示字幕,其余值搜索种子
|
||||
:param cat: 逗号分隔的站点分类 ID,优先于媒体类型,仅对种子搜索生效
|
||||
:return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源
|
||||
"""
|
||||
if search_type == "subtitles":
|
||||
@@ -241,7 +251,7 @@ class MTorrentSpider:
|
||||
if not self._apikey:
|
||||
return True, []
|
||||
|
||||
params = self.__get_params(keyword, mtype, page)
|
||||
params = self.__get_params(keyword, mtype, page, cat)
|
||||
|
||||
res = RequestUtils(
|
||||
headers={
|
||||
@@ -256,7 +266,7 @@ class MTorrentSpider:
|
||||
return self.__process_response(res)
|
||||
|
||||
async def async_search(self, keyword: str, mtype: Optional[MediaType] = None, page: Optional[int] = 0,
|
||||
search_type: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]:
|
||||
search_type: Optional[str] = None, cat: Optional[str] = None) -> Tuple[bool, List[dict[str, Any]]]:
|
||||
"""
|
||||
异步搜索种子或字幕。
|
||||
|
||||
@@ -264,6 +274,7 @@ class MTorrentSpider:
|
||||
:param mtype: 目标媒体类型,未指定时不额外限制媒体类型
|
||||
:param page: 从 0 开始的搜索页码
|
||||
:param search_type: 资源类型;subtitles 表示字幕,其余值搜索种子
|
||||
:param cat: 逗号分隔的站点分类 ID,优先于媒体类型,仅对种子搜索生效
|
||||
:return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源
|
||||
"""
|
||||
if search_type == "subtitles":
|
||||
@@ -271,7 +282,7 @@ class MTorrentSpider:
|
||||
if not self._apikey:
|
||||
return True, []
|
||||
|
||||
params = self.__get_params(keyword, mtype, page)
|
||||
params = self.__get_params(keyword, mtype, page, cat)
|
||||
|
||||
res = await AsyncRequestUtils(
|
||||
headers={
|
||||
|
||||
@@ -225,16 +225,27 @@ class YemaSpider:
|
||||
keyword: Optional[str],
|
||||
mtype: Optional[MediaType],
|
||||
page: Optional[int],
|
||||
cat: Optional[str] = None,
|
||||
) -> Optional[List[dict[str, Any]]]:
|
||||
"""
|
||||
统一执行认证准入并生成各分类的搜索请求体。
|
||||
|
||||
:param keyword: 搜索关键词
|
||||
:param mtype: 未指定站点分类时使用的媒体类型
|
||||
:param page: 从 0 开始的搜索页码
|
||||
:param cat: 逗号分隔的站点分类 ID,优先于媒体类型并逐分类查询
|
||||
:return: 各分类的请求体;缺少认证信息时返回 None
|
||||
"""
|
||||
if not self._api_key:
|
||||
logger.warning(f"{self._name} 未配置 API AuthKey")
|
||||
return None
|
||||
if cat:
|
||||
category_ids: List[Optional[int]] = [int(category) for category in cat.split(",")]
|
||||
else:
|
||||
category_ids = self._search_category_ids(mtype)
|
||||
return [
|
||||
self._build_params(keyword, page, category_id)
|
||||
for category_id in self._search_category_ids(mtype)
|
||||
for category_id in category_ids
|
||||
]
|
||||
|
||||
def _finalize_search_responses(
|
||||
@@ -258,6 +269,7 @@ class YemaSpider:
|
||||
keyword: Optional[str],
|
||||
mtype: Optional[MediaType] = None,
|
||||
page: Optional[int] = 0,
|
||||
cat: Optional[str] = None,
|
||||
) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
同步搜索 YemaPT 公开种子。
|
||||
@@ -265,12 +277,14 @@ class YemaSpider:
|
||||
:param keyword: 搜索关键字
|
||||
:param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询
|
||||
:param page: MoviePilot 从 0 开始的页码
|
||||
:param cat: 逗号分隔的站点分类 ID,优先于媒体类型并逐分类查询
|
||||
:return: 是否失败及标准种子列表
|
||||
"""
|
||||
payloads = self._prepare_search_requests(
|
||||
keyword=keyword,
|
||||
mtype=mtype,
|
||||
page=page,
|
||||
cat=cat,
|
||||
)
|
||||
if payloads is None:
|
||||
return True, []
|
||||
@@ -293,6 +307,7 @@ class YemaSpider:
|
||||
keyword: Optional[str],
|
||||
mtype: Optional[MediaType] = None,
|
||||
page: Optional[int] = 0,
|
||||
cat: Optional[str] = None,
|
||||
) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
异步搜索 YemaPT 公开种子。
|
||||
@@ -300,12 +315,14 @@ class YemaSpider:
|
||||
:param keyword: 搜索关键字
|
||||
:param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询
|
||||
:param page: MoviePilot 从 0 开始的页码
|
||||
:param cat: 逗号分隔的站点分类 ID,优先于媒体类型并逐分类查询
|
||||
:return: 是否失败及标准种子列表
|
||||
"""
|
||||
payloads = self._prepare_search_requests(
|
||||
keyword=keyword,
|
||||
mtype=mtype,
|
||||
page=page,
|
||||
cat=cat,
|
||||
)
|
||||
if payloads is None:
|
||||
return True, []
|
||||
|
||||
60
tests/test_indexer_category.py
Normal file
60
tests/test_indexer_category.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
验证 API 站点搜索的多选分类优先级及同步、异步请求体。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.indexer.spider import hddolby, mtorrent, yema
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize("selection", [None, "", "single", "multiple"])
|
||||
@pytest.mark.parametrize(
|
||||
("module", "spider_class", "selected_ids", "default_ids"),
|
||||
[
|
||||
(mtorrent, mtorrent.MTorrentSpider, ["402", "403"], mtorrent.MTorrentSpider._movie_category),
|
||||
(hddolby, hddolby.HddolbySpider, [402, 403], hddolby.HddolbySpider._movie_category),
|
||||
(yema, yema.YemaSpider, [5, 6], yema.YemaSpider._movie_category),
|
||||
],
|
||||
)
|
||||
def test_search_requests_use_explicit_categories_before_media_type(
|
||||
monkeypatch, asynchronous, selection, module, spider_class, selected_ids, default_ids,
|
||||
):
|
||||
"""
|
||||
单选和多选都优先于电影预置分类,空分类保持旧行为及各站分页单位。
|
||||
"""
|
||||
if module is not yema:
|
||||
monkeypatch.setattr(module, "get_configured_system_config", lambda: None)
|
||||
spider = spider_class({
|
||||
"id": 1, "name": "测试站点", "domain": "https://site.example/",
|
||||
"apikey": "offline-key", "ua": "MoviePilot-Test", "proxy": False,
|
||||
})
|
||||
ids = selected_ids[:1] if selection == "single" else selected_ids
|
||||
cat = ",".join(str(category) for category in ids) if selection else selection
|
||||
expected_ids = ids if selection else default_ids
|
||||
payload = {"success": True, "data": []} if module is not mtorrent else {"data": {"data": []}}
|
||||
response = SimpleNamespace(status_code=200, json=lambda: payload)
|
||||
request = AsyncMock(return_value=response) if asynchronous else Mock(return_value=response)
|
||||
transport = module.AsyncRequestUtils if asynchronous else module.RequestUtils
|
||||
monkeypatch.setattr(transport, "post_res", request)
|
||||
|
||||
arguments = {"keyword": "关键词", "mtype": MediaType.MOVIE, "page": 2, "cat": cat}
|
||||
result = asyncio.run(spider.async_search(**arguments)) if asynchronous else spider.search(**arguments)
|
||||
|
||||
assert result == (False, [])
|
||||
requests = [call.kwargs["json"] for call in request.call_args_list]
|
||||
if module is yema:
|
||||
assert [body["categoryId"] for body in requests] == expected_ids
|
||||
assert all(body["pageParam"] == {"current": 3, "pageSize": 40} for body in requests)
|
||||
assert all(body["keyword"] == "关键词" for body in requests)
|
||||
else:
|
||||
assert len(requests) == 1
|
||||
assert requests[0]["categories"] == expected_ids
|
||||
page_key = "pageNumber" if module is mtorrent else "page_number"
|
||||
assert requests[0][page_key] == (3 if module is mtorrent else 2)
|
||||
assert requests[0]["keyword"] == "关键词"
|
||||
@@ -399,7 +399,7 @@ def test_yema_sync_async_entries_share_request_plan_and_result_finalizer(
|
||||
spider.async_search(keyword="Movie", mtype=MediaType.MUSIC, page=2)
|
||||
)
|
||||
|
||||
expected_plan = {"keyword": "Movie", "mtype": MediaType.MUSIC, "page": 2}
|
||||
expected_plan = {"keyword": "Movie", "mtype": MediaType.MUSIC, "page": 2, "cat": None}
|
||||
assert sync_result == async_result == (False, [{"title": "projected"}])
|
||||
assert prepare == [expected_plan, expected_plan]
|
||||
assert finalized == [
|
||||
|
||||
@@ -80,7 +80,7 @@ def _install_search_observers(monkeypatch, events: list[tuple]) -> None:
|
||||
),
|
||||
(
|
||||
"mTorrent",
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3},
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3},
|
||||
),
|
||||
(
|
||||
"SunnyPT",
|
||||
@@ -93,7 +93,7 @@ def _install_search_observers(monkeypatch, events: list[tuple]) -> None:
|
||||
),
|
||||
(
|
||||
"Yema",
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3},
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3},
|
||||
),
|
||||
(
|
||||
"Milkie",
|
||||
@@ -105,7 +105,7 @@ def _install_search_observers(monkeypatch, events: list[tuple]) -> None:
|
||||
),
|
||||
(
|
||||
"HDDolby",
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3},
|
||||
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3},
|
||||
),
|
||||
(
|
||||
"RousiPro",
|
||||
@@ -136,6 +136,9 @@ def test_torrent_search_uses_identical_parser_selection_and_arguments(
|
||||
"""
|
||||
|
||||
def __init__(self, site):
|
||||
"""
|
||||
保存待验证的站点配置。
|
||||
"""
|
||||
self.site = site
|
||||
|
||||
def search(self, **kwargs):
|
||||
|
||||
32
tests/test_site_resource.py
Normal file
32
tests/test_site_resource.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
验证站点浏览接口保留零起始页码和多选分类。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints import site as site_endpoint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page", [0, 1, 2])
|
||||
def test_site_resource_preserves_zero_based_pages_and_categories(monkeypatch, page):
|
||||
"""
|
||||
第一页与后续页必须保持独立偏移,多选分类原样交给浏览链路。
|
||||
"""
|
||||
query = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(domain="https://site.example/")))
|
||||
torrent = SimpleNamespace(to_dict=lambda: {"title": "命中资源"})
|
||||
browse = AsyncMock(return_value=[torrent])
|
||||
monkeypatch.setattr(site_endpoint, "TorrentsChain", lambda: SimpleNamespace(async_browse=browse))
|
||||
|
||||
result = asyncio.run(site_endpoint.site_resource(
|
||||
site_id=1, keyword="关键词", cat="401,402", page=page, query=query,
|
||||
))
|
||||
|
||||
assert result == [{"title": "命中资源"}]
|
||||
query.get.assert_awaited_once_with(1)
|
||||
browse.assert_awaited_once_with(
|
||||
domain="https://site.example/", keyword="关键词", cat="401,402", page=page, mtype=None,
|
||||
)
|
||||
Reference in New Issue
Block a user