mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-03-20 03:57:30 +08:00
feat: add new tools for download management and enhance query capabilities
- Introduced DeleteDownloadTool, QueryDirectoriesTool, ListDirectoryTool, QueryTransferHistoryTool, and TransferFileTool to the toolset for improved download management. - Updated __all__ exports in init.py and factory.py to include the new tools. - Enhanced QueryDownloadsTool to support querying downloads by hash and title, providing more flexible search options and detailed results.
This commit is contained in:
@@ -8,11 +8,16 @@ from app.agent.tools.impl.add_download import AddDownloadTool
|
|||||||
from app.agent.tools.impl.query_subscribes import QuerySubscribesTool
|
from app.agent.tools.impl.query_subscribes import QuerySubscribesTool
|
||||||
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
|
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
|
||||||
from app.agent.tools.impl.query_downloads import QueryDownloadsTool
|
from app.agent.tools.impl.query_downloads import QueryDownloadsTool
|
||||||
|
from app.agent.tools.impl.delete_download import DeleteDownloadTool
|
||||||
from app.agent.tools.impl.query_downloaders import QueryDownloadersTool
|
from app.agent.tools.impl.query_downloaders import QueryDownloadersTool
|
||||||
from app.agent.tools.impl.query_sites import QuerySitesTool
|
from app.agent.tools.impl.query_sites import QuerySitesTool
|
||||||
from app.agent.tools.impl.test_site import TestSiteTool
|
from app.agent.tools.impl.test_site import TestSiteTool
|
||||||
from app.agent.tools.impl.get_recommendations import GetRecommendationsTool
|
from app.agent.tools.impl.get_recommendations import GetRecommendationsTool
|
||||||
from app.agent.tools.impl.query_media_library import QueryMediaLibraryTool
|
from app.agent.tools.impl.query_media_library import QueryMediaLibraryTool
|
||||||
|
from app.agent.tools.impl.query_directories import QueryDirectoriesTool
|
||||||
|
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
||||||
|
from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
|
||||||
|
from app.agent.tools.impl.transfer_file import TransferFileTool
|
||||||
from app.agent.tools.impl.send_message import SendMessageTool
|
from app.agent.tools.impl.send_message import SendMessageTool
|
||||||
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
||||||
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
|
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
|
||||||
@@ -28,12 +33,17 @@ __all__ = [
|
|||||||
"QuerySubscribesTool",
|
"QuerySubscribesTool",
|
||||||
"DeleteSubscribeTool",
|
"DeleteSubscribeTool",
|
||||||
"QueryDownloadsTool",
|
"QueryDownloadsTool",
|
||||||
|
"DeleteDownloadTool",
|
||||||
"QueryDownloadersTool",
|
"QueryDownloadersTool",
|
||||||
"QuerySitesTool",
|
"QuerySitesTool",
|
||||||
"TestSiteTool",
|
"TestSiteTool",
|
||||||
"UpdateSiteCookieTool",
|
"UpdateSiteCookieTool",
|
||||||
"GetRecommendationsTool",
|
"GetRecommendationsTool",
|
||||||
"QueryMediaLibraryTool",
|
"QueryMediaLibraryTool",
|
||||||
|
"QueryDirectoriesTool",
|
||||||
|
"ListDirectoryTool",
|
||||||
|
"QueryTransferHistoryTool",
|
||||||
|
"TransferFileTool",
|
||||||
"SendMessageTool",
|
"SendMessageTool",
|
||||||
"QuerySchedulersTool",
|
"QuerySchedulersTool",
|
||||||
"RunSchedulerTool",
|
"RunSchedulerTool",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ from app.agent.tools.impl.send_message import SendMessageTool
|
|||||||
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
||||||
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
|
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
|
||||||
from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
|
from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
|
||||||
|
from app.agent.tools.impl.delete_download import DeleteDownloadTool
|
||||||
|
from app.agent.tools.impl.query_directories import QueryDirectoriesTool
|
||||||
|
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
||||||
|
from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
|
||||||
|
from app.agent.tools.impl.transfer_file import TransferFileTool
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from .base import MoviePilotTool
|
from .base import MoviePilotTool
|
||||||
@@ -40,12 +45,17 @@ class MoviePilotToolFactory:
|
|||||||
QuerySubscribesTool,
|
QuerySubscribesTool,
|
||||||
DeleteSubscribeTool,
|
DeleteSubscribeTool,
|
||||||
QueryDownloadsTool,
|
QueryDownloadsTool,
|
||||||
|
DeleteDownloadTool,
|
||||||
QueryDownloadersTool,
|
QueryDownloadersTool,
|
||||||
QuerySitesTool,
|
QuerySitesTool,
|
||||||
TestSiteTool,
|
TestSiteTool,
|
||||||
UpdateSiteCookieTool,
|
UpdateSiteCookieTool,
|
||||||
GetRecommendationsTool,
|
GetRecommendationsTool,
|
||||||
QueryMediaLibraryTool,
|
QueryMediaLibraryTool,
|
||||||
|
QueryDirectoriesTool,
|
||||||
|
ListDirectoryTool,
|
||||||
|
QueryTransferHistoryTool,
|
||||||
|
TransferFileTool,
|
||||||
SendMessageTool,
|
SendMessageTool,
|
||||||
QuerySchedulersTool,
|
QuerySchedulersTool,
|
||||||
RunSchedulerTool
|
RunSchedulerTool
|
||||||
|
|||||||
62
app/agent/tools/impl/delete_download.py
Normal file
62
app/agent/tools/impl/delete_download.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""删除下载任务工具"""
|
||||||
|
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.chain.download import DownloadChain
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteDownloadInput(BaseModel):
|
||||||
|
"""删除下载任务工具的输入参数模型"""
|
||||||
|
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||||
|
task_identifier: str = Field(..., description="Task identifier: can be task hash (unique identifier) or task title/name")
|
||||||
|
downloader: Optional[str] = Field(None, description="Name of specific downloader (optional, if not provided will search all downloaders)")
|
||||||
|
delete_files: Optional[bool] = Field(False, description="Whether to delete downloaded files along with the task (default: False, only removes the task from downloader)")
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteDownloadTool(MoviePilotTool):
|
||||||
|
name: str = "delete_download"
|
||||||
|
description: str = "Delete a download task from the downloader. Can delete by task hash (unique identifier) or task title/name. Optionally specify the downloader name and whether to delete downloaded files."
|
||||||
|
args_schema: Type[BaseModel] = DeleteDownloadInput
|
||||||
|
|
||||||
|
async def run(self, task_identifier: str, downloader: Optional[str] = None,
|
||||||
|
delete_files: Optional[bool] = False, **kwargs) -> str:
|
||||||
|
logger.info(f"执行工具: {self.name}, 参数: task_identifier={task_identifier}, downloader={downloader}, delete_files={delete_files}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_chain = DownloadChain()
|
||||||
|
|
||||||
|
# 如果task_identifier看起来像hash(通常是40个字符的十六进制字符串)
|
||||||
|
task_hash = None
|
||||||
|
if len(task_identifier) == 40 and all(c in '0123456789abcdefABCDEF' for c in task_identifier):
|
||||||
|
# 直接使用hash
|
||||||
|
task_hash = task_identifier
|
||||||
|
else:
|
||||||
|
# 通过标题查找任务
|
||||||
|
downloads = download_chain.downloading(name=downloader)
|
||||||
|
for dl in downloads:
|
||||||
|
# 检查标题或名称是否匹配
|
||||||
|
if (task_identifier.lower() in (dl.title or "").lower()) or \
|
||||||
|
(task_identifier.lower() in (dl.name or "").lower()):
|
||||||
|
task_hash = dl.hash
|
||||||
|
break
|
||||||
|
|
||||||
|
if not task_hash:
|
||||||
|
return f"未找到匹配的下载任务:{task_identifier},请使用 query_downloads 工具查询可用的下载任务"
|
||||||
|
|
||||||
|
# 删除下载任务
|
||||||
|
# remove_torrents 支持 delete_file 参数,可以控制是否删除文件
|
||||||
|
result = download_chain.remove_torrents(hashs=[task_hash], downloader=downloader, delete_file=delete_files)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
files_info = "(包含文件)" if delete_files else "(不包含文件)"
|
||||||
|
return f"成功删除下载任务:{task_identifier} {files_info}"
|
||||||
|
else:
|
||||||
|
return f"删除下载任务失败:{task_identifier},请检查任务是否存在或下载器是否可用"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除下载任务失败: {e}", exc_info=True)
|
||||||
|
return f"删除下载任务时发生错误: {str(e)}"
|
||||||
|
|
||||||
119
app/agent/tools/impl/list_directory.py
Normal file
119
app/agent/tools/impl/list_directory.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""查询文件系统目录内容工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.chain.storage import StorageChain
|
||||||
|
from app.log import logger
|
||||||
|
from app.schemas.file import FileItem
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
|
||||||
|
class ListDirectoryInput(BaseModel):
|
||||||
|
"""查询文件系统目录内容工具的输入参数模型"""
|
||||||
|
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||||
|
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
|
||||||
|
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
|
||||||
|
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
|
||||||
|
|
||||||
|
|
||||||
|
class ListDirectoryTool(MoviePilotTool):
|
||||||
|
name: str = "list_directory"
|
||||||
|
description: str = "List contents of a file system directory. Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items."
|
||||||
|
args_schema: Type[BaseModel] = ListDirectoryInput
|
||||||
|
|
||||||
|
async def run(self, path: str, storage: Optional[str] = "local",
|
||||||
|
sort_by: Optional[str] = "name", **kwargs) -> str:
|
||||||
|
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 规范化路径
|
||||||
|
if not path:
|
||||||
|
return "错误:路径不能为空"
|
||||||
|
|
||||||
|
# 确保路径格式正确
|
||||||
|
if storage == "local":
|
||||||
|
# 本地路径处理
|
||||||
|
if not path.startswith("/") and not (len(path) > 1 and path[1] == ":"):
|
||||||
|
# 相对路径,尝试转换为绝对路径
|
||||||
|
path = str(Path(path).resolve())
|
||||||
|
else:
|
||||||
|
# 远程存储路径,确保以/开头
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
|
||||||
|
# 创建FileItem
|
||||||
|
fileitem = FileItem(
|
||||||
|
storage=storage or "local",
|
||||||
|
path=path,
|
||||||
|
type="dir"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询目录内容
|
||||||
|
storage_chain = StorageChain()
|
||||||
|
file_list = storage_chain.list_files(fileitem, recursion=False)
|
||||||
|
|
||||||
|
if file_list is None:
|
||||||
|
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
|
||||||
|
|
||||||
|
if not file_list:
|
||||||
|
return f"目录 {path} 为空"
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
if sort_by == "time":
|
||||||
|
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
|
||||||
|
else:
|
||||||
|
# 默认按名称排序(目录优先,然后按名称)
|
||||||
|
file_list.sort(key=lambda x: (
|
||||||
|
0 if x.type == "dir" else 1,
|
||||||
|
StringUtils.natural_sort_key(x.name or "")
|
||||||
|
))
|
||||||
|
|
||||||
|
# 限制返回数量
|
||||||
|
total_count = len(file_list)
|
||||||
|
limited_list = file_list[:20]
|
||||||
|
|
||||||
|
# 转换为字典格式
|
||||||
|
simplified_items = []
|
||||||
|
for item in limited_list:
|
||||||
|
# 格式化文件大小
|
||||||
|
size_str = None
|
||||||
|
if item.size:
|
||||||
|
size_str = StringUtils.str_filesize(item.size)
|
||||||
|
|
||||||
|
# 格式化修改时间
|
||||||
|
modify_time_str = None
|
||||||
|
if item.modify_time:
|
||||||
|
try:
|
||||||
|
modify_time_str = datetime.fromtimestamp(item.modify_time).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
modify_time_str = str(item.modify_time)
|
||||||
|
|
||||||
|
simplified = {
|
||||||
|
"name": item.name,
|
||||||
|
"type": item.type,
|
||||||
|
"path": item.path,
|
||||||
|
"size": size_str,
|
||||||
|
"modify_time": modify_time_str
|
||||||
|
}
|
||||||
|
# 如果是文件,添加扩展名
|
||||||
|
if item.type == "file" and item.extension:
|
||||||
|
simplified["extension"] = item.extension
|
||||||
|
simplified_items.append(simplified)
|
||||||
|
|
||||||
|
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 如果结果被裁剪,添加提示信息
|
||||||
|
if total_count > 20:
|
||||||
|
return f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n{result_json}"
|
||||||
|
else:
|
||||||
|
return result_json
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询目录内容失败: {e}", exc_info=True)
|
||||||
|
return f"查询目录内容时发生错误: {str(e)}"
|
||||||
|
|
||||||
113
app/agent/tools/impl/query_directories.py
Normal file
113
app/agent/tools/impl/query_directories.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""查询系统目录设置工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.helper.directory import DirectoryHelper
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class QueryDirectoriesInput(BaseModel):
|
||||||
|
"""查询系统目录设置工具的输入参数模型"""
|
||||||
|
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||||
|
directory_type: Optional[str] = Field("all",
|
||||||
|
description="Filter directories by type: 'download' for download directories, 'library' for media library directories, 'all' for all directories")
|
||||||
|
storage_type: Optional[str] = Field("all",
|
||||||
|
description="Filter directories by storage type: 'local' for local storage, 'remote' for remote storage, 'all' for all storage types")
|
||||||
|
name: Optional[str] = Field(None,
|
||||||
|
description="Filter directories by name (partial match, optional)")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryDirectoriesTool(MoviePilotTool):
|
||||||
|
name: str = "query_directories"
|
||||||
|
description: str = "Query system directory configuration and list all configured directories. Shows download directories, media library directories, storage settings, transfer modes, and other directory-related configurations."
|
||||||
|
args_schema: Type[BaseModel] = QueryDirectoriesInput
|
||||||
|
|
||||||
|
async def run(self, directory_type: Optional[str] = "all",
|
||||||
|
storage_type: Optional[str] = "all",
|
||||||
|
name: Optional[str] = None, **kwargs) -> str:
|
||||||
|
logger.info(f"执行工具: {self.name}, 参数: directory_type={directory_type}, storage_type={storage_type}, name={name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
directory_helper = DirectoryHelper()
|
||||||
|
|
||||||
|
# 根据目录类型获取目录列表
|
||||||
|
if directory_type == "download":
|
||||||
|
dirs = directory_helper.get_download_dirs()
|
||||||
|
elif directory_type == "library":
|
||||||
|
dirs = directory_helper.get_library_dirs()
|
||||||
|
else:
|
||||||
|
dirs = directory_helper.get_dirs()
|
||||||
|
|
||||||
|
# 按存储类型过滤
|
||||||
|
filtered_dirs = []
|
||||||
|
for d in dirs:
|
||||||
|
# 按存储类型过滤
|
||||||
|
if storage_type == "local":
|
||||||
|
# 对于下载目录,检查 storage;对于媒体库目录,检查 library_storage
|
||||||
|
if directory_type == "download" and d.storage != "local":
|
||||||
|
continue
|
||||||
|
elif directory_type == "library" and d.library_storage != "local":
|
||||||
|
continue
|
||||||
|
elif directory_type == "all":
|
||||||
|
# 检查是否有本地存储配置
|
||||||
|
if d.download_path and d.storage != "local":
|
||||||
|
continue
|
||||||
|
if d.library_path and d.library_storage != "local":
|
||||||
|
continue
|
||||||
|
elif storage_type == "remote":
|
||||||
|
# 对于下载目录,检查 storage;对于媒体库目录,检查 library_storage
|
||||||
|
if directory_type == "download" and d.storage == "local":
|
||||||
|
continue
|
||||||
|
elif directory_type == "library" and d.library_storage == "local":
|
||||||
|
continue
|
||||||
|
elif directory_type == "all":
|
||||||
|
# 检查是否有远程存储配置
|
||||||
|
if d.download_path and d.storage == "local":
|
||||||
|
continue
|
||||||
|
if d.library_path and d.library_storage == "local":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 按名称过滤(部分匹配)
|
||||||
|
if name and d.name and name.lower() not in d.name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered_dirs.append(d)
|
||||||
|
|
||||||
|
if filtered_dirs:
|
||||||
|
# 转换为字典格式,只保留关键信息
|
||||||
|
simplified_dirs = []
|
||||||
|
for d in filtered_dirs:
|
||||||
|
simplified = {
|
||||||
|
"name": d.name,
|
||||||
|
"priority": d.priority,
|
||||||
|
"storage": d.storage,
|
||||||
|
"download_path": d.download_path,
|
||||||
|
"library_path": d.library_path,
|
||||||
|
"library_storage": d.library_storage,
|
||||||
|
"media_type": d.media_type,
|
||||||
|
"media_category": d.media_category,
|
||||||
|
"monitor_type": d.monitor_type,
|
||||||
|
"monitor_mode": d.monitor_mode,
|
||||||
|
"transfer_type": d.transfer_type,
|
||||||
|
"overwrite_mode": d.overwrite_mode,
|
||||||
|
"renaming": d.renaming,
|
||||||
|
"scraping": d.scraping,
|
||||||
|
"notify": d.notify,
|
||||||
|
"download_type_folder": d.download_type_folder,
|
||||||
|
"download_category_folder": d.download_category_folder,
|
||||||
|
"library_type_folder": d.library_type_folder,
|
||||||
|
"library_category_folder": d.library_category_folder
|
||||||
|
}
|
||||||
|
simplified_dirs.append(simplified)
|
||||||
|
|
||||||
|
result_json = json.dumps(simplified_dirs, ensure_ascii=False, indent=2)
|
||||||
|
return result_json
|
||||||
|
return "未找到相关目录配置"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询系统目录设置失败: {e}", exc_info=True)
|
||||||
|
return f"查询系统目录设置时发生错误: {str(e)}"
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.agent.tools.base import MoviePilotTool
|
from app.agent.tools.base import MoviePilotTool
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
|
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -17,27 +18,113 @@ class QueryDownloadsInput(BaseModel):
|
|||||||
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
|
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
|
||||||
status: Optional[str] = Field("all",
|
status: Optional[str] = Field("all",
|
||||||
description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads")
|
description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads")
|
||||||
|
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)")
|
||||||
|
|
||||||
|
|
||||||
class QueryDownloadsTool(MoviePilotTool):
|
class QueryDownloadsTool(MoviePilotTool):
|
||||||
name: str = "query_downloads"
|
name: str = "query_downloads"
|
||||||
description: str = "Query download status and list all active download tasks. Shows download progress, completion status, and task details from configured downloaders."
|
description: str = "Query download status and list download tasks. Can query all active downloads, or search for specific tasks by hash or title. Shows download progress, completion status, and task details from configured downloaders."
|
||||||
args_schema: Type[BaseModel] = QueryDownloadsInput
|
args_schema: Type[BaseModel] = QueryDownloadsInput
|
||||||
|
|
||||||
async def run(self, downloader: Optional[str] = None,
|
async def run(self, downloader: Optional[str] = None,
|
||||||
status: Optional[str] = "all", **kwargs) -> str:
|
status: Optional[str] = "all",
|
||||||
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}")
|
hash: Optional[str] = None,
|
||||||
|
title: Optional[str] = None, **kwargs) -> str:
|
||||||
|
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}")
|
||||||
try:
|
try:
|
||||||
download_chain = DownloadChain()
|
download_chain = DownloadChain()
|
||||||
# 使用 DownloadChain.downloading 方法获取正在下载的任务
|
|
||||||
downloads = download_chain.downloading(name=downloader)
|
# 如果提供了hash,直接查询该hash的任务(不限制状态)
|
||||||
filtered_downloads = []
|
if hash:
|
||||||
for dl in downloads:
|
torrents = download_chain.list_torrents(downloader=downloader, hashs=[hash])
|
||||||
if downloader and dl.downloader != downloader:
|
if not torrents:
|
||||||
continue
|
return f"未找到hash为 {hash} 的下载任务(该任务可能已完成、已删除或不存在)"
|
||||||
if status != "all" and dl.status != status:
|
# 转换为DownloadingTorrent格式
|
||||||
continue
|
downloads = []
|
||||||
filtered_downloads.append(dl)
|
for torrent in torrents:
|
||||||
|
# 获取下载历史信息
|
||||||
|
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||||
|
if history:
|
||||||
|
torrent.media = {
|
||||||
|
"tmdbid": history.tmdbid,
|
||||||
|
"type": history.type,
|
||||||
|
"title": history.title,
|
||||||
|
"season": history.seasons,
|
||||||
|
"episode": history.episodes,
|
||||||
|
"image": history.image,
|
||||||
|
}
|
||||||
|
torrent.userid = history.userid
|
||||||
|
torrent.username = history.username
|
||||||
|
downloads.append(torrent)
|
||||||
|
filtered_downloads = downloads
|
||||||
|
elif title:
|
||||||
|
# 如果提供了title,查询所有任务并搜索匹配的标题
|
||||||
|
# 查询所有状态的任务
|
||||||
|
all_torrents = download_chain.list_torrents(downloader=downloader) or []
|
||||||
|
filtered_downloads = []
|
||||||
|
for torrent in all_torrents:
|
||||||
|
# 检查标题或名称是否匹配
|
||||||
|
if (title.lower() in (torrent.title or "").lower()) or \
|
||||||
|
(title.lower() in (torrent.name or "").lower()):
|
||||||
|
# 获取下载历史信息
|
||||||
|
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||||
|
if history:
|
||||||
|
torrent.media = {
|
||||||
|
"tmdbid": history.tmdbid,
|
||||||
|
"type": history.type,
|
||||||
|
"title": history.title,
|
||||||
|
"season": history.seasons,
|
||||||
|
"episode": history.episodes,
|
||||||
|
"image": history.image,
|
||||||
|
}
|
||||||
|
torrent.userid = history.userid
|
||||||
|
torrent.username = history.username
|
||||||
|
filtered_downloads.append(torrent)
|
||||||
|
if not filtered_downloads:
|
||||||
|
return f"未找到标题包含 '{title}' 的下载任务"
|
||||||
|
else:
|
||||||
|
# 根据status决定查询方式
|
||||||
|
if status == "downloading":
|
||||||
|
# 如果status为下载中,使用downloading方法
|
||||||
|
downloads = download_chain.downloading(name=downloader)
|
||||||
|
filtered_downloads = []
|
||||||
|
for dl in downloads:
|
||||||
|
if downloader and dl.downloader != downloader:
|
||||||
|
continue
|
||||||
|
filtered_downloads.append(dl)
|
||||||
|
else:
|
||||||
|
# 其他状态(completed、paused、all),使用list_torrents查询所有任务
|
||||||
|
# 查询所有状态的任务
|
||||||
|
all_torrents = download_chain.list_torrents(downloader=downloader) or []
|
||||||
|
filtered_downloads = []
|
||||||
|
for torrent in all_torrents:
|
||||||
|
if downloader and torrent.downloader != downloader:
|
||||||
|
continue
|
||||||
|
# 根据status过滤
|
||||||
|
if status == "completed":
|
||||||
|
# 已完成的任务(state为seeding或completed)
|
||||||
|
if torrent.state not in ["seeding", "completed"]:
|
||||||
|
continue
|
||||||
|
elif status == "paused":
|
||||||
|
# 已暂停的任务
|
||||||
|
if torrent.state != "paused":
|
||||||
|
continue
|
||||||
|
# status == "all" 时不过滤
|
||||||
|
# 获取下载历史信息
|
||||||
|
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||||
|
if history:
|
||||||
|
torrent.media = {
|
||||||
|
"tmdbid": history.tmdbid,
|
||||||
|
"type": history.type,
|
||||||
|
"title": history.title,
|
||||||
|
"season": history.seasons,
|
||||||
|
"episode": history.episodes,
|
||||||
|
"image": history.image,
|
||||||
|
}
|
||||||
|
torrent.userid = history.userid
|
||||||
|
torrent.username = history.username
|
||||||
|
filtered_downloads.append(torrent)
|
||||||
if filtered_downloads:
|
if filtered_downloads:
|
||||||
# 限制最多20条结果
|
# 限制最多20条结果
|
||||||
total_count = len(filtered_downloads)
|
total_count = len(filtered_downloads)
|
||||||
@@ -73,6 +160,13 @@ class QueryDownloadsTool(MoviePilotTool):
|
|||||||
# 如果结果被裁剪,添加提示信息
|
# 如果结果被裁剪,添加提示信息
|
||||||
if total_count > 20:
|
if total_count > 20:
|
||||||
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 20 条结果。\n\n{result_json}"
|
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 20 条结果。\n\n{result_json}"
|
||||||
|
|
||||||
|
# 如果查询的是特定hash或title,添加明确的状态信息
|
||||||
|
if hash:
|
||||||
|
return f"找到hash为 {hash} 的下载任务:\n\n{result_json}"
|
||||||
|
elif title:
|
||||||
|
return f"找到 {total_count} 个标题包含 '{title}' 的下载任务:\n\n{result_json}"
|
||||||
|
|
||||||
return result_json
|
return result_json
|
||||||
return "未找到相关下载任务"
|
return "未找到相关下载任务"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
116
app/agent/tools/impl/query_transfer_history.py
Normal file
116
app/agent/tools/impl/query_transfer_history.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""查询整理历史记录工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
import jieba
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.db import AsyncSessionFactory
|
||||||
|
from app.db.models.transferhistory import TransferHistory
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class QueryTransferHistoryInput(BaseModel):
|
||||||
|
"""查询整理历史记录工具的输入参数模型"""
|
||||||
|
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||||
|
title: Optional[str] = Field(None, description="Search by title (optional, supports partial match)")
|
||||||
|
status: Optional[str] = Field("all", description="Filter by status: 'success' for successful transfers, 'failed' for failed transfers, 'all' for all records (default: 'all')")
|
||||||
|
page: Optional[int] = Field(1, description="Page number for pagination (default: 1, each page contains 30 records)")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryTransferHistoryTool(MoviePilotTool):
|
||||||
|
name: str = "query_transfer_history"
|
||||||
|
description: str = "Query file transfer history records. Shows transfer status, source and destination paths, media information, and transfer details. Supports filtering by title and status."
|
||||||
|
args_schema: Type[BaseModel] = QueryTransferHistoryInput
|
||||||
|
|
||||||
|
async def run(self, title: Optional[str] = None,
|
||||||
|
status: Optional[str] = "all",
|
||||||
|
page: Optional[int] = 1, **kwargs) -> str:
|
||||||
|
logger.info(f"执行工具: {self.name}, 参数: title={title}, status={status}, page={page}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 处理状态参数
|
||||||
|
status_bool = None
|
||||||
|
if status == "success":
|
||||||
|
status_bool = True
|
||||||
|
elif status == "failed":
|
||||||
|
status_bool = False
|
||||||
|
|
||||||
|
# 处理页码参数
|
||||||
|
if page is None or page < 1:
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
# 每页记录数
|
||||||
|
count = 30
|
||||||
|
|
||||||
|
# 获取数据库会话
|
||||||
|
async with AsyncSessionFactory() as db:
|
||||||
|
# 处理标题搜索
|
||||||
|
if title:
|
||||||
|
# 使用 jieba 分词处理标题
|
||||||
|
words = jieba.cut(title, HMM=False)
|
||||||
|
title_search = "%".join(words)
|
||||||
|
# 查询记录
|
||||||
|
result = await TransferHistory.async_list_by_title(
|
||||||
|
db, title=title_search, page=page, count=count, status=status_bool
|
||||||
|
)
|
||||||
|
total = await TransferHistory.async_count_by_title(
|
||||||
|
db, title=title_search, status=status_bool
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 查询所有记录
|
||||||
|
result = await TransferHistory.async_list_by_page(
|
||||||
|
db, page=page, count=count, status=status_bool
|
||||||
|
)
|
||||||
|
total = await TransferHistory.async_count(db, status=status_bool)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return "未找到相关整理历史记录"
|
||||||
|
|
||||||
|
# 转换为字典格式,只保留关键信息
|
||||||
|
simplified_records = []
|
||||||
|
for record in result:
|
||||||
|
simplified = {
|
||||||
|
"id": record.id,
|
||||||
|
"title": record.title,
|
||||||
|
"year": record.year,
|
||||||
|
"type": record.type,
|
||||||
|
"category": record.category,
|
||||||
|
"seasons": record.seasons,
|
||||||
|
"episodes": record.episodes,
|
||||||
|
"src": record.src,
|
||||||
|
"dest": record.dest,
|
||||||
|
"mode": record.mode,
|
||||||
|
"status": "成功" if record.status else "失败",
|
||||||
|
"date": record.date,
|
||||||
|
"downloader": record.downloader,
|
||||||
|
"download_hash": record.download_hash
|
||||||
|
}
|
||||||
|
# 如果失败,添加错误信息
|
||||||
|
if not record.status and record.errmsg:
|
||||||
|
simplified["errmsg"] = record.errmsg
|
||||||
|
# 添加媒体ID信息(如果有)
|
||||||
|
if record.tmdbid:
|
||||||
|
simplified["tmdbid"] = record.tmdbid
|
||||||
|
if record.imdbid:
|
||||||
|
simplified["imdbid"] = record.imdbid
|
||||||
|
if record.doubanid:
|
||||||
|
simplified["doubanid"] = record.doubanid
|
||||||
|
simplified_records.append(simplified)
|
||||||
|
|
||||||
|
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 计算总页数
|
||||||
|
total_pages = (total + count - 1) // count if total > 0 else 1
|
||||||
|
|
||||||
|
# 构建分页信息
|
||||||
|
pagination_info = f"第 {page}/{total_pages} 页,共 {total} 条记录(每页 {count} 条)"
|
||||||
|
|
||||||
|
return f"{pagination_info}\n\n{result_json}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询整理历史记录失败: {e}", exc_info=True)
|
||||||
|
return f"查询整理历史记录时发生错误: {str(e)}"
|
||||||
|
|
||||||
116
app/agent/tools/impl/transfer_file.py
Normal file
116
app/agent/tools/impl/transfer_file.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""整理文件或目录工具"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.chain.transfer import TransferChain
|
||||||
|
from app.log import logger
|
||||||
|
from app.schemas import FileItem, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class TransferFileInput(BaseModel):
|
||||||
|
"""整理文件或目录工具的输入参数模型"""
|
||||||
|
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||||
|
file_path: str = Field(..., description="Path to the file or directory to transfer (e.g., '/path/to/file.mkv' or '/path/to/directory')")
|
||||||
|
storage: Optional[str] = Field("local", description="Storage type of the source file (default: 'local', can be 'smb', 'alist', etc.)")
|
||||||
|
target_path: Optional[str] = Field(None, description="Target path for the transferred file/directory (optional, uses default library path if not specified)")
|
||||||
|
target_storage: Optional[str] = Field(None, description="Target storage type (optional, uses default storage if not specified)")
|
||||||
|
media_type: Optional[str] = Field(None, description="Media type: '电影' for films, '电视剧' for television series (optional, will be auto-detected if not specified)")
|
||||||
|
tmdbid: Optional[int] = Field(None, description="TMDB ID for precise media identification (optional but recommended for accuracy)")
|
||||||
|
doubanid: Optional[str] = Field(None, description="Douban ID for media identification (optional)")
|
||||||
|
season: Optional[int] = Field(None, description="Season number for TV shows (optional)")
|
||||||
|
transfer_type: Optional[str] = Field(None, description="Transfer mode: 'move' to move files, 'copy' to copy files, 'link' for hard link, 'softlink' for symbolic link (optional, uses default mode if not specified)")
|
||||||
|
background: Optional[bool] = Field(False, description="Whether to run transfer in background (default: False, runs synchronously)")
|
||||||
|
|
||||||
|
|
||||||
|
class TransferFileTool(MoviePilotTool):
|
||||||
|
name: str = "transfer_file"
|
||||||
|
description: str = "Transfer/organize a file or directory to the media library. Automatically recognizes media information and organizes files according to configured rules. Supports custom target paths, media identification, and transfer modes."
|
||||||
|
args_schema: Type[BaseModel] = TransferFileInput
|
||||||
|
|
||||||
|
async def run(self, file_path: str, storage: Optional[str] = "local",
|
||||||
|
target_path: Optional[str] = None,
|
||||||
|
target_storage: Optional[str] = None,
|
||||||
|
media_type: Optional[str] = None,
|
||||||
|
tmdbid: Optional[int] = None,
|
||||||
|
doubanid: Optional[str] = None,
|
||||||
|
season: Optional[int] = None,
|
||||||
|
transfer_type: Optional[str] = None,
|
||||||
|
background: Optional[bool] = False, **kwargs) -> str:
|
||||||
|
logger.info(
|
||||||
|
f"执行工具: {self.name}, 参数: file_path={file_path}, storage={storage}, target_path={target_path}, "
|
||||||
|
f"target_storage={target_storage}, media_type={media_type}, tmdbid={tmdbid}, doubanid={doubanid}, "
|
||||||
|
f"season={season}, transfer_type={transfer_type}, background={background}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not file_path:
|
||||||
|
return "错误:必须提供文件或目录路径"
|
||||||
|
|
||||||
|
# 规范化路径
|
||||||
|
if storage == "local":
|
||||||
|
# 本地路径处理
|
||||||
|
if not file_path.startswith("/") and not (len(file_path) > 1 and file_path[1] == ":"):
|
||||||
|
# 相对路径,尝试转换为绝对路径
|
||||||
|
file_path = str(Path(file_path).resolve())
|
||||||
|
else:
|
||||||
|
# 远程存储路径,确保以/开头
|
||||||
|
if not file_path.startswith("/"):
|
||||||
|
file_path = "/" + file_path
|
||||||
|
|
||||||
|
# 创建FileItem
|
||||||
|
fileitem = FileItem(
|
||||||
|
storage=storage or "local",
|
||||||
|
path=file_path,
|
||||||
|
type="dir" if file_path.endswith("/") else "file"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 处理目标路径
|
||||||
|
target_path_obj = None
|
||||||
|
if target_path:
|
||||||
|
target_path_obj = Path(target_path)
|
||||||
|
|
||||||
|
# 处理媒体类型
|
||||||
|
mtype = None
|
||||||
|
if media_type:
|
||||||
|
try:
|
||||||
|
mtype = MediaType(media_type)
|
||||||
|
except ValueError:
|
||||||
|
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||||
|
|
||||||
|
# 调用整理方法
|
||||||
|
transfer_chain = TransferChain()
|
||||||
|
state, errormsg = transfer_chain.manual_transfer(
|
||||||
|
fileitem=fileitem,
|
||||||
|
target_storage=target_storage,
|
||||||
|
target_path=target_path_obj,
|
||||||
|
tmdbid=tmdbid,
|
||||||
|
doubanid=doubanid,
|
||||||
|
mtype=mtype,
|
||||||
|
season=season,
|
||||||
|
transfer_type=transfer_type,
|
||||||
|
background=background
|
||||||
|
)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
# 处理错误信息
|
||||||
|
if isinstance(errormsg, list):
|
||||||
|
error_text = f"整理完成,{len(errormsg)} 个文件转移失败"
|
||||||
|
if errormsg:
|
||||||
|
error_text += f":\n" + "\n".join(str(e) for e in errormsg[:5]) # 只显示前5个错误
|
||||||
|
if len(errormsg) > 5:
|
||||||
|
error_text += f"\n... 还有 {len(errormsg) - 5} 个错误"
|
||||||
|
else:
|
||||||
|
error_text = str(errormsg)
|
||||||
|
return f"整理失败:{error_text}"
|
||||||
|
else:
|
||||||
|
if background:
|
||||||
|
return f"整理任务已提交到后台运行:{file_path}"
|
||||||
|
else:
|
||||||
|
return f"整理成功:{file_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"整理文件失败: {e}", exc_info=True)
|
||||||
|
return f"整理文件时发生错误: {str(e)}"
|
||||||
|
|
||||||
Reference in New Issue
Block a user