Compare commits

...

12 Commits

Author SHA1 Message Date
jxxghp
bef2a81296 fix downloader task status queries 2026-06-14 18:23:18 +08:00
jxxghp
d0dcf6660f chore: update application and frontend version to v2.13.9 2026-06-14 16:28:34 +08:00
jxxghp
4e3eddec10 feat: add captcha recognition agent tool 2026-06-14 16:24:04 +08:00
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
43 changed files with 2738 additions and 499 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

@@ -37,6 +37,7 @@ from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
from app.agent.tools.impl.get_search_results import GetSearchResultsTool
from app.agent.tools.impl.search_web import SearchWebTool
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
from app.agent.tools.impl.send_message import SendMessageTool
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
from app.agent.tools.impl.send_local_file import SendLocalFileTool
@@ -165,6 +166,7 @@ class MoviePilotToolFactory:
SearchTorrentsTool,
GetSearchResultsTool,
SearchWebTool,
RecognizeCaptchaTool,
AddDownloadTool,
QuerySubscribesTool,
QuerySubscribeSharesTool,

View File

@@ -1,7 +1,7 @@
"""查询下载工具"""
import json
from typing import Any, Dict, List, Optional, Type, Union
from typing import Any, Dict, List, Optional, Type
from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.log import logger
from app.schemas import TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus, media_type_to_agent
from app.schemas import DownloaderTorrent
from app.schemas.types import TorrentQueryStatus, media_type_to_agent
class QueryDownloadTasksInput(BaseModel):
@@ -21,6 +21,10 @@ class QueryDownloadTasksInput(BaseModel):
description="Name of specific downloader to query (optional, if not provided queries all configured downloaders)")
status: Optional[str] = Field("all",
description="Filter downloads by status: 'downloading' for active downloads, 'completed' for finished downloads, 'paused' for paused downloads, 'all' for all downloads")
include_all_tags: Optional[bool] = Field(
False,
description="Include tasks without the MoviePilot built-in tag. Default false keeps the normal MoviePilot task scope.",
)
hash: Optional[str] = Field(None, description="Query specific download task by hash (optional, if provided will search for this specific task regardless of status)")
title: Optional[str] = Field(None, description="Query download tasks by title/name (optional, supports partial match, searches all tasks if provided)")
tag: Optional[str] = Field(None, description="Filter download tasks by tag (optional, supports partial match, e.g. 'movie' will match tasks with tag 'movie' or 'movie_2024')")
@@ -36,26 +40,45 @@ class QueryDownloadTasksTool(MoviePilotTool):
args_schema: Type[BaseModel] = QueryDownloadTasksInput
@staticmethod
def _get_all_torrents(download_chain: DownloadChain, downloader: Optional[str] = None) -> List[Union[TransferTorrent, DownloadingTorrent]]:
def _normalize_query_status(status: Optional[str]) -> TorrentQueryStatus:
"""
归一下载任务查询状态。
"""
status_value = str(status or "").strip().lower()
if not status_value or status_value == TorrentQueryStatus.ALL.value:
return TorrentQueryStatus.ALL
if status_value in {"completed", "complete", "seeding"}:
return TorrentQueryStatus.COMPLETED
if status_value in {"paused", "pause"}:
return TorrentQueryStatus.PAUSED
if status_value == TorrentQueryStatus.DOWNLOADING.value:
return TorrentQueryStatus.DOWNLOADING
return TorrentQueryStatus.ALL
@staticmethod
def _normalize_include_all_tags(include_all_tags: Any) -> bool:
"""
归一全部标签查询开关。
"""
if isinstance(include_all_tags, bool):
return include_all_tags
if isinstance(include_all_tags, str):
return include_all_tags.strip().lower() in {"1", "true", "yes", "on", ""}
return bool(include_all_tags)
@staticmethod
def _get_all_torrents(
download_chain: DownloadChain,
downloader: Optional[str] = None,
include_all_tags: bool = False,
) -> List[DownloaderTorrent]:
"""
查询所有状态的任务(包括下载中和已完成的任务)
"""
all_torrents = []
# 查询下载的任务
downloading_torrents = download_chain.list_torrents(
downloader=downloader,
status=TorrentStatus.DOWNLOADING
) or []
all_torrents.extend(downloading_torrents)
# 查询已完成的任务(可转移状态)
transfer_torrents = download_chain.list_torrents(
return download_chain.list_torrents(
downloader=downloader,
status=TorrentStatus.TRANSFER
include_all_tags=include_all_tags,
) or []
all_torrents.extend(transfer_torrents)
return all_torrents
@staticmethod
def _format_progress(progress: Optional[float]) -> Optional[str]:
@@ -71,7 +94,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
@staticmethod
def _apply_download_history(
torrent: Union[TransferTorrent, DownloadingTorrent], history: Any
torrent: DownloaderTorrent, history: Any
) -> None:
"""将下载历史中的补充信息回填到下载任务结果中。"""
if not history:
@@ -91,7 +114,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
@classmethod
def _load_history_map(
cls, torrents: List[Union[TransferTorrent, DownloadingTorrent]]
cls, torrents: List[DownloaderTorrent]
) -> Dict[str, Any]:
"""批量加载下载历史,避免逐条查询形成 N+1。"""
hashes = [torrent.hash for torrent in torrents if getattr(torrent, "hash", None)]
@@ -107,15 +130,22 @@ class QueryDownloadTasksTool(MoviePilotTool):
hash_value: Optional[str] = None,
title: Optional[str] = None,
tag: Optional[str] = None,
include_all_tags: bool = False,
) -> Dict[str, Any]:
"""
同步查询下载器和下载历史,整个链路放在线程池中执行。
"""
download_chain = DownloadChain()
query_status = cls._normalize_query_status(status)
include_all_tags = cls._normalize_include_all_tags(include_all_tags)
if hash_value:
torrents = (
download_chain.list_torrents(downloader=downloader, hashs=[hash_value])
download_chain.list_torrents(
downloader=downloader,
hashs=[hash_value],
include_all_tags=include_all_tags,
)
or []
)
if not torrents:
@@ -128,7 +158,11 @@ class QueryDownloadTasksTool(MoviePilotTool):
cls._apply_download_history(torrent, history_map.get(torrent.hash))
filtered_downloads = list(torrents)
elif title:
all_torrents = cls._get_all_torrents(download_chain, downloader)
all_torrents = cls._get_all_torrents(
download_chain,
downloader,
include_all_tags=include_all_tags,
)
history_map = cls._load_history_map(all_torrents)
filtered_downloads = []
title_lower = title.lower()
@@ -150,7 +184,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
if not filtered_downloads:
return {"message": f"未找到标题包含 '{title}' 的下载任务"}
else:
if status == "downloading":
if query_status == TorrentQueryStatus.DOWNLOADING and not include_all_tags:
downloads = download_chain.downloading(name=downloader) or []
filtered_downloads = [
dl
@@ -158,19 +192,12 @@ class QueryDownloadTasksTool(MoviePilotTool):
if not downloader or dl.downloader == downloader
]
else:
all_torrents = cls._get_all_torrents(download_chain, downloader)
filtered_downloads = []
for torrent in all_torrents:
if downloader and torrent.downloader != downloader:
continue
if status == "completed" and torrent.state not in [
"seeding",
"completed",
]:
continue
if status == "paused" and torrent.state != "paused":
continue
filtered_downloads.append(torrent)
list_status = None if query_status == TorrentQueryStatus.ALL else query_status.value
filtered_downloads = download_chain.list_torrents(
downloader=downloader,
status=list_status,
include_all_tags=include_all_tags,
) or []
history_map = cls._load_history_map(filtered_downloads)
for torrent in filtered_downloads:
@@ -195,6 +222,9 @@ class QueryDownloadTasksTool(MoviePilotTool):
status = kwargs.get("status", "all")
hash_value = kwargs.get("hash")
title = kwargs.get("title")
include_all_tags = self._normalize_include_all_tags(
kwargs.get("include_all_tags", False)
)
parts = ["查询下载任务"]
@@ -213,6 +243,8 @@ class QueryDownloadTasksTool(MoviePilotTool):
tag = kwargs.get("tag")
if tag:
parts.append(f"标签: {tag}")
if include_all_tags:
parts.append("范围: 全部标签")
return " | ".join(parts) if len(parts) > 1 else parts[0]
@@ -220,8 +252,13 @@ class QueryDownloadTasksTool(MoviePilotTool):
status: Optional[str] = "all",
hash: Optional[str] = None,
title: Optional[str] = None,
tag: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, hash={hash}, title={title}, tag={tag}")
tag: Optional[str] = None,
include_all_tags: Optional[bool] = False,
**kwargs) -> str:
logger.info(
f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, "
f"hash={hash}, title={title}, tag={tag}, include_all_tags={include_all_tags}"
)
try:
payload = await self.run_blocking(
"downloader",
@@ -231,6 +268,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
hash,
title,
tag,
self._normalize_include_all_tags(include_all_tags),
)
if payload.get("message"):
return payload["message"]

