mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-16 06:58:08 +08:00
fix downloader task status queries
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""查询下载工具"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -10,8 +10,8 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.chain.download import DownloadChain
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, media_type_to_agent
|
||||
from app.schemas import DownloaderTorrent
|
||||
from app.schemas.types import TorrentQueryStatus, media_type_to_agent
|
||||
|
||||
|
||||
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)")
|
||||
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")
|
||||
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)")
|
||||
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')")
|
||||
@@ -36,26 +40,45 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
args_schema: Type[BaseModel] = QueryDownloadTasksInput
|
||||
|
||||
@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 = []
|
||||
# 查询下载的任务
|
||||
downloading_torrents = download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=TorrentStatus.DOWNLOADING
|
||||
) or []
|
||||
all_torrents.extend(downloading_torrents)
|
||||
|
||||
# 查询已完成的任务(可转移状态)
|
||||
transfer_torrents = download_chain.list_torrents(
|
||||
return download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=TorrentStatus.TRANSFER
|
||||
include_all_tags=include_all_tags,
|
||||
) or []
|
||||
all_torrents.extend(transfer_torrents)
|
||||
|
||||
return all_torrents
|
||||
|
||||
@staticmethod
|
||||
def _format_progress(progress: Optional[float]) -> Optional[str]:
|
||||
@@ -71,7 +94,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _apply_download_history(
|
||||
torrent: Union[TransferTorrent, DownloadingTorrent], history: Any
|
||||
torrent: DownloaderTorrent, history: Any
|
||||
) -> None:
|
||||
"""将下载历史中的补充信息回填到下载任务结果中。"""
|
||||
if not history:
|
||||
@@ -91,7 +114,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
|
||||
@classmethod
|
||||
def _load_history_map(
|
||||
cls, torrents: List[Union[TransferTorrent, DownloadingTorrent]]
|
||||
cls, torrents: List[DownloaderTorrent]
|
||||
) -> Dict[str, Any]:
|
||||
"""批量加载下载历史,避免逐条查询形成 N+1。"""
|
||||
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
|
||||
@@ -107,15 +130,22 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
hash_value: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
include_all_tags: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
同步查询下载器和下载历史,整个链路放在线程池中执行。
|
||||
"""
|
||||
download_chain = DownloadChain()
|
||||
query_status = cls._normalize_query_status(status)
|
||||
include_all_tags = cls._normalize_include_all_tags(include_all_tags)
|
||||
|
||||
if hash_value:
|
||||
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 []
|
||||
)
|
||||
if not torrents:
|
||||
@@ -128,7 +158,11 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
cls._apply_download_history(torrent, history_map.get(torrent.hash))
|
||||
filtered_downloads = list(torrents)
|
||||
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)
|
||||
filtered_downloads = []
|
||||
title_lower = title.lower()
|
||||
@@ -150,7 +184,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not filtered_downloads:
|
||||
return {"message": f"未找到标题包含 '{title}' 的下载任务"}
|
||||
else:
|
||||
if status == "downloading":
|
||||
if query_status == TorrentQueryStatus.DOWNLOADING and not include_all_tags:
|
||||
downloads = download_chain.downloading(name=downloader) or []
|
||||
filtered_downloads = [
|
||||
dl
|
||||
@@ -158,19 +192,12 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not downloader or dl.downloader == downloader
|
||||
]
|
||||
else:
|
||||
all_torrents = cls._get_all_torrents(download_chain, downloader)
|
||||
filtered_downloads = []
|
||||
for torrent in all_torrents:
|
||||
if downloader and torrent.downloader != downloader:
|
||||
continue
|
||||
if status == "completed" and torrent.state not in [
|
||||
"seeding",
|
||||
"completed",
|
||||
]:
|
||||
continue
|
||||
if status == "paused" and torrent.state != "paused":
|
||||
continue
|
||||
filtered_downloads.append(torrent)
|
||||
list_status = None if query_status == TorrentQueryStatus.ALL else query_status.value
|
||||
filtered_downloads = download_chain.list_torrents(
|
||||
downloader=downloader,
|
||||
status=list_status,
|
||||
include_all_tags=include_all_tags,
|
||||
) or []
|
||||
|
||||
history_map = cls._load_history_map(filtered_downloads)
|
||||
for torrent in filtered_downloads:
|
||||
@@ -195,6 +222,9 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
status = kwargs.get("status", "all")
|
||||
hash_value = kwargs.get("hash")
|
||||
title = kwargs.get("title")
|
||||
include_all_tags = self._normalize_include_all_tags(
|
||||
kwargs.get("include_all_tags", False)
|
||||
)
|
||||
|
||||
parts = ["查询下载任务"]
|
||||
|
||||
@@ -213,6 +243,8 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
tag = kwargs.get("tag")
|
||||
if tag:
|
||||
parts.append(f"标签: {tag}")
|
||||
if include_all_tags:
|
||||
parts.append("范围: 全部标签")
|
||||
|
||||
return " | ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
@@ -220,8 +252,13 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
status: Optional[str] = "all",
|
||||
hash: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
tag: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}, tag={tag}")
|
||||
tag: Optional[str] = None,
|
||||
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:
|
||||
payload = await self.run_blocking(
|
||||
"downloader",
|
||||
@@ -231,6 +268,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
hash,
|
||||
title,
|
||||
tag,
|
||||
self._normalize_include_all_tags(include_all_tags),
|
||||
)
|
||||
if payload.get("message"):
|
||||
return payload["message"]
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.schemas.types import SystemConfigKey
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent])
|
||||
def current(
|
||||
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
|
||||
@@ -28,9 +28,8 @@ from app.log import logger
|
||||
from app.schemas import (
|
||||
RateLimitExceededException,
|
||||
TransferInfo,
|
||||
TransferTorrent,
|
||||
ExistMediaInfo,
|
||||
DownloadingTorrent,
|
||||
DownloaderTorrent,
|
||||
CommingMessage,
|
||||
Notification,
|
||||
WebhookEventInfo,
|
||||
@@ -1221,16 +1220,22 @@ class ChainBase(metaclass=ABCMeta):
|
||||
status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: Optional[str] = None,
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
include_all_tags: bool = False,
|
||||
) -> Optional[List[DownloaderTorrent]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:param include_all_tags: 是否包含未打内置标签的下载任务
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
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(
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.helper.directory import DirectoryHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
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
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
|
||||
ChainEventType
|
||||
@@ -1359,7 +1359,7 @@ class DownloadChain(ChainBase):
|
||||
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
|
||||
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:
|
||||
self.remove_torrents(hashs=[hash_str], delete_file=False)
|
||||
# 发出下载任务删除事件,如需处理辅种,可监听该事件
|
||||
|
||||
@@ -11,10 +11,33 @@ from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, _DownloaderBase
|
||||
from app.modules.qbittorrent.qbittorrent import Qbittorrent
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
|
||||
from app.schemas import DownloaderTorrent
|
||||
from app.schemas.types import (
|
||||
DownloadTaskState,
|
||||
DownloaderType,
|
||||
ModuleType,
|
||||
TorrentQueryStatus,
|
||||
TorrentStatus,
|
||||
)
|
||||
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]):
|
||||
|
||||
@@ -236,13 +259,15 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: Optional[str] = None
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
downloader: Optional[str] = None,
|
||||
include_all_tags: bool = False,
|
||||
) -> Optional[List[DownloaderTorrent]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:param include_all_tags: 是否包含未打内置标签的下载任务
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
# 获取下载器
|
||||
@@ -254,80 +279,96 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
else:
|
||||
servers: Dict[str, Qbittorrent] = self.get_instances()
|
||||
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:
|
||||
# 按Hash获取
|
||||
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:
|
||||
for torrent in torrents:
|
||||
content_path = torrent.get("content_path")
|
||||
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",
|
||||
))
|
||||
for torrent_info in torrents:
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.TRANSFER:
|
||||
elif query_status == TorrentQueryStatus.TRANSFER:
|
||||
# 获取已完成且未整理的
|
||||
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:
|
||||
for torrent in torrents:
|
||||
tags = torrent.get("tags") or []
|
||||
for torrent_info in torrents:
|
||||
tags = torrent_info.get("tags") or []
|
||||
if "已整理" in tags:
|
||||
continue
|
||||
# 内容路径
|
||||
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')
|
||||
))
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.DOWNLOADING:
|
||||
elif query_status == TorrentQueryStatus.DOWNLOADING:
|
||||
# 获取正在下载的任务
|
||||
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:
|
||||
for torrent in torrents:
|
||||
meta = MetaInfo(torrent.get('name'))
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
downloader=name,
|
||||
hash=torrent.get('hash'),
|
||||
title=torrent.get('name'),
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.get('progress') * 100,
|
||||
size=torrent.get('total_size'),
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
||||
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
||||
tags=torrent.get('tags'),
|
||||
left_time=StringUtils.str_secends(
|
||||
(torrent.get('total_size') - torrent.get('completed')) / torrent.get(
|
||||
'dlspeed')) if torrent.get(
|
||||
'dlspeed') > 0 else ''
|
||||
))
|
||||
for torrent_info in torrents:
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif query_status in (
|
||||
TorrentQueryStatus.ALL,
|
||||
TorrentQueryStatus.COMPLETED,
|
||||
TorrentQueryStatus.PAUSED,
|
||||
):
|
||||
# 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
|
||||
for name, server in servers.items():
|
||||
torrents, _ = server.get_torrents(tags=query_tags) or []
|
||||
try:
|
||||
for torrent_info in torrents:
|
||||
torrent_state = self.__normalize_torrent_state(torrent_info.get('state'))
|
||||
if (
|
||||
query_status != TorrentQueryStatus.ALL
|
||||
and torrent_state != query_status.value
|
||||
):
|
||||
continue
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
@@ -335,6 +376,53 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
return None
|
||||
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:
|
||||
"""
|
||||
转移完成后的处理
|
||||
|
||||
@@ -10,8 +10,14 @@ from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, _DownloaderBase
|
||||
from app.modules.rtorrent.rtorrent import Rtorrent
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
|
||||
from app.schemas import DownloaderTorrent
|
||||
from app.schemas.types import (
|
||||
DownloadTaskState,
|
||||
DownloaderType,
|
||||
ModuleType,
|
||||
TorrentQueryStatus,
|
||||
TorrentStatus,
|
||||
)
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -283,12 +289,14 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
|
||||
status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: Optional[str] = None,
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
include_all_tags: bool = False,
|
||||
) -> Optional[List[DownloaderTorrent]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:param include_all_tags: 是否包含未打内置标签的下载任务
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
# 获取下载器
|
||||
@@ -300,105 +308,108 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
|
||||
else:
|
||||
servers: Dict[str, Rtorrent] = self.get_instances()
|
||||
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:
|
||||
# 按Hash获取
|
||||
for name, server in servers.items():
|
||||
torrents, _ = (
|
||||
server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []
|
||||
server.get_torrents(ids=hashs, tags=query_tags) or []
|
||||
)
|
||||
try:
|
||||
for torrent in torrents:
|
||||
content_path = torrent.get("content_path")
|
||||
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",
|
||||
)
|
||||
)
|
||||
for torrent_info in torrents:
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.TRANSFER:
|
||||
elif query_status == TorrentQueryStatus.TRANSFER:
|
||||
# 获取已完成且未整理的
|
||||
for name, server in servers.items():
|
||||
torrents = (
|
||||
server.get_completed_torrents(tags=settings.TORRENT_TAG) or []
|
||||
server.get_completed_torrents(tags=query_tags) or []
|
||||
)
|
||||
try:
|
||||
for torrent in torrents:
|
||||
tags = torrent.get("tags") or ""
|
||||
for torrent_info in torrents:
|
||||
tags = torrent_info.get("tags") or ""
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
if "已整理" in tag_list:
|
||||
continue
|
||||
content_path = torrent.get("content_path")
|
||||
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"),
|
||||
)
|
||||
)
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.DOWNLOADING:
|
||||
elif query_status == TorrentQueryStatus.DOWNLOADING:
|
||||
# 获取正在下载的任务
|
||||
for name, server in servers.items():
|
||||
torrents = (
|
||||
server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []
|
||||
server.get_downloading_torrents(tags=query_tags) or []
|
||||
)
|
||||
try:
|
||||
for torrent in torrents:
|
||||
meta = MetaInfo(torrent.get("name"))
|
||||
dlspeed = torrent.get("dlspeed", 0)
|
||||
upspeed = torrent.get("upspeed", 0)
|
||||
total_size = torrent.get("total_size", 0)
|
||||
completed = torrent.get("completed", 0)
|
||||
ret_torrents.append(
|
||||
DownloadingTorrent(
|
||||
downloader=name,
|
||||
hash=torrent.get("hash"),
|
||||
title=torrent.get("name"),
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.get("progress", 0),
|
||||
size=total_size,
|
||||
state="paused"
|
||||
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 "",
|
||||
)
|
||||
for torrent_info in torrents:
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif query_status in (
|
||||
TorrentQueryStatus.ALL,
|
||||
TorrentQueryStatus.COMPLETED,
|
||||
TorrentQueryStatus.PAUSED,
|
||||
):
|
||||
# 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
|
||||
for name, server in servers.items():
|
||||
torrents, _ = server.get_torrents(tags=query_tags) or []
|
||||
try:
|
||||
for torrent_info in torrents:
|
||||
torrent_state = self.__normalize_torrent_state(
|
||||
torrent_info.get("state"), torrent_info.get("complete")
|
||||
)
|
||||
if (
|
||||
query_status != TorrentQueryStatus.ALL
|
||||
and torrent_state != query_status.value
|
||||
):
|
||||
continue
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
@@ -406,6 +417,55 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
|
||||
return None
|
||||
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(
|
||||
self, hashs: Union[str, list], downloader: Optional[str] = None
|
||||
) -> None:
|
||||
|
||||
@@ -11,10 +11,24 @@ from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, _DownloaderBase
|
||||
from app.modules.transmission.transmission import Transmission
|
||||
from app.schemas import TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
|
||||
from app.schemas import DownloaderTorrent
|
||||
from app.schemas.types import (
|
||||
DownloadTaskState,
|
||||
DownloaderType,
|
||||
ModuleType,
|
||||
TorrentQueryStatus,
|
||||
TorrentStatus,
|
||||
)
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
_TRANSMISSION_DOWNLOADING_STATES = {
|
||||
"download_pending",
|
||||
"downloading",
|
||||
}
|
||||
_TRANSMISSION_PAUSED_STATES = {
|
||||
"stopped",
|
||||
}
|
||||
|
||||
|
||||
class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
|
||||
@@ -225,13 +239,15 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: Optional[str] = None
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
downloader: Optional[str] = None,
|
||||
include_all_tags: bool = False,
|
||||
) -> Optional[List[DownloaderTorrent]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:param include_all_tags: 是否包含未打内置标签的下载任务
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
# 获取下载器
|
||||
@@ -243,77 +259,132 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
else:
|
||||
servers: Dict[str, Transmission] = self.get_instances()
|
||||
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:
|
||||
# 按Hash获取
|
||||
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:
|
||||
for torrent in torrents:
|
||||
torrent_path = Path(torrent.download_dir) / torrent.name
|
||||
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
|
||||
))
|
||||
for torrent_info in torrents:
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.TRANSFER:
|
||||
elif query_status == TorrentQueryStatus.TRANSFER:
|
||||
# 获取已完成且未整理的
|
||||
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:
|
||||
for torrent in torrents:
|
||||
for torrent_info in torrents:
|
||||
# 含"已整理"tag的不处理
|
||||
if "已整理" in torrent.labels or []:
|
||||
if "已整理" in torrent_info.labels or []:
|
||||
continue
|
||||
# 下载路径
|
||||
path = torrent.download_dir
|
||||
path = torrent_info.download_dir
|
||||
# 无法获取下载路径的不处理
|
||||
if not path:
|
||||
logger.debug(f"未获取到 {torrent.name} 下载保存路径")
|
||||
logger.debug(f"未获取到 {torrent_info.name} 下载保存路径")
|
||||
continue
|
||||
torrent_path = Path(torrent.download_dir) / torrent.name
|
||||
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",
|
||||
))
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.DOWNLOADING:
|
||||
elif query_status == TorrentQueryStatus.DOWNLOADING:
|
||||
# 获取正在下载的任务
|
||||
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:
|
||||
for torrent in torrents:
|
||||
meta = MetaInfo(torrent.name)
|
||||
dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload
|
||||
upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
downloader=name,
|
||||
hash=torrent.hashString,
|
||||
title=torrent.name,
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.progress,
|
||||
size=torrent.total_size,
|
||||
state="paused" if torrent.status == "stopped" else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(dlspeed),
|
||||
upspeed=StringUtils.str_filesize(upspeed),
|
||||
tags=",".join(torrent.labels or []),
|
||||
left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else ''
|
||||
))
|
||||
for torrent_info in torrents:
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif query_status in (
|
||||
TorrentQueryStatus.ALL,
|
||||
TorrentQueryStatus.COMPLETED,
|
||||
TorrentQueryStatus.PAUSED,
|
||||
):
|
||||
# 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
|
||||
for name, server in servers.items():
|
||||
torrents, _ = server.get_torrents(tags=query_tags) or []
|
||||
try:
|
||||
for torrent_info in torrents:
|
||||
torrent_state = self.__normalize_torrent_state(torrent_info.status)
|
||||
if (
|
||||
query_status != TorrentQueryStatus.ALL
|
||||
and torrent_state != query_status.value
|
||||
):
|
||||
continue
|
||||
ret_torrents.append(__build_torrent(name, torrent_info))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
@@ -321,6 +392,53 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
|
||||
return None
|
||||
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:
|
||||
"""
|
||||
转移完成后的处理
|
||||
|
||||
@@ -10,24 +10,9 @@ from app.schemas.system import TransferDirectoryConf
|
||||
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
|
||||
hash: Optional[str] = None
|
||||
@@ -35,6 +20,7 @@ class DownloadingTorrent(BaseModel):
|
||||
name: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
season_episode: Optional[str] = None
|
||||
path: Optional[Path] = None
|
||||
size: Optional[float] = 0.0
|
||||
progress: Optional[float] = 0.0
|
||||
state: Optional[str] = 'downloading'
|
||||
@@ -47,6 +33,18 @@ class DownloadingTorrent(BaseModel):
|
||||
left_time: Optional[str] = None
|
||||
|
||||
|
||||
class TransferTorrent(DownloaderTorrent):
|
||||
"""
|
||||
待转移任务信息
|
||||
"""
|
||||
|
||||
|
||||
class DownloadingTorrent(DownloaderTorrent):
|
||||
"""
|
||||
下载中任务信息
|
||||
"""
|
||||
|
||||
|
||||
class TransferTask(BaseModel):
|
||||
"""
|
||||
文件整理任务
|
||||
|
||||
@@ -43,6 +43,22 @@ class TorrentStatus(Enum):
|
||||
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):
|
||||
# 插件需要重载
|
||||
|
||||
@@ -108,6 +108,8 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
|
||||
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 |
|
||||
| DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` |
|
||||
|
||||
MCP 工具 `query_download_tasks` 支持 `status=all|downloading|completed|paused`;其中 `completed` 表示下载器任务既不是下载中,也不是暂停状态。默认仅查询带 MoviePilot 内置标签的任务;如需诊断下载器中未打内置标签的任务,可传 `include_all_tags=true`。
|
||||
|
||||
#### 系统
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@@ -126,6 +126,8 @@ Subscribe starting from a specific episode:
|
||||
List download tasks and get hash for further operations:
|
||||
`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):
|
||||
`node scripts/mp-cli.js delete_download hash=<hash>`
|
||||
|
||||
|
||||
199
tests/test_agent_query_download_tasks_tool.py
Normal file
199
tests/test_agent_query_download_tasks_tool.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user