Compare commits

..

9 Commits
v2.13.8 ... v2

Author SHA1 Message Date
jxxghp
93713ba662 feat: sync plugin markets from wiki 2026-06-14 12:57:40 +08:00
jxxghp
0f3e9574ab Configure subagent profiles from runtime files 2026-06-14 10:27:26 +08:00
nazoko
25dbe491fe fix(jellyfin): 修复播放通知封面缺失问题 (#5938) 2026-06-14 06:24:42 +08:00
jxxghp
d6db0a86f6 fix llm test message 2026-06-13 23:20:15 +08:00
jxxghp
6e8bce3d04 fix: 优化LLM测试基础地址错误提示 2026-06-13 23:02:21 +08:00
jxxghp
ed1e31d379 fix: 兼容插件仪表盘空返回 2026-06-13 22:54:35 +08:00
jxxghp
3a233014de docs: update moviepilot plugin creation skill 2026-06-13 22:39:18 +08:00
InfinityPacer
13cb1683ff fix: restrict log access and add zip download (#5936) 2026-06-13 20:20:10 +08:00
jxxghp
ac9132cba6 fix bcrypt 2026-06-13 20:16:04 +08:00
25 changed files with 1397 additions and 233 deletions

View File

@@ -0,0 +1,20 @@
---
version: 1
subagent_id: download-diagnostician
label: 下载诊断
description: Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.
include_tags:
- download
- transfer
- library
- directory
- file
- media
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state.

View File

@@ -0,0 +1,35 @@
---
version: 1
subagent_id: general-purpose
label: 通用调查
description: General read-only investigation subagent for cross-domain MoviePilot analysis and execution recommendations.
include_tags:
- media
- resource
- site
- subscription
- download
- library
- transfer
- system
- settings
- plugin
- workflow
- scheduler
- file
- directory
- web
- command
- filter_rule
- persona
- slash_command
- recommendation
- metadata
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in synthesizing media, site, subscription, download, and system status signals.

View File

@@ -0,0 +1,19 @@
---
version: 1
subagent_id: media-researcher
label: 媒体研究
description: Media research subagent for title recognition, people, episodes, metadata, and library existence checks.
include_tags:
- media
- library
- recommendation
- metadata
- web
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in media identity resolution, metadata validation, person credits, and library status analysis.

View File

@@ -0,0 +1,19 @@
---
version: 1
subagent_id: moviepilot-explorer
label: 代码探索
description: MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.
include_tags:
- system
- settings
- file
- directory
- command
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state.

View File

@@ -0,0 +1,18 @@
---
version: 1
subagent_id: resource-searcher
label: 资源搜索
description: Site and resource search subagent for site checks, torrent search, and resource quality analysis.
include_tags:
- resource
- site
- web
- media
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in site status, site user data, torrent search results, and resource quality judgment.

View File

@@ -0,0 +1,18 @@
---
version: 1
subagent_id: subscription-analyst
label: 订阅分析
description: Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.
include_tags:
- subscription
- filter_rule
- settings
- media
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions.

View File

@@ -0,0 +1,25 @@
---
version: 1
subagent_id: system-diagnostician
label: 系统诊断
description: System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.
include_tags:
- system
- settings
- plugin
- workflow
- scheduler
- file
- directory
- web
- command
- persona
- slash_command
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics.

View File

@@ -24,6 +24,7 @@ from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.runtime import SubAgentDefinition, agent_runtime_manager
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -87,7 +88,7 @@ Requirements:
@dataclass(frozen=True)
class _SubAgentProfile:
"""内置子代理定义。"""
"""子代理运行时定义。"""
name: str
description: str
@@ -197,40 +198,15 @@ def builtin_subagent_names() -> frozenset[str]:
@lru_cache(maxsize=1)
def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
"""构建 MoviePilot 默认内置子代理定义。"""
default_exclude_tags = frozenset(
{
ToolTag.Write.value,
ToolTag.Message.value,
ToolTag.UserInteraction.value,
}
"""从运行时配置目录加载 MoviePilot 子代理定义。"""
definitions = agent_runtime_manager.list_subagents()
profiles = tuple(
_profile_from_runtime_definition(definition)
for definition in definitions
)
general_tags = frozenset(
{
ToolTag.Media.value,
ToolTag.Resource.value,
ToolTag.Site.value,
ToolTag.Subscription.value,
ToolTag.Download.value,
ToolTag.Library.value,
ToolTag.Transfer.value,
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.Plugin.value,
ToolTag.Workflow.value,
ToolTag.Scheduler.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Web.value,
ToolTag.Command.value,
ToolTag.FilterRule.value,
ToolTag.Persona.value,
ToolTag.SlashCommand.value,
ToolTag.Recommendation.value,
ToolTag.Metadata.value,
}
)
if profiles:
return profiles
logger.warning("未加载到任何子代理定义,使用通用兜底子代理。")
return (
_SubAgentProfile(
name="general-purpose",
@@ -239,126 +215,34 @@ def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in synthesizing media, site, subscription, download, and system status signals."
),
include_tags=general_tags,
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="media-researcher",
description="Media research subagent for title recognition, people, episodes, metadata, and library existence checks.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in media identity resolution, metadata validation, person credits, and library status analysis."
),
include_tags=frozenset(
include_tags=frozenset(tag.value for tag in ToolTag),
exclude_tags=frozenset(
{
ToolTag.Media.value,
ToolTag.Library.value,
ToolTag.Recommendation.value,
ToolTag.Metadata.value,
ToolTag.Web.value,
ToolTag.Write.value,
ToolTag.Message.value,
ToolTag.UserInteraction.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="moviepilot-explorer",
description="MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. "
"Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state."
),
include_tags=frozenset(
{
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Command.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="resource-searcher",
description="Site and resource search subagent for site checks, torrent search, and resource quality analysis.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in site status, site user data, torrent search results, and resource quality judgment."
),
include_tags=frozenset(
{
ToolTag.Resource.value,
ToolTag.Site.value,
ToolTag.Web.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="subscription-analyst",
description="Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions."
),
include_tags=frozenset(
{
ToolTag.Subscription.value,
ToolTag.FilterRule.value,
ToolTag.Settings.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="system-diagnostician",
description="System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics."
),
include_tags=frozenset(
{
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.Plugin.value,
ToolTag.Workflow.value,
ToolTag.Scheduler.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Web.value,
ToolTag.Command.value,
ToolTag.Persona.value,
ToolTag.SlashCommand.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="download-diagnostician",
description="Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state."
),
include_tags=frozenset(
{
ToolTag.Download.value,
ToolTag.Transfer.value,
ToolTag.Library.value,
ToolTag.Directory.value,
ToolTag.File.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
)
def _profile_from_runtime_definition(
definition: SubAgentDefinition,
) -> _SubAgentProfile:
"""把运行时子代理定义转换为中间件可用的 profile。"""
prompt_parts = [SUBAGENT_BASE_PROMPT]
if definition.text.strip():
prompt_parts.append(definition.text.strip())
return _SubAgentProfile(
name=definition.subagent_id,
description=definition.description,
prompt="\n".join(prompt_parts),
include_tags=frozenset(definition.include_tags),
exclude_tags=frozenset(definition.exclude_tags),
)
def _tool_tag_values(tool: BaseTool) -> set[str]:
"""读取工具实例上的标签集合。"""
tags = getattr(tool, "tags", None) or []
@@ -1055,6 +939,8 @@ def create_subagent_middlewares(
stream_handler: Any = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
_builtin_subagent_profiles.cache_clear()
builtin_subagent_names.cache_clear()
profiles = _builtin_subagent_profiles()
subagent_middleware = _try_create_deepagents_middleware(
profiles=profiles,

View File

@@ -22,8 +22,11 @@ JOBS_DIR = "jobs"
ACTIVITY_DIR = "activity"
PERSONAS_DIR = "personas"
PERSONA_FILE = "PERSONA.md"
SUBAGENTS_DIR = "subagents"
SUBAGENT_FILE = "SUBAGENT.md"
CURRENT_PERSONA_SCHEMA_VERSION = 3
PERSONA_SCHEMA_VERSION = 1
SUBAGENT_SCHEMA_VERSION = 1
DEFAULT_PERSONA_ID = "default"
PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -111,6 +114,41 @@ class PersonaDefinition:
}
@dataclass
class SubAgentDefinition:
"""单个子代理定义。"""
subagent_id: str
path: Path
description: str
text: str
include_tags: list[str]
exclude_tags: list[str]
version: int = SUBAGENT_SCHEMA_VERSION
label: str = ""
def summary_line(self) -> str:
"""渲染可读的一行子代理摘要。"""
parts = [f"`{self.subagent_id}`"]
if self.label and self.label != self.subagent_id:
parts.append(self.label)
if self.description:
parts.append(self.description)
return " - ".join(parts)
def to_dict(self) -> dict[str, Any]:
"""输出给查询或调试入口的结构化信息。"""
return {
"subagent_id": self.subagent_id,
"label": self.label,
"description": self.description,
"include_tags": self.include_tags,
"exclude_tags": self.exclude_tags,
"version": self.version,
"path": str(self.path),
}
@dataclass
class AgentRuntimeConfig:
"""一次加载后的根层配置快照。"""
@@ -120,6 +158,7 @@ class AgentRuntimeConfig:
current_persona_path: Path
persona: PersonaDefinition
available_personas: list[PersonaDefinition]
available_subagents: list[SubAgentDefinition]
extra_context_paths: list[Path]
extra_contexts: list[tuple[Path, str]]
warnings: list[str] = field(default_factory=list)
@@ -135,6 +174,12 @@ class AgentRuntimeConfig:
if self.available_personas:
sections.append("- Available personas:")
sections.extend(f" - {persona.summary_line()}" for persona in self.available_personas)
if self.available_subagents:
sections.append("- Available subagents:")
sections.extend(
f" - {subagent.summary_line()}"
for subagent in self.available_subagents
)
sections.append("</agent_runtime_config>")
if self.warnings:
@@ -201,6 +246,7 @@ class AgentRuntimeManager:
self.skills_dir = self.agent_root_dir / SKILLS_DIR
self.jobs_dir = self.agent_root_dir / JOBS_DIR
self.activity_dir = self.agent_root_dir / ACTIVITY_DIR
self.subagents_dir = self.runtime_dir / SUBAGENTS_DIR
self.bundled_defaults_dir = bundled_defaults_dir or (
Path(__file__).parent / "defaults"
)
@@ -216,6 +262,7 @@ class AgentRuntimeManager:
self.skills_dir.mkdir(parents=True, exist_ok=True)
self.jobs_dir.mkdir(parents=True, exist_ok=True)
self.activity_dir.mkdir(parents=True, exist_ok=True)
self.subagents_dir.mkdir(parents=True, exist_ok=True)
self._migrate_root_runtime_files()
self._remove_obsolete_runtime_files()
self._sync_bundled_defaults()
@@ -278,6 +325,10 @@ class AgentRuntimeManager:
"""列出当前可用人格。"""
return self.load_runtime_config().available_personas
def list_subagents(self) -> list[SubAgentDefinition]:
"""列出当前可用子代理。"""
return self.load_runtime_config().available_subagents
def update_persona_definition(
self,
persona_query: str,
@@ -382,7 +433,7 @@ class AgentRuntimeManager:
return tuple(entries)
def _sync_bundled_defaults(self) -> None:
"""仅复制缺失的默认运行时文件,避免覆盖用户自定义。"""
"""同步默认运行时文件,并按版本更新内置子代理定义。"""
if not self.bundled_defaults_dir.exists():
return
for path in sorted(self.bundled_defaults_dir.rglob("*")):
@@ -392,11 +443,43 @@ class AgentRuntimeManager:
target.mkdir(parents=True, exist_ok=True)
continue
if target.exists():
if self._should_update_bundled_subagent(relative, path, target):
shutil.copy2(path, target)
logger.info(f"已更新默认 Agent 子代理定义: {target}")
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
logger.info("已同步默认 Agent 运行时文件: %s", target)
@classmethod
def _should_update_bundled_subagent(
cls,
relative_path: Path,
source_path: Path,
target_path: Path,
) -> bool:
"""判断是否需要用更高版本的内置子代理定义覆盖用户目录副本。"""
parts = relative_path.parts
if len(parts) < 3 or parts[0] != SUBAGENTS_DIR or relative_path.name != SUBAGENT_FILE:
return False
source_version = cls._read_markdown_version(source_path)
target_version = cls._read_markdown_version(target_path)
return source_version > target_version
@staticmethod
def _read_markdown_version(path: Path) -> int:
"""读取 Markdown frontmatter 中的整数版本,失败时按 0 处理。"""
try:
document = AgentRuntimeManager._read_markdown(path)
except AgentRuntimeConfigError as err:
logger.warning(f"读取 Agent 运行时文件版本失败 {path}: {err}")
return 0
return AgentRuntimeManager._coerce_int_metadata(
document.metadata.get("version"),
default=0,
)
def _migrate_root_runtime_files(self) -> None:
"""兼容早期直接放在 `config/agent` 根目录的 CURRENT_PERSONA。"""
source = self.agent_root_dir / CURRENT_PERSONA_FILE
@@ -451,6 +534,7 @@ class AgentRuntimeManager:
available_personas = self._load_personas(root)
persona = self._resolve_persona_definition(active_persona, available_personas)
available_subagents = self._load_subagents(root)
extra_contexts = [
(path, self._read_markdown(path).body)
for path in extra_context_paths
@@ -468,6 +552,7 @@ class AgentRuntimeManager:
current_persona_path=current_persona_path,
persona=persona,
available_personas=available_personas,
available_subagents=available_subagents,
extra_context_paths=extra_context_paths,
extra_contexts=extra_contexts,
warnings=warnings,
@@ -513,6 +598,71 @@ class AgentRuntimeManager:
raise AgentRuntimeConfigError(f"{personas_root} 中未找到任何人格定义")
return personas
def _load_subagents(self, root: Path) -> list[SubAgentDefinition]:
"""扫描并解析所有可用子代理。"""
subagents_root = root / SUBAGENTS_DIR
if not subagents_root.exists():
raise AgentRuntimeConfigError(f"缺少 subagents 目录: {subagents_root}")
subagents: list[SubAgentDefinition] = []
seen_ids: set[str] = set()
for subagent_dir in sorted(subagents_root.iterdir()):
if not subagent_dir.is_dir():
continue
subagent_path = subagent_dir / SUBAGENT_FILE
if not subagent_path.exists():
continue
document = self._read_markdown(subagent_path)
subagent_id = str(
document.metadata.get("subagent_id") or subagent_dir.name
).strip()
if not subagent_id:
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 subagent_id")
if not PERSONA_ID_PATTERN.fullmatch(subagent_id):
raise AgentRuntimeConfigError(
f"{subagent_path} 的 subagent_id 只能使用小写字母、数字、下划线和中划线,且必须以字母或数字开头"
)
if subagent_id in seen_ids:
raise AgentRuntimeConfigError(f"检测到重复的子代理 ID: {subagent_id}")
seen_ids.add(subagent_id)
description = str(document.metadata.get("description") or "").strip()
if not description:
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 description")
include_tags = self._normalize_string_list(
document.metadata.get("include_tags"),
f"{subagent_path}.include_tags",
)
if not include_tags:
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 include_tags")
exclude_tags = self._normalize_string_list(
document.metadata.get("exclude_tags"),
f"{subagent_path}.exclude_tags",
)
text = self._normalize_subagent_body(document.body)
if not text:
raise AgentRuntimeConfigError(f"{subagent_path} 子代理正文不能为空")
subagents.append(
SubAgentDefinition(
subagent_id=subagent_id,
path=subagent_path,
label=str(document.metadata.get("label") or subagent_id).strip(),
description=description,
text=text,
include_tags=include_tags,
exclude_tags=exclude_tags,
version=self._coerce_int_metadata(
document.metadata.get("version"),
default=SUBAGENT_SCHEMA_VERSION,
),
)
)
if not subagents:
raise AgentRuntimeConfigError(f"{subagents_root} 中未找到任何子代理定义")
return subagents
@staticmethod
def _resolve_persona_definition(
persona_query: str,
@@ -653,6 +803,27 @@ class AgentRuntimeManager:
return remainder.strip()
return normalized
@staticmethod
def _normalize_subagent_body(body: Optional[str]) -> str:
"""去掉重复的 SUBAGENT 标题,保持正文可安全加载。"""
normalized = (body or "").strip()
if not normalized:
return ""
if normalized.startswith("# SUBAGENT"):
_, _, remainder = normalized.partition("\n")
return remainder.strip()
return normalized
@staticmethod
def _coerce_int_metadata(value: Any, *, default: int = 0) -> int:
"""将 frontmatter 中的整数型元数据规范化。"""
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _validate_runtime_config(
self,
*,

View File

@@ -53,7 +53,7 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
清理错误信息中的敏感字段,避免回显密钥。
"""
if not message:
return "LLM 调用失败"
return "LLM 没有返回任何内容"
sanitized = message
if api_key:
@@ -68,6 +68,14 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
"Authorization: ***",
sanitized,
)
normalized_message = sanitized.lower().replace("_", "").replace(" ", "")
if "str" in normalized_message and "modeldump" in normalized_message:
return (
"服务返回内容不是兼容的模型响应,"
"请检查基础地址是否填写为 API Base URL不要填写网页地址或完整的 "
"chat/completions 路径"
)
return sanitized

View File

@@ -513,7 +513,7 @@ def plugin_dashboard(
plugin_id: str,
user_agent: Annotated[str | None, Header()] = None,
_: User = Depends(get_current_active_superuser),
) -> schemas.PluginDashboard:
) -> Optional[schemas.PluginDashboard]:
"""
根据插件ID获取插件仪表板
"""

View File

@@ -1,7 +1,11 @@
import asyncio
import io
import json
import re
import zipfile
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Union, Annotated
from urllib.parse import urljoin, urlparse
@@ -62,6 +66,100 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
)
}
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
_LOG_DOWNLOAD_LIMIT = 10
_LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
_PLUGIN_MARKET_WIKI_START = "<!-- plugin-market-repos:start -->"
_PLUGIN_MARKET_WIKI_END = "<!-- plugin-market-repos:end -->"
_PLUGIN_MARKET_WIKI_URL = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
_PLUGIN_MARKET_REPO_PATTERN = re.compile(
r"https?://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?",
re.IGNORECASE,
)
def _normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
"""
规范化插件仓库地址,便于跨来源合并去重。
"""
repo_url = (repo_url or "").strip().rstrip("/")
if not repo_url:
return None
repo_url = repo_url.removesuffix(".git")
parsed_url = urlparse(repo_url)
if parsed_url.scheme not in {"http", "https"}:
return None
if (parsed_url.hostname or "").lower() != "github.com":
return None
paths = [item for item in parsed_url.path.split("/") if item]
if len(paths) < 2:
return None
return f"https://github.com/{paths[0]}/{paths[1]}"
def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
"""
校验插件市场 Wiki 地址是否属于固定文档源。
"""
parsed_url = urlparse(wiki_url)
if parsed_url.scheme != "https":
return False
if (parsed_url.hostname or "").lower() != "raw.githubusercontent.com":
return False
return bool(
re.fullmatch(
r"/jxxghp/MoviePilot-Wiki/[^/]+/plugin\.md",
parsed_url.path,
)
)
def _split_plugin_market_repo_urls(value: Optional[str]) -> list[str]:
"""
拆分插件市场仓库配置并保持原有顺序去重。
"""
repos: list[str] = []
seen_repos = set()
for item in re.split(r"[\n,]+", value or ""):
normalized_repo = _normalize_plugin_market_repo_url(item)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return repos
def _extract_plugin_market_repos_from_wiki(markdown: str) -> list[str]:
"""
从 Wiki 插件文档中提取插件仓库地址。
"""
content = markdown or ""
if _PLUGIN_MARKET_WIKI_START in content and _PLUGIN_MARKET_WIKI_END in content:
content = content.split(_PLUGIN_MARKET_WIKI_START, 1)[1].split(_PLUGIN_MARKET_WIKI_END, 1)[0]
repos: list[str] = []
seen_repos = set()
for item in _PLUGIN_MARKET_REPO_PATTERN.findall(content):
normalized_repo = _normalize_plugin_market_repo_url(item)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return repos
def _merge_plugin_market_repos(local_repos: list[str], wiki_repos: list[str]) -> list[str]:
"""
合并本地与 Wiki 插件仓库地址,保留本地顺序并追加 Wiki 新地址。
"""
merged_repos: list[str] = []
seen_repos = set()
for repo in local_repos + wiki_repos:
normalized_repo = _normalize_plugin_market_repo_url(repo)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
merged_repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return merged_repos
def _match_nettest_prefix(url: str, prefix: str) -> bool:
@@ -285,6 +383,98 @@ def _build_nettest_rules() -> list[dict[str, Any]]:
return rules
def _collect_named_log_files(name: str) -> list[Path]:
"""
根据前端传入的日志标识收集可下载日志文件。
`moviepilot` 固定表示主程序日志,其余标识按插件 ID 处理并映射到
`plugins/<plugin_id>.log*`。这里不接收路径或后缀,避免下载入口变成任意
日志文件选择器;滚动日志按当前文件优先、备份文件按修改时间倒序补足。
"""
normalized_name = (name or "").strip().lower()
if not normalized_name or not _LOG_DOWNLOAD_NAME_PATTERN.fullmatch(normalized_name):
raise HTTPException(status_code=404, detail="Not Found")
log_root = settings.LOG_PATH
if normalized_name == "moviepilot":
log_dir = log_root
log_prefix = "moviepilot.log"
else:
log_dir = log_root / "plugins"
log_prefix = f"{normalized_name}.log"
if not log_dir.exists() or not log_dir.is_dir():
raise HTTPException(status_code=404, detail="Not Found")
current_log = log_dir / log_prefix
backup_logs = [
item
for item in log_dir.iterdir()
if item.is_file() and item.name.startswith(f"{log_prefix}.")
]
backup_logs.sort(key=lambda item: item.stat().st_mtime, reverse=True)
log_files = []
if current_log.exists() and current_log.is_file():
log_files.append(current_log)
log_files.extend(backup_logs)
return log_files[:_LOG_DOWNLOAD_LIMIT]
def _verify_log_resource_superuser(
token_payload: schemas.TokenPayload = Depends(verify_resource_token),
) -> schemas.TokenPayload:
"""
校验日志资源访问权限。
日志接口通过浏览器新窗口和 EventSource 访问,不能依赖普通 API 请求头;
因此这里复用资源 Cookie 完成身份识别,再额外要求管理员身份,避免普通
登录用户读取可能包含敏感信息的日志。
"""
if not token_payload.super_user:
raise HTTPException(status_code=403, detail="用户权限不足")
return token_payload
async def _build_log_zip_response(name: str) -> StreamingResponse:
"""
将指定日志标识对应的日志文件打包为 zip 响应。
打包前逐个校验文件仍位于日志根目录内,避免符号链接或并发文件变更绕过
`name` 到固定目录的映射约束。zip 内使用日志根目录相对路径,便于区分
主程序日志与插件日志。
"""
log_files = _collect_named_log_files(name)
if not log_files:
raise HTTPException(status_code=404, detail="Not Found")
log_root = settings.LOG_PATH
async_log_root = AsyncPath(log_root)
zip_buffer = io.BytesIO()
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
safe_name = (name or "logs").strip().lower() or "logs"
zip_stem = f"{safe_name}-logs-{filename_time}"
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for log_file in log_files:
if not await SecurityUtils.async_is_safe_path(
base_path=async_log_root,
user_path=AsyncPath(log_file),
):
raise HTTPException(status_code=404, detail="Not Found")
arcname = f"{zip_stem}/{log_file.name}"
archive.write(log_file, arcname)
zip_buffer.seek(0)
headers = {
"Content-Disposition": f'attachment; filename="{zip_stem}.zip"'
}
return StreamingResponse(
iter([zip_buffer.getvalue()]),
media_type="application/zip",
headers=headers,
)
def _validate_nettest_url(url: str) -> Optional[str]:
"""
对实际请求地址做基础安全校验。
@@ -625,6 +815,73 @@ async def get_public_setting(
return schemas.Response(success=True, data={"value": value})
@router.post(
"/setting/PLUGIN_MARKET/sync-wiki",
summary="从Wiki同步插件市场仓库",
response_model=schemas.Response,
)
async def sync_plugin_market_from_wiki(
request: Optional[schemas.PluginMarketSyncRequest] = Body(default=None),
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""
从 Wiki 插件文档同步插件市场仓库地址。
"""
wiki_url = (request.wiki_url if request else None) or _PLUGIN_MARKET_WIKI_URL
wiki_url = wiki_url.strip()
if not _is_allowed_plugin_market_wiki_url(wiki_url):
return schemas.Response(success=False, message="不支持的 Wiki 同步地址")
res = await AsyncRequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=30,
content_type=None,
accept_type="text/plain,*/*",
).get_res(wiki_url)
if res is None:
return schemas.Response(success=False, message="无法访问 Wiki 插件仓库清单")
if res.status_code != 200:
return schemas.Response(
success=False,
message=f"访问 Wiki 插件仓库清单失败,状态码:{res.status_code}",
)
wiki_repos = _extract_plugin_market_repos_from_wiki(res.text)
if not wiki_repos:
return schemas.Response(success=False, message="未在 Wiki 中识别到插件仓库地址")
local_repos = _split_plugin_market_repo_urls(settings.PLUGIN_MARKET)
local_repo_keys = {repo.lower() for repo in local_repos}
added_count = len([repo for repo in wiki_repos if repo.lower() not in local_repo_keys])
merged_repos = _merge_plugin_market_repos(local_repos, wiki_repos)
merged_value = ",".join(merged_repos)
success, message = settings.update_setting("PLUGIN_MARKET", merged_value)
if success:
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key="PLUGIN_MARKET", value=merged_value, change_type="update"
),
)
elif success is None:
success = True
return schemas.Response(
success=success,
message=message,
data={
"value": merged_value,
"repos": merged_repos,
"wiki_repos": wiki_repos,
"added_count": added_count,
"total_count": len(merged_repos),
"source_url": wiki_url,
},
)
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
async def get_setting(
key: str, _: User = Depends(get_current_active_superuser_async)
@@ -705,7 +962,7 @@ async def get_logging(
request: Request,
length: Optional[int] = 50,
logfile: Optional[str] = "moviepilot.log",
_: schemas.TokenPayload = Depends(verify_resource_token),
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
):
"""
实时获取系统日志
@@ -814,6 +1071,17 @@ async def get_logging(
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/logging/download/{name}", summary="下载日志")
async def download_logging(
name: str,
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
):
"""
按日志标识下载主程序或插件滚动日志,返回 zip 文件。
"""
return await _build_log_zip_response(name)
@router.get(
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
)

View File

@@ -1080,7 +1080,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
return dashboard_meta
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> Optional[schemas.PluginDashboard]:
"""
获取插件仪表盘
"""
@@ -1113,6 +1113,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
if dashboard is None:
return None
if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3:
logger.error(f"插件 {pid} 返回的仪表盘数据格式错误")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"插件 {pid} 返回的仪表盘数据格式错误")
cols, attrs, elements = dashboard
return schemas.PluginDashboard(
id=pid,

View File

@@ -502,18 +502,17 @@ class Jellyfin:
try:
res = RequestUtils(timeout=10).get_res(url, params)
if res:
images = res.json().get("Images")
images = res.json().get("Images") or []
for image in images:
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
return image.get("Url")
# return images[0].get("Url") # 首选无则返回第一张
# TMDB 无匹配时回退本地图片
logger.info(f"未找到 TMDB {image_type},回退本地图片")
else:
logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片")
return self.generate_image_link(item_id, image_type, True)
except Exception as e:
logger.error(f"连接Items/Id/RemoteImages出错" + str(e))
return None
return None
return self.generate_image_link(item_id, image_type, True)
def get_item_path_by_id(self, item_id: str) -> Optional[str]:
"""

View File

@@ -86,6 +86,17 @@ class NotificationSwitchConf(BaseModel):
action: Optional[str] = "all"
class PluginMarketSyncRequest(BaseModel):
"""
插件市场仓库同步请求
"""
# Wiki 插件文档 Markdown 原始文件地址
wiki_url: Optional[str] = Field(
default="https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md",
)
class StorageConf(BaseModel):
"""
存储配置

View File

@@ -114,6 +114,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| :--- | :--- | :--- |
| GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 |
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
### 插件补充接口

View File

@@ -1,14 +1,16 @@
---
name: create-moviepilot-plugin
version: 1
version: 2
description: >-
Use this skill when the user asks to create, modify, debug, validate, or
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
_PluginBase implementations, package.v2.json/package.json market metadata,
plugins.v2/plugins source layout, PLUGIN_LOCAL_REPO_PATHS local plugin
sources, plugin APIs, forms, pages, dashboards, commands, services, workflow
actions, agent tools, and local install/reload flows. Also use for Chinese
requests mentioning 编写插件、本地插件源、插件开发、V2插件、插件市场、本地安装插件、插件热加载.
sources, plugin APIs, Vuetify JSON forms/pages/dashboards, Vue module
federation remote components, get_render_mode, get_sidebar_nav, plugin
sidebar pages, commands, services, workflow actions, agent tools, and local
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
---
@@ -22,21 +24,45 @@ a local plugin source and installed into the running MoviePilot instance.
- Host plugin contract: `app/plugins/__init__.py`, especially `_PluginBase`.
- Host plugin discovery, local source sync, install, reload: `app/core/plugin.py`
and `app/helper/plugin.py`.
- Host plugin endpoints, API auth, static files, remotes, and sidebar nav:
`app/api/endpoints/plugin.py`.
- Local development note: `docs/development-setup.md`.
- Plugin repository conventions: `MoviePilot-Plugins` uses `plugins.v2/` with
`package.v2.json` for V2 plugins; legacy or cross-generation entries may use
`plugins/` with `package.json`.
- When working in or from `MoviePilot-Plugins`, read its `README.md`,
`docs/Repository_Guide.md`, and `docs/V2_Plugin_Development.md`. For
scenario-specific extensions, read the matching `docs/faq/*.md`.
- When the plugin uses Vue federation, also read
`MoviePilot-Frontend/docs/module-federation-guide.md`,
`MoviePilot-Frontend/docs/federation-troubleshooting.md`,
`MoviePilot-Frontend/src/utils/federationLoader.ts`, and
`MoviePilot-Frontend/src/pages/plugin-app.vue`.
- Repository boundaries: `MoviePilot` owns runtime loading, API registration,
events, services, data, and permissions; `MoviePilot-Frontend` owns plugin UI
rendering, federation loading, and sidebar pages; `MoviePilot-Plugins` owns
plugin source, icons, package indexes, and release metadata.
## Pre-Flight
1. Understand the user request: plugin purpose, trigger mode, configuration,
output UI, whether it needs a scheduler, API, command, workflow action, or
agent tool.
2. Inspect existing plugins before creating a new one:
2. Run the UI Mode Selection Gate before writing any UI code.
- If the user already explicitly chose JSON config/Vuetify JSON or Vue
federation, follow that choice.
- If the plugin has any UI surface and the user has not chosen a mode, ask
them to choose between the two modes below and wait for the answer before
implementing UI files or schemas.
- Do not silently default to either mode just because one seems easier.
3. Inspect existing plugins before creating a new one:
- Local runtime examples: `app/plugins/<plugin>/__init__.py`
- Market/local source candidates: use `query_market_plugins` when the
running instance is available.
3. Determine the target source path:
- For Vue federation examples, prefer current compliant plugins such as
`MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example
`MoviePilot-Frontend/examples/plugin-component/`.
4. Determine the target source path:
- Query `PLUGIN_LOCAL_REPO_PATHS` with `query_system_settings` when possible.
- If exactly one local plugin repository is configured, prefer that path.
- If several are configured, choose the one the user named; otherwise ask
@@ -47,11 +73,41 @@ a local plugin source and installed into the running MoviePilot instance.
plugin source loader. Create that source directory and write the plugin
under it; do not write new plugin source directly into `app/plugins/`
unless the user explicitly asks for a runtime-only experiment.
4. Choose the plugin ID:
5. Choose the plugin ID:
- Class name is the plugin ID, for example `MyNotifier`.
- Directory name is the class name lowercased, for example `mynotifier`.
- Avoid collisions with installed or market plugins unless the user is
explicitly modifying that plugin.
- Do not hardcode the original plugin ID for data/config namespaces when the
plugin may support clones; use `self.__class__.__name__`.
## UI Mode Selection Gate
MoviePilot plugin UI has exactly two implementation modes. Make the user choose
one whenever the request includes configuration, detail pages, dashboards,
sidebar pages, or any other plugin UI and the mode is not already explicit.
Ask a concise question like:
```text
这个插件 UI 用哪种方式实现?
1. JSON 配置:后端返回 Vuetify JSON适合普通配置表单、简单详情页和轻量仪表板。
2. 联邦 UI独立 Vue 远程组件,适合复杂交互、自定义布局、侧栏全页或多页面。
```
Selection rules:
- **JSON config / Vuetify JSON**: implement `get_form()`, `get_page()`, and
`get_dashboard()` with JSON component schemas. No frontend build or
`dist/assets/remoteEntry.js` is needed.
- **Federation UI / Vue remote component**: implement `get_render_mode()`,
expose Vue components through Vite federation, build frontend assets into the
plugin directory, and use `get_sidebar_nav()` only when a sidebar page is
requested.
- If the plugin truly has no user-facing UI, state that no UI mode is needed
and implement only the backend extension points the request requires.
- Backend-only work may proceed while waiting only if it cannot constrain or
preclude either UI mode.
## Local Source Layout
@@ -67,6 +123,25 @@ Default to V2 layout for new local plugins:
└── ... # helper modules, schemas, static assets
```
For a Vue federation plugin, the runtime requirement is the built remote assets
under the plugin directory:
```text
plugins.v2/<plugin_id_lower>/
├── __init__.py
├── dist/
│ └── assets/
│ ├── remoteEntry.js
│ └── ... # JS/CSS/assets referenced by remoteEntry
├── package.json # optional frontend build project metadata
├── vite.config.js # optional frontend build config
└── src/ # optional source, not required at runtime
```
Do not rely on frontend source files at runtime. If the source is kept in the
plugin repository for maintainability, still build and ship the `dist/assets`
files required by `remoteEntry.js`.
Only use the legacy layout when the user explicitly needs it:
```text
@@ -107,21 +182,29 @@ Rules:
- The package object key must match the plugin class name.
- `version` must match `plugin_version`.
- `name`, `description`, `icon`, `author`, and `level` should match the plugin
class attributes when those attributes exist.
- `name`, `description`, `icon`, `author`, `labels`, and `level` should match
the plugin class attributes when those attributes exist (`plugin_name`,
`plugin_desc`, `plugin_icon`, `plugin_author`, `plugin_label`, `auth_level`).
- `history` should record user-readable changes for each published version.
- Use `system_version` when the plugin depends on a host capability introduced
in a specific MoviePilot version.
in a specific MoviePilot version, including new backend APIs, helpers, events,
Vue federation behavior, sidebar nav, dashboard behavior, or agent tools.
- Use `"release": true` only when the plugin is intentionally distributed by a
GitHub Release archive.
- New plugin entries should usually be appended to the package index so they
appear as newer marketplace items.
- Do not add dependencies unless they are actually required. If
`requirements.txt` changes, the user must reinstall the plugin; hot reload is
not enough to install dependencies.
- Plugin dependencies are installed into the shared MoviePilot Python
environment. Do not pin or downgrade packages already provided by MoviePilot
unless the user has explicitly accepted the compatibility risk.
## Implementation Skeleton
Implement all abstract methods from `_PluginBase`. All new public classes,
public methods, and public functions need Chinese docstrings.
Implement all abstract methods from `_PluginBase`. All new functions and
methods need Chinese docstrings; public classes, public methods, and public
functions are a hard review gate.
```python
from typing import Any, Dict, List, Optional, Tuple
@@ -224,7 +307,8 @@ Use only the extension points the requested plugin actually needs:
- Notification: use `post_message()` instead of directly calling message
modules.
- APIs: return route definitions from `get_api()`; default auth is `apikey`
when `auth` is omitted.
when `auth` is omitted. Vue component APIs should normally use
`auth: "bear"` and be called through the `api` prop passed by the frontend.
- Commands: return slash-command definitions from `get_command()` and dispatch
through MoviePilot events.
- Services: return scheduler services from `get_service()` and always clean
@@ -239,6 +323,118 @@ Use only the extension points the requested plugin actually needs:
satisfy the request. Return `("vue", "<compiled-assets-path>")` and include
built frontend assets in the plugin directory.
## Vue Federation UI
Use Vue federation only after the Pre-Flight UI decision says JSON schema is not
enough. A Vue plugin must align backend methods, built files, and federation
exposes.
Backend requirements:
```python
from typing import Any, Dict, List, Tuple
@staticmethod
def get_render_mode() -> Tuple[str, str]:
"""声明插件使用 Vue 联邦组件渲染。"""
return "vue", "dist/assets"
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""Vue 模式下返回默认配置模型。"""
return [], self._current_config()
def get_page(self) -> List[dict]:
"""Vue 模式下详情页由远程 Page 组件渲染。"""
return []
```
When the plugin needs a main-layout sidebar page, also implement:
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""声明插件在主界面左侧导航栏中的全页入口。"""
if not self.get_state():
return []
return [
{
"nav_key": "main",
"title": "我的插件",
"icon": "mdi-puzzle",
"section": "system",
"permission": "manage",
"order": 10,
}
]
```
Sidebar rules:
- Sidebar entries are only aggregated for enabled plugins whose
`get_render_mode()` returns `"vue"`.
- `section` must be one of `start`, `discovery`, `subscribe`, `organize`,
`system`; invalid values fall back to `system`.
- `permission` may be `subscribe`, `discovery`, `search`, `manage`, or `admin`;
invalid values are ignored.
- `nav_key` defaults to `main` and must not contain `/`, `?`, `#`, or spaces.
- Multiple sidebar entries are allowed; each entry needs a stable `nav_key`.
Frontend federation requirements:
```js
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
},
shared: {
vue: { requiredVersion: false, generate: false },
vuetify: { requiredVersion: false, generate: false, singleton: true },
'vuetify/styles': { requiredVersion: false, generate: false, singleton: true },
},
format: 'esm',
})
```
Build requirements:
- Set Vite `build.target` to `esnext` because federation uses top-level await.
- Use `cssCodeSplit: true` and scoped/component-local styles where possible.
- Build with the frontend project's documented command, then keep `remoteEntry.js`
and every JS/CSS/asset file it references under `dist/assets`.
- Do not add frontend runtime dependencies to the plugin Python
`requirements.txt`; keep frontend dependencies in the frontend build project.
Component contracts:
- `Page` renders the plugin detail dialog and may emit `action`, `switch`, and
`close`.
- `Config` renders plugin settings, receives `initialConfig` and `api`, and
emits `save`, `close`, and `switch`.
- `Dashboard` receives `config` and `allowRefresh`.
- `AppPage` renders the main-layout sidebar page and receives `api`, `pluginId`,
and `navKey`.
- For sidebar `nav_key=main`, the frontend loads `./AppPage` then `./Page`.
- For any other `nav_key`, the frontend loads `./AppPage{PascalCase(nav_key)}`,
then `./AppPage`, then `./Page`. Examples: `settings -> AppPageSettings`,
`my_tool -> AppPageMyTool`.
- A single `AppPage` may branch on `navKey`, or separate
`AppPage{PascalCase}` files may be exposed for specific entries.
Vue API calls:
- Define frontend-facing plugin APIs with `auth: "bear"`.
- Call them with the injected API object, for example
`props.api.get(\`plugin/${props.pluginId}/history\`)`.
- Do not pass `settings.API_TOKEN` into Vue components for browser-side calls.
## Local Install And Reload
1. After writing files in a configured local plugin repository, call
@@ -258,6 +454,17 @@ Use only the extension points the requested plugin actually needs:
and package version are consistent.
- Confirm every public class, public method, and public function has a Chinese
docstring.
- Confirm every newly written function or method has a Chinese docstring, even
when it is private helper code.
- For Vue federation plugins, confirm `get_render_mode()` returns
`("vue", "dist/assets")` or the actual built asset path, and that
`dist/assets/remoteEntry.js` exists.
- For sidebar plugins, confirm the plugin is enabled, `get_state()` returns
`True`, `get_sidebar_nav()` returns valid items, and matching `AppPage`
exposes exist for all non-main `nav_key` values or a generic `AppPage` handles
them.
- Confirm frontend-facing API routes use `auth: "bear"` and browser code calls
them through the provided `api` prop.
- Keep external HTTP calls behind MoviePilot utilities and avoid real network
calls in tests.
- If the plugin has non-trivial logic, add or update pytest-native tests. Plugin
@@ -266,6 +473,27 @@ Use only the extension points the requested plugin actually needs:
- Run the narrowest allowed validation for the touched area. In this repository,
follow `docs/rules/03-commands.md`; for plugin-only repositories, follow their
own documented validation commands.
- For plugin repository Python changes, use the host Python environment when
possible and run at least syntax compilation for touched plugin files.
- For Vue federation changes, run the frontend project's documented typecheck
and build commands when available, then verify the built assets were copied to
the plugin directory.
## Vue Federation Troubleshooting
- `GET /api/v1/plugin/remotes?token=moviepilot` should include the plugin with a
URL ending in `/plugin/file/<plugin_id_lower>/<dist_path>/remoteEntry.js`.
- `GET /api/v1/plugin/sidebar_nav` should include sidebar entries for enabled
Vue plugins with valid `nav_key`, `section`, and `permission`.
- If the console says `Module name 'vue' does not resolve to a valid URL`, check
the federation `shared` config and use `requiredVersion: false`.
- If the console says top-level await is unavailable, set `build.target` to
`esnext`.
- If dynamic import fails, check the remote file request status, the computed
`remoteEntry.js` path, and whether the installed runtime plugin directory
actually contains the built assets.
- If a sidebar page is blank, check the expose name resolution for the current
`nav_key` and fallbacks (`AppPage{PascalCase}` -> `AppPage` -> `Page`).
## Final Report
@@ -273,5 +501,7 @@ Report:
- Plugin ID, source path, and runtime path if installed.
- Package file changed (`package.v2.json` or `package.json`).
- UI mode used (`vuetify` JSON or `vue` federation), and for Vue plugins the
exposed components and built asset path.
- Whether the plugin was installed or reloaded.
- Validation commands run, or why validation was not run.

View File

@@ -1,7 +1,7 @@
---
name: moviepilot-api
version: 1
description: Use this skill when you need to call MoviePilot REST API endpoints directly. Covers all 244 API endpoints across 27 categories including media search, downloads, subscriptions, library management, site management, system administration, plugins, workflows, and more. Use this skill whenever the user asks to interact with MoviePilot via its HTTP API, or when the moviepilot-cli skill cannot cover a specific operation.
description: Use this skill when you need to call MoviePilot REST API endpoints directly. Covers all 245 API endpoints across 27 categories including media search, downloads, subscriptions, library management, site management, system administration, plugins, workflows, and more. Use this skill whenever the user asks to interact with MoviePilot via its HTTP API, or when the moviepilot-cli skill cannot cover a specific operation.
---
# MoviePilot REST API
@@ -320,7 +320,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
### System (23 endpoints)
### System (24 endpoints)
| Method | Path | Description |
|--------|------|-------------|
@@ -330,6 +330,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/system/setting/public/{key}` | Get allowlisted non-sensitive system setting for authenticated users |
| GET | `/api/v1/system/setting/{key}` | Get system setting |
| POST | `/api/v1/system/setting/{key}` | Update system setting |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | Sync plugin market repository URLs from the MoviePilot Wiki and merge with local `PLUGIN_MARKET` |
| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |
| GET | `/api/v1/system/global/user` | User-related settings |
| GET | `/api/v1/system/restart` | Restart system |

View File

@@ -45,6 +45,22 @@ class TestAgentRuntimeConfig(unittest.TestCase):
/ "PERSONA.md"
).exists()
)
self.assertTrue(
(
self.agent_root
/ "runtime"
/ "subagents"
/ "general-purpose"
/ "SUBAGENT.md"
).exists()
)
self.assertIn(
"media-researcher",
[
subagent.subagent_id
for subagent in runtime_config.available_subagents
],
)
def test_legacy_root_markdown_is_migrated_to_memory_directory(self):
self.agent_root.mkdir(parents=True, exist_ok=True)
@@ -109,6 +125,8 @@ class TestAgentRuntimeConfig(unittest.TestCase):
self.assertIn("<agent_persona>", sections)
self.assertIn("Active persona: `default`", sections)
self.assertIn("`guide`", sections)
self.assertIn("Available subagents:", sections)
self.assertIn("`media-researcher`", sections)
def test_set_active_persona_supports_id_and_alias(self):
manager = self._manager()

View File

@@ -0,0 +1,172 @@
import textwrap
from pathlib import Path
from app.agent.runtime import AgentRuntimeManager
import app.agent.middleware.subagents as subagent_module
def _write_current_persona(defaults_root: Path) -> None:
"""写入最小可用的人格激活配置。"""
defaults_root.mkdir(parents=True, exist_ok=True)
(defaults_root / "CURRENT_PERSONA.md").write_text(
textwrap.dedent(
"""\
---
version: 3
active_persona: default
extra_context_files: []
deprecated_phrases: []
---
# CURRENT_PERSONA
"""
),
encoding="utf-8",
)
persona_dir = defaults_root / "personas" / "default"
persona_dir.mkdir(parents=True, exist_ok=True)
(persona_dir / "PERSONA.md").write_text(
textwrap.dedent(
"""\
---
version: 1
persona_id: default
label: 默认
description: 默认人格
aliases: []
---
# PERSONA
测试人格。
"""
),
encoding="utf-8",
)
def _write_subagent(
root: Path,
subagent_id: str,
*,
version: int = 1,
description: str = "测试子代理",
body: str = "测试子代理提示。",
) -> Path:
"""写入一个子代理定义文件。"""
subagent_dir = root / "subagents" / subagent_id
subagent_dir.mkdir(parents=True, exist_ok=True)
path = subagent_dir / "SUBAGENT.md"
path.write_text(
textwrap.dedent(
f"""\
---
version: {version}
subagent_id: {subagent_id}
label: 测试
description: {description}
include_tags:
- media
- web
exclude_tags:
- write
- message
---
# SUBAGENT
{body}
"""
),
encoding="utf-8",
)
return path
def test_runtime_syncs_and_parses_subagent_definitions(tmp_path):
"""运行时应同步并解析 defaults/subagents 下的子代理定义。"""
defaults_root = tmp_path / "defaults"
_write_current_persona(defaults_root)
_write_subagent(
defaults_root,
"custom-reader",
description="Custom reader subagent.",
body="Only inspect custom media signals.",
)
manager = AgentRuntimeManager(
agent_root_dir=tmp_path / "agent",
bundled_defaults_dir=defaults_root,
)
runtime_config = manager.load_runtime_config()
copied_path = (
tmp_path
/ "agent"
/ "runtime"
/ "subagents"
/ "custom-reader"
/ "SUBAGENT.md"
)
subagent = runtime_config.available_subagents[0]
assert copied_path.exists()
assert subagent.subagent_id == "custom-reader"
assert subagent.description == "Custom reader subagent."
assert subagent.include_tags == ["media", "web"]
assert subagent.exclude_tags == ["write", "message"]
assert "Only inspect custom media signals." in subagent.text
def test_runtime_updates_bundled_subagent_when_version_increases(tmp_path):
"""内置子代理版本升高时应覆盖用户目录里的旧版副本。"""
defaults_root = tmp_path / "defaults"
_write_current_persona(defaults_root)
_write_subagent(
defaults_root,
"custom-reader",
version=1,
body="version one",
)
manager = AgentRuntimeManager(
agent_root_dir=tmp_path / "agent",
bundled_defaults_dir=defaults_root,
)
manager.ensure_layout()
_write_subagent(
defaults_root,
"custom-reader",
version=2,
body="version two",
)
manager.invalidate_cache()
runtime_config = manager.load_runtime_config()
subagent = runtime_config.available_subagents[0]
assert subagent.version == 2
assert "version two" in subagent.text
def test_middleware_profiles_are_loaded_from_runtime_config(monkeypatch, tmp_path):
"""子代理中间件应从运行时 YAML 定义生成 profile。"""
defaults_root = tmp_path / "defaults"
_write_current_persona(defaults_root)
_write_subagent(
defaults_root,
"custom-reader",
description="Runtime custom reader.",
body="Runtime-only prompt.",
)
manager = AgentRuntimeManager(
agent_root_dir=tmp_path / "agent",
bundled_defaults_dir=defaults_root,
)
monkeypatch.setattr(subagent_module, "agent_runtime_manager", manager)
subagent_module._builtin_subagent_profiles.cache_clear()
subagent_module.builtin_subagent_names.cache_clear()
profiles = subagent_module._builtin_subagent_profiles()
assert [profile.name for profile in profiles] == ["custom-reader"]
assert profiles[0].description == "Runtime custom reader."
assert profiles[0].include_tags == frozenset({"media", "web"})
assert "Runtime-only prompt." in profiles[0].prompt

View File

@@ -0,0 +1,26 @@
import asyncio
from unittest.mock import AsyncMock, patch
from app.api.endpoints import llm as llm_endpoint
def test_llm_test_maps_internal_model_dump_error_to_base_url_hint():
"""LLM 测试遇到 SDK 内部响应解析错误时应提示检查基础地址。"""
with patch.object(llm_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
llm_endpoint.settings, "LLM_PROVIDER", "openai"
), patch.object(llm_endpoint.settings, "LLM_MODEL", "gpt-4o-mini"), patch.object(
llm_endpoint.settings, "LLM_API_KEY", "sk-test"
), patch.object(
llm_endpoint.settings, "LLM_BASE_URL", "https://example.com/not-api"
), patch.object(
llm_endpoint.LLMHelper,
"test_current_settings",
AsyncMock(side_effect=RuntimeError("'str' object has no attribute 'model_dump'")),
create=True,
):
resp = asyncio.run(llm_endpoint.llm_test(_="token"))
assert not resp.success
assert "基础地址" in resp.message
assert "API Base URL" in resp.message
assert "model_dump" not in resp.message

View File

@@ -0,0 +1,65 @@
from types import SimpleNamespace
from typing import Any, Iterator
import pytest
from fastapi import HTTPException
from app.core.plugin import PluginManager
from app.utils.singleton import Singleton
@pytest.fixture
def plugin_manager() -> Iterator[PluginManager]:
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
Singleton._instances.pop((PluginManager, (), frozenset()), None)
manager = PluginManager()
yield manager
Singleton._instances.pop((PluginManager, (), frozenset()), None)
def _plugin_with_dashboard(dashboard: Any) -> SimpleNamespace:
"""构造仅包含仪表板接口的插件实例。"""
return SimpleNamespace(
plugin_name="演示插件",
get_render_mode=lambda: ("vue", "dist/assets"),
get_dashboard=lambda key=None, user_agent=None: dashboard,
)
def test_plugin_dashboard_keeps_vue_elements_none(plugin_manager: PluginManager) -> None:
"""Vue 仪表板的 elements=None 应原样返回给前端渲染远程组件。"""
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
(
{"cols": 12},
{"title": "演示插件", "border": True},
None,
)
)
dashboard = plugin_manager.get_plugin_dashboard("DemoPlugin", "usage")
assert dashboard.id == "DemoPlugin"
assert dashboard.render_mode == "vue"
assert dashboard.cols == {"cols": 12}
assert dashboard.attrs == {"title": "演示插件", "border": True}
assert dashboard.elements is None
def test_plugin_dashboard_returns_none_when_plugin_has_no_dashboard(plugin_manager: PluginManager) -> None:
"""插件声明当前无仪表板时应返回 None而不是触发解包异常。"""
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(None)
assert plugin_manager.get_plugin_dashboard("DemoPlugin", "missing") is None
def test_plugin_dashboard_rejects_invalid_dashboard_shape(plugin_manager: PluginManager) -> None:
"""非空但不符合三元组契约的仪表板数据应返回服务端错误。"""
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
{"cols": {}, "attrs": {}, "elements": []}
)
with pytest.raises(HTTPException) as exc_info:
plugin_manager.get_plugin_dashboard("DemoPlugin", "broken")
assert exc_info.value.status_code == 500
assert "仪表盘数据格式错误" in exc_info.value.detail

View File

@@ -1,71 +1,103 @@
import asyncio
from unittest import TestCase
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from app import schemas
from app.api.endpoints.plugin import plugin_history
from app.api.endpoints.system import sync_plugin_market_from_wiki
class PluginEndpointTest(TestCase):
def test_plugin_history_merges_remote_metadata():
"""
已安装插件点击更新说明时,接口会按需合并远端仓库中的更新记录。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
history={"v1.1.0": "- 新增更新说明"},
system_version=">=2.0.0",
system_version_compatible=True,
has_update=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
def test_plugin_history_merges_remote_metadata(self):
"""
已安装插件点击更新说明时,接口会按需合并远端仓库中的更新记录。
"""
try:
from app import schemas
from app.api.endpoints.plugin import plugin_history
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
history={"v1.1.0": "- 新增更新说明"},
system_version=">=2.0.0",
system_version_compatible=True,
has_update=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
assert result.repo_url == "https://github.com/demo/plugins"
assert result.history == {"v1.1.0": "- 新增更新说明"}
assert result.system_version == ">=2.0.0"
assert result.has_update
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
self.assertEqual("https://github.com/demo/plugins", result.repo_url)
self.assertEqual({"v1.1.0": "- 新增更新说明"}, result.history)
self.assertEqual(">=2.0.0", result.system_version)
self.assertTrue(result.has_update)
def test_plugin_history_returns_installed_plugin_when_remote_missing():
"""
远端仓库不可用时,接口仍返回本地已安装插件信息,前端可继续展示兜底状态。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
def test_plugin_history_returns_installed_plugin_when_remote_missing(self):
"""
远端仓库不可用时,接口仍返回本地已安装插件信息,前端可继续展示兜底状态。
"""
try:
from app import schemas
from app.api.endpoints.plugin import plugin_history
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
assert result.id == "DemoPlugin"
assert result.history == {}
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
self.assertEqual("DemoPlugin", result.id)
self.assertEqual({}, result.history)
def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos():
"""
Wiki 同步会提取标记区域内的 GitHub 仓库地址,并与本地配置合并去重后写入。
"""
markdown = """
<!-- plugin-market-repos:start -->
- https://github.com/local/existing/
- https://github.com/wiki/new-repo/
- https://github.com/wiki/new-repo
<!-- plugin-market-repos:end -->
- https://github.com/wiki/ignored-outside-marker
"""
response = MagicMock(status_code=200, text=markdown)
request_utils = MagicMock()
request_utils.get_res = AsyncMock(return_value=response)
with (
patch("app.api.endpoints.system.AsyncRequestUtils", return_value=request_utils),
patch("app.api.endpoints.system.settings.PLUGIN_MARKET", "https://github.com/local/existing"),
patch(
"app.core.config.Settings.update_setting",
autospec=True,
return_value=(True, ""),
) as update_setting,
patch("app.api.endpoints.system.eventmanager.async_send_event", new=AsyncMock()) as send_event,
):
result = asyncio.run(sync_plugin_market_from_wiki(None, None))
assert result.success
assert result.data["repos"] == [
"https://github.com/local/existing",
"https://github.com/wiki/new-repo",
]
assert result.data["added_count"] == 1
assert result.data["total_count"] == 2
update_setting.assert_called_once_with(
ANY,
"PLUGIN_MARKET",
"https://github.com/local/existing,https://github.com/wiki/new-repo",
)
send_event.assert_awaited_once()

View File

@@ -0,0 +1,116 @@
"""系统日志查看与下载接口的权限和打包行为测试。"""
import asyncio
import io
import zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from starlette.responses import Response
from app.api.endpoints import system as system_endpoint
from app.core.config import settings
def test_logging_routes_use_superuser_dependency():
"""日志查看和下载路由都必须绑定管理员依赖,避免普通登录用户读取敏感日志。"""
routes = {route.path: route for route in system_endpoint.router.routes}
logging_dependencies = {dependency.call for dependency in routes["/logging"].dependant.dependencies}
download_dependencies = {dependency.call for dependency in routes["/logging/download/{name}"].dependant.dependencies}
assert system_endpoint._verify_log_resource_superuser in logging_dependencies
assert system_endpoint._verify_log_resource_superuser in download_dependencies
def test_log_resource_dependency_rejects_normal_user():
"""日志资源依赖必须拒绝非管理员 resource token。"""
with pytest.raises(HTTPException) as exc_info:
system_endpoint._verify_log_resource_superuser(
SimpleNamespace(super_user=False),
)
assert exc_info.value.status_code == 403
@pytest.fixture(name="isolated_log_path")
def fixture_isolated_log_path(monkeypatch, tmp_path: Path) -> Path:
"""将日志目录隔离到临时目录,避免测试读取或打包真实运行日志。"""
config_path = tmp_path / "config"
log_path = config_path / "logs"
log_path.mkdir(parents=True)
monkeypatch.setattr(settings, "CONFIG_DIR", str(config_path))
return log_path
def test_logging_requires_superuser_dependency(monkeypatch, isolated_log_path):
"""实时日志查看接口必须通过管理员依赖,普通资源令牌不能直接读取日志。"""
(isolated_log_path / "moviepilot.log").write_text("hello\n", encoding="utf-8")
response = asyncio.run(
system_endpoint.get_logging(
request=SimpleNamespace(is_disconnected=lambda: False),
length=-1,
logfile="moviepilot.log",
_=SimpleNamespace(id=1, name="admin", is_superuser=True),
)
)
assert isinstance(response, Response)
def test_download_moviepilot_logs_packages_latest_ten_log_files(isolated_log_path):
"""传入 moviepilot 时下载主程序滚动日志,最多打包 10 个文件。"""
for index in range(12):
(isolated_log_path / f"moviepilot.log.{index}").write_text(f"old-{index}", encoding="utf-8")
(isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8")
(isolated_log_path / "moviepilot.txt").write_text("ignored", encoding="utf-8")
(isolated_log_path / "plugins").mkdir()
(isolated_log_path / "plugins" / "demo.log").write_text("plugin", encoding="utf-8")
response = asyncio.run(system_endpoint.download_logging(name="moviepilot", _=SimpleNamespace()))
body = asyncio.run(_read_streaming_body(response))
with zipfile.ZipFile(io.BytesIO(body)) as archive:
names = archive.namelist()
moviepilot_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
assert response.media_type == "application/zip"
assert 'filename="moviepilot-logs-' in response.headers["Content-Disposition"]
assert "moviepilot-moviepilot-logs" not in response.headers["Content-Disposition"]
assert len(names) == 10
assert f"{moviepilot_zip_root}/moviepilot.log" in names
assert "moviepilot.log" not in names
assert "plugins/demo.log" not in names
assert "moviepilot.txt" not in names
def test_download_plugin_logs_packages_plugin_files_only(isolated_log_path):
"""传入插件 ID 时只下载该插件滚动日志,最多打包 10 个文件。"""
plugin_dir = isolated_log_path / "plugins"
plugin_dir.mkdir()
for index in range(11):
(plugin_dir / f"demoplugin.log.{index}").write_text(f"plugin-{index}", encoding="utf-8")
(plugin_dir / "demoplugin.log").write_text("current", encoding="utf-8")
(plugin_dir / "other.log").write_text("other", encoding="utf-8")
(isolated_log_path / "moviepilot.log").write_text("main", encoding="utf-8")
response = asyncio.run(system_endpoint.download_logging(name="DemoPlugin", _=SimpleNamespace()))
body = asyncio.run(_read_streaming_body(response))
with zipfile.ZipFile(io.BytesIO(body)) as archive:
names = archive.namelist()
plugin_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
assert len(names) == 10
assert f"{plugin_zip_root}/demoplugin.log" in names
assert "demoplugin.log" not in names
assert "plugins/demoplugin.log" not in names
assert "plugins/other.log" not in names
assert "moviepilot.log" not in names
async def _read_streaming_body(response) -> bytes:
"""读取 StreamingResponse 内容,便于断言 zip 文件条目。"""
return b"".join([chunk async for chunk in response.body_iterator])

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.13.8'
APP_VERSION = 'v2.13.8-1'
FRONTEND_VERSION = 'v2.13.8'