View File

@@ -0,0 +1,167 @@
"""识别图形验证码工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.helper.browser import BrowserSessionHelper
from app.helper.ocr import OcrHelper
from app.log import logger
class RecognizeCaptchaInput(BaseModel):
"""识别图形验证码工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this captcha image needs to be recognized",
)
image_url: str = Field(
...,
description=(
"Captcha image URL obtained from the browser page, usually an img.src value. "
"Supports http/https URLs and data:image/...;base64,... URLs."
),
)
cookie: Optional[str] = Field(
None,
description=(
"Optional Cookie header used to download the captcha image when the image URL "
"requires the same authenticated browser session."
),
)
user_agent: Optional[str] = Field(
None,
description="Optional User-Agent used when downloading the captcha image.",
)
allow_private_network: bool = Field(
False,
description="Allow captcha image URLs on localhost, loopback, private, or link-local addresses.",
)
class RecognizeCaptchaTool(MoviePilotTool):
"""
图形验证码识别工具,供 Agent 在浏览器自动化登录时读取验证码文本。
"""
name: str = "recognize_captcha"
tags: list[str] = [
ToolTag.Read,
ToolTag.Web,
]
description: str = (
"Recognize a graphic captcha image and return the captcha text. "
"Use this after browser automation extracts a captcha img.src from the page. "
"Pass cookie and user_agent when the image URL requires the current browser session. "
"Supports http/https image URLs and data:image/...;base64,... URLs. "
"For safety, localhost and private network URLs are blocked by default unless "
"allow_private_network is true."
)
args_schema: Type[BaseModel] = RecognizeCaptchaInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据验证码图片参数生成友好的提示消息。"""
image_url = str(kwargs.get("image_url") or "")
if image_url.lower().startswith("data:image/"):
return "识别图形验证码: data image"
return f"识别图形验证码: {image_url}"
@staticmethod
def _recognize_captcha_sync(
image_url: str,
cookie: Optional[str] = None,
user_agent: Optional[str] = None,
allow_private_network: bool = False,
) -> str:
"""
在线程池中下载并识别验证码图片。
:param image_url: 验证码图片地址
:param cookie: 下载图片时使用的 Cookie
:param user_agent: 下载图片时使用的 User-Agent
:param allow_private_network: 是否允许访问本机或私网地址
:return: 验证码文本,失败时返回空字符串
"""
clean_url = (image_url or "").strip()
if not clean_url:
return ""
if not clean_url.lower().startswith("data:image/"):
BrowserSessionHelper.validate_url(
clean_url,
allow_private_network=allow_private_network,
)
return OcrHelper().get_captcha_text(
image_url=clean_url,
cookie=cookie,
ua=user_agent,
)
async def run(
self,
image_url: str,
cookie: Optional[str] = None,
user_agent: Optional[str] = None,
allow_private_network: bool = False,
**kwargs,
) -> str:
"""
识别指定图片地址中的图形验证码文本。
:param image_url: 验证码图片地址
:param cookie: 下载图片时使用的 Cookie
:param user_agent: 下载图片时使用的 User-Agent
:param allow_private_network: 是否允许访问本机或私网地址
:return: JSON 格式的识别结果
"""
logger.info(f"执行工具: {self.name}, 参数: image_url={image_url}")
try:
captcha_text = await self.run_blocking(
"web",
self._recognize_captcha_sync,
image_url,
cookie,
user_agent,
allow_private_network,
)
if captcha_text:
return json.dumps(
{
"success": True,
"captcha_text": captcha_text,
"message": "验证码识别成功",
},
ensure_ascii=False,
)
return json.dumps(
{
"success": False,
"captcha_text": "",
"message": "验证码识别失败或未返回内容",
},
ensure_ascii=False,
)
except ValueError as err:
logger.warning(f"验证码图片地址校验失败: {str(err)}")
return json.dumps(
{
"success": False,
"captcha_text": "",
"message": str(err),
},
ensure_ascii=False,
)
except Exception as err:
logger.error(f"识别图形验证码失败: {str(err)}", exc_info=True)
return json.dumps(
{
"success": False,
"captcha_text": "",
"message": f"识别图形验证码时发生错误: {str(err)}",
},
ensure_ascii=False,
)

View File

@@ -17,7 +17,7 @@ from app.schemas.types import SystemConfigKey
router = APIRouter()
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
@router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent])
def current(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:

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

@@ -28,9 +28,8 @@ from app.log import logger
from app.schemas import (
RateLimitExceededException,
TransferInfo,
TransferTorrent,
ExistMediaInfo,
DownloadingTorrent,
DownloaderTorrent,
CommingMessage,
Notification,
WebhookEventInfo,
@@ -1221,16 +1220,22 @@ class ChainBase(metaclass=ABCMeta):
status: TorrentStatus = None,
hashs: Union[list, str] = None,
downloader: Optional[str] = None,
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
"""
获取下载器种子列表
:param status: 种子状态
:param hashs: 种子Hash
:param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表
"""
return self.run_module(
"list_torrents", status=status, hashs=hashs, downloader=downloader
"list_torrents",
status=status,
hashs=hashs,
downloader=downloader,
include_all_tags=include_all_tags,
)
def transfer(

View File

@@ -22,7 +22,7 @@ from app.helper.directory import DirectoryHelper
from app.helper.thread import ThreadHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, \
from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTorrent, Notification, ResourceSelectionEventData, \
ResourceDownloadEventData
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
ChainEventType
@@ -1359,7 +1359,7 @@ class DownloadChain(ChainBase):
link=settings.MP_DOMAIN('#/downloading')
))
def downloading(self, name: Optional[str] = None) -> List[DownloadingTorrent]:
def downloading(self, name: Optional[str] = None) -> List[DownloaderTorrent]:
"""
查询正在下载的任务
"""
@@ -1417,7 +1417,7 @@ class DownloadChain(ChainBase):
return
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
# 先查询种子
torrents: List[schemas.TransferTorrent] = self.list_torrents(hashs=[hash_str])
torrents: List[schemas.DownloaderTorrent] = self.list_torrents(hashs=[hash_str])
if torrents:
self.remove_torrents(hashs=[hash_str], delete_file=False)
# 发出下载任务删除事件,如需处理辅种,可监听该事件

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

@@ -6,31 +6,63 @@ from app.utils.http import RequestUtils
class OcrHelper:
"""
OCR 辅助类,负责获取验证码图片并调用 OCR 服务识别文本。
"""
_ocr_b64_url = f"{settings.OCR_HOST}/captcha/base64"
def get_captcha_text(self, image_url: Optional[str] = None, image_b64: Optional[str] = None,
cookie: Optional[str] = None, ua: Optional[str] = None):
def get_captcha_text(
self,
image_url: Optional[str] = None,
image_b64: Optional[str] = None,
cookie: Optional[str] = None,
ua: Optional[str] = None,
) -> str:
"""
根据图片地址,获取验证码图片,并识别内容
:param image_url: 图片地址
:param image_b64: 图片base64跳过图片地址下载
:param cookie: 下载图片使用的cookie
:param ua: 下载图片使用的ua
:return: 验证码识别结果,失败时返回空字符串
"""
image_b64 = self._normalize_image_base64(image_b64)
if image_url:
ret = RequestUtils(ua=ua,
cookies=cookie).get_res(image_url)
if ret is not None:
image_bin = ret.content
if not image_bin:
return ""
image_b64 = base64.b64encode(image_bin).decode()
data_url_b64 = self._extract_data_url_base64(image_url)
if data_url_b64:
image_b64 = data_url_b64
else:
ret = RequestUtils(ua=ua,
cookies=cookie).get_res(image_url)
if ret is not None:
image_bin = ret.content
if not image_bin:
return ""
image_b64 = base64.b64encode(image_bin).decode()
if not image_b64:
return ""
ret = RequestUtils(content_type="application/json").post_res(
url=self._ocr_b64_url,
json={"base64_img": image_b64})
if ret:
return ret.json().get("result")
return ret.json().get("result") or ""
return ""
@staticmethod
def _normalize_image_base64(image_b64: Optional[str]) -> str:
"""规范化外部传入的图片 base64 内容。"""
if not image_b64:
return ""
return OcrHelper._extract_data_url_base64(image_b64) or image_b64.strip()
@staticmethod
def _extract_data_url_base64(image_url: Optional[str]) -> str:
"""从 data:image/...;base64,... 地址中提取纯 base64 内容。"""
image_url = (image_url or "").strip()
if not image_url.lower().startswith("data:image/"):
return ""
metadata, separator, data = image_url.partition(",")
if not separator or ";base64" not in metadata.lower():
return ""
return data.strip()

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

@@ -11,10 +11,33 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase
from app.modules.qbittorrent.qbittorrent import Qbittorrent
from app.schemas import TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
from app.schemas import DownloaderTorrent
from app.schemas.types import (
DownloadTaskState,
DownloaderType,
ModuleType,
TorrentQueryStatus,
TorrentStatus,
)
from app.utils.string import StringUtils
_QBITTORRENT_DOWNLOADING_STATES = {
"allocating",
"checkingdl",
"downloading",
"forceddl",
"metadl",
"queueddl",
"stalleddl",
}
_QBITTORRENT_PAUSED_STATES = {
"paused",
"pauseddl",
"pausedup",
"stoppeddl",
"stoppedup",
}
class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
@@ -236,13 +259,15 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
def list_torrents(self, status: TorrentStatus = None,
hashs: Union[list, str] = None,
downloader: Optional[str] = None
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
downloader: Optional[str] = None,
include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
"""
获取下载器种子列表
:param status: 种子状态
:param hashs: 种子Hash
:param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表
"""
# 获取下载器
@@ -254,80 +279,96 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
else:
servers: Dict[str, Qbittorrent] = self.get_instances()
ret_torrents = []
query_status = self.__normalize_query_status(status)
query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_path(torrent_data: dict) -> Path:
"""
获取种子内容路径。
"""
content_path = torrent_data.get("content_path")
if content_path:
return Path(content_path)
return Path(torrent_data.get('save_path')) / torrent_data.get('name')
def __build_torrent(downloader_name: str, torrent_data: dict) -> DownloaderTorrent:
"""
构造统一下载器任务对象。
"""
meta = MetaInfo(torrent_data.get('name'))
torrent_path = __get_torrent_path(torrent_data)
dlspeed = torrent_data.get('dlspeed') or 0
total_size = torrent_data.get('total_size') or 0
completed_size = torrent_data.get('completed') or 0
return DownloaderTorrent(
downloader=downloader_name,
title=torrent_data.get('name'),
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
hash=torrent_data.get('hash'),
size=total_size,
tags=torrent_data.get('tags'),
progress=(torrent_data.get('progress') or 0) * 100,
state=self.__normalize_torrent_state(torrent_data.get('state')),
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(torrent_data.get('upspeed')),
left_time=StringUtils.str_secends(
(total_size - completed_size) / dlspeed
) if dlspeed > 0 else '',
)
if hashs:
# 按Hash获取
for name, server in servers.items():
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []
torrents, _ = server.get_torrents(ids=hashs, tags=query_tags) or []
try:
for torrent in torrents:
content_path = torrent.get("content_path")
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.get('name'),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get('hash'),
size=torrent.get('total_size'),
tags=torrent.get('tags'),
progress=torrent.get('progress') * 100,
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
))
for torrent_info in torrents:
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif status == TorrentStatus.TRANSFER:
elif query_status == TorrentQueryStatus.TRANSFER:
# 获取已完成且未整理的
for name, server in servers.items():
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or []
torrents = server.get_completed_torrents(tags=query_tags) or []
try:
for torrent in torrents:
tags = torrent.get("tags") or []
for torrent_info in torrents:
tags = torrent_info.get("tags") or []
if "已整理" in tags:
continue
# 内容路径
content_path = torrent.get("content_path")
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = torrent.get('save_path') / torrent.get('name')
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.get('name'),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get('hash'),
tags=torrent.get('tags')
))
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif status == TorrentStatus.DOWNLOADING:
elif query_status == TorrentQueryStatus.DOWNLOADING:
# 获取正在下载的任务
for name, server in servers.items():
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []
torrents = server.get_downloading_torrents(tags=query_tags) or []
try:
for torrent in torrents:
meta = MetaInfo(torrent.get('name'))
ret_torrents.append(DownloadingTorrent(
downloader=name,
hash=torrent.get('hash'),
title=torrent.get('name'),
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
progress=torrent.get('progress') * 100,
size=torrent.get('total_size'),
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
tags=torrent.get('tags'),
left_time=StringUtils.str_secends(
(torrent.get('total_size') - torrent.get('completed')) / torrent.get(
'dlspeed')) if torrent.get(
'dlspeed') > 0 else ''
))
for torrent_info in torrents:
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif query_status in (
TorrentQueryStatus.ALL,
TorrentQueryStatus.COMPLETED,
TorrentQueryStatus.PAUSED,
):
# 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
for name, server in servers.items():
torrents, _ = server.get_torrents(tags=query_tags) or []
try:
for torrent_info in torrents:
torrent_state = self.__normalize_torrent_state(torrent_info.get('state'))
if (
query_status != TorrentQueryStatus.ALL
and torrent_state != query_status.value
):
continue
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
@@ -335,6 +376,53 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
return None
return ret_torrents # noqa
@staticmethod
def __normalize_query_status(
status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]]
) -> TorrentQueryStatus:
"""
归一任务查询状态。
"""
status_value = getattr(status, "value", status)
status_text = str(status_value or "").strip().lower()
if not status_text or status_text in {"all", "全部"}:
return TorrentQueryStatus.ALL
if status_text in {
TorrentStatus.TRANSFER.value,
TorrentQueryStatus.TRANSFER.value,
"transfer",
}:
return TorrentQueryStatus.TRANSFER
if status_text in {
TorrentStatus.DOWNLOADING.value,
TorrentQueryStatus.DOWNLOADING.value,
"downloading",
}:
return TorrentQueryStatus.DOWNLOADING
if status_text in {
TorrentQueryStatus.COMPLETED.value,
"complete",
"seeding",
"完成",
"已完成",
}:
return TorrentQueryStatus.COMPLETED
if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}:
return TorrentQueryStatus.PAUSED
return TorrentQueryStatus.ALL
@staticmethod
def __normalize_torrent_state(state: Optional[Union[str, int]]) -> str:
"""
归一 qBittorrent 原始任务状态。
"""
state_text = str(state or "").strip().lower()
if state_text in _QBITTORRENT_PAUSED_STATES:
return DownloadTaskState.PAUSED.value
if state_text in _QBITTORRENT_DOWNLOADING_STATES:
return DownloadTaskState.DOWNLOADING.value
return DownloadTaskState.COMPLETED.value
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
"""
转移完成后的处理

