diff --git a/app/agent/tools/impl/query_download_tasks.py b/app/agent/tools/impl/query_download_tasks.py index 35fbb24f..9bdb547c 100644 --- a/app/agent/tools/impl/query_download_tasks.py +++ b/app/agent/tools/impl/query_download_tasks.py @@ -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"] diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index f342fcff..922a569a 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -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: diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 22066000..576a9c93 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -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( diff --git a/app/chain/download.py b/app/chain/download.py index 47e14c66..07b18371 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -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) # 发出下载任务删除事件,如需处理辅种,可监听该事件 diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index 31585d3b..70902f66 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -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: """ 转移完成后的处理 diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index 92016735..fb078e3c 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -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: diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index 4bb84d55..bbae4358 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -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: """ 转移完成后的处理 diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 273e4e72..e9f343e1 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -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): """ 文件整理任务 diff --git a/app/schemas/types.py b/app/schemas/types.py index e5c1f1e8..d7036a3c 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -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): # 插件需要重载 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 0c0654ec..345a494b 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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`。 + #### 系统 | 方法 | 路径 | 说明 | diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index 53a979b9..74ebc89d 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -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=` diff --git a/tests/test_agent_query_download_tasks_tool.py b/tests/test_agent_query_download_tasks_tool.py new file mode 100644 index 00000000..9cfbe85e --- /dev/null +++ b/tests/test_agent_query_download_tasks_tool.py @@ -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, + ) diff --git a/tests/test_downloader_path_mapping.py b/tests/test_downloader_path_mapping.py index 85c3efac..a12f89f7 100644 --- a/tests/test_downloader_path_mapping.py +++ b/tests/test_downloader_path_mapping.py @@ -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) diff --git a/tests/test_qbittorrent_compat.py b/tests/test_qbittorrent_compat.py index 04f2e075..7fea30ec 100644 --- a/tests/test_qbittorrent_compat.py +++ b/tests/test_qbittorrent_compat.py @@ -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()