"""整理文件或目录工具""" 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="Allowed values: movie, tv") 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 require_admin: bool = True def get_tool_message(self, **kwargs) -> Optional[str]: """根据整理参数生成友好的提示消息""" file_path = kwargs.get("file_path", "") media_type = kwargs.get("media_type") transfer_type = kwargs.get("transfer_type") background = kwargs.get("background", False) message = f"整理文件: {file_path}" if media_type: message += f" [{media_type}]" if transfer_type: transfer_map = { "move": "移动", "copy": "复制", "link": "硬链接", "softlink": "软链接", } message += f" 模式: {transfer_map.get(transfer_type, transfer_type)}" if background: message += " [后台运行]" return message @staticmethod def _transfer_file_sync( 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, ) -> str: """ 文件整理链路包含大量同步磁盘与外部服务调用,需要在线程池中运行。 """ 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()) elif not file_path.startswith("/"): file_path = "/" + file_path fileitem = FileItem( storage=storage or "local", path=file_path, type="dir" if file_path.endswith("/") else "file", ) target_path_obj = Path(target_path) if target_path else None media_type_enum = None if media_type: media_type_enum = MediaType.from_agent(media_type) if not media_type_enum: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" state, errormsg = TransferChain().manual_transfer( fileitem=fileitem, target_storage=target_storage, target_path=target_path_obj, tmdbid=tmdbid, doubanid=doubanid, mtype=media_type_enum, season=season, transfer_type=transfer_type, background=background, ) if state: if background: return f"整理任务已提交到后台运行:{file_path}" return f"整理成功:{file_path}" if isinstance(errormsg, list): error_text = f"整理完成,{len(errormsg)} 个文件转移失败" if errormsg: error_text += ":\n" + "\n".join(str(e) for e in errormsg[:5]) if len(errormsg) > 5: error_text += f"\n... 还有 {len(errormsg) - 5} 个错误" else: error_text = str(errormsg) return f"整理失败:{error_text}" 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: return await self.run_blocking( "storage", self._transfer_file_sync, file_path, storage, target_path, target_storage, media_type, tmdbid, doubanid, season, transfer_type, background, ) except Exception as e: logger.error(f"整理文件失败: {e}", exc_info=True) return f"整理文件时发生错误: {str(e)}"