View File

@@ -10,8 +10,14 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase
from app.modules.rtorrent.rtorrent import Rtorrent
from app.schemas import TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
from app.schemas import DownloaderTorrent
from app.schemas.types import (
DownloadTaskState,
DownloaderType,
ModuleType,
TorrentQueryStatus,
TorrentStatus,
)
from app.utils.string import StringUtils
@@ -283,12 +289,14 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
status: TorrentStatus = None,
hashs: Union[list, str] = None,
downloader: Optional[str] = None,
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
"""
获取下载器种子列表
:param status: 种子状态
:param hashs: 种子Hash
:param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表
"""
# 获取下载器
@@ -300,105 +308,108 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
else:
servers: Dict[str, Rtorrent] = self.get_instances()
ret_torrents = []
query_status = self.__normalize_query_status(status)
query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_path(torrent_data: dict) -> Path:
"""
获取种子内容路径。
"""
content_path = torrent_data.get("content_path")
if content_path:
return Path(content_path)
return Path(torrent_data.get("save_path")) / torrent_data.get("name")
def __build_torrent(downloader_name: str, torrent_data: dict) -> DownloaderTorrent:
"""
构造统一下载器任务对象。
"""
meta = MetaInfo(torrent_data.get("name"))
dlspeed = torrent_data.get("dlspeed") or 0
upspeed = torrent_data.get("upspeed") or 0
total_size = torrent_data.get("total_size") or 0
completed_size = torrent_data.get("completed") or 0
torrent_path = __get_torrent_path(torrent_data)
return DownloaderTorrent(
downloader=downloader_name,
hash=torrent_data.get("hash"),
title=torrent_data.get("name"),
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
progress=torrent_data.get("progress", 0),
size=total_size,
state=self.__normalize_torrent_state(
torrent_data.get("state"), torrent_data.get("complete")
),
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(upspeed),
tags=torrent_data.get("tags"),
left_time=StringUtils.str_secends((total_size - completed_size) / dlspeed)
if dlspeed > 0
else "",
)
if hashs:
# 按Hash获取
for name, server in servers.items():
torrents, _ = (
server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []
server.get_torrents(ids=hashs, tags=query_tags) or []
)
try:
for torrent in torrents:
content_path = torrent.get("content_path")
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = Path(torrent.get("save_path")) / torrent.get(
"name"
)
ret_torrents.append(
TransferTorrent(
downloader=name,
title=torrent.get("name"),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get("hash"),
size=torrent.get("total_size"),
tags=torrent.get("tags"),
progress=torrent.get("progress", 0),
state="paused"
if torrent.get("state") == 0
else "downloading",
)
)
for torrent_info in torrents:
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif status == TorrentStatus.TRANSFER:
elif query_status == TorrentQueryStatus.TRANSFER:
# 获取已完成且未整理的
for name, server in servers.items():
torrents = (
server.get_completed_torrents(tags=settings.TORRENT_TAG) or []
server.get_completed_torrents(tags=query_tags) or []
)
try:
for torrent in torrents:
tags = torrent.get("tags") or ""
for torrent_info in torrents:
tags = torrent_info.get("tags") or ""
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if "已整理" in tag_list:
continue
content_path = torrent.get("content_path")
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = Path(torrent.get("save_path")) / torrent.get(
"name"
)
ret_torrents.append(
TransferTorrent(
downloader=name,
title=torrent.get("name"),
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.get("hash"),
tags=torrent.get("tags"),
)
)
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif status == TorrentStatus.DOWNLOADING:
elif query_status == TorrentQueryStatus.DOWNLOADING:
# 获取正在下载的任务
for name, server in servers.items():
torrents = (
server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []
server.get_downloading_torrents(tags=query_tags) or []
)
try:
for torrent in torrents:
meta = MetaInfo(torrent.get("name"))
dlspeed = torrent.get("dlspeed", 0)
upspeed = torrent.get("upspeed", 0)
total_size = torrent.get("total_size", 0)
completed = torrent.get("completed", 0)
ret_torrents.append(
DownloadingTorrent(
downloader=name,
hash=torrent.get("hash"),
title=torrent.get("name"),
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
progress=torrent.get("progress", 0),
size=total_size,
state="paused"
if torrent.get("state") == 0
else "downloading",
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(upspeed),
tags=torrent.get("tags"),
left_time=StringUtils.str_secends(
(total_size - completed) / dlspeed
)
if dlspeed > 0
else "",
)
for torrent_info in torrents:
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif query_status in (
TorrentQueryStatus.ALL,
TorrentQueryStatus.COMPLETED,
TorrentQueryStatus.PAUSED,
):
# 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
for name, server in servers.items():
torrents, _ = server.get_torrents(tags=query_tags) or []
try:
for torrent_info in torrents:
torrent_state = self.__normalize_torrent_state(
torrent_info.get("state"), torrent_info.get("complete")
)
if (
query_status != TorrentQueryStatus.ALL
and torrent_state != query_status.value
):
continue
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
@@ -406,6 +417,55 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
return None
return ret_torrents # noqa
@staticmethod
def __normalize_query_status(
status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]]
) -> TorrentQueryStatus:
"""
归一任务查询状态。
"""
status_value = getattr(status, "value", status)
status_text = str(status_value or "").strip().lower()
if not status_text or status_text in {"all", "全部"}:
return TorrentQueryStatus.ALL
if status_text in {
TorrentStatus.TRANSFER.value,
TorrentQueryStatus.TRANSFER.value,
"transfer",
}:
return TorrentQueryStatus.TRANSFER
if status_text in {
TorrentStatus.DOWNLOADING.value,
TorrentQueryStatus.DOWNLOADING.value,
"downloading",
}:
return TorrentQueryStatus.DOWNLOADING
if status_text in {
TorrentQueryStatus.COMPLETED.value,
"complete",
"seeding",
"完成",
"已完成",
}:
return TorrentQueryStatus.COMPLETED
if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}:
return TorrentQueryStatus.PAUSED
return TorrentQueryStatus.ALL
@staticmethod
def __normalize_torrent_state(
state: Optional[Union[int, str]],
complete: Optional[Union[int, str]],
) -> str:
"""
归一 rTorrent 原始任务状态。
"""
if str(state) == "0":
return DownloadTaskState.PAUSED.value
if str(complete) == "0":
return DownloadTaskState.DOWNLOADING.value
return DownloadTaskState.COMPLETED.value
def transfer_completed(
self, hashs: Union[str, list], downloader: Optional[str] = None
) -> None:

