feat: 优化工具和SKILL.md

This commit is contained in:
PKC278
2026-03-18 14:27:07 +08:00
parent 226432ec7f
commit cedb0f565c
25 changed files with 457 additions and 424 deletions

View File

@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool, ToolChain from app.agent.tools.base import MoviePilotTool, ToolChain
from app.chain.search import SearchChain from app.chain.search import SearchChain
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.core.config import settings
from app.core.context import Context from app.core.context import Context
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper from app.db.site_oper import SiteOper
@@ -19,17 +20,10 @@ from app.utils.crypto import HashUtils
class AddDownloadInput(BaseModel): class AddDownloadInput(BaseModel):
"""添加下载工具的输入参数模型""" """添加下载工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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( 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, downloader: Optional[str] = Field(None,
description="Name of the downloader to use (optional, uses default if not specified)") description="Name of the downloader to use (optional, uses default if not specified)")
save_path: Optional[str] = Field(None, save_path: Optional[str] = Field(None,
@@ -40,19 +34,15 @@ class AddDownloadInput(BaseModel):
class AddDownloadTool(MoviePilotTool): class AddDownloadTool(MoviePilotTool):
name: str = "add_download" 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 args_schema: Type[BaseModel] = AddDownloadInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据下载参数生成友好的提示消息""" """根据下载参数生成友好的提示消息"""
torrent_title = kwargs.get("torrent_title", "")
torrent_url = kwargs.get("torrent_url") torrent_url = kwargs.get("torrent_url")
site_name = kwargs.get("site_name", "")
downloader = kwargs.get("downloader") downloader = kwargs.get("downloader")
message = f"正在添加下载任务: {torrent_title or f'资源 {torrent_url}'}" message = f"正在添加下载任务: 资源 {torrent_url}"
if site_name:
message += f" (来源: {site_name})"
if downloader: if downloader:
message += f" [下载器: {downloader}]" message += f" [下载器: {downloader}]"
@@ -95,35 +85,36 @@ class AddDownloadTool(MoviePilotTool):
return None return None
return context return context
async def run(self, site_name: Optional[str] = None, torrent_title: Optional[str] = None, @staticmethod
torrent_url: Optional[str] = None, def _merge_labels_with_system_tag(labels: Optional[str]) -> Optional[str]:
torrent_description: Optional[str] = None, """合并用户标签与系统默认标签,确保任务可被系统管理"""
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, downloader: Optional[str] = None, save_path: Optional[str] = None,
labels: Optional[str] = None, **kwargs) -> str: labels: Optional[str] = None, **kwargs) -> str:
logger.info( 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: try:
cached_context = None if not torrent_url or not self._is_torrent_ref(torrent_url):
if torrent_url: return "错误torrent_url 必须是 search_torrents 返回的 hash:id 引用,请重新搜索后选择。"
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_context = self._resolve_cached_context(torrent_url)
cached_torrent = cached_context.torrent_info if not cached_context or not cached_context.torrent_info:
site_name = site_name or cached_torrent.site_name return "错误torrent_url 无效,请重新使用 search_torrents 搜索"
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: cached_torrent = cached_context.torrent_info
return "错误:必须提供种子标题和下载链接" site_name = cached_torrent.site_name
if not torrent_url: torrent_title = cached_torrent.title
return "错误:必须提供种子标题和下载链接" torrent_description = cached_torrent.description
torrent_url = cached_torrent.enclosure
# 使用DownloadChain添加下载 # 使用DownloadChain添加下载
download_chain = DownloadChain() download_chain = DownloadChain()
@@ -159,11 +150,13 @@ class AddDownloadTool(MoviePilotTool):
media_info=media_info media_info=media_info
) )
merged_labels = self._merge_labels_with_system_tag(labels)
did = download_chain.download_single( did = download_chain.download_single(
context=context, context=context,
downloader=downloader, downloader=downloader,
save_path=save_path, save_path=save_path,
label=labels label=merged_labels
) )
if did: if did:
return f"成功添加下载任务:{torrent_title}" return f"成功添加下载任务:{torrent_title}"

View File

