fix(indexer): use M-Team subtitle search API

This commit is contained in:
jxxghp
2026-10-01 12:29:28 +08:00
parent 842ba547ec
commit 58ec86b586
4 changed files with 471 additions and 25 deletions

View File

@@ -51,6 +51,10 @@ _SPECIALIZED_SEARCH_ARGUMENTS = {
"RousiPro": ("keyword", "mtype", "cat", "page"),
}
_SPECIALIZED_SUBTITLE_SEARCH_ARGUMENTS = {
"mTorrent": ("keyword", "page", "search_type"),
}
_UNKNOWN_SEARCH_FAILURE_MESSAGE = "站点请求或页面解析失败"
# NexusPHP、Gazelle 和 Unit3d 覆盖大多数站点,其余模型多为专站适配,
@@ -332,7 +336,23 @@ class IndexerModule(_ModuleBase):
return None
search_word = IndexerModule.__clear_search_text(keyword)
parser_name = str(site.get("parser") or "")
parser_class = SPIDER_PARSER_CLASSES.get(parser_name)
if search_type == "subtitles":
argument_names = _SPECIALIZED_SUBTITLE_SEARCH_ARGUMENTS.get(parser_name)
if parser_class and argument_names:
available_arguments = {
"keyword": search_word,
"page": page,
"search_type": search_type,
}
return _IndexerSearchRequest(
parser_class=parser_class,
arguments=MappingProxyType({
name: available_arguments[name]
for name in argument_names
}),
)
return _IndexerSearchRequest(
parser_class=None,
arguments=MappingProxyType({
@@ -343,8 +363,6 @@ class IndexerModule(_ModuleBase):
}),
)
parser_name = str(site.get("parser") or "")
parser_class = SPIDER_PARSER_CLASSES.get(parser_name)
argument_names = _SPECIALIZED_SEARCH_ARGUMENTS.get(parser_name)
if parser_class and argument_names:
available_arguments = {
@@ -379,11 +397,12 @@ class IndexerModule(_ModuleBase):
) -> Tuple[bool, List[dict[str, Any]], Optional[str]]:
"""通过同步解析器执行已冻结的搜索请求,返回错误标志、结果与失败原因"""
if request.parser_class:
spider = request.parser_class(site)
error_flag, result = cast(
Tuple[bool, List[dict[str, Any]]],
request.parser_class(site).search(**request.arguments),
spider.search(**request.arguments),
)
return error_flag, result, None
return error_flag, result, getattr(spider, "error_detail", None)
return IndexerModule.__spider_search(**request.arguments)
@staticmethod
@@ -393,11 +412,12 @@ class IndexerModule(_ModuleBase):
) -> Tuple[bool, List[dict[str, Any]], Optional[str]]:
"""通过异步解析器执行已冻结的搜索请求,返回错误标志、结果与失败原因"""
if request.parser_class:
spider = request.parser_class(site)
error_flag, result = cast(
Tuple[bool, List[dict[str, Any]]],
await request.parser_class(site).async_search(**request.arguments),
await spider.async_search(**request.arguments),
)
return error_flag, result, None
return error_flag, result, getattr(spider, "error_detail", None)
return await IndexerModule.__async_spider_search(**request.arguments)
@staticmethod
@@ -504,6 +524,8 @@ class IndexerModule(_ModuleBase):
result = []
start_time = datetime.now()
error_flag = False
error: Optional[Exception] = None
failure_detail: Optional[str] = None
request = self.__create_search_request(
site=site,
@@ -515,16 +537,32 @@ class IndexerModule(_ModuleBase):
return []
try:
error_flag, result, _ = self.__execute_search(site, request)
error_flag, result, failure_detail = self.__execute_search(site, request)
except Exception as err:
error = err
self.__log_search_error(site, "subtitles", err)
outcome = self.__create_search_outcome(start_time, error_flag, result)
outcome = self.__create_search_outcome(
start_time,
error_flag,
result,
error=error,
failure_detail=failure_detail,
)
self.__indexer_statistic(
site=site,
error_flag=outcome.error_flag,
seconds=outcome.seconds,
)
report_site_search_outcome(
attempted=True,
outcome=(
_classify_search_failure(outcome.error)
if outcome.error_flag or outcome.error is not None
else "success"
),
error=_search_failure_message(outcome),
)
return self.__parse_subtitle_result(
site=site,
result_array=outcome.result,
@@ -612,6 +650,8 @@ class IndexerModule(_ModuleBase):
result = []
start_time = datetime.now()
error_flag = False
error: Optional[Exception] = None
failure_detail: Optional[str] = None
request = self.__create_search_request(
site=site,
@@ -623,16 +663,32 @@ class IndexerModule(_ModuleBase):
return []
try:
error_flag, result, _ = await self.__async_execute_search(site, request)
error_flag, result, failure_detail = await self.__async_execute_search(site, request)
except Exception as err:
error = err
self.__log_search_error(site, "subtitles", err)
outcome = self.__create_search_outcome(start_time, error_flag, result)
outcome = self.__create_search_outcome(
start_time,
error_flag,
result,
error=error,
failure_detail=failure_detail,
)
await self.__async_indexer_statistic(
site=site,
error_flag=outcome.error_flag,
seconds=outcome.seconds,
)
report_site_search_outcome(
attempted=True,
outcome=(
_classify_search_failure(outcome.error)
if outcome.error_flag or outcome.error is not None
else "success"
),
error=_search_failure_message(outcome),
)
return self.__parse_subtitle_result(
site=site,
result_array=outcome.result,

View File

@@ -11,12 +11,13 @@ from app.runtime.log import logger
from app.schemas.types import MediaType
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
from app.domain import site as site_rules
from app.foundation import size as size_tools
from app.foundation import temporal as time_tools
class MTorrentSpider:
"""
mTorrent API
mTorrent API 搜索与字幕下载适配器。
"""
_indexerid = None
_domain = None
@@ -26,11 +27,13 @@ class MTorrentSpider:
_cookie = None
_ua = None
_size = 100
_searchurl = "https://api.%s/api/torrent/search"
_downloadurl = "https://api.%s/api/torrent/genDlToken"
_subtitle_list_url = "https://api.%s/api/subtitle/list"
_subtitle_genlink_url = "https://api.%s/api/subtitle/genlink"
_subtitle_download_url ="https://api.%s/api/subtitle/dlV2?credential=%s"
_subtitle_size = 20
_api_domain = None
_searchurl = "https://%s/api/torrent/search"
_downloadurl = "https://%s/api/torrent/genDlToken"
_subtitle_list_url = "https://%s/api/subtitle/list"
_subtitle_genlink_url = "https://%s/api/subtitle/genlink"
_subtitle_download_url ="https://%s/api/subtitle/dlV2?credential=%s"
_pageurl = "%sdetail/%s"
_timeout = 15
@@ -45,6 +48,8 @@ class MTorrentSpider:
_apikey = None
# JWT Token
_token = None
# 最近一次字幕搜索失败原因
error_detail: Optional[str] = None
# 标签
_labels = {
@@ -72,7 +77,8 @@ class MTorrentSpider:
self._indexerid = indexer.get('id')
self._url = indexer.get('domain')
self._domain = site_rules.extract_domain(self._url)
self._searchurl = self._searchurl % self._domain
self._api_domain = self.__resolve_api_domain(indexer)
self._searchurl = self._searchurl % self._api_domain
self._name = indexer.get('name')
if indexer.get('proxy'):
self._proxy = get_runtime_setting('PROXY')
@@ -81,6 +87,31 @@ class MTorrentSpider:
self._apikey = indexer.get('apikey')
self._token = indexer.get('token')
self._timeout = indexer.get('timeout') or 15
self.error_detail = None
@staticmethod
def __resolve_api_domain(indexer: dict) -> str:
"""
从站点资源配置解析 M-Team API 主机。
资源可以直接提供 ``api_domain`` 或 ``api_host``;未提供时沿用
M-Team 的 ``api.<注册域名>`` 约定,避免把旧网页接口写死在代码中。
"""
configured = indexer.get("api_domain") or indexer.get("api_host")
if configured:
value = str(configured).strip()
parsed = urlparse(value if "://" in value else f"//{value}")
host = parsed.netloc or parsed.path.split("/", 1)[0]
if host:
return host
domain_url = str(indexer.get("domain") or "").strip()
parsed = urlparse(domain_url if "://" in domain_url else f"//{domain_url}")
host = parsed.netloc or parsed.path.split("/", 1)[0]
if host.startswith("api."):
return host
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: MediaType = None, page: Optional[int] = 0) -> dict:
"""
@@ -179,10 +210,13 @@ class MTorrentSpider:
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
return True, []
def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0,
search_type: Optional[str] = None) -> Tuple[bool, List[dict]]:
"""
搜索
搜索种子或字幕。
"""
if search_type == "subtitles":
return self.search_subtitles(keyword=keyword, page=page)
# 检查ApiKey
if not self._apikey:
return True, []
@@ -203,10 +237,13 @@ class MTorrentSpider:
).post_res(url=self._searchurl, json=params)
return self.__process_response(res)
async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0,
search_type: Optional[str] = None) -> Tuple[bool, List[dict]]:
"""
搜索
异步搜索种子或字幕。
"""
if search_type == "subtitles":
return await self.async_search_subtitles(keyword=keyword, page=page)
# 检查ApiKey
if not self._apikey:
return True, []
@@ -227,6 +264,200 @@ class MTorrentSpider:
).post_res(url=self._searchurl, json=params)
return self.__process_response(res)
def __get_subtitle_params(self, keyword: str, page: Optional[int] = 0) -> dict:
"""构造 M-Team 字幕搜索请求体。"""
return {
"keyword": keyword,
"pageNumber": int(page) + 1,
"pageSize": self._subtitle_size,
}
@staticmethod
def __get_subtitle_rows(payload: Any) -> List[dict]:
"""兼容 M-Team 字幕接口分页数据的不同包装层级。"""
if not isinstance(payload, dict):
return []
data = payload.get("data")
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
for key in ("data", "items", "records", "list"):
rows = data.get(key)
if isinstance(rows, list):
return [item for item in rows if isinstance(item, dict)]
return []
@staticmethod
def __get_int(value: Any, default: int = 0) -> int:
"""把接口中的数字字段安全转换为整数。"""
try:
return default if value is None else int(value)
except (TypeError, ValueError):
return default
def __parse_subtitle_rows(self, results: List[dict]) -> List[dict]:
"""
解析 M-Team 字幕结果并为每条记录生成可直接下载的签名地址。
"""
subtitles = []
for result in results:
subtitle_id = result.get("id")
if subtitle_id is None:
continue
torrent = result.get("torrent")
torrent_id = torrent.get("id") if isinstance(torrent, dict) else torrent
link = self.__subtitle_genlink(str(subtitle_id))
if not link:
continue
title = result.get("name") or result.get("filename") or str(subtitle_id)
file_name = result.get("filename") or result.get("fileName")
created = result.get("createdDate") or result.get("lastModifiedDate")
if isinstance(created, (int, float)) or str(created or "").isdigit():
pubdate = time_tools.format_timestamp(created)
else:
pubdate = time_tools.normalize_datetime(str(created)) if created else None
subtitles.append({
"title": title,
"description": title,
"enclosure": link,
"page_url": self._pageurl % (self._url, torrent_id) if torrent_id else None,
"language": result.get("lang") or result.get("language") or "",
"size": size_tools.parse_size(result.get("size") or 0),
"pubdate": pubdate,
"grabs": self.__get_int(result.get("hits")),
"uploader": result.get("author"),
"torrent_id": str(torrent_id) if torrent_id is not None else None,
"subtitle_id": str(subtitle_id),
"file_name": file_name,
})
return subtitles
def __process_subtitle_response(self, res: Any) -> Tuple[bool, List[dict[str, Any]]]:
"""判定字幕搜索响应并转换为 MoviePilot 字幕字段。"""
self.error_detail = None
if res is None:
self.error_detail = f"无法连接 {self._api_domain or self._domain}"
logger.warn(f"{self._name} 字幕搜索失败,{self.error_detail}")
return True, []
if res.status_code != 200:
self.error_detail = f"HTTP {res.status_code}"
logger.warn(f"{self._name} 字幕搜索失败,错误码:{res.status_code}")
return True, []
try:
payload = res.json()
except (TypeError, ValueError):
self.error_detail = "响应不是有效 JSON"
logger.warn(f"{self._name} 字幕搜索失败,{self.error_detail}")
return True, []
if not isinstance(payload, dict) or self.__get_int(payload.get("code"), -1) != 0:
self.error_detail = str(payload.get("message") or "接口返回失败") if isinstance(payload, dict) else "接口返回失败"
logger.warn(f"{self._name} 字幕搜索失败,返回:{self.error_detail}")
return True, []
return False, self.__parse_subtitle_rows(self.__get_subtitle_rows(payload))
def search_subtitles(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
"""通过 M-Team 字幕 API 搜索字幕。"""
if not self._apikey:
self.error_detail = "未配置 API Key"
return True, []
params = self.__get_subtitle_params(keyword, page)
res = RequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}",
"x-api-key": self._apikey,
},
proxies=self._proxy,
timeout=self._timeout,
).post_res(url=f"https://{self._api_domain}/api/subtitle/search", json=params)
return self.__process_subtitle_response(res)
async def __async_subtitle_genlink(self, subtitle_id: str) -> Optional[str]:
"""异步获取字幕下载链接。"""
url = self._subtitle_genlink_url % self._api_domain
res = await AsyncRequestUtils(
headers={
"Accept": "application/json, text/plain, */*",
"User-Agent": f"{self._ua}",
"x-api-key": self._apikey,
},
proxies=self._proxy,
timeout=self._timeout,
).post_res(url, data={"id": subtitle_id})
if res and res.status_code == 200:
result = res.json()
if self.__get_int(result.get("code"), -1) == 0 and isinstance(result.get("data"), str):
return self._subtitle_download_url % (self._api_domain, result["data"])
return None
return None
async def __async_parse_subtitle_rows(self, results: List[dict]) -> List[dict]:
"""异步解析 M-Team 字幕结果并生成下载地址。"""
subtitles = []
for result in results:
subtitle_id = result.get("id")
if subtitle_id is None:
continue
torrent = result.get("torrent")
torrent_id = torrent.get("id") if isinstance(torrent, dict) else torrent
link = await self.__async_subtitle_genlink(str(subtitle_id))
if not link:
continue
title = result.get("name") or result.get("filename") or str(subtitle_id)
file_name = result.get("filename") or result.get("fileName")
created = result.get("createdDate") or result.get("lastModifiedDate")
if isinstance(created, (int, float)) or str(created or "").isdigit():
pubdate = time_tools.format_timestamp(created)
else:
pubdate = time_tools.normalize_datetime(str(created)) if created else None
subtitles.append({
"title": title,
"description": title,
"enclosure": link,
"page_url": self._pageurl % (self._url, torrent_id) if torrent_id else None,
"language": result.get("lang") or result.get("language") or "",
"size": size_tools.parse_size(result.get("size") or 0),
"pubdate": pubdate,
"grabs": self.__get_int(result.get("hits")),
"uploader": result.get("author"),
"torrent_id": str(torrent_id) if torrent_id is not None else None,
"subtitle_id": str(subtitle_id),
"file_name": file_name,
})
return subtitles
async def async_search_subtitles(self, keyword: str, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
"""异步通过 M-Team 字幕 API 搜索字幕。"""
if not self._apikey:
self.error_detail = "未配置 API Key"
return True, []
params = self.__get_subtitle_params(keyword, page)
res = await AsyncRequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}",
"x-api-key": self._apikey,
},
proxies=self._proxy,
timeout=self._timeout,
).post_res(url=f"https://{self._api_domain}/api/subtitle/search", json=params)
self.error_detail = None
if res is None:
self.error_detail = f"无法连接 {self._api_domain or self._domain}"
return True, []
if res.status_code != 200:
self.error_detail = f"HTTP {res.status_code}"
return True, []
try:
payload = res.json()
except (TypeError, ValueError):
self.error_detail = "响应不是有效 JSON"
return True, []
if not isinstance(payload, dict) or self.__get_int(payload.get("code"), -1) != 0:
self.error_detail = str(payload.get("message") or "接口返回失败") if isinstance(payload, dict) else "接口返回失败"
return True, []
return False, await self.__async_parse_subtitle_rows(self.__get_subtitle_rows(payload))
@staticmethod
def __find_imdbid(imdb: str) -> str:
"""
@@ -272,7 +503,7 @@ class MTorrentSpider:
"""
获取下载链接,返回base64编码的json字符串及URL
"""
url = self._downloadurl % self._domain
url = self._downloadurl % self._api_domain
params = {
'method': 'post',
'cookie': False,
@@ -335,7 +566,7 @@ class MTorrentSpider:
:return: 字幕ID
:rtype: List[str] | None
"""
url = self._subtitle_list_url % self._domain
url = self._subtitle_list_url % self._api_domain
# 发送请求
res = RequestUtils(
headers={
@@ -371,7 +602,7 @@ class MTorrentSpider:
:return: 下载链接
:rtype: str | None
"""
url = self._subtitle_genlink_url % self._domain
url = self._subtitle_genlink_url % self._api_domain
# 发送请求
res = RequestUtils(
headers={
@@ -385,7 +616,7 @@ class MTorrentSpider:
if res and res.status_code == 200:
result = res.json()
if int(result.get("code", -1)) == 0 and isinstance(result.get("data"), str):
return self._subtitle_download_url % (self._domain, result["data"])
return self._subtitle_download_url % (self._api_domain, result["data"])
else:
logger.warn(
f'{self._name} 获取字幕下载链接失败,返回:{result.get("message", "未知")}'

View File

@@ -386,6 +386,54 @@ def test_subtitle_search_uses_one_request_and_preserves_order(monkeypatch):
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 = []

View File

@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
# pylint: disable=no-name-in-module
import asyncio
from types import SimpleNamespace
import pytest
from app.modules.indexer.spider import mtorrent as mtorrent_module
@@ -41,6 +44,16 @@ def test_movie_search_keeps_movie_categories(mteam_spider):
assert params["categories"] == MTorrentSpider._movie_category
def test_api_domain_can_come_from_site_resource(mteam_spider):
"""站点资源提供 API 主机时应优先使用该配置。"""
indexer = {
**_build_indexer(),
"api_domain": "https://subtitle-api.example/api",
}
assert mteam_spider._MTorrentSpider__resolve_api_domain(indexer) == "subtitle-api.example"
def test_parse_result_marks_music_torrents(mteam_spider):
"""音乐分区种子应标记为音乐媒体类型,供音乐搜索链路筛选。"""
results = mteam_spider._MTorrentSpider__parse_result([
@@ -56,3 +69,101 @@ def test_parse_result_marks_music_torrents(mteam_spider):
MediaType.MOVIE.value,
MediaType.UNKNOWN.value,
]
def test_subtitle_search_uses_api_and_generates_download_links(monkeypatch, mteam_spider):
"""M-Team 字幕搜索应使用 JSON API,并把字幕 ID 转换为下载链接。"""
calls = []
class FakeRequest:
"""记录请求适配器收到的配置。"""
def __init__(self, **kwargs):
"""保存请求头和超时配置。"""
self.kwargs = kwargs
def post_res(self, url, json=None, data=None):
"""记录请求地址与 JSON 请求体。"""
calls.append((self.kwargs, url, json, data))
return SimpleNamespace(
status_code=200,
json=lambda: {
"code": 0,
"data": {
"data": [{
"id": "subtitle-1",
"name": "Kill Bill 简中",
"filename": "Kill.Bill.zh.srt",
"torrent": "torrent-1",
"size": "12 KB",
"lang": "简中",
"hits": 3,
"author": "uploader",
"createdDate": "2026-01-01 00:00:00",
}],
},
},
)
monkeypatch.setattr(mtorrent_module, "RequestUtils", FakeRequest)
monkeypatch.setattr(
mteam_spider,
"_MTorrentSpider__subtitle_genlink",
lambda subtitle_id: f"https://api.m-team.io/api/subtitle/dlV2?credential={subtitle_id}",
)
error, subtitles = mteam_spider.search_subtitles("杀死比尔", page=2)
assert error is False
assert subtitles[0]["subtitle_id"] == "subtitle-1"
assert subtitles[0]["enclosure"].endswith("credential=subtitle-1")
assert subtitles[0]["size"] == 12 * 1024
request_kwargs, url, payload, data = calls[0]
assert data is None
assert url == "https://api.m-team.io/api/subtitle/search"
assert payload == {"keyword": "杀死比尔", "pageNumber": 3, "pageSize": 20}
assert request_kwargs["headers"]["x-api-key"] == "mteam-secret"
def test_async_subtitle_search_uses_same_api_contract(monkeypatch, mteam_spider):
"""M-Team 字幕搜索的异步入口应保持同步入口的地址和请求体。"""
calls = []
class FakeAsyncRequest:
"""记录异步请求适配器收到的配置。"""
def __init__(self, **kwargs):
"""保存请求头和超时配置。"""
self.kwargs = kwargs
async def post_res(self, url, json=None, data=None):
"""记录异步请求地址与 JSON 请求体。"""
calls.append((self.kwargs, url, json, data))
return SimpleNamespace(
status_code=200,
json=lambda: {"code": 0, "data": {"data": []}},
)
monkeypatch.setattr(mtorrent_module, "AsyncRequestUtils", FakeAsyncRequest)
error, subtitles = asyncio.run(mteam_spider.async_search_subtitles("杀死比尔", page=1))
assert error is False
assert subtitles == []
request_kwargs, url, payload, data = calls[0]
assert data is None
assert url == "https://api.m-team.io/api/subtitle/search"
assert payload == {"keyword": "杀死比尔", "pageNumber": 2, "pageSize": 20}
assert request_kwargs["headers"]["x-api-key"] == "mteam-secret"
@pytest.mark.parametrize("status_code", [401, 403, 404, 405])
def test_subtitle_search_reports_http_errors(mteam_spider, status_code):
"""字幕接口鉴权或路径错误应保留明确的 HTTP 状态原因。"""
error, subtitles = mteam_spider._MTorrentSpider__process_subtitle_response(
SimpleNamespace(status_code=status_code, json=lambda: {})
)
assert error is True
assert subtitles == []
assert mteam_spider.error_detail == f"HTTP {status_code}"