View File

@@ -11,10 +11,24 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase, _DownloaderBase
from app.modules.transmission.transmission import Transmission
from app.schemas import TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus, ModuleType, DownloaderType
from app.schemas import DownloaderTorrent
from app.schemas.types import (
DownloadTaskState,
DownloaderType,
ModuleType,
TorrentQueryStatus,
TorrentStatus,
)
from app.utils.string import StringUtils
_TRANSMISSION_DOWNLOADING_STATES = {
"download_pending",
"downloading",
}
_TRANSMISSION_PAUSED_STATES = {
"stopped",
}
class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
@@ -225,13 +239,15 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
def list_torrents(self, status: TorrentStatus = None,
hashs: Union[list, str] = None,
downloader: Optional[str] = None
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
downloader: Optional[str] = None,
include_all_tags: bool = False,
) -> Optional[List[DownloaderTorrent]]:
"""
获取下载器种子列表
:param status: 种子状态
:param hashs: 种子Hash
:param downloader: 下载器
:param include_all_tags: 是否包含未打内置标签的下载任务
:return: 下载器中符合状态的种子列表
"""
# 获取下载器
@@ -243,77 +259,132 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
else:
servers: Dict[str, Transmission] = self.get_instances()
ret_torrents = []
query_status = self.__normalize_query_status(status)
query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_attr(torrent_data, *attr_names):
"""
兼容 transmission-rpc 新旧字段名。
"""
for attr_name in attr_names:
if hasattr(torrent_data, attr_name):
return getattr(torrent_data, attr_name)
return None
def __get_torrent_progress(torrent_data) -> float:
"""
获取任务进度。
"""
return __get_torrent_attr(torrent_data, "progress", "percent_done") or 0
def __get_torrent_size(torrent_data) -> int:
"""
获取任务大小。
"""
return __get_torrent_attr(torrent_data, "total_size", "totalSize") or 0
def __get_torrent_labels(torrent_data) -> str:
"""
获取任务标签。
"""
return ",".join(getattr(torrent_data, "labels", None) or [])
def __get_torrent_path(torrent_data) -> Path:
"""
获取任务内容路径。
"""
return Path(torrent_data.download_dir) / torrent_data.name
def __build_torrent(downloader_name: str, torrent_data) -> DownloaderTorrent:
"""
构造统一下载器任务对象。
"""
meta = MetaInfo(torrent_data.name)
dlspeed = __get_torrent_attr(
torrent_data, "rate_download", "rateDownload"
) or 0
upspeed = __get_torrent_attr(
torrent_data, "rate_upload", "rateUpload"
) or 0
left_until_done = __get_torrent_attr(
torrent_data, "left_until_done", "leftUntilDone"
) or 0
torrent_path = __get_torrent_path(torrent_data)
return DownloaderTorrent(
downloader=downloader_name,
hash=torrent_data.hashString,
title=torrent_data.name,
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
path=Path(self.normalize_return_path(torrent_path, downloader_name)),
progress=__get_torrent_progress(torrent_data),
size=__get_torrent_size(torrent_data),
state=self.__normalize_torrent_state(torrent_data.status),
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(upspeed),
tags=__get_torrent_labels(torrent_data),
left_time=StringUtils.str_secends(
left_until_done / dlspeed
) if dlspeed > 0 else ''
)
if hashs:
# 按Hash获取
for name, server in servers.items():
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []
torrents, _ = server.get_torrents(ids=hashs, tags=query_tags) or []
try:
for torrent in torrents:
torrent_path = Path(torrent.download_dir) / torrent.name
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.name,
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.hashString,
size=torrent.total_size,
tags=",".join(torrent.labels or []),
progress=torrent.progress
))
for torrent_info in torrents:
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif status == TorrentStatus.TRANSFER:
elif query_status == TorrentQueryStatus.TRANSFER:
# 获取已完成且未整理的
for name, server in servers.items():
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or []
torrents = server.get_completed_torrents(tags=query_tags) or []
try:
for torrent in torrents:
for torrent_info in torrents:
# 含"已整理"tag的不处理
if "已整理" in torrent.labels or []:
if "已整理" in torrent_info.labels or []:
continue
# 下载路径
path = torrent.download_dir
path = torrent_info.download_dir
# 无法获取下载路径的不处理
if not path:
logger.debug(f"未获取到 {torrent.name} 下载保存路径")
logger.debug(f"未获取到 {torrent_info.name} 下载保存路径")
continue
torrent_path = Path(torrent.download_dir) / torrent.name
ret_torrents.append(TransferTorrent(
downloader=name,
title=torrent.name,
path=Path(self.normalize_return_path(torrent_path, name)),
hash=torrent.hashString,
tags=",".join(torrent.labels or []),
progress=torrent.progress,
state="paused" if torrent.status == "stopped" else "downloading",
))
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif status == TorrentStatus.DOWNLOADING:
elif query_status == TorrentQueryStatus.DOWNLOADING:
# 获取正在下载的任务
for name, server in servers.items():
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []
torrents = server.get_downloading_torrents(tags=query_tags) or []
try:
for torrent in torrents:
meta = MetaInfo(torrent.name)
dlspeed = torrent.rate_download if hasattr(torrent, "rate_download") else torrent.rateDownload
upspeed = torrent.rate_upload if hasattr(torrent, "rate_upload") else torrent.rateUpload
ret_torrents.append(DownloadingTorrent(
downloader=name,
hash=torrent.hashString,
title=torrent.name,
name=meta.name,
year=meta.year,
season_episode=meta.season_episode,
progress=torrent.progress,
size=torrent.total_size,
state="paused" if torrent.status == "stopped" else "downloading",
dlspeed=StringUtils.str_filesize(dlspeed),
upspeed=StringUtils.str_filesize(upspeed),
tags=",".join(torrent.labels or []),
left_time=StringUtils.str_secends(torrent.left_until_done / dlspeed) if dlspeed > 0 else ''
))
for torrent_info in torrents:
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
elif query_status in (
TorrentQueryStatus.ALL,
TorrentQueryStatus.COMPLETED,
TorrentQueryStatus.PAUSED,
):
# 获取完整任务列表,由 MoviePilot 统一归一实际下载器状态。
for name, server in servers.items():
torrents, _ = server.get_torrents(tags=query_tags) or []
try:
for torrent_info in torrents:
torrent_state = self.__normalize_torrent_state(torrent_info.status)
if (
query_status != TorrentQueryStatus.ALL
and torrent_state != query_status.value
):
continue
ret_torrents.append(__build_torrent(name, torrent_info))
finally:
torrents.clear()
del torrents
@@ -321,6 +392,53 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
return None
return ret_torrents # noqa
@staticmethod
def __normalize_query_status(
status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]]
) -> TorrentQueryStatus:
"""
归一任务查询状态。
"""
status_value = getattr(status, "value", status)
status_text = str(status_value or "").strip().lower()
if not status_text or status_text in {"all", "全部"}:
return TorrentQueryStatus.ALL
if status_text in {
TorrentStatus.TRANSFER.value,
TorrentQueryStatus.TRANSFER.value,
"transfer",
}:
return TorrentQueryStatus.TRANSFER
if status_text in {
TorrentStatus.DOWNLOADING.value,
TorrentQueryStatus.DOWNLOADING.value,
"downloading",
}:
return TorrentQueryStatus.DOWNLOADING
if status_text in {
TorrentQueryStatus.COMPLETED.value,
"complete",
"seeding",
"完成",
"已完成",
}:
return TorrentQueryStatus.COMPLETED
if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}:
return TorrentQueryStatus.PAUSED
return TorrentQueryStatus.ALL
@staticmethod
def __normalize_torrent_state(status: Optional[str]) -> str:
"""
归一 Transmission 原始任务状态。
"""
status_text = str(status or "").strip().lower()
if status_text in _TRANSMISSION_PAUSED_STATES:
return DownloadTaskState.PAUSED.value
if status_text in _TRANSMISSION_DOWNLOADING_STATES:
return DownloadTaskState.DOWNLOADING.value
return DownloadTaskState.COMPLETED.value
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
"""
转移完成后的处理

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

@@ -10,24 +10,9 @@ from app.schemas.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode
class TransferTorrent(BaseModel):
class DownloaderTorrent(BaseModel):
"""
待转移任务信息
"""
downloader: Optional[str] = None
title: Optional[str] = None
path: Optional[Path] = None
hash: Optional[str] = None
tags: Optional[str] = None
size: Optional[int] = 0
userid: Optional[str] = None
progress: Optional[float] = 0.0
state: Optional[str] = None
class DownloadingTorrent(BaseModel):
"""
下载中任务信息
下载器任务信息
"""
downloader: Optional[str] = None
hash: Optional[str] = None
@@ -35,6 +20,7 @@ class DownloadingTorrent(BaseModel):
name: Optional[str] = None
year: Optional[str] = None
season_episode: Optional[str] = None
path: Optional[Path] = None
size: Optional[float] = 0.0
progress: Optional[float] = 0.0
state: Optional[str] = 'downloading'
@@ -47,6 +33,18 @@ class DownloadingTorrent(BaseModel):
left_time: Optional[str] = None
class TransferTorrent(DownloaderTorrent):
"""
待转移任务信息
"""
class DownloadingTorrent(DownloaderTorrent):
"""
下载中任务信息
"""
class TransferTask(BaseModel):
"""
文件整理任务

