feat: enhance agent download task controls

This commit is contained in:
jxxghp
2026-06-15 13:51:35 +08:00
parent d2803bed1e
commit 6a635ac720
19 changed files with 1332 additions and 183 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
from app.agent.tools.impl.delete_download import DeleteDownloadTool
from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
from app.agent.tools.impl.modify_download import ModifyDownloadTool
from app.agent.tools.impl.update_download_tasks import UpdateDownloadTasksTool
from app.agent.tools.impl.query_directory_settings import QueryDirectorySettingsTool
from app.agent.tools.impl.list_directory import ListDirectoryTool
from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
@@ -186,7 +186,7 @@ class MoviePilotToolFactory:
DeleteDownloadTool,
DeleteDownloadHistoryTool,
DeleteTransferHistoryTool,
ModifyDownloadTool,
UpdateDownloadTasksTool,
QueryDownloadersTool,
QuerySitesTool,
UpdateSiteTool,
-143
View File
@@ -1,143 +0,0 @@
"""修改下载任务工具"""
from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.log import logger
class ModifyDownloadInput(BaseModel):
"""修改下载任务工具的输入参数模型"""
explanation: Optional[str] = Field(None,
description="Clear explanation of why this tool is being used in the current context",)
hash: str = Field(
..., description="Task hash (can be obtained from query_download_tasks tool)"
)
action: Optional[str] = Field(
None,
description="Action to perform on the task: 'start' to resume downloading, 'stop' to pause downloading. "
"If not provided, no start/stop action will be performed.",
)
tags: Optional[List[str]] = Field(
None,
description="List of tags to set on the download task. If provided, these tags will be added to the task. "
"Example: ['movie', 'hd']",
)
downloader: Optional[str] = Field(
None,
description="Name of specific downloader (optional, if not provided will search all downloaders)",
)
class ModifyDownloadTool(MoviePilotTool):
"""修改下载任务工具"""
name: str = "modify_download"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = (
"Modify a download task in the downloader by task hash. "
"Supports: 1) Setting tags on a download task, "
"2) Starting (resuming) a paused download task, "
"3) Stopping (pausing) a downloading task. "
"Multiple operations can be performed in a single call."
)
args_schema: Type[BaseModel] = ModifyDownloadInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]:
hash_value = kwargs.get("hash", "")
action = kwargs.get("action")
tags = kwargs.get("tags")
downloader = kwargs.get("downloader")
parts = [f"修改下载任务: {hash_value}"]
if action == "start":
parts.append("操作: 开始下载")
elif action == "stop":
parts.append("操作: 暂停下载")
if tags:
parts.append(f"标签: {', '.join(tags)}")
if downloader:
parts.append(f"下载器: {downloader}")
return " | ".join(parts)
@staticmethod
def _modify_download_sync(
hash_value: str,
action: Optional[str] = None,
tags: Optional[List[str]] = None,
downloader: Optional[str] = None,
) -> List[str]:
"""同步修改下载任务状态和标签,避免下载器 SDK 阻塞事件循环。"""
download_chain = DownloadChain()
results = []
if tags:
tag_result = download_chain.set_torrents_tag(
hashs=[hash_value], tags=tags, downloader=downloader
)
if tag_result:
results.append(f"成功设置标签:{', '.join(tags)}")
else:
results.append("设置标签失败,请检查任务是否存在或下载器是否可用")
if action:
action_result = download_chain.set_downloading(
hash_str=hash_value, oper=action, name=downloader
)
action_desc = "开始" if action == "start" else "暂停"
if action_result:
results.append(f"成功{action_desc}下载任务")
else:
results.append(f"{action_desc}下载任务失败,请检查任务是否存在或下载器是否可用")
return results
async def run(
self,
hash: str,
action: Optional[str] = None,
tags: Optional[List[str]] = None,
downloader: Optional[str] = None,
**kwargs,
) -> str:
logger.info(
f"执行工具: {self.name}, 参数: hash={hash}, action={action}, tags={tags}, downloader={downloader}"
)
try:
# 校验 hash 格式
if len(hash) != 40 or not all(c in "0123456789abcdefABCDEF" for c in hash):
return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
# 校验参数:至少需要一个操作
if not action and not tags:
return "参数错误:至少需要指定 actionstart/stop)或 tags 中的一个。"
# 校验 action 参数
if action and action not in ("start", "stop"):
return f"参数错误:action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'"
results = await self.run_blocking(
"downloader",
self._modify_download_sync,
hash,
action,
tags,
downloader,
)
return f"下载任务 {hash}" + "".join(results)
except Exception as e:
logger.error(f"修改下载任务失败: {e}", exc_info=True)
return f"修改下载任务时发生错误: {str(e)}"
+31 -1
View File
@@ -25,6 +25,10 @@ class QueryDownloadTasksInput(BaseModel):
False,
description="Include tasks without the MoviePilot built-in tag. Default false keeps the normal MoviePilot task scope.",
)
include_trackers: Optional[bool] = Field(
False,
description="Include tracker URLs when supported. Hash queries always include trackers.",
)
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')")
@@ -131,6 +135,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
title: Optional[str] = None,
tag: Optional[str] = None,
include_all_tags: bool = False,
include_trackers: bool = False,
) -> Dict[str, Any]:
"""
同步查询下载器和下载历史,整个链路放在线程池中执行。
@@ -214,6 +219,16 @@ class QueryDownloadTasksTool(MoviePilotTool):
if not filtered_downloads:
return {"message": "未找到相关下载任务"}
if hash_value or include_trackers:
for torrent in filtered_downloads:
if not getattr(torrent, "hash", None):
continue
tracker_map = download_chain.get_torrent_trackers(
hash_string=torrent.hash,
downloader=getattr(torrent, "downloader", None) or downloader,
) or {}
torrent.trackers = tracker_map.get(getattr(torrent, "downloader", None)) or []
return {"downloads": filtered_downloads}
def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -245,6 +260,8 @@ class QueryDownloadTasksTool(MoviePilotTool):
parts.append(f"标签: {tag}")
if include_all_tags:
parts.append("范围: 全部标签")
if kwargs.get("include_trackers"):
parts.append("包含Tracker")
return " | ".join(parts) if len(parts) > 1 else parts[0]
@@ -254,10 +271,12 @@ class QueryDownloadTasksTool(MoviePilotTool):
title: Optional[str] = None,
tag: Optional[str] = None,
include_all_tags: Optional[bool] = False,
include_trackers: 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}"
f"hash={hash}, title={title}, tag={tag}, include_all_tags={include_all_tags}, "
f"include_trackers={include_trackers}"
)
try:
payload = await self.run_blocking(
@@ -269,6 +288,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
title,
tag,
self._normalize_include_all_tags(include_all_tags),
self._normalize_include_all_tags(include_trackers),
)
if payload.get("message"):
return payload["message"]
@@ -294,6 +314,16 @@ class QueryDownloadTasksTool(MoviePilotTool):
"upspeed": getattr(d, "upspeed", None),
"dlspeed": getattr(d, "dlspeed", None),
"tags": d.tags,
"save_path": getattr(d, "save_path", None),
"content_path": getattr(d, "content_path", None) or (
d.path.as_posix() if getattr(d, "path", None) else None
),
"category": getattr(d, "category", None),
"download_limit": getattr(d, "download_limit", None),
"upload_limit": getattr(d, "upload_limit", None),
"ratio_limit": getattr(d, "ratio_limit", None),
"seeding_time_limit": getattr(d, "seeding_time_limit", None),
"trackers": getattr(d, "trackers", None) or [],
"left_time": getattr(d, "left_time", None)
}
# 精简 media 字段
@@ -0,0 +1,310 @@
"""更新下载任务工具"""
import json
from typing import Any, Dict, List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.log import logger
class UpdateDownloadTasksInput(BaseModel):
"""更新下载任务工具的输入参数模型"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this tool is being used in the current context",
)
hash: str = Field(
..., description="Task hash (can be obtained from query_download_tasks tool)"
)
action: Optional[str] = Field(
None,
description="Action to perform on the task: 'start' to resume downloading, 'stop' to pause downloading.",
)
tags: Optional[List[str]] = Field(
None,
description="List of tags to add to the download task. Example: ['movie', 'hd']",
)
downloader: Optional[str] = Field(
None,
description="Name of specific downloader. If omitted, the tool resolves it from the task hash.",
)
download_limit: Optional[float] = Field(
None,
description="Per-task download speed limit in KB/s. Use 0 to disable the limit when supported.",
)
upload_limit: Optional[float] = Field(
None,
description="Per-task upload speed limit in KB/s. Use 0 to disable the limit when supported.",
)
trackers: Optional[List[str]] = Field(
None,
description="Tracker URL list to add or set, depending on downloader support.",
)
save_path: Optional[str] = Field(
None,
description="New save/download directory for the task, when supported.",
)
category: Optional[str] = Field(
None,
description="Downloader category to set, when supported.",
)
ratio_limit: Optional[float] = Field(
None,
description="Per-task share ratio limit, when supported.",
)
seeding_time_limit: Optional[int] = Field(
None,
description="Per-task seeding time limit in minutes, when supported.",
)
class UpdateDownloadTasksTool(MoviePilotTool):
"""更新下载任务工具"""
name: str = "update_download_tasks"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = (
"Update a download task by hash. Supports start/stop, adding tags, per-task "
"upload/download speed limits, trackers, save directory, category, share ratio, "
"and seeding time where the configured downloader supports them. "
"Use query_download_tasks first to get the hash and current downloader."
)
args_schema: Type[BaseModel] = UpdateDownloadTasksInput
require_admin: bool = True
@staticmethod
def _is_valid_hash(hash_value: str) -> bool:
"""校验下载任务Hash格式。"""
return len(hash_value) == 40 and all(c in "0123456789abcdefABCDEF" for c in hash_value)
@staticmethod
def _normalize_non_empty_list(values: Optional[List[str]]) -> Optional[List[str]]:
"""清理字符串列表中的空值。"""
if values is None:
return None
return [str(value).strip() for value in values if str(value).strip()]
@staticmethod
def _has_update_params(**kwargs) -> bool:
"""判断是否传入至少一个修改参数。"""
return any(value is not None and value != [] for value in kwargs.values())
@staticmethod
def _build_result(operation: str, success: bool, message: str) -> Dict[str, Any]:
"""构造单项操作结果。"""
return {
"operation": operation,
"success": success,
"message": message,
}
@classmethod
def _resolve_downloader(
cls,
download_chain: DownloadChain,
hash_value: str,
downloader: Optional[str],
) -> Optional[str]:
"""根据Hash解析下载任务所在下载器。"""
if downloader:
return downloader
torrents = download_chain.list_torrents(
hashs=[hash_value],
include_all_tags=True,
) or []
return getattr(torrents[0], "downloader", None) if torrents else None
@classmethod
def _update_download_sync(
cls,
hash_value: str,
action: Optional[str] = None,
tags: Optional[List[str]] = None,
downloader: Optional[str] = None,
download_limit: Optional[float] = None,
upload_limit: Optional[float] = None,
trackers: Optional[List[str]] = None,
save_path: Optional[str] = None,
category: Optional[str] = None,
ratio_limit: Optional[float] = None,
seeding_time_limit: Optional[int] = None,
) -> Dict[str, Any]:
"""同步更新下载任务,避免下载器 SDK 阻塞事件循环。"""
download_chain = DownloadChain()
resolved_downloader = cls._resolve_downloader(
download_chain=download_chain,
hash_value=hash_value,
downloader=downloader,
)
if not resolved_downloader:
return {
"hash": hash_value,
"downloader": downloader,
"results": [
cls._build_result("resolve_downloader", False, "未找到下载任务或下载器不可用")
],
}
results = []
if tags:
tag_result = download_chain.set_torrents_tag(
hashs=[hash_value], tags=tags, downloader=resolved_downloader
)
results.append(
cls._build_result(
"tags",
bool(tag_result),
f"成功设置标签:{', '.join(tags)}" if tag_result else "设置标签失败",
)
)
if action:
action_result = download_chain.set_downloading(
hash_str=hash_value, oper=action, name=resolved_downloader
)
action_desc = "开始" if action == "start" else "暂停"
results.append(
cls._build_result(
action,
bool(action_result),
f"成功{action_desc}下载任务" if action_result else f"{action_desc}下载任务失败",
)
)
update_result = {}
if cls._has_update_params(
download_limit=download_limit,
upload_limit=upload_limit,
trackers=trackers,
save_path=save_path,
category=category,
ratio_limit=ratio_limit,
seeding_time_limit=seeding_time_limit,
):
update_result = download_chain.update_torrent(
hash_string=hash_value,
downloader=resolved_downloader,
download_limit=download_limit,
upload_limit=upload_limit,
tracker_list=trackers,
save_path=save_path,
category=category,
ratio_limit=ratio_limit,
seeding_time_limit=seeding_time_limit,
)
operation_messages = {
"limits": "限速/做种策略",
"trackers": "Tracker",
"save_path": "保存目录",
"category": "分类",
}
for operation, success in (update_result or {}).items():
label = operation_messages.get(operation, operation)
results.append(
cls._build_result(
operation,
bool(success),
f"{label}修改成功" if success else f"{label}修改失败或下载器不支持",
)
)
return {
"hash": hash_value,
"downloader": resolved_downloader,
"results": results,
}
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据更新参数生成友好的提示消息。"""
hash_value = kwargs.get("hash", "")
parts = [f"更新下载任务: {hash_value}"]
action = kwargs.get("action")
if action == "start":
parts.append("操作: 开始下载")
elif action == "stop":
parts.append("操作: 暂停下载")
if kwargs.get("tags"):
parts.append(f"标签: {', '.join(kwargs.get('tags'))}")
if kwargs.get("download_limit") is not None or kwargs.get("upload_limit") is not None:
parts.append("限速")
if kwargs.get("trackers") is not None:
parts.append("Tracker")
if kwargs.get("save_path"):
parts.append("保存目录")
if kwargs.get("category") is not None:
parts.append("分类")
if kwargs.get("downloader"):
parts.append(f"下载器: {kwargs.get('downloader')}")
return " | ".join(parts)
async def run(
self,
hash: str,
action: Optional[str] = None,
tags: Optional[List[str]] = None,
downloader: Optional[str] = None,
download_limit: Optional[float] = None,
upload_limit: Optional[float] = None,
trackers: Optional[List[str]] = None,
save_path: Optional[str] = None,
category: Optional[str] = None,
ratio_limit: Optional[float] = None,
seeding_time_limit: Optional[int] = None,
**kwargs,
) -> str:
"""执行下载任务更新。"""
logger.info(
f"执行工具: {self.name}, 参数: hash={hash}, action={action}, tags={tags}, "
f"downloader={downloader}, download_limit={download_limit}, upload_limit={upload_limit}, "
f"trackers={trackers}, save_path={save_path}, category={category}, "
f"ratio_limit={ratio_limit}, seeding_time_limit={seeding_time_limit}"
)
try:
if not self._is_valid_hash(hash):
return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
tags = self._normalize_non_empty_list(tags)
trackers = self._normalize_non_empty_list(trackers)
if action and action not in ("start", "stop"):
return f"参数错误:action 只支持 'start'(开始下载)或 'stop'(暂停下载),收到: '{action}'"
if not self._has_update_params(
action=action,
tags=tags,
download_limit=download_limit,
upload_limit=upload_limit,
trackers=trackers,
save_path=save_path,
category=category,
ratio_limit=ratio_limit,
seeding_time_limit=seeding_time_limit,
):
return "参数错误:至少需要指定一个要更新的字段。"
result = await self.run_blocking(
"downloader",
self._update_download_sync,
hash,
action,
tags,
downloader,
download_limit,
upload_limit,
trackers,
save_path,
category,
ratio_limit,
seeding_time_limit,
)
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"更新下载任务失败: {e}", exc_info=True)
return f"更新下载任务时发生错误: {str(e)}"
+55
View File
@@ -1353,6 +1353,61 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("set_torrents_tag", hashs=hashs, tags=tags, downloader=downloader)
def update_torrent(
self,
hash_string: str,
downloader: Optional[str] = None,
download_limit: Optional[float] = None,
upload_limit: Optional[float] = None,
tracker_list: Optional[list] = None,
save_path: Optional[str] = None,
category: Optional[str] = None,
ratio_limit: Optional[float] = None,
seeding_time_limit: Optional[int] = None,
) -> Optional[Dict[str, bool]]:
"""
修改下载任务属性。
:param hash_string: 种子Hash
:param downloader: 下载器
:param download_limit: 下载限速,单位 KB/s
:param upload_limit: 上传限速,单位 KB/s
:param tracker_list: Tracker URL列表
:param save_path: 保存目录
:param category: 分类
:param ratio_limit: 分享率限制
:param seeding_time_limit: 做种时间限制,单位分钟
:return: 各项修改结果
"""
return self.run_module(
"update_torrent",
hash_string=hash_string,
downloader=downloader,
download_limit=download_limit,
upload_limit=upload_limit,
tracker_list=tracker_list,
save_path=save_path,
category=category,
ratio_limit=ratio_limit,
seeding_time_limit=seeding_time_limit,
)
def get_torrent_trackers(
self,
hash_string: str,
downloader: Optional[str] = None,
) -> Optional[Dict[str, List[str]]]:
"""
查询下载任务Tracker列表。
:param hash_string: 种子Hash
:param downloader: 下载器
:return: 下载器名称到Tracker列表的映射
"""
return self.run_module(
"get_torrent_trackers",
hash_string=hash_string,
downloader=downloader,
)
def torrent_files(
self, tid: str, downloader: Optional[str] = None
) -> Optional[Union[TorrentFilesList, List[File]]]:
+89
View File
@@ -307,9 +307,18 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
save_path=self.normalize_return_path(
Path(torrent_data.get('save_path') or ""), downloader_name
) if torrent_data.get('save_path') else None,
content_path=self.normalize_return_path(torrent_path, downloader_name),
hash=torrent_data.get('hash'),
size=total_size,
tags=torrent_data.get('tags'),
category=torrent_data.get('category'),
download_limit=(torrent_data.get('dl_limit') or 0) / 1024,
upload_limit=(torrent_data.get('up_limit') or 0) / 1024,
ratio_limit=torrent_data.get('ratio_limit'),
seeding_time_limit=torrent_data.get('seeding_time_limit'),
progress=(torrent_data.get('progress') or 0) * 100,
state=self.__normalize_torrent_state(torrent_data.get('state')),
dlspeed=StringUtils.str_filesize(dlspeed),
@@ -464,6 +473,86 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
server.set_torrents_tag(ids=hashs, tags=tags)
return True
def update_torrent(
self,
hash_string: str,
downloader: Optional[str] = None,
download_limit: Optional[float] = None,
upload_limit: Optional[float] = None,
tracker_list: Optional[list] = None,
save_path: Optional[str] = None,
category: Optional[str] = None,
ratio_limit: Optional[float] = None,
seeding_time_limit: Optional[int] = None,
) -> Optional[Dict[str, bool]]:
"""
修改下载任务属性。
:param hash_string: 种子Hash
:param downloader: 下载器
:param download_limit: 下载限速,单位 KB/s
:param upload_limit: 上传限速,单位 KB/s
:param tracker_list: Tracker URL列表
:param save_path: 保存目录
:param category: 分类
:param ratio_limit: 分享率限制
:param seeding_time_limit: 做种时间限制,单位分钟
:return: 各项修改结果
"""
server: Qbittorrent = self.get_instance(downloader)
if not server:
return None
results = {}
if any(
value is not None
for value in (download_limit, upload_limit, ratio_limit, seeding_time_limit)
):
results["limits"] = server.change_torrent(
hash_string=hash_string,
download_limit=download_limit,
upload_limit=upload_limit,
ratio_limit=ratio_limit,
seeding_time_limit=seeding_time_limit,
)
if tracker_list is not None:
results["trackers"] = server.update_tracker(
hash_string=hash_string, tracker_list=tracker_list
)
if save_path is not None:
results["save_path"] = server.set_torrent_location(
hash_string=hash_string,
location=self.normalize_path(Path(save_path), downloader),
)
if category is not None:
results["category"] = server.set_torrent_category(
hash_string=hash_string, category=category
)
return results
def get_torrent_trackers(
self,
hash_string: str,
downloader: Optional[str] = None,
) -> Optional[Dict[str, List[str]]]:
"""
查询下载任务Tracker列表。
:param hash_string: 种子Hash
:param downloader: 下载器
:return: 下载器名称到Tracker列表的映射
"""
if downloader:
server: Qbittorrent = self.get_instance(downloader)
if not server:
return None
servers = {downloader: server}
else:
servers: Dict[str, Qbittorrent] = self.get_instances()
ret_trackers = {}
for name, server in servers.items():
trackers = server.get_trackers(hash_string)
if trackers is not None:
ret_trackers[name] = trackers
return ret_trackers
def start_torrents(self, hashs: Union[list, str],
downloader: Optional[str] = None) -> Optional[bool]:
"""
+102 -2
View File
@@ -537,8 +537,8 @@ class Qbittorrent:
"""
if not self.qbc:
return False
download_limit = download_limit * 1024
upload_limit = upload_limit * 1024
download_limit = (download_limit or 0) * 1024
upload_limit = (upload_limit or 0) * 1024
try:
self.qbc.transfer.upload_limit = int(upload_limit)
self.qbc.transfer.download_limit = int(download_limit)
@@ -578,6 +578,87 @@ class Qbittorrent:
logger.error(f"重新校验种子出错:{str(err)}")
return False
def change_torrent(
self,
hash_string: str,
upload_limit: Optional[float] = None,
download_limit: Optional[float] = None,
ratio_limit: Optional[float] = None,
seeding_time_limit: Optional[int] = None,
) -> bool:
"""
修改单个种子的限速和做种策略。
:param hash_string: 种子Hash
:param upload_limit: 上传限速,单位 KB/s,0 表示不限速
:param download_limit: 下载限速,单位 KB/s,0 表示不限速
:param ratio_limit: 分享率限制
:param seeding_time_limit: 做种时间限制,单位分钟
:return: 是否修改成功
"""
if not self.qbc or not hash_string:
return False
try:
if upload_limit is not None:
self.qbc.torrents_set_upload_limit(
limit=int(float(upload_limit) * 1024),
torrent_hashes=hash_string,
)
if download_limit is not None:
self.qbc.torrents_set_download_limit(
limit=int(float(download_limit) * 1024),
torrent_hashes=hash_string,
)
if ratio_limit is not None:
self.qbc.torrents_set_share_limits(
ratio_limit=round(float(ratio_limit), 2),
seeding_time_limit=int(seeding_time_limit or -1),
inactive_seeding_time_limit=-1,
torrent_hashes=hash_string,
)
elif seeding_time_limit is not None:
self.qbc.torrents_set_share_limits(
ratio_limit=-2,
seeding_time_limit=int(seeding_time_limit),
inactive_seeding_time_limit=-1,
torrent_hashes=hash_string,
)
return True
except Exception as err:
logger.error(f"设置种子属性出错:{str(err)}")
return False
def set_torrent_location(self, hash_string: str, location: str) -> bool:
"""
修改种子保存目录。
:param hash_string: 种子Hash
:param location: 新保存目录
:return: 是否修改成功
"""
if not self.qbc or not hash_string or not location:
return False
try:
self.qbc.torrents_set_location(location=location, torrent_hashes=hash_string)
return True
except Exception as err:
logger.error(f"设置种子保存目录出错:{str(err)}")
return False
def set_torrent_category(self, hash_string: str, category: str) -> bool:
"""
修改种子分类。
:param hash_string: 种子Hash
:param category: 分类名称
:return: 是否修改成功
"""
if not self.qbc or not hash_string:
return False
try:
self.qbc.torrents_set_category(category=category or "", torrent_hashes=hash_string)
return True
except Exception as err:
logger.error(f"设置种子分类出错:{str(err)}")
return False
def update_tracker(self, hash_string: str, tracker_list: list) -> bool:
"""
添加tracker
@@ -591,6 +672,25 @@ class Qbittorrent:
logger.error(f"修改tracker出错:{str(err)}")
return False
def get_trackers(self, hash_string: str) -> Optional[List[str]]:
"""
获取种子Tracker列表。
:param hash_string: 种子Hash
:return: Tracker URL列表
"""
if not self.qbc or not hash_string:
return None
try:
trackers = self.qbc.torrents_trackers(torrent_hash=hash_string) or []
return [
tracker.get("url")
for tracker in trackers
if tracker.get("url")
]
except Exception as err:
logger.error(f"获取tracker出错:{str(err)}")
return None
def get_content_layout(self) -> Optional[str]:
"""
获取内容布局
+52
View File
@@ -338,6 +338,10 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
save_path=self.normalize_return_path(
Path(torrent_data.get("save_path") or ""), downloader_name
) if torrent_data.get("save_path") else None,
content_path=self.normalize_return_path(torrent_path, downloader_name),
progress=torrent_data.get("progress", 0),
size=total_size,
state=self.__normalize_torrent_state(
@@ -522,6 +526,54 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
return None
return server.set_torrents_tag(ids=hashs, tags=tags)
def update_torrent(
self,
hash_string: str,
downloader: Optional[str] = None,
download_limit: Optional[float] = None,
upload_limit: Optional[float] = None,
tracker_list: Optional[list] = None,
save_path: Optional[str] = None,
category: Optional[str] = None,
ratio_limit: Optional[float] = None,
seeding_time_limit: Optional[int] = None,
) -> Optional[Dict[str, bool]]:
"""
修改下载任务属性。
:param hash_string: 种子Hash
:param downloader: 下载器
:param download_limit: 下载限速,单位 KB/s
:param upload_limit: 上传限速,单位 KB/s
:param tracker_list: Tracker URL列表,rTorrent 当前封装不支持
:param save_path: 保存目录
:param category: 分类,rTorrent 当前封装不支持
:param ratio_limit: 分享率限制,rTorrent 当前封装不支持
:param seeding_time_limit: 做种时间限制,rTorrent 当前封装不支持
:return: 各项修改结果
"""
server: Rtorrent = self.get_instance(downloader)
if not server:
return None
results = {}
if download_limit is not None or upload_limit is not None:
results["limits"] = server.change_torrent(
hash_string=hash_string,
download_limit=download_limit,
upload_limit=upload_limit,
)
if ratio_limit is not None or seeding_time_limit is not None:
results["seeding_limits"] = False
if tracker_list is not None:
results["trackers"] = False
if save_path is not None:
results["save_path"] = server.set_torrent_location(
hash_string=hash_string,
location=self.normalize_path(Path(save_path), downloader),
)
if category is not None:
results["category"] = False
return results
def start_torrents(
self, hashs: Union[list, str], downloader: Optional[str] = None
) -> Optional[bool]:
+56
View File
@@ -530,6 +530,62 @@ class Rtorrent:
break
return torrent_id
@staticmethod
def __build_throttle_name(torrent_hash: str) -> str:
"""
生成单任务限速组名称。
"""
return f"mp_{torrent_hash.lower()[:16]}"
def change_torrent(
self,
hash_string: str,
upload_limit: Optional[float] = None,
download_limit: Optional[float] = None,
) -> bool:
"""
修改单个种子的上传和下载限速。
:param hash_string: 种子Hash
:param upload_limit: 上传限速,单位 KB/s,0 表示不限速
:param download_limit: 下载限速,单位 KB/s,0 表示不限速
:return: 是否修改成功
"""
if not self._proxy or not hash_string:
return False
try:
throttle_name = self.__build_throttle_name(hash_string)
if download_limit is not None:
self._proxy.throttle.down.max.set(
throttle_name,
int(float(download_limit) * 1024),
)
if upload_limit is not None:
self._proxy.throttle.up.max.set(
throttle_name,
int(float(upload_limit) * 1024),
)
self._proxy.d.throttle_name.set(hash_string, throttle_name)
return True
except Exception as err:
logger.error(f"设置种子限速出错:{str(err)}")
return False
def set_torrent_location(self, hash_string: str, location: str) -> bool:
"""
修改种子保存目录。
:param hash_string: 种子Hash
:param location: 新保存目录
:return: 是否修改成功
"""
if not self._proxy or not hash_string or not location:
return False
try:
self._proxy.d.directory.set(hash_string, location)
return True
except Exception as err:
logger.error(f"设置种子保存目录出错:{str(err)}")
return False
def transfer_info(self) -> Optional[Dict]:
"""
获取传输信息
+89
View File
@@ -310,6 +310,8 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
torrent_data, "left_until_done", "leftUntilDone"
) or 0
torrent_path = __get_torrent_path(torrent_data)
ratio_limit = __get_torrent_attr(torrent_data, "seed_ratio_limit", "seedRatioLimit")
seeding_time_limit = __get_torrent_attr(torrent_data, "seed_idle_limit", "seedIdleLimit")
return DownloaderTorrent(
downloader=downloader_name,
hash=torrent_data.hashString,
@@ -318,12 +320,20 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
save_path=self.normalize_return_path(
Path(torrent_data.download_dir), downloader_name
) if getattr(torrent_data, "download_dir", None) else None,
content_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),
download_limit=__get_torrent_attr(torrent_data, "download_limit", "downloadLimit"),
upload_limit=__get_torrent_attr(torrent_data, "upload_limit", "uploadLimit"),
ratio_limit=ratio_limit,
seeding_time_limit=seeding_time_limit,
left_time=StringUtils.str_secends(
left_until_done / dlspeed
) if dlspeed > 0 else ''
@@ -491,6 +501,85 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
org_tags = server.get_torrent_tags(ids=hashs)
return server.set_torrent_tag(ids=hashs, tags=tags, org_tags=org_tags)
def update_torrent(
self,
hash_string: str,
downloader: Optional[str] = None,
download_limit: Optional[float] = None,
upload_limit: Optional[float] = None,
tracker_list: Optional[list] = None,
save_path: Optional[str] = None,
category: Optional[str] = None,
ratio_limit: Optional[float] = None,
seeding_time_limit: Optional[int] = None,
) -> Optional[Dict[str, bool]]:
"""
修改下载任务属性。
:param hash_string: 种子Hash
:param downloader: 下载器
:param download_limit: 下载限速,单位 KB/s
:param upload_limit: 上传限速,单位 KB/s
:param tracker_list: Tracker URL列表
:param save_path: 保存目录
:param category: 分类,Transmission 不支持
:param ratio_limit: 分享率限制
:param seeding_time_limit: 做种时间限制,单位分钟
:return: 各项修改结果
"""
server: Transmission = self.get_instance(downloader)
if not server:
return None
results = {}
if any(
value is not None
for value in (download_limit, upload_limit, ratio_limit, seeding_time_limit)
):
change_result = server.change_torrent(
hash_string=hash_string,
download_limit=download_limit,
upload_limit=upload_limit,
ratio_limit=ratio_limit,
seeding_time_limit=seeding_time_limit,
)
results["limits"] = change_result
if save_path is not None:
results["save_path"] = server.set_torrent_location(
hash_string=hash_string,
location=self.normalize_path(Path(save_path), downloader),
)
if tracker_list is not None:
results["trackers"] = server.update_tracker(
hash_string=hash_string, tracker_list=tracker_list
)
if category is not None:
results["category"] = False
return results
def get_torrent_trackers(
self,
hash_string: str,
downloader: Optional[str] = None,
) -> Optional[Dict[str, List[str]]]:
"""
查询下载任务Tracker列表。
:param hash_string: 种子Hash
:param downloader: 下载器
:return: 下载器名称到Tracker列表的映射
"""
if downloader:
server: Transmission = self.get_instance(downloader)
if not server:
return None
servers = {downloader: server}
else:
servers: Dict[str, Transmission] = self.get_instances()
ret_trackers = {}
for name, server in servers.items():
trackers = server.get_trackers(hash_string)
if trackers is not None:
ret_trackers[name] = trackers
return ret_trackers
def start_torrents(self, hashs: Union[list, str],
downloader: Optional[str] = None) -> Optional[bool]:
"""
+62 -33
View File
@@ -402,45 +402,46 @@ class Transmission:
"""
if not hash_string:
return False
if upload_limit:
uploadLimited = True
uploadLimit = int(upload_limit)
else:
uploadLimited = False
uploadLimit = 0
if download_limit:
downloadLimited = True
downloadLimit = int(download_limit)
else:
downloadLimited = False
downloadLimit = 0
if ratio_limit:
seedRatioMode = 1
seedRatioLimit = round(float(ratio_limit), 2)
else:
seedRatioMode = 2
seedRatioLimit = 0
if seeding_time_limit:
seedIdleMode = 1
seedIdleLimit = int(seeding_time_limit)
else:
seedIdleMode = 2
seedIdleLimit = 0
change_kwargs = {"ids": hash_string}
if upload_limit is not None:
change_kwargs["uploadLimited"] = bool(upload_limit)
change_kwargs["uploadLimit"] = int(upload_limit)
if download_limit is not None:
change_kwargs["downloadLimited"] = bool(download_limit)
change_kwargs["downloadLimit"] = int(download_limit)
if ratio_limit is not None:
change_kwargs["seedRatioMode"] = 1 if ratio_limit else 2
change_kwargs["seedRatioLimit"] = round(float(ratio_limit), 2) if ratio_limit else 0
if seeding_time_limit is not None:
change_kwargs["seedIdleMode"] = 1 if seeding_time_limit else 2
change_kwargs["seedIdleLimit"] = int(seeding_time_limit) if seeding_time_limit else 0
try:
self.trc.change_torrent(ids=hash_string,
uploadLimited=uploadLimited,
uploadLimit=uploadLimit,
downloadLimited=downloadLimited,
downloadLimit=downloadLimit,
seedRatioMode=seedRatioMode,
seedRatioLimit=seedRatioLimit,
seedIdleMode=seedIdleMode,
seedIdleLimit=seedIdleLimit)
self.trc.change_torrent(**change_kwargs)
return True
except Exception as err:
logger.error(f"设置种子出错:{str(err)}")
return False
def set_torrent_location(self, hash_string: str, location: str) -> bool:
"""
修改种子保存目录。
:param hash_string: 种子Hash
:param location: 新保存目录
:return: 是否修改成功
"""
if not self.trc or not hash_string or not location:
return False
try:
move_torrent_data = getattr(self.trc, "move_torrent_data", None)
if callable(move_torrent_data):
move_torrent_data(ids=hash_string, location=location)
else:
self.trc.change_torrent(ids=hash_string, download_dir=location)
return True
except Exception as err:
logger.error(f"设置种子保存目录出错:{str(err)}")
return False
def update_tracker(self, hash_string: str, tracker_list: list = None) -> bool:
"""
tr4.0及以上弃用直接设置tracker 共用change方法
@@ -456,6 +457,34 @@ class Transmission:
logger.error(f"修改tracker出错:{str(err)}")
return False
def get_trackers(self, hash_string: str) -> Optional[List[str]]:
"""
获取种子Tracker列表。
:param hash_string: 种子Hash
:return: Tracker URL列表
"""
if not self.trc or not hash_string:
return None
try:
torrents = self.trc.get_torrents(ids=hash_string, arguments=self._trarg)
if not torrents:
return []
torrent = torrents[0]
tracker_list = getattr(torrent, "tracker_list", None) \
or getattr(torrent, "trackerList", None) \
or []
if tracker_list:
return list(tracker_list)
trackers = getattr(torrent, "trackers", None) or []
return [
tracker.get("announce")
for tracker in trackers
if isinstance(tracker, dict) and tracker.get("announce")
]
except Exception as err:
logger.error(f"获取tracker出错:{str(err)}")
return None
def get_session(self) -> Optional[Session]:
"""
获取Transmission当前的会话信息和配置设置
+8
View File
@@ -27,6 +27,14 @@ class DownloaderTorrent(BaseModel):
upspeed: Optional[str] = None
dlspeed: Optional[str] = None
tags: Optional[str] = None
save_path: Optional[str] = None
content_path: Optional[str] = None
category: Optional[str] = None
download_limit: Optional[float] = None
upload_limit: Optional[float] = None
ratio_limit: Optional[float] = None
seeding_time_limit: Optional[int] = None
trackers: Optional[List[str]] = Field(default_factory=list)
media: Optional[dict] = Field(default_factory=dict)
userid: Optional[str] = None
username: Optional[str] = None