mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
* 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>
508 lines
15 KiB
Python
508 lines
15 KiB
Python
"""
|
|
验证站点索引同步与异步入口共享请求计划、投影和失败语义。
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
import app.modules.indexer as indexer_module
|
|
from app.modules.indexer import IndexerModule
|
|
from app.schemas.types import MediaType
|
|
|
|
|
|
def _install_search_observers(monkeypatch, events: list[tuple]) -> None:
|
|
"""
|
|
安装只记录业务顺序且不访问外部资源的搜索边界替身
|
|
"""
|
|
|
|
def search_check(site, keyword=None):
|
|
events.append(("check", site["name"], keyword))
|
|
return True
|
|
|
|
def clear_search_text(keyword):
|
|
events.append(("normalize", keyword))
|
|
return "clean keyword"
|
|
|
|
def statistic(*, site, error_flag=False, seconds=0):
|
|
events.append(("statistic", site["name"], error_flag, seconds))
|
|
|
|
async def async_statistic(*, site, error_flag=False, seconds=0):
|
|
events.append(("statistic", site["name"], error_flag, seconds))
|
|
|
|
def parse_result(*, site, result_array, seconds):
|
|
events.append(("parse", site["name"], tuple(result_array), seconds))
|
|
return list(result_array)
|
|
|
|
def parse_subtitle_result(*, site, result_array, seconds):
|
|
events.append(("parse", site["name"], tuple(result_array), seconds))
|
|
return list(result_array)
|
|
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__search_check",
|
|
staticmethod(search_check),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__clear_search_text",
|
|
staticmethod(clear_search_text),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__indexer_statistic",
|
|
staticmethod(statistic),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__async_indexer_statistic",
|
|
staticmethod(async_statistic),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__parse_result",
|
|
staticmethod(parse_result),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__parse_subtitle_result",
|
|
staticmethod(parse_subtitle_result),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("parser_name", "expected_arguments"),
|
|
[
|
|
("TNodeSpider", {"keyword": "clean keyword", "page": 3}),
|
|
(
|
|
"TorrentLeech",
|
|
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3},
|
|
),
|
|
(
|
|
"mTorrent",
|
|
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3},
|
|
),
|
|
(
|
|
"SunnyPT",
|
|
{
|
|
"keyword": "clean keyword",
|
|
"mtype": MediaType.MOVIE,
|
|
"cat": "movie",
|
|
"page": 3,
|
|
},
|
|
),
|
|
(
|
|
"Yema",
|
|
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3},
|
|
),
|
|
(
|
|
"Milkie",
|
|
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "page": 3},
|
|
),
|
|
(
|
|
"Haidan",
|
|
{"keyword": "clean keyword", "mtype": MediaType.MOVIE},
|
|
),
|
|
(
|
|
"HDDolby",
|
|
{"keyword": "clean keyword", "mtype": MediaType.MOVIE, "cat": "movie", "page": 3},
|
|
),
|
|
(
|
|
"RousiPro",
|
|
{
|
|
"keyword": "clean keyword",
|
|
"mtype": MediaType.MOVIE,
|
|
"cat": "movie",
|
|
"page": 3,
|
|
},
|
|
),
|
|
],
|
|
)
|
|
def test_torrent_search_uses_identical_parser_selection_and_arguments(
|
|
monkeypatch,
|
|
parser_name,
|
|
expected_arguments,
|
|
):
|
|
"""
|
|
各专用解析器的同步、异步入口必须消费同一参数投影
|
|
"""
|
|
events = []
|
|
calls = []
|
|
_install_search_observers(monkeypatch, events)
|
|
|
|
class FakeSpider:
|
|
"""
|
|
记录专用解析器收到的同步与异步调用
|
|
"""
|
|
|
|
def __init__(self, site):
|
|
"""
|
|
保存待验证的站点配置。
|
|
"""
|
|
self.site = site
|
|
|
|
def search(self, **kwargs):
|
|
"""
|
|
记录同步搜索的站点、请求参数与执行顺序,并返回固定命中结果。
|
|
"""
|
|
calls.append(("sync", self.site, kwargs))
|
|
events.append(("io", kwargs))
|
|
return False, [{"title": "hit"}]
|
|
|
|
async def async_search(self, **kwargs):
|
|
"""
|
|
记录异步搜索的站点、请求参数与执行顺序,并返回固定命中结果。
|
|
"""
|
|
calls.append(("async", self.site, kwargs))
|
|
events.append(("io", kwargs))
|
|
return False, [{"title": "hit"}]
|
|
|
|
monkeypatch.setitem(indexer_module.SPIDER_PARSER_CLASSES, parser_name, FakeSpider)
|
|
site = {"id": 1, "name": "Parity", "parser": parser_name}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
sync_result = module.search_torrents(
|
|
site=site,
|
|
keyword="Raw.Keyword",
|
|
mtype=MediaType.MOVIE,
|
|
cat="movie",
|
|
page=3,
|
|
)
|
|
sync_events = list(events)
|
|
events.clear()
|
|
async_result = asyncio.run(module.async_search_torrents(
|
|
site=site,
|
|
keyword="Raw.Keyword",
|
|
mtype=MediaType.MOVIE,
|
|
cat="movie",
|
|
page=3,
|
|
))
|
|
|
|
assert sync_result == async_result == [{"title": "hit"}]
|
|
assert calls == [
|
|
("sync", site, expected_arguments),
|
|
("async", site, expected_arguments),
|
|
]
|
|
assert sync_events == events
|
|
assert [event[0] for event in events] == [
|
|
"check",
|
|
"normalize",
|
|
"io",
|
|
"statistic",
|
|
"parse",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error_flag", "raw_result"),
|
|
[
|
|
(True, []),
|
|
(False, []),
|
|
(False, [{"title": "hit"}]),
|
|
],
|
|
)
|
|
def test_torrent_search_preserves_result_and_status_parity(
|
|
monkeypatch,
|
|
error_flag,
|
|
raw_result,
|
|
):
|
|
"""
|
|
成功、解析失败和空结果都必须以相同顺序完成统计与结果整理
|
|
"""
|
|
events = []
|
|
_install_search_observers(monkeypatch, events)
|
|
|
|
class FakeSpider:
|
|
"""
|
|
返回参数化状态的专用解析器替身
|
|
"""
|
|
|
|
def __init__(self, _site):
|
|
pass
|
|
|
|
def search(self, **_kwargs):
|
|
"""
|
|
记录同步请求阶段,并返回当前用例配置的错误标志与结果副本。
|
|
"""
|
|
events.append(("io",))
|
|
return error_flag, list(raw_result)
|
|
|
|
async def async_search(self, **_kwargs):
|
|
"""
|
|
记录异步请求阶段,并返回当前用例配置的错误标志与结果副本。
|
|
"""
|
|
events.append(("io",))
|
|
return error_flag, list(raw_result)
|
|
|
|
monkeypatch.setitem(indexer_module.SPIDER_PARSER_CLASSES, "TNodeSpider", FakeSpider)
|
|
site = {"id": 1, "name": "Parity", "parser": "TNodeSpider"}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
sync_result = module.search_torrents(site=site, keyword="Raw.Keyword")
|
|
sync_events = list(events)
|
|
events.clear()
|
|
async_result = asyncio.run(module.async_search_torrents(site=site, keyword="Raw.Keyword"))
|
|
|
|
assert sync_result == async_result == raw_result
|
|
assert sync_events == events
|
|
assert events[-2][0:3] == ("statistic", "Parity", error_flag)
|
|
assert events[-1][0:3] == ("parse", "Parity", tuple(raw_result))
|
|
|
|
|
|
def test_torrent_search_preserves_exception_fallback_parity(monkeypatch):
|
|
"""
|
|
解析器抛错后两种入口都沿用空结果与非解析错误统计语义
|
|
"""
|
|
events = []
|
|
_install_search_observers(monkeypatch, events)
|
|
|
|
class FailingSpider:
|
|
"""
|
|
同步与异步均抛出相同操作错误的解析器替身
|
|
"""
|
|
|
|
def __init__(self, _site):
|
|
pass
|
|
|
|
def search(self, **_kwargs):
|
|
"""
|
|
记录同步请求阶段后抛出固定异常,验证错误归一语义。
|
|
"""
|
|
events.append(("io",))
|
|
raise RuntimeError("broken")
|
|
|
|
async def async_search(self, **_kwargs):
|
|
"""
|
|
记录异步请求阶段后抛出固定异常,验证错误归一语义。
|
|
"""
|
|
events.append(("io",))
|
|
raise RuntimeError("broken")
|
|
|
|
monkeypatch.setitem(indexer_module.SPIDER_PARSER_CLASSES, "TNodeSpider", FailingSpider)
|
|
site = {"id": 1, "name": "Parity", "parser": "TNodeSpider"}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
sync_result = module.search_torrents(site=site, keyword="Raw.Keyword")
|
|
sync_events = list(events)
|
|
events.clear()
|
|
async_result = asyncio.run(module.async_search_torrents(site=site, keyword="Raw.Keyword"))
|
|
|
|
assert sync_result == async_result == []
|
|
assert sync_events == events
|
|
assert events[-2][0:3] == ("statistic", "Parity", False)
|
|
assert events[-1][0:3] == ("parse", "Parity", ())
|
|
|
|
|
|
def test_generic_torrent_search_uses_identical_request_projection(monkeypatch):
|
|
"""
|
|
普通页面 Spider 的同步、异步入口必须收到同一完整请求
|
|
"""
|
|
events = []
|
|
calls = []
|
|
_install_search_observers(monkeypatch, events)
|
|
|
|
def spider_search(**kwargs):
|
|
calls.append(("sync", kwargs))
|
|
events.append(("io", kwargs))
|
|
return False, [{"title": "generic"}], None
|
|
|
|
async def async_spider_search(**kwargs):
|
|
calls.append(("async", kwargs))
|
|
events.append(("io", kwargs))
|
|
return False, [{"title": "generic"}], None
|
|
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__spider_search",
|
|
staticmethod(spider_search),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__async_spider_search",
|
|
staticmethod(async_spider_search),
|
|
)
|
|
site = {"id": 1, "name": "Generic", "parser": "NexusPhp"}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
sync_result = module.search_torrents(
|
|
site=site,
|
|
keyword="Raw.Keyword",
|
|
mtype=MediaType.TV,
|
|
cat="series",
|
|
page=2,
|
|
)
|
|
sync_events = list(events)
|
|
events.clear()
|
|
async_result = asyncio.run(module.async_search_torrents(
|
|
site=site,
|
|
keyword="Raw.Keyword",
|
|
mtype=MediaType.TV,
|
|
cat="series",
|
|
page=2,
|
|
))
|
|
|
|
expected_arguments = {
|
|
"search_word": "clean keyword",
|
|
"indexer": site,
|
|
"mtype": MediaType.TV,
|
|
"cat": "series",
|
|
"page": 2,
|
|
}
|
|
assert sync_result == async_result == [{"title": "generic"}]
|
|
assert calls == [("sync", expected_arguments), ("async", expected_arguments)]
|
|
assert sync_events == events
|
|
|
|
|
|
def test_rejected_torrent_search_skips_normalization_io_and_statistics(monkeypatch):
|
|
"""
|
|
搜索许可拒绝后同步、异步入口都必须立即停止后续工作
|
|
"""
|
|
events = []
|
|
|
|
def search_check(_site, _keyword=None):
|
|
events.append(("check",))
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__search_check",
|
|
staticmethod(search_check),
|
|
)
|
|
site = {"id": 1, "name": "Rejected", "parser": "TNodeSpider"}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
assert module.search_torrents(site=site, keyword="Raw.Keyword") == []
|
|
assert asyncio.run(module.async_search_torrents(site=site, keyword="Raw.Keyword")) == []
|
|
assert events == [("check",), ("check",)]
|
|
|
|
|
|
def test_subtitle_search_uses_one_request_and_preserves_order(monkeypatch):
|
|
"""
|
|
字幕同步、异步入口必须共享许可、参数和状态整理顺序
|
|
"""
|
|
events = []
|
|
calls = []
|
|
_install_search_observers(monkeypatch, events)
|
|
|
|
def spider_search(**kwargs):
|
|
calls.append(("sync", kwargs))
|
|
events.append(("io", kwargs))
|
|
return True, [], None
|
|
|
|
async def async_spider_search(**kwargs):
|
|
calls.append(("async", kwargs))
|
|
events.append(("io", kwargs))
|
|
return True, [], None
|
|
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__spider_search",
|
|
staticmethod(spider_search),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__async_spider_search",
|
|
staticmethod(async_spider_search),
|
|
)
|
|
site = {"id": 1, "name": "Subtitle", "subtitles": {"search": {}}}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
sync_result = module.search_subtitles(site=site, keyword="Raw.Keyword", page=4)
|
|
sync_events = list(events)
|
|
events.clear()
|
|
async_result = asyncio.run(module.async_search_subtitles(
|
|
site=site,
|
|
keyword="Raw.Keyword",
|
|
page=4,
|
|
))
|
|
|
|
expected_arguments = {
|
|
"search_word": "clean keyword",
|
|
"indexer": site,
|
|
"page": 4,
|
|
"search_type": "subtitles",
|
|
}
|
|
assert sync_result == async_result == []
|
|
assert calls == [("sync", expected_arguments), ("async", expected_arguments)]
|
|
assert sync_events == events
|
|
assert [event[0] for event in events] == [
|
|
"check",
|
|
"normalize",
|
|
"io",
|
|
"statistic",
|
|
"parse",
|
|
]
|
|
assert events[-2][0:3] == ("statistic", "Subtitle", True)
|
|
|
|
|
|
def test_mtorrent_subtitle_search_uses_specialized_parser(monkeypatch):
|
|
"""
|
|
mTorrent 字幕搜索应走专用 API 解析器,而不是旧网页解析器。
|
|
"""
|
|
events = []
|
|
calls = []
|
|
_install_search_observers(monkeypatch, events)
|
|
|
|
class FakeSpider:
|
|
"""
|
|
记录字幕专用解析器收到的参数。
|
|
"""
|
|
|
|
error_detail = None
|
|
|
|
def __init__(self, site):
|
|
"""
|
|
保存站点配置。
|
|
"""
|
|
self.site = site
|
|
|
|
def search(self, **kwargs):
|
|
"""
|
|
记录同步字幕搜索调用。
|
|
"""
|
|
calls.append(("sync", kwargs))
|
|
events.append(("io", kwargs))
|
|
return False, [{"title": "subtitle"}]
|
|
|
|
async def async_search(self, **kwargs):
|
|
"""
|
|
记录异步字幕搜索调用。
|
|
"""
|
|
calls.append(("async", kwargs))
|
|
events.append(("io", kwargs))
|
|
return False, [{"title": "subtitle"}]
|
|
|
|
monkeypatch.setitem(indexer_module.SPIDER_PARSER_CLASSES, "mTorrent", FakeSpider)
|
|
site = {
|
|
"id": 1,
|
|
"name": "M-Team",
|
|
"parser": "mTorrent",
|
|
"subtitles": {"search": {}},
|
|
}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
sync_result = module.search_subtitles(site=site, keyword="Raw.Keyword", page=4)
|
|
async_result = asyncio.run(module.async_search_subtitles(site=site, keyword="Raw.Keyword", page=4))
|
|
|
|
expected_arguments = {
|
|
"keyword": "clean keyword",
|
|
"page": 4,
|
|
"search_type": "subtitles",
|
|
}
|
|
assert sync_result == async_result == [{"title": "subtitle"}]
|
|
assert calls == [("sync", expected_arguments), ("async", expected_arguments)]
|
|
|
|
|
|
def test_disabled_subtitle_search_skips_all_follow_up_work(monkeypatch):
|
|
"""
|
|
未启用字幕能力时两种入口都不得触发许可检查、I/O 或统计
|
|
"""
|
|
events = []
|
|
_install_search_observers(monkeypatch, events)
|
|
site = {"id": 1, "name": "Subtitle", "subtitles": None}
|
|
module = object.__new__(IndexerModule)
|
|
|
|
assert module.search_subtitles(site=site, keyword="Raw.Keyword") == []
|
|
assert asyncio.run(module.async_search_subtitles(site=site, keyword="Raw.Keyword")) == []
|
|
assert events == []
|