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
+76 -38
View File
@@ -1,7 +1,7 @@
"""查询下载工具""" """查询下载工具"""
import json import json
from typing import Any, Dict, List, Optional, Type, Union from typing import Any, Dict, List, Optional, Type
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.downloadhistory_oper import DownloadHistoryOper
from app.log import logger from app.log import logger
from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas import DownloaderTorrent
from app.schemas.types import TorrentStatus, media_type_to_agent from app.schemas.types import TorrentQueryStatus, media_type_to_agent
class QueryDownloadTasksInput(BaseModel): class QueryDownloadTasksInput(BaseModel):
@@ -21,6 +21,10 @@ class QueryDownloadTasksInput(BaseModel):
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)") description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
status: Optional[str] = Field("all", status: Optional[str] = Field("all",
description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads") description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads")
include_all_tags: Optional[bool] = Field(
False,
description="Include tasks without the MoviePilot built-in tag. Default false keeps the normal MoviePilot task scope.",
)
hash: Optional[str] = Field(None, description="Query specific download task by hash (optional, if provided will search for this specific task regardless of status)") hash: Optional[str] = Field(None, description="Query specific download task by hash (optional, if provided will search for this specific task regardless of status)")
title: Optional[str] = Field(None, description="Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)") title: Optional[str] = Field(None, description="Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)")
tag: Optional[str] = Field(None, description="Filter download tasks by tag (optional, supports partial match, e.g. 'movie' will match tasks with tag 'movie' or 'movie_2024')") tag: Optional[str] = Field(None, description="Filter download tasks by tag (optional, supports partial match, e.g. 'movie' will match tasks with tag 'movie' or 'movie_2024')")
@@ -36,26 +40,45 @@ class QueryDownloadTasksTool(MoviePilotTool):
args_schema: Type[BaseModel] = QueryDownloadTasksInput args_schema: Type[BaseModel] = QueryDownloadTasksInput
@staticmethod @staticmethod
def _get_all_torrents(download_chain: DownloadChain, downloader: Optional[str] = None) -> List[Union[TransferTorrent, DownloadingTorrent]]: def _normalize_query_status(status: Optional[str]) -> TorrentQueryStatus:
"""
归一下载任务查询状态。
"""
status_value = str(status or "").strip().lower()
if not status_value or status_value == TorrentQueryStatus.ALL.value:
return TorrentQueryStatus.ALL
if status_value in {"completed", "complete", "seeding"}:
return TorrentQueryStatus.COMPLETED
if status_value in {"paused", "pause"}:
return TorrentQueryStatus.PAUSED
if status_value == TorrentQueryStatus.DOWNLOADING.value:
return TorrentQueryStatus.DOWNLOADING
return TorrentQueryStatus.ALL
@staticmethod
def _normalize_include_all_tags(include_all_tags: Any) -> bool:
"""
归一全部标签查询开关。
"""
if isinstance(include_all_tags, bool):
return include_all_tags
if isinstance(include_all_tags, str):
return include_all_tags.strip().lower() in {"1", "true", "yes", "on", ""}
return bool(include_all_tags)
@staticmethod
def _get_all_torrents(
download_chain: DownloadChain,
downloader: Optional[str] = None,
include_all_tags: bool = False,
) -> List[DownloaderTorrent]:
""" """
查询所有状态的任务(包括下载中和已完成的任务) 查询所有状态的任务(包括下载中和已完成的任务)
""" """
all_torrents = [] return download_chain.list_torrents(
# 查询下载的任务
downloading_torrents = download_chain.list_torrents(
downloader=downloader, downloader=downloader,
status=TorrentStatus.DOWNLOADING include_all_tags=include_all_tags,
) or [] ) or []
all_torrents.extend(downloading_torrents)
# 查询已完成的任务(可转移状态)
transfer_torrents = download_chain.list_torrents(
downloader=downloader,
status=TorrentStatus.TRANSFER
) or []
all_torrents.extend(transfer_torrents)
return all_torrents
@staticmethod @staticmethod
def _format_progress(progress: Optional[float]) -> Optional[str]: def _format_progress(progress: Optional[float]) -> Optional[str]:
@@ -71,7 +94,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
@staticmethod @staticmethod
def _apply_download_history( def _apply_download_history(
torrent: Union[TransferTorrent, DownloadingTorrent], history: Any torrent: DownloaderTorrent, history: Any
) -> None: ) -> None:
"""将下载历史中的补充信息回填到下载任务结果中。""" """将下载历史中的补充信息回填到下载任务结果中。"""
if not history: if not history:
@@ -91,7 +114,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
@classmethod @classmethod
def _load_history_map( def _load_history_map(
cls, torrents: List[Union[TransferTorrent, DownloadingTorrent]] cls, torrents: List[DownloaderTorrent]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""批量加载下载历史,避免逐条查询形成 N+1。""" """批量加载下载历史,避免逐条查询形成 N+1。"""
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)] hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
@@ -107,15 +130,22 @@ class QueryDownloadTasksTool(MoviePilotTool):
hash_value: Optional[str] = None, hash_value: Optional[str] = None,
title: Optional[str] = None, title: Optional[str] = None,
tag: Optional[str] = None, tag: Optional[str] = None,
include_all_tags: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
同步查询下载器和下载历史,整个链路放在线程池中执行。 同步查询下载器和下载历史,整个链路放在线程池中执行。
""" """
download_chain = DownloadChain() download_chain = DownloadChain()
query_status = cls._normalize_query_status(status)
include_all_tags = cls._normalize_include_all_tags(include_all_tags)
if hash_value: if hash_value:
torrents = ( torrents = (
download_chain.list_torrents(downloader=downloader, hashs=[hash_value]) download_chain.list_torrents(
downloader=downloader,
hashs=[hash_value],
include_all_tags=include_all_tags,
)
or [] or []
) )
if not torrents: if not torrents:
@@ -128,7 +158,11 @@ class QueryDownloadTasksTool(MoviePilotTool):
cls._apply_download_history(torrent, history_map.get(torrent.hash)) cls._apply_download_history(torrent, history_map.get(torrent.hash))
filtered_downloads = list(torrents) filtered_downloads = list(torrents)
elif title: elif title:
all_torrents = cls._get_all_torrents(download_chain, downloader) all_torrents = cls._get_all_torrents(
download_chain,
downloader,
include_all_tags=include_all_tags,
)
history_map = cls._load_history_map(all_torrents) history_map = cls._load_history_map(all_torrents)
filtered_downloads = [] filtered_downloads = []
title_lower = title.lower() title_lower = title.lower()
@@ -150,7 +184,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
if not filtered_downloads: if not filtered_downloads:
return {"message": f"未找到标题包含 '{title}' 的下载任务"} return {"message": f"未找到标题包含 '{title}' 的下载任务"}
else: else:
if status == "downloading": if query_status == TorrentQueryStatus.DOWNLOADING and not include_all_tags:
downloads = download_chain.downloading(name=downloader) or [] downloads = download_chain.downloading(name=downloader) or []
filtered_downloads = [ filtered_downloads = [
dl dl
@@ -158,19 +192,12 @@ class QueryDownloadTasksTool(MoviePilotTool):
if not downloader or dl.downloader == downloader if not downloader or dl.downloader == downloader
] ]
else: else:
all_torrents = cls._get_all_torrents(download_chain, downloader) list_status = None if query_status == TorrentQueryStatus.ALL else query_status.value
filtered_downloads = [] filtered_downloads = download_chain.list_torrents(
for torrent in all_torrents: downloader=downloader,
if downloader and torrent.downloader != downloader: status=list_status,
continue include_all_tags=include_all_tags,
if status == "completed" and torrent.state not in [ ) or []
"seeding",
"completed",
]:
continue
if status == "paused" and torrent.state != "paused":
continue
filtered_downloads.append(torrent)
history_map = cls._load_history_map(filtered_downloads) history_map = cls._load_history_map(filtered_downloads)
for torrent in filtered_downloads: for torrent in filtered_downloads:
@@ -195,6 +222,9 @@ class QueryDownloadTasksTool(MoviePilotTool):
status = kwargs.get("status", "all") status = kwargs.get("status", "all")
hash_value = kwargs.get("hash") hash_value = kwargs.get("hash")
title = kwargs.get("title") title = kwargs.get("title")
include_all_tags = self._normalize_include_all_tags(
kwargs.get("include_all_tags", False)
)
parts = ["查询下载任务"] parts = ["查询下载任务"]
@@ -213,6 +243,8 @@ class QueryDownloadTasksTool(MoviePilotTool):
tag = kwargs.get("tag") tag = kwargs.get("tag")
if tag: if tag:
parts.append(f"标签: {tag}") parts.append(f"标签: {tag}")
if include_all_tags:
parts.append("范围: 全部标签")
return " | ".join(parts) if len(parts) > 1 else parts[0] return " | ".join(parts) if len(parts) > 1 else parts[0]
@@ -220,8 +252,13 @@ class QueryDownloadTasksTool(MoviePilotTool):
status: Optional[str] = "all", status: Optional[str] = "all",
hash: Optional[str] = None, hash: Optional[str] = None,
title: Optional[str] = None, title: Optional[str] = None,
tag: Optional[str] = None, **kwargs) -> str: tag: Optional[str] = None,
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}, tag={tag}") include_all_tags: Optional[bool] = False,
**kwargs) -> str:
logger.info(
f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, "
f"hash={hash}, title={title}, tag={tag}, include_all_tags={include_all_tags}"
)
try: try:
payload = await self.run_blocking( payload = await self.run_blocking(
"downloader", "downloader",
@@ -231,6 +268,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
hash, hash,
title, title,
tag, tag,
self._normalize_include_all_tags(include_all_tags),
) )
if payload.get("message"): if payload.get("message"):
return payload["message"] return payload["message"]
+1 -1
View File
@@ -17,7 +17,7 @@ from app.schemas.types import SystemConfigKey
router = APIRouter() router = APIRouter()
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent]) @router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent])
def current( def current(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token) name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
) -> Any: ) -> Any:
+9 -4
View File
@@ -28,9 +28,8 @@ from app.log import logger
from app.schemas import ( from app.schemas import (
RateLimitExceededException, RateLimitExceededException,
TransferInfo, TransferInfo,
TransferTorrent,
ExistMediaInfo, ExistMediaInfo,
DownloadingTorrent, DownloaderTorrent,
CommingMessage, CommingMessage,
Notification, Notification,
WebhookEventInfo, WebhookEventInfo,
@@ -1221,16 +1220,22 @@ class ChainBase(metaclass=ABCMeta):
status: TorrentStatus = None, status: TorrentStatus = None,
hashs: Union[list, str] = None, hashs: Union[list, str] = None,
downloader: Optional[str] = None, downloader: Optional[str] = None,
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
""" """
获取下载器种子列表 获取下载器种子列表
:param status: 种子状态 :param status: 种子状态
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器 :param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表 :return: 下载器中符合状态的种子列表
""" """
return self.run_module( return self.run_module(
"list_torrents", status=status, hashs=hashs, downloader=downloader "list_torrents",
status=status,
hashs=hashs,
downloader=downloader,
include_all_tags=include_all_tags,
) )
def transfer( def transfer(
+3 -3
View File
@@ -22,7 +22,7 @@ from app.helper.directory import DirectoryHelper
from app.helper.thread import ThreadHelper from app.helper.thread import ThreadHelper
from app.helper.torrent import TorrentHelper from app.helper.torrent import TorrentHelper
from app.log import logger from app.log import logger
from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, \ from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTorrent, Notification, ResourceSelectionEventData, \
ResourceDownloadEventData ResourceDownloadEventData
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \ from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
ChainEventType ChainEventType
@@ -1359,7 +1359,7 @@ class DownloadChain(ChainBase):
link=settings.MP_DOMAIN('#/downloading') link=settings.MP_DOMAIN('#/downloading')
)) ))
def downloading(self, name: Optional[str] = None) -> List[DownloadingTorrent]: def downloading(self, name: Optional[str] = None) -> List[DownloaderTorrent]:
""" """
查询正在下载的任务 查询正在下载的任务
""" """
@@ -1417,7 +1417,7 @@ class DownloadChain(ChainBase):
return return
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}") logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
# 先查询种子 # 先查询种子
torrents: List[schemas.TransferTorrent] = self.list_torrents(hashs=[hash_str]) torrents: List[schemas.DownloaderTorrent] = self.list_torrents(hashs=[hash_str])
if torrents: if torrents:
self.remove_torrents(hashs=[hash_str], delete_file=False) self.remove_torrents(hashs=[hash_str], delete_file=False)
# 发出下载任务删除事件,如需处理辅种,可监听该事件 # 发出下载任务删除事件,如需处理辅种,可监听该事件
+148 -60
View File
@@ -11,10 +11,33 @@ from app.core.metainfo import MetaInfo
from app.log import logger from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase from app.modules import _ModuleBase, _DownloaderBase
from app.modules.qbittorrent.qbittorrent import Qbittorrent from app.modules.qbittorrent.qbittorrent import Qbittorrent
from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas import DownloaderTorrent
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType from app.schemas.types import (
DownloadTaskState,
DownloaderType,
ModuleType,
TorrentQueryStatus,
TorrentStatus,
)
from app.utils.string import StringUtils from app.utils.string import StringUtils
_QBITTORRENT_DOWNLOADING_STATES = {
"allocating",
"checkingdl",
"downloading",
"forceddl",
"metadl",
"queueddl",
"stalleddl",
}
_QBITTORRENT_PAUSED_STATES = {
"paused",
"pauseddl",
"pausedup",
"stoppeddl",
"stoppedup",
}
class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
@@ -236,13 +259,15 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
def list_torrents(self, status: TorrentStatus = None, def list_torrents(self, status: TorrentStatus = None,
hashs: Union[list, str] = None, hashs: Union[list, str] = None,
downloader: Optional[str] = None downloader: Optional[str] = None,
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
""" """
获取下载器种子列表 获取下载器种子列表
:param status: 种子状态 :param status: 种子状态
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器 :param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表 :return: 下载器中符合状态的种子列表
""" """
# 获取下载器 # 获取下载器
@@ -254,80 +279,96 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
else: else:
servers: Dict[str, Qbittorrent] = self.get_instances() servers: Dict[str, Qbittorrent] = self.get_instances()
ret_torrents = [] ret_torrents = []
query_status = self.__normalize_query_status(status)
query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_path(torrent_data: dict) -> Path:
"""
获取种子内容路径。
"""
content_path = torrent_data.get("content_path")
if content_path:
return Path(content_path)
return Path(torrent_data.get('save_path')) / torrent_data.get('name')
def __build_torrent(downloader_name: str, torrent_data: dict) -> DownloaderTorrent:
"""
构造统一下载器任务对象。
"""
meta = MetaInfo(torrent_data.get('name'))
torrent_path = __get_torrent_path(torrent_data)
dlspeed = torrent_data.get('dlspeed') or 0
total_size = torrent_data.get('total_size') or 0
completed_size = torrent_data.get('completed') or 0
return DownloaderTorrent(
downloader=downloader_name,
title=torrent_data.get('name'),
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
hash=torrent_data.get('hash'),
size=total_size,
tags=torrent_data.get('tags'),
progress=(torrent_data.get('progress') or 0) * 100,
state=self.__normalize_torrent_state(torrent_data.get('state')),
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(torrent_data.get('upspeed')),
left_time=StringUtils.str_secends(
(total_size - completed_size) / dlspeed
) if dlspeed > 0 else '',
)
if hashs: if hashs:
# 按Hash获取 # 按Hash获取
for name, server in servers.items(): for name, server in servers.items():
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or [] torrents, _ = server.get_torrents(ids=hashs, tags=query_tags) or []
try: try:
for torrent in torrents: for torrent_info in torrents:
content_path = torrent.get("content_path") ret_torrents.append(__build_torrent(name, torrent_info))
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.get('name'),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get('hash'),
size=torrent.get('total_size'),
tags=torrent.get('tags'),
progress=torrent.get('progress') * 100,
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
))
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
elif status == TorrentStatus.TRANSFER: elif query_status == TorrentQueryStatus.TRANSFER:
# 获取已完成且未整理的 # 获取已完成且未整理的
for name, server in servers.items(): for name, server in servers.items():
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or [] torrents = server.get_completed_torrents(tags=query_tags) or []
try: try:
for torrent in torrents: for torrent_info in torrents:
tags = torrent.get("tags") or [] tags = torrent_info.get("tags") or []
if "已整理" in tags: if "已整理" in tags:
continue continue
# 内容路径 ret_torrents.append(__build_torrent(name, torrent_info))
content_path = torrent.get("content_path")
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = torrent.get('save_path') / torrent.get('name')
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.get('name'),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get('hash'),
tags=torrent.get('tags')
))
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
elif status == TorrentStatus.DOWNLOADING: elif query_status == TorrentQueryStatus.DOWNLOADING:
# 获取正在下载的任务 # 获取正在下载的任务
for name, server in servers.items(): for name, server in servers.items():
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or [] torrents = server.get_downloading_torrents(tags=query_tags) or []
try: try:
for torrent in torrents: for torrent_info in torrents:
meta = MetaInfo(torrent.get('name')) ret_torrents.append(__build_torrent(name, torrent_info))
ret_torrents.append(DownloadingTorrent( finally:
downloader=name, torrents.clear()
hash=torrent.get('hash'), del torrents
title=torrent.get('name'), elif query_status in (
name=meta.name, TorrentQueryStatus.ALL,
year=meta.year, TorrentQueryStatus.COMPLETED,
season_episode=meta.season_episode, TorrentQueryStatus.PAUSED,
progress=torrent.get('progress') * 100, ):
size=torrent.get('total_size'), # 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading", for name, server in servers.items():
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')), torrents, _ = server.get_torrents(tags=query_tags) or []
upspeed=StringUtils.str_filesize(torrent.get('upspeed')), try:
tags=torrent.get('tags'), for torrent_info in torrents:
left_time=StringUtils.str_secends( torrent_state = self.__normalize_torrent_state(torrent_info.get('state'))
(torrent.get('total_size') - torrent.get('completed')) / torrent.get( if (
'dlspeed')) if torrent.get( query_status != TorrentQueryStatus.ALL
'dlspeed') > 0 else '' and torrent_state != query_status.value
)) ):
continue
ret_torrents.append(__build_torrent(name, torrent_info))
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
@@ -335,6 +376,53 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
return None return None
return ret_torrents # noqa return ret_torrents # noqa
@staticmethod
def __normalize_query_status(
status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]]
) -> TorrentQueryStatus:
"""
归一任务查询状态。
"""
status_value = getattr(status, "value", status)
status_text = str(status_value or "").strip().lower()
if not status_text or status_text in {"all", "全部"}:
return TorrentQueryStatus.ALL
if status_text in {
TorrentStatus.TRANSFER.value,
TorrentQueryStatus.TRANSFER.value,
"transfer",
}:
return TorrentQueryStatus.TRANSFER
if status_text in {
TorrentStatus.DOWNLOADING.value,
TorrentQueryStatus.DOWNLOADING.value,
"downloading",
}:
return TorrentQueryStatus.DOWNLOADING
if status_text in {
TorrentQueryStatus.COMPLETED.value,
"complete",
"seeding",
"完成",
"已完成",
}:
return TorrentQueryStatus.COMPLETED
if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}:
return TorrentQueryStatus.PAUSED
return TorrentQueryStatus.ALL
@staticmethod
def __normalize_torrent_state(state: Optional[Union[str, int]]) -> str:
"""
归一 qBittorrent 原始任务状态。
"""
state_text = str(state or "").strip().lower()
if state_text in _QBITTORRENT_PAUSED_STATES:
return DownloadTaskState.PAUSED.value
if state_text in _QBITTORRENT_DOWNLOADING_STATES:
return DownloadTaskState.DOWNLOADING.value
return DownloadTaskState.COMPLETED.value
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None: def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
""" """
转移完成后的处理 转移完成后的处理
+136 -76
View File
@@ -10,8 +10,14 @@ from app.core.metainfo import MetaInfo
from app.log import logger from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase from app.modules import _ModuleBase, _DownloaderBase
from app.modules.rtorrent.rtorrent import Rtorrent from app.modules.rtorrent.rtorrent import Rtorrent
from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas import DownloaderTorrent
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType from app.schemas.types import (
DownloadTaskState,
DownloaderType,
ModuleType,
TorrentQueryStatus,
TorrentStatus,
)
from app.utils.string import StringUtils from app.utils.string import StringUtils
@@ -283,12 +289,14 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
status: TorrentStatus = None, status: TorrentStatus = None,
hashs: Union[list, str] = None, hashs: Union[list, str] = None,
downloader: Optional[str] = None, downloader: Optional[str] = None,
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
""" """
获取下载器种子列表 获取下载器种子列表
:param status: 种子状态 :param status: 种子状态
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器 :param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表 :return: 下载器中符合状态的种子列表
""" """
# 获取下载器 # 获取下载器
@@ -300,105 +308,108 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
else: else:
servers: Dict[str, Rtorrent] = self.get_instances() servers: Dict[str, Rtorrent] = self.get_instances()
ret_torrents = [] ret_torrents = []
query_status = self.__normalize_query_status(status)
query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_path(torrent_data: dict) -> Path:
"""
获取种子内容路径。
"""
content_path = torrent_data.get("content_path")
if content_path:
return Path(content_path)
return Path(torrent_data.get("save_path")) / torrent_data.get("name")
def __build_torrent(downloader_name: str, torrent_data: dict) -> DownloaderTorrent:
"""
构造统一下载器任务对象。
"""
meta = MetaInfo(torrent_data.get("name"))
dlspeed = torrent_data.get("dlspeed") or 0
upspeed = torrent_data.get("upspeed") or 0
total_size = torrent_data.get("total_size") or 0
completed_size = torrent_data.get("completed") or 0
torrent_path = __get_torrent_path(torrent_data)
return DownloaderTorrent(
downloader=downloader_name,
hash=torrent_data.get("hash"),
title=torrent_data.get("name"),
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
progress=torrent_data.get("progress", 0),
size=total_size,
state=self.__normalize_torrent_state(
torrent_data.get("state"), torrent_data.get("complete")
),
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(upspeed),
tags=torrent_data.get("tags"),
left_time=StringUtils.str_secends((total_size - completed_size) / dlspeed)
if dlspeed > 0
else "",
)
if hashs: if hashs:
# 按Hash获取 # 按Hash获取
for name, server in servers.items(): for name, server in servers.items():
torrents, _ = ( torrents, _ = (
server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or [] server.get_torrents(ids=hashs, tags=query_tags) or []
) )
try: try:
for torrent in torrents: for torrent_info in torrents:
content_path = torrent.get("content_path") ret_torrents.append(__build_torrent(name, torrent_info))
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = Path(torrent.get("save_path")) / torrent.get(
"name"
)
ret_torrents.append(
TransferTorrent(
downloader=name,
title=torrent.get("name"),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get("hash"),
size=torrent.get("total_size"),
tags=torrent.get("tags"),
progress=torrent.get("progress", 0),
state="paused"
if torrent.get("state") == 0
else "downloading",
)
)
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
elif status == TorrentStatus.TRANSFER: elif query_status == TorrentQueryStatus.TRANSFER:
# 获取已完成且未整理的 # 获取已完成且未整理的
for name, server in servers.items(): for name, server in servers.items():
torrents = ( torrents = (
server.get_completed_torrents(tags=settings.TORRENT_TAG) or [] server.get_completed_torrents(tags=query_tags) or []
) )
try: try:
for torrent in torrents: for torrent_info in torrents:
tags = torrent.get("tags") or "" tags = torrent_info.get("tags") or ""
tag_list = [t.strip() for t in tags.split(",") if t.strip()] tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if "已整理" in tag_list: if "已整理" in tag_list:
continue continue
content_path = torrent.get("content_path") ret_torrents.append(__build_torrent(name, torrent_info))
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = Path(torrent.get("save_path")) / torrent.get(
"name"
)
ret_torrents.append(
TransferTorrent(
downloader=name,
title=torrent.get("name"),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get("hash"),
tags=torrent.get("tags"),
)
)
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
elif status == TorrentStatus.DOWNLOADING: elif query_status == TorrentQueryStatus.DOWNLOADING:
# 获取正在下载的任务 # 获取正在下载的任务
for name, server in servers.items(): for name, server in servers.items():
torrents = ( torrents = (
server.get_downloading_torrents(tags=settings.TORRENT_TAG) or [] server.get_downloading_torrents(tags=query_tags) or []
) )
try: try:
for torrent in torrents: for torrent_info in torrents:
meta = MetaInfo(torrent.get("name")) ret_torrents.append(__build_torrent(name, torrent_info))
dlspeed = torrent.get("dlspeed", 0) finally:
upspeed = torrent.get("upspeed", 0) torrents.clear()
total_size = torrent.get("total_size", 0) del torrents
completed = torrent.get("completed", 0) elif query_status in (
ret_torrents.append( TorrentQueryStatus.ALL,
DownloadingTorrent( TorrentQueryStatus.COMPLETED,
downloader=name, TorrentQueryStatus.PAUSED,
hash=torrent.get("hash"), ):
title=torrent.get("name"), # 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
name=meta.name, for name, server in servers.items():
year=meta.year, torrents, _ = server.get_torrents(tags=query_tags) or []
season_episode=meta.season_episode, try:
progress=torrent.get("progress", 0), for torrent_info in torrents:
size=total_size, torrent_state = self.__normalize_torrent_state(
state="paused" torrent_info.get("state"), torrent_info.get("complete")
if torrent.get("state") == 0
else "downloading",
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(upspeed),
tags=torrent.get("tags"),
left_time=StringUtils.str_secends(
(total_size - completed) / dlspeed
)
if dlspeed > 0
else "",
)
) )
if (
query_status != TorrentQueryStatus.ALL
and torrent_state != query_status.value
):
continue
ret_torrents.append(__build_torrent(name, torrent_info))
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
@@ -406,6 +417,55 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
return None return None
return ret_torrents # noqa return ret_torrents # noqa
@staticmethod
def __normalize_query_status(
status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]]
) -> TorrentQueryStatus:
"""
归一任务查询状态。
"""
status_value = getattr(status, "value", status)
status_text = str(status_value or "").strip().lower()
if not status_text or status_text in {"all", "全部"}:
return TorrentQueryStatus.ALL
if status_text in {
TorrentStatus.TRANSFER.value,
TorrentQueryStatus.TRANSFER.value,
"transfer",
}:
return TorrentQueryStatus.TRANSFER
if status_text in {
TorrentStatus.DOWNLOADING.value,
TorrentQueryStatus.DOWNLOADING.value,
"downloading",
}:
return TorrentQueryStatus.DOWNLOADING
if status_text in {
TorrentQueryStatus.COMPLETED.value,
"complete",
"seeding",
"完成",
"已完成",
}:
return TorrentQueryStatus.COMPLETED
if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}:
return TorrentQueryStatus.PAUSED
return TorrentQueryStatus.ALL
@staticmethod
def __normalize_torrent_state(
state: Optional[Union[int, str]],
complete: Optional[Union[int, str]],
) -> str:
"""
归一 rTorrent 原始任务状态。
"""
if str(state) == "0":
return DownloadTaskState.PAUSED.value
if str(complete) == "0":
return DownloadTaskState.DOWNLOADING.value
return DownloadTaskState.COMPLETED.value
def transfer_completed( def transfer_completed(
self, hashs: Union[str, list], downloader: Optional[str] = None self, hashs: Union[str, list], downloader: Optional[str] = None
) -> None: ) -> None:
+171 -53
View File
@@ -11,10 +11,24 @@ from app.core.metainfo import MetaInfo
from app.log import logger from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase from app.modules import _ModuleBase, _DownloaderBase
from app.modules.transmission.transmission import Transmission from app.modules.transmission.transmission import Transmission
from app.schemas import TransferTorrent, DownloadingTorrent from app.schemas import DownloaderTorrent
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType from app.schemas.types import (
DownloadTaskState,
DownloaderType,
ModuleType,
TorrentQueryStatus,
TorrentStatus,
)
from app.utils.string import StringUtils from app.utils.string import StringUtils
_TRANSMISSION_DOWNLOADING_STATES = {
"download_pending",
"downloading",
}
_TRANSMISSION_PAUSED_STATES = {
"stopped",
}
class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
@@ -225,13 +239,15 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
def list_torrents(self, status: TorrentStatus = None, def list_torrents(self, status: TorrentStatus = None,
hashs: Union[list, str] = None, hashs: Union[list, str] = None,
downloader: Optional[str] = None downloader: Optional[str] = None,
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
""" """
获取下载器种子列表 获取下载器种子列表
:param status: 种子状态 :param status: 种子状态
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器 :param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表 :return: 下载器中符合状态的种子列表
""" """
# 获取下载器 # 获取下载器
@@ -243,77 +259,132 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
else: else:
servers: Dict[str, Transmission] = self.get_instances() servers: Dict[str, Transmission] = self.get_instances()
ret_torrents = [] ret_torrents = []
query_status = self.__normalize_query_status(status)
query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_attr(torrent_data, *attr_names):
"""
兼容 transmission-rpc 新旧字段名。
"""
for attr_name in attr_names:
if hasattr(torrent_data, attr_name):
return getattr(torrent_data, attr_name)
return None
def __get_torrent_progress(torrent_data) -> float:
"""
获取任务进度。
"""
return __get_torrent_attr(torrent_data, "progress", "percent_done") or 0
def __get_torrent_size(torrent_data) -> int:
"""
获取任务大小。
"""
return __get_torrent_attr(torrent_data, "total_size", "totalSize") or 0
def __get_torrent_labels(torrent_data) -> str:
"""
获取任务标签。
"""
return ",".join(getattr(torrent_data, "labels", None) or [])
def __get_torrent_path(torrent_data) -> Path:
"""
获取任务内容路径。
"""
return Path(torrent_data.download_dir) / torrent_data.name
def __build_torrent(downloader_name: str, torrent_data) -> DownloaderTorrent:
"""
构造统一下载器任务对象。
"""
meta = MetaInfo(torrent_data.name)
dlspeed = __get_torrent_attr(
torrent_data, "rate_download", "rateDownload"
) or 0
upspeed = __get_torrent_attr(
torrent_data, "rate_upload", "rateUpload"
) or 0
left_until_done = __get_torrent_attr(
torrent_data, "left_until_done", "leftUntilDone"
) or 0
torrent_path = __get_torrent_path(torrent_data)
return DownloaderTorrent(
downloader=downloader_name,
hash=torrent_data.hashString,
title=torrent_data.name,
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
progress=__get_torrent_progress(torrent_data),
size=__get_torrent_size(torrent_data),
state=self.__normalize_torrent_state(torrent_data.status),
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(upspeed),
tags=__get_torrent_labels(torrent_data),
left_time=StringUtils.str_secends(
left_until_done / dlspeed
) if dlspeed > 0 else ''
)
if hashs: if hashs:
# 按Hash获取 # 按Hash获取
for name, server in servers.items(): for name, server in servers.items():
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or [] torrents, _ = server.get_torrents(ids=hashs, tags=query_tags) or []
try: try:
for torrent in torrents: for torrent_info in torrents:
torrent_path = Path(torrent.download_dir) / torrent.name ret_torrents.append(__build_torrent(name, torrent_info))
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.name,
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.hashString,
size=torrent.total_size,
tags=",".join(torrent.labels or []),
progress=torrent.progress
))
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
elif status == TorrentStatus.TRANSFER: elif query_status == TorrentQueryStatus.TRANSFER:
# 获取已完成且未整理的 # 获取已完成且未整理的
for name, server in servers.items(): for name, server in servers.items():
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or [] torrents = server.get_completed_torrents(tags=query_tags) or []
try: try:
for torrent in torrents: for torrent_info in torrents:
# 含"已整理"tag的不处理 # 含"已整理"tag的不处理
if "已整理" in torrent.labels or []: if "已整理" in torrent_info.labels or []:
continue continue
# 下载路径 # 下载路径
path = torrent.download_dir path = torrent_info.download_dir
# 无法获取下载路径的不处理 # 无法获取下载路径的不处理
if not path: if not path:
logger.debug(f"未获取到 {torrent.name} 下载保存路径") logger.debug(f"未获取到 {torrent_info.name} 下载保存路径")
continue continue
torrent_path = Path(torrent.download_dir) / torrent.name ret_torrents.append(__build_torrent(name, torrent_info))
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.name,
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.hashString,
tags=",".join(torrent.labels or []),
progress=torrent.progress,
state="paused" if torrent.status == "stopped" else "downloading",
))
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
elif status == TorrentStatus.DOWNLOADING: elif query_status == TorrentQueryStatus.DOWNLOADING:
# 获取正在下载的任务 # 获取正在下载的任务
for name, server in servers.items(): for name, server in servers.items():
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or [] torrents = server.get_downloading_torrents(tags=query_tags) or []
try: try:
for torrent in torrents: for torrent_info in torrents:
meta = MetaInfo(torrent.name) ret_torrents.append(__build_torrent(name, torrent_info))
dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload finally:
upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload torrents.clear()
ret_torrents.append(DownloadingTorrent( del torrents
downloader=name, elif query_status in (
hash=torrent.hashString, TorrentQueryStatus.ALL,
title=torrent.name, TorrentQueryStatus.COMPLETED,
name=meta.name, TorrentQueryStatus.PAUSED,
year=meta.year, ):
season_episode=meta.season_episode, # 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
progress=torrent.progress, for name, server in servers.items():
size=torrent.total_size, torrents, _ = server.get_torrents(tags=query_tags) or []
state="paused" if torrent.status == "stopped" else "downloading", try:
dlspeed=StringUtils.str_filesize(dlspeed), for torrent_info in torrents:
upspeed=StringUtils.str_filesize(upspeed), torrent_state = self.__normalize_torrent_state(torrent_info.status)
tags=",".join(torrent.labels or []), if (
left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else '' query_status != TorrentQueryStatus.ALL
)) and torrent_state != query_status.value
):
continue
ret_torrents.append(__build_torrent(name, torrent_info))
finally: finally:
torrents.clear() torrents.clear()
del torrents del torrents
@@ -321,6 +392,53 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
return None return None
return ret_torrents # noqa return ret_torrents # noqa
@staticmethod
def __normalize_query_status(
status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]]
) -> TorrentQueryStatus:
"""
归一任务查询状态。
"""
status_value = getattr(status, "value", status)
status_text = str(status_value or "").strip().lower()
if not status_text or status_text in {"all", "全部"}:
return TorrentQueryStatus.ALL
if status_text in {
TorrentStatus.TRANSFER.value,
TorrentQueryStatus.TRANSFER.value,
"transfer",
}:
return TorrentQueryStatus.TRANSFER
if status_text in {
TorrentStatus.DOWNLOADING.value,
TorrentQueryStatus.DOWNLOADING.value,
"downloading",
}:
return TorrentQueryStatus.DOWNLOADING
if status_text in {
TorrentQueryStatus.COMPLETED.value,
"complete",
"seeding",
"完成",
"已完成",
}:
return TorrentQueryStatus.COMPLETED
if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}:
return TorrentQueryStatus.PAUSED
return TorrentQueryStatus.ALL
@staticmethod
def __normalize_torrent_state(status: Optional[str]) -> str:
"""
归一 Transmission 原始任务状态。
"""
status_text = str(status or "").strip().lower()
if status_text in _TRANSMISSION_PAUSED_STATES:
return DownloadTaskState.PAUSED.value
if status_text in _TRANSMISSION_DOWNLOADING_STATES:
return DownloadTaskState.DOWNLOADING.value
return DownloadTaskState.COMPLETED.value
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None: def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
""" """
转移完成后的处理 转移完成后的处理
+15 -17
View File
@@ -10,24 +10,9 @@ from app.schemas.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode from app.schemas.tmdb import TmdbEpisode
class TransferTorrent(BaseModel): class DownloaderTorrent(BaseModel):
""" """
待转移任务信息 下载器任务信息
"""
downloader: Optional[str] = None
title: Optional[str] = None
path: Optional[Path] = None
hash: Optional[str] = None
tags: Optional[str] = None
size: Optional[int] = 0
userid: Optional[str] = None
progress: Optional[float] = 0.0
state: Optional[str] = None
class DownloadingTorrent(BaseModel):
"""
下载中任务信息
""" """
downloader: Optional[str] = None downloader: Optional[str] = None
hash: Optional[str] = None hash: Optional[str] = None
@@ -35,6 +20,7 @@ class DownloadingTorrent(BaseModel):
name: Optional[str] = None name: Optional[str] = None
year: Optional[str] = None year: Optional[str] = None
season_episode: Optional[str] = None season_episode: Optional[str] = None
path: Optional[Path] = None
size: Optional[float] = 0.0 size: Optional[float] = 0.0
progress: Optional[float] = 0.0 progress: Optional[float] = 0.0
state: Optional[str] = 'downloading' state: Optional[str] = 'downloading'
@@ -47,6 +33,18 @@ class DownloadingTorrent(BaseModel):
left_time: Optional[str] = None left_time: Optional[str] = None
class TransferTorrent(DownloaderTorrent):
"""
待转移任务信息
"""
class DownloadingTorrent(DownloaderTorrent):
"""
下载中任务信息
"""
class TransferTask(BaseModel): class TransferTask(BaseModel):
""" """
文件整理任务 文件整理任务
+16
View File
@@ -43,6 +43,22 @@ class TorrentStatus(Enum):
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 EventType(Enum): class EventType(Enum):
# 插件需要重载 # 插件需要重载
+2
View File
@@ -108,6 +108,8 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 | | GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 |
| DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` | | DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` |
MCP 工具 `query_download_tasks` 支持 `status=all|downloading|completed|paused`;其中 `completed` 表示下载器任务既不是下载中,也不是暂停状态。默认仅查询带 MoviePilot 内置标签的任务;如需诊断下载器中未打内置标签的任务,可传 `include_all_tags=true`
#### 系统 #### 系统
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
+2
View File
@@ -126,6 +126,8 @@ Subscribe starting from a specific episode:
List download tasks and get hash for further operations: List download tasks and get hash for further operations:
`node scripts/mp-cli.js query_download_tasks status=downloading` `node scripts/mp-cli.js query_download_tasks status=downloading`
Use `status=completed` for tasks that are neither downloading nor paused in the downloader; use `status=all` to include every MoviePilot-tagged downloader task. Add `include_all_tags=true` when diagnosing tasks that do not have the MoviePilot built-in tag.
Delete a download task (confirm with user first — irreversible): Delete a download task (confirm with user first — irreversible):
`node scripts/mp-cli.js delete_download hash=<hash>` `node scripts/mp-cli.js delete_download hash=<hash>`
@@ -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,
)
+78
View File
@@ -133,10 +133,26 @@ def _load_transmission_module():
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
class _DownloaderTorrent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class TorrentStatus(Enum): class TorrentStatus(Enum):
TRANSFER = "transfer" TRANSFER = "transfer"
DOWNLOADING = "downloading" 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: class _Logger:
def debug(self, *_args, **_kwargs): def debug(self, *_args, **_kwargs):
pass pass
@@ -179,8 +195,11 @@ def _load_transmission_module():
cache_module.FileCache = _FileCache cache_module.FileCache = _FileCache
schemas_module.TransferTorrent = _TransferTorrent schemas_module.TransferTorrent = _TransferTorrent
schemas_module.DownloadingTorrent = _DownloadingTorrent schemas_module.DownloadingTorrent = _DownloadingTorrent
schemas_module.DownloaderTorrent = _DownloaderTorrent
schemas_module.DownloaderInfo = object schemas_module.DownloaderInfo = object
schema_types_module.TorrentStatus = TorrentStatus 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.ModuleType = Enum("ModuleType", {"Downloader": "downloader"})
schema_types_module.DownloaderType = Enum( schema_types_module.DownloaderType = Enum(
"DownloaderType", {"Transmission": "Transmission"} "DownloaderType", {"Transmission": "Transmission"}
@@ -359,3 +378,62 @@ class TransmissionPathMappingTest(unittest.TestCase):
Path("/mnt/raid5/home_lt999lt/video/downloads/movie/Movie"), Path("/mnt/raid5/home_lt999lt/video/downloads/movie/Movie"),
"tr", "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)
+96
View File
@@ -92,10 +92,26 @@ def _load_qbittorrent_modules():
def from_string(content): def from_string(content):
return types.SimpleNamespace(name="test", total_size=len(content)) return types.SimpleNamespace(name="test", total_size=len(content))
class _DownloaderTorrent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class TorrentStatus(Enum): class TorrentStatus(Enum):
TRANSFER = "transfer" TRANSFER = "transfer"
DOWNLOADING = "downloading" 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): class ModuleType(Enum):
Downloader = "Downloader" Downloader = "Downloader"
@@ -109,7 +125,10 @@ def _load_qbittorrent_modules():
schemas_module.DownloaderInfo = object schemas_module.DownloaderInfo = object
schemas_module.TransferTorrent = object schemas_module.TransferTorrent = object
schemas_module.DownloadingTorrent = object schemas_module.DownloadingTorrent = object
schemas_module.DownloaderTorrent = _DownloaderTorrent
schema_types_module.TorrentStatus = TorrentStatus schema_types_module.TorrentStatus = TorrentStatus
schema_types_module.TorrentQueryStatus = TorrentQueryStatus
schema_types_module.DownloadTaskState = DownloadTaskState
schema_types_module.ModuleType = ModuleType schema_types_module.ModuleType = ModuleType
schema_types_module.DownloaderType = DownloaderType schema_types_module.DownloaderType = DownloaderType
string_module.StringUtils = _StringUtils 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() 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(): def test_add_torrent_accepts_structured_success_response():
"""新版 qBittorrent API 结构化成功响应应返回新增种子 ID。""" """新版 qBittorrent API 结构化成功响应应返回新增种子 ID。"""
fake_client = MagicMock() fake_client = MagicMock()