@@ -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')") 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)") year: str = Field(..., description="Release year of the media (required for accurate identification)")
media_type: str = Field(..., 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, season: Optional[int] = Field(None,
description="Season number for TV shows (optional, if not specified will subscribe to all seasons)") description="Season number for TV shows (optional, if not specified will subscribe to all seasons)")
tmdb_id: Optional[str] = Field(None, tmdb_id: Optional[int] = Field(None,
description="TMDB database ID for precise media identification (optional but recommended for accuracy)") description="TMDB database ID for precise media identification (optional, can be obtained from search_media tool)")
start_episode: Optional[int] = Field(None, start_episode: Optional[int] = Field(None,
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)") description="Starting episode number for TV shows (optional, defaults to 1 if not specified)")
total_episode: Optional[int] = Field(None, total_episode: Optional[int] = Field(None,
@@ -32,9 +32,9 @@ class AddSubscribeInput(BaseModel):
effect: Optional[str] = Field(None, effect: Optional[str] = Field(None,
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')") description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')")
filter_groups: Optional[List[str]] = Field(None, 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, 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): class AddSubscribeTool(MoviePilotTool):
@@ -60,7 +60,7 @@ class AddSubscribeTool(MoviePilotTool):
return message return message
async def run(self, title: str, year: str, media_type: str, 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, start_episode: Optional[int] = None, total_episode: Optional[int] = None,
quality: Optional[str] = None, resolution: Optional[str] = None, quality: Optional[str] = None, resolution: Optional[str] = None,
effect: Optional[str] = None, filter_groups: Optional[List[str]] = None, effect: Optional[str] = None, filter_groups: Optional[List[str]] = None,
@@ -73,13 +73,13 @@ class AddSubscribeTool(MoviePilotTool):
try: try:
subscribe_chain = SubscribeChain() subscribe_chain = SubscribeChain()
# 转换 tmdb_id 为整数 media_type_key = media_type.strip().lower()
tmdbid_int = None if media_type_key == "movie":
if tmdb_id: media_type_enum = MediaType.MOVIE
try: elif media_type_key == "tv":
tmdbid_int = int(tmdb_id) media_type_enum = MediaType.TV
except (ValueError, TypeError): else:
logger.warning(f"无效的 tmdb_id: {tmdb_id},将忽略") return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
# 构建额外的订阅参数 # 构建额外的订阅参数
subscribe_kwargs = {} subscribe_kwargs = {}
@@ -99,10 +99,10 @@ class AddSubscribeTool(MoviePilotTool):
subscribe_kwargs['sites'] = sites subscribe_kwargs['sites'] = sites
sid, message = await subscribe_chain.async_add( sid, message = await subscribe_chain.async_add(
mtype=MediaType(media_type), mtype=media_type_enum,
title=title, title=title,
year=year, year=year,
tmdbid=tmdbid_int, tmdbid=tmdb_id,
season=season, season=season,
username=self._user_id, username=self._user_id,
**subscribe_kwargs **subscribe_kwargs

View File

@@ -12,23 +12,23 @@ from app.log import logger
class DeleteDownloadInput(BaseModel): class DeleteDownloadInput(BaseModel):
"""删除下载任务工具的输入参数模型""" """删除下载任务工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") 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)") 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): class DeleteDownloadTool(MoviePilotTool):
name: str = "delete_download" 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 args_schema: Type[BaseModel] = DeleteDownloadInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据删除参数生成友好的提示消息""" """根据删除参数生成友好的提示消息"""
task_identifier = kwargs.get("task_identifier", "") hash_value = kwargs.get("hash", "")
downloader = kwargs.get("downloader") downloader = kwargs.get("downloader")
delete_files = kwargs.get("delete_files", False) delete_files = kwargs.get("delete_files", False)
message = f"正在删除下载任务: {task_identifier}" message = f"正在删除下载任务: {hash_value}"
if downloader: if downloader:
message += f" [下载器: {downloader}]" message += f" [下载器: {downloader}]"
if delete_files: if delete_files:
@@ -36,40 +36,26 @@ class DeleteDownloadTool(MoviePilotTool):
return message 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: 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: try:
download_chain = DownloadChain() download_chain = DownloadChain()
# 如果task_identifier看起来像hash通常是40个字符的十六进制字符串 # 仅支持通过hash删除任务
task_hash = None if len(hash) != 40 or not all(c in '0123456789abcdefABCDEF' for c in hash):
if len(task_identifier) == 40 and all(c in '0123456789abcdefABCDEF' for c in task_identifier): return "参数错误hash 格式无效,请先使用 query_download_tasks 工具获取正确的 hash。"
# 直接使用hash
task_hash = task_identifier
else:
# 通过标题查找任务
downloads = download_chain.downloading(name=downloader)
for dl in downloads:
# 检查标题或名称是否匹配
if (task_identifier.lower() in (dl.title or "").lower()) or \
(task_identifier.lower() in (dl.name or "").lower()):
task_hash = dl.hash
break
if not task_hash:
return f"未找到匹配的下载任务:{task_identifier},请使用 query_downloads 工具查询可用的下载任务"
# 删除下载任务 # 删除下载任务
# remove_torrents 支持 delete_file 参数,可以控制是否删除文件 # 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: if result:
files_info = "(包含文件)" if delete_files else "(不包含文件)" files_info = "(包含文件)" if delete_files else "(不包含文件)"
return f"成功删除下载任务:{task_identifier} {files_info}" return f"成功删除下载任务:{hash} {files_info}"
else: else:
return f"删除下载任务失败:{task_identifier},请检查任务是否存在或下载器是否可用" return f"删除下载任务失败:{hash},请检查任务是否存在或下载器是否可用"
except Exception as e: except Exception as e:
logger.error(f"删除下载任务失败: {e}", exc_info=True) logger.error(f"删除下载任务失败: {e}", exc_info=True)
return f"删除下载任务时发生错误: {str(e)}" return f"删除下载任务时发生错误: {str(e)}"

View File

@@ -30,7 +30,7 @@ class GetRecommendationsInput(BaseModel):
"'douban_tv_animation' for Douban popular animation, " "'douban_tv_animation' for Douban popular animation, "
"'bangumi_calendar' for Bangumi anime calendar") "'bangumi_calendar' for Bangumi anime calendar")
media_type: Optional[str] = Field("all", 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, limit: Optional[int] = Field(20,
description="Maximum number of recommendations to return (default: 20, maximum: 100)") 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: media_type: Optional[str] = "all", limit: Optional[int] = 20, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, limit={limit}") logger.info(f"执行工具: {self.name}, 参数: source={source}, media_type={media_type}, limit={limit}")
try: try:
if media_type not in ["all", "movie", "tv"]:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
recommend_chain = RecommendChain() recommend_chain = RecommendChain()
results = [] results = []
if source == "tmdb_trending": if source == "tmdb_trending":

View File

@@ -1,6 +1,7 @@
"""获取搜索结果工具""" """获取搜索结果工具"""
import json import json
import re
from typing import List, Optional, Type from typing import List, Optional, Type
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -18,17 +19,18 @@ from ._torrent_search_utils import (
class GetSearchResultsInput(BaseModel): class GetSearchResultsInput(BaseModel):
"""获取搜索结果工具的输入参数模型""" """获取搜索结果工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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") site: Optional[List[str]] = Field(None, description="Site name filters")
season: Optional[List[str]] = Field(None, description="Filter by season/episode label, supports multiple values") season: Optional[List[str]] = Field(None, description="Season or episode filters")
free_state: Optional[List[str]] = Field(None, description="Filter by promotion state, supports multiple values") free_state: Optional[List[str]] = Field(None, description="Promotion state filters")
video_code: Optional[List[str]] = Field(None, description="Filter by video codec, supports multiple values") video_code: Optional[List[str]] = Field(None, description="Video codec filters")
edition: Optional[List[str]] = Field(None, description="Filter by edition/quality, supports multiple values") edition: Optional[List[str]] = Field(None, description="Edition filters")
resolution: Optional[List[str]] = Field(None, description="Filter by resolution, supports multiple values") resolution: Optional[List[str]] = Field(None, description="Resolution filters")
release_group: Optional[List[str]] = Field(None, description="Filter by release group, supports multiple values") 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): class GetSearchResultsTool(MoviePilotTool):
name: str = "get_search_results" 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 args_schema: Type[BaseModel] = GetSearchResultsInput
def get_tool_message(self, **kwargs) -> Optional[str]: 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, 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, free_state: Optional[List[str]] = None, video_code: Optional[List[str]] = None,
edition: Optional[List[str]] = None, resolution: 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( 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: try:
items = await SearchChain().async_last_search_results() or [] items = await SearchChain().async_last_search_results() or []
@@ -56,6 +67,12 @@ class GetSearchResultsTool(MoviePilotTool):
resolution=resolution, resolution=resolution,
release_group=release_group, 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: if not filtered_items:
return "没有符合筛选条件的搜索结果,请调整筛选条件" return "没有符合筛选条件的搜索结果,请调整筛选条件"

View File

@@ -24,7 +24,7 @@ class ListDirectoryInput(BaseModel):
class ListDirectoryTool(MoviePilotTool): class ListDirectoryTool(MoviePilotTool):
name: str = "list_directory" 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 args_schema: Type[BaseModel] = ListDirectoryInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:

View File

@@ -6,23 +6,21 @@ from typing import Optional, Type
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool from app.agent.tools.base import MoviePilotTool
from app.chain.media import MediaChain
from app.chain.tmdb import TmdbChain from app.chain.tmdb import TmdbChain
from app.log import logger from app.log import logger
from app.schemas import MediaType
class QueryEpisodeScheduleInput(BaseModel): class QueryEpisodeScheduleInput(BaseModel):
"""查询剧集上映时间工具的输入参数模型""" """查询剧集上映时间工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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") season: int = Field(..., description="Season number to query")
episode_group: Optional[str] = Field(None, description="Episode group ID (optional)") episode_group: Optional[str] = Field(None, description="Episode group ID (optional)")
class QueryEpisodeScheduleTool(MoviePilotTool): class QueryEpisodeScheduleTool(MoviePilotTool):
name: str = "query_episode_schedule" 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 args_schema: Type[BaseModel] = QueryEpisodeScheduleInput
def get_tool_message(self, **kwargs) -> Optional[str]: 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}") logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, season={season}, episode_group={episode_group}")
try: 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() tmdb_chain = TmdbChain()
episodes = await tmdb_chain.async_tmdb_episodes( 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)) episode_list.sort(key=lambda x: (x["air_date"] or "", x["episode_number"] or 0))
result = { result = {
"success": True,
"tmdb_id": tmdb_id,
"season": season, "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), "total_episodes": len(episodes),
"episodes_with_air_date": len(episode_list), "episodes_with_air_date": len(episode_list),
"episodes": episode_list "episodes": episode_list

View File

@@ -7,8 +7,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool from app.agent.tools.base import MoviePilotTool
from app.chain.mediaserver import MediaServerChain 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.log import logger
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -16,67 +14,58 @@ from app.schemas.types import MediaType
class QueryLibraryExistsInput(BaseModel): class QueryLibraryExistsInput(BaseModel):
"""查询媒体库工具的输入参数模型""" """查询媒体库工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
media_type: Optional[str] = Field("all", 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.")
description="Type of media content: '电影' for films, '电视剧' for television series or anime series, 'all' for all types") 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.")
title: Optional[str] = Field(None, media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
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)")
class QueryLibraryExistsTool(MoviePilotTool): class QueryLibraryExistsTool(MoviePilotTool):
name: str = "query_library_exists" 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 args_schema: Type[BaseModel] = QueryLibraryExistsInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息""" """根据查询参数生成友好的提示消息"""
media_type = kwargs.get("media_type", "all") tmdb_id = kwargs.get("tmdb_id")
title = kwargs.get("title") douban_id = kwargs.get("douban_id")
year = kwargs.get("year") media_type = kwargs.get("media_type")
parts = ["正在查询媒体库"] 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
if title: async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
parts.append(f"标题: {title}") media_type: Optional[str] = None, **kwargs) -> str:
if year: logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
parts.append(f"年份: {year}")
if media_type != "all":
parts.append(f"类型: {media_type}")
return " | ".join(parts) if len(parts) > 1 else parts[0]
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}")
try: try:
if not title: if not tmdb_id and not douban_id:
return "请提供媒体标题进行查询" 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() media_chain = MediaServerChain()
mediainfo = media_chain.recognize_media(
# 1. 识别媒体信息(获取 TMDB ID 和各季的总集数等元数据) tmdbid=tmdb_id,
meta = MetaBase(title=title) doubanid=douban_id,
if year: mtype=media_type_enum,
meta.year = str(year) )
if media_type == "电影": if not mediainfo:
meta.type = MediaType.MOVIE media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
elif media_type == "电视剧": return f"未识别到媒体信息: {media_id}"
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
# 2. 调用媒体服务器接口实时查询存在信息 # 2. 调用媒体服务器接口实时查询存在信息
existsinfo = media_chain.media_exists(mediainfo=mediainfo) existsinfo = media_chain.media_exists(mediainfo=mediainfo)

View File

@@ -14,13 +14,13 @@ from app.schemas import MediaType
class QueryMediaDetailInput(BaseModel): class QueryMediaDetailInput(BaseModel):
"""查询媒体详情工具的输入参数模型""" """查询媒体详情工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") 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="Media type: 'movie' or 'tv'") media_type: str = Field(..., description="Allowed values: movie, tv")
class QueryMediaDetailTool(MoviePilotTool): class QueryMediaDetailTool(MoviePilotTool):
name: str = "query_media_detail" 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 args_schema: Type[BaseModel] = QueryMediaDetailInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -34,14 +34,16 @@ class QueryMediaDetailTool(MoviePilotTool):
try: try:
media_chain = MediaChain() media_chain = MediaChain()
mtype = None media_type_key = (media_type or "").strip().lower()
if media_type: if media_type_key not in ["movie", "tv"]:
if media_type.lower() == 'movie': return json.dumps({
mtype = MediaType.MOVIE "success": False,
elif media_type.lower() == 'tv': "message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
mtype = MediaType.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: if not mediainfo:
return json.dumps({ return json.dumps({
@@ -74,12 +76,6 @@ class QueryMediaDetailTool(MoviePilotTool):
# 构建基础媒体详情信息 # 构建基础媒体详情信息
result = { 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, "status": mediainfo.status,
"genres": genres, "genres": genres,
"directors": directors, "directors": directors,

View File

@@ -16,7 +16,7 @@ from app.schemas.types import MediaType
class QueryPopularSubscribesInput(BaseModel): class QueryPopularSubscribesInput(BaseModel):
"""查询热门订阅工具的输入参数模型""" """查询热门订阅工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") 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)") 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)") 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]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息""" """根据查询参数生成友好的提示消息"""
stype = kwargs.get("stype", "") media_type = kwargs.get("media_type", "")
page = kwargs.get("page", 1) page = kwargs.get("page", 1)
min_sub = kwargs.get("min_sub") min_sub = kwargs.get("min_sub")
min_rating = kwargs.get("min_rating") min_rating = kwargs.get("min_rating")
max_rating = kwargs.get("max_rating") max_rating = kwargs.get("max_rating")
parts = [f"正在查询热门订阅 [{stype}]"] parts = [f"正在查询热门订阅 [{media_type}]"]
if min_sub: if min_sub:
parts.append(f"最少订阅: {min_sub}") parts.append(f"最少订阅: {min_sub}")
@@ -52,7 +52,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
return " | ".join(parts) if len(parts) > 1 else parts[0] 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, page: Optional[int] = 1,
count: Optional[int] = 30, count: Optional[int] = 30,
min_sub: Optional[int] = None, min_sub: Optional[int] = None,
@@ -61,7 +61,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
max_rating: Optional[float] = None, max_rating: Optional[float] = None,
sort_type: Optional[str] = None, **kwargs) -> str: sort_type: Optional[str] = None, **kwargs) -> str:
logger.info( 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}") f"genre_id={genre_id}, min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}")
try: try:
@@ -69,10 +69,12 @@ class QueryPopularSubscribesTool(MoviePilotTool):
page = 1 page = 1
if count is None or count < 1: if count is None or count < 1:
count = 30 count = 30
if media_type not in ["movie", "tv"]:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
subscribe_helper = SubscribeHelper() subscribe_helper = SubscribeHelper()
subscribes = await subscribe_helper.async_get_statistic( subscribes = await subscribe_helper.async_get_statistic(
stype=stype, stype=media_type,
page=page, page=page,
count=count, count=count,
genre_id=genre_id, genre_id=genre_id,
@@ -94,7 +96,15 @@ class QueryPopularSubscribesTool(MoviePilotTool):
continue continue
media = MediaInfo() 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") media.tmdb_id = sub.get("tmdbid")
# 处理标题 # 处理标题
title = sub.get("name") title = sub.get("name")

View File

@@ -15,7 +15,7 @@ from app.log import logger
class QuerySiteUserdataInput(BaseModel): class QuerySiteUserdataInput(BaseModel):
"""查询站点用户数据工具的输入参数模型""" """查询站点用户数据工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") workdate: Optional[str] = Field(None, description="Work date to query (optional, format: 'YYYY-MM-DD', if not specified returns latest data)")

View File

@@ -14,7 +14,7 @@ from app.log import logger
class QuerySubscribeHistoryInput(BaseModel): class QuerySubscribeHistoryInput(BaseModel):
"""查询订阅历史工具的输入参数模型""" """查询订阅历史工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") 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}") logger.info(f"执行工具: {self.name}, 参数: media_type={media_type}, name={name}")
try: try:
if media_type not in ["all", "movie", "tv"]:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
# 获取数据库会话 # 获取数据库会话
async with AsyncSessionFactory() as db: async with AsyncSessionFactory() as db:
# 根据类型查询 # 根据类型查询

View File

@@ -16,7 +16,7 @@ class QuerySubscribesInput(BaseModel):
status: Optional[str] = Field("all", status: Optional[str] = Field("all",
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions") description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions")
media_type: Optional[str] = Field("all", 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): 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: 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}") logger.info(f"执行工具: {self.name}, 参数: status={status}, media_type={media_type}")
try: try:
if media_type not in ["all", "movie", "tv"]:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
subscribe_oper = SubscribeOper() subscribe_oper = SubscribeOper()
subscribes = await subscribe_oper.async_list() subscribes = await subscribe_oper.async_list()
filtered_subscribes = [] filtered_subscribes = []

View File

@@ -14,21 +14,21 @@ from app.log import logger
class RunWorkflowInput(BaseModel): class RunWorkflowInput(BaseModel):
"""执行工作流工具的输入参数模型""" """执行工作流工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") 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): class RunWorkflowTool(MoviePilotTool):
name: str = "run_workflow" 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 args_schema: Type[BaseModel] = RunWorkflowInput
def get_tool_message(self, **kwargs) -> Optional[str]: 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) from_begin = kwargs.get("from_begin", True)
message = f"正在执行工作流: {workflow_identifier}" message = f"正在执行工作流: {workflow_id}"
if not from_begin: if not from_begin:
message += " (从上次位置继续)" message += " (从上次位置继续)"
else: else:
@@ -36,27 +36,18 @@ class RunWorkflowTool(MoviePilotTool):
return message return message
async def run(self, workflow_identifier: str, async def run(self, workflow_id: int,
from_begin: Optional[bool] = True, **kwargs) -> str: 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: try:
# 获取数据库会话 # 获取数据库会话
async with AsyncSessionFactory() as db: async with AsyncSessionFactory() as db:
workflow_oper = WorkflowOper(db) workflow_oper = WorkflowOper(db)
workflow = await workflow_oper.async_get(workflow_id)
# 尝试解析为工作流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)
if not workflow: if not workflow:
return f"未找到工作流:{workflow_identifier},请使用 query_workflows 工具查询可用的工作流" return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流"
# 执行工作流 # 执行工作流
workflow_chain = WorkflowChain() workflow_chain = WorkflowChain()

View File

@@ -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')") 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)") year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
media_type: Optional[str] = Field(None, 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, season: Optional[int] = Field(None,
description="Season number for TV shows and anime (optional, only applicable for series)") description="Season number for TV shows and anime (optional, only applicable for series)")
@@ -56,13 +56,22 @@ class SearchMediaTool(MoviePilotTool):
# 过滤结果 # 过滤结果
if results: 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 = [] filtered_results = []
for result in results: for result in results:
if year and result.year != year: if year and result.year != year:
continue continue
if media_type: if media_type_enum and result.type != media_type_enum:
if result.type != MediaType(media_type): continue
continue
if season is not None and result.season != season: if season is not None and result.season != season:
continue continue
filtered_results.append(result) filtered_results.append(result)

View File

@@ -15,10 +15,10 @@ from app.log import logger
class SearchSubscribeInput(BaseModel): class SearchSubscribeInput(BaseModel):
"""搜索订阅缺失剧集工具的输入参数模型""" """搜索订阅缺失剧集工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") manual: Optional[bool] = Field(False, description="Whether this is a manual search (default: False)")
filter_groups: Optional[List[str]] = Field(None, 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): class SearchSubscribeTool(MoviePilotTool):

View File

@@ -1,15 +1,16 @@
"""搜索种子工具""" """搜索种子工具"""
import json import json
import re
from typing import List, Optional, Type from typing import List, Optional, Type
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool from app.agent.tools.base import MoviePilotTool
from app.chain.search import SearchChain 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.log import logger
from app.schemas.types import MediaType from app.schemas.types import MediaType, SystemConfigKey
from ._torrent_search_utils import ( from ._torrent_search_utils import (
SEARCH_RESULT_CACHE_FILE, SEARCH_RESULT_CACHE_FILE,
build_filter_options, build_filter_options,
@@ -19,87 +20,94 @@ from ._torrent_search_utils import (
class SearchTorrentsInput(BaseModel): class SearchTorrentsInput(BaseModel):
"""搜索种子工具的输入参数模型""" """搜索种子工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
title: str = Field(..., 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.")
description="The title of the media resource to search for (e.g., 'The Matrix 1999', 'Breaking Bad S01E01')") 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.")
year: Optional[str] = Field(None, media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
description="Release year of the media (optional, helps narrow down search results)") area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'")
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)")
sites: Optional[List[int]] = Field(None, sites: Optional[List[int]] = Field(None,
description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)") 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): class SearchTorrentsTool(MoviePilotTool):
name: str = "search_torrents" 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 args_schema: Type[BaseModel] = SearchTorrentsInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据搜索参数生成友好的提示消息""" """根据搜索参数生成友好的提示消息"""
title = kwargs.get("title", "") tmdb_id = kwargs.get("tmdb_id")
year = kwargs.get("year") douban_id = kwargs.get("douban_id")
media_type = kwargs.get("media_type") media_type = kwargs.get("media_type")
season = kwargs.get("season")
filter_pattern = kwargs.get("filter_pattern")
message = f"正在搜索种子: {title}" if tmdb_id:
if year: message = f"正在搜索种子: TMDB={tmdb_id}"
message += f" ({year})" elif douban_id:
message = f"正在搜索种子: 豆瓣={douban_id}"
else:
message = "正在搜索种子"
if media_type: if media_type:
message += f" [{media_type}]" message += f" [{media_type}]"
if season:
message += f"{season}"
if filter_pattern:
message += f" 过滤: {filter_pattern}"
return message return message
async def run(self, title: str, year: Optional[str] = None, async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
media_type: Optional[str] = None, season: Optional[int] = None, media_type: Optional[str] = None, area: Optional[str] = None,
sites: Optional[List[int]] = None, filter_pattern: Optional[str] = None, **kwargs) -> str: sites: Optional[List[int]] = None, **kwargs) -> str:
logger.info( 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: try:
search_chain = SearchChain() search_chain = SearchChain()
torrents = await search_chain.async_search_by_title(title=title, sites=sites) media_type_enum = None
filtered_torrents = [] if media_type:
# 编译正则表达式(如果提供) media_type_key = media_type.strip().lower()
regex_pattern = None if media_type_key == "movie":
if filter_pattern: media_type_enum = MediaType.MOVIE
try: elif media_type_key == "tv":
regex_pattern = re.compile(filter_pattern, re.IGNORECASE) media_type_enum = MediaType.TV
except re.error as e: else:
logger.warning(f"正则表达式编译失败: {filter_pattern}, 错误: {e}") return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
return f"正则表达式格式错误: {str(e)}"
for torrent in torrents: filtered_torrents = await search_chain.async_search_by_id(
# torrent 是 Context 对象,需要通过 meta_info 和 media_info 访问属性 tmdbid=tmdb_id,
if year and torrent.meta_info and torrent.meta_info.year != year: doubanid=douban_id,
continue mtype=media_type_enum,
if media_type and torrent.meta_info and torrent.meta_info.type: area=area or "title",
if torrent.meta_info.type != MediaType(media_type): sites=sites,
continue cache_local=False,
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: all_indexers = await SitesHelper().async_get_indexers()
if not regex_pattern.search(torrent.torrent_info.title): all_sites = [{"id": indexer.get("id"), "name": indexer.get("name")} for indexer in (all_indexers or [])]
continue
filtered_torrents.append(torrent) 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: if filtered_torrents:
await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE) await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE)
result_json = json.dumps({ result_json = json.dumps({
"total_count": len(filtered_torrents), "total_count": len(filtered_torrents),
"message": "搜索完成。请使用 get_search_results 工具获取搜索结果。", "message": "搜索完成。请使用 get_search_results 工具获取搜索结果。",
"all_sites": all_sites,
"search_site_ids": search_site_ids,
"filter_options": build_filter_options(filtered_torrents), "filter_options": build_filter_options(filtered_torrents),
}, ensure_ascii=False, indent=2) }, ensure_ascii=False, indent=2)
return result_json return result_json
else: 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: except Exception as e:
error_message = f"搜索种子时发生错误: {str(e)}" error_message = f"搜索种子时发生错误: {str(e)}"
logger.error(f"搜索种子失败: {e}", exc_info=True) logger.error(f"搜索种子失败: {e}", exc_info=True)

View File

@@ -8,53 +8,31 @@ from app.agent.tools.base import MoviePilotTool
from app.chain.site import SiteChain from app.chain.site import SiteChain
from app.db.site_oper import SiteOper from app.db.site_oper import SiteOper
from app.log import logger from app.log import logger
from app.utils.string import StringUtils
class TestSiteInput(BaseModel): class TestSiteInput(BaseModel):
"""测试站点连通性工具的输入参数模型""" """测试站点连通性工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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): class TestSiteTool(MoviePilotTool):
name: str = "test_site" 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 args_schema: Type[BaseModel] = TestSiteInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据测试参数生成友好的提示消息""" """根据测试参数生成友好的提示消息"""
site_identifier = kwargs.get("site_identifier", "") site_identifier = kwargs.get("site_identifier")
return f"正在测试站点连通性: {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}") logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}")
try: try:
site_oper = SiteOper() site_oper = SiteOper()
site_chain = SiteChain() site_chain = SiteChain()
site = await site_oper.async_get(site_identifier)
# 尝试解析为站点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)
if not site: if not site:
return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点" return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"

View File

@@ -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.)") 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_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)") 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)") 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)") doubanid: Optional[str] = Field(None, description="Douban ID for media identification (optional)")
season: Optional[int] = Field(None, description="Season number for TV shows (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) target_path_obj = Path(target_path)
# 处理媒体类型 # 处理媒体类型
mtype = None media_type_enum = None
if media_type: if media_type:
try: media_type_key = media_type.strip().lower()
mtype = MediaType(media_type) if media_type_key == "movie":
except ValueError: media_type_enum = MediaType.MOVIE
elif media_type_key == "tv":
media_type_enum = MediaType.TV
else:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
# 调用整理方法 # 调用整理方法
@@ -106,7 +109,7 @@ class TransferFileTool(MoviePilotTool):
target_path=target_path_obj, target_path=target_path_obj,
tmdbid=tmdbid, tmdbid=tmdbid,
doubanid=doubanid, doubanid=doubanid,
mtype=mtype, mtype=media_type_enum,
season=season, season=season,
transfer_type=transfer_type, transfer_type=transfer_type,
background=background background=background

View File

@@ -17,7 +17,7 @@ from app.utils.string import StringUtils
class UpdateSiteInput(BaseModel): class UpdateSiteInput(BaseModel):
"""更新站点工具的输入参数模型""" """更新站点工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") name: Optional[str] = Field(None, description="Site name (optional)")
url: Optional[str] = Field(None, description="Site URL (optional, will be automatically formatted)") 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)") pri: Optional[int] = Field(None, description="Site priority (optional, smaller value = higher priority, e.g., pri=1 has higher priority than pri=10)")

View File

@@ -8,13 +8,12 @@ from app.agent.tools.base import MoviePilotTool
from app.chain.site import SiteChain from app.chain.site import SiteChain
from app.db.site_oper import SiteOper from app.db.site_oper import SiteOper
from app.log import logger from app.log import logger
from app.utils.string import StringUtils
class UpdateSiteCookieInput(BaseModel): class UpdateSiteCookieInput(BaseModel):
"""更新站点Cookie和UA工具的输入参数模型""" """更新站点Cookie和UA工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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") username: str = Field(..., description="Site login username")
password: str = Field(..., description="Site login password") 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)") 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): class UpdateSiteCookieTool(MoviePilotTool):
name: str = "update_site_cookie" 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 args_schema: Type[BaseModel] = UpdateSiteCookieInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据更新参数生成友好的提示消息""" """根据更新参数生成友好的提示消息"""
site_identifier = kwargs.get("site_identifier", "") site_identifier = kwargs.get("site_identifier")
username = kwargs.get("username", "") username = kwargs.get("username", "")
two_step_code = kwargs.get("two_step_code") two_step_code = kwargs.get("two_step_code")
@@ -37,35 +36,14 @@ class UpdateSiteCookieTool(MoviePilotTool):
return message 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: two_step_code: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}, username={username}") logger.info(f"执行工具: {self.name}, 参数: site_identifier={site_identifier}, username={username}")
try: try:
site_oper = SiteOper() site_oper = SiteOper()
site_chain = SiteChain() site_chain = SiteChain()
site = await site_oper.async_get(site_identifier)
# 尝试解析为站点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)
if not site: if not site:
return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点" return f"未找到站点:{site_identifier},请使用 query_sites 工具查询可用的站点"

View File

@@ -16,7 +16,7 @@ from app.schemas.types import EventType
class UpdateSubscribeInput(BaseModel): class UpdateSubscribeInput(BaseModel):
"""更新订阅工具的输入参数模型""" """更新订阅工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context") 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)") name: Optional[str] = Field(None, description="Subscription name/title (optional)")
year: Optional[str] = Field(None, description="Release year (optional)") year: Optional[str] = Field(None, description="Release year (optional)")
season: Optional[int] = Field(None, description="Season number for TV shows (optional)") season: Optional[int] = Field(None, description="Season number for TV shows (optional)")