View File

@@ -43,6 +43,22 @@ class TorrentStatus(Enum):
DOWNLOADING = "下载中"
# 下载器任务查询状态
class TorrentQueryStatus(Enum):
ALL = "all"
TRANSFER = "transfer"
DOWNLOADING = "downloading"
COMPLETED = "completed"
PAUSED = "paused"
# 下载器任务归一状态
class DownloadTaskState(Enum):
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
# 异步广播事件
class EventType(Enum):
# 插件需要重载

View File

@@ -108,12 +108,15 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 |
| DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` |
MCP 工具 `query_download_tasks` 支持 `status=all|downloading|completed|paused`;其中 `completed` 表示下载器任务既不是下载中,也不是暂停状态。默认仅查询带 MoviePilot 内置标签的任务;如需诊断下载器中未打内置标签的任务,可传 `include_all_tags=true`
#### 系统
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| 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` 合并去重后写入配置 |
### 插件补充接口
@@ -221,6 +224,20 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
`browse_webpage` 使用持久浏览器会话,默认以当前 Agent 会话作为 `session_key``goto``snapshot``click``click_ref``fill``fill_ref``select``select_ref``wait` 等动作会返回页面快照,快照中的 `interactive_elements[].ref` 可用于后续 `*_ref` 操作。支持 `list_tabs``open_tab``focus_tab``close_tab` 管理标签页,支持 `close_session` 释放会话。出于安全考虑,默认拒绝访问 localhost、环回地址、私网地址和链路本地地址确需访问可信内网或本机页面时可显式传入 `allow_private_network: true`
**`recognize_captcha` 图形验证码识别示例**:
```json
{
"tool_name": "recognize_captcha",
"arguments": {
"image_url": "https://example.com/captcha.png",
"cookie": "sid=...",
"user_agent": "Mozilla/5.0 ..."
}
}
```
`recognize_captcha` 用于浏览器自动化登录时识别普通图形验证码。智能体可先通过 `browse_webpage``evaluate` 动作从页面元素中提取 `img.src`,再把图片地址传给该工具;支持 `http/https` 图片地址和 `data:image/...;base64,...`。当验证码图片依赖当前浏览器会话时,可传入 Cookie 与 User-Agent。出于安全考虑默认拒绝访问 localhost、环回地址、私网地址和链路本地地址确需访问可信内网或本机验证码图片时可显式传入 `allow_private_network: true`
### 3. 获取工具详情
**GET** `/api/v1/mcp/tools/{tool_name}`

