diff --git a/app/agent/tools/impl/add_download.py b/app/agent/tools/impl/add_download.py index 3a2e6023..090d263b 100644 --- a/app/agent/tools/impl/add_download.py +++ b/app/agent/tools/impl/add_download.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool, ToolChain from app.chain.search import SearchChain from app.chain.download import DownloadChain +from app.core.config import settings from app.core.context import Context from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper @@ -19,17 +20,10 @@ from app.utils.crypto import HashUtils class AddDownloadInput(BaseModel): """添加下载工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - site_name: Optional[str] = Field(None, description="Name of the torrent site/source (e.g., 'The Pirate Bay')") - torrent_title: Optional[str] = Field( - None, - description="The display name/title of the torrent (e.g., 'The.Matrix.1999.1080p.BluRay.x264')" - ) torrent_url: str = Field( ..., - description="Torrent download link, magnet URI, or search result reference returned by search_torrents" + description="torrent_url in hash:id format (can be obtained from search_torrents tool)" ) - torrent_description: Optional[str] = Field(None, - description="Brief description of the torrent content (optional)") downloader: Optional[str] = Field(None, description="Name of the downloader to use (optional, uses default if not specified)") save_path: Optional[str] = Field(None, @@ -40,19 +34,15 @@ class AddDownloadInput(BaseModel): class AddDownloadTool(MoviePilotTool): name: str = "add_download" - description: str = "Add torrent download task to the configured downloader (qBittorrent, Transmission, etc.). Downloads the torrent file and starts the download process with specified settings." + description: str = "Add torrent download task to the configured downloader (qBittorrent, Transmission, etc.) using torrent_url reference from search_torrents results." args_schema: Type[BaseModel] = AddDownloadInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据下载参数生成友好的提示消息""" - torrent_title = kwargs.get("torrent_title", "") torrent_url = kwargs.get("torrent_url") - site_name = kwargs.get("site_name", "") downloader = kwargs.get("downloader") - message = f"正在添加下载任务: {torrent_title or f'资源 {torrent_url}'}" - if site_name: - message += f" (来源: {site_name})" + message = f"正在添加下载任务: 资源 {torrent_url}" if downloader: message += f" [下载器: {downloader}]" @@ -95,35 +85,36 @@ class AddDownloadTool(MoviePilotTool): return None return context - async def run(self, site_name: Optional[str] = None, torrent_title: Optional[str] = None, - torrent_url: Optional[str] = None, - torrent_description: Optional[str] = None, + @staticmethod + def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]: + """合并用户标签与系统默认标签,确保任务可被系统管理""" + system_tag = (settings.TORRENT_TAG or "").strip() + user_labels = [item.strip() for item in (labels or "").split(",") if item.strip()] + + if system_tag and system_tag not in user_labels: + user_labels.append(system_tag) + + return ",".join(user_labels) if user_labels else None + + async def run(self, torrent_url: Optional[str] = None, downloader: Optional[str] = None, save_path: Optional[str] = None, labels: Optional[str] = None, **kwargs) -> str: logger.info( - f"执行工具: {self.name}, 参数: site_name={site_name}, torrent_title={torrent_title}, torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}") + f"执行工具: {self.name}, 参数: torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}") try: - cached_context = None - if torrent_url: - is_torrent_ref = self._is_torrent_ref(torrent_url) - cached_context = self._resolve_cached_context(torrent_url) - if is_torrent_ref and (not cached_context or not cached_context.torrent_info): - return "错误:torrent_url 无效,请重新使用 search_torrents 搜索" - if not cached_context or not cached_context.torrent_info: - cached_context = None + if not torrent_url or not self._is_torrent_ref(torrent_url): + return "错误:torrent_url 必须是 search_torrents 返回的 hash:id 引用,请重新搜索后选择。" - if cached_context and cached_context.torrent_info: - cached_torrent = cached_context.torrent_info - site_name = site_name or cached_torrent.site_name - torrent_title = torrent_title or cached_torrent.title - torrent_url = cached_torrent.enclosure - torrent_description = torrent_description or cached_torrent.description + cached_context = self._resolve_cached_context(torrent_url) + if not cached_context or not cached_context.torrent_info: + return "错误:torrent_url 无效,请重新使用 search_torrents 搜索" - if not torrent_title: - return "错误:必须提供种子标题和下载链接" - if not torrent_url: - return "错误:必须提供种子标题和下载链接" + cached_torrent = cached_context.torrent_info + site_name = cached_torrent.site_name + torrent_title = cached_torrent.title + torrent_description = cached_torrent.description + torrent_url = cached_torrent.enclosure # 使用DownloadChain添加下载 download_chain = DownloadChain() @@ -159,11 +150,13 @@ class AddDownloadTool(MoviePilotTool): media_info=media_info ) + merged_labels = self._merge_labels_with_system_tag(labels) + did = download_chain.download_single( context=context, downloader=downloader, save_path=save_path, - label=labels + label=merged_labels ) if did: return f"成功添加下载任务:{torrent_title}" diff --git a/app/agent/tools/impl/add_subscribe.py b/app/agent/tools/impl/add_subscribe.py index a2b26cc1..afc1b10e 100644 --- a/app/agent/tools/impl/add_subscribe.py +++ b/app/agent/tools/impl/add_subscribe.py @@ -16,11 +16,11 @@ class AddSubscribeInput(BaseModel): title: str = Field(..., description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')") year: str = Field(..., description="Release year of the media (required for accurate identification)") media_type: str = Field(..., - description="Type of media content: '电影' for films, '电视剧' for television series or anime series") + description="Allowed values: movie, tv") season: Optional[int] = Field(None, description="Season number for TV shows (optional, if not specified will subscribe to all seasons)") - tmdb_id: Optional[str] = Field(None, - description="TMDB database ID for precise media identification (optional but recommended for accuracy)") + tmdb_id: Optional[int] = Field(None, + description="TMDB database ID for precise media identification (optional, can be obtained from search_media tool)") start_episode: Optional[int] = Field(None, description="Starting episode number for TV shows (optional, defaults to 1 if not specified)") total_episode: Optional[int] = Field(None, @@ -32,9 +32,9 @@ class AddSubscribeInput(BaseModel): effect: Optional[str] = Field(None, description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')") filter_groups: Optional[List[str]] = Field(None, - description="List of filter rule group names to apply (optional, use query_rule_groups tool to get available rule groups)") + description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)") sites: Optional[List[int]] = Field(None, - description="List of site IDs to search from (optional, use query_sites tool to get available site IDs)") + description="List of site IDs to search from (optional, can be obtained from query_sites tool)") class AddSubscribeTool(MoviePilotTool): @@ -60,7 +60,7 @@ class AddSubscribeTool(MoviePilotTool): return message async def run(self, title: str, year: str, media_type: str, - season: Optional[int] = None, tmdb_id: Optional[str] = None, + season: Optional[int] = None, tmdb_id: Optional[int] = None, start_episode: Optional[int] = None, total_episode: Optional[int] = None, quality: Optional[str] = None, resolution: Optional[str] = None, effect: Optional[str] = None, filter_groups: Optional[List[str]] = None, @@ -73,13 +73,13 @@ class AddSubscribeTool(MoviePilotTool): try: subscribe_chain = SubscribeChain() - # 转换 tmdb_id 为整数 - tmdbid_int = None - if tmdb_id: - try: - tmdbid_int = int(tmdb_id) - except (ValueError, TypeError): - logger.warning(f"无效的 tmdb_id: {tmdb_id},将忽略") + media_type_key = media_type.strip().lower() + if media_type_key == "movie": + media_type_enum = MediaType.MOVIE + elif media_type_key == "tv": + media_type_enum = MediaType.TV + else: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" # 构建额外的订阅参数 subscribe_kwargs = {} @@ -99,10 +99,10 @@ class AddSubscribeTool(MoviePilotTool): subscribe_kwargs['sites'] = sites sid, message = await subscribe_chain.async_add( - mtype=MediaType(media_type), + mtype=media_type_enum, title=title, year=year, - tmdbid=tmdbid_int, + tmdbid=tmdb_id, season=season, username=self._user_id, **subscribe_kwargs diff --git a/app/agent/tools/impl/delete_download.py b/app/agent/tools/impl/delete_download.py index 12952b16..9433d765 100644 --- a/app/agent/tools/impl/delete_download.py +++ b/app/agent/tools/impl/delete_download.py @@ -12,23 +12,23 @@ 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") + hash: str = Field(..., description="Task hash (can be obtained from query_download_tasks tool)") 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." + description: str = "Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files." args_schema: Type[BaseModel] = DeleteDownloadInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据删除参数生成友好的提示消息""" - task_identifier = kwargs.get("task_identifier", "") + hash_value = kwargs.get("hash", "") downloader = kwargs.get("downloader") delete_files = kwargs.get("delete_files", False) - message = f"正在删除下载任务: {task_identifier}" + message = f"正在删除下载任务: {hash_value}" if downloader: message += f" [下载器: {downloader}]" if delete_files: @@ -36,40 +36,26 @@ class DeleteDownloadTool(MoviePilotTool): return message - async def run(self, task_identifier: str, downloader: Optional[str] = None, + async def run(self, hash: 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}") + logger.info(f"执行工具: {self.name}, 参数: hash={hash}, 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 工具查询可用的下载任务" + + # 仅支持通过hash删除任务 + if len(hash) != 40 or not all(c in '0123456789abcdefABCDEF' for c in hash): + return "参数错误:hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。" # 删除下载任务 # remove_torrents 支持 delete_file 参数,可以控制是否删除文件 - result = download_chain.remove_torrents(hashs=[task_hash], downloader=downloader, delete_file=delete_files) + result = download_chain.remove_torrents(hashs=[hash], downloader=downloader, delete_file=delete_files) if result: files_info = "(包含文件)" if delete_files else "(不包含文件)" - return f"成功删除下载任务:{task_identifier} {files_info}" + return f"成功删除下载任务:{hash} {files_info}" else: - return f"删除下载任务失败:{task_identifier},请检查任务是否存在或下载器是否可用" + return f"删除下载任务失败:{hash},请检查任务是否存在或下载器是否可用" except Exception as e: logger.error(f"删除下载任务失败: {e}", exc_info=True) return f"删除下载任务时发生错误: {str(e)}" diff --git a/app/agent/tools/impl/get_recommendations.py b/app/agent/tools/impl/get_recommendations.py index 4d7cfa06..249a2dea 100644 --- a/app/agent/tools/impl/get_recommendations.py +++ b/app/agent/tools/impl/get_recommendations.py @@ -30,7 +30,7 @@ class GetRecommendationsInput(BaseModel): "'douban_tv_animation' for Douban popular animation, " "'bangumi_calendar' for Bangumi anime calendar") media_type: Optional[str] = Field("all", - description="Type of media content: '电影' for films, '电视剧' for television series or anime series, 'all' for all types") + description="Allowed values: movie, tv, all") limit: Optional[int] = Field(20, description="Maximum number of recommendations to return (default: 20, maximum: 100)") @@ -75,6 +75,9 @@ class GetRecommendationsTool(MoviePilotTool): media_type: Optional[str] = "all", limit: Optional[int] = 20, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, limit={limit}") try: + if media_type not in ["all", "movie", "tv"]: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'" + recommend_chain = RecommendChain() results = [] if source == "tmdb_trending": diff --git a/app/agent/tools/impl/get_search_results.py b/app/agent/tools/impl/get_search_results.py index 8cb84d38..754292ed 100644 --- a/app/agent/tools/impl/get_search_results.py +++ b/app/agent/tools/impl/get_search_results.py @@ -1,6 +1,7 @@ """获取搜索结果工具""" import json +import re from typing import List, Optional, Type from pydantic import BaseModel, Field @@ -18,17 +19,18 @@ from ._torrent_search_utils import ( class GetSearchResultsInput(BaseModel): """获取搜索结果工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - site: Optional[List[str]] = Field(None, description="Filter by site name, supports multiple values") - season: Optional[List[str]] = Field(None, description="Filter by season/episode label, supports multiple values") - free_state: Optional[List[str]] = Field(None, description="Filter by promotion state, supports multiple values") - video_code: Optional[List[str]] = Field(None, description="Filter by video codec, supports multiple values") - edition: Optional[List[str]] = Field(None, description="Filter by edition/quality, supports multiple values") - resolution: Optional[List[str]] = Field(None, description="Filter by resolution, supports multiple values") - release_group: Optional[List[str]] = Field(None, description="Filter by release group, supports multiple values") + site: Optional[List[str]] = Field(None, description="Site name filters") + season: Optional[List[str]] = Field(None, description="Season or episode filters") + free_state: Optional[List[str]] = Field(None, description="Promotion state filters") + video_code: Optional[List[str]] = Field(None, description="Video codec filters") + edition: Optional[List[str]] = Field(None, description="Edition filters") + resolution: Optional[List[str]] = Field(None, description="Resolution filters") + release_group: Optional[List[str]] = Field(None, description="Release group filters") + title_pattern: Optional[str] = Field(None, description="Regular expression pattern to filter torrent titles (e.g., '4K|2160p|UHD', '1080p.*BluRay')") class GetSearchResultsTool(MoviePilotTool): name: str = "get_search_results" - description: str = "Get torrent search results from the most recent search_torrents call, with optional frontend-style filters such as site, season, promotion state, codec, quality, resolution, and release group. Returns at most the first 50 matching results." + description: str = "Get cached torrent search results from search_torrents with optional filters. Returns at most the first 50 matches." args_schema: Type[BaseModel] = GetSearchResultsInput def get_tool_message(self, **kwargs) -> Optional[str]: @@ -37,9 +39,18 @@ class GetSearchResultsTool(MoviePilotTool): async def run(self, site: Optional[List[str]] = None, season: Optional[List[str]] = None, free_state: Optional[List[str]] = None, video_code: Optional[List[str]] = None, edition: Optional[List[str]] = None, resolution: Optional[List[str]] = None, - release_group: Optional[List[str]] = None, **kwargs) -> str: + release_group: Optional[List[str]] = None, title_pattern: Optional[str] = None, + **kwargs) -> str: logger.info( - f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}") + f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}") + + regex_pattern = None + if title_pattern: + try: + regex_pattern = re.compile(title_pattern, re.IGNORECASE) + except re.error as e: + logger.warning(f"正则表达式编译失败: {title_pattern}, 错误: {e}") + return f"正则表达式格式错误: {str(e)}" try: items = await SearchChain().async_last_search_results() or [] @@ -56,6 +67,12 @@ class GetSearchResultsTool(MoviePilotTool): resolution=resolution, release_group=release_group, ) + if regex_pattern: + filtered_items = [ + item for item in filtered_items + if item.torrent_info and item.torrent_info.title + and regex_pattern.search(item.torrent_info.title) + ] if not filtered_items: return "没有符合筛选条件的搜索结果,请调整筛选条件" diff --git a/app/agent/tools/impl/list_directory.py b/app/agent/tools/impl/list_directory.py index 85f1a6fa..283315c7 100644 --- a/app/agent/tools/impl/list_directory.py +++ b/app/agent/tools/impl/list_directory.py @@ -24,7 +24,7 @@ class ListDirectoryInput(BaseModel): class ListDirectoryTool(MoviePilotTool): name: str = "list_directory" - description: str = "List actual files and folders in a file system directory (NOT configuration). 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. Use 'query_directories' to query directory configuration settings." + description: str = "List actual files and folders in a file system directory (NOT configuration). 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. Use 'query_directory_settings' to query directory configuration settings." args_schema: Type[BaseModel] = ListDirectoryInput def get_tool_message(self, **kwargs) -> Optional[str]: diff --git a/app/agent/tools/impl/query_episode_schedule.py b/app/agent/tools/impl/query_episode_schedule.py index 9c32ba7f..cb4ce4cf 100644 --- a/app/agent/tools/impl/query_episode_schedule.py +++ b/app/agent/tools/impl/query_episode_schedule.py @@ -6,23 +6,21 @@ from typing import Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool -from app.chain.media import MediaChain from app.chain.tmdb import TmdbChain from app.log import logger -from app.schemas import MediaType class QueryEpisodeScheduleInput(BaseModel): """查询剧集上映时间工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - tmdb_id: int = Field(..., description="TMDB ID of the TV series") + tmdb_id: int = Field(..., description="TMDB ID of the TV series (can be obtained from search_media tool)") season: int = Field(..., description="Season number to query") episode_group: Optional[str] = Field(None, description="Episode group ID (optional)") class QueryEpisodeScheduleTool(MoviePilotTool): name: str = "query_episode_schedule" - description: str = "Query TV series episode air dates and schedule. Returns detailed information for each episode including air date, episode number, title, overview, and other metadata. Filters out episodes without air dates." + description: str = "Query TV series episode air dates and schedule. Returns non-duplicated schedule fields, including episode list, air-date statistics, and per-episode metadata. Filters out episodes without air dates." args_schema: Type[BaseModel] = QueryEpisodeScheduleInput def get_tool_message(self, **kwargs) -> Optional[str]: @@ -41,12 +39,6 @@ class QueryEpisodeScheduleTool(MoviePilotTool): logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, season={season}, episode_group={episode_group}") try: - # 获取媒体信息(用于获取标题和海报) - media_chain = MediaChain() - mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, mtype=MediaType.TV) - if not mediainfo: - return f"未找到 TMDB ID {tmdb_id} 的媒体信息" - # 获取集列表 tmdb_chain = TmdbChain() episodes = await tmdb_chain.async_tmdb_episodes( @@ -92,12 +84,7 @@ class QueryEpisodeScheduleTool(MoviePilotTool): episode_list.sort(key=lambda x: (x["air_date"] or "", x["episode_number"] or 0)) result = { - "success": True, - "tmdb_id": tmdb_id, "season": season, - "episode_group": episode_group, - "series_title": mediainfo.title if mediainfo else None, - "series_poster": mediainfo.poster_path if mediainfo else None, "total_episodes": len(episodes), "episodes_with_air_date": len(episode_list), "episodes": episode_list diff --git a/app/agent/tools/impl/query_library_exists.py b/app/agent/tools/impl/query_library_exists.py index 19a009d6..b7fdfdc5 100644 --- a/app/agent/tools/impl/query_library_exists.py +++ b/app/agent/tools/impl/query_library_exists.py @@ -7,8 +7,6 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.mediaserver import MediaServerChain -from app.core.context import MediaInfo -from app.core.meta import MetaBase from app.log import logger from app.schemas.types import MediaType @@ -16,67 +14,58 @@ from app.schemas.types import MediaType class QueryLibraryExistsInput(BaseModel): """查询媒体库工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - media_type: Optional[str] = Field("all", - description="Type of media content: '电影' for films, '电视剧' for television series or anime series, 'all' for all types") - title: Optional[str] = Field(None, - description="Specific media title to check if it exists in the media library (optional, if provided checks for that specific media)") - year: Optional[str] = Field(None, - description="Release year of the media (optional, helps narrow down search results)") + tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") + douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") + media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") class QueryLibraryExistsTool(MoviePilotTool): name: str = "query_library_exists" - description: str = "Check if a specific media resource already exists in the media library (Plex, Emby, Jellyfin). Use this tool to verify whether a movie or TV series has been successfully processed and added to the media server before performing operations like downloading or subscribing." + description: str = "Check whether a specific media resource already exists in the media library (Plex, Emby, Jellyfin) by media ID. Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching." args_schema: Type[BaseModel] = QueryLibraryExistsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" - media_type = kwargs.get("media_type", "all") - title = kwargs.get("title") - year = kwargs.get("year") - - parts = ["正在查询媒体库"] - - if title: - parts.append(f"标题: {title}") - if year: - parts.append(f"年份: {year}") - if media_type != "all": - parts.append(f"类型: {media_type}") - - return " | ".join(parts) if len(parts) > 1 else parts[0] + tmdb_id = kwargs.get("tmdb_id") + douban_id = kwargs.get("douban_id") + media_type = kwargs.get("media_type") - async def run(self, media_type: Optional[str] = "all", - title: Optional[str] = None, year: Optional[str] = None, **kwargs) -> str: - logger.info(f"执行工具: {self.name}, 参数: media_type={media_type}, title={title}") + if tmdb_id: + message = f"正在查询媒体库: TMDB={tmdb_id}" + elif douban_id: + message = f"正在查询媒体库: 豆瓣={douban_id}" + else: + message = "正在查询媒体库" + if media_type: + message += f" [{media_type}]" + return message + + async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, + media_type: Optional[str] = None, **kwargs) -> str: + logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}") try: - if not title: - return "请提供媒体标题进行查询" + if not tmdb_id and not douban_id: + return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。" + + media_type_enum = None + if media_type: + media_type_key = media_type.strip().lower() + if media_type_key == "movie": + media_type_enum = MediaType.MOVIE + elif media_type_key == "tv": + media_type_enum = MediaType.TV + else: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" media_chain = MediaServerChain() - - # 1. 识别媒体信息(获取 TMDB ID 和各季的总集数等元数据) - meta = MetaBase(title=title) - if year: - meta.year = str(year) - if media_type == "电影": - meta.type = MediaType.MOVIE - elif media_type == "电视剧": - meta.type = MediaType.TV - - # 使用识别方法补充信息 - recognize_info = media_chain.recognize_media(meta=meta) - if recognize_info: - mediainfo = recognize_info - else: - # 识别失败,创建基本信息的 MediaInfo - mediainfo = MediaInfo() - mediainfo.title = title - mediainfo.year = year - if media_type == "电影": - mediainfo.type = MediaType.MOVIE - elif media_type == "电视剧": - mediainfo.type = MediaType.TV + mediainfo = media_chain.recognize_media( + tmdbid=tmdb_id, + doubanid=douban_id, + mtype=media_type_enum, + ) + if not mediainfo: + media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}" + return f"未识别到媒体信息: {media_id}" # 2. 调用媒体服务器接口实时查询存在信息 existsinfo = media_chain.media_exists(mediainfo=mediainfo) diff --git a/app/agent/tools/impl/query_media_detail.py b/app/agent/tools/impl/query_media_detail.py index 84c638b5..1d5f55a9 100644 --- a/app/agent/tools/impl/query_media_detail.py +++ b/app/agent/tools/impl/query_media_detail.py @@ -14,13 +14,13 @@ from app.schemas import MediaType class QueryMediaDetailInput(BaseModel): """查询媒体详情工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - tmdb_id: int = Field(..., description="TMDB ID of the media (movie or TV series)") - media_type: str = Field(..., description="Media type: 'movie' or 'tv'") + tmdb_id: int = Field(..., description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)") + media_type: str = Field(..., description="Allowed values: movie, tv") class QueryMediaDetailTool(MoviePilotTool): name: str = "query_media_detail" - description: str = "Query detailed media information from TMDB by ID and media_type. IMPORTANT: Convert search results type: '电影'→'movie', '电视剧'→'tv'. Returns core metadata including title, year, overview, status, genres, directors, actors, and season count for TV series." + description: str = "Query supplementary media details from TMDB by ID and media_type. media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series." args_schema: Type[BaseModel] = QueryMediaDetailInput def get_tool_message(self, **kwargs) -> Optional[str]: @@ -34,14 +34,16 @@ class QueryMediaDetailTool(MoviePilotTool): try: media_chain = MediaChain() - mtype = None - if media_type: - if media_type.lower() == 'movie': - mtype = MediaType.MOVIE - elif media_type.lower() == 'tv': - mtype = MediaType.TV + media_type_key = (media_type or "").strip().lower() + if media_type_key not in ["movie", "tv"]: + return json.dumps({ + "success": False, + "message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" + }, ensure_ascii=False) - mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, mtype=mtype) + media_type_enum = MediaType.MOVIE if media_type_key == "movie" else MediaType.TV + + mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, mtype=media_type_enum) if not mediainfo: return json.dumps({ @@ -74,12 +76,6 @@ class QueryMediaDetailTool(MoviePilotTool): # 构建基础媒体详情信息 result = { - "success": True, - "tmdb_id": tmdb_id, - "type": mediainfo.type.value if mediainfo.type else None, - "title": mediainfo.title, - "year": mediainfo.year, - "overview": mediainfo.overview, "status": mediainfo.status, "genres": genres, "directors": directors, diff --git a/app/agent/tools/impl/query_popular_subscribes.py b/app/agent/tools/impl/query_popular_subscribes.py index a7b139f5..e519c148 100644 --- a/app/agent/tools/impl/query_popular_subscribes.py +++ b/app/agent/tools/impl/query_popular_subscribes.py @@ -16,7 +16,7 @@ from app.schemas.types import MediaType class QueryPopularSubscribesInput(BaseModel): """查询热门订阅工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - stype: str = Field(..., description="Media type: '电影' for films, '电视剧' for television series") + media_type: str = Field(..., description="Allowed values: movie, tv") page: Optional[int] = Field(1, description="Page number for pagination (default: 1)") count: Optional[int] = Field(30, description="Number of items per page (default: 30)") min_sub: Optional[int] = Field(None, description="Minimum number of subscribers filter (optional, e.g., 5)") @@ -33,13 +33,13 @@ class QueryPopularSubscribesTool(MoviePilotTool): def get_tool_message(self, **kwargs) -> Optional[str]: """根据查询参数生成友好的提示消息""" - stype = kwargs.get("stype", "") + media_type = kwargs.get("media_type", "") page = kwargs.get("page", 1) min_sub = kwargs.get("min_sub") min_rating = kwargs.get("min_rating") max_rating = kwargs.get("max_rating") - parts = [f"正在查询热门订阅 [{stype}]"] + parts = [f"正在查询热门订阅 [{media_type}]"] if min_sub: parts.append(f"最少订阅: {min_sub}") @@ -52,7 +52,7 @@ class QueryPopularSubscribesTool(MoviePilotTool): return " | ".join(parts) if len(parts) > 1 else parts[0] - async def run(self, stype: str, + async def run(self, media_type: str, page: Optional[int] = 1, count: Optional[int] = 30, min_sub: Optional[int] = None, @@ -61,7 +61,7 @@ class QueryPopularSubscribesTool(MoviePilotTool): max_rating: Optional[float] = None, sort_type: Optional[str] = None, **kwargs) -> str: logger.info( - f"执行工具: {self.name}, 参数: stype={stype}, page={page}, count={count}, min_sub={min_sub}, " + f"执行工具: {self.name}, 参数: media_type={media_type}, page={page}, count={count}, min_sub={min_sub}, " f"genre_id={genre_id}, min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}") try: @@ -69,10 +69,12 @@ class QueryPopularSubscribesTool(MoviePilotTool): page = 1 if count is None or count < 1: count = 30 + if media_type not in ["movie", "tv"]: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" subscribe_helper = SubscribeHelper() subscribes = await subscribe_helper.async_get_statistic( - stype=stype, + stype=media_type, page=page, count=count, genre_id=genre_id, @@ -94,7 +96,15 @@ class QueryPopularSubscribesTool(MoviePilotTool): continue media = MediaInfo() - media.type = MediaType(sub.get("type")) + raw_type = str(sub.get("type") or "").strip().lower() + if raw_type in ["movie", "电影"]: + media.type = MediaType.MOVIE + elif raw_type in ["tv", "电视剧"]: + media.type = MediaType.TV + else: + # 跳过无法识别类型的数据,避免单条脏数据导致整批失败 + logger.warning(f"跳过未知媒体类型: {sub.get('type')}") + continue media.tmdb_id = sub.get("tmdbid") # 处理标题 title = sub.get("name") diff --git a/app/agent/tools/impl/query_site_userdata.py b/app/agent/tools/impl/query_site_userdata.py index 4fd0e395..e4ea4aa5 100644 --- a/app/agent/tools/impl/query_site_userdata.py +++ b/app/agent/tools/impl/query_site_userdata.py @@ -15,7 +15,7 @@ from app.log import logger 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") + 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)") diff --git a/app/agent/tools/impl/query_subscribe_history.py b/app/agent/tools/impl/query_subscribe_history.py index b4fa1a00..e3b7f660 100644 --- a/app/agent/tools/impl/query_subscribe_history.py +++ b/app/agent/tools/impl/query_subscribe_history.py @@ -14,7 +14,7 @@ from app.log import logger class QuerySubscribeHistoryInput(BaseModel): """查询订阅历史工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - media_type: Optional[str] = Field("all", description="Filter by media type: '电影' for films, '电视剧' for television series, 'all' for all types (default: 'all')") + media_type: Optional[str] = Field("all", description="Allowed values: movie, tv, all") name: Optional[str] = Field(None, description="Filter by media name (partial match, optional)") @@ -42,6 +42,9 @@ class QuerySubscribeHistoryTool(MoviePilotTool): logger.info(f"执行工具: {self.name}, 参数: media_type={media_type}, name={name}") try: + if media_type not in ["all", "movie", "tv"]: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'" + # 获取数据库会话 async with AsyncSessionFactory() as db: # 根据类型查询 diff --git a/app/agent/tools/impl/query_subscribes.py b/app/agent/tools/impl/query_subscribes.py index ee383a8a..0f165ca6 100644 --- a/app/agent/tools/impl/query_subscribes.py +++ b/app/agent/tools/impl/query_subscribes.py @@ -16,7 +16,7 @@ class QuerySubscribesInput(BaseModel): status: Optional[str] = Field("all", description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions") media_type: Optional[str] = Field("all", - description="Filter by media type: '电影' for films, '电视剧' for television series, 'all' for all types") + description="Allowed values: movie, tv, all") class QuerySubscribesTool(MoviePilotTool): @@ -45,6 +45,9 @@ class QuerySubscribesTool(MoviePilotTool): async def run(self, status: Optional[str] = "all", media_type: Optional[str] = "all", **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: status={status}, media_type={media_type}") try: + if media_type not in ["all", "movie", "tv"]: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'" + subscribe_oper = SubscribeOper() subscribes = await subscribe_oper.async_list() filtered_subscribes = [] diff --git a/app/agent/tools/impl/run_workflow.py b/app/agent/tools/impl/run_workflow.py index 98692237..8e20f2bf 100644 --- a/app/agent/tools/impl/run_workflow.py +++ b/app/agent/tools/impl/run_workflow.py @@ -14,21 +14,21 @@ from app.log import logger class RunWorkflowInput(BaseModel): """执行工作流工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - workflow_identifier: str = Field(..., description="Workflow identifier: can be workflow ID (integer as string) or workflow name") + workflow_id: int = Field(..., description="Workflow ID (can be obtained from query_workflows tool)") from_begin: Optional[bool] = Field(True, description="Whether to run workflow from the beginning (default: True, if False will continue from last executed action)") class RunWorkflowTool(MoviePilotTool): name: str = "run_workflow" - description: str = "Execute a specific workflow manually. Can run workflow by ID or name. Supports running from the beginning or continuing from the last executed action." + description: str = "Execute a specific workflow manually by workflow ID. Supports running from the beginning or continuing from the last executed action." args_schema: Type[BaseModel] = RunWorkflowInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据工作流参数生成友好的提示消息""" - workflow_identifier = kwargs.get("workflow_identifier", "") + workflow_id = kwargs.get("workflow_id") from_begin = kwargs.get("from_begin", True) - message = f"正在执行工作流: {workflow_identifier}" + message = f"正在执行工作流: {workflow_id}" if not from_begin: message += " (从上次位置继续)" else: @@ -36,27 +36,18 @@ class RunWorkflowTool(MoviePilotTool): return message - async def run(self, workflow_identifier: str, + async def run(self, workflow_id: int, from_begin: Optional[bool] = True, **kwargs) -> str: - logger.info(f"执行工具: {self.name}, 参数: workflow_identifier={workflow_identifier}, from_begin={from_begin}") + logger.info(f"执行工具: {self.name}, 参数: workflow_id={workflow_id}, from_begin={from_begin}") try: # 获取数据库会话 async with AsyncSessionFactory() as db: workflow_oper = WorkflowOper(db) - - # 尝试解析为工作流ID - workflow = None - if workflow_identifier.isdigit(): - # 如果是数字,尝试作为工作流ID查询 - workflow = await workflow_oper.async_get(int(workflow_identifier)) - - # 如果不是ID或ID查询失败,尝试按名称查询 - if not workflow: - workflow = await workflow_oper.async_get_by_name(workflow_identifier) + workflow = await workflow_oper.async_get(workflow_id) if not workflow: - return f"未找到工作流:{workflow_identifier},请使用 query_workflows 工具查询可用的工作流" + return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流" # 执行工作流 workflow_chain = WorkflowChain() diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py index b1abf57a..acfdbe38 100644 --- a/app/agent/tools/impl/search_media.py +++ b/app/agent/tools/impl/search_media.py @@ -17,7 +17,7 @@ class SearchMediaInput(BaseModel): title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')") year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)") media_type: Optional[str] = Field(None, - description="Type of media content: '电影' for films, '电视剧' for television series or anime series") + description="Allowed values: movie, tv") season: Optional[int] = Field(None, description="Season number for TV shows and anime (optional, only applicable for series)") @@ -56,13 +56,22 @@ class SearchMediaTool(MoviePilotTool): # 过滤结果 if results: + media_type_enum = None + if media_type: + media_type_key = media_type.strip().lower() + if media_type_key == "movie": + media_type_enum = MediaType.MOVIE + elif media_type_key == "tv": + media_type_enum = MediaType.TV + else: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" + filtered_results = [] for result in results: if year and result.year != year: continue - if media_type: - if result.type != MediaType(media_type): - continue + if media_type_enum and result.type != media_type_enum: + continue if season is not None and result.season != season: continue filtered_results.append(result) diff --git a/app/agent/tools/impl/search_subscribe.py b/app/agent/tools/impl/search_subscribe.py index ebde39cb..1dc0ef03 100644 --- a/app/agent/tools/impl/search_subscribe.py +++ b/app/agent/tools/impl/search_subscribe.py @@ -15,10 +15,10 @@ from app.log import logger class SearchSubscribeInput(BaseModel): """搜索订阅缺失剧集工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - subscribe_id: int = Field(..., description="The ID of the subscription to search for missing episodes") + subscribe_id: int = Field(..., description="The ID of the subscription to search for missing episodes (can be obtained from query_subscribes tool)") manual: Optional[bool] = Field(False, description="Whether this is a manual search (default: False)") filter_groups: Optional[List[str]] = Field(None, - description="List of filter rule group names to apply for this search (optional, use query_rule_groups tool to get available rule groups. If provided, will temporarily update the subscription's filter groups before searching)") + description="List of filter rule group names to apply for this search (optional, can be obtained from query_rule_groups tool. If provided, will temporarily update the subscription's filter groups before searching)") class SearchSubscribeTool(MoviePilotTool): diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py index 8c7dd37c..63ee91aa 100644 --- a/app/agent/tools/impl/search_torrents.py +++ b/app/agent/tools/impl/search_torrents.py @@ -1,15 +1,16 @@ """搜索种子工具""" import json -import re from typing import List, Optional, Type from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.search import SearchChain +from app.db.systemconfig_oper import SystemConfigOper +from app.helper.sites import SitesHelper from app.log import logger -from app.schemas.types import MediaType +from app.schemas.types import MediaType, SystemConfigKey from ._torrent_search_utils import ( SEARCH_RESULT_CACHE_FILE, build_filter_options, @@ -19,87 +20,94 @@ from ._torrent_search_utils import ( class SearchTorrentsInput(BaseModel): """搜索种子工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - title: str = Field(..., - description="The title of the media resource to search for (e.g., 'The Matrix 1999', 'Breaking Bad S01E01')") - year: Optional[str] = Field(None, - description="Release year of the media (optional, helps narrow down search results)") - media_type: Optional[str] = Field(None, - description="Type of media content: '电影' for films, '电视剧' for television series or anime series") - season: Optional[int] = Field(None, description="Season number for TV shows (optional, only applicable for series)") + tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") + douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.") + media_type: Optional[str] = Field(None, description="Allowed values: movie, tv") + area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'") sites: Optional[List[int]] = Field(None, description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)") - filter_pattern: Optional[str] = Field(None, - description="Regular expression pattern to filter torrent titles by resolution, quality, or other keywords (e.g., '4K|2160p|UHD' for 4K content, '1080p|BluRay' for 1080p BluRay)") class SearchTorrentsTool(MoviePilotTool): name: str = "search_torrents" - description: str = "Search for torrent files across configured indexer sites based on media information. Returns available frontend-style filter options for the most recent search and caches the underlying results for get_search_results." + description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, " + "and return available filter options for follow-up selection. " + "Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.") args_schema: Type[BaseModel] = SearchTorrentsInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据搜索参数生成友好的提示消息""" - title = kwargs.get("title", "") - year = kwargs.get("year") + tmdb_id = kwargs.get("tmdb_id") + douban_id = kwargs.get("douban_id") media_type = kwargs.get("media_type") - season = kwargs.get("season") - filter_pattern = kwargs.get("filter_pattern") - - message = f"正在搜索种子: {title}" - if year: - message += f" ({year})" + + if tmdb_id: + message = f"正在搜索种子: TMDB={tmdb_id}" + elif douban_id: + message = f"正在搜索种子: 豆瓣={douban_id}" + else: + message = "正在搜索种子" if media_type: message += f" [{media_type}]" - if season: - message += f" 第{season}季" - if filter_pattern: - message += f" 过滤: {filter_pattern}" - return message - async def run(self, title: str, year: Optional[str] = None, - media_type: Optional[str] = None, season: Optional[int] = None, - sites: Optional[List[int]] = None, filter_pattern: Optional[str] = None, **kwargs) -> str: + async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, + media_type: Optional[str] = None, area: Optional[str] = None, + sites: Optional[List[int]] = None, **kwargs) -> str: logger.info( - f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, season={season}, sites={sites}, filter_pattern={filter_pattern}") + f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}") + + if not tmdb_id and not douban_id: + return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID。" try: search_chain = SearchChain() - torrents = await search_chain.async_search_by_title(title=title, sites=sites) - filtered_torrents = [] - # 编译正则表达式(如果提供) - regex_pattern = None - if filter_pattern: - try: - regex_pattern = re.compile(filter_pattern, re.IGNORECASE) - except re.error as e: - logger.warning(f"正则表达式编译失败: {filter_pattern}, 错误: {e}") - return f"正则表达式格式错误: {str(e)}" - - for torrent in torrents: - # torrent 是 Context 对象,需要通过 meta_info 和 media_info 访问属性 - if year and torrent.meta_info and torrent.meta_info.year != year: - continue - if media_type and torrent.meta_info and torrent.meta_info.type: - if torrent.meta_info.type != MediaType(media_type): - continue - if season is not None and torrent.meta_info and torrent.meta_info.begin_season != season: - continue - # 使用正则表达式过滤标题(分辨率、质量等关键字) - if regex_pattern and torrent.torrent_info and torrent.torrent_info.title: - if not regex_pattern.search(torrent.torrent_info.title): - continue - filtered_torrents.append(torrent) + media_type_enum = None + if media_type: + media_type_key = media_type.strip().lower() + if media_type_key == "movie": + media_type_enum = MediaType.MOVIE + elif media_type_key == "tv": + media_type_enum = MediaType.TV + else: + return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" + + filtered_torrents = await search_chain.async_search_by_id( + tmdbid=tmdb_id, + doubanid=douban_id, + mtype=media_type_enum, + area=area or "title", + sites=sites, + cache_local=False, + ) + + # 获取站点信息 + all_indexers = await SitesHelper().async_get_indexers() + all_sites = [{"id": indexer.get("id"), "name": indexer.get("name")} for indexer in (all_indexers or [])] + + if sites: + search_site_ids = sites + else: + configured_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) + search_site_ids = configured_sites if configured_sites else [] if filtered_torrents: await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE) result_json = json.dumps({ "total_count": len(filtered_torrents), "message": "搜索完成。请使用 get_search_results 工具获取搜索结果。", + "all_sites": all_sites, + "search_site_ids": search_site_ids, "filter_options": build_filter_options(filtered_torrents), }, ensure_ascii=False, indent=2) return result_json else: - return f"未找到相关种子资源: {title}" + media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}" + result_json = json.dumps({ + "message": f"未找到相关种子资源: {media_id}", + "all_sites": all_sites, + "search_site_ids": search_site_ids, + }, ensure_ascii=False, indent=2) + return result_json except Exception as e: error_message = f"搜索种子时发生错误: {str(e)}" logger.error(f"搜索种子失败: {e}", exc_info=True) diff --git a/app/agent/tools/impl/test_site.py b/app/agent/tools/impl/test_site.py index ee61d34d..4ed8343e 100644 --- a/app/agent/tools/impl/test_site.py +++ b/app/agent/tools/impl/test_site.py @@ -8,53 +8,31 @@ from app.agent.tools.base import MoviePilotTool from app.chain.site import SiteChain from app.db.site_oper import SiteOper from app.log import logger -from app.utils.string import StringUtils class TestSiteInput(BaseModel): """测试站点连通性工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - site_identifier: str = Field(..., description="Site identifier: can be site ID (integer as string), site name, or site domain/URL") + site_identifier: int = Field(..., description="Site ID to test (can be obtained from query_sites tool)") class TestSiteTool(MoviePilotTool): name: str = "test_site" - description: str = "Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID, site name, or site domain/URL as identifier." + description: str = "Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID only." args_schema: Type[BaseModel] = TestSiteInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据测试参数生成友好的提示消息""" - site_identifier = kwargs.get("site_identifier", "") + site_identifier = kwargs.get("site_identifier") return f"正在测试站点连通性: {site_identifier}" - async def run(self, site_identifier: str, **kwargs) -> str: + async def run(self, site_identifier: int, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}") try: site_oper = SiteOper() site_chain = SiteChain() - - # 尝试解析为站点ID - site = None - if site_identifier.isdigit(): - # 如果是数字,尝试作为站点ID查询 - site = await site_oper.async_get(int(site_identifier)) - - # 如果不是ID或ID查询失败,尝试按名称或域名查询 - if not site: - # 尝试按名称查询 - sites = await site_oper.async_list() - for s in sites: - if (site_identifier.lower() in (s.name or "").lower()) or \ - (site_identifier.lower() in (s.domain or "").lower()): - site = s - break - - # 如果还是没找到,尝试从URL提取域名 - if not site: - domain = StringUtils.get_url_domain(site_identifier) - if domain: - site = await site_oper.async_get_by_domain(domain) + site = await site_oper.async_get(site_identifier) if not site: return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点" diff --git a/app/agent/tools/impl/transfer_file.py b/app/agent/tools/impl/transfer_file.py index 4684ce2c..8ebebaf4 100644 --- a/app/agent/tools/impl/transfer_file.py +++ b/app/agent/tools/impl/transfer_file.py @@ -18,7 +18,7 @@ class TransferFileInput(BaseModel): 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)") + 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)") @@ -91,11 +91,14 @@ class TransferFileTool(MoviePilotTool): target_path_obj = Path(target_path) # 处理媒体类型 - mtype = None + media_type_enum = None if media_type: - try: - mtype = MediaType(media_type) - except ValueError: + media_type_key = media_type.strip().lower() + if media_type_key == "movie": + media_type_enum = MediaType.MOVIE + elif media_type_key == "tv": + media_type_enum = MediaType.TV + else: return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" # 调用整理方法 @@ -106,7 +109,7 @@ class TransferFileTool(MoviePilotTool): target_path=target_path_obj, tmdbid=tmdbid, doubanid=doubanid, - mtype=mtype, + mtype=media_type_enum, season=season, transfer_type=transfer_type, background=background diff --git a/app/agent/tools/impl/update_site.py b/app/agent/tools/impl/update_site.py index 59d5349b..a9c80643 100644 --- a/app/agent/tools/impl/update_site.py +++ b/app/agent/tools/impl/update_site.py @@ -17,7 +17,7 @@ from app.utils.string import StringUtils class UpdateSiteInput(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 update") + site_id: int = Field(..., description="The ID of the site to update (can be obtained from query_sites tool)") name: Optional[str] = Field(None, description="Site name (optional)") url: Optional[str] = Field(None, description="Site URL (optional, will be automatically formatted)") pri: Optional[int] = Field(None, description="Site priority (optional, smaller value = higher priority, e.g., pri=1 has higher priority than pri=10)") diff --git a/app/agent/tools/impl/update_site_cookie.py b/app/agent/tools/impl/update_site_cookie.py index c93b5a25..f91b706f 100644 --- a/app/agent/tools/impl/update_site_cookie.py +++ b/app/agent/tools/impl/update_site_cookie.py @@ -8,13 +8,12 @@ from app.agent.tools.base import MoviePilotTool from app.chain.site import SiteChain from app.db.site_oper import SiteOper from app.log import logger -from app.utils.string import StringUtils class UpdateSiteCookieInput(BaseModel): """更新站点Cookie和UA工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - site_identifier: str = Field(..., description="Site identifier: can be site ID (integer as string), site name, or site domain/URL") + site_identifier: int = Field(..., description="Site ID to update Cookie and User-Agent for (can be obtained from query_sites tool)") username: str = Field(..., description="Site login username") password: str = Field(..., description="Site login password") two_step_code: Optional[str] = Field(None, description="Two-step verification code or secret key (optional, required for sites with 2FA enabled)") @@ -22,12 +21,12 @@ class UpdateSiteCookieInput(BaseModel): class UpdateSiteCookieTool(MoviePilotTool): name: str = "update_site_cookie" - description: str = "Update site Cookie and User-Agent by logging in with username and password. This tool can automatically obtain and update the site's authentication credentials. Supports two-step verification for sites that require it. Accepts site ID, site name, or site domain/URL as identifier." + description: str = "Update site Cookie and User-Agent by logging in with username and password. This tool can automatically obtain and update the site's authentication credentials. Supports two-step verification for sites that require it. Accepts site ID only." args_schema: Type[BaseModel] = UpdateSiteCookieInput def get_tool_message(self, **kwargs) -> Optional[str]: """根据更新参数生成友好的提示消息""" - site_identifier = kwargs.get("site_identifier", "") + site_identifier = kwargs.get("site_identifier") username = kwargs.get("username", "") two_step_code = kwargs.get("two_step_code") @@ -37,35 +36,14 @@ class UpdateSiteCookieTool(MoviePilotTool): return message - async def run(self, site_identifier: str, username: str, password: str, + async def run(self, site_identifier: int, username: str, password: str, two_step_code: Optional[str] = None, **kwargs) -> str: logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}, username={username}") try: site_oper = SiteOper() site_chain = SiteChain() - - # 尝试解析为站点ID - site = None - if site_identifier.isdigit(): - # 如果是数字,尝试作为站点ID查询 - site = await site_oper.async_get(int(site_identifier)) - - # 如果不是ID或ID查询失败,尝试按名称或域名查询 - if not site: - # 尝试按名称查询 - sites = await site_oper.async_list() - for s in sites: - if (site_identifier.lower() in (s.name or "").lower()) or \ - (site_identifier.lower() in (s.domain or "").lower()): - site = s - break - - # 如果还是没找到,尝试从URL提取域名 - if not site: - domain = StringUtils.get_url_domain(site_identifier) - if domain: - site = await site_oper.async_get_by_domain(domain) + site = await site_oper.async_get(site_identifier) if not site: return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点" diff --git a/app/agent/tools/impl/update_subscribe.py b/app/agent/tools/impl/update_subscribe.py index 362a8b2a..9e635598 100644 --- a/app/agent/tools/impl/update_subscribe.py +++ b/app/agent/tools/impl/update_subscribe.py @@ -16,7 +16,7 @@ from app.schemas.types import EventType class UpdateSubscribeInput(BaseModel): """更新订阅工具的输入参数模型""" explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") - subscribe_id: int = Field(..., description="The ID of the subscription to update") + subscribe_id: int = Field(..., description="The ID of the subscription to update (can be obtained from query_subscribes tool)") name: Optional[str] = Field(None, description="Subscription name/title (optional)") year: Optional[str] = Field(None, description="Release year (optional)") season: Optional[int] = Field(None, description="Season number for TV shows (optional)") diff --git a/docs/mcp-api.md b/docs/mcp-api.md index b5f27ba6..44cf3ead 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -123,7 +123,7 @@ MoviePilot 实现了标准的 **Model Context Protocol (MCP)**,允许 AI 智 "arguments": { "title": "流浪地球", "year": "2019", - "media_type": "电影" + "media_type": "movie" } } ``` diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index 9cadcb45..9e55d69c 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -1,79 +1,119 @@ --- name: moviepilot-cli -description: Use this skill when the user wants to manage a home media ecosystem via MoviePilot. Covers searching movies/TV shows/anime, managing subscriptions, controlling downloads (torrent search, quality filtering), monitoring download progress, and organizing media libraries. Trigger when user mentions movie/show titles, asks about subscriptions, downloads, library organization, or references MoviePilot directly. +description: Use this skill when the user wants to find, download, or subscribe to a movie or TV show (including anime); asks about download or subscription status; needs to check or organize the media library; or mentions MoviePilot directly. Covers the full media acquisition workflow via MoviePilot — searching TMDB, filtering and downloading torrents from PT indexer sites, managing subscriptions for automatic episode tracking, and handling library organization, site accounts, filter rules, and schedulers. --- -# MoviePilot Media Management Skill +# MoviePilot CLI -## Overview +Use `scripts/mp-cli.js` to interact with the MoviePilot backend. -This skill interacts with the MoviePilot backend via the Node.js command-line script `scripts/mp-cli.js`. It supports four core capabilities: media search and recognition, subscription management, download control, and media library organization. - -## CLI Reference - -``` -Usage: mp-cli.js [-h HOST] [-k KEY] [COMMAND] [ARGS...] - -Options: - -h HOST backend host - -k KEY API key - -Commands: - (no command) save config when -h and -k are provided - list list all commands - show show command details and usage example - [k=v...] run a command -``` - -## Discovering Available Tools - -Before performing any task, use these two commands to understand what the current environment supports. - -**List all available commands:** +## Discover Commands ```bash -node scripts/mp-cli.js list +node scripts/mp-cli.js list # list all available commands +node scripts/mp-cli.js show # show parameters, required fields, and usage ``` -**Inspect a command's parameters:** +Always run `show ` before calling a command. Do not guess parameter names or argument formats. + +## Command Groups + +| Category | Commands | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Media Search | search_media, recognize_media, query_media_detail, get_recommendations, search_person, search_person_credits | +| Torrent | search_torrents, get_search_results | +| Download | add_download, query_download_tasks, delete_download, query_downloaders | +| Subscription | add_subscribe, query_subscribes, update_subscribe, delete_subscribe, search_subscribe, query_subscribe_history, query_popular_subscribes, query_subscribe_shares | +| Library | query_library_exists, query_library_latest, transfer_file, scrape_metadata, query_transfer_history | +| Files | list_directory, query_directory_settings | +| Sites | query_sites, query_site_userdata, test_site, update_site, update_site_cookie | +| System | query_schedulers, run_scheduler, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message | + +## Gotchas + +- **Don't guess command parameters.** Parameter names vary per command and are not inferrable. Always run `show ` first. +- **`search_torrents` results are cached server-side.** `get_search_results` reads from that cache — always run `search_torrents` first in the same session before filtering. +- **Omitting `sites` uses the user's configured default sites**, not all available sites. Only call `query_sites` and pass `sites=` when the user explicitly asks for a specific site. +- **TMDB season numbers don't always match fan-labeled seasons.** Anime and long-running shows often split one TMDB season into parts. Always validate with `query_media_detail` when the user mentions a specific season. +- **`add_download` is irreversible without manual cleanup.** Always present torrent details and wait for explicit confirmation before calling it. +- **`volume_factor` and `freedate_diff` indicate promotional status.** `volume_factor` describes the discount type (e.g. `免费` = free download, `2X` = double upload only, `2X免费` = free download + double upload, `普通` = no discount). `freedate_diff` is the remaining free window (e.g. `2天3小时`); empty means no active promotion. Always include both fields when presenting results — they are critical for the user to pick the best-value torrent. + +## Common Workflows + +### Search and Download ```bash -node scripts/mp-cli.js show +# 1. Search TMDB to get tmdb_id +node scripts/mp-cli.js search_media title="流浪地球2" media_type="movie" + +# [TV only, only if user specified a season] Validate season — see "Season Validation" section below +node scripts/mp-cli.js query_media_detail tmdb_id=... media_type="tv" + +# 2. Search torrents using tmdb_id — results are cached server-side +# Response includes available filter options (resolution, release group, etc.) +# [Optional] If the user specifies sites, first run query_sites to get IDs, then pass them via sites param +node scripts/mp-cli.js query_sites # get site IDs +node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" # use user's default sites +node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" sites='1,3' # override with specific sites + +# 3. Present available filter options to the user and ask for their preferences +# e.g. "Available resolutions: 1080p, 2160p. Release groups: CMCT, PTer. Which do you prefer?" + +# 4. Filter cached results using the user's selected preferences +node scripts/mp-cli.js get_search_results resolution='2160p' + +# 5. Present ALL filtered results as a numbered list — do not pre-select or discard any +# Show for each: index, title, size, seeders, resolution, release group, volume_factor, freedate_diff +# Let the user pick by number; only then call add_download +node scripts/mp-cli.js add_download torrent_url="..." ``` -`show` displays a command's name, its parameters, and a usage example. For each parameter, it shows the name, type, required/optional status, and description. **Always run `show` before calling any command** — never guess parameter names or formats. +### Add Subscription -## Standard Workflow +```bash +# 1. Search to get tmdb_id (required for accurate identification) +node scripts/mp-cli.js search_media title="黑镜" media_type="tv" -Follow this sequence for any media task: - -``` -1. list → confirm which commands are available -2. show → confirm parameter format before calling -3. Search / recognize → resolve exact metadata (TMDB ID, season, episode) -4. Check library / subs → avoid duplicate downloads or subscriptions -5. Execute action → downloads require explicit user confirmation first -6. Confirm final state → report the outcome to the user +# 2. Subscribe — the system will auto-download new episodes +node scripts/mp-cli.js add_subscribe title="黑镜" year="2011" media_type="tv" tmdb_id=42009 ``` -## Tool Calling Strategy +### Manage Subscriptions -**Fallback search**: If a media search returns no results, try in order: fuzzy recognition → web search → ask the user for more information. +```bash +node scripts/mp-cli.js query_subscribes status=R # list active +node scripts/mp-cli.js update_subscribe subscribe_id=123 resolution="1080p" # update filters +node scripts/mp-cli.js search_subscribe subscribe_id=123 # search missing episodes +node scripts/mp-cli.js delete_subscribe subscribe_id=123 # remove +``` -**Disambiguation**: If search results are ambiguous, call the detail-query command to obtain precise metadata before proceeding. +## Season Validation (only when user specifies a season) -## Download Safety Rules +Skip this section if the user did not mention a specific season. -Before executing any download command, you **must**: +**Step 1 — Verify the season exists:** -1. Search for and retrieve a list of available torrent resources. -2. Present torrent details to the user (size, seeders, quality, release group). -3. **Wait for explicit user confirmation** before initiating the download. +```bash +node scripts/mp-cli.js query_media_detail tmdb_id= media_type="tv" +``` + +Check `season_info` against the season the user requested: + +- **Season exists:** use that season number directly, then proceed to torrent search. +- **Season does not exist:** anime and long-running shows often split one TMDB season into multiple parts that fans call separate seasons. Use the latest available season number and continue to Step 2. + +**Step 2 — Identify the correct episode range:** + +```bash +node scripts/mp-cli.js query_episode_schedule tmdb_id= season= +``` + +Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. If no such block exists, tell the user the content is unavailable. Otherwise, confirm the episode range with the user before proceeding to torrent search. ## Error Handling -| Error | Resolution | -| --------------------- | --------------------------------------------------------------------------- | -| No search results | Try fuzzy recognition → web search → ask the user | -| Download failure | Check downloader status; advise user to verify disk space | -| Missing configuration | Prompt user to run `node scripts/mp-cli.js -h -k ` to configure | +| Error | Resolution | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No search results | Retry with an alternative title (e.g. English title). If still empty, ask the user to confirm the title or provide the TMDB ID directly. | +| Download failure | Check downloader status with `query_downloaders`; advise the user to verify storage or downloader health. If these are normal, mention it could be a network error and suggest retrying later. | +| Missing configuration | Ask the user for the backend host and API key. Once provided, run `node scripts/mp-cli.js -h -k ` (no command) to save the config persistently — subsequent commands will use it automatically. | diff --git a/skills/moviepilot-cli/scripts/mp-cli.js b/skills/moviepilot-cli/scripts/mp-cli.js index 7d410da9..5f0f6337 100755 --- a/skills/moviepilot-cli/scripts/mp-cli.js +++ b/skills/moviepilot-cli/scripts/mp-cli.js @@ -129,6 +129,26 @@ function normalizeItemType(schema = {}) { return null; } +function normalizeCommand(tool = {}) { + const properties = tool?.inputSchema?.properties || {}; + const required = Array.isArray(tool?.inputSchema?.required) ? tool.inputSchema.required : []; + const fields = Object.entries(properties) + .filter(([fieldName]) => fieldName !== 'explanation') + .map(([fieldName, schema]) => ({ + name: fieldName, + type: normalizeType(schema), + description: schema?.description || '', + required: required.includes(fieldName), + item_type: normalizeItemType(schema), + })); + + return { + name: tool?.name, + description: tool?.description || '', + fields, + }; +} + function request(method, targetUrl, headers = {}, body) { return new Promise((resolve, reject) => { let url; @@ -192,31 +212,38 @@ async function loadCommandsJson() { } commandsJson = Array.isArray(response) - ? response - .map((tool) => { - const properties = tool?.inputSchema?.properties || {}; - const required = Array.isArray(tool?.inputSchema?.required) ? tool.inputSchema.required : []; - const fields = Object.entries(properties) - .filter(([fieldName]) => fieldName !== 'explanation') - .map(([fieldName, schema]) => ({ - name: fieldName, - type: normalizeType(schema), - description: schema?.description || '', - required: required.includes(fieldName), - item_type: normalizeItemType(schema), - })); - - return { - name: tool?.name, - description: tool?.description || '', - fields, - }; - }) + ? response.map((tool) => normalizeCommand(tool)) : []; commandsLoaded = true; } +async function loadCommandJson(commandName) { + const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools/${commandName}`, { + 'X-API-KEY': mpApiKey, + }); + + if (statusCode === '404') { + console.error(`Error: command '${commandName}' not found`); + console.error(`Run '${SCRIPT_NAME} list' to see available commands`); + process.exit(1); + } + + if (statusCode !== '200') { + console.error(`Error: failed to load command definition (HTTP ${statusCode || 'unknown'})`); + process.exit(1); + } + + let response; + try { + response = JSON.parse(body); + } catch { + fail(`Error: backend returned invalid JSON for command '${commandName}'`); + } + + return normalizeCommand(response); +} + function ensureConfig() { loadConfig(); let ok = true; @@ -246,69 +273,76 @@ function printValue(value) { return; } - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function formatUsageValue(field) { + if (field?.type === 'array') { + return "','"; + } + return ''; } async function cmdList() { await loadCommandsJson(); - for (const command of commandsJson) { - process.stdout.write(`- ${command.name}${spacePad(command.name)}${command.description}\n`); + const sortedCommands = [...commandsJson].sort((left, right) => left.name.localeCompare(right.name)); + for (const command of sortedCommands) { + process.stdout.write(`${command.name}\n`); } } async function cmdShow(commandName) { - await loadCommandsJson(); - if (!commandName) { fail(`Usage: ${SCRIPT_NAME} show `); } - const command = commandsJson.find((item) => item.name === commandName); - if (!command) { - console.error(`Error: command '${commandName}' not found`); - console.error(`Run '${SCRIPT_NAME} list' to see available commands`); - process.exit(1); - } + const command = await loadCommandJson(commandName); const commandLabel = 'Command:'; + const descriptionLabel = 'Description:'; const paramsLabel = 'Parameters:'; - const usageLabel = 'Usage example:'; - const detailLabelWidth = Math.max(commandLabel.length, paramsLabel.length, usageLabel.length); + const usageLabel = 'Usage:'; + const detailLabelWidth = Math.max( + commandLabel.length, + descriptionLabel.length, + paramsLabel.length, + usageLabel.length + ); - process.stdout.write(`${commandLabel} ${command.name}\n\n`); + process.stdout.write(`${commandLabel} ${command.name}\n`); + process.stdout.write(`${descriptionLabel} ${command.description || '(none)'}\n\n`); if (command.fields.length === 0) { process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\n`); } else { const fieldLines = command.fields.map((field) => [ - field.name, + field.required ? `${field.name}*` : field.name, field.type, - field.required ? '[required]' : '[optional]', field.description, ]); const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0); const typeWidth = Math.max(...fieldLines.map(([, type]) => type.length), 0); - const reqWidth = Math.max(...fieldLines.map(([, , required]) => required.length), 0); process.stdout.write(`${paramsLabel}\n`); - for (const [fieldName, fieldType, fieldRequired, fieldDesc] of fieldLines) { + for (const [fieldName, fieldType, fieldDesc] of fieldLines) { process.stdout.write( - ` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldRequired}${spacePad(fieldRequired, reqWidth)}${fieldDesc}\n` + ` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldDesc}\n` ); } } - const usageLine = `${SCRIPT_NAME} ${command.name}`; - const reqPart = command.fields.filter((field) => field.required).map((field) => ` ${field.name}=`).join(''); + const usageLine = `${command.name}`; + const reqPart = command.fields + .filter((field) => field.required) + .map((field) => ` ${field.name}=${formatUsageValue(field)}`) + .join(''); const optPart = command.fields .filter((field) => !field.required) - .map((field) => ` [${field.name}=]`) + .map((field) => ` [${field.name}=${formatUsageValue(field)}]`) .join(''); - process.stdout.write( - `\n${usageLabel}${spacePad(usageLabel, detailLabelWidth)}${usageLine}${reqPart}${optPart}\n` - ); + process.stdout.write(`\n${usageLabel} ${usageLine}${reqPart}${optPart}\n`); } function parseBoolean(value) { @@ -420,7 +454,7 @@ async function cmdRun(commandName, pairs) { const command = commandsJson.find((item) => item.name === commandName); if (!command) { console.error(`Error: command '${commandName}' not found`); - console.error(`Run '${SCRIPT_NAME} list' to see available commands`); + console.error(`Run 'node ${SCRIPT_NAME} list' to see available commands`); process.exit(1); } @@ -446,12 +480,17 @@ async function cmdRun(commandName, pairs) { try { const parsed = JSON.parse(body); + if (Object.prototype.hasOwnProperty.call(parsed, 'error') && parsed.error) { + printValue(parsed); + return; + } + if (Object.prototype.hasOwnProperty.call(parsed, 'result')) { if (typeof parsed.result === 'string') { try { printValue(JSON.parse(parsed.result)); } catch { - printValue(parsed.result); + printValue({ result: parsed.result }); } } else { printValue(parsed.result);