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:
GuanAllen
2026-10-19 18:33:28 +08:00
committed by GitHub
parent c85f9b262f
commit 458329b3f7
9 changed files with 162 additions and 23 deletions

View File

@@ -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:

View File

@@ -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"),
}

View File

@@ -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={

View File

@@ -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={

View File

@@ -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, []

View 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"] == "关键词"

View File

@@ -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 == [

View File

@@ -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):

View 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,
)