diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py index 02eabf16f..fb1b44760 100644 --- a/app/application/torrent/download.py +++ b/app/application/torrent/download.py @@ -2,7 +2,7 @@ import datetime import re from functools import lru_cache from pathlib import Path -from typing import Any, Dict, List, Optional, Protocol, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union from urllib.parse import unquote from torrentool.api import Torrent @@ -119,6 +119,19 @@ def _parse_filter_size_range(size_range: str) -> Tuple[str, float, Optional[floa return "unknown", 0, None +def _cache_valid_torrent_content( + parser: Callable[[Union[str, bytes]], Tuple[str, List[str]]], + cache_backend: Any, + cache_path: Path, + content: bytes, +) -> Tuple[str, List[str]]: + """解析合法种子并缓存原始二进制内容。""" + folder_name, file_list = parser(content) + if file_list: + cache_backend.set(cache_path.as_posix(), content, region="torrents") + return folder_name, file_list + + class TorrentHelper: """ 种子帮助类 @@ -187,6 +200,13 @@ class TorrentHelper: if req.content.startswith(b"magnet:"): # 磁力链接 return cache_path, req.text, "", [], "获取到磁力链接" + # 优先按原始二进制解析,避免合法 torrent 被误判为首次下载 HTML。 + folder_name, file_list = _cache_valid_torrent_content( + self.get_fileinfo_from_torrent_content, cache_backend, cache_path, req.content + ) + if file_list: + # 成功拿到种子数据 + return cache_path, req.content, folder_name, file_list, "" if "下载种子文件".encode("utf-8") in req.content: # 首次下载提示页面 skip_flag = False @@ -224,24 +244,17 @@ class TorrentHelper: logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)}") if not skip_flag: return cache_path, None, "", [], "种子数据有误,请确认链接是否正确,如为PT站点则需手工在站点下载一次种子" + # 首次下载提示页面完成后,重新按原始二进制解析 POST 响应。 assert req is not None - # 种子内容 if req.content: - # 检查是不是种子文件,如果不是仍然抛出异常 - try: - # 获取种子目录和文件清单 - folder_name, file_list = self.get_fileinfo_from_torrent_content(req.content) - if file_list: - # 保存到缓存 - cache_backend.set(cache_path.as_posix(), req.content, region="torrents") + folder_name, file_list = _cache_valid_torrent_content( + self.get_fileinfo_from_torrent_content, cache_backend, cache_path, req.content + ) + if file_list: # 成功拿到种子数据 return cache_path, req.content, folder_name, file_list, "" - except Exception as err: - logger.error(f"种子文件解析失败:{str(err)}") - # 种子数据仍然错误 - return cache_path, None, "", [], "种子数据有误,请确认链接是否正确" - # 返回失败 - return cache_path, None, "", [], "" + # 解析失败时终止,避免把 HTML 继续下沉到下载器或缓存。 + return cache_path, None, "", [], "种子数据有误,请确认链接是否正确" elif req is None: return cache_path, None, "", [], "无法打开链接" elif req.status_code == 429: diff --git a/tests/test_site_access_ports.py b/tests/test_site_access_ports.py index 032d559c9..75e2f3b0d 100644 --- a/tests/test_site_access_ports.py +++ b/tests/test_site_access_ports.py @@ -125,6 +125,79 @@ def test_torrent_fake_port_preserves_rate_limit_message(monkeypatch) -> None: assert result[1:] == (None, "", [], "触发站点流控,请稍后重试") +def test_torrent_binary_is_parsed_before_first_download_html_detection(monkeypatch) -> None: + """合法二进制种子包含首次下载提示文本时仍应按 torrent 解析。""" + marker = "下载种子文件".encode("utf-8") + torrent_content = ( + b"d4:infod7:comment" + + str(len(marker)).encode("ascii") + + b":" + + marker + + b"4:name7:foo.mkv6:lengthi1eee" + ) + cached: list[bytes] = [] + + class Cache: + """记录合法种子缓存写入,不接触真实缓存后端。""" + + @staticmethod + def get(*_args, **_kwargs): + """固定返回缓存未命中。""" + return None + + @staticmethod + def set(_key, value, **_kwargs): + """记录原始二进制种子内容。""" + cached.append(value) + + class HttpPort: + """返回包含提示文本的合法二进制种子响应。""" + + @staticmethod + def request(**_kwargs): + """返回固定 torrent 响应。""" + return SimpleNamespace( + status_code=200, + headers={"Content-Type": "application/x-bittorrent"}, + content=torrent_content, + text=torrent_content.decode("utf-8"), + reason="", + ) + + monkeypatch.setattr(torrent_module, "FileCache", Cache) + configure_torrent_port(HttpPort()) + + result = TorrentHelper().download_torrent("https://example.test/download/1") + + assert result[1:] == (torrent_content, "", ["foo.mkv"], "") + assert cached == [torrent_content] + + +def test_torrent_invalid_http_200_body_is_rejected_before_downloader(monkeypatch) -> None: + """HTTP 200 返回非种子内容时应在下载层终止并避免缓存。""" + + class HttpPort: + """返回登录页面的种子 HTTP 假端口。""" + + @staticmethod + def request(**_kwargs): + """返回固定 HTML 响应。""" + return SimpleNamespace( + status_code=200, + headers={"Content-Type": "text/html"}, + content=b"login", + text="login", + reason="", + ) + + monkeypatch.setattr(torrent_module, "FileCache", _EmptyCache) + configure_torrent_port(HttpPort()) + + result = TorrentHelper().download_torrent("https://example.test/download/2") + + assert result[1:] == (None, "", [], "种子数据有误,请确认链接是否正确") + + def test_cookie_invalid_parameters_keep_legacy_result_without_ports() -> None: """参数错误属于 Application 语义,不应依赖浏览器端口已装配。""" assert CookieHelper().get_site_cookie_ua("", "", "") == (