agent工具增加管理员权限校验:查询站点、查询已安装插件、查询插件能力、查询站点用户数据、刮削元数据

This commit is contained in:
jxxghp
2026-03-30 11:54:48 +08:00
parent 9c51f73a72
commit cbff2fed17
5 changed files with 151 additions and 93 deletions
@@ -26,6 +26,7 @@ class QueryInstalledPluginsTool(MoviePilotTool):
"description, version, author, running state, and other information. " "description, version, author, running state, and other information. "
"Use this tool to discover what plugins are available before querying plugin capabilities or running plugin commands." "Use this tool to discover what plugins are available before querying plugin capabilities or running plugin commands."
) )
require_admin: bool = True
args_schema: Type[BaseModel] = QueryInstalledPluginsInput args_schema: Type[BaseModel] = QueryInstalledPluginsInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -33,6 +33,7 @@ class QueryPluginCapabilitiesTool(MoviePilotTool):
"Scheduled services are periodic tasks that can be triggered via the run_scheduler tool. " "Scheduled services are periodic tasks that can be triggered via the run_scheduler tool. "
"Optionally specify a plugin_id to query a specific plugin, or omit to query all running plugins." "Optionally specify a plugin_id to query a specific plugin, or omit to query all running plugins."
) )
require_admin: bool = True
args_schema: Type[BaseModel] = QueryPluginCapabilitiesInput args_schema: Type[BaseModel] = QueryPluginCapabilitiesInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
+81 -48
View File
@@ -14,60 +14,74 @@ from app.log import logger
class QuerySiteUserdataInput(BaseModel): class QuerySiteUserdataInput(BaseModel):
"""查询站点用户数据工具的输入参数模型""" """查询站点用户数据工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
site_id: int = Field(..., description="The ID of the site to query user data for (can be obtained from query_sites tool)") explanation: str = Field(
workdate: Optional[str] = Field(None, description="Work date to query (optional, format: 'YYYY-MM-DD', if not specified returns latest data)") ...,
description="Clear explanation of why this tool is being used in the current context",
)
site_id: int = Field(
...,
description="The ID of the site to query user data for (can be obtained from query_sites tool)",
)
workdate: Optional[str] = Field(
None,
description="Work date to query (optional, format: 'YYYY-MM-DD', if not specified returns latest data)",
)
class QuerySiteUserdataTool(MoviePilotTool): class QuerySiteUserdataTool(MoviePilotTool):
name: str = "query_site_userdata" name: str = "query_site_userdata"
description: str = "Query user data for a specific site including username, user level, upload/download statistics, seeding information, bonus points, and other account details. Supports querying data for a specific date or latest data." description: str = "Query user data for a specific site including username, user level, upload/download statistics, seeding information, bonus points, and other account details. Supports querying data for a specific date or latest data."
require_admin: bool = True
args_schema: Type[BaseModel] = QuerySiteUserdataInput args_schema: Type[BaseModel] = QuerySiteUserdataInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息""" """根据查询参数生成友好的提示消息"""
site_id = kwargs.get("site_id") site_id = kwargs.get("site_id")
workdate = kwargs.get("workdate") workdate = kwargs.get("workdate")
message = f"正在查询站点 #{site_id} 的用户数据" message = f"正在查询站点 #{site_id} 的用户数据"
if workdate: if workdate:
message += f" (日期: {workdate})" message += f" (日期: {workdate})"
else: else:
message += " (最新数据)" message += " (最新数据)"
return message return message
async def run(self, site_id: int, workdate: Optional[str] = None, **kwargs) -> str: async def run(self, site_id: int, workdate: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: site_id={site_id}, workdate={workdate}") logger.info(
f"执行工具: {self.name}, 参数: site_id={site_id}, workdate={workdate}"
)
try: try:
# 获取数据库会话 # 获取数据库会话
async with AsyncSessionFactory() as db: async with AsyncSessionFactory() as db:
# 获取站点 # 获取站点
site = await Site.async_get(db, site_id) site = await Site.async_get(db, site_id)
if not site: if not site:
return json.dumps({ return json.dumps(
"success": False, {"success": False, "message": f"站点不存在: {site_id}"},
"message": f"站点不存在: {site_id}" ensure_ascii=False,
}, ensure_ascii=False) )
# 获取站点用户数据 # 获取站点用户数据
user_data_list = await SiteUserData.async_get_by_domain( user_data_list = await SiteUserData.async_get_by_domain(
db, db, domain=site.domain, workdate=workdate
domain=site.domain,
workdate=workdate
) )
if not user_data_list: if not user_data_list:
return json.dumps({ return json.dumps(
"success": False, {
"message": f"站点 {site.name} ({site.domain}) 暂无用户数据", "success": False,
"site_id": site_id, "message": f"站点 {site.name} ({site.domain}) 暂无用户数据",
"site_name": site.name, "site_id": site_id,
"site_domain": site.domain, "site_name": site.name,
"workdate": workdate "site_domain": site.domain,
}, ensure_ascii=False) "workdate": workdate,
},
ensure_ascii=False,
)
# 格式化用户数据 # 格式化用户数据
result = { result = {
"success": True, "success": True,
@@ -76,16 +90,26 @@ class QuerySiteUserdataTool(MoviePilotTool):
"site_domain": site.domain, "site_domain": site.domain,
"workdate": workdate, "workdate": workdate,
"data_count": len(user_data_list), "data_count": len(user_data_list),
"user_data": [] "user_data": [],
} }
for user_data in user_data_list: for user_data in user_data_list:
# 格式化上传/下载量(转换为可读格式) # 格式化上传/下载量(转换为可读格式)
upload_gb = user_data.upload / (1024 ** 3) if user_data.upload else 0 upload_gb = user_data.upload / (1024**3) if user_data.upload else 0
download_gb = user_data.download / (1024 ** 3) if user_data.download else 0 download_gb = (
seeding_size_gb = user_data.seeding_size / (1024 ** 3) if user_data.seeding_size else 0 user_data.download / (1024**3) if user_data.download else 0
leeching_size_gb = user_data.leeching_size / (1024 ** 3) if user_data.leeching_size else 0 )
seeding_size_gb = (
user_data.seeding_size / (1024**3)
if user_data.seeding_size
else 0
)
leeching_size_gb = (
user_data.leeching_size / (1024**3)
if user_data.leeching_size
else 0
)
user_data_dict = { user_data_dict = {
"domain": user_data.domain, "domain": user_data.domain,
"name": user_data.name, "name": user_data.name,
@@ -100,37 +124,46 @@ class QuerySiteUserdataTool(MoviePilotTool):
"download_gb": round(download_gb, 2), "download_gb": round(download_gb, 2),
"ratio": round(user_data.ratio, 2) if user_data.ratio else 0, "ratio": round(user_data.ratio, 2) if user_data.ratio else 0,
"seeding": int(user_data.seeding) if user_data.seeding else 0, "seeding": int(user_data.seeding) if user_data.seeding else 0,
"leeching": int(user_data.leeching) if user_data.leeching else 0, "leeching": int(user_data.leeching)
if user_data.leeching
else 0,
"seeding_size": user_data.seeding_size, "seeding_size": user_data.seeding_size,
"seeding_size_gb": round(seeding_size_gb, 2), "seeding_size_gb": round(seeding_size_gb, 2),
"leeching_size": user_data.leeching_size, "leeching_size": user_data.leeching_size,
"leeching_size_gb": round(leeching_size_gb, 2), "leeching_size_gb": round(leeching_size_gb, 2),
"seeding_info": user_data.seeding_info if user_data.seeding_info else [], "seeding_info": user_data.seeding_info
if user_data.seeding_info
else [],
"message_unread": user_data.message_unread, "message_unread": user_data.message_unread,
"message_unread_contents": user_data.message_unread_contents if user_data.message_unread_contents else [], "message_unread_contents": user_data.message_unread_contents
if user_data.message_unread_contents
else [],
"err_msg": user_data.err_msg, "err_msg": user_data.err_msg,
"updated_day": user_data.updated_day, "updated_day": user_data.updated_day,
"updated_time": user_data.updated_time "updated_time": user_data.updated_time,
} }
result["user_data"].append(user_data_dict) result["user_data"].append(user_data_dict)
# 如果有多条数据,只返回最新的(按更新时间排序) # 如果有多条数据,只返回最新的(按更新时间排序)
if len(result["user_data"]) > 1: if len(result["user_data"]) > 1:
result["user_data"].sort( result["user_data"].sort(
key=lambda x: (x.get("updated_day", ""), x.get("updated_time", "")), key=lambda x: (
reverse=True x.get("updated_day", ""),
x.get("updated_time", ""),
),
reverse=True,
)
result["message"] = (
f"找到 {len(result['user_data'])} 条数据,显示最新的一条"
) )
result["message"] = f"找到 {len(result['user_data'])} 条数据,显示最新的一条"
result["user_data"] = [result["user_data"][0]] result["user_data"] = [result["user_data"][0]]
return json.dumps(result, ensure_ascii=False, indent=2) return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e: except Exception as e:
error_message = f"查询站点用户数据失败: {str(e)}" error_message = f"查询站点用户数据失败: {str(e)}"
logger.error(f"查询站点用户数据失败: {e}", exc_info=True) logger.error(f"查询站点用户数据失败: {e}", exc_info=True)
return json.dumps({ return json.dumps(
"success": False, {"success": False, "message": error_message, "site_id": site_id},
"message": error_message, ensure_ascii=False,
"site_id": site_id )
}, ensure_ascii=False)
+1
View File
@@ -29,6 +29,7 @@ class QuerySitesInput(BaseModel):
class QuerySitesTool(MoviePilotTool): class QuerySitesTool(MoviePilotTool):
name: str = "query_sites" name: str = "query_sites"
description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)." description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
require_admin: bool = True
args_schema: Type[BaseModel] = QuerySitesInput args_schema: Type[BaseModel] = QuerySitesInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
+67 -45
View File
@@ -16,18 +16,29 @@ from app.schemas import FileItem
class ScrapeMetadataInput(BaseModel): class ScrapeMetadataInput(BaseModel):
"""刮削媒体元数据工具的输入参数模型""" """刮削媒体元数据工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
path: str = Field(..., explanation: str = Field(
description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')") ...,
storage: Optional[str] = Field("local", description="Clear explanation of why this tool is being used in the current context",
description="Storage type: 'local' for local storage, 'smb', 'alist', etc. for remote storage (default: 'local')") )
overwrite: Optional[bool] = Field(False, path: str = Field(
description="Whether to overwrite existing metadata files (default: False)") ...,
description="Path to the file or directory to scrape metadata for (e.g., '/path/to/file.mkv' or '/path/to/directory')",
)
storage: Optional[str] = Field(
"local",
description="Storage type: 'local' for local storage, 'smb', 'alist', etc. for remote storage (default: 'local')",
)
overwrite: Optional[bool] = Field(
False,
description="Whether to overwrite existing metadata files (default: False)",
)
class ScrapeMetadataTool(MoviePilotTool): class ScrapeMetadataTool(MoviePilotTool):
name: str = "scrape_metadata" name: str = "scrape_metadata"
description: str = "Generate metadata files (NFO files, posters, backgrounds, etc.) for existing media files or directories. Automatically recognizes media information from the file path and creates metadata files. Supports both local and remote storage. Use 'search_media' to search TMDB database, or 'recognize_media' to extract info from torrent titles/file paths without generating files." description: str = "Generate metadata files (NFO files, posters, backgrounds, etc.) for existing media files or directories. Automatically recognizes media information from the file path and creates metadata files. Supports both local and remote storage. Use 'search_media' to search TMDB database, or 'recognize_media' to extract info from torrent titles/file paths without generating files."
require_admin: bool = True
args_schema: Type[BaseModel] = ScrapeMetadataInput args_schema: Type[BaseModel] = ScrapeMetadataInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -44,33 +55,38 @@ class ScrapeMetadataTool(MoviePilotTool):
return message return message
async def run(self, path: str, storage: Optional[str] = "local", async def run(
overwrite: Optional[bool] = False, **kwargs) -> str: self,
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, overwrite={overwrite}") path: str,
storage: Optional[str] = "local",
overwrite: Optional[bool] = False,
**kwargs,
) -> str:
logger.info(
f"执行工具: {self.name}, 参数: path={path}, storage={storage}, overwrite={overwrite}"
)
try: try:
# 验证路径 # 验证路径
if not path: if not path:
return json.dumps({ return json.dumps(
"success": False, {"success": False, "message": "刮削路径不能为空"},
"message": "刮削路径不能为空" ensure_ascii=False,
}, ensure_ascii=False) )
# 创建 FileItem # 创建 FileItem
fileitem = FileItem( fileitem = FileItem(
storage=storage, storage=storage, path=path, type="file" if Path(path).suffix else "dir"
path=path,
type="file" if Path(path).suffix else "dir"
) )
# 检查本地存储路径是否存在 # 检查本地存储路径是否存在
if storage == "local": if storage == "local":
scrape_path = Path(path) scrape_path = Path(path)
if not scrape_path.exists(): if not scrape_path.exists():
return json.dumps({ return json.dumps(
"success": False, {"success": False, "message": f"刮削路径不存在: {path}"},
"message": f"刮削路径不存在: {path}" ensure_ascii=False,
}, ensure_ascii=False) )
# 识别媒体信息 # 识别媒体信息
media_chain = MediaChain() media_chain = MediaChain()
@@ -79,11 +95,14 @@ class ScrapeMetadataTool(MoviePilotTool):
mediainfo = await media_chain.async_recognize_by_meta(meta) mediainfo = await media_chain.async_recognize_by_meta(meta)
if not mediainfo: if not mediainfo:
return json.dumps({ return json.dumps(
"success": False, {
"message": f"刮削失败,无法识别媒体信息: {path}", "success": False,
"path": path "message": f"刮削失败,无法识别媒体信息: {path}",
}, ensure_ascii=False) "path": path,
},
ensure_ascii=False,
)
# 在线程池中执行同步的刮削操作 # 在线程池中执行同步的刮削操作
await global_vars.loop.run_in_executor( await global_vars.loop.run_in_executor(
@@ -92,28 +111,31 @@ class ScrapeMetadataTool(MoviePilotTool):
fileitem=fileitem, fileitem=fileitem,
meta=meta, meta=meta,
mediainfo=mediainfo, mediainfo=mediainfo,
overwrite=overwrite overwrite=overwrite,
) ),
) )
return json.dumps({ return json.dumps(
"success": True, {
"message": f"{path} 刮削完成", "success": True,
"path": path, "message": f"{path} 刮削完成",
"media_info": { "path": path,
"title": mediainfo.title, "media_info": {
"year": mediainfo.year, "title": mediainfo.title,
"type": mediainfo.type.value if mediainfo.type else None, "year": mediainfo.year,
"tmdb_id": mediainfo.tmdb_id, "type": mediainfo.type.value if mediainfo.type else None,
"season": mediainfo.season "tmdb_id": mediainfo.tmdb_id,
} "season": mediainfo.season,
}, ensure_ascii=False, indent=2) },
},
ensure_ascii=False,
indent=2,
)
except Exception as e: except Exception as e:
error_message = f"刮削媒体元数据失败: {str(e)}" error_message = f"刮削媒体元数据失败: {str(e)}"
logger.error(f"刮削媒体元数据失败: {e}", exc_info=True) logger.error(f"刮削媒体元数据失败: {e}", exc_info=True)
return json.dumps({ return json.dumps(
"success": False, {"success": False, "message": error_message, "path": path},
"message": error_message, ensure_ascii=False,
"path": path )
}, ensure_ascii=False)