fix downloader task status queries

This commit is contained in:
jxxghp
2026-06-14 18:23:18 +08:00
parent d0dcf6660f
commit bef2a81296
14 changed files with 952 additions and 252 deletions

View File

@@ -0,0 +1,199 @@
import asyncio
import json
from unittest.mock import MagicMock, patch
from app.agent.tools.impl.query_download_tasks import QueryDownloadTasksTool
from app.schemas import DownloaderTorrent
def test_completed_status_returns_qbittorrent_and_transmission_completed_states():
"""
按完成状态查询时应包含 QB/TR 中非下载中、非暂停的实际状态。
"""
completed_torrents = [
DownloaderTorrent(
downloader="qb",
hash="hash-qb",
title="QB Done",
size=1024,
progress=100,
state="completed",
tags="moviepilot",
),
DownloaderTorrent(
downloader="tr",
hash="hash-tr",
title="TR Done",
size=2048,
progress=100,
state="completed",
tags="moviepilot",
),
]
download_chain = MagicMock()
download_chain.list_torrents.return_value = completed_torrents
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(status="completed")
assert result["downloads"] == completed_torrents
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status="completed",
include_all_tags=False,
)
def test_run_completed_status_formats_completed_download_tasks():
"""
工具输出应保留完成任务的实际下载器状态,便于用户判断来源。
"""
completed_torrents = [
DownloaderTorrent(
downloader="qb",
hash="hash-qb",
title="QB Done",
size=1024,
progress=100,
state="completed",
tags="moviepilot",
)
]
with patch.object(
QueryDownloadTasksTool,
"_query_downloads_sync",
return_value={"downloads": completed_torrents},
):
result = asyncio.run(
QueryDownloadTasksTool(session_id="session-1", user_id="10001").run(
status="completed"
)
)
payload = json.loads(result)
assert payload[0]["hash"] == "hash-qb"
assert payload[0]["state"] == "completed"
def test_include_all_tags_passes_scope_to_downloader_query():
"""
智能体显式扩大范围时,应查询未打 MoviePilot 内置标签的下载任务。
"""
all_scope_torrents = [
DownloaderTorrent(
downloader="qb",
hash="hash-external",
title="External Task",
size=1024,
progress=10,
state="downloading",
tags="external",
)
]
download_chain = MagicMock()
download_chain.list_torrents.return_value = all_scope_torrents
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(
status="all",
include_all_tags=True,
)
assert result["downloads"] == all_scope_torrents
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status=None,
include_all_tags=True,
)
def test_include_all_tags_downloading_status_uses_list_torrents():
"""
查询全部标签范围的下载中任务时,不应走只面向 MoviePilot 任务的便捷方法。
"""
download_chain = MagicMock()
download_chain.list_torrents.return_value = [
DownloaderTorrent(
downloader="tr",
hash="hash-downloading",
title="Downloading External",
size=2048,
progress=50,
state="downloading",
tags="external",
)
]
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(
status="downloading",
include_all_tags=True,
)
assert result["downloads"][0].hash == "hash-downloading"
download_chain.downloading.assert_not_called()
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status="downloading",
include_all_tags=True,
)
def test_include_all_tags_false_string_keeps_builtin_tag_scope():
"""
CLI 字符串 false 不应被 Python 真值规则误判为扩大查询范围。
"""
download_chain = MagicMock()
download_chain.list_torrents.return_value = [
DownloaderTorrent(
downloader="qb",
hash="hash-moviepilot",
title="MoviePilot Task",
size=1024,
progress=100,
state="completed",
tags="moviepilot",
)
]
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(
status="completed",
include_all_tags="false",
)
assert result["downloads"][0].hash == "hash-moviepilot"
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status="completed",
include_all_tags=False,
)

View File

@@ -133,10 +133,26 @@ def _load_transmission_module():
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
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 _Logger:
def debug(self, *_args, **_kwargs):
pass
@@ -179,8 +195,11 @@ def _load_transmission_module():
cache_module.FileCache = _FileCache
schemas_module.TransferTorrent = _TransferTorrent
schemas_module.DownloadingTorrent = _DownloadingTorrent
schemas_module.DownloaderTorrent = _DownloaderTorrent
schemas_module.DownloaderInfo = object
schema_types_module.TorrentStatus = TorrentStatus
schema_types_module.TorrentQueryStatus = TorrentQueryStatus
schema_types_module.DownloadTaskState = DownloadTaskState
schema_types_module.ModuleType = Enum("ModuleType", {"Downloader": "downloader"})
schema_types_module.DownloaderType = Enum(
"DownloaderType", {"Transmission": "Transmission"}
@@ -359,3 +378,62 @@ class TransmissionPathMappingTest(unittest.TestCase):
Path("/mnt/raid5/home_lt999lt/video/downloads/movie/Movie"),
"tr",
)
def test_all_torrents_include_completed_and_downloading_states(self):
server = MagicMock()
server.get_torrents.return_value = (
[
SimpleNamespace(
name="Completed",
download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie",
hashString="hash-completed",
total_size=1024,
labels=[],
progress=100,
status="seed_pending",
),
SimpleNamespace(
name="Downloading",
download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie",
hashString="hash-downloading",
total_size=2048,
labels=[],
progress=50,
status="downloading",
rate_download=1024,
rate_upload=0,
left_until_done=1024,
),
],
False,
)
module = self._build_module(server)
torrents = module.list_torrents()
self.assertEqual(["completed", "downloading"], [torrent.state for torrent in torrents])
self.assertEqual(["hash-completed", "hash-downloading"], [torrent.hash for torrent in torrents])
server.get_torrents.assert_called_once_with(tags="moviepilot-tag")
def test_include_all_tags_removes_builtin_tag_filter(self):
server = MagicMock()
server.get_torrents.return_value = (
[
SimpleNamespace(
name="External",
download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie",
hashString="hash-external",
total_size=1024,
labels=["external"],
progress=100,
status="seeding",
)
],
False,
)
module = self._build_module(server)
torrents = module.list_torrents(include_all_tags=True)
self.assertEqual(["hash-external"], [torrent.hash for torrent in torrents])
server.get_torrents.assert_called_once_with(tags=None)

View File

@@ -92,10 +92,26 @@ def _load_qbittorrent_modules():
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"
@@ -109,7 +125,10 @@ def _load_qbittorrent_modules():
schemas_module.DownloaderInfo = object
schemas_module.TransferTorrent = object
schemas_module.DownloadingTorrent = object
schemas_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
string_module.StringUtils = _StringUtils
@@ -253,6 +272,83 @@ def test_login_skips_incomplete_file_suffix_when_already_matches():
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_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()