View File

@@ -123,7 +123,7 @@ MoviePilot 实现了标准的 **Model Context Protocol (MCP)**,允许 AI 智
"arguments": { "arguments": {
"title": "流浪地球", "title": "流浪地球",
"year": "2019", "year": "2019",
"media_type": "电影" "media_type": "movie"
} }
} }
``` ```

View File

@@ -1,79 +1,119 @@
--- ---
name: moviepilot-cli 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. ## Discover Commands
## 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 <command> show command details and usage example
<command> [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 ```bash
node scripts/mp-cli.js list node scripts/mp-cli.js list # list all available commands
node scripts/mp-cli.js show <command> # show parameters, required fields, and usage
``` ```
**Inspect a command's parameters:** Always run `show <command>` 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 <command>` 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 ```bash
node scripts/mp-cli.js show <command> # 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: # 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
```
1. list → confirm which commands are available
2. show <command> → 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 ### 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. ```bash
2. Present torrent details to the user (size, seeders, quality, release group). node scripts/mp-cli.js query_media_detail tmdb_id=<id> media_type="tv"
3. **Wait for explicit user confirmation** before initiating the download. ```
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=<id> season=<latest_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 Handling
| Error | Resolution | | Error | Resolution |
| --------------------- | --------------------------------------------------------------------------- | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No search results | Try fuzzy recognition → web search → ask the user | | 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; advise user to verify disk space | | 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 | Prompt user to run `node scripts/mp-cli.js -h <HOST> -k <KEY>` to configure | | Missing configuration | Ask the user for the backend host and API key. Once provided, run `node scripts/mp-cli.js -h <HOST> -k <KEY>` (no command) to save the config persistently — subsequent commands will use it automatically. |

View File

@@ -129,6 +129,26 @@ function normalizeItemType(schema = {}) {
return null; 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) { function request(method, targetUrl, headers = {}, body) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let url; let url;
@@ -192,31 +212,38 @@ async function loadCommandsJson() {
} }
commandsJson = Array.isArray(response) commandsJson = Array.isArray(response)
? response ? response.map((tool) => normalizeCommand(tool))
.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; 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() { function ensureConfig() {
loadConfig(); loadConfig();
let ok = true; let ok = true;
@@ -246,69 +273,76 @@ function printValue(value) {
return; 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 "'<value1>,<value2>'";
}
return '<value>';
} }
async function cmdList() { async function cmdList() {
await loadCommandsJson(); await loadCommandsJson();
for (const command of commandsJson) { const sortedCommands = [...commandsJson].sort((left, right) => left.name.localeCompare(right.name));
process.stdout.write(`- ${command.name}${spacePad(command.name)}${command.description}\n`); for (const command of sortedCommands) {
process.stdout.write(`${command.name}\n`);
} }
} }
async function cmdShow(commandName) { async function cmdShow(commandName) {
await loadCommandsJson();
if (!commandName) { if (!commandName) {
fail(`Usage: ${SCRIPT_NAME} show <command>`); fail(`Usage: ${SCRIPT_NAME} show <command>`);
} }
const command = commandsJson.find((item) => item.name === commandName); const command = await loadCommandJson(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 commandLabel = 'Command:';
const descriptionLabel = 'Description:';
const paramsLabel = 'Parameters:'; const paramsLabel = 'Parameters:';
const usageLabel = 'Usage example:'; const usageLabel = 'Usage:';
const detailLabelWidth = Math.max(commandLabel.length, paramsLabel.length, usageLabel.length); 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) { if (command.fields.length === 0) {
process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\n`); process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\n`);
} else { } else {
const fieldLines = command.fields.map((field) => [ const fieldLines = command.fields.map((field) => [
field.name, field.required ? `${field.name}*` : field.name,
field.type, field.type,
field.required ? '[required]' : '[optional]',
field.description, field.description,
]); ]);
const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0); const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0);
const typeWidth = Math.max(...fieldLines.map(([, type]) => type.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`); process.stdout.write(`${paramsLabel}\n`);
for (const [fieldName, fieldType, fieldRequired, fieldDesc] of fieldLines) { for (const [fieldName, fieldType, fieldDesc] of fieldLines) {
process.stdout.write( 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 usageLine = `${command.name}`;
const reqPart = command.fields.filter((field) => field.required).map((field) => ` ${field.name}=<value>`).join(''); const reqPart = command.fields
.filter((field) => field.required)
.map((field) => ` ${field.name}=${formatUsageValue(field)}`)
.join('');
const optPart = command.fields const optPart = command.fields
.filter((field) => !field.required) .filter((field) => !field.required)
.map((field) => ` [${field.name}=<value>]`) .map((field) => ` [${field.name}=${formatUsageValue(field)}]`)
.join(''); .join('');
process.stdout.write( process.stdout.write(`\n${usageLabel} ${usageLine}${reqPart}${optPart}\n`);
`\n${usageLabel}${spacePad(usageLabel, detailLabelWidth)}${usageLine}${reqPart}${optPart}\n`
);
} }
function parseBoolean(value) { function parseBoolean(value) {
@@ -420,7 +454,7 @@ async function cmdRun(commandName, pairs) {
const command = commandsJson.find((item) => item.name === commandName); const command = commandsJson.find((item) => item.name === commandName);
if (!command) { if (!command) {
console.error(`Error: command '${commandName}' not found`); 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); process.exit(1);
} }
@@ -446,12 +480,17 @@ async function cmdRun(commandName, pairs) {
try { try {
const parsed = JSON.parse(body); 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 (Object.prototype.hasOwnProperty.call(parsed, 'result')) {
if (typeof parsed.result === 'string') { if (typeof parsed.result === 'string') {
try { try {
printValue(JSON.parse(parsed.result)); printValue(JSON.parse(parsed.result));
} catch { } catch {
printValue(parsed.result); printValue({ result: parsed.result });
} }
} else { } else {
printValue(parsed.result); printValue(parsed.result);