mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
737 lines
28 KiB
Python
737 lines
28 KiB
Python
import importlib.util
|
|
import sys
|
|
import types
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from unittest.mock import call, MagicMock, patch
|
|
|
|
|
|
def _load_qbittorrent_modules():
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
|
|
app_module = types.ModuleType("app")
|
|
app_module.__path__ = []
|
|
core_module = types.ModuleType("app.core")
|
|
core_module.__path__ = []
|
|
domain_module = types.ModuleType("app.domain")
|
|
domain_module.__path__ = []
|
|
foundation_module = types.ModuleType("app.foundation")
|
|
foundation_module.__path__ = []
|
|
torrent_rules_module = types.ModuleType("app.domain.torrent")
|
|
size_tools_module = types.ModuleType("app.foundation.size")
|
|
temporal_tools_module = types.ModuleType("app.foundation.temporal")
|
|
text_tools_module = types.ModuleType("app.foundation.text")
|
|
url_tools_module = types.ModuleType("app.foundation.url")
|
|
modules_module = types.ModuleType("app.modules")
|
|
modules_module.__path__ = []
|
|
qbittorrent_package_module = types.ModuleType("app.modules.qbittorrent")
|
|
qbittorrent_package_module.__path__ = []
|
|
runtime_module = types.ModuleType("app.runtime")
|
|
runtime_module.__path__ = []
|
|
log_module = types.ModuleType("app.runtime.log")
|
|
cache_module = types.ModuleType("app.runtime.cache")
|
|
config_module = types.ModuleType("app.runtime.config")
|
|
runtime_settings_module = types.ModuleType("app.runtime.settings")
|
|
metainfo_module = types.ModuleType("app.domain.metainfo")
|
|
schemas_module = types.ModuleType("app.schemas")
|
|
schemas_module.__path__ = []
|
|
schema_dashboard_module = types.ModuleType("app.schemas.dashboard")
|
|
schema_transfer_module = types.ModuleType("app.schemas.transfer")
|
|
schema_types_module = types.ModuleType("app.schemas.types")
|
|
torrentool_module = types.ModuleType("torrentool")
|
|
torrentool_module.__path__ = []
|
|
torrentool_torrent_module = types.ModuleType("torrentool.torrent")
|
|
qbittorrentapi_module = types.ModuleType("qbittorrentapi")
|
|
qbittorrentapi_client_module = types.ModuleType("qbittorrentapi.client")
|
|
qbittorrentapi_transfer_module = types.ModuleType("qbittorrentapi.transfer")
|
|
|
|
class _Logger:
|
|
def info(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def warn(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def warning(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def error(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def _is_magnet_link(value):
|
|
"""按生产领域规则识别测试磁力链接。"""
|
|
if isinstance(value, bytes):
|
|
return value.startswith(b"magnet:")
|
|
return isinstance(value, str) and value.startswith("magnet:")
|
|
|
|
def _parse_address(address, include_scheme=False):
|
|
"""返回隔离测试使用的主机和固定端口。"""
|
|
return address, 8080
|
|
|
|
def _random_string(_length):
|
|
"""生成可断言的固定临时标签。"""
|
|
return "tmp-tag-01"
|
|
|
|
def _format_size(value):
|
|
"""返回隔离测试需要的简化容量文本。"""
|
|
return str(value)
|
|
|
|
def _format_duration(value):
|
|
"""返回隔离测试需要的简化时长文本。"""
|
|
return str(value)
|
|
|
|
class _FileCache:
|
|
def get(self, *_args, **_kwargs):
|
|
return None
|
|
|
|
class _RuntimeSettingsCompat:
|
|
"""隔离测试用动态配置代理,保持生产模块的兼容读取语义。"""
|
|
|
|
def __getattr__(self, key):
|
|
"""从测试提供的旧 Settings 桩读取配置项。"""
|
|
return getattr(config_module.settings, key)
|
|
|
|
class _MetaInfo:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
self.year = None
|
|
self.season_episode = ""
|
|
self.episode_list = []
|
|
|
|
class _ModuleBase:
|
|
pass
|
|
|
|
class _DownloaderBase:
|
|
def __class_getitem__(cls, _item):
|
|
return cls
|
|
|
|
# 隔离环境下的下载器业务样板基类,镜像 app.modules._base.downloader 的行为
|
|
class _DownloaderModuleBase(_ModuleBase, _DownloaderBase):
|
|
def test(self):
|
|
return True, ""
|
|
|
|
def scheduler_job(self):
|
|
pass
|
|
|
|
def _get_torrent_info(self, content):
|
|
torrent_info, torrent_content = None, None
|
|
if isinstance(content, Path):
|
|
torrent_content = content.read_bytes() if content.exists() else None
|
|
else:
|
|
torrent_content = content
|
|
if torrent_content:
|
|
if torrent_rules_module.is_magnet_link(torrent_content):
|
|
return None, torrent_content
|
|
torrent_info = torrentool_torrent_module.Torrent.from_string(torrent_content)
|
|
return torrent_info, torrent_content
|
|
|
|
@staticmethod
|
|
def _normalize_query_status(status):
|
|
status_value = getattr(status, "value", status)
|
|
status_text = str(status_value or "").strip().lower()
|
|
if status_text in {"transfer", TorrentStatus.TRANSFER.value}:
|
|
return TorrentQueryStatus.TRANSFER
|
|
if status_text in {"downloading", TorrentStatus.DOWNLOADING.value}:
|
|
return TorrentQueryStatus.DOWNLOADING
|
|
if status_text in {"completed", "seeding", "complete", "完成", "已完成"}:
|
|
return TorrentQueryStatus.COMPLETED
|
|
if status_text in {"paused", "pause", "暂停", "已暂停"}:
|
|
return TorrentQueryStatus.PAUSED
|
|
return TorrentQueryStatus.ALL
|
|
|
|
class _Torrent:
|
|
@staticmethod
|
|
def from_string(content):
|
|
return types.SimpleNamespace(name="test", total_size=len(content))
|
|
|
|
class _DownloaderTorrent:
|
|
def __init__(self, **kwargs):
|
|
self.__dict__.update(kwargs)
|
|
|
|
class TorrentStatus(Enum):
|
|
TRANSFER = "transfer"
|
|
DOWNLOADING = "downloading"
|
|
|
|
class TorrentQueryStatus(Enum):
|
|
ALL = "all"
|
|
TRANSFER = "transfer"
|
|
DOWNLOADING = "downloading"
|
|
COMPLETED = "completed"
|
|
PAUSED = "paused"
|
|
|
|
class DownloadTaskState(Enum):
|
|
DOWNLOADING = "downloading"
|
|
PAUSED = "paused"
|
|
COMPLETED = "completed"
|
|
|
|
class ModuleType(Enum):
|
|
Downloader = "Downloader"
|
|
|
|
class DownloaderType(Enum):
|
|
Qbittorrent = "Qbittorrent"
|
|
|
|
log_module.logger = _Logger()
|
|
cache_module.FileCache = _FileCache
|
|
config_module.settings = types.SimpleNamespace(TORRENT_TAG="moviepilot-tag")
|
|
runtime_settings_module.RuntimeSettingsCompat = _RuntimeSettingsCompat
|
|
metainfo_module.MetaInfo = _MetaInfo
|
|
schema_dashboard_module.DownloaderInfo = object
|
|
schema_transfer_module.TransferTorrent = object
|
|
schema_transfer_module.DownloadingTorrent = object
|
|
schema_transfer_module.DownloaderTorrent = _DownloaderTorrent
|
|
schema_types_module.TorrentStatus = TorrentStatus
|
|
schema_types_module.TorrentQueryStatus = TorrentQueryStatus
|
|
schema_types_module.DownloadTaskState = DownloadTaskState
|
|
schema_types_module.ModuleType = ModuleType
|
|
schema_types_module.DownloaderType = DownloaderType
|
|
torrent_rules_module.is_magnet_link = _is_magnet_link
|
|
url_tools_module.parse_address = _parse_address
|
|
text_tools_module.random_string = _random_string
|
|
size_tools_module.format_compact_size = _format_size
|
|
temporal_tools_module.format_duration = _format_duration
|
|
modules_module._ModuleBase = _ModuleBase
|
|
modules_module._DownloaderBase = _DownloaderBase
|
|
base_module = types.ModuleType("app.modules._base")
|
|
base_module._DownloaderModuleBase = _DownloaderModuleBase
|
|
modules_module._base = base_module
|
|
torrentool_torrent_module.Torrent = _Torrent
|
|
qbittorrentapi_module.TorrentDictionary = dict
|
|
qbittorrentapi_module.TorrentFilesList = list
|
|
qbittorrentapi_module.LoginFailed = type("LoginFailed", (Exception,), {})
|
|
qbittorrentapi_module.Forbidden403Error = type("Forbidden403Error", (Exception,), {})
|
|
qbittorrentapi_module.Unauthorized401Error = type("Unauthorized401Error", (Exception,), {})
|
|
qbittorrentapi_module.Client = object
|
|
qbittorrentapi_client_module.Client = object
|
|
qbittorrentapi_transfer_module.TransferInfoDictionary = dict
|
|
|
|
app_module.core = core_module
|
|
app_module.domain = domain_module
|
|
app_module.foundation = foundation_module
|
|
app_module.log = log_module
|
|
app_module.modules = modules_module
|
|
app_module.runtime = runtime_module
|
|
app_module.schemas = schemas_module
|
|
domain_module.torrent = torrent_rules_module
|
|
foundation_module.size = size_tools_module
|
|
foundation_module.temporal = temporal_tools_module
|
|
foundation_module.text = text_tools_module
|
|
foundation_module.url = url_tools_module
|
|
core_module.cache = cache_module
|
|
core_module.config = config_module
|
|
core_module.metainfo = metainfo_module
|
|
runtime_module.cache = cache_module
|
|
runtime_module.config = config_module
|
|
runtime_module.log = log_module
|
|
runtime_module.settings = runtime_settings_module
|
|
schemas_module.dashboard = schema_dashboard_module
|
|
schemas_module.transfer = schema_transfer_module
|
|
schemas_module.types = schema_types_module
|
|
modules_module.qbittorrent = qbittorrent_package_module
|
|
torrentool_module.torrent = torrentool_torrent_module
|
|
|
|
stub_modules = {
|
|
"app": app_module,
|
|
"app.core": core_module,
|
|
"app.domain": domain_module,
|
|
"app.domain.torrent": torrent_rules_module,
|
|
"app.foundation": foundation_module,
|
|
"app.foundation.size": size_tools_module,
|
|
"app.foundation.temporal": temporal_tools_module,
|
|
"app.foundation.text": text_tools_module,
|
|
"app.foundation.url": url_tools_module,
|
|
"app.runtime.cache": cache_module,
|
|
"app.runtime.config": config_module,
|
|
"app.runtime": runtime_module,
|
|
"app.domain.metainfo": metainfo_module,
|
|
"app.runtime.log": log_module,
|
|
"app.runtime.settings": runtime_settings_module,
|
|
"app.modules": modules_module,
|
|
"app.modules._base": base_module,
|
|
"app.modules.qbittorrent": qbittorrent_package_module,
|
|
"app.schemas": schemas_module,
|
|
"app.schemas.dashboard": schema_dashboard_module,
|
|
"app.schemas.transfer": schema_transfer_module,
|
|
"app.schemas.types": schema_types_module,
|
|
"qbittorrentapi": qbittorrentapi_module,
|
|
"qbittorrentapi.client": qbittorrentapi_client_module,
|
|
"qbittorrentapi.transfer": qbittorrentapi_transfer_module,
|
|
"torrentool": torrentool_module,
|
|
"torrentool.torrent": torrentool_torrent_module,
|
|
}
|
|
|
|
for stub_module in stub_modules.values():
|
|
stub_module._qbittorrent_test_stub = True
|
|
|
|
qbittorrent_path = repo_root / "app" / "modules" / "qbittorrent" / "qbittorrent.py"
|
|
qbittorrent_spec = importlib.util.spec_from_file_location(
|
|
"app.modules.qbittorrent.qbittorrent",
|
|
qbittorrent_path,
|
|
)
|
|
qbittorrent_module = importlib.util.module_from_spec(qbittorrent_spec)
|
|
assert qbittorrent_spec and qbittorrent_spec.loader
|
|
|
|
module_path = repo_root / "app" / "modules" / "qbittorrent" / "__init__.py"
|
|
qbittorrent_module_spec = importlib.util.spec_from_file_location(
|
|
"_test_qbittorrent_module",
|
|
module_path,
|
|
)
|
|
module_package = importlib.util.module_from_spec(qbittorrent_module_spec)
|
|
assert qbittorrent_module_spec and qbittorrent_module_spec.loader
|
|
|
|
with patch.dict(sys.modules, stub_modules):
|
|
sys.modules[qbittorrent_spec.name] = qbittorrent_module
|
|
qbittorrent_spec.loader.exec_module(qbittorrent_module)
|
|
qbittorrent_package_module.qbittorrent = qbittorrent_module
|
|
qbittorrent_module_spec.loader.exec_module(module_package)
|
|
|
|
return qbittorrent_module, module_package
|
|
|
|
|
|
qbittorrent_module, qbittorrent_package_module = _load_qbittorrent_modules()
|
|
Qbittorrent = qbittorrent_module.Qbittorrent
|
|
QbittorrentModule = qbittorrent_package_module.QbittorrentModule
|
|
|
|
|
|
def test_login_uses_api_key_header_without_auth_login():
|
|
"""API Key 登录时应使用 Bearer Header 并跳过用户名密码登录。"""
|
|
fake_client = MagicMock()
|
|
fake_client.app_version.return_value = "v5.2.0"
|
|
|
|
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client) as client_cls:
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, apikey="secret-token")
|
|
|
|
assert downloader.qbc is fake_client
|
|
fake_client.auth_log_in.assert_not_called()
|
|
fake_client.app_version.assert_called_once_with()
|
|
assert client_cls.call_args.kwargs["EXTRA_HEADERS"] == {"Authorization": "Bearer secret-token"}
|
|
|
|
|
|
def test_login_enables_incomplete_file_suffix_by_default():
|
|
"""
|
|
登录成功后默认开启未完成文件后缀,避免下载中的媒体文件被提前整理。
|
|
"""
|
|
fake_client = MagicMock()
|
|
fake_client.app_preferences.return_value = {"incomplete_files_ext": False}
|
|
|
|
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
assert downloader.qbc is fake_client
|
|
fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": True})
|
|
|
|
|
|
def test_login_disables_incomplete_file_suffix_when_configured():
|
|
"""
|
|
用户关闭配置后应同步关闭 qBittorrent 未完成文件后缀偏好。
|
|
"""
|
|
fake_client = MagicMock()
|
|
fake_client.app_preferences.return_value = {"incomplete_files_ext": True}
|
|
|
|
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client):
|
|
downloader = Qbittorrent(
|
|
host="http://127.0.0.1",
|
|
port=8080,
|
|
username="admin",
|
|
password="adminadmin",
|
|
incomplete_files_ext=False,
|
|
)
|
|
|
|
assert downloader.qbc is fake_client
|
|
fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": False})
|
|
|
|
|
|
def test_login_skips_incomplete_file_suffix_when_already_matches():
|
|
"""
|
|
远端未完成文件后缀状态已匹配配置时不重复写入全局偏好。
|
|
"""
|
|
fake_client = MagicMock()
|
|
fake_client.app_preferences.return_value = {"incomplete_files_ext": True}
|
|
|
|
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
assert downloader.qbc is fake_client
|
|
fake_client.app_set_preferences.assert_not_called()
|
|
|
|
|
|
def test_completed_status_includes_qbittorrent_finished_upload_states():
|
|
"""
|
|
qBittorrent 按完成状态查询时应包含非下载中、非暂停的上传侧状态。
|
|
"""
|
|
server = MagicMock()
|
|
server.get_torrents.return_value = (
|
|
[
|
|
{
|
|
"name": "QB Done",
|
|
"content_path": "/downloads/QB Done",
|
|
"hash": "hash-qb",
|
|
"total_size": 1024,
|
|
"completed": 1024,
|
|
"progress": 1,
|
|
"state": "stalledUP",
|
|
"tags": "moviepilot-tag",
|
|
"dlspeed": 0,
|
|
"upspeed": 128,
|
|
},
|
|
{
|
|
"name": "QB Downloading",
|
|
"content_path": "/downloads/QB Downloading",
|
|
"hash": "hash-downloading",
|
|
"total_size": 2048,
|
|
"completed": 1024,
|
|
"progress": 0.5,
|
|
"state": "queuedDL",
|
|
"tags": "moviepilot-tag",
|
|
"dlspeed": 64,
|
|
"upspeed": 0,
|
|
},
|
|
],
|
|
False,
|
|
)
|
|
module = QbittorrentModule.__new__(QbittorrentModule)
|
|
module.get_instances = MagicMock(return_value={"qb": server})
|
|
module.normalize_return_path = MagicMock(side_effect=lambda path, _name: str(path))
|
|
|
|
torrents = module.list_torrents(status="completed")
|
|
|
|
assert [torrent.hash for torrent in torrents] == ["hash-qb"]
|
|
assert torrents[0].state == "completed"
|
|
server.get_torrents.assert_called_once_with(tags="moviepilot-tag")
|
|
|
|
|
|
def test_get_completed_torrents_includes_finished_stopped_tasks():
|
|
"""
|
|
已完成但不再做种的 qBittorrent 任务仍应进入待整理列表。
|
|
"""
|
|
fake_client = MagicMock()
|
|
fake_client.torrents_info.return_value = [
|
|
{
|
|
"hash": "hash-stalled-up",
|
|
"progress": 1,
|
|
"amount_left": 0,
|
|
"state": "stalledUP",
|
|
},
|
|
{
|
|
"hash": "hash-stopped-up",
|
|
"progress": 1,
|
|
"amount_left": 0,
|
|
"state": "stoppedUP",
|
|
},
|
|
{
|
|
"hash": "hash-moving",
|
|
"progress": 1,
|
|
"amount_left": 0,
|
|
"state": "moving",
|
|
},
|
|
{
|
|
"hash": "hash-metadata",
|
|
"progress": 0,
|
|
"amount_left": 0,
|
|
"state": "metaDL",
|
|
},
|
|
{
|
|
"hash": "hash-download-left",
|
|
"progress": 0.9,
|
|
"amount_left": 1024,
|
|
"state": "stalledDL",
|
|
},
|
|
]
|
|
|
|
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
torrents = downloader.get_completed_torrents()
|
|
|
|
assert [torrent["hash"] for torrent in torrents] == ["hash-stalled-up", "hash-stopped-up"]
|
|
fake_client.torrents_info.assert_called_once_with(torrent_hashes=None, status_filter="completed")
|
|
|
|
|
|
def test_list_torrents_include_all_tags_removes_builtin_tag_filter():
|
|
"""
|
|
智能体扩大查询范围时,qBittorrent 查询应取消内置标签过滤。
|
|
"""
|
|
server = MagicMock()
|
|
server.get_torrents.return_value = (
|
|
[
|
|
{
|
|
"name": "External Task",
|
|
"content_path": "/downloads/External Task",
|
|
"hash": "hash-external",
|
|
"total_size": 1024,
|
|
"completed": 1024,
|
|
"progress": 1,
|
|
"state": "stalledUP",
|
|
"tags": "external",
|
|
"dlspeed": 0,
|
|
"upspeed": 0,
|
|
}
|
|
],
|
|
False,
|
|
)
|
|
module = QbittorrentModule.__new__(QbittorrentModule)
|
|
module.get_instances = MagicMock(return_value={"qb": server})
|
|
module.normalize_return_path = MagicMock(side_effect=lambda path, _name: str(path))
|
|
|
|
torrents = module.list_torrents(include_all_tags=True)
|
|
|
|
assert [torrent.hash for torrent in torrents] == ["hash-external"]
|
|
server.get_torrents.assert_called_once_with(tags=None)
|
|
|
|
|
|
def test_add_torrent_accepts_structured_success_response():
|
|
"""新版 qBittorrent API 结构化成功响应应返回新增种子 ID。"""
|
|
fake_client = MagicMock()
|
|
fake_client.torrents_add.return_value = {
|
|
"success_count": 1,
|
|
"failure_count": 0,
|
|
"pending_count": 0,
|
|
"added_torrent_ids": ["abc123"],
|
|
}
|
|
|
|
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent")
|
|
assert success
|
|
assert added_torrent_ids == ["abc123"]
|
|
|
|
|
|
def test_add_torrent_accepts_pending_success_response_without_ids():
|
|
"""新版 qBittorrent API 待处理成功响应没有 ID 时仍应视为添加成功。"""
|
|
fake_client = MagicMock()
|
|
fake_client.torrents_add.return_value = {
|
|
"success_count": 0,
|
|
"failure_count": 0,
|
|
"pending_count": 1,
|
|
"added_torrent_ids": [],
|
|
}
|
|
|
|
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent")
|
|
assert success
|
|
assert added_torrent_ids == []
|
|
|
|
|
|
def test_add_torrent_uses_cookie_api_for_qbittorrent_52():
|
|
"""qBittorrent 5.2 对应 Web API 应通过 Cookie API 同步站点 Cookie。"""
|
|
fake_client = MagicMock()
|
|
fake_client.app_web_api_version.return_value = "2.11.3"
|
|
fake_client.app_cookies.return_value = [
|
|
{
|
|
"domain": "old.example.com",
|
|
"path": "/",
|
|
"name": "old",
|
|
"value": "cookie",
|
|
}
|
|
]
|
|
fake_client.torrents_add.return_value = "Ok."
|
|
|
|
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
success, added_torrent_ids = downloader.add_torrent(
|
|
content="https://tracker.example.com/download?id=1",
|
|
cookie="uid=1; passkey=abc",
|
|
)
|
|
assert success
|
|
assert added_torrent_ids == []
|
|
set_cookie_call = fake_client.app_set_cookies.call_args.kwargs["cookies"]
|
|
assert {
|
|
"domain": "tracker.example.com",
|
|
"path": "/",
|
|
"name": "uid",
|
|
"value": "1",
|
|
} in set_cookie_call
|
|
assert {
|
|
"domain": "tracker.example.com",
|
|
"path": "/",
|
|
"name": "passkey",
|
|
"value": "abc",
|
|
} in set_cookie_call
|
|
assert fake_client.torrents_add.call_args.kwargs["cookie"] is None
|
|
|
|
|
|
def test_add_torrent_keeps_legacy_cookie_param_for_old_webapi():
|
|
"""旧版 qBittorrent Web API 不支持 Cookie API 时保留添加种子 Cookie 参数。"""
|
|
fake_client = MagicMock()
|
|
fake_client.app_web_api_version.return_value = "2.11.2"
|
|
fake_client.torrents_add.return_value = "Ok."
|
|
|
|
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
success, added_torrent_ids = downloader.add_torrent(
|
|
content="https://tracker.example.com/download?id=1",
|
|
cookie="uid=1",
|
|
)
|
|
assert success
|
|
assert added_torrent_ids == []
|
|
fake_client.app_set_cookies.assert_not_called()
|
|
assert fake_client.torrents_add.call_args.kwargs["cookie"] == "uid=1"
|
|
|
|
|
|
def _build_module(server):
|
|
"""构造仅包含下载所需方法的 QbittorrentModule 测试实例。"""
|
|
module = QbittorrentModule.__new__(QbittorrentModule)
|
|
module.get_instance = MagicMock(return_value=server)
|
|
module.normalize_path = MagicMock(side_effect=lambda path, _downloader: path)
|
|
module.get_default_config_name = MagicMock(return_value="default-qb")
|
|
return module
|
|
|
|
|
|
def test_download_prefers_added_torrent_ids_before_tag_lookup():
|
|
"""添加任务响应包含种子 ID 时应优先使用响应值。"""
|
|
fake_server = MagicMock()
|
|
fake_server.add_torrent.return_value = (True, ["abc123"])
|
|
fake_server.get_content_layout.return_value = "Original"
|
|
fake_server.is_force_resume.return_value = False
|
|
|
|
module = _build_module(fake_server)
|
|
result = module.download(
|
|
content="magnet:?xt=urn:btih:123",
|
|
download_dir=Path("/downloads"),
|
|
cookie="",
|
|
downloader="qb",
|
|
)
|
|
|
|
assert result == ("qb", "abc123", "Original", "添加下载成功")
|
|
fake_server.delete_torrents_tag.assert_called_once_with("abc123", "tmp-tag-01")
|
|
fake_server.get_torrent_id_by_tag.assert_not_called()
|
|
assert fake_server.add_torrent.call_args.kwargs["tag"] == ["tmp-tag-01", "moviepilot-tag"]
|
|
assert fake_server.mock_calls.index(
|
|
call.delete_torrents_tag("abc123", "tmp-tag-01")
|
|
) < fake_server.mock_calls.index(call.get_content_layout())
|
|
|
|
|
|
def test_download_falls_back_to_tag_lookup_when_added_ids_missing():
|
|
"""添加任务响应缺少种子 ID 时应回退到临时标签查询。"""
|
|
fake_server = MagicMock()
|
|
fake_server.add_torrent.return_value = (True, [])
|
|
fake_server.get_content_layout.return_value = "Original"
|
|
fake_server.get_torrent_id_by_tag.return_value = "def456"
|
|
fake_server.is_force_resume.return_value = False
|
|
|
|
module = _build_module(fake_server)
|
|
result = module.download(
|
|
content="magnet:?xt=urn:btih:456",
|
|
download_dir=Path("/downloads"),
|
|
cookie="",
|
|
downloader="qb",
|
|
)
|
|
|
|
assert result == ("qb", "def456", "Original", "添加下载成功")
|
|
fake_server.delete_torrents_tag.assert_not_called()
|
|
fake_server.get_torrent_id_by_tag.assert_called_once_with(tags="tmp-tag-01")
|
|
|
|
|
|
def test_download_removes_temporary_tag_from_existing_torrent():
|
|
"""重复添加任务时应从已存在的种子中删除本次临时标签。"""
|
|
fake_server = MagicMock()
|
|
fake_server.add_torrent.return_value = (False, [])
|
|
fake_server.get_content_layout.return_value = "Original"
|
|
fake_server.get_torrents.return_value = ([{
|
|
"name": "test",
|
|
"total_size": len(b"torrent-content"),
|
|
"hash": "existing123",
|
|
"tags": None,
|
|
}], None)
|
|
|
|
module = _build_module(fake_server)
|
|
result = module.download(
|
|
content=b"torrent-content",
|
|
download_dir=Path("/downloads"),
|
|
cookie="",
|
|
downloader="qb",
|
|
)
|
|
|
|
assert result == ("qb", "existing123", "Original", "下载任务已存在")
|
|
fake_server.delete_torrents_tag.assert_called_once_with("existing123", "tmp-tag-01")
|
|
assert fake_server.mock_calls.index(
|
|
call.delete_torrents_tag("existing123", "tmp-tag-01")
|
|
) < fake_server.mock_calls.index(call.get_content_layout())
|
|
|
|
|
|
def test_delete_torrents_tag_uses_supported_qbittorrent_api_arguments():
|
|
"""删除标签时应分别调用任务移除接口和全局标签删除接口。"""
|
|
fake_client = MagicMock()
|
|
downloader = Qbittorrent.__new__(Qbittorrent)
|
|
downloader.qbc = fake_client
|
|
|
|
assert downloader.delete_torrents_tag("abc123", "tmp-tag-01")
|
|
fake_client.torrents_remove_tags.assert_called_once_with(
|
|
torrent_hashes="abc123",
|
|
tags="tmp-tag-01",
|
|
)
|
|
fake_client.torrents_delete_tags.assert_called_once_with(tags="tmp-tag-01")
|
|
|
|
|
|
def test_get_files_retries_until_qbittorrent_files_available():
|
|
"""qBittorrent 添加任务后文件列表短暂未就绪时应重试。"""
|
|
torrent_files = [{"id": 12, "name": "Show.S01E12.mkv"}]
|
|
fake_client = MagicMock()
|
|
fake_client.torrents_files.side_effect = [
|
|
Exception("Torrent hash(es): abc123"),
|
|
torrent_files,
|
|
]
|
|
|
|
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
with patch.object(qbittorrent_module.time, "sleep") as sleep:
|
|
result = downloader.get_files("abc123", retry=2, interval=1)
|
|
|
|
assert result == torrent_files
|
|
assert fake_client.torrents_files.call_count == 2
|
|
fake_client.torrents_files.assert_called_with(torrent_hash="abc123")
|
|
sleep.assert_called_once_with(1)
|
|
|
|
|
|
def test_download_episode_selection_retries_file_list_after_add():
|
|
"""按集选择下载时应等待 qBittorrent 刚添加的任务文件列表。"""
|
|
|
|
class _EpisodeMetaInfo:
|
|
"""测试用集数识别对象。"""
|
|
|
|
def __init__(self, name):
|
|
self.episode_list = [12] if "E12" in name else [1]
|
|
|
|
fake_server = MagicMock()
|
|
fake_server.add_torrent.return_value = (True, ["abc123"])
|
|
fake_server.get_content_layout.return_value = "Original"
|
|
fake_server.get_files.return_value = [
|
|
{"id": 1, "name": "Show.S01E01.mkv"},
|
|
{"id": 12, "name": "Show.S01E12.mkv"},
|
|
]
|
|
fake_server.is_force_resume.return_value = False
|
|
|
|
module = _build_module(fake_server)
|
|
with patch.object(qbittorrent_package_module, "MetaInfo", _EpisodeMetaInfo):
|
|
result = module.download(
|
|
content=b"torrent-content",
|
|
download_dir=Path("/downloads"),
|
|
cookie="",
|
|
episodes={12},
|
|
downloader="qb",
|
|
)
|
|
|
|
assert result == ("qb", "abc123", "Original", "添加下载成功,已选择集数:[12]")
|
|
fake_server.get_files.assert_called_once_with("abc123", retry=5, interval=1)
|
|
fake_server.set_files.assert_called_once_with(torrent_hash="abc123", file_ids=[1], priority=0)
|
|
fake_server.start_torrents.assert_called_once_with("abc123")
|
|
|
|
|
|
def test_set_speed_limit_allows_single_direction_limit():
|
|
"""
|
|
设置全局限速时允许只传一个方向,未传方向按不限速处理。
|
|
"""
|
|
fake_client = MagicMock()
|
|
|
|
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
|
|
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
|
|
|
|
assert downloader.set_speed_limit(download_limit=1024)
|
|
assert fake_client.transfer.download_limit == 1024 * 1024
|
|
assert fake_client.transfer.upload_limit == 0
|