View File

@@ -8,7 +8,7 @@ description: >-
interaction, such as checking a site page, confirming a JavaScript-rendered
result, testing login state, capturing visible errors, or updating and
validating tracker site cookies.
allowed-tools: browse_webpage search_web query_sites update_site_cookie test_site update_site
allowed-tools: browse_webpage recognize_captcha search_web query_sites update_site_cookie test_site update_site
---
# Browser Use
@@ -41,6 +41,9 @@ dedicated tool can complete the task more directly and safely.
`get_content`, `screenshot`, `click`, `click_ref`, `fill`, `fill_ref`,
`select`, `select_ref`, `evaluate`, `wait`, `list_tabs`, `open_tab`,
`focus_tab`, `close_tab`, `close_session`.
- `recognize_captcha` - Recognize graphic captcha text from an image URL or
`data:image/...;base64,...` value extracted from the page. Pass Cookie and
User-Agent when the image requires the current browser session.
- `search_web` - Find current pages or official references before opening a
target URL. It supports DDGS-backed `search_engine` (`auto`, `duckduckgo`,
`google`, `brave`, etc.) and `site_url` for limiting results to a specified
@@ -174,6 +177,28 @@ update_site_cookie site_identifier=<id> username="..." password="..." two_step_c
Ask for missing username, password, or two-step code only when required for the
operation. Do not expose secrets in the final answer.
### Login Page With A Graphic Captcha
When a user explicitly asks to complete a login flow that contains a normal
graphic captcha:
1. Open the login page and inspect the form with `snapshot`.
2. Extract the captcha image URL with `evaluate`, for example:
```text
browse_webpage action="evaluate" script="() => document.querySelector('img[src*=\"captcha\"], img[alt*=\"验证码\"], img[title*=\"验证码\"]')?.src || ''"
```
3. If the captcha image needs session cookies, extract `document.cookie` and the
current `navigator.userAgent` with `evaluate`.
4. Call `recognize_captcha image_url="<img.src>"` and pass `cookie` /
`user_agent` when needed.
5. Fill the returned `captcha_text`, submit the form, and verify the login
result.
If recognition fails, refresh the captcha once and retry. Stop after a second
failure and tell the user manual input is needed.
### Inspect A Tracker Page
When the user asks what is visible on a site page:
@@ -187,8 +212,9 @@ When the user asks what is visible on a site page:
- Ask before submitting forms that create, delete, purchase, publish, or change
account/security settings.
- Never solve captchas, bypass access controls, or scrape private content beyond
the user's explicit task.
- Solve graphic captchas only for a user-requested login flow. Do not use this
to bypass access controls, defeat anti-bot challenges, or scrape private
content beyond the user's explicit task.
- Do not print passwords, tokens, cookies, two-step secrets, or full session
headers in the response.
- Localhost, loopback, private, and link-local URLs are blocked by default. Set

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

