diff --git a/README.md b/README.md index 104aa5bd..94a60b25 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ 官方Wiki:https://wiki.movie-pilot.org +### 为 AI Agent 添加 Skills +```shell +npx skills add https://github.com/jxxghp/MoviePilot +``` + ## 参与开发 API文档:https://api.movie-pilot.org diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index fa5d1685..5e84891a 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -27,6 +27,7 @@ from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool from app.agent.tools.impl.search_torrents import SearchTorrentsTool +from app.agent.tools.impl.get_search_results import GetSearchResultsTool from app.agent.tools.impl.search_web import SearchWebTool from app.agent.tools.impl.send_message import SendMessageTool from app.agent.tools.impl.query_schedulers import QuerySchedulersTool @@ -70,6 +71,7 @@ class MoviePilotToolFactory: UpdateSubscribeTool, SearchSubscribeTool, SearchTorrentsTool, + GetSearchResultsTool, SearchWebTool, AddDownloadTool, QuerySubscribesTool, diff --git a/app/agent/tools/impl/_torrent_search_utils.py b/app/agent/tools/impl/_torrent_search_utils.py new file mode 100644 index 00000000..b5a8b3bd --- /dev/null +++ b/app/agent/tools/impl/_torrent_search_utils.py @@ -0,0 +1,176 @@ +"""种子搜索工具辅助函数""" + +import re +from typing import List, Optional + +from app.core.context import Context +from app.utils.crypto import HashUtils +from app.utils.string import StringUtils + +SEARCH_RESULT_CACHE_FILE = "__search_result__" +TORRENT_RESULT_LIMIT = 50 + + +def build_torrent_ref(context: Optional[Context]) -> str: + """生成用于下载校验的短引用""" + if not context or not context.torrent_info: + return "" + return HashUtils.sha1(context.torrent_info.enclosure or "")[:7] + + +def sort_season_options(options: List[str]) -> List[str]: + """按前端逻辑排序季集选项""" + if len(options) <= 1: + return options + + parsed_options = [] + for index, option in enumerate(options): + match = re.match(r"^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$", option or "") + if not match: + parsed_options.append({ + "original": option, + "season_num": 0, + "episode_num": 0, + "max_episode_num": 0, + "is_whole_season": False, + "index": index, + }) + continue + + episode_num = int(match.group(3)) if match.group(3) else 0 + max_episode_num = int(match.group(4)) if match.group(4) else episode_num + parsed_options.append({ + "original": option, + "season_num": int(match.group(1)), + "episode_num": episode_num, + "max_episode_num": max_episode_num, + "is_whole_season": not match.group(3), + "index": index, + }) + + whole_seasons = [item for item in parsed_options if item["is_whole_season"]] + episodes = [item for item in parsed_options if not item["is_whole_season"]] + + whole_seasons.sort(key=lambda item: (-item["season_num"], item["index"])) + episodes.sort( + key=lambda item: ( + -item["season_num"], + -(item["max_episode_num"] or item["episode_num"]), + -item["episode_num"], + item["index"], + ) + ) + return [item["original"] for item in whole_seasons + episodes] + + +def append_option(options: List[str], value: Optional[str]) -> None: + """按前端逻辑收集去重后的筛选项""" + if value and value not in options: + options.append(value) + + +def build_filter_options(items: List[Context]) -> dict: + """从搜索结果中构建筛选项汇总""" + filter_options = { + "site": [], + "season": [], + "freeState": [], + "edition": [], + "resolution": [], + "videoCode": [], + "releaseGroup": [], + } + + for item in items: + torrent_info = item.torrent_info + meta_info = item.meta_info + append_option(filter_options["site"], getattr(torrent_info, "site_name", None)) + append_option(filter_options["season"], getattr(meta_info, "season_episode", None)) + append_option(filter_options["freeState"], getattr(torrent_info, "volume_factor", None)) + append_option(filter_options["edition"], getattr(meta_info, "edition", None)) + append_option(filter_options["resolution"], getattr(meta_info, "resource_pix", None)) + append_option(filter_options["videoCode"], getattr(meta_info, "video_encode", None)) + append_option(filter_options["releaseGroup"], getattr(meta_info, "resource_team", None)) + + filter_options["season"] = sort_season_options(filter_options["season"]) + return filter_options + + +def match_filter(filter_values: Optional[List[str]], value: Optional[str]) -> bool: + """匹配前端同款多选筛选规则""" + return not filter_values or bool(value and value in filter_values) + + +def filter_contexts(items: List[Context], + 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) -> List[Context]: + """按前端同款维度筛选结果""" + filtered_items = [] + for item in items: + torrent_info = item.torrent_info + meta_info = item.meta_info + if ( + match_filter(site, getattr(torrent_info, "site_name", None)) + and match_filter(free_state, getattr(torrent_info, "volume_factor", None)) + and match_filter(season, getattr(meta_info, "season_episode", None)) + and match_filter(release_group, getattr(meta_info, "resource_team", None)) + and match_filter(video_code, getattr(meta_info, "video_encode", None)) + and match_filter(resolution, getattr(meta_info, "resource_pix", None)) + and match_filter(edition, getattr(meta_info, "edition", None)) + ): + filtered_items.append(item) + return filtered_items + + +def simplify_search_result(context: Context, index: int) -> dict: + """精简单条搜索结果""" + simplified = {} + torrent_info = context.torrent_info + meta_info = context.meta_info + media_info = context.media_info + + if torrent_info: + simplified["torrent_info"] = { + "title": torrent_info.title, + "size": StringUtils.format_size(torrent_info.size), + "seeders": torrent_info.seeders, + "peers": torrent_info.peers, + "site_name": torrent_info.site_name, + "torrent_url": f"{build_torrent_ref(context)}:{index}", + "page_url": torrent_info.page_url, + "volume_factor": torrent_info.volume_factor, + "freedate_diff": torrent_info.freedate_diff, + "pubdate": torrent_info.pubdate, + } + + if media_info: + simplified["media_info"] = { + "title": media_info.title, + "en_title": media_info.en_title, + "year": media_info.year, + "type": media_info.type.value if media_info.type else None, + "season": media_info.season, + "tmdb_id": media_info.tmdb_id, + } + + if meta_info: + simplified["meta_info"] = { + "name": meta_info.name, + "cn_name": meta_info.cn_name, + "en_name": meta_info.en_name, + "year": meta_info.year, + "type": meta_info.type.value if meta_info.type else None, + "begin_season": meta_info.begin_season, + "season_episode": meta_info.season_episode, + "resource_team": meta_info.resource_team, + "video_encode": meta_info.video_encode, + "edition": meta_info.edition, + "resource_pix": meta_info.resource_pix, + } + + return simplified diff --git a/app/agent/tools/impl/add_download.py b/app/agent/tools/impl/add_download.py index baaf3bfa..3a2e6023 100644 --- a/app/agent/tools/impl/add_download.py +++ b/app/agent/tools/impl/add_download.py @@ -1,25 +1,33 @@ """添加下载工具""" +import re from typing import Optional, Type 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.context import Context from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper from app.log import logger from app.schemas import TorrentInfo +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: str = Field(..., description="Name of the torrent site/source (e.g., 'The Pirate Bay')") - torrent_title: str = Field(..., - description="The display name/title of the torrent (e.g., 'The.Matrix.1999.1080p.BluRay.x264')") - torrent_url: str = Field(..., description="Direct URL to the torrent file (.torrent) or magnet link") + 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" + ) torrent_description: Optional[str] = Field(None, description="Brief description of the torrent content (optional)") downloader: Optional[str] = Field(None, @@ -38,10 +46,11 @@ class AddDownloadTool(MoviePilotTool): 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}" + message = f"正在添加下载任务: {torrent_title or f'资源 {torrent_url}'}" if site_name: message += f" (来源: {site_name})" if downloader: @@ -49,14 +58,71 @@ class AddDownloadTool(MoviePilotTool): return message - async def run(self, site_name: str, torrent_title: str, torrent_url: str, torrent_description: Optional[str] = None, + @staticmethod + def _build_torrent_ref(context: Context) -> str: + """生成用于校验缓存项的短引用""" + if not context or not context.torrent_info: + return "" + return HashUtils.sha1(context.torrent_info.enclosure or "")[:7] + + @staticmethod + def _is_torrent_ref(torrent_ref: Optional[str]) -> bool: + """判断是否为内部搜索结果引用""" + if not torrent_ref: + return False + return bool(re.fullmatch(r"[0-9a-f]{7}:\d+", str(torrent_ref).strip())) + + @classmethod + def _resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]: + """从最近一次搜索缓存中解析种子上下文,仅支持 hash:id 格式""" + ref = str(torrent_ref).strip() + if ":" not in ref: + return None + try: + ref_hash, ref_index = ref.split(":", 1) + index = int(ref_index) + except (TypeError, ValueError): + return None + + if index < 1: + return None + + results = SearchChain().last_search_results() or [] + if index > len(results): + return None + context = results[index - 1] + if not ref_hash or cls._build_torrent_ref(context) != ref_hash: + 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, 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}") try: - if not torrent_title or not torrent_url: + 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 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 + + if not torrent_title: + return "错误:必须提供种子标题和下载链接" + if not torrent_url: return "错误:必须提供种子标题和下载链接" # 使用DownloadChain添加下载 @@ -82,7 +148,9 @@ class AddDownloadTool(MoviePilotTool): site_downloader=siteinfo.downloader ) meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description) - media_info = await ToolChain().async_recognize_media(meta=meta_info) + media_info = cached_context.media_info if cached_context and cached_context.media_info else None + if not media_info: + media_info = await ToolChain().async_recognize_media(meta=meta_info) if not media_info: return "错误:无法识别媒体信息,无法添加下载任务" context = Context( diff --git a/app/agent/tools/impl/get_search_results.py b/app/agent/tools/impl/get_search_results.py new file mode 100644 index 00000000..8cb84d38 --- /dev/null +++ b/app/agent/tools/impl/get_search_results.py @@ -0,0 +1,81 @@ +"""获取搜索结果工具""" + +import json +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.log import logger +from ._torrent_search_utils import ( + TORRENT_RESULT_LIMIT, + filter_contexts, + simplify_search_result, +) + + +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") + +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." + args_schema: Type[BaseModel] = GetSearchResultsInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在获取搜索结果" + + 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: + 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}") + + try: + items = await SearchChain().async_last_search_results() or [] + if not items: + return "没有可用的搜索结果,请先使用 search_torrents 搜索" + + filtered_items = filter_contexts( + items=items, + site=site, + season=season, + free_state=free_state, + video_code=video_code, + edition=edition, + resolution=resolution, + release_group=release_group, + ) + if not filtered_items: + return "没有符合筛选条件的搜索结果,请调整筛选条件" + + total_count = len(filtered_items) + filtered_ids = {id(item) for item in filtered_items} + matched_indices = [index for index, item in enumerate(items, start=1) if id(item) in filtered_ids] + limited_items = filtered_items[:TORRENT_RESULT_LIMIT] + limited_indices = matched_indices[:TORRENT_RESULT_LIMIT] + results = [ + simplify_search_result(item, index) + for item, index in zip(limited_items, limited_indices) + ] + payload = { + "total_count": total_count, + "results": results, + } + if total_count > TORRENT_RESULT_LIMIT: + payload["message"] = f"搜索结果共找到 {total_count} 条,仅显示前 {TORRENT_RESULT_LIMIT} 条结果。" + return json.dumps(payload, ensure_ascii=False, indent=2) + except Exception as e: + error_message = f"获取搜索结果失败: {str(e)}" + logger.error(f"获取搜索结果失败: {e}", exc_info=True) + return error_message diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py index efe68f01..8c7dd37c 100644 --- a/app/agent/tools/impl/search_torrents.py +++ b/app/agent/tools/impl/search_torrents.py @@ -4,13 +4,16 @@ import json import re from typing import List, Optional, Type -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.chain.search import SearchChain from app.log import logger from app.schemas.types import MediaType -from app.utils.string import StringUtils +from ._torrent_search_utils import ( + SEARCH_RESULT_CACHE_FILE, + build_filter_options, +) class SearchTorrentsInput(BaseModel): @@ -28,32 +31,9 @@ class SearchTorrentsInput(BaseModel): 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)") - @field_validator("sites", mode="before") - @classmethod - def normalize_sites(cls, value): - """兼容字符串格式的站点列表(如 "[28]"、"28,30")""" - if value is None: - return value - if isinstance(value, str): - value = value.strip() - if not value: - return None - try: - parsed = json.loads(value) - if isinstance(parsed, list): - return parsed - except Exception: - pass - if "," in value: - return [v.strip() for v in value.split(",") if v.strip()] - if value.isdigit(): - return [value] - return value - - class SearchTorrentsTool(MoviePilotTool): name: str = "search_torrents" - description: str = "Search for torrent files across configured indexer sites based on media information. Returns available torrent downloads with details like file size, quality, and download links." + 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." args_schema: Type[BaseModel] = SearchTorrentsInput def get_tool_message(self, **kwargs) -> Optional[str]: @@ -99,8 +79,8 @@ class SearchTorrentsTool(MoviePilotTool): # torrent 是 Context 对象,需要通过 meta_info 和 media_info 访问属性 if year and torrent.meta_info and torrent.meta_info.year != year: continue - if media_type and torrent.media_info: - if torrent.media_info.type != MediaType(media_type): + 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 @@ -111,51 +91,12 @@ class SearchTorrentsTool(MoviePilotTool): filtered_torrents.append(torrent) if filtered_torrents: - # 限制最多50条结果 - total_count = len(filtered_torrents) - limited_torrents = filtered_torrents[:50] - # 精简字段,只保留关键信息 - simplified_torrents = [] - for t in limited_torrents: - simplified = {} - # 精简 torrent_info - if t.torrent_info: - simplified["torrent_info"] = { - "title": t.torrent_info.title, - "size": StringUtils.format_size(t.torrent_info.size), - "seeders": t.torrent_info.seeders, - "peers": t.torrent_info.peers, - "site_name": t.torrent_info.site_name, - "enclosure": t.torrent_info.enclosure, - "page_url": t.torrent_info.page_url, - "volume_factor": t.torrent_info.volume_factor, - "pubdate": t.torrent_info.pubdate - } - # 精简 media_info - if t.media_info: - simplified["media_info"] = { - "title": t.media_info.title, - "en_title": t.media_info.en_title, - "year": t.media_info.year, - "type": t.media_info.type.value if t.media_info.type else None, - "season": t.media_info.season, - "tmdb_id": t.media_info.tmdb_id - } - # 精简 meta_info - if t.meta_info: - simplified["meta_info"] = { - "name": t.meta_info.name, - "cn_name": t.meta_info.cn_name, - "en_name": t.meta_info.en_name, - "year": t.meta_info.year, - "type": t.meta_info.type.value if t.meta_info.type else None, - "begin_season": t.meta_info.begin_season - } - simplified_torrents.append(simplified) - result_json = json.dumps(simplified_torrents, ensure_ascii=False, indent=2) - # 如果结果被裁剪,添加提示信息 - if total_count > 50: - return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 50 条结果。\n\n{result_json}" + 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 工具获取搜索结果。", + "filter_options": build_filter_options(filtered_torrents), + }, ensure_ascii=False, indent=2) return result_json else: return f"未找到相关种子资源: {title}" diff --git a/app/agent/tools/manager.py b/app/agent/tools/manager.py index d86e3e23..1b36b135 100644 --- a/app/agent/tools/manager.py +++ b/app/agent/tools/manager.py @@ -99,6 +99,56 @@ class MoviePilotToolsManager: return tool return None + @staticmethod + def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]: + """ + 解析字段schema,兼容 Optional[T] 生成的 anyOf 结构 + """ + if field_info.get("type"): + return field_info + + any_of = field_info.get("anyOf") + if not any_of: + return field_info + + for type_option in any_of: + if type_option.get("type") and type_option["type"] != "null": + merged = dict(type_option) + if "description" not in merged and field_info.get("description"): + merged["description"] = field_info["description"] + if "default" not in merged and "default" in field_info: + merged["default"] = field_info["default"] + return merged + + return field_info + + @staticmethod + def _normalize_scalar_value(field_type: Optional[str], value: Any, key: str) -> Any: + """ + 根据字段类型规范化单个值 + """ + if field_type == "integer" and isinstance(value, str): + try: + return int(value) + except (ValueError, TypeError): + logger.warning(f"无法将参数 {key}='{value}' 转换为整数,保持原值") + return None + if field_type == "number" and isinstance(value, str): + try: + return float(value) + except (ValueError, TypeError): + logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,保持原值") + return None + if field_type == "boolean": + if isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, bool): + return value + return True + return value + @staticmethod def _normalize_arguments(tool_instance: Any, arguments: Dict[str, Any]) -> Dict[str, Any]: """ @@ -132,40 +182,11 @@ class MoviePilotToolsManager: normalized[key] = value continue - field_info = properties[key] + field_info = MoviePilotToolsManager._resolve_field_schema(properties[key]) field_type = field_info.get("type") - # 处理 anyOf 类型(例如 Optional[int] 会生成 anyOf) - any_of = field_info.get("anyOf") - if any_of and not field_type: - # 从 anyOf 中提取实际类型 - for type_option in any_of: - if "type" in type_option and type_option["type"] != "null": - field_type = type_option["type"] - break - # 根据类型进行转换 - if field_type == "integer" and isinstance(value, str): - try: - normalized[key] = int(value) - except (ValueError, TypeError): - logger.warning(f"无法将参数 {key}='{value}' 转换为整数,保持原值") - normalized[key] = None - elif field_type == "number" and isinstance(value, str): - try: - normalized[key] = float(value) - except (ValueError, TypeError): - logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,保持原值") - normalized[key] = None - elif field_type == "boolean": - if isinstance(value, str): - normalized[key] = value.lower() in ("true", "1", "yes", "on") - elif isinstance(value, (int, float)): - normalized[key] = value != 0 - else: - normalized[key] = True - else: - normalized[key] = value + normalized[key] = MoviePilotToolsManager._normalize_scalar_value(field_type, value, key) return normalized @@ -235,14 +256,15 @@ class MoviePilotToolsManager: if "properties" in schema: for field_name, field_info in schema["properties"].items(): + resolved_field_info = MoviePilotToolsManager._resolve_field_schema(field_info) # 转换字段类型 - field_type = field_info.get("type", "string") - field_description = field_info.get("description", "") + field_type = resolved_field_info.get("type", "string") + field_description = resolved_field_info.get("description", "") # 处理可选字段 if field_name not in schema.get("required", []): # 可选字段 - default_value = field_info.get("default") + default_value = resolved_field_info.get("default") properties[field_name] = { "type": field_type, "description": field_description @@ -257,12 +279,12 @@ class MoviePilotToolsManager: required.append(field_name) # 处理枚举类型 - if "enum" in field_info: - properties[field_name]["enum"] = field_info["enum"] + if "enum" in resolved_field_info: + properties[field_name]["enum"] = resolved_field_info["enum"] # 处理数组类型 - if field_type == "array" and "items" in field_info: - properties[field_name]["items"] = field_info["items"] + if field_type == "array" and "items" in resolved_field_info: + properties[field_name]["items"] = resolved_field_info["items"] return { "type": "object", diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index 7da7634d..d949b346 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -19,6 +19,17 @@ router = APIRouter() # MCP 协议版本 MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"] MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本 +MCP_HIDDEN_TOOLS = {"execute_command", "search_web"} + + +def list_exposed_tools(): + """ + 获取 MCP 可见工具列表 + """ + return [ + tool for tool in moviepilot_tool_manager.list_tools() + if tool.name not in MCP_HIDDEN_TOOLS + ] def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]: @@ -174,7 +185,7 @@ async def handle_tools_list() -> Dict[str, Any]: """ 处理工具列表请求 """ - tools = moviepilot_tool_manager.list_tools() + tools = list_exposed_tools() # 转换为 MCP 工具格式 mcp_tools = [] @@ -202,6 +213,9 @@ async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]: raise ValueError("Missing tool name") try: + if tool_name in MCP_HIDDEN_TOOLS: + raise ValueError(f"工具 '{tool_name}' 未找到") + result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments) return { @@ -248,7 +262,7 @@ async def list_tools( """ try: # 获取所有工具定义 - tools = moviepilot_tool_manager.list_tools() + tools = list_exposed_tools() # 转换为字典格式 tools_list = [] @@ -278,7 +292,9 @@ async def call_tool( 工具执行结果 """ try: - # 调用工具 + if request.tool_name in MCP_HIDDEN_TOOLS: + raise ValueError(f"工具 '{request.tool_name}' 未找到") + result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments) return schemas.ToolCallResponse( @@ -306,7 +322,7 @@ async def get_tool_info( """ try: # 获取所有工具 - tools = moviepilot_tool_manager.list_tools() + tools = list_exposed_tools() # 查找指定工具 for tool in tools: @@ -338,7 +354,7 @@ async def get_tool_schema( """ try: # 获取所有工具 - tools = moviepilot_tool_manager.list_tools() + tools = list_exposed_tools() # 查找指定工具 for tool in tools: diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 5ca7ba9c..39c2ee51 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -109,6 +109,19 @@ class HashUtils: data = data.encode(encoding) return hashlib.md5(data).hexdigest() + @staticmethod + def sha1(data: Union[str, bytes], encoding: str = "utf-8") -> str: + """ + 生成数据的SHA-1哈希值,并以字符串形式返回 + + :param data: 输入的数据,类型为字符串或字节 + :param encoding: 字符串编码类型,默认使用UTF-8 + :return: 生成的SHA-1哈希字符串 + """ + if isinstance(data, str): + data = data.encode(encoding) + return hashlib.sha1(data).hexdigest() + @staticmethod def md5_bytes(data: Union[str, bytes], encoding: str = "utf-8") -> bytes: """ diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md new file mode 100644 index 00000000..9cadcb45 --- /dev/null +++ b/skills/moviepilot-cli/SKILL.md @@ -0,0 +1,79 @@ +--- +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. +--- + +# MoviePilot Media Management Skill + +## Overview + +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:** + +```bash +node scripts/mp-cli.js list +``` + +**Inspect a command's parameters:** + +```bash +node scripts/mp-cli.js show +``` + +`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. + +## Standard Workflow + +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 +``` + +## Tool Calling Strategy + +**Fallback search**: If a media search returns no results, try in order: fuzzy recognition → web search → ask the user for more information. + +**Disambiguation**: If search results are ambiguous, call the detail-query command to obtain precise metadata before proceeding. + +## Download Safety Rules + +Before executing any download command, you **must**: + +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. + +## 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 | diff --git a/skills/moviepilot-cli/scripts/mp-cli.js b/skills/moviepilot-cli/scripts/mp-cli.js new file mode 100755 index 00000000..7d410da9 --- /dev/null +++ b/skills/moviepilot-cli/scripts/mp-cli.js @@ -0,0 +1,593 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const https = require('https'); + +const SCRIPT_NAME = process.env.MP_SCRIPT_NAME || path.basename(process.argv[1] || 'mp-cli.js'); +const CONFIG_DIR = path.join(os.homedir(), '.config', 'moviepilot_cli'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'config'); + +let commandsJson = []; +let commandsLoaded = false; + +let optHost = ''; +let optKey = ''; + +const envHost = process.env.MP_HOST || ''; +const envKey = process.env.MP_API_KEY || ''; + +let mpHost = ''; +let mpApiKey = ''; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function spacePad(text = '', targetCol = 0) { + const spaces = text.length < targetCol ? targetCol - text.length + 2 : 2; + return ' '.repeat(spaces); +} + +function printBox(title, lines) { + const rightPadding = 0; + const contentWidth = + lines.reduce((max, line) => Math.max(max, line.length), title.length) + rightPadding; + const innerWidth = contentWidth + 2; + const topLabel = `─ ${title}`; + + console.error(`┌${topLabel}${'─'.repeat(Math.max(innerWidth - topLabel.length, 0))}┐`); + for (const line of lines) { + console.error(`│ ${line}${' '.repeat(contentWidth - line.length)} │`); + } + console.error(`└${'─'.repeat(innerWidth)}┘`); +} + +function readConfig() { + let cfgHost = ''; + let cfgKey = ''; + + if (!fs.existsSync(CONFIG_FILE)) { + return { cfgHost, cfgKey }; + } + + const content = fs.readFileSync(CONFIG_FILE, 'utf8'); + for (const line of content.split(/\r?\n/)) { + if (!line.trim() || /^\s*#/.test(line)) { + continue; + } + + const index = line.indexOf('='); + if (index === -1) { + continue; + } + + const key = line.slice(0, index).replace(/\s+/g, ''); + const value = line.slice(index + 1); + + if (key === 'MP_HOST') { + cfgHost = value; + } else if (key === 'MP_API_KEY') { + cfgKey = value; + } + } + + return { cfgHost, cfgKey }; +} + +function saveConfig(host, key) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, `MP_HOST=${host}\nMP_API_KEY=${key}\n`, 'utf8'); + fs.chmodSync(CONFIG_FILE, 0o600); +} + +function loadConfig() { + const { cfgHost: initialHost, cfgKey: initialKey } = readConfig(); + let cfgHost = initialHost; + let cfgKey = initialKey; + + if (optHost || optKey) { + const nextHost = optHost || cfgHost; + const nextKey = optKey || cfgKey; + saveConfig(nextHost, nextKey); + cfgHost = nextHost; + cfgKey = nextKey; + } + + mpHost = optHost || mpHost || envHost || cfgHost; + mpApiKey = optKey || mpApiKey || envKey || cfgKey; +} + +function normalizeType(schema = {}) { + if (schema.type) { + return schema.type; + } + if (Array.isArray(schema.anyOf)) { + const candidate = schema.anyOf.find((item) => item && item.type && item.type !== 'null'); + return candidate?.type || 'string'; + } + return 'string'; +} + +function normalizeItemType(schema = {}) { + const items = schema.items; + if (!items) { + return null; + } + if (items.type) { + return items.type; + } + if (Array.isArray(items.anyOf)) { + const candidate = items.anyOf.find((item) => item && item.type && item.type !== 'null'); + return candidate?.type || null; + } + return null; +} + +function request(method, targetUrl, headers = {}, body) { + return new Promise((resolve, reject) => { + let url; + try { + url = new URL(targetUrl); + } catch (error) { + reject(new Error(`Invalid URL: ${targetUrl}`)); + return; + } + + const transport = url.protocol === 'https:' ? https : http; + const req = transport.request( + { + method, + hostname: url.hostname, + port: url.port || undefined, + path: `${url.pathname}${url.search}`, + headers, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + statusCode: res.statusCode ? String(res.statusCode) : '', + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + } + ); + + req.on('error', reject); + + if (body !== undefined) { + req.write(body); + } + + req.end(); + }); +} + +async function loadCommandsJson() { + if (commandsLoaded) { + return; + } + + const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools`, { + 'X-API-KEY': mpApiKey, + }); + + if (statusCode !== '200') { + console.error(`Error: failed to load command definitions (HTTP ${statusCode || 'unknown'})`); + process.exit(1); + } + + let response; + try { + response = JSON.parse(body); + } catch { + fail('Error: backend returned invalid JSON for command definitions'); + } + + 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, + }; + }) + : []; + + commandsLoaded = true; +} + +function ensureConfig() { + loadConfig(); + let ok = true; + + if (!mpHost) { + console.error('Error: backend host is not configured.'); + console.error(' Use: -h HOST to set it'); + console.error(' Or set environment variable: MP_HOST=http://localhost:3001'); + ok = false; + } + + if (!mpApiKey) { + console.error('Error: API key is not configured.'); + console.error(' Use: -k KEY to set it'); + console.error(' Or set environment variable: MP_API_KEY=your_key'); + ok = false; + } + + if (!ok) { + process.exit(1); + } +} + +function printValue(value) { + if (typeof value === 'string') { + process.stdout.write(`${value}\n`); + return; + } + + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +async function cmdList() { + await loadCommandsJson(); + for (const command of commandsJson) { + process.stdout.write(`- ${command.name}${spacePad(command.name)}${command.description}\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 commandLabel = 'Command:'; + const paramsLabel = 'Parameters:'; + const usageLabel = 'Usage example:'; + const detailLabelWidth = Math.max(commandLabel.length, paramsLabel.length, usageLabel.length); + + process.stdout.write(`${commandLabel} ${command.name}\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.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) { + process.stdout.write( + ` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldRequired}${spacePad(fieldRequired, reqWidth)}${fieldDesc}\n` + ); + } + } + + const usageLine = `${SCRIPT_NAME} ${command.name}`; + const reqPart = command.fields.filter((field) => field.required).map((field) => ` ${field.name}=`).join(''); + const optPart = command.fields + .filter((field) => !field.required) + .map((field) => ` [${field.name}=]`) + .join(''); + + process.stdout.write( + `\n${usageLabel}${spacePad(usageLabel, detailLabelWidth)}${usageLine}${reqPart}${optPart}\n` + ); +} + +function parseBoolean(value) { + return value === 'true' || value === '1' || value === 'yes'; +} + +function parseNumber(value, key) { + if (value === '') { + fail(`Error: invalid numeric value for '${key}'`); + } + + const result = Number(value); + if (Number.isNaN(result)) { + fail(`Error: invalid numeric value for '${key}': '${value}'`); + } + return result; +} + +function parseScalarValue(value, key, type = 'string') { + if (type === 'integer' || type === 'number') { + return parseNumber(value, key); + } + + if (type === 'boolean') { + return parseBoolean(value); + } + + return value; +} + +function parseArrayValue(value, key, itemType = 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + + if (trimmed.startsWith('[')) { + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + fail(`Error: invalid array value for '${key}': '${value}'`); + } + + if (!Array.isArray(parsed)) { + fail(`Error: invalid array value for '${key}': '${value}'`); + } + + return parsed.map((item) => { + if (typeof item === 'string') { + return parseScalarValue(item.trim(), key, itemType); + } + if (itemType === 'integer' || itemType === 'number') { + if (typeof item !== 'number') { + fail(`Error: invalid numeric value for '${key}': '${item}'`); + } + return item; + } + if (itemType === 'boolean') { + if (typeof item !== 'boolean') { + fail(`Error: invalid boolean value for '${key}': '${item}'`); + } + return item; + } + return item; + }); + } + + return trimmed + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => parseScalarValue(item, key, itemType)); +} + +function buildArguments(command, pairs) { + const args = { explanation: 'CLI invocation' }; + + for (const kv of pairs) { + if (!kv.includes('=')) { + fail(`Error: argument must be in key=value format, got: '${kv}'`); + } + + const index = kv.indexOf('='); + const key = kv.slice(0, index); + const value = kv.slice(index + 1); + const field = command.fields.find((item) => item.name === key); + const fieldType = field?.type || 'string'; + const itemType = field?.item_type || 'string'; + + if (fieldType === 'array') { + args[key] = parseArrayValue(value, key, itemType); + continue; + } + + args[key] = parseScalarValue(value, key, fieldType); + } + + return args; +} + +async function cmdRun(commandName, pairs) { + await loadCommandsJson(); + + if (!commandName) { + fail(`Usage: ${SCRIPT_NAME} [key=value ...]`); + } + + 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 requestBody = JSON.stringify({ + tool_name: commandName, + arguments: buildArguments(command, pairs), + }); + + const { statusCode, body } = await request( + 'POST', + `${mpHost}/api/v1/mcp/tools/call`, + { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + 'X-API-KEY': mpApiKey, + }, + requestBody + ); + + if (statusCode && statusCode !== '200' && statusCode !== '201') { + console.error(`Warning: HTTP status ${statusCode}`); + } + + try { + const parsed = JSON.parse(body); + if (Object.prototype.hasOwnProperty.call(parsed, 'result')) { + if (typeof parsed.result === 'string') { + try { + printValue(JSON.parse(parsed.result)); + } catch { + printValue(parsed.result); + } + } else { + printValue(parsed.result); + } + return; + } + + printValue(parsed); + } catch { + process.stdout.write(`${body}\n`); + } +} + +function printUsage() { + const { cfgHost, cfgKey } = readConfig(); + let effectiveHost = mpHost || envHost || cfgHost; + let effectiveKey = mpApiKey || envKey || cfgKey; + + if (optHost) { + effectiveHost = optHost; + } + if (optKey) { + effectiveKey = optKey; + } + + if (!effectiveHost || !effectiveKey) { + const warningLines = []; + if (!effectiveHost) { + const opt = '-h HOST'; + const desc = 'set backend host'; + warningLines.push(`${opt}${spacePad(opt)}${desc}`); + } + if (!effectiveKey) { + const opt = '-k KEY'; + const desc = 'set API key'; + warningLines.push(`${opt}${spacePad(opt)}${desc}`); + } + printBox('Warning: not configured', warningLines); + console.error(''); + } + + process.stdout.write(`Usage: ${SCRIPT_NAME} [-h HOST] [-k KEY] [COMMAND] [ARGS...]\n\n`); + const optionWidth = Math.max('-h HOST'.length, '-k KEY'.length); + process.stdout.write('Options:\n'); + process.stdout.write(` -h HOST${spacePad('-h HOST', optionWidth)}backend host\n`); + process.stdout.write(` -k KEY${spacePad('-k KEY', optionWidth)}API key\n\n`); + const commandWidth = Math.max( + '(no command)'.length, + 'list'.length, + 'show '.length, + ' [k=v...]'.length + ); + process.stdout.write('Commands:\n'); + process.stdout.write( + ` (no command)${spacePad('(no command)', commandWidth)}save config when -h and -k are provided\n` + ); + process.stdout.write(` list${spacePad('list', commandWidth)}list all commands\n`); + process.stdout.write( + ` show ${spacePad('show ', commandWidth)}show command details and usage example\n` + ); + process.stdout.write( + ` [k=v...]${spacePad(' [k=v...]', commandWidth)}run a command\n` + ); +} + +async function main() { + const args = []; + const argv = process.argv.slice(2); + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-?') { + printUsage(); + process.exit(0); + } + + if (arg === '-h') { + index += 1; + optHost = argv[index] || ''; + continue; + } + + if (arg === '-k') { + index += 1; + optKey = argv[index] || ''; + continue; + } + + if (arg === '--') { + args.push(...argv.slice(index + 1)); + break; + } + + if (arg.startsWith('-')) { + console.error(`Unknown option: ${arg}`); + printUsage(); + process.exit(1); + } + + args.push(arg); + } + + if ((optHost && !optKey) || (!optHost && optKey)) { + fail('Error: -h and -k must be provided together'); + } + + const command = args[0] || ''; + + if (command === 'list') { + ensureConfig(); + await cmdList(); + return; + } + + if (command === 'show') { + ensureConfig(); + await cmdShow(args[1] || ''); + return; + } + + if (!command) { + if (optHost || optKey) { + loadConfig(); + process.stdout.write('Configuration saved.\n'); + return; + } + + printUsage(); + return; + } + + ensureConfig(); + await cmdRun(command, args.slice(1)); +} + +main().catch((error) => { + fail(`Error: ${error.message}`); +});