@@ -126,6 +126,8 @@ Subscribe starting from a specific episode:
List download tasks and get hash for further operations:
`node scripts/mp-cli.js query_download_tasks status=downloading`
Use `status=completed` for tasks that are neither downloading nor paused in the downloader; use `status=all` to include every MoviePilot-tagged downloader task. Add `include_all_tags=true` when diagnosing tasks that do not have the MoviePilot built-in tag.
Delete a download task (confirm with user first — irreversible):
`node scripts/mp-cli.js delete_download hash=<hash>`

View File

@@ -0,0 +1,199 @@
import asyncio
import json
from unittest.mock import MagicMock, patch
from app.agent.tools.impl.query_download_tasks import QueryDownloadTasksTool
from app.schemas import DownloaderTorrent
def test_completed_status_returns_qbittorrent_and_transmission_completed_states():
"""
按完成状态查询时应包含 QB/TR 中非下载中、非暂停的实际状态。
"""
completed_torrents = [
DownloaderTorrent(
downloader="qb",
hash="hash-qb",
title="QB Done",
size=1024,
progress=100,
state="completed",
tags="moviepilot",
),
DownloaderTorrent(
downloader="tr",
hash="hash-tr",
title="TR Done",
size=2048,
progress=100,
state="completed",
tags="moviepilot",
),
]
download_chain = MagicMock()
download_chain.list_torrents.return_value = completed_torrents
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(status="completed")
assert result["downloads"] == completed_torrents
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status="completed",
include_all_tags=False,
)
def test_run_completed_status_formats_completed_download_tasks():
"""
工具输出应保留完成任务的实际下载器状态,便于用户判断来源。
"""
completed_torrents = [
DownloaderTorrent(
downloader="qb",
hash="hash-qb",
title="QB Done",
size=1024,
progress=100,
state="completed",
tags="moviepilot",
)
]
with patch.object(
QueryDownloadTasksTool,
"_query_downloads_sync",
return_value={"downloads": completed_torrents},
):
result = asyncio.run(
QueryDownloadTasksTool(session_id="session-1", user_id="10001").run(
status="completed"
)
)
payload = json.loads(result)
assert payload[0]["hash"] == "hash-qb"
assert payload[0]["state"] == "completed"
def test_include_all_tags_passes_scope_to_downloader_query():
"""
智能体显式扩大范围时,应查询未打 MoviePilot 内置标签的下载任务。
"""
all_scope_torrents = [
DownloaderTorrent(
downloader="qb",
hash="hash-external",
title="External Task",
size=1024,
progress=10,
state="downloading",
tags="external",
)
]
download_chain = MagicMock()
download_chain.list_torrents.return_value = all_scope_torrents
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(
status="all",
include_all_tags=True,
)
assert result["downloads"] == all_scope_torrents
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status=None,
include_all_tags=True,
)
def test_include_all_tags_downloading_status_uses_list_torrents():
"""
查询全部标签范围的下载中任务时,不应走只面向 MoviePilot 任务的便捷方法。
"""
download_chain = MagicMock()
download_chain.list_torrents.return_value = [
DownloaderTorrent(
downloader="tr",
hash="hash-downloading",
title="Downloading External",
size=2048,
progress=50,
state="downloading",
tags="external",
)
]
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(
status="downloading",
include_all_tags=True,
)
assert result["downloads"][0].hash == "hash-downloading"
download_chain.downloading.assert_not_called()
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status="downloading",
include_all_tags=True,
)
def test_include_all_tags_false_string_keeps_builtin_tag_scope():
"""
CLI 字符串 false 不应被 Python 真值规则误判为扩大查询范围。
"""
download_chain = MagicMock()
download_chain.list_torrents.return_value = [
DownloaderTorrent(
downloader="qb",
hash="hash-moviepilot",
title="MoviePilot Task",
size=1024,
progress=100,
state="completed",
tags="moviepilot",
)
]
with patch(
"app.agent.tools.impl.query_download_tasks.DownloadChain",
return_value=download_chain,
), patch.object(
QueryDownloadTasksTool,
"_load_history_map",
return_value={},
):
result = QueryDownloadTasksTool._query_downloads_sync(
status="completed",
include_all_tags="false",
)
assert result["downloads"][0].hash == "hash-moviepilot"
download_chain.list_torrents.assert_called_once_with(
downloader=None,
status="completed",
include_all_tags=False,
)

View File

@@ -0,0 +1,134 @@
import asyncio
import base64
import json
from unittest.mock import patch
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.helper.ocr import OcrHelper
class _FakeResponse:
"""测试用响应对象,模拟 requests.Response 的最小行为。"""
def __init__(self, content: bytes = b"", payload: dict = None):
"""初始化响应内容与 JSON 载荷。"""
self.content = content
self.payload = payload or {}
def json(self) -> dict:
"""返回测试预设 JSON 内容。"""
return self.payload
def __bool__(self) -> bool:
"""模拟 requests.Response 在成功状态下为真。"""
return True
def test_factory_registers_recognize_captcha_tool():
"""工具工厂应注册图形验证码识别工具。"""
with patch(
"app.agent.tools.factory.PluginManager.get_plugin_agent_tools",
return_value=[],
):
tools = MoviePilotToolFactory.create_tools(
session_id="captcha-session",
user_id="10001",
)
tool_names = {tool.name for tool in tools}
assert "recognize_captcha" in tool_names
def test_mcp_tool_manager_exposes_recognize_captcha_schema():
"""MCP 工具管理器应暴露验证码识别工具参数。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")
with patch(
"app.agent.tools.manager.MoviePilotToolFactory.create_tools",
return_value=[tool],
):
manager = MoviePilotToolsManager(is_admin=True)
tool_definitions = manager.list_tools()
schema = tool_definitions[0].input_schema
assert [item.name for item in tool_definitions] == ["recognize_captcha"]
assert "image_url" in schema["required"]
assert "cookie" in schema["properties"]
assert "user_agent" in schema["properties"]
assert "allow_private_network" in schema["properties"]
def test_ocr_helper_extracts_data_url_base64_without_downloading_image():
"""data:image 地址应直接提取 base64 内容并提交给 OCR 服务。"""
image_b64 = base64.b64encode(b"captcha-image").decode()
image_url = f"data:image/png;base64,{image_b64}"
with patch("app.helper.ocr.RequestUtils") as request_utils:
request_utils.return_value.post_res.return_value = _FakeResponse(
payload={"result": "a8k2"}
)
result = OcrHelper().get_captcha_text(image_url=image_url)
assert result == "a8k2"
request_utils.return_value.get_res.assert_not_called()
request_utils.return_value.post_res.assert_called_once()
assert request_utils.return_value.post_res.call_args.kwargs["json"] == {
"base64_img": image_b64
}
def test_recognize_captcha_tool_returns_captcha_text_from_ocr_helper():
"""验证码工具应返回结构化识别结果,便于 Agent 继续填写表单。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")
async def _run_tool():
"""执行一次带 mock OCR 的工具调用。"""
with patch(
"app.agent.tools.impl.recognize_captcha.OcrHelper.get_captcha_text",
return_value="x7p9",
) as recognize_mock:
result = await tool.run(
image_url="https://example.com/captcha.png",
cookie="sid=abc",
user_agent="MoviePilotTest/1.0",
)
return result, recognize_mock
result, recognize_mock = asyncio.run(_run_tool())
payload = json.loads(result)
assert payload == {
"success": True,
"captcha_text": "x7p9",
"message": "验证码识别成功",
}
recognize_mock.assert_called_once_with(
image_url="https://example.com/captcha.png",
cookie="sid=abc",
ua="MoviePilotTest/1.0",
)
def test_recognize_captcha_tool_blocks_private_network_by_default():
"""验证码工具默认应拒绝本机和私网图片地址。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")
with patch(
"app.agent.tools.impl.recognize_captcha.OcrHelper.get_captcha_text",
return_value="x7p9",
) as recognize_mock:
result = asyncio.run(
tool.run(image_url="http://127.0.0.1/captcha.png")
)
payload = json.loads(result)
assert payload["success"] is False
assert payload["captcha_text"] == ""
assert "默认不允许访问本机或私网地址" in payload["message"]
recognize_mock.assert_not_called()

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

@@ -133,10 +133,26 @@ def _load_transmission_module():
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _DownloaderTorrent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class TorrentStatus(Enum):
TRANSFER = "transfer"
DOWNLOADING = "downloading"
class TorrentQueryStatus(Enum):
ALL = "all"
TRANSFER = "transfer"
DOWNLOADING = "downloading"
COMPLETED = "completed"
PAUSED = "paused"
class DownloadTaskState(Enum):
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
class _Logger:
def debug(self, *_args, **_kwargs):
pass
@@ -179,8 +195,11 @@ def _load_transmission_module():
cache_module.FileCache = _FileCache
schemas_module.TransferTorrent = _TransferTorrent
schemas_module.DownloadingTorrent = _DownloadingTorrent
schemas_module.DownloaderTorrent = _DownloaderTorrent
schemas_module.DownloaderInfo = object
schema_types_module.TorrentStatus = TorrentStatus
schema_types_module.TorrentQueryStatus = TorrentQueryStatus
schema_types_module.DownloadTaskState = DownloadTaskState
schema_types_module.ModuleType = Enum("ModuleType", {"Downloader": "downloader"})
schema_types_module.DownloaderType = Enum(
"DownloaderType", {"Transmission": "Transmission"}
@@ -359,3 +378,62 @@ class TransmissionPathMappingTest(unittest.TestCase):
Path("/mnt/raid5/home_lt999lt/video/downloads/movie/Movie"),
"tr",
)
def test_all_torrents_include_completed_and_downloading_states(self):
server = MagicMock()
server.get_torrents.return_value = (
[
SimpleNamespace(
name="Completed",
download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie",
hashString="hash-completed",
total_size=1024,
labels=[],
progress=100,
status="seed_pending",
),
SimpleNamespace(
name="Downloading",
download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie",
hashString="hash-downloading",
total_size=2048,
labels=[],
progress=50,
status="downloading",
rate_download=1024,
rate_upload=0,
left_until_done=1024,
),
],
False,
)
module = self._build_module(server)
torrents = module.list_torrents()
self.assertEqual(["completed", "downloading"], [torrent.state for torrent in torrents])
self.assertEqual(["hash-completed", "hash-downloading"], [torrent.hash for torrent in torrents])
server.get_torrents.assert_called_once_with(tags="moviepilot-tag")
def test_include_all_tags_removes_builtin_tag_filter(self):
server = MagicMock()
server.get_torrents.return_value = (
[
SimpleNamespace(
name="External",
download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie",
hashString="hash-external",
total_size=1024,
labels=["external"],
progress=100,
status="seeding",
)
],
False,
)
module = self._build_module(server)
torrents = module.list_torrents(include_all_tags=True)
self.assertEqual(["hash-external"], [torrent.hash for torrent in torrents])
server.get_torrents.assert_called_once_with(tags=None)

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

@@ -92,10 +92,26 @@ def _load_qbittorrent_modules():
def from_string(content):
return types.SimpleNamespace(name="test", total_size=len(content))
class _DownloaderTorrent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class TorrentStatus(Enum):
TRANSFER = "transfer"
DOWNLOADING = "downloading"
class TorrentQueryStatus(Enum):
ALL = "all"
TRANSFER = "transfer"
DOWNLOADING = "downloading"
COMPLETED = "completed"
PAUSED = "paused"
class DownloadTaskState(Enum):
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
class ModuleType(Enum):
Downloader = "Downloader"
@@ -109,7 +125,10 @@ def _load_qbittorrent_modules():
schemas_module.DownloaderInfo = object
schemas_module.TransferTorrent = object
schemas_module.DownloadingTorrent = object
schemas_module.DownloaderTorrent = _DownloaderTorrent
schema_types_module.TorrentStatus = TorrentStatus
schema_types_module.TorrentQueryStatus = TorrentQueryStatus
schema_types_module.DownloadTaskState = DownloadTaskState
schema_types_module.ModuleType = ModuleType
schema_types_module.DownloaderType = DownloaderType
string_module.StringUtils = _StringUtils
@@ -253,6 +272,83 @@ def test_login_skips_incomplete_file_suffix_when_already_matches():
fake_client.app_set_preferences.assert_not_called()
def test_completed_status_includes_qbittorrent_finished_upload_states():
"""
qBittorrent 按完成状态查询时应包含非下载中、非暂停的上传侧状态。
"""
server = MagicMock()
server.get_torrents.return_value = (
[
{
"name": "QB Done",
"content_path": "/downloads/QB Done",
"hash": "hash-qb",
"total_size": 1024,
"completed": 1024,
"progress": 1,
"state": "stalledUP",
"tags": "moviepilot-tag",
"dlspeed": 0,
"upspeed": 128,
},
{
"name": "QB Downloading",
"content_path": "/downloads/QB Downloading",
"hash": "hash-downloading",
"total_size": 2048,
"completed": 1024,
"progress": 0.5,
"state": "queuedDL",
"tags": "moviepilot-tag",
"dlspeed": 64,
"upspeed": 0,
},
],
False,
)
module = QbittorrentModule.__new__(QbittorrentModule)
module.get_instances = MagicMock(return_value={"qb": server})
module.normalize_return_path = MagicMock(side_effect=lambda path, _name: str(path))
torrents = module.list_torrents(status="completed")
assert [torrent.hash for torrent in torrents] == ["hash-qb"]
assert torrents[0].state == "completed"
server.get_torrents.assert_called_once_with(tags="moviepilot-tag")
def test_list_torrents_include_all_tags_removes_builtin_tag_filter():
"""
智能体扩大查询范围时qBittorrent 查询应取消内置标签过滤。
"""
server = MagicMock()
server.get_torrents.return_value = (
[
{
"name": "External Task",
"content_path": "/downloads/External Task",
"hash": "hash-external",
"total_size": 1024,
"completed": 1024,
"progress": 1,
"state": "stalledUP",
"tags": "external",
"dlspeed": 0,
"upspeed": 0,
}
],
False,
)
module = QbittorrentModule.__new__(QbittorrentModule)
module.get_instances = MagicMock(return_value={"qb": server})
module.normalize_return_path = MagicMock(side_effect=lambda path, _name: str(path))
torrents = module.list_torrents(include_all_tags=True)
assert [torrent.hash for torrent in torrents] == ["hash-external"]
server.get_torrents.assert_called_once_with(tags=None)
def test_add_torrent_accepts_structured_success_response():
"""新版 qBittorrent API 结构化成功响应应返回新增种子 ID。"""
fake_client = MagicMock()

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'
FRONTEND_VERSION = 'v2.13.8'
APP_VERSION = 'v2.13.9'
FRONTEND_VERSION = 'v2.13.9'