Compare commits

...
25 Commits
Author SHA1 Message Date
LinFei83andCursor 6a02e7de21 MCP get_search_results 增加 include_labels 按需返回种子标签 (#6335)
让 Agent 在筛选命中标签时能按需查看 labels,默认不返回以免拉长上下文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 17:28:38 +08:00
jxxghp 458c08a137 Update version.py 2026-08-10 13:48:39 +08:00
千石 7012e0e305 feat(storages): 新增 AList 存储类型 (#6245) 2026-08-09 14:40:22 +08:00
ngcat 91ce365f78 fix(transhandler): suppress noisy error notification for TV special and extra sample files without episode numbers (#6247) 2026-08-08 15:30:11 +08:00
jxxghp 17be4304c1 ci: register v3 build workflow 2026-08-07 23:33:20 +08:00
jxxghp c6bd396794 Improve agent file reading and structured LLM summaries 2026-08-07 13:31:39 +08:00
jxxghp 7985268f10 fix(agent): emit web tool status immediately 2026-08-07 13:01:47 +08:00
jxxghp 865635c59d perf(agent): optimize web SSE streaming 2026-08-07 12:45:36 +08:00
jxxghp 759b9e47eb fix(transfer): expire stale jobs and deduplicate diagnostics 2026-08-07 12:44:04 +08:00
jxxghp 63e492be7c fix(agent): report only meaningful progress milestones 2026-08-07 11:27:36 +08:00
jxxghp b5eca00ba3 更新 version.py 2026-08-07 07:52:44 +08:00
jxxghp b80642f56f fix(agent): decouple progress prompt from tool display 2026-08-07 07:17:27 +08:00
jxxghp 6d3161f3cb fix(agent): align progress updates with Codex cadence 2026-08-07 07:01:03 +08:00
jxxghp ea8d1f8d26 fix(agent): report progress during long tool runs 2026-08-07 06:57:07 +08:00
jxxghp 5654512d41 fix(api): return avatar filename in data 2026-08-06 23:36:34 +08:00
jxxghp a52e1fdc1c feat(agent): improve prompt cache hit rate 2026-08-06 23:34:29 +08:00
jxxghp 44db45ea28 feat(docker): include sshpass for remote shell access 2026-08-06 22:47:02 +08:00
jxxghp 83409c1439 feat(agent): expand tool output and search pagination 2026-08-06 22:36:05 +08:00
jxxghp cf6c73d85b fix(agent): persist streamed message order 2026-08-06 22:24:54 +08:00
jxxghp 987c1722d7 feat(api): add unified v2 response layer 2026-08-06 17:32:33 +08:00
秋澪Akimio 57220c93db fix(checks): update regex for proxy string to support socks proxy (#6236) 2026-08-06 15:08:29 +08:00
jxxghp 4abae809c2 fix: preserve parent year in media recognition 2026-08-06 12:45:48 +08:00
jxxghp 7fe7be6d71 feat(plugin): sync default markets from wiki at release 2026-08-06 09:15:30 +08:00
jxxghp 4b1df72a4a refactor(cache): simplify recognition cache persistence 2026-08-06 07:48:43 +08:00
jxxghp a23ac6c56d feat: 支持模型服务端联网搜索 2026-08-05 19:19:26 +08:00
103 changed files with 5384 additions and 1165 deletions
+1
View File
@@ -71,6 +71,7 @@ test_*
# Build artifacts
build/
.build/
dist/
*.egg-info/
rust/**/target/
+26 -1
View File
@@ -2,6 +2,10 @@ name: MoviePilot Builder Beta
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
Docker-build:
runs-on: ubuntu-latest
@@ -16,6 +20,25 @@ jobs:
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
path: .build/moviepilot-wiki
sparse-checkout: plugin.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Generate Plugin Market Default
id: plugin_market
run: |
python3 -m scripts.generate_plugin_market_default \
--wiki-file .build/moviepilot-wiki/plugin.md \
--config-file app/core/config.py
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
@@ -55,6 +78,8 @@ jobs:
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: |
${{ steps.meta.outputs.labels }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
+14
View File
@@ -0,0 +1,14 @@
name: MoviePilot Builder v3
on:
workflow_dispatch:
jobs:
select-v3:
runs-on: ubuntu-latest
steps:
# GitHub 仅从默认分支登记手动工作流;选择 v3 后会加载 v3 分支的完整构建配置。
- name: Require v3 branch
run: |
echo "::error::请在 Run workflow 中选择 v3 分支"
exit 1
+54 -3
View File
@@ -7,6 +7,10 @@ on:
paths:
- 'version.py'
permissions:
contents: write
packages: write
jobs:
Docker-build:
runs-on: ubuntu-latest
@@ -23,6 +27,39 @@ jobs:
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
- name: Checkout Wiki Plugin Market
uses: actions/checkout@v4
with:
repository: jxxghp/MoviePilot-Wiki
ref: main
path: .build/moviepilot-wiki
sparse-checkout: plugin.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Generate Plugin Market Default
id: plugin_market
run: |
python3 -m scripts.generate_plugin_market_default \
--wiki-file .build/moviepilot-wiki/plugin.md \
--config-file app/core/config.py
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
- name: Create Release Snapshot
id: release_snapshot
env:
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add app/core/config.py
if ! git diff --cached --quiet; then
git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}"
fi
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Docker Meta
id: meta
@@ -65,7 +102,10 @@ jobs:
linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
cache-from: type=gha,scope=moviepilot-docker,version=2
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
@@ -78,9 +118,9 @@ jobs:
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
if [ -z "$PREVIOUS_TAG" ]; then
COMMITS=$(git log --pretty=format:"%s||%an" HEAD)
COMMITS=$(git log --pretty=format:"%s||%an" "${SOURCE_COMMIT}")
else
COMMITS=$(git log --pretty=format:"%s||%an" ${PREVIOUS_TAG}..HEAD)
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
fi
# 分类收集 commit 消息(使用关联数组去重)
@@ -188,6 +228,17 @@ jobs:
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Release Tag
env:
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
run: |
tag_name="v${{ env.app_version }}"
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
git tag -d "$tag_name"
fi
git tag "$tag_name" "$RELEASE_COMMIT"
git push origin "refs/tags/${tag_name}"
- name: Generate Release
uses: softprops/action-gh-release@v2
with:
+101 -5
View File
@@ -129,9 +129,18 @@ class _SessionUsageSnapshot:
last_output_tokens: int = 0
last_total_tokens: int = 0
last_context_usage_ratio: Optional[float] = None
last_cache_usage_available: bool = False
last_cache_read_input_tokens: int = 0
last_cache_write_input_tokens: int = 0
last_uncached_input_tokens: int = 0
last_cache_hit_ratio: Optional[float] = None
total_input_tokens: int = 0
total_output_tokens: int = 0
total_tokens: int = 0
total_cache_read_input_tokens: int = 0
total_cache_write_input_tokens: int = 0
total_uncached_input_tokens: int = 0
cache_usage_available: bool = False
model_call_count: int = 0
last_updated_at: Optional[datetime] = None
@@ -144,9 +153,23 @@ class _SessionUsageSnapshot:
"last_output_tokens": self.last_output_tokens,
"last_total_tokens": self.last_total_tokens,
"last_context_usage_ratio": self.last_context_usage_ratio,
"last_cache_usage_available": self.last_cache_usage_available,
"last_cache_read_input_tokens": self.last_cache_read_input_tokens,
"last_cache_write_input_tokens": self.last_cache_write_input_tokens,
"last_uncached_input_tokens": self.last_uncached_input_tokens,
"last_cache_hit_ratio": self.last_cache_hit_ratio,
"total_input_tokens": self.total_input_tokens,
"total_output_tokens": self.total_output_tokens,
"total_tokens": self.total_tokens,
"total_cache_read_input_tokens": self.total_cache_read_input_tokens,
"total_cache_write_input_tokens": self.total_cache_write_input_tokens,
"total_uncached_input_tokens": self.total_uncached_input_tokens,
"cache_usage_available": self.cache_usage_available,
"total_cache_hit_ratio": (
self.total_cache_read_input_tokens / self.total_input_tokens
if self.cache_usage_available and self.total_input_tokens
else None
),
"model_call_count": self.model_call_count,
"last_updated_at": self.last_updated_at.strftime("%Y-%m-%d %H:%M:%S")
if self.last_updated_at
@@ -318,13 +341,19 @@ class MoviePilotAgent:
"""
构造可展示的 Agent 会话消息。
"""
normalized_content = content or ""
return {
"id": f"{role}-{uuid.uuid4().hex}",
"role": role,
"content": content or "",
"content": normalized_content,
"createdAt": cls._current_timestamp_ms(),
"status": status,
"tools": [],
"segments": (
[{"type": "text", "content": normalized_content}]
if normalized_content
else []
),
"attachments": attachments or [],
"choices": [],
}
@@ -548,9 +577,33 @@ class MoviePilotAgent:
self._session_usage.last_output_tokens = output_tokens
self._session_usage.last_total_tokens = total_tokens
self._session_usage.last_context_usage_ratio = usage.get("context_usage_ratio")
cache_usage_available = bool(usage.get("cache_usage_available"))
cache_read_input_tokens = self._coerce_int(
usage.get("cache_read_input_tokens")
) or 0
cache_write_input_tokens = self._coerce_int(
usage.get("cache_write_input_tokens")
) or 0
uncached_input_tokens = self._coerce_int(
usage.get("uncached_input_tokens")
)
if uncached_input_tokens is None:
uncached_input_tokens = max(
input_tokens - cache_read_input_tokens - cache_write_input_tokens,
0,
)
self._session_usage.last_cache_usage_available = cache_usage_available
self._session_usage.last_cache_read_input_tokens = cache_read_input_tokens
self._session_usage.last_cache_write_input_tokens = cache_write_input_tokens
self._session_usage.last_uncached_input_tokens = uncached_input_tokens
self._session_usage.last_cache_hit_ratio = usage.get("cache_hit_ratio")
self._session_usage.total_input_tokens += input_tokens
self._session_usage.total_output_tokens += output_tokens
self._session_usage.total_tokens += total_tokens
self._session_usage.total_cache_read_input_tokens += cache_read_input_tokens
self._session_usage.total_cache_write_input_tokens += cache_write_input_tokens
self._session_usage.total_uncached_input_tokens += uncached_input_tokens
self._session_usage.cache_usage_available |= cache_usage_available
def get_session_status(self) -> dict[str, Any]:
if not self._session_usage.model:
@@ -584,6 +637,17 @@ class MoviePilotAgent:
input_tokens=self._session_usage.total_input_tokens,
output_tokens=self._session_usage.total_output_tokens,
total_tokens=self._session_usage.total_tokens,
cache_read_input_tokens=self._session_usage.total_cache_read_input_tokens,
cache_write_input_tokens=self._session_usage.total_cache_write_input_tokens,
uncached_input_tokens=self._session_usage.total_uncached_input_tokens,
cache_hit_ratio=(
self._session_usage.total_cache_read_input_tokens
/ self._session_usage.total_input_tokens
if self._session_usage.cache_usage_available
and self._session_usage.total_input_tokens
else None
),
cache_usage_available=self._session_usage.cache_usage_available,
model_call_count=self._session_usage.model_call_count,
success=success,
error=error,
@@ -730,6 +794,7 @@ class MoviePilotAgent:
use_proxy=settings.LLM_USE_PROXY,
thinking_level=settings.LLM_THINKING_LEVEL,
api_protocol=settings.LLM_API_PROTOCOL,
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
)
selected_event = await eventmanager.async_send_event(
ChainEventType.AgentLLMProvider,
@@ -773,6 +838,9 @@ class MoviePilotAgent:
api_protocol = self._clean_optional_text(
self._get_event_value(resolved_data, "api_protocol")
) or settings.LLM_API_PROTOCOL
web_search_mode = self._clean_optional_text(
self._get_event_value(resolved_data, "web_search_mode")
) or settings.LLM_WEB_SEARCH_MODE
selected_provider_id = self._clean_optional_text(
self._get_event_value(resolved_data, "selected_provider_id")
)
@@ -799,6 +867,7 @@ class MoviePilotAgent:
"use_proxy": bool(use_proxy),
"thinking_level": thinking_level,
"api_protocol": api_protocol,
"web_search_mode": web_search_mode,
}
return self._llm_runtime_config
@@ -808,7 +877,17 @@ class MoviePilotAgent:
:param streaming: 是否启用流式输出
"""
runtime_config = await self._resolve_llm_runtime_config()
return await LLMHelper.get_llm(streaming=streaming, **runtime_config)
return await LLMHelper.get_llm(
streaming=streaming,
prompt_cache_key=self._build_prompt_cache_key(),
**runtime_config,
)
def _build_prompt_cache_key(self) -> str:
"""生成不暴露用户标识、且在同一会话内稳定的提示词缓存键。"""
cache_identity = f"{self.user_id or ''}\x00{self.session_id}"
digest = hashlib.sha256(cache_identity.encode("utf-8")).hexdigest()[:32]
return f"moviepilot-agent-{digest}"
@classmethod
def _has_image_input_content(cls, content: Any) -> bool:
@@ -1006,6 +1085,13 @@ class MoviePilotAgent:
allow_message_tools=self.allow_message_tools,
)
@staticmethod
def _filter_local_web_search_tools(tools: List, enabled: bool) -> List:
"""按联网搜索策略保留或移除本地 search_web 工具。"""
if enabled:
return tools
return [tool for tool in tools if getattr(tool, "name", None) != "search_web"]
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
"""
刷新本轮工具共享上下文。
@@ -1035,6 +1121,7 @@ class MoviePilotAgent:
bool(runtime_config.get("use_proxy")),
runtime_config.get("thinking_level"),
runtime_config.get("api_protocol"),
runtime_config.get("web_search_mode"),
)
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
@@ -1165,6 +1252,8 @@ class MoviePilotAgent:
# LLM 模型(用于 agent 执行)
agent_model = await self._initialize_llm(streaming=streaming)
self._sync_model_profile(agent_model)
server_tools = LLMHelper.get_server_tools(agent_model)
use_local_web_search = LLMHelper.should_use_local_web_search(agent_model)
# 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
non_streaming_model = (
@@ -1174,7 +1263,10 @@ class MoviePilotAgent:
)
# 工具列表
tools = self._initialize_tools()
tools = self._filter_local_web_search_tools(
self._initialize_tools(),
enabled=use_local_web_search,
)
tools.extend(await self._initialize_mcp_tools())
skills_middleware = SkillsMiddleware(
sources=[str(agent_runtime_manager.skills_dir)],
@@ -1192,11 +1284,15 @@ class MoviePilotAgent:
activity_log_tools = list(
getattr(activity_log_middleware, "tools", []) or []
)
subagent_tools = self._initialize_subagent_tools()
subagent_tools = self._filter_local_web_search_tools(
self._initialize_subagent_tools(),
enabled=use_local_web_search,
)
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=subagent_tools,
server_tools=server_tools,
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
@@ -1271,7 +1367,7 @@ class MoviePilotAgent:
agent = create_agent(
model=agent_model,
tools=[*tools, *skill_tools, *activity_log_tools],
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
system_prompt=system_prompt,
middleware=middlewares,
checkpointer=InMemorySaver(),
+317 -109
View File
@@ -5,13 +5,17 @@ import inspect
import json
import time
from functools import wraps
from typing import Any, List, Optional
from typing import TYPE_CHECKING, Any, List, Optional
from urllib.parse import urlsplit
from langchain_core.messages import AIMessage, AIMessageChunk
from app.core.config import settings
from app.log import logger
if TYPE_CHECKING:
from app.agent.llm.server_tools import ServerToolResolution
class LLMTestError(RuntimeError):
"""LLM 测试调用异常,附带请求耗时。"""
@@ -224,74 +228,76 @@ def _is_deepseek_thinking_enabled(model_name: str | None, extra_body: Any) -> bo
return False
def _patch_deepseek_reasoning_content_support():
"""
修补 langchain-deepseek 在 tool-call 场景下遗漏 reasoning_content 回传的问题。
DeepSeek thinking mode 要求:若 assistant 历史消息包含 tool_calls
后续请求中必须带回该条消息的顶层 reasoning_content。
某些 langchain-deepseek 版本虽然能从响应中拿到 reasoning_content
但不会在重放消息历史时写回请求载荷,导致 400。
"""
try:
from langchain_deepseek import ChatDeepSeek
except Exception as err:
logger.debug(f"跳过 langchain-deepseek reasoning_content 修补:{err}")
def _patch_interleaved_reasoning_request_support(
model_cls: Any,
*,
patch_marker: str,
thinking_filter: Any = None,
normalize_deepseek_messages: bool = False,
inject_missing_as_empty: bool = False,
) -> None:
"""为兼容模型统一补回工具调用历史中的 reasoning_content。"""
if getattr(model_cls, patch_marker, False):
return
if getattr(ChatDeepSeek, "_moviepilot_reasoning_content_patched", False):
return
original_get_request_payload = getattr(ChatDeepSeek, "_get_request_payload", None)
original_get_request_payload = getattr(model_cls, "_get_request_payload", None)
if not callable(original_get_request_payload):
logger.warning("langchain-deepseek 缺少 _get_request_payload,无法修补 reasoning_content")
logger.warning(
f"{model_cls.__name__} 缺少 _get_request_payload,无法修补 reasoning_content"
)
return
@wraps(original_get_request_payload)
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
if "messages" not in payload:
return payload
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
if not _is_deepseek_thinking_enabled(
extra_body = getattr(self, "extra_body", None)
if extra_body is None:
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
if thinking_filter is not None and not thinking_filter(
getattr(self, "model_name", None) or getattr(self, "model", None),
extra_body,
):
return payload
# 从原始 LangChain 消息中取回 reasoning_content。上游 payload 构造器
# 不会自动透传这个 DeepSeek 扩展字段。
messages = self._convert_input(input_).to_messages()
for i, message in enumerate(payload["messages"]):
if message["role"] == "tool" and isinstance(message["content"], list):
message["content"] = json.dumps(message["content"])
elif message["role"] == "assistant":
if isinstance(message["content"], list):
# DeepSeek API 要求 assistant content 为字符串;工具场景下
# LangChain 可能保留为内容块列表,这里只拼回可见文本块。
text_parts = [
block.get("text", "")
for block in message["content"]
if isinstance(block, dict) and block.get("type") == "text"
]
message["content"] = "".join(text_parts) if text_parts else ""
# DeepSeek thinking mode 要求历史 assistant 消息携带
# reasoning_content,即便本地只保存到了 additional_kwargs。
if (
"reasoning_content" not in message
and i < len(messages)
and isinstance(messages[i], AIMessage)
for index, payload_message in enumerate(payload["messages"]):
if normalize_deepseek_messages:
if payload_message.get("role") == "tool" and isinstance(
payload_message.get("content"), list
):
message["reasoning_content"] = messages[i].additional_kwargs.get(
"reasoning_content", ""
payload_message["content"] = json.dumps(payload_message["content"])
elif payload_message.get("role") == "assistant" and isinstance(
payload_message.get("content"), list
):
payload_message["content"] = "".join(
block.get("text", "")
for block in payload_message["content"]
if isinstance(block, dict) and block.get("type") == "text"
)
if (
payload_message.get("role") != "assistant"
or index >= len(messages)
or not isinstance(messages[index], AIMessage)
or "reasoning_content" in payload_message
):
continue
reasoning_content = messages[index].additional_kwargs.get(
"reasoning_content"
)
if reasoning_content is not None:
payload_message["reasoning_content"] = reasoning_content
elif inject_missing_as_empty:
payload_message["reasoning_content"] = ""
return payload
ChatDeepSeek._get_request_payload = _patched_get_request_payload
ChatDeepSeek._moviepilot_reasoning_content_patched = True
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
model_cls._get_request_payload = _patched_get_request_payload
setattr(model_cls, patch_marker, True)
def _patch_openai_interleaved_reasoning_content_support():
@@ -352,42 +358,10 @@ def _patch_openai_interleaved_reasoning_content_support():
_openai_base._moviepilot_reasoning_response_patched = True
if getattr(ChatOpenAI, "_moviepilot_interleaved_reasoning_patched", False):
return
original_get_request_payload = getattr(ChatOpenAI, "_get_request_payload", None)
if not callable(original_get_request_payload):
logger.warning("langchain-openai 缺少 _get_request_payload,无法修补 reasoning_content")
return
@wraps(original_get_request_payload)
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
if "messages" not in payload:
return payload
messages = self._convert_input(input_).to_messages()
for index, payload_message in enumerate(payload["messages"]):
if (
payload_message.get("role") != "assistant"
or index >= len(messages)
or not isinstance(messages[index], AIMessage)
or "reasoning_content" in payload_message
):
continue
reasoning_content = messages[index].additional_kwargs.get(
"reasoning_content"
)
if reasoning_content is not None:
# 只回传模型真实返回过的思考字段。普通模型没有该字段时,
# payload 保持原样,不额外塞未知参数。
payload_message["reasoning_content"] = reasoning_content
return payload
ChatOpenAI._get_request_payload = _patched_get_request_payload
ChatOpenAI._moviepilot_interleaved_reasoning_patched = True
_patch_interleaved_reasoning_request_support(
ChatOpenAI,
patch_marker="_moviepilot_interleaved_reasoning_patched",
)
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
@@ -840,6 +814,91 @@ class LLMHelper:
headers["User-Agent"] = normalized_user_agent
return headers or None
@staticmethod
def _matches_endpoint_host(base_url: str | None, expected_host: str) -> bool:
"""严格匹配官方 API 主机,避免向兼容端点发送供应商专属参数。"""
try:
return (urlsplit(str(base_url or "")).hostname or "").lower() == expected_host
except ValueError:
return False
@classmethod
def _build_openai_prompt_cache_options(
cls,
*,
provider: str,
base_url: str | None,
use_responses_api: bool | None,
prompt_cache_key: str | None,
default_headers: dict[str, str] | None,
model_kwargs: dict[str, Any],
) -> tuple[dict[str, str] | None, dict[str, Any]]:
"""为 OpenAI 与 xAI 官方端点构造稳定提示词缓存路由参数。"""
cache_key = str(prompt_cache_key or "").strip()
headers = dict(default_headers or {})
kwargs = dict(model_kwargs)
provider_name = str(provider or "").strip().lower()
if not cache_key:
return headers or None, kwargs
is_openai = provider_name in {"chatgpt", "openai"} and cls._matches_endpoint_host(
base_url,
"api.openai.com",
)
is_xai = provider_name == "xai" and cls._matches_endpoint_host(
base_url,
"api.x.ai",
)
if not is_openai and not is_xai:
return headers or None, kwargs
if is_xai and use_responses_api is not True:
headers["x-grok-conv-id"] = cache_key
return headers, kwargs
extra_body = dict(kwargs.get("extra_body") or {})
extra_body["prompt_cache_key"] = cache_key
kwargs["extra_body"] = extra_body
return headers or None, kwargs
@staticmethod
def _with_prompt_cache_control(
model_cls: type,
cache_control: dict[str, str],
) -> type:
"""创建在最终模型绑定阶段保留缓存控制参数的适配类。"""
class PromptCachingModel(model_cls):
"""在 LangChain 工具绑定后仍保留提示词缓存参数的模型适配器。"""
def bind(self, **kwargs: Any) -> Any:
"""绑定调用参数,并补入当前 Provider 的默认缓存控制。"""
kwargs.setdefault("cache_control", dict(cache_control))
return super().bind(**kwargs)
PromptCachingModel.__name__ = f"PromptCaching{model_cls.__name__}"
return PromptCachingModel
@classmethod
def _use_anthropic_prompt_cache(
cls,
*,
provider: str,
runtime: dict[str, Any],
prompt_cache_key: str | None,
) -> bool:
"""判断当前运行时是否为可安全启用缓存的 Anthropic 官方端点。"""
return (
bool(str(prompt_cache_key or "").strip())
and str(provider or "").strip().lower() == "anthropic"
and str(runtime.get("runtime") or "").strip().lower()
== "anthropic_compatible"
and cls._matches_endpoint_host(
runtime.get("base_url"),
"api.anthropic.com",
)
)
@classmethod
def _should_use_openai_responses_api(
cls,
@@ -931,6 +990,36 @@ class LLMHelper:
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
@staticmethod
def _attach_server_tool_metadata(
model: Any,
resolution: "ServerToolResolution",
) -> None:
"""把服务端工具解析结果挂到模型实例,供 Agent 组装工具列表。"""
metadata = {
"mode": resolution.mode,
"use_local_web_search": resolution.use_local_web_search,
"server_tools": [dict(tool) for tool in resolution.server_tools],
"available": resolution.available,
"reason": resolution.reason,
}
try:
setattr(model, "_moviepilot_server_tool_metadata", metadata)
except Exception:
object.__setattr__(model, "_moviepilot_server_tool_metadata", metadata)
@staticmethod
def get_server_tools(model: Any) -> list[dict[str, Any]]:
"""读取模型已解析的服务端工具定义。"""
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
return [dict(tool) for tool in metadata.get("server_tools", [])]
@staticmethod
def should_use_local_web_search(model: Any) -> bool:
"""判断当前模型是否应保留 MoviePilot 本地联网搜索工具。"""
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
return bool(metadata.get("use_local_web_search", True))
@classmethod
def _resolve_thinking_level(
cls,
@@ -979,6 +1068,8 @@ class LLMHelper:
temperature: Optional[float] = None,
use_proxy: bool | None = None,
api_protocol: str | None = None,
web_search_mode: str | None = None,
prompt_cache_key: str | None = None,
):
"""
获取LLM实例
@@ -999,6 +1090,10 @@ class LLMHelper:
auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API
``chat_completions`` 强制走 Chat Completions``auto`` 保持原有自动判断。
:param web_search_mode: 联网搜索模式
local/builtin/auto/disabled)。未显式传入时使用配置项
``LLM_WEB_SEARCH_MODE``。
:param prompt_cache_key: 同一 Agent 会话内稳定且脱敏的提示词缓存路由键。
:return: LLM实例
"""
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
@@ -1037,6 +1132,40 @@ class LLMHelper:
user_agent=user_agent_value,
)
model_name = runtime.get("model_id") or model_name
from app.agent.llm.server_tools import (
ServerToolRegistry,
ServerToolUnavailableError,
)
server_tool_resolution = ServerToolRegistry.resolve_web_search(
provider=provider_name,
model=model_name,
mode=(
web_search_mode
if web_search_mode is not None
else getattr(settings, "LLM_WEB_SEARCH_MODE", "local")
),
api_protocol=(
api_protocol
if api_protocol is not None
else settings.LLM_API_PROTOCOL
),
base_url=runtime.get("base_url"),
)
if (
server_tool_resolution.mode == "builtin"
and not server_tool_resolution.available
):
raise ServerToolUnavailableError(
provider=provider_name,
model=str(model_name or ""),
tool_id="web_search",
)
effective_api_protocol = (
server_tool_resolution.required_api_protocol
if server_tool_resolution.required_api_protocol == "responses"
else api_protocol
)
default_headers = cls._build_openai_default_headers(
runtime.get("default_headers"),
user_agent=user_agent_value,
@@ -1050,7 +1179,15 @@ class LLMHelper:
provider=provider_name,
model=model_name,
runtime=runtime,
api_protocol=api_protocol,
api_protocol=effective_api_protocol,
)
default_headers, openai_model_kwargs = cls._build_openai_prompt_cache_options(
provider=provider_name,
base_url=runtime.get("base_url"),
use_responses_api=use_responses_api,
prompt_cache_key=prompt_cache_key,
default_headers=default_headers,
model_kwargs=thinking_kwargs,
)
llm_proxy = _resolve_llm_proxy(use_proxy)
@@ -1072,10 +1209,22 @@ class LLMHelper:
client_args=_build_google_client_args(llm_proxy),
**thinking_kwargs,
)
elif runtime["runtime"] == "deepseek":
elif (
runtime["runtime"] == "deepseek"
and server_tool_resolution.client_adapter != "openai_responses"
and use_responses_api is not True
):
from langchain_deepseek import ChatDeepSeek
_patch_deepseek_reasoning_content_support()
_patch_interleaved_reasoning_request_support(
ChatDeepSeek,
patch_marker="_moviepilot_reasoning_content_patched",
thinking_filter=lambda model_name, extra_body: (
_is_deepseek_thinking_enabled(model_name, extra_body)
),
normalize_deepseek_messages=True,
inject_missing_as_empty=True,
)
model = ChatDeepSeek(
model=model_name,
api_key=runtime["api_key"],
@@ -1093,6 +1242,16 @@ class LLMHelper:
from app.agent.llm.provider import LLMProviderManager
bedrock_model_cls = ChatBedrockConverse
if (
str(prompt_cache_key or "").strip()
and runtime.get("supports_prompt_cache")
):
bedrock_model_cls = cls._with_prompt_cache_control(
ChatBedrockConverse,
{"type": "default"},
)
aws_region = runtime.get("aws_region") or "us-east-1"
aws_auth = runtime.get("aws_auth") or {}
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证
@@ -1105,7 +1264,7 @@ class LLMHelper:
use_proxy=use_proxy,
read_timeout=settings.LLM_TOOL_TIMEOUT,
)
model = ChatBedrockConverse(
model = bedrock_model_cls(
model_id=model_name,
client=bedrock_client,
temperature=temperature_value,
@@ -1114,7 +1273,18 @@ class LLMHelper:
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
from langchain_anthropic import ChatAnthropic
model = ChatAnthropic(
anthropic_model_cls = ChatAnthropic
if cls._use_anthropic_prompt_cache(
provider=provider_name,
runtime=runtime,
prompt_cache_key=prompt_cache_key,
):
anthropic_model_cls = cls._with_prompt_cache_control(
ChatAnthropic,
{"type": "ephemeral"},
)
model = anthropic_model_cls(
model=model_name,
api_key=runtime["api_key"],
base_url=runtime["base_url"],
@@ -1154,7 +1324,8 @@ class LLMHelper:
),
default_headers=default_headers,
use_responses_api=use_responses_api,
**thinking_kwargs,
output_version=("responses/v1" if use_responses_api else None),
**openai_model_kwargs,
)
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
@@ -1181,6 +1352,7 @@ class LLMHelper:
}
cls._attach_runtime_metadata(model, runtime)
cls._attach_server_tool_metadata(model, server_tool_resolution)
return model
@staticmethod
@@ -1241,12 +1413,14 @@ class LLMHelper:
temperature: Optional[float] = None,
use_proxy: bool | None = None,
api_protocol: str | None = None,
web_search_mode: str | None = None,
) -> dict:
"""
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
"""
provider_name = provider if provider is not None else settings.LLM_PROVIDER
model_name = model if model is not None else settings.LLM_MODEL
@@ -1262,6 +1436,7 @@ class LLMHelper:
"user_agent": user_agent,
"use_proxy": use_proxy,
"api_protocol": api_protocol,
"web_search_mode": web_search_mode,
}
if temperature is not None:
llm_kwargs["temperature"] = temperature
@@ -1310,7 +1485,7 @@ class LLMHelper:
try:
from app.agent.llm.provider import LLMProviderManager
return await LLMProviderManager().list_models(
models = await LLMProviderManager().list_models(
provider_id=provider,
api_key=api_key,
base_url=base_url,
@@ -1319,16 +1494,25 @@ class LLMHelper:
use_proxy=use_proxy,
force_refresh=force_refresh,
)
return self._attach_server_tool_capabilities(
provider,
models,
base_url=base_url,
)
except Exception as err:
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
if provider == "google":
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_google_models(
api_key or "",
use_proxy=use_proxy,
)
]
return self._attach_server_tool_capabilities(
provider,
[
{"id": model_id, "name": model_id}
for model_id in await self._get_google_models(
api_key or "",
use_proxy=use_proxy,
)
],
base_url=base_url,
)
try:
from app.agent.llm.provider import LLMProviderManager
@@ -1342,16 +1526,40 @@ class LLMHelper:
)
except Exception:
model_list_base_url = base_url
return [
{"id": model_id, "name": model_id}
for model_id in await self._get_openai_compatible_models(
provider,
api_key or "",
model_list_base_url,
user_agent=user_agent,
use_proxy=use_proxy,
)
]
return self._attach_server_tool_capabilities(
provider,
[
{"id": model_id, "name": model_id}
for model_id in await self._get_openai_compatible_models(
provider,
api_key or "",
model_list_base_url,
user_agent=user_agent,
use_proxy=use_proxy,
)
],
base_url=base_url,
)
@staticmethod
def _attach_server_tool_capabilities(
provider: str,
models: List[dict[str, Any]],
base_url: Optional[str] = None,
) -> List[dict[str, Any]]:
"""为模型目录附加通用服务端工具能力元数据。"""
from app.agent.llm.server_tools import ServerToolRegistry
result = []
for item in models:
model_item = dict(item)
model_item["server_tools"] = ServerToolRegistry.list_capabilities(
provider=provider,
model=str(model_item.get("id") or ""),
base_url=base_url,
)
result.append(model_item)
return result
@staticmethod
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:
+40 -10
View File
@@ -1665,6 +1665,20 @@ class LLMProviderManager(metaclass=Singleton):
await self.get_models_dev_data(use_proxy=use_proxy)
).get(models_dev_provider_id, {}) or {}
@staticmethod
def _models_dev_model_candidates(
provider_id: str,
model_id: str,
) -> tuple[str, ...]:
"""生成模型目录查询候选,兼容 Provider 添加的透明模型前缀。"""
candidates = [model_id]
if model_id.startswith("models/"):
candidates.append(model_id.removeprefix("models/"))
if provider_id == "amazon-bedrock" and "." in model_id:
# Cross-region Inference Profile 会增加 us./eu./global. 等前缀。
candidates.append(model_id.split(".", 1)[1])
return tuple(dict.fromkeys(candidates))
async def _models_dev_model(
self,
provider_id: str,
@@ -1684,15 +1698,32 @@ class LLMProviderManager(metaclass=Singleton):
if not isinstance(models, dict):
return None
candidates = [model_id]
if model_id.startswith("models/"):
candidates.append(model_id.removeprefix("models/"))
for candidate in candidates:
for candidate in self._models_dev_model_candidates(provider_id, model_id):
if candidate in models:
return models[candidate]
return None
@staticmethod
def _metadata_supports_prompt_cache(metadata: Any) -> bool:
"""从统一模型元数据中判断是否声明了提示词缓存能力。"""
if not isinstance(metadata, dict):
return False
explicit_capability = metadata.get("prompt_cache")
if isinstance(explicit_capability, bool):
return explicit_capability
capabilities = metadata.get("capabilities")
if isinstance(capabilities, dict):
explicit_capability = capabilities.get("prompt_cache")
if isinstance(explicit_capability, bool):
return explicit_capability
cost = metadata.get("cost")
return isinstance(cost, dict) and any(
key in cost for key in ("cache_read", "cache_write")
)
def _cached_models_dev_model(
self,
provider_id: str,
@@ -1719,11 +1750,7 @@ class LLMProviderManager(metaclass=Singleton):
if not isinstance(models, dict):
return None
candidates = [model_id]
if model_id.startswith("models/"):
candidates.append(model_id.removeprefix("models/"))
for candidate in candidates:
for candidate in self._models_dev_model_candidates(provider_id, model_id):
if candidate in models:
return models[candidate]
return None
@@ -3112,6 +3139,9 @@ class LLMProviderManager(metaclass=Singleton):
"model_id": model,
"model_record": model_record,
"model_metadata": model_metadata,
"supports_prompt_cache": self._metadata_supports_prompt_cache(
model_metadata
),
"default_headers": None,
"use_responses_api": None,
"auth_mode": "api_key",
+249
View File
@@ -0,0 +1,249 @@
"""LLM 服务端工具能力注册与解析。"""
from dataclasses import dataclass
from fnmatch import fnmatch
from typing import Any, Optional
WEB_SEARCH_MODES = frozenset({"local", "builtin", "auto", "disabled"})
class ServerToolUnavailableError(ValueError):
"""表示用户强制选择了当前模型不可用的服务端工具。"""
def __init__(self, *, provider: str, model: str, tool_id: str) -> None:
"""初始化服务端工具不可用异常。"""
self.provider = provider
self.model = model
self.tool_id = tool_id
super().__init__(
f"当前模型 {provider}/{model} 或接口地址不支持服务端联网搜索,"
"请改用“自动”或“MoviePilot 本地搜索”"
)
@dataclass(frozen=True)
class ServerToolCapability:
"""描述一个模型可用的服务端工具能力。"""
tool_id: str
provider_ids: tuple[str, ...]
model_patterns: tuple[str, ...]
required_api_protocol: str
client_adapter: str
tool_definition: dict[str, Any]
base_url_patterns: tuple[str, ...] = ()
match_without_base_url: bool = True
def matches(self, provider: str, model: str, base_url: Optional[str] = None) -> bool:
"""判断给定 provider/model 是否匹配当前能力。"""
normalized_provider = str(provider or "").strip().lower()
normalized_model = str(model or "").strip().lower().removeprefix("models/")
normalized_base_url = str(base_url or "").strip().lower()
return (
normalized_provider in self.provider_ids
and any(fnmatch(normalized_model, pattern) for pattern in self.model_patterns)
and (
(not normalized_base_url and self.match_without_base_url)
or not self.base_url_patterns
or any(
pattern in normalized_base_url
for pattern in self.base_url_patterns
)
)
)
def serialize(self) -> dict[str, Any]:
"""返回供 API 与前端使用的能力元数据。"""
return {
"id": self.tool_id,
"required_api_protocol": self.required_api_protocol,
"client_adapter": self.client_adapter,
}
@dataclass(frozen=True)
class ServerToolResolution:
"""记录本次联网搜索模式解析后的执行策略。"""
mode: str
use_local_web_search: bool
server_tools: tuple[dict[str, Any], ...] = ()
client_adapter: Optional[str] = None
required_api_protocol: Optional[str] = None
available: bool = False
reason: Optional[str] = None
class ServerToolRegistry:
"""集中注册模型服务端工具,并解析通用执行策略。"""
_CAPABILITIES = (
ServerToolCapability(
tool_id="web_search",
provider_ids=("chatgpt",),
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
base_url_patterns=("api.openai.com",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("openai",),
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
base_url_patterns=("api.openai.com",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
match_without_base_url=False,
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("anthropic",),
model_patterns=(
"claude-opus-4*",
"claude-sonnet-4*",
"claude-haiku-4*",
"claude-opus-5*",
"claude-sonnet-5*",
"claude-haiku-5*",
"claude-fable-5*",
"claude-mythos-5*",
),
base_url_patterns=("api.anthropic.com",),
required_api_protocol="native",
client_adapter="anthropic_native",
tool_definition={
"type": "web_search_20250305",
"name": "web_search",
},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("google",),
model_patterns=("gemini-3*", "gemini-2.5*", "gemini-2.0-flash*"),
required_api_protocol="native",
client_adapter="google_native",
tool_definition={"google_search": {}},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("xai",),
model_patterns=("grok-4.5*",),
base_url_patterns=("api.x.ai",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
),
ServerToolCapability(
tool_id="web_search",
provider_ids=("deepseek",),
model_patterns=("deepseek-v4-flash",),
base_url_patterns=("api.deepseek.com",),
required_api_protocol="responses",
client_adapter="openai_responses",
tool_definition={"type": "web_search"},
),
)
@classmethod
def normalize_web_search_mode(cls, mode: Optional[str]) -> str:
"""规范化联网搜索模式,未知值回退为本地搜索。"""
normalized = str(mode or "local").strip().lower()
return normalized if normalized in WEB_SEARCH_MODES else "local"
@classmethod
def get_capability(
cls,
*,
provider: str,
model: str,
base_url: Optional[str] = None,
tool_id: str,
) -> Optional[ServerToolCapability]:
"""查找指定模型的服务端工具能力。"""
return next(
(
capability
for capability in cls._CAPABILITIES
if capability.tool_id == tool_id
and capability.matches(provider, model, base_url)
),
None,
)
@classmethod
def list_capabilities(
cls,
*,
provider: str,
model: str,
base_url: Optional[str] = None,
) -> list[dict[str, Any]]:
"""列出指定模型可用的服务端工具能力。"""
return [
capability.serialize()
for capability in cls._CAPABILITIES
if capability.matches(provider, model, base_url)
]
@classmethod
def resolve_web_search(
cls,
*,
provider: str,
model: str,
mode: Optional[str],
api_protocol: Optional[str],
base_url: Optional[str] = None,
) -> ServerToolResolution:
"""解析联网搜索应使用本地工具还是模型服务端工具。"""
normalized_mode = cls.normalize_web_search_mode(mode)
normalized_protocol = str(api_protocol or "auto").strip().lower()
capability = cls.get_capability(
provider=provider,
model=model,
base_url=base_url,
tool_id="web_search",
)
if normalized_mode == "disabled":
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=False,
reason="web_search_disabled",
)
if normalized_mode == "local":
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=True,
reason="local_web_search_selected",
)
if capability is None:
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=normalized_mode == "auto",
reason="builtin_web_search_unavailable",
)
if (
normalized_mode == "auto"
and normalized_protocol == "chat_completions"
and capability.required_api_protocol == "responses"
):
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=True,
available=True,
reason="chat_completions_uses_local_fallback",
)
return ServerToolResolution(
mode=normalized_mode,
use_local_web_search=False,
server_tools=(dict(capability.tool_definition),),
client_adapter=capability.client_adapter,
required_api_protocol=capability.required_api_protocol,
available=True,
reason="builtin_web_search_selected",
)
+7 -33
View File
@@ -3,7 +3,7 @@
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询。
系统提示词只注入稳定的检索规则,完整日志由工具按需查询。
"""
import asyncio
@@ -447,7 +447,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
llm = await LLMHelper.get_llm(streaming=False)
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
response = await llm.ainvoke(prompt)
summary = response.content.strip()
summary = LLMHelper.extract_text_content(response.content).strip()
# 清理模型可能输出的前缀(如 "摘要:" "总结:"
summary = re.sub(r"^(摘要|总结|活动记录)[:]\s*", "", summary)
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
@@ -459,12 +459,8 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
<activity_log_index>
{activity_log_index}
</activity_log_index>
<activity_log_guidelines>
The index only shows recent dates and entry counts, not full log contents.
Activity log contents and indexes are not included in the default context.
Use `query_activity_log` only when the user references previous work, asks to continue a prior task, or recent activity is clearly relevant.
Activity logs are read-only and retained for {retention_days} days; use MEMORY.md for durable preferences.
</activity_log_guidelines>
@@ -473,10 +469,10 @@ ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
"""自动记录 Agent 活动日志并注入轻量索引的中间件。
"""自动记录 Agent 活动日志并注入稳定检索规则的中间件。
- abefore_agent: 加载近几天的活动日志索引
- awrap_model_call: 将活动日志索引和检索规则注入系统提示词
- awrap_model_call: 将固定的活动日志检索规则注入系统提示词
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
参数:
@@ -516,31 +512,9 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
"""获取指定日期的日志文件路径。"""
return AsyncPath(self.activity_dir) / f"{date_str}.md"
def _format_activity_log(self, contents: dict[str, str]) -> str:
"""格式化活动日志索引用于系统提示词注入"""
if not contents:
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
retention_days=self.retention_days,
)
# 按日期排序(最近的在前)
sorted_dates = sorted(contents.keys(), reverse=True)
sections = []
for date_str in sorted_dates:
content = contents[date_str].strip()
if content:
sections.append(f"### {date_str}\n{content}")
if not sections:
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
retention_days=self.retention_days,
)
log_body = "\n".join(sections)
def _format_activity_log(self, _contents: dict[str, str]) -> str:
"""生成不受活动日志内容变化影响的系统提示词。"""
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
activity_log_index=log_body,
retention_days=self.retention_days,
)
+67 -8
View File
@@ -27,8 +27,10 @@ from app.agent.middleware.utils import append_to_system_message
from app.agent.tools.tags import ToolTag
from app.log import logger
# 安全提示: SKILL.md 文件最大限制为 10MB,防止 DoS 攻击
MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
# 磁盘读取上限与模型返回上限分离,避免异常大的 Skill 文件撑爆内存或上下文。
MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024
MAX_SKILL_RESULT_CHARS = 64 * 1024
SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)"
# Agent Skills 规范约束 (https://agentskills.io/specification)
MAX_SKILL_NAME_LENGTH = 64
@@ -248,7 +250,17 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = await skill_md_path.read_text(encoding="utf-8", errors="replace")
stat = await skill_md_path.stat()
if stat.st_size > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: file too large (%d bytes)",
skill_md_path,
stat.st_size,
)
continue
skill_content = (await skill_md_path.read_bytes()).decode(
"utf-8", errors="replace"
)
# 解析元数据
skill_metadata = _parse_skill_metadata(
@@ -280,7 +292,16 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
skills: list[SkillMetadata] = []
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = skill_md_path.read_text(encoding="utf-8", errors="replace")
if skill_md_path.stat().st_size > MAX_SKILL_FILE_SIZE:
logger.warning(
"Skipping %s: file too large (%d bytes)",
skill_md_path,
skill_md_path.stat().st_size,
)
continue
skill_content = skill_md_path.read_bytes().decode(
"utf-8", errors="replace"
)
skill_metadata = _parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
@@ -456,6 +477,46 @@ class _SkillToolProvider:
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
return raw_content.decode("utf-8", errors="replace"), truncated
@staticmethod
def _serialize_skill_payload(payload: dict[str, Any]) -> str:
"""序列化 Skill 返回值,并严格限制最终进入模型的字符数。"""
serialized = json.dumps(payload, ensure_ascii=False, indent=2)
if len(serialized) <= MAX_SKILL_RESULT_CHARS:
return serialized
original_content = str(payload.get("content") or "")
truncated_payload = dict(payload)
truncated_payload["truncated"] = True
low = 0
high = len(original_content)
best_result = json.dumps(
{
**truncated_payload,
"content": SKILL_CONTENT_TRUNCATION_SUFFIX.strip(),
},
ensure_ascii=False,
indent=2,
)
while low <= high:
middle = (low + high) // 2
candidate = json.dumps(
{
**truncated_payload,
"content": (
original_content[:middle]
+ SKILL_CONTENT_TRUNCATION_SUFFIX
),
},
ensure_ascii=False,
indent=2,
)
if len(candidate) <= MAX_SKILL_RESULT_CHARS:
best_result = candidate
low = middle + 1
else:
high = middle - 1
return best_result
async def load_skill(self, name: str) -> str:
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
logger.info(f"加载 Skill: name={name}")
@@ -471,7 +532,7 @@ class _SkillToolProvider:
)
content, truncated = await self._read_skill_content(skill["path"])
return json.dumps(
return self._serialize_skill_payload(
{
"success": True,
"skill": {
@@ -483,9 +544,7 @@ class _SkillToolProvider:
},
"content": content,
"truncated": truncated,
},
ensure_ascii=False,
indent=2,
}
)
except Exception as err:
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
+11 -1
View File
@@ -377,11 +377,13 @@ class _SubAgentAgentProvider:
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
) -> None:
"""初始化子代理执行器。"""
self._model = model
self._profiles = {profile.name: profile for profile in profiles}
self._tools = tools
self._server_tools = server_tools or []
self._agents = {}
self._default_agent_name = "general-purpose"
@@ -404,7 +406,7 @@ class _SubAgentAgentProvider:
)
agent = create_agent(
model=self._model,
tools=subagent_tools,
tools=[*subagent_tools, *self._server_tools],
system_prompt=profile.prompt,
name=profile.name,
)
@@ -462,16 +464,19 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
system_prompt: str = SUBAGENT_PARENT_PROMPT,
task_description: str = SUBAGENT_TASK_DESCRIPTION,
stream_handler: Any = None,
) -> None:
"""初始化同步子代理中间件。"""
self.system_prompt = system_prompt
self.stream_handler = stream_handler
self._provider = _SubAgentAgentProvider(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools,
)
self.tools = [
StructuredTool.from_function(
@@ -549,6 +554,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
stream_handler: Any = None,
) -> None:
@@ -558,6 +564,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools,
)
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
@@ -1111,6 +1118,7 @@ def create_subagent_middlewares(
*,
model: BaseChatModel,
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
stream_handler: Any = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
@@ -1120,12 +1128,14 @@ def create_subagent_middlewares(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
)
control_middleware = SubAgentTaskControlMiddleware(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
)
+200 -1
View File
@@ -51,6 +51,18 @@ class UsageMiddleware(AgentMiddleware):
return None
@classmethod
def _first_int(
cls,
candidates: tuple[tuple[Any, tuple[str, ...]], ...],
) -> int | None:
"""按优先级返回首个可用的 usage 整数值。"""
for container, keys in candidates:
value = cls._lookup_int(container, *keys)
if value is not None:
return value
return None
@classmethod
def _extract_model_name(cls, model: Any) -> str | None:
return (
@@ -82,6 +94,131 @@ class UsageMiddleware(AgentMiddleware):
or {}
)
input_token_details = None
if usage_metadata:
getter = getattr(usage_metadata, "get", None)
input_token_details = (
getter("input_token_details")
if callable(getter)
else getattr(usage_metadata, "input_token_details", None)
)
cache_read_tokens = cls._first_int(
(
(
input_token_details,
(
"cache_read",
"cached_tokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
),
),
(
token_usage,
(
"prompt_cache_hit_tokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
),
),
(
response_metadata,
(
"prompt_cache_hit_tokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
"cached_tokens",
),
),
)
)
if cache_read_tokens is None:
cache_read_tokens = cls._first_int(
(
(
token_usage.get("prompt_tokens_details", {}),
("cached_tokens", "cache_read"),
),
(
token_usage.get("input_tokens_details", {}),
("cached_tokens", "cache_read"),
),
)
)
cache_write_tokens = cls._first_int(
(
(
input_token_details,
(
"cache_creation",
"cache_write",
"cache_write_tokens",
"cache_write_input_tokens",
"cacheWriteInputTokens",
),
),
(
token_usage,
(
"cache_creation_input_tokens",
"cache_write_tokens",
"cache_write_input_tokens",
"cacheWriteInputTokens",
),
),
(
response_metadata,
(
"cache_creation_input_tokens",
"cache_write_tokens",
"cache_write_input_tokens",
"cacheWriteInputTokens",
),
),
)
)
if cache_write_tokens is None:
cache_write_tokens = cls._first_int(
(
(
token_usage.get("prompt_tokens_details", {}),
("cache_write_tokens", "cache_creation"),
),
(
token_usage.get("input_tokens_details", {}),
("cache_write_tokens", "cache_creation"),
),
)
)
cache_write_ttl_tokens = sum(
cls._lookup_int(
input_token_details,
ttl_key,
)
or 0
for ttl_key in (
"ephemeral_5m_input_tokens",
"ephemeral_1h_input_tokens",
)
)
if cache_write_ttl_tokens:
cache_write_tokens = cache_write_ttl_tokens
cache_miss_tokens = cls._first_int(
(
(
token_usage,
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
),
(
response_metadata,
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
),
)
)
if input_tokens is None:
input_tokens = cls._lookup_int(
token_usage,
@@ -94,6 +231,27 @@ class UsageMiddleware(AgentMiddleware):
"prompt_token_count",
"input_tokens",
)
if input_tokens is None:
bedrock_input_tokens = cls._lookup_int(token_usage, "inputTokens")
if bedrock_input_tokens is not None:
input_tokens = (
bedrock_input_tokens
+ (cache_read_tokens or 0)
+ (cache_write_tokens or 0)
)
if input_tokens is None and any(
value is not None
for value in (
cache_read_tokens,
cache_write_tokens,
cache_miss_tokens,
)
):
input_tokens = (
(cache_read_tokens or 0)
+ (cache_write_tokens or 0)
+ (cache_miss_tokens or 0)
)
if output_tokens is None:
output_tokens = cls._lookup_int(
@@ -113,8 +271,24 @@ class UsageMiddleware(AgentMiddleware):
if total_tokens is None:
total_tokens = cls._lookup_int(response_metadata, "total_token_count")
has_cache_usage = any(
value is not None
for value in (
cache_read_tokens,
cache_write_tokens,
cache_miss_tokens,
)
)
has_usage = any(
value is not None for value in (input_tokens, output_tokens, total_tokens)
value is not None
for value in (
input_tokens,
output_tokens,
total_tokens,
cache_read_tokens,
cache_write_tokens,
cache_miss_tokens,
)
)
resolved_input = input_tokens or 0
resolved_output = output_tokens or 0
@@ -123,12 +297,32 @@ class UsageMiddleware(AgentMiddleware):
if total_tokens is not None
else resolved_input + resolved_output
)
resolved_cache_read = cache_read_tokens or 0
resolved_cache_write = cache_write_tokens or 0
uncached_input_tokens = (
cache_miss_tokens
if cache_miss_tokens is not None
else max(
resolved_input - resolved_cache_read - resolved_cache_write,
0,
)
)
cache_hit_ratio = (
resolved_cache_read / resolved_input
if has_cache_usage and resolved_input
else None
)
return {
"has_usage": has_usage,
"cache_usage_available": has_cache_usage,
"input_tokens": resolved_input,
"output_tokens": resolved_output,
"total_tokens": resolved_total,
"cache_read_input_tokens": resolved_cache_read,
"cache_write_input_tokens": resolved_cache_write,
"uncached_input_tokens": uncached_input_tokens,
"cache_hit_ratio": cache_hit_ratio,
}
async def awrap_model_call(
@@ -157,9 +351,14 @@ class UsageMiddleware(AgentMiddleware):
if ai_message
else {
"has_usage": False,
"cache_usage_available": False,
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"cache_read_input_tokens": 0,
"cache_write_input_tokens": 0,
"uncached_input_tokens": 0,
"cache_hit_ratio": None,
}
)
context_window_tokens = self._extract_context_window_tokens(request.model)
+9 -2
View File
@@ -65,7 +65,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths. Use `list_directory` to inspect one known directory or a supported remote storage backend, and use `read_file` when the exact local file is known.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
@@ -85,7 +85,14 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
</agent_core>
<communication_runtime>
{verbose_spec}
<progress_updates>
- Base progress updates on meaningful changes in understanding or execution, not on elapsed time or the number of tool calls.
- Do not send a progress update merely because work is starting or because one or two tools have finished. Work through a coherent batch of investigation first.
- Send an intermediate update when you have a useful preliminary conclusion, complete or validate a meaningful stage, discover evidence that materially changes the working direction, or encounter a sustained blocker the user should know about.
- Explain the result or new direction with enough context to be useful, including the key evidence and what you will do next. An update may use several sentences when the finding needs explanation; brevity is not a goal by itself.
- Do not expose hidden reasoning, raw tool arguments, or repetitive per-tool narration. Do not repeat an unchanged status.
- Continue working after each update. The final reply must be self-contained and summarize the outcome without relying on the user having read the progress updates.
</progress_updates>
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
{button_choice_spec}
+1 -14
View File
@@ -24,6 +24,7 @@ SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
COMMON_SHELL_COMMANDS = (
"ssh",
"sshpass",
"scp",
"sftp",
"git",
@@ -139,19 +140,6 @@ class PromptManager:
markdown_spec = self._generate_formatting_instructions(caps)
button_choice_spec = self._generate_button_choice_instructions(msg_channel)
# 啰嗦模式
verbose_spec = ""
if not settings.AI_AGENT_VERBOSE:
verbose_spec = (
"\n\n[Important Instruction] STRICTLY ENFORCED: "
"If tools are needed, DO NOT output any conversational text, explanations, progress updates, "
"or acknowledgements before the first tool call or between tool calls. "
"Call tools directly without any transitional phrases. "
"You MUST remain completely silent until all required tools have finished and you have the final result. "
"Only then may you send one final user-facing reply. "
"DO NOT output any intermediate content whatsoever."
)
# MoviePilot系统信息
moviepilot_info = self._get_moviepilot_info()
voice_reply_spec = self._generate_voice_reply_instructions()
@@ -159,7 +147,6 @@ class PromptManager:
# 始终替换占位符,避免后续 .format() 时因残留花括号报 KeyError
base_prompt = base_prompt.format(
markdown_spec=markdown_spec,
verbose_spec=verbose_spec,
moviepilot_info=moviepilot_info,
voice_reply_spec=voice_reply_spec,
button_choice_spec=button_choice_spec,
+29 -15
View File
@@ -28,7 +28,6 @@ class ToolChain(ChainBase):
# 单个工具结果的兜底上限。各工具仍应优先在自身逻辑中分页或摘要化;
# 这里用于拦截遗漏路径,避免超大结果直接进入模型上下文。
DEFAULT_TOOL_RESULT_MAX_CHARS = 64 * 1024
MIN_TOOL_RESULT_PREVIEW_CHARS = 512
def serialize_tool_result_for_agent(result: Any) -> str:
@@ -59,20 +58,35 @@ def format_tool_result_for_agent(
if not max_chars or max_chars <= 0 or len(formatted_result) <= max_chars:
return formatted_result
preview_limit = max(MIN_TOOL_RESULT_PREVIEW_CHARS, max_chars)
preview = formatted_result[:preview_limit]
payload = {
"tool_result_truncated": True,
"tool_name": tool_name,
"total_chars": len(formatted_result),
"returned_chars": len(preview),
"content_preview": preview,
"message": (
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
def _dump_preview(preview: str) -> str:
"""序列化截断结果,并让 returned_chars 与实际预览保持一致。"""
payload = {
"tool_result_truncated": True,
"tool_name": tool_name,
"total_chars": len(formatted_result),
"returned_chars": len(preview),
"content_preview": preview,
"message": (
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
# JSON 会转义换行、引号和反斜杠,预览本身等于上限时,最终返回值仍可能
# 明显超限。通过二分查找预留包装开销,确保进入模型的最终字符串是硬上限。
low = 0
high = min(len(formatted_result), max_chars)
best_result = _dump_preview("")
while low <= high:
middle = (low + high) // 2
candidate = _dump_preview(formatted_result[:middle])
if len(candidate) <= max_chars:
best_result = candidate
low = middle + 1
else:
high = middle - 1
return best_result
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
@@ -131,6 +131,7 @@ def simplify_search_result(
context: Context,
index: int,
include_description: bool = False,
include_labels: bool = False,
) -> dict:
"""
精简单条搜索结果
@@ -138,6 +139,7 @@ def simplify_search_result(
:param context: 搜索结果上下文
:param index: 搜索结果在原始缓存中的序号
:param include_description: 是否返回种子简介
:param include_labels: 是否返回种子标签
:return: 精简后的搜索结果
"""
simplified = {}
@@ -160,6 +162,8 @@ def simplify_search_result(
}
if include_description:
simplified["torrent_info"]["description"] = torrent_info.description
if include_labels:
simplified["torrent_info"]["labels"] = torrent_info.labels or []
if media_info:
simplified["media_info"] = {
+2 -2
View File
@@ -12,8 +12,8 @@ from app.agent.tools.tags import ToolTag
from app.helper.browser import BrowserSessionHelper
from app.log import logger
# 页面内容最大长度
MAX_CONTENT_LENGTH = 8000
# 页面内容最大长度;保留在全局工具结果兜底上限以内。
MAX_CONTENT_LENGTH = 12_000
# 默认超时时间(秒)
DEFAULT_TIMEOUT = 30
# 截图最大宽度
+71 -11
View File
@@ -7,6 +7,7 @@ import json
import os
import signal
import subprocess
from collections import deque
from dataclasses import dataclass, field
from tempfile import NamedTemporaryFile
from typing import Any, Literal, Optional, TextIO, Type
@@ -27,7 +28,9 @@ from app.log import logger
DEFAULT_TIMEOUT_SECONDS = 60
MAX_TIMEOUT_SECONDS = 300
MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
MAX_OUTPUT_PREVIEW_BYTES = 32 * 1024
MAX_OUTPUT_HEAD_BYTES = 16 * 1024
MAX_OUTPUT_TAIL_BYTES = 16 * 1024
READ_CHUNK_SIZE = 4096
KILL_GRACE_SECONDS = 3
COMMAND_CONCURRENCY_LIMIT = 2
@@ -36,11 +39,13 @@ _command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
@dataclass
class _CommandOutput:
"""保存前 10KB 预览,并在超限时将完整输出写入临时文件。"""
"""保存命令头尾预览,并在超限时将完整输出写入临时文件。"""
preview_limit_bytes: int
preview_entries: list[tuple[str, str]] = field(default_factory=list)
tail_entries: deque[tuple[str, str]] = field(default_factory=deque)
captured_bytes: int = 0
tail_bytes: int = 0
preview_truncated: bool = False
temp_file_path: Optional[str] = None
temp_file_handle: Optional[TextIO] = None
@@ -93,10 +98,12 @@ class _CommandOutput:
self.temp_file_handle = None
def append(self, stream_name: str, text: str) -> None:
"""追加一段输出,超出预览上限后保留完整日志文件。"""
"""追加一段输出,超出预览上限后保留头尾预览和完整日志文件。"""
if not text:
return
self._append_tail(stream_name, text)
if self.temp_file_handle:
self._write_chunk(stream_name, text)
return
@@ -117,6 +124,60 @@ class _CommandOutput:
self.preview_entries.append((stream_name, preview))
self.captured_bytes += len(preview.encode("utf-8"))
def _append_tail(self, stream_name: str, text: str) -> None:
"""维护固定字节大小的尾部输出,方便定位测试和构建失败信息。"""
self.tail_entries.append((stream_name, text))
self.tail_bytes += len(text.encode("utf-8"))
while self.tail_bytes > MAX_OUTPUT_TAIL_BYTES and self.tail_entries:
old_stream, old_text = self.tail_entries.popleft()
old_bytes = len(old_text.encode("utf-8"))
overflow = self.tail_bytes - MAX_OUTPUT_TAIL_BYTES
if old_bytes <= overflow:
self.tail_bytes -= old_bytes
continue
kept_text = old_text.encode("utf-8")[overflow:].decode(
"utf-8", errors="ignore"
)
kept_bytes = len(kept_text.encode("utf-8"))
self.tail_bytes -= old_bytes
if kept_text:
self.tail_entries.appendleft((old_stream, kept_text))
self.tail_bytes += kept_bytes
@staticmethod
def _format_entries(entries: list[tuple[str, str]]) -> str:
"""按 stdout/stderr 切换插入可读的输出分段标题。"""
parts: list[str] = []
last_stream: Optional[str] = None
for stream_name, text in entries:
if stream_name != last_stream:
title = "标准输出" if stream_name == "stdout" else "错误输出"
parts.append(f"\n[{title}]\n")
last_stream = stream_name
parts.append(text)
return "".join(parts).strip()
@property
def combined_preview(self) -> str:
"""返回完整输出或头尾组合预览。"""
if not self.preview_truncated:
return self._format_entries(self.preview_entries)
head_entries: list[tuple[str, str]] = []
remaining = MAX_OUTPUT_HEAD_BYTES
for stream_name, text in self.preview_entries:
if remaining <= 0:
break
clipped = self._clip_text_to_bytes(text, remaining)
if clipped:
head_entries.append((stream_name, clipped))
remaining -= len(clipped.encode("utf-8"))
head = self._format_entries(head_entries)
tail = self._format_entries(list(self.tail_entries))
return (
f"{head}\n\n...(中间输出已省略,完整内容在临时文件中)...\n\n{tail}"
).strip()
@property
def stdout(self) -> str:
"""返回当前保留的 stdout 预览。"""
@@ -295,7 +356,7 @@ class ExecuteCommandTool(MoviePilotTool):
stream_name: str,
output: _CommandOutput,
) -> None:
"""按块读取一次性命令输出,只把前 10KB 保留在返回结果中"""
"""按块读取一次性命令输出,保留 32KB 头尾预览"""
while True:
chunk = await stream.read(READ_CHUNK_SIZE)
if not chunk:
@@ -379,17 +440,16 @@ class ExecuteCommandTool(MoviePilotTool):
file_note = "截至命令终止前的完整输出" if timed_out else "完整输出"
result += (
"\n\n提示:\n"
f"命令输出超过 10KB,仅返回前 {MAX_OUTPUT_PREVIEW_BYTES} 字节内容。\n"
f"命令输出超过 {MAX_OUTPUT_PREVIEW_BYTES // 1024}KB"
f"仅返回前后各 {MAX_OUTPUT_HEAD_BYTES // 1024}KB 预览。\n"
f"{file_note}已写入临时文件: {output.temp_file_path}\n"
"如需完整内容,请继续读取该文件。"
)
if output.stdout:
result += f"\n\n标准输出:\n{output.stdout}"
if output.stderr:
result += f"\n\n错误输出:\n{output.stderr}"
if output.combined_preview:
result += f"\n\n命令输出预览:\n{output.combined_preview}"
if output.preview_truncated:
result += "\n\n...(仅展示前 10KB 内容)"
if not output.stdout and not output.stderr:
result += "\n\n...(仅展示前后各 16KB 内容)"
if not output.combined_preview:
result += "\n\n(无输出内容)"
return result
+8 -1
View File
@@ -42,6 +42,10 @@ class GetSearchResultsInput(BaseModel):
False,
description="Whether to include torrent descriptions in returned results",
)
include_labels: Optional[bool] = Field(
False,
description="Whether to include torrent labels in returned results",
)
show_filter_options: Optional[bool] = Field(
False,
description="Whether to return only optional filter options for re-checking available conditions",
@@ -79,6 +83,7 @@ class GetSearchResultsTool(MoviePilotTool):
title_pattern: Optional[str] = None,
content_pattern: Optional[str] = None,
include_description: bool = False,
include_labels: bool = False,
show_filter_options: bool = False,
page: Optional[int] = 1,
**kwargs,
@@ -96,6 +101,7 @@ class GetSearchResultsTool(MoviePilotTool):
:param title_pattern: 仅匹配种子标题的正则表达式
:param content_pattern: 匹配种子标题简介和标签的正则表达式
:param include_description: 是否在结果中返回种子简介
:param include_labels: 是否在结果中返回种子标签
:param show_filter_options: 是否只返回可用筛选项
:param page: 分页页码
:param kwargs: 工具框架附加参数
@@ -103,7 +109,7 @@ class GetSearchResultsTool(MoviePilotTool):
"""
page = max(1, page or 1)
logger.info(
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, show_filter_options={show_filter_options}, page={page}"
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, include_labels={include_labels}, show_filter_options={show_filter_options}, page={page}"
)
try:
@@ -193,6 +199,7 @@ class GetSearchResultsTool(MoviePilotTool):
item,
index,
include_description=include_description,
include_labels=include_labels,
)
for item, index in zip(page_items, page_indices)
]
+79 -16
View File
@@ -15,21 +15,47 @@ from app.schemas.file import FileItem
from app.utils.string import StringUtils
DEFAULT_DIRECTORY_PAGE_SIZE = 50
MAX_DIRECTORY_PAGE_SIZE = 200
class ListDirectoryInput(BaseModel):
"""查询文件系统目录内容工具的输入参数模型"""
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
limit: Optional[int] = Field(
DEFAULT_DIRECTORY_PAGE_SIZE,
ge=1,
le=MAX_DIRECTORY_PAGE_SIZE,
description=(
f"Maximum items to return in this page (default: {DEFAULT_DIRECTORY_PAGE_SIZE}, "
f"maximum: {MAX_DIRECTORY_PAGE_SIZE})"
),
)
offset: Optional[int] = Field(
0,
ge=0,
description="Number of sorted directory items to skip before this page",
)
class ListDirectoryTool(MoviePilotTool):
"""分页查询本地或远程存储目录中的文件和子目录。"""
name: str = "list_directory"
tags: list[str] = [
ToolTag.Read,
ToolTag.Directory,
ToolTag.File,
]
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
description: str = (
"List actual files and folders in a file system directory (NOT configuration). "
"Shows files and subdirectories with their names, types, sizes, and modification "
f"times. Returns a page of up to {DEFAULT_DIRECTORY_PAGE_SIZE} items with total "
f"count and next offset; limit is capped at {MAX_DIRECTORY_PAGE_SIZE}. "
"Use 'query_directory_settings' to query directory configuration settings."
)
args_schema: Type[BaseModel] = ListDirectoryInput
def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -45,10 +71,14 @@ class ListDirectoryTool(MoviePilotTool):
@staticmethod
def _list_directory_sync(
path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name"
path: str,
storage: Optional[str] = "local",
sort_by: Optional[str] = "name",
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
offset: Optional[int] = 0,
) -> str:
"""
目录遍历可能触发本地磁盘或远程存储请求统一放到线程池中执行
目录遍历可能触发本地磁盘或远程存储请求统一放到线程池中执行并分页返回
"""
if not path:
return "错误:路径不能为空"
@@ -64,9 +94,6 @@ class ListDirectoryTool(MoviePilotTool):
if file_list is None:
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
if not file_list:
return f"目录 {path} 为空"
if sort_by == "time":
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
else:
@@ -78,7 +105,14 @@ class ListDirectoryTool(MoviePilotTool):
)
total_count = len(file_list)
limited_list = file_list[:20]
normalized_limit = max(
1,
min(int(limit or DEFAULT_DIRECTORY_PAGE_SIZE), MAX_DIRECTORY_PAGE_SIZE),
)
normalized_offset = max(0, int(offset or 0))
limited_list = file_list[
normalized_offset : normalized_offset + normalized_limit
]
simplified_items = []
for item in limited_list:
size_str = StringUtils.str_filesize(item.size) if item.size else None
@@ -102,16 +136,39 @@ class ListDirectoryTool(MoviePilotTool):
simplified["extension"] = item.extension
simplified_items.append(simplified)
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
if total_count > 20:
return (
f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n"
f"{result_json}"
)
return result_json
returned_count = len(simplified_items)
has_more = normalized_offset + returned_count < total_count
return json.dumps(
{
"items": simplified_items,
"total_count": total_count,
"returned_count": returned_count,
"limit": normalized_limit,
"offset": normalized_offset,
"has_more": has_more,
"next_offset": (
normalized_offset + returned_count if has_more else None
),
},
ensure_ascii=False,
indent=2,
)
async def run(self, path: str, storage: Optional[str] = "local",
sort_by: Optional[str] = "name", **kwargs) -> str:
sort_by: Optional[str] = "name",
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
offset: Optional[int] = 0,
**kwargs) -> str:
"""
分页查询指定目录的文件和子目录
:param path: 要查询的目录路径
:param storage: 存储类型默认为本地存储
:param sort_by: 排序方式支持名称或修改时间
:param limit: 当前页最大条数最高不超过工具上限
:param offset: 当前页起始偏移量
:return: 包含项目列表和分页元数据的 JSON 字符串
"""
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
try:
@@ -123,7 +180,13 @@ class ListDirectoryTool(MoviePilotTool):
if resolved_path:
path = str(resolved_path)
return await self.run_blocking(
"storage", self._list_directory_sync, path, storage, sort_by
"storage",
self._list_directory_sync,
path,
storage,
sort_by,
limit,
offset,
)
except Exception as e:
logger.error(f"查询目录内容失败: {e}", exc_info=True)
+22 -15
View File
@@ -14,6 +14,10 @@ from app.log import logger
# 最大读取大小 50KB
MAX_READ_SIZE = 50 * 1024
READ_FILE_TRUNCATION_MESSAGE = (
"文件内容超过50KB,本次结果已截断。"
"请使用 start_line 和 end_line 参数指定行号范围分段读取。"
)
class ReadFileInput(BaseModel):
@@ -39,7 +43,11 @@ class ReadFileTool(MoviePilotTool):
ToolTag.Read,
ToolTag.File,
]
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
description: str = (
"Read the content of a text file. Supports reading by line range. Each "
"read is limited to 50KB; when content is truncated, continue with "
"smaller start_line and end_line ranges."
)
args_schema: Type[BaseModel] = ReadFileInput
def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -99,22 +107,21 @@ class ReadFileTool(MoviePilotTool):
truncated = True
if include_metadata:
return json.dumps(
{
"file_path": str(resolved_path),
"sha256": hashlib.sha256(raw_content).hexdigest(),
"size_bytes": len(raw_content),
"start_line": start_line,
"end_line": end_line,
"truncated": truncated,
"content": content,
},
ensure_ascii=False,
indent=2,
)
payload = {
"file_path": str(resolved_path),
"sha256": hashlib.sha256(raw_content).hexdigest(),
"size_bytes": len(raw_content),
"start_line": start_line,
"end_line": end_line,
"truncated": truncated,
}
if truncated:
payload["truncation_message"] = READ_FILE_TRUNCATION_MESSAGE
payload["content"] = content
return json.dumps(payload, ensure_ascii=False, indent=2)
if truncated:
return f"{content}\n\n[警告:文件内容已超过50KB限制,以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"
return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]"
return content
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.apiv1 import api_router
api_router_v2 = APIRouter()
api_router_v2.include_router(api_router)
+224
View File
@@ -0,0 +1,224 @@
import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
from app.schemas.response import Response
API_V2_STR = "/api/v2"
OPENAPI_V2_PATH = f"{API_V2_STR}/openapi.json"
_PROTOCOL_PREFIXES = ("/openai", "/anthropic", "/mcp")
_JSON_CONTENT_TYPES = ("application/json", "+json")
def _is_protocol_path(path: str) -> bool:
"""判断路径是否属于需要保留原始协议响应的接口。"""
relative_path = path.removeprefix(API_V2_STR)
return any(
relative_path == prefix or relative_path.startswith(f"{prefix}/")
for prefix in _PROTOCOL_PREFIXES
)
def _is_json_response(response: StarletteResponse) -> bool:
"""判断响应是否为可安全解析的 JSON 响应。"""
content_type = response.headers.get("content-type", "").split(";", 1)[0]
return any(
content_type == accepted_type or content_type.endswith(accepted_type)
for accepted_type in _JSON_CONTENT_TYPES
)
def _is_response_payload(payload: Any) -> bool:
"""判断响应内容是否已经符合通用 Response 结构。"""
return isinstance(payload, dict) and {
"success",
"message",
"data",
}.issubset(payload)
def _get_error_message(payload: Any) -> str:
"""从旧版错误响应中提取统一的错误消息。"""
if isinstance(payload, dict):
detail = payload.get("detail")
if isinstance(detail, str) and detail:
return detail
if isinstance(detail, list):
messages = [
item.get("msg")
for item in detail
if isinstance(item, dict) and isinstance(item.get("msg"), str)
]
if messages:
return "; ".join(messages)
if detail is not None:
return json.dumps(detail, ensure_ascii=False)
message = payload.get("message")
if isinstance(message, str) and message:
return message
if isinstance(payload, str) and payload:
return payload
return "请求失败"
def _copy_response_headers(source: StarletteResponse, target: StarletteResponse) -> None:
"""复制适配前响应中仍然有效的头信息。"""
for key, value in source.raw_headers:
if key.lower() not in {b"content-length", b"content-type"}:
target.raw_headers.append((key, value))
def _restore_response_body(
source: StarletteResponse,
body: bytes,
) -> StarletteResponse:
"""在检查响应体后恢复原始响应内容和头信息。"""
restored_response = StarletteResponse(
content=body,
status_code=source.status_code,
background=source.background,
)
restored_response.raw_headers = list(source.raw_headers)
return restored_response
class V2ResponseMiddleware(BaseHTTPMiddleware):
"""
v2 REST 接口适配统一的 Response 响应结构
已经返回项目 Response 模型的成功响应保持原样避免改变既有接口语义
OpenAIAnthropic MCP 协议接口也保持原始协议响应
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""处理 v2 请求并在必要时封装 JSON 响应。"""
response = await call_next(request)
if not request.url.path.startswith(f"{API_V2_STR}/"):
return response
if request.url.path == OPENAPI_V2_PATH:
return response
if _is_protocol_path(request.url.path):
return response
if response.status_code in {204, 304} or not _is_json_response(response):
return response
if response.headers.get("content-encoding"):
return response
route = request.scope.get("route")
route_response_model = getattr(route, "response_model", None)
if response.status_code < 400 and route_response_model is Response:
return response
body = b"".join([chunk async for chunk in response.body_iterator])
if not body:
return _restore_response_body(response, body)
try:
payload = json.loads(body)
except (TypeError, ValueError):
return _restore_response_body(response, body)
if _is_response_payload(payload):
return _restore_response_body(response, body)
if response.status_code >= 400:
content = {
"success": False,
"message": _get_error_message(payload),
"data": {},
}
if isinstance(payload, dict) and isinstance(payload.get("detail_i18n"), str):
content["message_i18n"] = payload["detail_i18n"]
else:
content = {
"success": True,
"message": "",
"data": payload,
}
wrapped_response = JSONResponse(
content=content,
status_code=response.status_code,
background=response.background,
)
_copy_response_headers(response, wrapped_response)
return wrapped_response
def configure_v2_openapi(app: FastAPI) -> None:
"""
v2 普通 JSON 接口的 OpenAPI 响应模型改为通用 Response
:param app: 已完成 v1/v2 路由注册的 FastAPI 应用
"""
if getattr(app, "_v2_openapi_configured", False):
return
original_openapi = app.openapi
def custom_openapi() -> dict[str, Any]:
"""生成包含 v2 通用响应模型的 OpenAPI 文档。"""
schema = original_openapi()
components = schema.setdefault("components", {}).setdefault("schemas", {})
components["Response"] = Response.model_json_schema(
ref_template="#/components/schemas/{model}"
)
route_map = {
(route.path, method.lower()): route
for route in app.routes
if isinstance(route, APIRoute)
for method in route.methods
}
response_ref = {"$ref": "#/components/schemas/Response"}
for path, path_item in schema.get("paths", {}).items():
if not path.startswith(f"{API_V2_STR}/"):
continue
for method, operation in path_item.items():
if method not in {
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
}:
continue
route = route_map.get((path, method))
if (
route is None
or route.response_model is None
or route.response_model is Any
or route.response_model is Response
or _is_protocol_path(path)
):
continue
if route.status_code in {204, 304}:
continue
content_type = getattr(route.response_class, "media_type", None)
if content_type and not (
content_type == "application/json" or content_type.endswith("+json")
):
continue
status_code = str(route.status_code or 200)
response = operation.get("responses", {}).get(status_code)
if response and "content" in response:
json_content = response["content"].get("application/json")
if json_content is not None:
json_content["schema"] = response_ref
app.openapi_schema = schema
return schema
app.openapi = custom_openapi
app._v2_openapi_configured = True
+258 -38
View File
@@ -7,6 +7,7 @@ import shutil
import subprocess
import time
import uuid
from collections import deque
from queue import Empty, Queue
from pathlib import Path
from threading import Lock
@@ -50,6 +51,10 @@ WEB_AGENT_UPLOAD_CHUNK_SIZE = 1024 * 1024
WEB_AGENT_BROWSER_AUDIO_SUFFIXES = {".aac", ".m4a", ".mp3", ".mp4", ".wav", ".wave"}
WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0
WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0
WEB_AGENT_STREAM_COALESCE_SECONDS = 0.03
WEB_AGENT_STREAM_COALESCE_MAX_CHARS = 256
WEB_AGENT_STREAM_HEARTBEAT_SECONDS = 15.0
WEB_AGENT_STREAM_QUEUE_MAX_SIZE = 64
_WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {}
_WEB_AGENT_NOTICE_QUEUES: dict[str, list[Queue[schemas.Notification]]] = {}
_WEB_AGENT_NOTICE_LOCK = Lock()
@@ -57,6 +62,107 @@ _WEB_AGENT_NOTICE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
class _WebAgentEventPublisher:
"""合并 WebAgent 文本增量,并通过有界队列向 SSE 消费者提供事件。"""
def __init__(self) -> None:
self._queue: asyncio.Queue[dict] = asyncio.Queue(
maxsize=WEB_AGENT_STREAM_QUEUE_MAX_SIZE
)
self._pending_events: deque[dict] = deque()
self._pending_signal = asyncio.Event()
self._pending_delta = ""
self._delta_timer: Optional[asyncio.TimerHandle] = None
self._disposed = False
self._max_depth = 0
self._last_logged_depth = 0
self._pump_task = asyncio.create_task(self._pump())
@property
def max_depth(self) -> int:
"""返回本轮发布器观测到的最大积压深度。"""
return self._max_depth
def publish(self, event: dict) -> None:
"""发布事件;相邻文本会按时间或长度边界合并。"""
if self._disposed:
return
if event.get("type") == "delta":
self._pending_delta += str(event.get("content") or "")
if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS:
self._flush_delta()
elif self._delta_timer is None:
loop = asyncio.get_running_loop()
self._delta_timer = loop.call_later(
WEB_AGENT_STREAM_COALESCE_SECONDS,
self._flush_delta,
)
return
self._flush_delta()
self._append_event(event)
async def get(self) -> dict:
"""等待并返回下一条已排序事件。"""
return await self._queue.get()
async def aclose(self) -> None:
"""停止发布器并释放等待中的泵任务。"""
if self._disposed:
return
self._disposed = True
self._cancel_delta_timer()
self._pending_delta = ""
self._pending_events.clear()
self._pump_task.cancel()
try:
await self._pump_task
except asyncio.CancelledError:
pass
def _cancel_delta_timer(self) -> None:
"""取消尚未触发的文本合并计时器。"""
if self._delta_timer is None:
return
self._delta_timer.cancel()
self._delta_timer = None
def _flush_delta(self) -> None:
"""把当前文本缓冲转换成一条增量事件。"""
self._cancel_delta_timer()
if not self._pending_delta or self._disposed:
return
content = self._pending_delta
self._pending_delta = ""
self._append_event({"type": "delta", "content": content})
def _append_event(self, event: dict) -> None:
"""追加待发布事件,相邻文本在出口阻塞时继续合并。"""
if (
event.get("type") == "delta"
and self._pending_events
and self._pending_events[-1].get("type") == "delta"
):
self._pending_events[-1]["content"] += str(event.get("content") or "")
else:
self._pending_events.append(event)
self._pending_signal.set()
depth = self._queue.qsize() + len(self._pending_events)
self._max_depth = max(self._max_depth, depth)
if depth >= WEB_AGENT_STREAM_QUEUE_MAX_SIZE // 2 and depth > self._last_logged_depth:
self._last_logged_depth = depth
logger.debug(f"WebAgent SSE事件积压深度: {depth}")
async def _pump(self) -> None:
"""按发布顺序把本地合并结果写入有界出口队列。"""
while True:
await self._pending_signal.wait()
while self._pending_events:
event = self._pending_events.popleft()
await self._queue.put(event)
self._pending_signal.clear()
def _ensure_superuser(user: User) -> None:
"""校验当前用户是否为超级管理员。"""
if not getattr(user, "is_superuser", False):
@@ -146,6 +252,20 @@ class _WebAgentStreamingHandler(StreamingHandler):
"""
self._on_emit = on_emit
def record_tool_call(
self,
tool_name: str,
tool_message: Optional[str] = None,
tool_kwargs: Optional[dict[str, Any]] = None,
) -> None:
"""记录并立即输出 Web 工具事件,避免汇总延迟到正文结束后。"""
super().record_tool_call(
tool_name=tool_name,
tool_message=tool_message,
tool_kwargs=tool_kwargs,
)
self.flush_pending_tool_summary()
def emit(self, token: str) -> str:
"""追加 token 并同步通知 SSE 生产者。"""
emitted = super().emit(token)
@@ -268,6 +388,18 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
"""文本输出交由 Web 流式处理器统一回调,避免重复增量。"""
self.stream_handler.emit(text)
def _emit_output(self, text: str) -> None:
"""保留完整输出状态,同时只把本次增量交给 Web SSE 回调。"""
if not text:
return
self._streamed_output += text
if not callable(self.output_callback):
return
try:
self.output_callback(text)
except Exception as e:
logger.debug(f"Web智能体输出回调失败: {e}")
def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str:
"""
@@ -318,16 +450,53 @@ async def _get_accessible_agent_chat(
return chat
def _append_web_agent_text_segment(assistant_message: dict, content: str) -> None:
"""
将文本增量追加到展示消息并仅合并相邻文本片段
:param assistant_message: 当前助手展示消息
:param content: 新增文本
"""
if not content:
return
assistant_message["content"] = str(assistant_message.get("content") or "") + content
segments = assistant_message.setdefault("segments", [])
if segments and segments[-1].get("type") == "text":
segments[-1]["content"] = str(segments[-1].get("content") or "") + content
else:
segments.append({"type": "text", "content": content})
def _build_legacy_web_agent_segments(content: str, tools: list[dict]) -> list[dict]:
"""
为未携带有序片段的旧展示消息生成兼容布局
:param content: 聚合后的助手文本
:param tools: 工具提示列表
:return: 按旧版工具在前文本在后的顺序生成的片段
"""
segments = [
{"type": "tool", "toolIndex": index}
for index in range(len(tools))
]
if content:
segments.append({"type": "text", "content": content})
return segments
def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None:
"""
WebAgent SSE 事件同步应用到服务端展示消息快照
"""
event_type = event.get("type")
if event_type == "delta":
assistant_message["content"] += event.get("content") or ""
_append_web_agent_text_segment(
assistant_message, event.get("content") or ""
)
elif event_type == "tool":
for tool in assistant_message["tools"]:
tool["status"] = "done"
tool_index = len(assistant_message["tools"])
assistant_message["tools"].append(
{
"id": f"tool-{uuid.uuid4().hex}",
@@ -335,6 +504,9 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None
"status": "running",
}
)
assistant_message.setdefault("segments", []).append(
{"type": "tool", "toolIndex": tool_index}
)
elif event_type == "attachment" and event.get("attachment"):
assistant_message["attachments"].append(event["attachment"])
elif event_type == "choice" and event.get("choice"):
@@ -346,14 +518,22 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None
assistant_message["attachments"] = target_message.get("attachments") or []
assistant_message["choices"] = target_message.get("choices") or []
assistant_message["tools"] = target_message.get("tools") or []
target_segments = target_message.get("segments")
assistant_message["segments"] = (
target_segments
if isinstance(target_segments, list)
else _build_legacy_web_agent_segments(
assistant_message["content"], assistant_message["tools"]
)
)
assistant_message["status"] = target_message.get("status") or "done"
elif event_type == "error":
assistant_message["status"] = "error"
assistant_message["content"] = (
assistant_message["content"]
or event.get("message")
or "智能助手响应失败"
)
if not assistant_message["content"]:
_append_web_agent_text_segment(
assistant_message,
event.get("message") or "智能助手响应失败",
)
for tool in assistant_message["tools"]:
tool["status"] = "done"
elif event_type == "done":
@@ -1743,14 +1923,52 @@ async def web_agent_stream(
{"session_id": session_id},
locale=locale,
)
events = await _collect_web_agent_traditional_events(
text=prompt,
current_user=current_user,
original_message_id=payload.original_message_id,
original_chat_id=payload.original_chat_id,
collection_task = asyncio.create_task(
_collect_web_agent_traditional_events(
text=prompt,
current_user=current_user,
original_message_id=payload.original_message_id,
original_chat_id=payload.original_chat_id,
)
)
try:
while True:
try:
events = await asyncio.wait_for(
asyncio.shield(collection_task),
timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS,
)
break
except asyncio.TimeoutError:
if await request.is_disconnected():
collection_task.cancel()
return
yield ": heartbeat\n\n"
except asyncio.CancelledError:
if not collection_task.done():
collection_task.cancel()
return
assistant_message = _build_web_agent_display_message_from_events(events)
display_messages.append(assistant_message)
async def save_display_snapshot() -> None:
"""后台保存传统消息展示快照,不阻塞 SSE 终态。"""
try:
await run_in_threadpool(
_save_web_agent_display_snapshot,
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
except Exception as err:
logger.error(f"保存WebAgent传统消息快照失败: {str(err)}")
snapshot_task = asyncio.create_task(save_display_snapshot())
_WEB_AGENT_BACKGROUND_TASKS.add(snapshot_task)
snapshot_task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
await asyncio.sleep(0)
for event in events:
event_payload = copy.deepcopy(event)
yield _build_web_agent_sse(
@@ -1759,21 +1977,14 @@ async def web_agent_stream(
locale=locale,
)
if await request.is_disconnected():
break
await run_in_threadpool(
_save_web_agent_display_snapshot,
session_id=session_id,
current_user=current_user,
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
return
yield _build_web_agent_sse("done", {}, locale=locale)
return StreamingResponse(
traditional_event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
@@ -1820,8 +2031,7 @@ async def web_agent_stream(
session_id = _build_web_agent_session_id(current_user, payload.session_id)
MessageChain().bind_user_session(str(current_user.id), session_id)
event_queue: asyncio.Queue = asyncio.Queue()
last_output = ""
event_publisher = _WebAgentEventPublisher()
user_attachments = _build_web_agent_input_attachments(
images=payload.images or [],
files=[
@@ -1846,16 +2056,13 @@ async def web_agent_stream(
)
display_messages.append(assistant_display_message)
def output_callback(output: str) -> None:
def output_callback(delta: str) -> None:
"""
接收 Agent 累积输出并转成增量事件
接收 Agent 文本增量并转换成 SSE 事件
"""
nonlocal last_output
delta = output[len(last_output):] if output.startswith(last_output) else output
last_output = output
for item in _split_web_agent_output(delta):
_apply_web_agent_display_event(item, assistant_display_message)
event_queue.put_nowait(item)
event_publisher.publish(item)
def notification_callback(notification: schemas.Notification) -> None:
"""
@@ -1863,7 +2070,7 @@ async def web_agent_stream(
"""
for item in _build_web_agent_notification_events(notification):
_apply_web_agent_display_event(item, assistant_display_message)
event_queue.put_nowait(item)
event_publisher.publish(item)
async def event_generator() -> AsyncIterator[str]:
"""
@@ -1905,10 +2112,12 @@ async def web_agent_stream(
"message": f"智能助手执行失败: {str(err)}",
}
_apply_web_agent_display_event(error_event, assistant_display_message)
await event_queue.put(error_event)
event_publisher.publish(error_event)
finally:
done_event = {"type": "done"}
_apply_web_agent_display_event(done_event, assistant_display_message)
# 终态先进入 SSE 队列,避免展示快照落库延迟前端结束动画。
event_publisher.publish(done_event)
await run_in_threadpool(
_save_web_agent_display_snapshot,
session_id=session_id,
@@ -1916,40 +2125,51 @@ async def web_agent_stream(
messages=display_messages,
client_session_id=payload.session_id or session_id,
)
await event_queue.put(done_event)
task = asyncio.create_task(run_agent())
_WEB_AGENT_BACKGROUND_TASKS.add(task)
task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
disconnected = False
terminal_sent = False
try:
yield _build_web_agent_sse(
"start",
{"session_id": session_id},
locale=locale,
)
disconnected = False
while not global_vars.is_system_stopped:
if await request.is_disconnected():
disconnected = True
break
event = await event_queue.get()
try:
event = await asyncio.wait_for(
event_publisher.get(),
timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS,
)
except asyncio.TimeoutError:
yield ": heartbeat\n\n"
continue
event_type = str(event.get("type") or "")
if event_type == "done":
terminal_sent = True
yield _build_web_agent_sse(
event.pop("type"),
event,
event_type,
{key: value for key, value in event.items() if key != "type"},
locale=locale,
)
if task.done() and event_queue.empty():
if event_type == "done":
break
except asyncio.CancelledError:
disconnected = True
return
finally:
if not task.done() and not disconnected:
if not task.done() and not disconnected and not terminal_sent:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await event_publisher.aclose()
# 客户端退到后台导致 SSE 断开时,保留后台 Agent 继续执行;完成后会保存展示快照,
# 前端恢复可见时可通过会话详情接口拉取最终状态。
@@ -1957,7 +2177,7 @@ async def web_agent_stream(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
-57
View File
@@ -4,70 +4,13 @@ from fastapi import APIRouter, Depends
from app import schemas
from app.chain.douban import DoubanChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser_async
from app.modules.douban.douban_cache import DoubanCache
from app.schemas import MediaType
from app.schemas.types import SystemConfigKey
router = APIRouter()
@router.get(
"/cache", summary="查询豆瓣识别缓存", response_model=schemas.Response
)
async def douban_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的豆瓣识别缓存。"""
cache_items = DoubanCache().list_items()
recognized_count = sum(1 for item in cache_items if item["douban_id"])
return schemas.Response(
success=True,
data={
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"shared_recognized": SystemConfigOper().get(
SystemConfigKey.MediaRecognizeShareCount
) or 0,
"shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE,
"data": cache_items,
},
)
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定豆瓣识别缓存",
response_model=schemas.Response,
)
async def delete_douban_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条豆瓣识别缓存。"""
deleted_item = DoubanCache().delete(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="豆瓣识别缓存不存在")
return schemas.Response(success=True, message="豆瓣识别缓存删除成功")
@router.delete(
"/cache", summary="清空豆瓣识别缓存", response_model=schemas.Response
)
async def clear_douban_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部豆瓣识别缓存。"""
DoubanCache().clear()
return schemas.Response(success=True, message="豆瓣识别缓存清理完成")
@router.get(
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
)
+3
View File
@@ -39,6 +39,7 @@ class LlmTestRequest(BaseModel):
temperature: Optional[float] = None
use_proxy: Optional[bool] = None
api_protocol: Optional[str] = None
web_search_mode: Optional[str] = None
class LlmProviderAuthStartRequest(BaseModel):
@@ -271,6 +272,7 @@ async def llm_test(
user_agent=settings.LLM_USER_AGENT,
use_proxy=settings.LLM_USE_PROXY,
api_protocol=settings.LLM_API_PROTOCOL,
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
)
if not payload.provider:
@@ -305,6 +307,7 @@ async def llm_test(
"user_agent": payload.user_agent,
"use_proxy": payload.use_proxy,
"api_protocol": payload.api_protocol,
"web_search_mode": payload.web_search_mode,
}
if payload.temperature is not None:
test_kwargs["temperature"] = payload.temperature
+1 -1
View File
@@ -90,7 +90,7 @@ def wallpaper() -> Any:
"""
url = WallpaperHelper().get_wallpaper()
if url:
return schemas.Response(success=True, message=url)
return schemas.Response(success=True, data=url)
return schemas.Response(success=False)
+25 -3
View File
@@ -9,6 +9,7 @@ from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import Context
from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.core.security import verify_token, verify_apitoken
from app.db.models import User
@@ -22,6 +23,29 @@ router = APIRouter()
MediaSource = str
def _build_recognize_metainfo(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
) -> MetaBase:
"""构造标题识别元数据,并兼容第三方客户端传入媒体文件路径。"""
custom_word_list = custom_words.split("\n") if custom_words else None
normalized_title = title.replace("\\", "/")
title_path = Path(normalized_title)
if (
("/" in title or "\\" in title)
and "://" not in title
and title_path.suffix.lower() in settings.RMT_MEDIAEXT
):
metainfo = MetaInfoPath(
title_path,
custom_words=custom_word_list,
)
metainfo.title = title
return metainfo
return MetaInfo(title, subtitle, custom_words=custom_word_list)
def _build_media_seasons(
mediainfo: Any, season: Optional[int] = None,
) -> List[schemas.MediaSeason]:
@@ -84,9 +108,7 @@ async def recognize(
:param _:
"""
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = MetaInfo(
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
)
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
source=source,
+20 -4
View File
@@ -11,6 +11,7 @@ from starlette import status
from starlette.responses import StreamingResponse
from app import schemas
from app.api.apiv2_utils import API_V2_STR, OPENAPI_V2_PATH
from app.command import Command
from app.core.cache import async_fresh
from app.core.config import settings
@@ -36,8 +37,15 @@ from app.scheduler import Scheduler
from app.schemas.event import PluginDataResetEventData
from app.schemas.types import ChainEventType, SystemConfigKey
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
PROTECTED_ROUTES = {
"/api/v1/openapi.json",
OPENAPI_V2_PATH,
"/docs",
"/docs/oauth2-redirect",
"/redoc",
}
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
PLUGIN_V2_PREFIX = f"{API_V2_STR}/plugin"
router = APIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
@@ -158,8 +166,11 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
elif Depends(verify_apikey) not in dependencies:
dependencies.append(Depends(verify_apikey))
app.add_api_route(**api, tags=["plugin"])
v2_api = api.copy()
v2_api["path"] = api_path.replace(PLUGIN_PREFIX, PLUGIN_V2_PREFIX, 1)
app.add_api_route(**v2_api, tags=["plugin"])
is_modified = True
logger.debug(f"Added plugin route: {api_path}")
logger.debug(f"Added plugin routes: {api_path}, {v2_api['path']}")
except Exception as e:
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
@@ -177,8 +188,13 @@ def _remove_routes(plugin_id: str) -> bool:
"""
if not plugin_id:
return False
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)]
prefixes = {
f"{PLUGIN_PREFIX}/{plugin_id}/",
f"{PLUGIN_V2_PREFIX}/{plugin_id}/",
}
routes_to_remove = [
route for route in app.routes if any(route.path.startswith(prefix) for prefix in prefixes)
]
removed = False
for route in routes_to_remove:
try:
+51 -75
View File
@@ -36,6 +36,12 @@ from app.db.user_oper import (
)
from app.helper.image import ImageHelper
from app.helper.locale import LocaleHelper
from app.helper.market import (
PLUGIN_MARKET_WIKI_URL,
extract_plugin_market_repos_from_wiki,
merge_plugin_market_repos,
split_plugin_market_repo_urls,
)
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper
@@ -70,32 +76,47 @@ _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:
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
"""校验强制服务端联网搜索配置,返回用户可读错误信息。"""
from app.agent.llm.server_tools import (
ServerToolRegistry,
ServerToolUnavailableError,
)
mode = ServerToolRegistry.normalize_web_search_mode(
env.get(
"LLM_WEB_SEARCH_MODE",
getattr(settings, "LLM_WEB_SEARCH_MODE", "local"),
)
)
if mode != "builtin":
return None
repo_url = repo_url.removesuffix(".git")
parsed_url = urlparse(repo_url)
if parsed_url.scheme not in {"http", "https"}:
provider = str(
env.get("LLM_PROVIDER", getattr(settings, "LLM_PROVIDER", "")) or ""
).strip()
model = str(
env.get("LLM_MODEL", getattr(settings, "LLM_MODEL", "")) or ""
).strip()
base_url = env.get("LLM_BASE_URL", getattr(settings, "LLM_BASE_URL", None))
capability = ServerToolRegistry.get_capability(
provider=provider,
model=model,
base_url=str(base_url or "").strip() or None,
tool_id="web_search",
)
if capability:
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]}"
return str(
ServerToolUnavailableError(
provider=provider,
model=model,
tool_id="web_search",
)
)
def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
@@ -115,55 +136,6 @@ def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
)
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:
"""
判断目标URL是否仍然落在允许的协议主机端口和路径前缀内
@@ -763,6 +735,10 @@ async def set_env_setting(
"""
更新系统环境变量仅管理员
"""
validation_error = _validate_llm_server_tool_config(env)
if validation_error:
return schemas.Response(success=False, message=validation_error)
result = settings.update_settings(env=env)
# 统计成功和失败的结果
success_updates = {k: v for k, v in result.items() if v[0]}
@@ -844,7 +820,7 @@ async def sync_plugin_market_from_wiki(
"""
Wiki 插件文档同步插件市场仓库地址
"""
wiki_url = (request.wiki_url if request else None) or _PLUGIN_MARKET_WIKI_URL
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 同步地址")
@@ -864,14 +840,14 @@ async def sync_plugin_market_from_wiki(
message=f"访问 Wiki 插件仓库清单失败,状态码:{res.status_code}",
)
wiki_repos = _extract_plugin_market_repos_from_wiki(res.text)
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_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_repos = merge_plugin_market_repos(local_repos, wiki_repos)
merged_value = ",".join(merged_repos)
success, message = settings.update_setting("PLUGIN_MARKET", merged_value)
+1 -1
View File
@@ -119,7 +119,7 @@ async def upload_avatar(
if not user:
return schemas.Response(success=False, message="用户不存在")
await user.async_update(db, {"avatar": f"data:image/ico;base64,{file_base64}"})
return schemas.Response(success=True, message=file.filename)
return schemas.Response(success=True, data={"filename": file.filename})
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
+27
View File
@@ -1349,6 +1349,33 @@ class MessageChain(ChainBase):
f"排队消息数: {status.get('pending_messages', 0)}",
f"最后更新: {status.get('last_updated_at') or '暂无'}",
]
if status.get("cache_usage_available"):
last_cache_ratio = status.get("last_cache_hit_ratio")
total_cache_ratio = status.get("total_cache_hit_ratio")
lines.insert(
6,
"最近一次缓存: "
f"命中 {cls._format_token_count(status.get('last_cache_read_input_tokens'))} / "
f"写入 {cls._format_token_count(status.get('last_cache_write_input_tokens'))} / "
f"未命中 {cls._format_token_count(status.get('last_uncached_input_tokens'))}"
+ (
f" ({last_cache_ratio * 100:.2f}%)"
if last_cache_ratio is not None
else ""
),
)
lines.insert(
8,
"当前会话累计缓存: "
f"命中 {cls._format_token_count(status.get('total_cache_read_input_tokens'))} / "
f"写入 {cls._format_token_count(status.get('total_cache_write_input_tokens'))} / "
f"未命中 {cls._format_token_count(status.get('total_uncached_input_tokens'))}"
+ (
f" ({total_cache_ratio * 100:.2f}%)"
if total_cache_ratio is not None
else ""
),
)
return "\n".join(lines)
def remote_session_status(
+171 -11
View File
@@ -6,6 +6,7 @@ import traceback
import uuid
from copy import deepcopy
from pathlib import Path
from time import monotonic
from typing import List, Optional, Tuple, Union, Dict, Callable, Any
from app import schemas
@@ -122,11 +123,17 @@ class JobManager:
_season_episodes: Dict[Tuple, List[int]] = {}
# 记录从 meta 作业迁移到 media 作业的关系,用于清理提前失败后残留的 media 作业
_meta_to_media_ids: Dict[Tuple, set[Tuple]] = {}
# 记录任务最近一次状态心跳,供外部异步接管任务的失活检测使用
_task_state_changed_at: Dict[Tuple[str, str], float] = {}
# 记录仍由主程序整理线程直接执行的任务,避免把阻塞中的本地任务误判为失活
_active_executions: set[Tuple[str, str]] = set()
def __init__(self):
self._job_view = {}
self._season_episodes = {}
self._meta_to_media_ids = {}
self._task_state_changed_at = {}
self._active_executions = set()
@staticmethod
def __get_meta_id(meta: MetaBase = None, season: Optional[int] = None) -> Tuple:
@@ -248,6 +255,7 @@ class JobManager:
state=state,
)
)
self._task_state_changed_at[file_key] = monotonic()
# 添加季集信息
if self._season_episodes.get(__mediaid__):
self._season_episodes[__mediaid__].extend(task.meta.episode_list)
@@ -262,7 +270,9 @@ class JobManager:
"""
将任务从 meta 作业迁移到 media 作业
"""
curr_task, source_job_id = self.__remove_task_with_job_id(task.fileitem)
curr_task, source_job_id = self.__remove_task_with_job_id(
task.fileitem, preserve_execution=True
)
if not self.add_task(task, state=curr_task.state if curr_task else "waiting"):
return False
if curr_task and task.mediainfo:
@@ -290,14 +300,116 @@ class JobManager:
"""
移除指定作业和对应季集缓存
"""
if job_id in self._season_episodes:
self._season_episodes.pop(job_id)
if job_id in self._job_view:
self._job_view.pop(job_id)
job = self._job_view.pop(job_id, None)
self._season_episodes.pop(job_id, None)
if not job:
return
for task in job.tasks:
file_key = self.__get_file_key(task.fileitem)
if file_key:
self._task_state_changed_at.pop(file_key, None)
self._active_executions.discard(file_key)
def __remove_done_job_groups(self, job_ids: set[Tuple]):
"""
清理已进入终态的独立作业或关联作业组
"""
candidates = set(job_ids)
for metaid, mediaids in list(self._meta_to_media_ids.items()):
related_ids = {metaid, *mediaids}
if not related_ids.intersection(candidates):
continue
if all(self.__is_job_done(job_id) for job_id in related_ids):
for job_id in related_ids:
self.__pop_job(job_id)
self._meta_to_media_ids.pop(metaid, None)
candidates.difference_update(related_ids)
referenced_ids = {
job_id
for metaid, mediaids in self._meta_to_media_ids.items()
for job_id in {metaid, *mediaids}
}
for job_id in candidates - referenced_ids:
if self.__is_job_done(job_id):
self.__pop_job(job_id)
def start_execution(self, task: TransferTask):
"""
标记任务仍由主程序整理线程直接执行
:param task: 整理任务
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
self._active_executions.add(file_key)
def finish_execution(self, task: TransferTask):
"""
结束主程序整理线程对任务的直接执行标记
:param task: 整理任务
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
self._active_executions.discard(file_key)
def expire_stale_running_tasks(
self, timeout_seconds: int
) -> List[Tuple[FileItem, int]]:
"""
将外部接管后长期无心跳的运行中任务标记失败并清理作业视图
主程序整理线程仍在直接执行的任务不会被清理以免把阻塞中的真实任务
误报为已终止外部接管方可重复调用 ``running_task`` 刷新状态心跳
:param timeout_seconds: 失活超时秒数小于等于 0 时禁用
:return: 已失活任务及其无心跳秒数
"""
if timeout_seconds <= 0:
return []
current_time = monotonic()
expired: List[Tuple[FileItem, int]] = []
affected_job_ids: set[Tuple] = set()
with job_lock:
for mediaid, job in self._job_view.items():
for task in job.tasks:
file_key = self.__get_file_key(task.fileitem)
if (
not file_key
or task.state != "running"
or file_key in self._active_executions
):
continue
updated_at = self._task_state_changed_at.get(file_key, current_time)
inactive_seconds = current_time - updated_at
if inactive_seconds < timeout_seconds:
continue
task.state = "failed"
self._task_state_changed_at[file_key] = current_time
episodes = getattr(task.meta, "episode_list", None) or []
if mediaid in self._season_episodes:
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid]) - set(episodes)
)
expired.append((task.fileitem, int(inactive_seconds)))
affected_job_ids.add(mediaid)
self.__remove_done_job_groups(affected_job_ids)
return expired
def running_task(self, task: TransferTask):
"""
设置任务为运行中
设置任务为运行中并刷新外部异步任务的状态心跳
"""
with job_lock:
__mediaid__ = self.__get_id(task)
@@ -307,6 +419,9 @@ class JobManager:
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "running"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def finish_task(self, task: TransferTask):
@@ -321,6 +436,9 @@ class JobManager:
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "completed"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def fail_task(self, task: TransferTask):
@@ -335,6 +453,9 @@ class JobManager:
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "failed"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
# 移除剧集信息
if __mediaid__ in self._season_episodes:
@@ -359,6 +480,7 @@ class JobManager:
continue
if job_task.state not in ["completed", "failed"]:
job_task.state = "failed"
self._task_state_changed_at[file_key] = monotonic()
if mediaid in self._season_episodes:
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid])
@@ -374,7 +496,9 @@ class JobManager:
return task
def __remove_task_with_job_id(
self, fileitem: FileItem
self,
fileitem: FileItem,
preserve_execution: bool = False,
) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]:
"""
根据文件项移除任务并返回任务所在的作业ID
@@ -388,6 +512,9 @@ class JobManager:
for task in job.tasks:
if self.__get_file_key(task.fileitem) == file_key:
job.tasks.remove(task)
self._task_state_changed_at.pop(file_key, None)
if not preserve_execution:
self._active_executions.discard(file_key)
# 如果没有作业了,则移除作业
if not job.tasks:
self._job_view.pop(mediaid)
@@ -407,10 +534,9 @@ class JobManager:
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ in self._job_view:
# 移除季集信息
if __mediaid__ in self._season_episodes:
self._season_episodes.pop(__mediaid__)
return self._job_view.pop(__mediaid__)
job = self._job_view[__mediaid__]
self.__pop_job(__mediaid__)
return job
return None
def try_remove_job(self, task: TransferTask):
@@ -1509,6 +1635,33 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return
self.jobview.remove_task(fileitem)
def __start_job_execution(self, task: TransferTask):
"""在作业视图支持执行租约时标记主程序任务开始执行。"""
marker = getattr(self.jobview, "start_execution", None)
if marker:
marker(task)
def __finish_job_execution(self, task: TransferTask):
"""在作业视图支持执行租约时标记主程序任务结束执行。"""
marker = getattr(self.jobview, "finish_execution", None)
if marker:
marker(task)
def __expire_stale_transfer_tasks(self):
"""清理外部接管后失去状态心跳的运行中整理任务。"""
timeout_minutes = max(int(settings.TRANSFER_TASK_TIMEOUT), 0)
expire_tasks = getattr(self.jobview, "expire_stale_running_tasks", None)
expired_tasks = (
expire_tasks(timeout_seconds=timeout_minutes * 60)
if expire_tasks
else []
)
for fileitem, inactive_seconds in expired_tasks:
logger.error(
f"整理任务 {fileitem.path} 已连续 {inactive_seconds // 60} 分钟无状态心跳,"
"已标记失败并从整理队列视图清理"
)
def __fail_transfer_task(self, task: TransferTask):
"""
标记异常整理任务失败并清理作业视图
@@ -1560,6 +1713,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
self._active_tasks += 1
try:
self.__start_job_execution(task)
# 更新进度
__process_msg = f"正在整理 {fileitem.name} ..."
logger.info(__process_msg)
@@ -1598,6 +1752,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
self._processed_num += 1
self._fail_num += 1
finally:
self.__finish_job_execution(task)
self._queue.task_done()
with task_lock:
# 减少运行中的任务数
@@ -1618,6 +1773,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
except queue.Empty:
# 即使队列空了,如果还有任务在运行,也不应该结束进度
# 这部分逻辑已经在 finally 的 active_tasks == 0 中处理了
self.__expire_stale_transfer_tasks()
continue
except Exception as e:
logger.error(f"整理队列处理出现错误:{e} - {traceback.format_exc()}")
@@ -1914,6 +2070,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
获取整理任务列表
"""
self.__expire_stale_transfer_tasks()
return self.jobview.list_jobs()
def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:
@@ -3446,6 +3603,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
},
)
try:
self.__start_job_execution(transfer_task)
state, err_msg = self.__handle_transfer(
task=transfer_task,
callback=_preview_callback if preview else self.__default_callback,
@@ -3458,6 +3616,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if not preview:
self.__fail_transfer_task(transfer_task)
state, err_msg = False, str(e)
finally:
self.__finish_job_execution(transfer_task)
if not state:
all_success = False
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
+16
View File
@@ -1274,9 +1274,17 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
cache_key, cached_value, cache_region
)
async def cache_delete(*args, **kwargs) -> None:
"""
删除当前参数对应的缓存
"""
cache_key = __get_cache_key(args, kwargs)
await cache_backend.delete(cache_key, region=cache_region)
async_wrapper.cache_region = cache_region
async_wrapper.cache_clear = cache_clear
async_wrapper.cache_exists = cache_exists
async_wrapper.cache_delete = cache_delete
return async_wrapper
else:
# 同步函数使用同步缓存后端
@@ -1317,9 +1325,17 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
cache_key, cached_value, cache_region
)
def cache_delete(*args, **kwargs) -> None:
"""
删除当前参数对应的缓存
"""
cache_key = __get_cache_key(args, kwargs)
cache_backend.delete(cache_key, region=cache_region)
wrapper.cache_region = cache_region
wrapper.cache_clear = cache_clear
wrapper.cache_exists = cache_exists
wrapper.cache_delete = cache_delete
return wrapper
return decorator
+8 -23
View File
@@ -178,7 +178,7 @@ class ConfigModel(BaseModel):
PACKAGE_CACHE_DAYS: int = 90
# pip/uv 包下载缓存根目录,留空时使用配置目录下的 .cache
PACKAGE_CACHE_ROOT: Optional[str] = None
# 元数据识别缓存过期时间(小时),0为自动
# 单条元数据识别缓存有效期(小时),0为自动
META_CACHE_EXPIRE: int = 0
# ==================== 网络代理配置 ====================
@@ -391,6 +391,8 @@ class ConfigModel(BaseModel):
# ==================== 整理配置 ====================
# 文件整理线程数
TRANSFER_THREADS: int = 1
# 外部接管的运行中整理任务无状态心跳超时(分钟),0 表示禁用
TRANSFER_TASK_TIMEOUT: int = 120
# 电影重命名格式
MOVIE_RENAME_FORMAT: str = (
"{{title}}{% if year %} ({{year}}){% endif %}"
@@ -430,26 +432,7 @@ class ConfigModel(BaseModel):
# ==================== 插件配置 ====================
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = (
"https://github.com/jxxghp/MoviePilot-Plugins,"
"https://github.com/thsrite/MoviePilot-Plugins,"
"https://github.com/honue/MoviePilot-Plugins,"
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
"https://github.com/DDSRem-Dev/MoviePilot-Plugins,"
"https://github.com/madrays/MoviePilot-Plugins,"
"https://github.com/justzerock/MoviePilot-Plugins,"
"https://github.com/KoWming/MoviePilot-Plugins,"
"https://github.com/wikrin/MoviePilot-Plugins,"
"https://github.com/HankunYu/MoviePilot-Plugins,"
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
"https://github.com/Aqr-K/MoviePilot-Plugins,"
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
"https://github.com/gxterry/MoviePilot-Plugins,"
"https://github.com/DzAvril/MoviePilot-Plugins,"
"https://github.com/mrtian2016/MoviePilot-Plugins,"
"https://github.com/Hqyel/MoviePilot-Plugins-Third,"
"https://github.com/xijin285/MoviePilot-Plugins,"
"https://github.com/Seed680/MoviePilot-Plugins,"
"https://github.com/imaliang/MoviePilot-Plugins"
"https://github.com/jxxghp/MoviePilot-Plugins"
)
# 插件安装数据共享
PLUGIN_STATISTIC_SHARE: bool = True
@@ -571,6 +554,8 @@ class ConfigModel(BaseModel):
LLM_THINKING_LEVEL: Optional[str] = "off"
# OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses
LLM_API_PROTOCOL: str = "auto"
# 联网搜索模式:local(本地)/ builtin(模型服务端)/ auto(自动)/ disabled(关闭)
LLM_WEB_SEARCH_MODE: str = "local"
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
LLM_SUPPORT_IMAGE_INPUT: bool = True
# 是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent
@@ -585,8 +570,8 @@ class ConfigModel(BaseModel):
LLM_USE_PROXY: bool = True
# LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录
LLM_BASE_URL_PRESET: Optional[str] = None
# LLM最大上下文Token数量(K
LLM_MAX_CONTEXT_TOKENS: int = 128
# LLM最大上下文Token数量(K,仅在模型目录未提供规格时作为回退值
LLM_MAX_CONTEXT_TOKENS: int = 256
# LLM OpenAI兼容接口请求User-Agent
LLM_USER_AGENT: Optional[str] = None
# LLM温度参数
+126 -33
View File
@@ -9,6 +9,7 @@ import socket
import sqlite3
import sys
from collections import deque
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable, Optional
from urllib.error import HTTPError, URLError
@@ -48,6 +49,11 @@ LOG_ERROR_PATTERNS = (
LOG_RECORD_PATTERN = re.compile(
r"(?:【(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)】|(?:DEBUG|INFO|WARNING|ERROR|CRITICAL):)"
)
LOG_TIMESTAMP_PATTERN = re.compile(
r"(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})"
)
LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
LOG_LOOKBACK_HOURS = 24
CONSOLE_LOGGER_PATTERN = re.compile(r"\[([^\]]+)]")
PLUGIN_ERROR_PATTERNS = (
re.compile(r"(?:^|\s-\s)plugin\.py\s+-\s", re.IGNORECASE),
@@ -295,6 +301,78 @@ def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -
return list(deque((_mask_text(line) for line in text.splitlines()), maxlen=max_lines))
def _parse_log_timestamp(line: str) -> Optional[datetime]:
"""解析日志行中的时间戳。"""
match = LOG_TIMESTAMP_PATTERN.search(line[:96])
if not match:
return None
try:
return datetime.strptime(match.group(1), LOG_TIMESTAMP_FORMAT)
except ValueError:
return None
def _recent_log_lines(
lines: list[str],
now: Optional[datetime] = None,
) -> list[str]:
"""按日志记录边界保留诊断时间窗内的日志。"""
if not lines:
return []
timestamps = [_parse_log_timestamp(line) for line in lines]
if not any(timestamps):
return lines
cutoff = (now or datetime.now()) - timedelta(hours=LOG_LOOKBACK_HOURS)
recent: list[str] = []
include_record = False
for line, timestamp in zip(lines, timestamps):
if timestamp is not None:
include_record = timestamp >= cutoff
if include_record:
recent.append(line)
return recent
def _error_fingerprint(line: str) -> str:
"""生成跨主日志、控制台镜像和插件独立日志可比较的错误指纹。"""
normalized = LOG_TIMESTAMP_PATTERN.sub("<time>", line.strip())
if " - " in normalized:
normalized = normalized.rsplit(" - ", 1)[-1]
normalized = re.sub(
r"^(?:【(?:ERROR|CRITICAL|WARNING)】|(?:ERROR|CRITICAL|WARNING):)\s*",
"",
normalized,
flags=re.IGNORECASE,
)
return re.sub(r"\s+", " ", normalized).strip().lower()
def _aggregate_log_entries(
entries: list[tuple[Path, str]],
max_matches: int = 12,
) -> tuple[list[str], list[str]]:
"""合并跨日志的重复错误,并返回详情行和来源文件。"""
unique_entries: dict[str, dict[str, Any]] = {}
log_files: list[str] = []
for path, line in entries:
path_text = str(path)
if path_text not in log_files:
log_files.append(path_text)
fingerprint = _error_fingerprint(line)
if fingerprint not in unique_entries:
unique_entries[fingerprint] = {"line": line, "sources": []}
sources = unique_entries[fingerprint]["sources"]
if path.name not in sources:
sources.append(path.name)
details = [
f"[{', '.join(item['sources'])}] {item['line']}"
for item in list(unique_entries.values())[-max_matches:]
]
return details, log_files
def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
"""从近期日志中提取错误关键词命中的行。"""
matches: list[str] = []
@@ -430,13 +508,13 @@ def _check_config(runner: DoctorRunnerProtocol) -> None:
)
proxy_host = (settings.PROXY_HOST or "").strip()
if proxy_host and not re.match(r"^https?://", proxy_host, re.IGNORECASE):
if proxy_host and not re.match(r"^(https?|socks5h?)://", proxy_host, re.IGNORECASE):
runner.add(
finding_id="config.proxy_format",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="代理地址格式可能不完整",
detail=f"PROXY_HOST={proxy_host} 未包含 http:// 或 https:// 前缀。",
detail=f"PROXY_HOST={proxy_host} 未包含 http:// 或 https:// 或 socks5:// 或 socks5h:// 前缀。",
recommendation="如果外部访问异常,请把 PROXY_HOST 调整为完整 URL。",
)
@@ -756,11 +834,15 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
log_files.extend(plugin_log_files[:20])
found_any = False
entries: dict[str, list[tuple[Path, str]]] = {
"core": [],
"plugin": [],
}
for path in log_files:
if not path.exists() or not path.is_file():
continue
found_any = True
lines = _tail_lines(path)
lines = _recent_log_lines(_tail_lines(path))
is_plugin_log = plugin_log_dir in path.parents
if is_plugin_log:
scoped_errors = [(True, _find_error_lines(lines))]
@@ -770,35 +852,9 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
plugin_logger_names,
)
scoped_errors = [(False, core_errors), (True, plugin_errors)]
if not any(errors for _, errors in scoped_errors):
continue
has_core_errors = bool(scoped_errors[0][1]) if not is_plugin_log else False
for is_plugin_error, errors in scoped_errors:
if not errors:
continue
finding_suffix = (
"plugin_errors"
if is_plugin_error and has_core_errors
else "recent_errors"
)
runner.add(
finding_id=f"logs.{path.stem}.{finding_suffix}",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="最近日志存在插件异常" if is_plugin_error else "最近日志存在错误线索",
detail="\n".join(errors),
recommendation=(
"可使用安全模式启动后检查插件配置。"
if is_plugin_error
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
),
affects_report_status=not is_plugin_error,
context={
"log_file": str(path),
"matches": len(errors),
"component": "plugin" if is_plugin_error else "core",
},
)
component = "plugin" if is_plugin_error else "core"
entries[component].extend((path, error) for error in errors)
if not found_any:
runner.add(
@@ -811,13 +867,50 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
)
return
if not any(finding.id.startswith("logs.") and finding.id.endswith("recent_errors") for finding in runner.report.findings):
core_first_path = entries["core"][0][0] if entries["core"] else None
for component in ("core", "plugin"):
component_entries = entries[component]
if not component_entries:
continue
first_path = component_entries[0][0]
finding_suffix = (
"plugin_errors"
if component == "plugin" and first_path == core_first_path
else "recent_errors"
)
detail_lines, source_files = _aggregate_log_entries(component_entries)
runner.add(
finding_id=f"logs.{first_path.stem}.{finding_suffix}",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="最近日志存在插件异常" if component == "plugin" else "最近日志存在错误线索",
detail="\n".join(detail_lines),
recommendation=(
"可使用安全模式启动后检查插件配置。"
if component == "plugin"
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
),
affects_report_status=component == "core",
context={
"log_file": str(first_path),
"log_files": source_files,
"matches": len(component_entries),
"unique_matches": len(detail_lines),
"component": component,
"lookback_hours": LOG_LOOKBACK_HOURS,
},
)
if not entries["core"]:
runner.add(
finding_id="logs.recent",
severity=DoctorSeverity.Info,
status=DoctorFindingStatus.Ok,
title="最近日志未发现明显错误关键词",
detail=f"已扫描 {settings.LOG_PATH} 下的主日志、启动日志和插件日志。",
detail=(
f"已扫描 {settings.LOG_PATH} 下最近 {LOG_LOOKBACK_HOURS} 小时的主日志、"
"启动日志和插件日志;插件扩展告警不参与核心健康状态。"
),
recommendation="如果问题仍存在,请结合具体操作时间扩大日志范围排查。",
)
+3 -1
View File
@@ -38,7 +38,9 @@ def format_text_report(report: DoctorReport) -> str:
summary = report.summary
lines.extend([
"",
f"汇总: total={summary['total']} error={summary['error']} warn={summary['warn']} fixed={summary['fixed']}",
f"汇总: total={summary['total']} error={summary['error']} "
f"warn={summary['warn']} advisory={summary['advisory']} "
f"fixed={summary['fixed']}",
])
return "\n".join(lines)
+3
View File
@@ -114,11 +114,14 @@ class DoctorReport:
"warn": 0,
"error": 0,
"fixed": 0,
"advisory": 0,
}
for finding in self.findings:
counts[finding.severity.value] += 1
if finding.fixed:
counts["fixed"] += 1
elif not finding.affects_report_status:
counts["advisory"] += 1
return counts
def exit_code(self) -> int:
+32 -8
View File
@@ -1,12 +1,27 @@
from typing import Awaitable, Callable
import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.apiv2_utils import OPENAPI_V2_PATH, V2ResponseMiddleware
from app.core.config import settings
from app.helper.locale import LocaleHelper
from app.startup.lifecycle import lifespan
from version import APP_VERSION
def _get_http_exception_message(detail: Any) -> str:
"""将 HTTPException 的 detail 转换为统一消息文本。"""
if isinstance(detail, str) and detail:
return detail
if detail is None:
return "请求失败"
try:
return json.dumps(detail, ensure_ascii=False)
except TypeError:
return str(detail)
async def localized_http_exception_handler(
@@ -14,18 +29,20 @@ async def localized_http_exception_handler(
exc: HTTPException,
) -> JSONResponse:
"""
HTTPException 响应补充多语言错误详情
HTTPException 响应统一封装为 Response 结构并保留原始错误消息
:param _request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常
:return: detail_i18n JSON 错误响应
:return: 统一 JSON 错误响应
"""
content = {"detail": exc.detail}
if isinstance(exc.detail, str):
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
message = _get_http_exception_message(exc.detail)
return JSONResponse(
status_code=exc.status_code,
content=content,
content={
"success": False,
"message": message,
"data": {},
},
headers=exc.headers,
)
@@ -36,10 +53,16 @@ def create_app() -> FastAPI:
"""
_app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
version=APP_VERSION,
openapi_url=OPENAPI_V2_PATH,
lifespan=lifespan
)
@_app.get(f"{settings.API_V1_STR}/openapi.json", include_in_schema=False)
def get_v1_openapi_schema() -> dict[str, Any]:
"""保留旧版 OpenAPI 地址并返回当前完整接口文档。"""
return _app.openapi()
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
# 配置 CORS 中间件
@@ -50,6 +73,7 @@ def create_app() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
_app.add_middleware(V2ResponseMiddleware)
@_app.middleware("http")
async def locale_context_middleware(
+98
View File
@@ -0,0 +1,98 @@
import re
from typing import Optional
from urllib.parse import urlparse
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 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, require_markers: bool = False
) -> list[str]:
"""
Wiki 插件文档中提取插件仓库地址
:param markdown: Wiki 插件文档 Markdown 内容
:param require_markers: 是否要求文档包含唯一且有序的清单边界标记
:return: 规范化并按文档顺序去重的插件仓库地址
"""
content = markdown or ""
start_count = content.count(PLUGIN_MARKET_WIKI_START)
end_count = content.count(PLUGIN_MARKET_WIKI_END)
start_index = content.find(PLUGIN_MARKET_WIKI_START)
end_index = content.find(PLUGIN_MARKET_WIKI_END)
if start_count == 1 and end_count == 1 and start_index < end_index:
content = content[
start_index + len(PLUGIN_MARKET_WIKI_START):end_index
]
elif require_markers:
raise ValueError("Wiki 插件仓库清单必须包含唯一且有序的开始和结束标记")
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
-3
View File
@@ -183,9 +183,6 @@
"TheMovieDb 识别缓存不存在": "TheMovieDb recognition cache does not exist",
"TheMovieDb 识别缓存删除成功": "TheMovieDb recognition cache deleted successfully",
"TheMovieDb 识别缓存清理完成": "TheMovieDb recognition cache cleanup completed",
"豆瓣识别缓存不存在": "Douban recognition cache does not exist",
"豆瓣识别缓存删除成功": "Douban recognition cache deleted successfully",
"豆瓣识别缓存清理完成": "Douban recognition cache cleanup completed",
"重新识别完成": "Re-recognition completed",
"未识别到新名称": "Unable to recognize new name",
"缺少参数": "Missing parameters",
+1 -4
View File
@@ -110,10 +110,7 @@
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置",
"TheMovieDb 识别缓存不存在": "TheMovieDb 识别缓存不存在",
"TheMovieDb 识别缓存删除成功": "TheMovieDb 识别缓存删除成功",
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成",
"豆瓣识别缓存不存在": "豆瓣识别缓存不存在",
"豆瓣识别缓存删除成功": "豆瓣识别缓存删除成功",
"豆瓣识别缓存清理完成": "豆瓣识别缓存清理完成"
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成"
},
"message_patterns": [
{
-3
View File
@@ -183,9 +183,6 @@
"TheMovieDb 识别缓存不存在": "TheMovieDb 識別快取不存在",
"TheMovieDb 识别缓存删除成功": "TheMovieDb 識別快取刪除成功",
"TheMovieDb 识别缓存清理完成": "TheMovieDb 識別快取清理完成",
"豆瓣识别缓存不存在": "豆瓣識別快取不存在",
"豆瓣识别缓存删除成功": "豆瓣識別快取刪除成功",
"豆瓣识别缓存清理完成": "豆瓣識別快取清理完成",
"重新识别完成": "重新識別完成",
"未识别到新名称": "未識別到新名稱",
"缺少参数": "缺少參數",
+48 -147
View File
@@ -11,7 +11,6 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase
from app.modules.douban.apiv2 import DoubanApi
from app.modules.douban.douban_cache import DoubanCache
from app.modules.douban.scraper import DoubanScraper
from app.schemas import MediaPerson, APIRateLimitException
from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
@@ -24,12 +23,10 @@ from app.utils.zhconv import convert as zhconv_convert
class DoubanModule(_ModuleBase):
doubanapi: DoubanApi = None
scraper: DoubanScraper = None
cache: DoubanCache = None
def init_module(self) -> None:
self.doubanapi = DoubanApi()
self.scraper = DoubanScraper()
self.cache = DoubanCache()
def stop(self):
self.doubanapi.close()
@@ -110,7 +107,6 @@ class DoubanModule(_ModuleBase):
def _recognize_media_core(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: Optional[str] = None,
cache: Optional[bool] = True,
douban_info_func=None,
match_doubaninfo_func=None,
**kwargs) -> Optional[MediaInfo]:
@@ -119,7 +115,6 @@ class DoubanModule(_ModuleBase):
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:param douban_info_func: 获取豆瓣信息的函数
:param match_doubaninfo_func: 匹配豆瓣信息的函数
:return: 识别的媒体信息包括剧集信息
@@ -134,69 +129,39 @@ class DoubanModule(_ModuleBase):
):
return None
if not meta:
# 未提供元数据时,直接查询豆瓣信息,不使用缓存
cache_info = {}
if doubanid:
info = douban_info_func(
doubanid=doubanid,
mtype=mtype or (meta.type if meta else None),
)
elif not meta.name:
logger.error("识别媒体信息时未提供元数据名称")
return None
else:
# 读取缓存
if mtype:
meta.type = mtype
if doubanid:
meta.doubanid = doubanid
cache_info = self.cache.get(meta) if cache else {}
cache_hit = False
# 识别豆瓣信息
if not cache_info or not cache:
# 缓存没有或者强制不使用缓存
if doubanid:
# 直接查询详情
info = douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)
elif meta:
info = {}
for name in self._prepare_search_names(meta):
if meta.begin_season is not None:
logger.info(f"正在识别 {name}{meta.begin_season}季 ...")
else:
logger.info(f"正在识别 {name} ...")
# 匹配豆瓣信息
match_info = match_doubaninfo_func(name=name,
mtype=mtype or meta.type,
year=meta.year,
season=meta.begin_season)
if match_info:
# 匹配到豆瓣信息
info = douban_info_func(
doubanid=match_info.get("id"),
mtype=mtype or meta.type
)
if info:
break
else:
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
return None
# 保存到缓存
if meta and cache:
self.cache.update(meta, info)
else:
# 使用缓存信息
cache_hit = True
if cache_info.get("title"):
logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}")
info = douban_info_func(mtype=cache_info.get("type"),
doubanid=cache_info.get("id"))
else:
logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别")
info = None
info = {}
for name in self._prepare_search_names(meta):
if meta.begin_season is not None:
logger.info(f"正在识别 {name}{meta.begin_season}季 ...")
else:
logger.info(f"正在识别 {name} ...")
match_info = match_doubaninfo_func(
name=name,
mtype=mtype or meta.type,
year=meta.year,
season=meta.begin_season,
)
if match_info:
info = douban_info_func(
doubanid=match_info.get("id"),
mtype=mtype or meta.type,
)
if info:
break
if info:
# 赋值TMDB信息并返回
mediainfo = MediaInfo(douban_info=info)
mediainfo.recognize_cache_hit = cache_hit
if meta:
logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year} "
@@ -213,7 +178,6 @@ class DoubanModule(_ModuleBase):
async def _async_recognize_media_core(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: Optional[str] = None,
cache: Optional[bool] = True,
async_douban_info_func=None,
async_match_doubaninfo_func=None,
**kwargs) -> Optional[MediaInfo]:
@@ -222,7 +186,6 @@ class DoubanModule(_ModuleBase):
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:param async_douban_info_func: 获取豆瓣信息的异步函数
:param async_match_doubaninfo_func: 匹配豆瓣信息的异步函数
:return: 识别的媒体信息包括剧集信息
@@ -237,69 +200,39 @@ class DoubanModule(_ModuleBase):
):
return None
if not meta:
# 未提供元数据时,直接查询豆瓣信息,不使用缓存
cache_info = {}
if doubanid:
info = await async_douban_info_func(
doubanid=doubanid,
mtype=mtype or (meta.type if meta else None),
)
elif not meta.name:
logger.error("识别媒体信息时未提供元数据名称")
return None
else:
# 读取缓存
if mtype:
meta.type = mtype
if doubanid:
meta.doubanid = doubanid
cache_info = self.cache.get(meta) if cache else {}
cache_hit = False
# 识别豆瓣信息
if not cache_info or not cache:
# 缓存没有或者强制不使用缓存
if doubanid:
# 直接查询详情
info = await async_douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)
elif meta:
info = {}
for name in self._prepare_search_names(meta):
if meta.begin_season is not None:
logger.info(f"正在识别 {name}{meta.begin_season}季 ...")
else:
logger.info(f"正在识别 {name} ...")
# 匹配豆瓣信息
match_info = await async_match_doubaninfo_func(name=name,
mtype=mtype or meta.type,
year=meta.year,
season=meta.begin_season)
if match_info:
# 匹配到豆瓣信息
info = await async_douban_info_func(
doubanid=match_info.get("id"),
mtype=mtype or meta.type
)
if info:
break
else:
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
return None
# 保存到缓存
if meta and cache:
self.cache.update(meta, info)
else:
# 使用缓存信息
cache_hit = True
if cache_info.get("title"):
logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}")
info = await async_douban_info_func(mtype=cache_info.get("type"),
doubanid=cache_info.get("id"))
else:
logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别")
info = None
info = {}
for name in self._prepare_search_names(meta):
if meta.begin_season is not None:
logger.info(f"正在识别 {name}{meta.begin_season}季 ...")
else:
logger.info(f"正在识别 {name} ...")
match_info = await async_match_doubaninfo_func(
name=name,
mtype=mtype or meta.type,
year=meta.year,
season=meta.begin_season,
)
if match_info:
info = await async_douban_info_func(
doubanid=match_info.get("id"),
mtype=mtype or meta.type,
)
if info:
break
if info:
# 赋值TMDB信息并返回
mediainfo = MediaInfo(douban_info=info)
mediainfo.recognize_cache_hit = cache_hit
if meta:
logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year} "
@@ -316,21 +249,18 @@ class DoubanModule(_ModuleBase):
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: Optional[str] = None,
cache: Optional[bool] = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:return: 识别的媒体信息包括剧集信息
"""
return self._recognize_media_core(
meta=meta,
mtype=mtype,
doubanid=doubanid,
cache=cache,
douban_info_func=self.douban_info,
match_doubaninfo_func=self.match_doubaninfo,
**kwargs
@@ -339,51 +269,23 @@ class DoubanModule(_ModuleBase):
async def async_recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: Optional[str] = None,
cache: Optional[bool] = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息异步版本
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:return: 识别的媒体信息包括剧集信息
"""
return await self._async_recognize_media_core(
meta=meta,
mtype=mtype,
doubanid=doubanid,
cache=cache,
async_douban_info_func=self.async_douban_info,
async_match_doubaninfo_func=self.async_match_doubaninfo,
**kwargs
)
def update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MediaInfo,
) -> Optional[bool]:
"""
回填豆瓣本地识别缓存覆盖名称负缓存避免共享识别后重复回查
"""
if not meta or not mediainfo:
return None
if mediainfo.source != "douban" or not mediainfo.douban_info:
return None
self.cache.update(meta, mediainfo.douban_info)
return True
async def async_update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MediaInfo,
) -> Optional[bool]:
"""
异步回填豆瓣本地识别缓存
"""
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
@rate_limit_exponential(source="douban_info")
def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]:
"""
@@ -1272,7 +1174,6 @@ class DoubanModule(_ModuleBase):
"""
logger.info("开始清除豆瓣缓存 ...")
self.doubanapi.clear_cache()
self.cache.clear()
logger.info("豆瓣缓存清除完成")
def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]:
-199
View File
@@ -1,199 +0,0 @@
import pickle
import traceback
from pathlib import Path
from threading import RLock
from typing import Optional
from app.core.cache import TTLCache
from app.core.config import settings
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.log import logger
from app.schemas.types import MediaType
from app.utils.singleton import WeakSingleton
lock = RLock()
class DoubanCache(metaclass=WeakSingleton):
"""
豆瓣缓存数据
{
"id": '',
"title": '',
"year": '',
"type": MediaType
}
"""
# 豆瓣缓存过期
_douban_cache_expire: bool = True
def __init__(self):
"""初始化豆瓣识别缓存并恢复本地持久化数据。"""
self.maxsize = settings.CONF.douban
self.ttl = settings.CONF.meta
self.region = "__douban_cache__"
self._meta_filepath = settings.TEMP_PATH / self.region
# 初始化缓存
self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)
# 非Redis加载本地缓存数据
if not self._cache.is_redis():
for key, value in self.__load(self._meta_filepath).items():
self._cache.set(key, value)
def clear(self):
"""
清空所有豆瓣缓存
"""
with lock:
self._cache.clear()
self.save(force=True)
def list_items(self) -> list[dict]:
"""返回可供管理界面展示的豆瓣识别缓存列表。"""
with lock:
cache_items = []
for key, value in self._cache.items():
if not isinstance(value, dict):
continue
media_type = value.get("type")
if not isinstance(media_type, MediaType):
try:
media_type = MediaType(media_type)
except (TypeError, ValueError):
media_type = None
cache_items.append({
"key": key,
"douban_id": value.get("id") or 0,
"title": value.get("title") or "",
"year": value.get("year") or "",
"media_type": media_type.to_agent() if media_type else "unknown",
"poster_path": value.get("poster_path") or "",
})
return sorted(cache_items, key=lambda item: item["key"])
@staticmethod
def __get_key(meta: MetaBase) -> str:
"""
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}]" \
f"{meta.doubanid or meta.name}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""
根据KEY值获取缓存值
"""
key = self.__get_key(meta)
with lock:
return self._cache.get(key) or {}
def delete(self, key: str) -> dict:
"""
删除缓存信息
@param key: 缓存key
@return: 被删除的缓存内容
"""
with lock:
redis_data = self._cache.get(key)
if redis_data:
self._cache.delete(key)
self.save(force=True)
return redis_data
return {}
def modify(self, key: str, title: str) -> dict:
"""
修改缓存信息
@param key: 缓存key
@param title: 标题
@return: 被修改后缓存内容
"""
with lock:
redis_data = self._cache.get(key)
if redis_data:
redis_data["title"] = title
self._cache.set(key, redis_data)
return redis_data
return {}
@staticmethod
def __load(path: Path) -> dict:
"""
从文件中加载缓存
"""
try:
if path.exists():
with open(path, 'rb') as f:
data = pickle.load(f)
return data
except Exception as e:
logger.error(f"加载缓存失败: {str(e)} - {traceback.format_exc()}")
return {}
def update(self, meta: MetaBase, info: dict) -> None:
"""
新增或更新缓存条目
"""
if info:
# 缓存标题
cache_title = info.get("title")
# 缓存年份
cache_year = info.get('year')
# 类型
if isinstance(info.get('media_type'), MediaType):
mtype = info.get('media_type')
elif info.get("type"):
mtype = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
else:
meta = MetaInfo(cache_title)
if meta.begin_season is not None:
mtype = MediaType.TV
else:
mtype = MediaType.MOVIE
# 海报
poster_path = info.get("pic", {}).get("large")
if not poster_path and info.get("cover_url"):
poster_path = info.get("cover_url")
if not poster_path and info.get("cover"):
poster_path = info.get("cover").get("url")
with lock:
self._cache.set(self.__get_key(meta), {
"id": info.get("id"),
"type": mtype,
"year": cache_year,
"title": cache_title,
"poster_path": poster_path
})
elif info is not None:
# None时不缓存,此时代表网络错误,允许重复请求
with lock:
self._cache.set(self.__get_key(meta), {
"id": 0
})
def save(self, force: Optional[bool] = False) -> None:
"""
保存缓存数据到文件
"""
# Redis不需要保存到本地文件
if self._cache.is_redis():
return
# 本地文件
meta_data = self.__load(self._meta_filepath)
# 当前缓存数据(去除无法识别)
new_meta_data = {k: v for k, v in self._cache.items() if v.get("id")}
if not force \
and meta_data.keys() == new_meta_data.keys():
return
# 写入本地
with open(self._meta_filepath, 'wb') as f:
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
def __del__(self):
"""实例释放前保存非 Redis 缓存。"""
self.save()
+19 -6
View File
@@ -47,7 +47,10 @@ class Alist(StorageBase, metaclass=WeakSingleton):
"""
初始化
"""
self.__generate_token.cache_clear() # noqa
conf = self.get_conf()
self.__login_token.cache_delete( # noqa
self, self.__get_base_url, conf.get("username"), conf.get("password")
)
def _delay_get_item(
self, path: Path, /, refresh: bool = False
@@ -117,22 +120,32 @@ class Alist(StorageBase, metaclass=WeakSingleton):
"""
return self.__generate_token()
@cached(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5, skip_empty=True)
def __generate_token(self) -> str:
"""
如果设置永久令牌则返回永久令牌否则使用账号密码生成一个临时 token
缓存2天提前5分钟更新
"""
conf = self.get_conf()
token = conf.get("token")
if token:
return str(token)
return self.__login_token(
self.__get_base_url, conf.get("username"), conf.get("password")
)
@cached(maxsize=8, ttl=60 * 60 * 24 * 2 - 60 * 5, skip_empty=True)
def __login_token(
self, base_url: str, username: Optional[str], password: Optional[str]
) -> str:
"""
使用账号密码生成一个临时 token
缓存2天提前5分钟更新
"""
resp = RequestUtils(headers={"Content-Type": "application/json"}).post_res(
self.__get_api_url("/api/auth/login"),
UrlUtils.adapt_request_url(base_url, "/api/auth/login"),
data=json.dumps(
{
"username": conf.get("username"),
"password": conf.get("password"),
"username": username,
"password": password,
}
),
)
@@ -0,0 +1,12 @@
from app.modules.filemanager.storages.alist import Alist
from app.schemas.types import StorageSchema
class AlistGo(Alist):
"""
AList相关操作
API 文档https://docs.alistgo.com/
"""
schema = StorageSchema.AlistGo
+26
View File
@@ -190,6 +190,21 @@ class TransHandler:
return True
return False
def __is_special_extra_file(_fileitem: FileItem) -> bool:
"""
判断是否为特典/附加视频文件 NCOP/NCED/Menu/CM/PV/Event/Logo 等无集数编号的视频/样本
"""
file_name = _fileitem.name or ""
return bool(
re.search(
r"(?:^|[\s_.\-\[【(])("
r"NC(?:OP|ED)|NCOP|NCED|OP|ED|MENU|PV|CM|TRAILER|TV\s*SPOT|SP|OVA|OAD|EVENT|IV|INTERVIEW|LOGO|PRODUCER\s*LOGO|BEHIND\s*THE\s*SCENES|FEATURETTE"
r")(?:\d*|[\s_.\-\]】)]|$)",
file_name,
re.IGNORECASE,
)
)
# 整理结果
result = TransferInfo()
@@ -299,6 +314,17 @@ class TransHandler:
if mediainfo.type == MediaType.TV:
# 电视剧
if in_meta.begin_episode is None:
if __is_special_extra_file(fileitem):
logger.info(f"文件 {fileitem.path} 未识别到文件集数,识别为特典/附加视频文件,跳过正片集数整理")
self.__update_result(
result=result,
success=True,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=False,
)
return result
logger.warn(f"文件 {fileitem.path} 整理失败:未识别到文件集数")
self.__update_result(
result=result,
+131 -41
View File
@@ -1,9 +1,10 @@
import pickle
import traceback
from pathlib import Path
from math import ceil
from threading import RLock
from time import time
from app.core.cache import TTLCache
from app.core.cache import FileCache, TTLCache
from app.core.config import settings
from app.core.meta import MetaBase
from app.log import logger
@@ -11,6 +12,9 @@ from app.schemas.types import MediaType
from app.utils.singleton import WeakSingleton
lock = RLock()
PERSISTENCE_VERSION = 1
PERSISTENCE_REGION = "recognize"
PERSISTENCE_KEY = "tmdb"
class TmdbCache(metaclass=WeakSingleton):
@@ -23,21 +27,78 @@ class TmdbCache(metaclass=WeakSingleton):
"type": MediaType
}
"""
# TMDB缓存过期
_tmdb_cache_expire: bool = True
def __init__(self):
"""初始化 TMDB 识别缓存并恢复本地持久化数据。"""
self.maxsize = settings.CONF.douban
"""初始化 TMDB 识别缓存并恢复未过期的持久化数据。"""
self.maxsize = settings.CONF.tmdb
self.ttl = settings.CONF.meta
self.region = "__tmdb_cache__"
self._meta_filepath = settings.TEMP_PATH / self.region
# 初始化缓存
self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)
# 非Redis加载本地缓存数据
self._expires_at: dict[str, float] = {}
self._dirty = False
self._file_cache = None
self._legacy_file_cache = None
self._legacy_cache_found = False
if not self._cache.is_redis():
for key, value in self.__load(self._meta_filepath).items():
self._cache.set(key, value)
self._file_cache = FileCache(base=settings.CACHE_PATH, ttl=self.ttl)
self._legacy_file_cache = FileCache(base=settings.TEMP_PATH.parent, ttl=self.ttl)
self._restore()
def _restore(self) -> None:
"""从统一文件缓存恢复仍在有效期内的 TMDB 识别数据。"""
try:
content = self._file_cache.get(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
if not content:
content = self._legacy_file_cache.get(
self.region,
region=settings.TEMP_PATH.name,
)
if content:
self._legacy_cache_found = True
self._dirty = True
if not content:
return
payload = pickle.loads(content)
now = time()
if (
isinstance(payload, dict)
and payload.get("version") == PERSISTENCE_VERSION
and isinstance(payload.get("items"), dict)
):
items = payload["items"]
elif isinstance(payload, dict):
# 旧版缓存没有保存过期时间,迁移时从当前时刻重新计算一次有效期。
items = {
key: {"value": value, "expires_at": now + self.ttl}
for key, value in payload.items()
}
self._dirty = True
else:
return
for key, item in items.items():
if not isinstance(item, dict):
self._dirty = True
continue
value = item.get("value")
expires_at = item.get("expires_at")
if not isinstance(value, dict) or not isinstance(expires_at, (int, float)):
self._dirty = True
continue
remaining_ttl = expires_at - now
if remaining_ttl <= 0:
self._dirty = True
continue
self._cache.set(key, value, ttl=ceil(remaining_ttl))
self._expires_at[key] = expires_at
except Exception as err:
logger.error(f"加载TMDB识别缓存失败:{str(err)} - {traceback.format_exc()}")
def _set(self, key: str, value: dict) -> None:
"""写入单条 TMDB 识别缓存并记录其独立过期时间。"""
self._cache.set(key, value)
if not self._cache.is_redis():
self._expires_at[key] = time() + self.ttl
self._dirty = True
def clear(self):
"""
@@ -45,6 +106,8 @@ class TmdbCache(metaclass=WeakSingleton):
"""
with lock:
self._cache.clear()
self._expires_at.clear()
self._dirty = True
self.save(force=True)
def list_items(self) -> list[dict]:
@@ -87,7 +150,10 @@ class TmdbCache(metaclass=WeakSingleton):
key = self.__get_key(meta)
with lock:
return self._cache.get(key) or {}
cache_data = self._cache.get(key)
if not cache_data and self._expires_at.pop(key, None) is not None:
self._dirty = True
return cache_data or {}
def delete(self, key: str) -> dict:
"""
@@ -99,6 +165,8 @@ class TmdbCache(metaclass=WeakSingleton):
redis_data = self._cache.get(key)
if redis_data:
self._cache.delete(key)
self._expires_at.pop(key, None)
self._dirty = True
self.save(force=True)
return redis_data
return {}
@@ -114,24 +182,10 @@ class TmdbCache(metaclass=WeakSingleton):
redis_data = self._cache.get(key)
if redis_data:
redis_data['title'] = title
self._cache.set(key, redis_data)
self._set(key, redis_data)
return redis_data
return {}
@staticmethod
def __load(path: Path) -> dict:
"""
从文件中加载缓存
"""
try:
if path.exists():
with open(path, 'rb') as f:
data = pickle.load(f)
return data
except Exception as e:
logger.error(f'加载缓存失败:{str(e)} - {traceback.format_exc()}')
return {}
def update(self, meta: MetaBase, info: dict) -> None:
"""
新增或更新缓存条目
@@ -157,32 +211,68 @@ class TmdbCache(metaclass=WeakSingleton):
"poster_path": info.get("poster_path"),
"backdrop_path": info.get("backdrop_path")
}
self._cache.set(key, cache_data)
self._set(key, cache_data)
elif info is not None:
# None时不缓存,此时代表网络错误,允许重复请求
with lock:
self._cache.set(key, {"id": 0})
self._set(key, {"id": 0})
def save(self, force: bool = False) -> None:
"""
保存缓存数据到文件
使用统一文件缓存保存未过期的 TMDB 识别数据
"""
# Redis不需要保存到本地文件
if self._cache.is_redis():
return
with lock:
now = time()
cache_items = dict(self._cache.items())
active_keys = set(cache_items)
stale_keys = set(self._expires_at) - active_keys
if stale_keys:
for key in stale_keys:
self._expires_at.pop(key, None)
self._dirty = True
# Redis不可用时,保存到本地文件
meta_data = self.__load(self._meta_filepath)
# 当前缓存,去除无法识别
new_meta_data = {k: v for k, v in self._cache.items() if v.get("id")}
persisted_items = {}
for key, value in cache_items.items():
expires_at = self._expires_at.get(key)
if expires_at is None:
expires_at = now + self.ttl
self._expires_at[key] = expires_at
self._dirty = True
if expires_at <= now or not value.get("id"):
continue
persisted_items[key] = {
"value": value,
"expires_at": expires_at,
}
if not force \
and meta_data.keys() == new_meta_data.keys():
return
if not force and not self._dirty:
return
with open(self._meta_filepath, 'wb') as f:
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # type: ignore
try:
if persisted_items:
payload = {
"version": PERSISTENCE_VERSION,
"items": persisted_items,
}
self._file_cache.set(
PERSISTENCE_KEY,
pickle.dumps(payload, pickle.HIGHEST_PROTOCOL),
region=PERSISTENCE_REGION,
)
else:
self._file_cache.delete(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
if self._legacy_cache_found:
self._legacy_file_cache.delete(
self.region,
region=settings.TEMP_PATH.name,
)
self._legacy_cache_found = False
self._dirty = False
except Exception as err:
logger.error(f"保存TMDB识别缓存失败:{str(err)} - {traceback.format_exc()}")
def __del__(self):
"""实例释放前保存非 Redis 缓存。"""
-10
View File
@@ -660,16 +660,6 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
kwargs={"job_id": "scheduler_job"},
)
# 缓存清理服务,每隔24小时
self._scheduler.add_job(
self.start,
"interval",
id="clear_cache",
name="缓存清理",
hours=settings.CONF.meta / 3600,
kwargs={"job_id": "clear_cache"},
)
# 数据表清理服务,每天凌晨执行一次
if settings.DATA_CLEANUP_ENABLE:
self._scheduler.add_job(
+11
View File
@@ -133,6 +133,16 @@ class AgentChatToolCall(BaseModel):
status: str = Field(default="done", description="工具状态")
class AgentChatMessageSegment(BaseModel):
"""
Agent 会话消息中的有序展示片段
"""
type: str = Field(..., description="片段类型")
content: str = Field(default="", description="文本片段内容")
toolIndex: Optional[int] = Field(None, description="工具提示索引")
class AgentChatChoiceButton(BaseModel):
"""
Agent 会话选择按钮
@@ -185,6 +195,7 @@ class AgentChatMessage(BaseModel):
createdAt: Union[int, float] = Field(..., description="创建时间戳")
status: str = Field(default="done", description="消息状态")
tools: list[AgentChatToolCall] = Field(default_factory=list, description="工具提示列表")
segments: list[AgentChatMessageSegment] = Field(default_factory=list, description="有序展示片段")
attachments: list[AgentChatAttachment] = Field(default_factory=list, description="附件列表")
choices: list[AgentChatChoiceCard] = Field(default_factory=list, description="选择卡片列表")
choice_selection: Optional[AgentChatChoiceSelection] = Field(None, description="用户选择项快照")
+6
View File
@@ -95,6 +95,7 @@ class AgentLLMProviderEventData(ChainEventData):
use_proxy: Optional[bool] = Field(default=None, description="是否使用系统代理")
thinking_level: Optional[str] = Field(default=None, description="思考模式级别")
api_protocol: Optional[str] = Field(default=None, description="OpenAI兼容接口API协议:auto/chat_completions/responses")
web_search_mode: Optional[str] = Field(default=None, description="联网搜索模式:local/builtin/auto/disabled")
selected_provider_id: Optional[str] = Field(default=None, description="插件侧供应商ID")
selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称")
source: Optional[str] = Field(default=None, description="选择来源")
@@ -118,6 +119,11 @@ class AgentTokensUsageEventData(BaseEventData):
input_tokens: int = Field(default=0, description="输入 tokens")
output_tokens: int = Field(default=0, description="输出 tokens")
total_tokens: int = Field(default=0, description="总 tokens")
cache_read_input_tokens: int = Field(default=0, description="从提示词缓存读取的输入 tokens")
cache_write_input_tokens: int = Field(default=0, description="写入提示词缓存的输入 tokens")
uncached_input_tokens: int = Field(default=0, description="未命中缓存的输入 tokens")
cache_hit_ratio: Optional[float] = Field(default=None, description="提示词缓存命中率")
cache_usage_available: bool = Field(default=False, description="供应商是否返回缓存用量明细")
model_call_count: int = Field(default=0, description="模型调用次数")
success: bool = Field(default=False, description="Agent 执行是否成功")
error: Optional[str] = Field(default=None, description="失败原因")
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Optional, Union
from typing import Any, Optional
from pydantic import BaseModel, Field, model_validator
@@ -15,7 +15,7 @@ class Response(BaseModel):
# 多语言消息文本
message_i18n: Optional[str] = None
# 数据
data: Optional[Union[dict, list]] = Field(default_factory=dict)
data: Optional[Any] = Field(default_factory=dict)
@model_validator(mode="after")
def fill_message_i18n(self) -> "Response":
+1
View File
@@ -413,6 +413,7 @@ class StorageSchema(Enum):
U115 = "u115"
Rclone = "rclone"
Alist = "alist"
AlistGo = "alistgo"
SMB = "smb"
+5
View File
@@ -8,10 +8,15 @@ def init_routers(app: FastAPI):
初始化路由
"""
from app.api.apiv1 import api_router
from app.api.apiv2 import api_router_v2
from app.api.apiv2_utils import API_V2_STR, configure_v2_openapi
from app.api.servarr import arr_router
from app.api.servcookie import cookie_router
# API路由
app.include_router(api_router, prefix=settings.API_V1_STR)
# v2 API复用v1路由,仅在响应出口统一封装
app.include_router(api_router_v2, prefix=API_V2_STR)
configure_v2_openapi(app)
# Radarr、Sonarr路由
app.include_router(arr_router, prefix="/api/v3")
# CookieCloud路由
+1
View File
@@ -39,6 +39,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fuse3 \
rsync \
openssh-client \
sshpass \
iproute2 \
netcat-openbsd \
lsof \
+3 -2
View File
@@ -31,15 +31,16 @@ location /cookiecloud {
}
# SSE特殊配置
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
error_log /dev/null crit;
# SSE MIME类型设置
default_type text/event-stream;
# 禁用缓存
add_header Cache-Control no-cache;
add_header Cache-Control "no-cache, no-transform";
add_header X-Accel-Buffering no;
gzip off;
proxy_buffering off;
proxy_cache off;
+9 -1
View File
@@ -238,6 +238,10 @@ moviepilot setup --config-dir /path/to/moviepilot-config
- 默认下载目录与媒体库目录
- AI Agent
可按需启用,并配置 `LLM_PROVIDER``LLM_MODEL``LLM_API_KEY``LLM_BASE_URL`
`LLM_WEB_SEARCH_MODE`。联网搜索支持 MoviePilot 本地搜索、模型服务端搜索、
服务端优先自动回退与完全关闭;服务端模式仅在当前模型目录声明支持时生效。
当前可识别 OpenAI、Anthropic Claude、Google Gemini、xAI Grok 与 DeepSeek
官方端点已公布的服务端联网搜索能力,第三方兼容端点不会被自动误判。
- 用户站点认证
可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数
- 开机自启
@@ -374,6 +378,7 @@ moviepilot version
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
- 后端默认监听 `PORT`,默认值 `3001`
- `TRANSFER_TASK_TIMEOUT` 控制外部异步接管的运行中整理任务失活超时,单位为分钟,默认 `120`,设为 `0` 可禁用;主程序整理线程仍在直接执行的任务不受此项清理
- 前端通过 `service.js` 代理 `/api``/cookiecloud` 到后端
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
@@ -392,7 +397,8 @@ moviepilot doctor --deep
- `--json` 输出稳定 JSON,可供 Agent、脚本或 Issue 流程收集
- `--fix` 只执行白名单安全修复,例如清理过期 runtime 文件或补齐不合法的 `API_TOKEN`
- `--deep` 执行可能较慢的深度探测,例如 PostgreSQL TCP 连通性检查
- 插件日志异常会保留为诊断告警并标记 `affects_report_status=false`,但不会单独降低系统整体状态;核心错误仍正常参与状态聚合
- Doctor 只分析最近 24 小时日志,并跨主日志、控制台镜像和插件独立日志聚合相同错误
- 插件日志异常会保留为诊断告警并标记 `affects_report_status=false`,但不会单独降低系统整体状态;`summary.advisory` 单独统计这类建议项,核心错误仍正常参与状态聚合
- Docker 环境可使用 `docker exec <container> moviepilot doctor`;如果容器已退出,也可用镜像挂载同一配置目录运行 `python -m app.cli doctor`
日志:
@@ -484,6 +490,8 @@ moviepilot tool run search_torrents media_type=movie tmdb_id=12345
- `read_file``write_file``edit_file``execute_command`
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
由 Agent 按当前用户权限直接调用这些工具。
- `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
`start_line``end_line` 指定更小的行号范围继续读取。
## Scheduler 命令
+19
View File
@@ -110,6 +110,25 @@ chmod +x scripts/start-local.sh
如果资源文件没有放到 `app/helper/`,站点索引、规则和内置资源相关能力可能无法按本地开发预期工作;如果插件没有放到 `app/plugins/`,主程序也不会在本地运行时发现该插件。
### 4.1 GitHub 发版时生成插件市场默认值
源码分支中的 `ConfigModel.PLUGIN_MARKET` 只保留官方插件仓库作为离线兜底。GitHub 的正式版与 Beta 镜像构建会检出 `MoviePilot-Wiki``main` 分支,并由 `scripts/generate_plugin_market_default.py` 读取 `plugin.md``plugin-market-repos:start/end` 标记区域,将规范化、去重后的公开仓库清单写入构建工作区。
生成过程遵循以下约束:
- 标记必须唯一、顺序正确,清单不能为空且必须包含 `jxxghp/MoviePilot-Plugins`;不满足时直接终止构建。
- 生成脚本只替换 `ConfigModel` 中的 `PLUGIN_MARKET` 默认值,不写入运行时环境变量,因此用户仍可通过系统环境变量或 `/config/app.env` 覆盖。
- 正式版工作流会创建仅由 Release Tag 引用的本地快照提交,Docker 镜像和 Tag 源码归档均来自该快照;Actions 不会将生成结果回写到 `v2` 分支。
- Release Tag 快照提交信息和镜像标签会记录本次使用的 MoviePilot Wiki Commit,便于追溯清单来源。
本地验证生成结果时,先激活项目虚拟环境,再执行:
```bash
python -m scripts.generate_plugin_market_default \
--wiki-file /path/to/MoviePilot-Wiki/plugin.md \
--config-file app/core/config.py
```
### 5. 运行安全检查
我们使用 `safety` 工具检查依赖项中是否存在已知安全漏洞。更新运行时依赖后,应至少检查运行时入口;更新开发测试依赖时,也应覆盖开发入口。
+4 -2
View File
@@ -38,7 +38,7 @@ Doctor 默认执行只读检查:
- 运行路径:程序目录、配置目录、日志目录、Python 解释器
- 关键配置:`API_TOKEN``PORT``NGINX_PORT`、代理格式、安全模式
- 进程与端口:后端、前端端口监听状态,runtime 文件是否过期
- 日志线索:后端日志、启动日志、前端日志和插件日志中的近期错误
- 日志线索:后端日志、启动日志、前端日志和插件日志最近 24 小时内的错误
- 核心依赖:FastAPI、Pydantic、SQLAlchemy、Uvicorn、CloakBrowser 等是否可导入
- 数据库:SQLite 只读打开和完整性检查;PostgreSQL 默认做配置检查
- 前端资源:`version.txt``service.js` 或核心静态文件是否存在
@@ -48,6 +48,8 @@ Doctor 默认执行只读检查:
整体状态只聚合会影响 MoviePilot 核心运行的诊断项。插件独立日志以及主日志中可明确识别的插件子系统异常仍会作为 `warn/degraded` 诊断项保留,但其 `affects_report_status``false`,不会单独把整体状态从 `healthy` 降为 `degraded`;同一日志中若还存在核心错误,核心错误仍会参与状态聚合。
Doctor 会按核心与插件两个组件聚合日志发现,并对主日志、控制台镜像和插件独立日志中的相同错误去重。JSON 汇总中的 `warn` 保留全部警告数量,`advisory` 单独统计不影响整体状态的建议项;日志发现的 `context.log_files``matches``unique_matches` 分别说明来源、原始命中数和去重后命中数。
## 自救能力
`moviepilot doctor --fix` 只做白名单安全修复:
@@ -87,4 +89,4 @@ Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否
## Issue 反馈集成
`feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。
`feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。连续重复的同类日志模板会保留首条、末条和重复次数,避免轮询或等待日志挤掉真正的错误上下文。
+21 -8
View File
@@ -112,9 +112,23 @@ MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器
MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
标准 REST 响应包含 `success``message``message_i18n``data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US``Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
#### REST API 版本
FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail`
- `/api/v1` 默认保持原有响应结构,已有客户端无需迁移;登录壁纸接口的 URL 已统一放入 `data`
- `/api/v2` 复用 `/api/v1` 的同一套路由、请求参数、鉴权依赖和业务实现,只统一普通 JSON 响应结构。
- v1 中已经使用通用 `Response` 的接口在 v2 中保持原样;其他成功 JSON 响应转换为 `{"success": true, "message": "", "data": <原响应>}`
- HTTP 错误保留原状态码,并统一返回 `{"success": false, "message": <错误详情>, "data": {}}`;非业务异常不做多语言翻译。
- SSE、文件、图片、空响应,以及 OpenAI、Anthropic、MCP 等标准协议接口保持原始响应格式,不进行通用封装。
因此,普通 REST 接口可将文档中的 `/api/v1/...` 路径直接替换为 `/api/v2/...`。例如 `/api/v1/download/` 对应 `/api/v2/download/`
通用 REST 响应包含 `success``message``message_i18n``data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US``Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
`GET /api/v1/login/wallpaper` 及对应的 v2 路径会将壁纸 URL 放在 `data` 字段中。`POST /api/v1/user/avatar/{user_id}` 及对应的 v2 路径会以 `data.filename` 返回原始文件名。上述接口的 `message` 均不再承载业务数据。
FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`
交互式接口文档 `/docs` 默认读取 `/api/v2/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。旧地址 `/api/v1/openapi.json` 继续保留并返回同一份完整接口文档。
#### 媒体识别 / 整理
@@ -125,7 +139,7 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 按标题搜索媒体、合集或人物,参数:`title``type``page``count`,可选 `source``media` 支持 `themoviedb``douban``bangumi``anilist``collection` 支持 `themoviedb``person` 支持 `themoviedb``douban` |
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `source` |
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 |
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:``douban:``bangumi:``anilist:` 及插件自定义来源前缀 |
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source``media_id``type_name`(电影/电视剧)可指定本次刮削媒体 |
@@ -213,11 +227,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
| GET | `/api/v1/tmdb/cache` | 查询 TheMovieDb 识别缓存统计、共享识别累计成功命中次数及开关状态 |
| DELETE | `/api/v1/tmdb/cache/{cache_key}` | 按缓存键删除单条 TheMovieDb 识别缓存,缓存键需要进行 URL 编码 |
| DELETE | `/api/v1/tmdb/cache` | 清空全部 TheMovieDb 识别缓存 |
| GET | `/api/v1/douban/cache` | 查询豆瓣识别缓存统计、共享识别累计成功命中次数及开关状态 |
| DELETE | `/api/v1/douban/cache/{cache_key}` | 按缓存键删除单条豆瓣识别缓存,缓存键需要进行 URL 编码 |
| DELETE | `/api/v1/douban/cache` | 清空全部豆瓣识别缓存 |
缓存查询响应的 `data` 包含 `count``recognized``unrecognized``data`,以及共享识别统计字段
TMDB 缓存查询响应的 `data` 包含 `count``recognized``unrecognized``data`,以及共享识别统计字段
`shared_recognized` 和开关字段 `shared_recognize_enabled`。共享命中次数仅在共享结果驱动的二次媒体识别成功后累计。
### 插件补充接口
@@ -249,10 +260,12 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
内置 Agent 的本地文件与命令工具 `read_file``write_file``edit_file`
`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
`start_line``end_line` 指定更小的行号范围继续读取。
媒体相关 MCP 工具(如 `query_media_detail``search_torrents``query_library_exists``add_subscribe``transfer_file`)接受 `tmdb_id`/`tmdbid``douban_id`/`doubanid``bangumi_id`/`bangumiid``anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。
`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`;需要查看种子标签时,传入 `include_labels=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。
#### Agent 自主定时任务工具
+17 -1
View File
@@ -285,4 +285,20 @@ bash scripts/collect-site-adapter.sh
- Never put a Cookie or other credential in command arguments or shell history.
- Feature Request attachments are public. Review all four files in the generated ZIP before attaching it, and never attach raw HTML, HAR, or browser network archives.
*Last Updated: 2026-07-12*
---
## Plugin Market Release Default
```bash
# Run after activating the project virtual environment
python -m scripts.generate_plugin_market_default \
--wiki-file /path/to/MoviePilot-Wiki/plugin.md \
--config-file app/core/config.py
```
**Rules:**
- The Wiki document must contain exactly one `plugin-market-repos:start/end` marker pair.
- The marked list must be nonempty and include `jxxghp/MoviePilot-Plugins`.
- This command rewrites only `ConfigModel.PLUGIN_MARKET`; inspect the resulting diff before committing or packaging.
*Last Updated: 2026-08-06*
+84
View File
@@ -0,0 +1,84 @@
"""
根据 MoviePilot Wiki 清单生成发版快照中的插件市场默认值
"""
import argparse
import ast
from pathlib import Path
from typing import Optional
from app.helper.market import extract_plugin_market_repos_from_wiki
OFFICIAL_PLUGIN_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins"
def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="生成插件市场发版默认值")
parser.add_argument("--wiki-file", type=Path, required=True)
parser.add_argument("--config-file", type=Path, required=True)
return parser.parse_args(args)
def _find_plugin_market_assignment(source: str) -> tuple[int, int, str]:
tree = ast.parse(source)
source_lines = source.splitlines(keepends=True)
for node in tree.body:
if not isinstance(node, ast.ClassDef) or node.name != "ConfigModel":
continue
for item in node.body:
if not isinstance(item, ast.AnnAssign):
continue
if not isinstance(item.target, ast.Name):
continue
if item.target.id != "PLUGIN_MARKET" or item.end_lineno is None:
continue
start = item.lineno - 1
indent_size = len(source_lines[start]) - len(source_lines[start].lstrip())
indent = source_lines[start][:indent_size]
return start, item.end_lineno, indent
raise ValueError("未在 ConfigModel 中找到 PLUGIN_MARKET 默认值")
def _format_plugin_market_assignment(repos: list[str], indent: str) -> str:
lines = [f"{indent}PLUGIN_MARKET: str = (\n"]
for index, repo in enumerate(repos):
suffix = "," if index < len(repos) - 1 else ""
lines.append(f'{indent} "{repo}{suffix}"\n')
lines.append(f"{indent})\n")
return "".join(lines)
def _generate_plugin_market_default(wiki_file: Path, config_file: Path) -> list[str]:
markdown = wiki_file.read_text(encoding="utf-8")
repos = extract_plugin_market_repos_from_wiki(markdown, require_markers=True)
if not repos:
raise ValueError("Wiki 插件仓库清单为空")
if OFFICIAL_PLUGIN_MARKET not in repos:
raise ValueError("Wiki 插件仓库清单缺少 MoviePilot 官方插件仓库")
source = config_file.read_text(encoding="utf-8")
start, end, indent = _find_plugin_market_assignment(source)
source_lines = source.splitlines(keepends=True)
replacement = _format_plugin_market_assignment(repos, indent)
updated_source = (
"".join(source_lines[:start])
+ replacement
+ "".join(source_lines[end:])
)
config_file.write_text(updated_source, encoding="utf-8")
return repos
def main(args: Optional[list[str]] = None) -> int:
"""
读取 Wiki 清单并更新指定配置文件中的插件市场默认值
"""
options = _parse_args(args)
repos = _generate_plugin_market_default(options.wiki_file, options.config_file)
print(f"已生成 PLUGIN_MARKET 默认值,共 {len(repos)} 个仓库")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+15
View File
@@ -2093,6 +2093,21 @@ def _collect_agent_config(
runtime_python=runtime_python,
)
config["LLM_MODEL"] = _prompt_model_choice(models, default=current_model)
config["LLM_WEB_SEARCH_MODE"] = _prompt_choice(
"LLM 联网搜索模式",
choices={
"local": "MoviePilot 本地搜索",
"builtin": "模型服务端搜索",
"auto": "自动(服务端优先,不支持时回退本地)",
"disabled": "关闭联网搜索",
},
default=(
_env_default("LLM_WEB_SEARCH_MODE", "local")
if _env_default("LLM_WEB_SEARCH_MODE", "local")
in {"local", "builtin", "auto", "disabled"}
else "local"
),
)
return config
+2
View File
@@ -41,6 +41,8 @@ a local plugin source and installed into the running MoviePilot instance.
`list_directory` only when inspecting one known folder or a configured remote
storage backend.
- Read the relevant implementation and adjacent example before editing.
- If `read_file` reports truncation, continue with smaller `start_line` and
`end_line` ranges until all relevant sections have been inspected.
- Before using a Python or Node.js dependency API, determine the exact installed
or locked version from requirements, package manifests, lockfiles, local
package source, and `.pyi`/`.d.ts` declarations. If those are insufficient,
+4 -1
View File
@@ -1,6 +1,6 @@
---
name: feedback-issue
version: 7
version: 8
description: >-
Use this skill ONLY when the user EXPLICITLY requests filing an
upstream issue for MoviePilot core, frontend, or an installed plugin,
@@ -92,6 +92,9 @@ Log relevance rules:
then applies a recent time window, removes Agent/tool dispatch noise,
and keeps only timestamped log blocks whose first line contains a
normalized keyword.
- Consecutive log records with the same template are compacted to the
first record, a repetition count, and the last record. Verify the
retained boundary records before treating the excerpt as evidence.
- If no specific keyword survives normalization, the script records the
doctor report and log-selection metadata but does not include recent
log lines. This avoids attaching unrelated noise.
@@ -33,6 +33,10 @@ _LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
_LOG_MODULE_RE = re.compile(
r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+-\s+([^\s][^\-]*?)\s+-\s+"
)
_LOG_DYNAMIC_VALUE_RE = re.compile(
r"(?<![A-Za-z])(?:[0-9a-f]{8,}|\d+(?:\.\d+)?)(?![A-Za-z])",
re.IGNORECASE,
)
_META_NOISE_MODULES = frozenset({
"collect_feedback_diagnostics.py",
@@ -238,6 +242,43 @@ def is_meta_noise(line: str) -> bool:
return match.group(1).strip() in _META_NOISE_MODULES
def _repetition_fingerprint(line: str) -> str:
"""生成用于识别连续重复日志模板的指纹。"""
if parse_line_timestamp(line) is None:
return line
normalized = _LOG_TIMESTAMP_RE.sub("<time>", line)
normalized = _LOG_DYNAMIC_VALUE_RE.sub("<value>", normalized)
return re.sub(r"\s+", " ", normalized).strip().lower()
def _compact_repeated_lines(lines: list[str]) -> list[str]:
"""压缩连续重复日志模板,同时保留首条、末条和重复次数。"""
compacted: list[str] = []
group: list[str] = []
fingerprint: Optional[str] = None
def flush_group() -> None:
if len(group) < 4:
compacted.extend(group)
return
compacted.append(group[0])
compacted.append(
f"... 同类日志连续重复 {len(group)} 次,已省略 {len(group) - 2} 行 ..."
)
compacted.append(group[-1])
for line in lines:
current_fingerprint = _repetition_fingerprint(line)
if group and current_fingerprint != fingerprint:
flush_group()
group = []
group.append(line)
fingerprint = current_fingerprint
if group:
flush_group()
return compacted
def filter_lines(
text: str,
keywords: list[str],
@@ -285,7 +326,8 @@ def filter_lines(
elif keep_block:
matched.append(line)
if matched:
return matched[-max_lines:], sorted(matched_keywords)
compacted = _compact_repeated_lines(matched)
return compacted[-max_lines:], sorted(matched_keywords)
return [], []
@@ -371,6 +371,14 @@ def build_prefill_url(
return f"{issue_new_url(repo)}?{encoded}"
def _safe_count(value: Any) -> int:
"""把不可信的诊断计数字段转换为非负整数。"""
try:
return max(int(value or 0), 0)
except (TypeError, ValueError):
return 0
def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
"""把 doctor JSON 报告压缩成适合 Issue 和预览展示的摘要。"""
if not isinstance(doctor, dict):
@@ -390,28 +398,75 @@ def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
runtime = environment.get("runtime")
if runtime:
lines.append(f"运行环境:{runtime}")
findings = report.get("findings") or []
summary = report.get("summary") or {}
if isinstance(summary, dict):
advisory_count = summary.get("advisory")
if advisory_count is None and isinstance(findings, list):
advisory_count = sum(
1
for item in findings
if isinstance(item, dict)
and item.get("affects_report_status") is False
and not item.get("fixed")
)
lines.append(
"汇总:"
f"total={summary.get('total', 0)} "
f"error={summary.get('error', 0)} "
f"warn={summary.get('warn', 0)} "
f"advisory={advisory_count or 0} "
f"fixed={summary.get('fixed', 0)}"
)
findings = report.get("findings") or []
if isinstance(findings, list):
important = [
item for item in findings
if isinstance(item, dict) and item.get("severity") in {"error", "warn"}
][:8]
grouped: dict[tuple[str, str, str, bool], dict[str, Any]] = {}
for item in findings:
if not isinstance(item, dict) or item.get("severity") not in {"error", "warn"}:
continue
title = str(item.get("title") or item.get("id") or "未知诊断项")
recommendation = str(item.get("recommendation") or "").strip()
advisory = item.get("affects_report_status") is False
key = (str(item.get("severity")), title, recommendation, advisory)
group = grouped.setdefault(
key,
{
"count": 0,
"matches": 0,
"unique_matches": 0,
"sources": [],
},
)
group["count"] += 1
context = item.get("context") or {}
if not isinstance(context, dict):
continue
group["matches"] += _safe_count(context.get("matches"))
group["unique_matches"] += _safe_count(
context.get("unique_matches") or context.get("matches") or 0
)
source_files = context.get("log_files") or [context.get("log_file")]
for source_file in source_files:
if not source_file:
continue
source_name = Path(str(source_file)).name
if source_name not in group["sources"]:
group["sources"].append(source_name)
important = list(grouped.items())[:8]
if important:
lines.append("关键发现:")
for item in important:
title = str(item.get("title") or item.get("id") or "未知诊断项")
recommendation = str(item.get("recommendation") or "").strip()
line = f"- [{item.get('severity')}] {title}"
for (severity, title, recommendation, advisory), group in important:
marker = f"{severity}/advisory" if advisory else severity
line = f"- [{marker}] {title}"
if group["count"] > 1:
line = f"{line}(合并 {group['count']} 项)"
if group["sources"]:
line = f"{line};来源:{', '.join(group['sources'])}"
if group["matches"]:
line = f"{line};命中:{group['matches']}"
if group["unique_matches"] < group["matches"]:
line = f"{line},去重后 {group['unique_matches']}"
if recommendation:
line = f"{line};建议:{recommendation}"
lines.append(line)
+29 -10
View File
@@ -1,6 +1,6 @@
---
name: moviepilot-api
version: 7
version: 9
description: >-
Use this skill when you need to call MoviePilot REST API endpoints directly
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
@@ -65,6 +65,25 @@ python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
- Both methods validate against the same `API_TOKEN` value.
- Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available.
### API versions and response envelopes
- `/api/v1` preserves the existing endpoint-specific response shapes by
default; the login wallpaper URL is now returned in `data`.
- `/api/v2` reuses the same routes, parameters, authentication dependencies,
and business handlers, but wraps ordinary JSON responses in the shared
`Response` envelope.
- A successful raw v1 payload becomes
`{"success":true,"message":"","data":<original payload>}` in v2.
- Existing `Response` payloads are not wrapped again. HTTP errors on both v1
and v2 keep their original status code and expose the error text in
`message` with `data={}`. Non-business HTTP exceptions are not translated.
- SSE, files, images, empty responses, and OpenAI, Anthropic, or MCP protocol
endpoints keep their protocol-native response body.
Use `/api/v2` for app clients that require one JSON envelope. Any ordinary
REST path listed below can switch from `/api/v1/...` to `/api/v2/...` without
changing its method, parameters, request body, or authentication.
### Examples
```bash
@@ -79,6 +98,9 @@ python scripts/mp-api.py DELETE /api/v1/subscribe/123
# Endpoints that require ?token= auth
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
# Uniform v2 JSON response envelope
python scripts/mp-api.py GET /api/v2/dashboard/cpu
```
## Complete API Reference
@@ -92,8 +114,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/media/search` | Search media, collections, or people by title. Params: `title` (required), `type`, `page`, `count`, optional `source`. Supported sources: `media` = `themoviedb`, `douban`, `bangumi`, `anilist`; `collection` = `themoviedb`; `person` = `themoviedb`, `douban` |
| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle`, `custom_words`, optional `source` |
| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source` |
| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata such as title and year |
| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata |
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` |
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` |
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: `media_source`, `media_id`, `type_name` (`电影`/`电视剧`) |
@@ -444,9 +466,9 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache |
| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Params: `tmdbid`, `doubanid` |
### Recognition Cache (6 endpoints)
### Recognition Cache (3 endpoints)
The two list endpoints return local cache totals plus `shared_recognized` and
The list endpoint returns local cache totals plus `shared_recognized` and
`shared_recognize_enabled` for the persisted successful shared-recognition count.
| Method | Path | Description |
@@ -454,9 +476,6 @@ The two list endpoints return local cache totals plus `shared_recognized` and
| GET | `/api/v1/tmdb/cache` | Get TheMovieDb recognition cache statistics |
| DELETE | `/api/v1/tmdb/cache/{cache_key}` | Delete one URL-encoded TheMovieDb recognition cache key |
| DELETE | `/api/v1/tmdb/cache` | Clear TheMovieDb recognition cache |
| GET | `/api/v1/douban/cache` | Get Douban recognition cache statistics |
| DELETE | `/api/v1/douban/cache/{cache_key}` | Delete one URL-encoded Douban recognition cache key |
| DELETE | `/api/v1/douban/cache` | Clear Douban recognition cache |
### Message (8 endpoints)
@@ -482,7 +501,7 @@ The two list endpoints return local cache totals plus `shared_recognized` and
| GET | `/api/v1/user/{username}` | User detail |
| DELETE | `/api/v1/user/id/{user_id}` | Delete user by ID |
| DELETE | `/api/v1/user/name/{user_name}` | Delete user by username |
| POST | `/api/v1/user/avatar/{user_id}` | Upload avatar. Body: multipart/form-data |
| POST | `/api/v1/user/avatar/{user_id}` | Upload avatar. Body: multipart/form-data; original filename is returned in `data.filename` |
| GET | `/api/v1/user/config/{key}` | Get user config |
| POST | `/api/v1/user/config/{key}` | Update user config |
@@ -491,7 +510,7 @@ The two list endpoints return local cache totals plus `shared_recognized` and
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
| GET | `/api/v1/login/wallpaper` | Login page wallpaper |
| GET | `/api/v1/login/wallpaper` | Login page wallpaper; URL is returned in `data` |
| GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
### MCP Tools (6 endpoints)
+2 -2
View File
@@ -103,8 +103,8 @@ Filter values must come from the `filter_options` returned by `search_torrents`
Fetch results with selected filters:
`moviepilot tool run get_search_results resolution='1080p,2160p' free_state='免费,50%'`
To filter subtitle, audio, DIY, translation, or release notes that may appear outside the title, use `content_pattern`. It matches the torrent title, description, and labels while `title_pattern` continues to match the title only. Set `include_description=true` when the description is needed to explain why a result matched:
`moviepilot tool run get_search_results content_pattern='特效字幕|国语|DIY' include_description=true`
To filter subtitle, audio, DIY, translation, or release notes that may appear outside the title, use `content_pattern`. It matches the torrent title, description, and labels while `title_pattern` continues to match the title only. Set `include_description=true` when the description is needed to explain why a result matched, and `include_labels=true` when the labels should be returned:
`moviepilot tool run get_search_results content_pattern='特效字幕|国语|DIY' include_description=true include_labels=true`
If empty, tell the user which filter to relax and ask before retrying.
+31 -3
View File
@@ -53,8 +53,8 @@ def test_activity_log_index_counts_entries_without_body(tmp_path):
assert "整理了电影文件" not in json.dumps(index, ensure_ascii=False)
def test_activity_log_prompt_injects_index_not_full_log(tmp_path):
"""ActivityLogMiddleware 注入系统提示词不应携带完整活动日志正文"""
def test_activity_log_prompt_is_stable_and_excludes_log_index(tmp_path):
"""ActivityLogMiddleware 系统提示词不应随活动日志索引变化"""
date_str = datetime.now().strftime("%Y-%m-%d")
_write_activity_log(
tmp_path,
@@ -74,10 +74,15 @@ def test_activity_log_prompt_injects_index_not_full_log(tmp_path):
modified = middleware.modify_request(request)
system_text = str(modified.system_message.content)
stable_prompt = middleware._format_activity_log(
{"2099-12-31": "999 条活动记录"}
)
assert "1 条活动记录" in system_text
assert "1 条活动记录" not in system_text
assert date_str not in system_text
assert "这是一条不应默认进入上下文的活动正文" not in system_text
assert "query_activity_log" in system_text
assert middleware._format_activity_log(state_update["activity_log_contents"]) == stable_prompt
def test_activity_log_abefore_agent_refreshes_existing_state(tmp_path):
@@ -146,6 +151,29 @@ def test_summarize_with_llm_ignores_skip_marker():
llm.ainvoke.assert_awaited_once()
def test_summarize_with_llm_extracts_text_blocks():
"""活动摘要应兼容 LLM 返回的结构化文本块。"""
llm = SimpleNamespace(
ainvoke=AsyncMock(
return_value=SimpleNamespace(
content=[
{"type": "reasoning", "text": "内部推理"},
{"type": "text", "text": "摘要:用户完成了文件工具排查。"},
]
)
)
)
with patch(
"app.agent.llm.LLMHelper.get_llm",
new=AsyncMock(return_value=llm),
):
summary = asyncio.run(_summarize_with_llm("用户: 排查文件工具"))
assert summary == "用户完成了文件工具排查。"
llm.ainvoke.assert_awaited_once()
def test_activity_log_records_detailed_summary(tmp_path):
"""有实际工具动作的交互应写入较完整的活动摘要。"""
summary = (
+60 -1
View File
@@ -3,10 +3,14 @@
import asyncio
import hashlib
import json
from types import SimpleNamespace
from unittest.mock import patch
from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.read_file import ReadFileTool
from app.agent.tools.impl.list_directory import ListDirectoryTool
from app.agent.tools.impl.read_file import MAX_READ_SIZE, ReadFileTool
from app.agent.tools.impl.write_file import WriteFileTool
from app.chain.storage import StorageChain
def _make_admin_tool(tool_class):
@@ -129,3 +133,58 @@ def test_read_file_can_return_sha256_metadata(tmp_path):
"插件内容".encode("utf-8")
).hexdigest()
assert payload["truncated"] is False
def test_read_file_returns_line_range_hint_when_truncated(tmp_path):
"""超过50KB时应保留前段内容并提示按行号范围继续读取。"""
file_path = tmp_path / "large.py"
exact_content = "a" * MAX_READ_SIZE
file_path.write_text(exact_content, encoding="utf-8")
tool = _make_admin_tool(ReadFileTool)
exact_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
file_path.write_text(f"{exact_content}b", encoding="utf-8")
truncated_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
metadata_result = asyncio.run(
tool.run(str(file_path), include_metadata=True)
)
metadata = json.loads(metadata_result)
assert exact_result == exact_content
assert truncated_result.startswith(exact_content)
assert "50KB" in truncated_result
assert "start_line" in truncated_result
assert "end_line" in truncated_result
assert "tool_result_truncated" not in truncated_result
assert metadata["truncated"] is True
assert "行号范围" in metadata["truncation_message"]
def test_list_directory_returns_paged_items_with_next_offset(tmp_path):
"""目录工具应返回可继续查询的分页元数据。"""
items = [
SimpleNamespace(
name=f"file-{index:03d}.txt",
type="file",
path=str(tmp_path / f"file-{index:03d}.txt"),
size=100,
modify_time=None,
extension=".txt",
)
for index in range(120)
]
tool = _make_admin_tool(ListDirectoryTool)
with patch.object(StorageChain, "list_files", return_value=items):
result = asyncio.run(
tool.run(str(tmp_path), limit=50, offset=50)
)
payload = json.loads(result)
assert payload["total_count"] == 120
assert payload["returned_count"] == 50
assert payload["offset"] == 50
assert payload["limit"] == 50
assert payload["has_more"] is True
assert payload["next_offset"] == 100
assert payload["items"][0]["name"] == "file-050.txt"
@@ -54,6 +54,17 @@ def test_simplify_search_result_only_includes_description_when_requested():
assert detailed_result["torrent_info"]["description"] == "简繁特效字幕"
def test_simplify_search_result_only_includes_labels_when_requested():
"""精简结果应按参数控制标签输出,避免默认增加上下文长度。"""
context = _build_context("Movie.2026.1080p", labels=["官译", "特效"])
default_result = simplify_search_result(context, 1)
detailed_result = simplify_search_result(context, 1, include_labels=True)
assert "labels" not in default_result["torrent_info"]
assert detailed_result["torrent_info"]["labels"] == ["官译", "特效"]
def test_content_pattern_matches_title_description_and_labels():
"""内容正则应联合匹配标题、简介和标签,并可返回命中的简介。"""
items = [
+19
View File
@@ -84,3 +84,22 @@ def test_resolve_llm_runtime_config_prefers_plugin_api_protocol(monkeypatch) ->
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
assert runtime_config["api_protocol"] == "chat_completions"
def test_resolve_llm_runtime_config_prefers_plugin_web_search_mode(monkeypatch) -> None:
"""插件显式覆盖联网搜索模式时应优先使用插件值。"""
monkeypatch.setattr(settings, "LLM_WEB_SEARCH_MODE", "local")
agent = MoviePilotAgent(session_id="web-search-plugin", user_id="user-1")
async def override_web_search_mode(_event_type, event_data):
"""模拟插件覆盖联网搜索模式。"""
event_data.web_search_mode = "builtin"
return SimpleNamespace(event_data=event_data)
with patch(
"app.agent.eventmanager.async_send_event",
new=AsyncMock(side_effect=override_web_search_mode),
):
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
assert runtime_config["web_search_mode"] == "builtin"
+35
View File
@@ -0,0 +1,35 @@
from unittest.mock import patch
from app.agent.prompt import prompt_manager
from app.core.config import settings
from app.schemas.types import MessageChannel
def test_progress_prompt_is_independent_from_tool_display_mode() -> None:
"""进度沟通规则不应随工具逐条或汇总展示模式变化。"""
with patch.object(settings, "AI_AGENT_VERBOSE", False):
summary_mode_prompt = prompt_manager.get_agent_prompt(
channel=MessageChannel.WebAgent.value
)
with patch.object(settings, "AI_AGENT_VERBOSE", True):
verbose_mode_prompt = prompt_manager.get_agent_prompt(
channel=MessageChannel.WebAgent.value
)
assert summary_mode_prompt == verbose_mode_prompt
assert "meaningful changes in understanding or execution" in summary_mode_prompt
assert "not on elapsed time or the number of tool calls" in summary_mode_prompt
assert "one or two tools have finished" in summary_mode_prompt
assert "useful preliminary conclusion" in summary_mode_prompt
assert "materially changes the working direction" in summary_mode_prompt
assert "sustained blocker the user should know about" in summary_mode_prompt
assert "including the key evidence and what you will do next" in summary_mode_prompt
assert "brevity is not a goal by itself" in summary_mode_prompt
assert "do not repeat an unchanged status" in summary_mode_prompt.lower()
assert "Continue working after each update" in summary_mode_prompt
assert "The final reply must be self-contained" in summary_mode_prompt
assert "before the first tool call" not in summary_mode_prompt
assert "approximately every 30 to 60 seconds" not in summary_mode_prompt
assert "one or two short sentences" not in summary_mode_prompt
assert "remain completely silent" not in summary_mode_prompt
assert "DO NOT output any intermediate content" not in summary_mode_prompt
+225
View File
@@ -0,0 +1,225 @@
import pytest
from langchain_core.messages import AIMessage
from app.agent import MoviePilotAgent
from app.agent.llm.helper import LLMHelper
from app.agent.llm.provider import LLMProviderManager
from app.agent.middleware.usage import UsageMiddleware
from app.chain.message import MessageChain
def test_usage_extracts_normalized_cache_details() -> None:
"""标准 usage_metadata 应解析缓存读取、写入和未命中 tokens。"""
usage = UsageMiddleware._extract_usage(
AIMessage(
content="ok",
usage_metadata={
"input_tokens": 1200,
"output_tokens": 100,
"total_tokens": 1300,
"input_token_details": {
"cache_read": 700,
"cache_creation": 0,
"ephemeral_5m_input_tokens": 300,
},
},
)
)
assert usage["cache_usage_available"]
assert usage["cache_read_input_tokens"] == 700
assert usage["cache_write_input_tokens"] == 300
assert usage["uncached_input_tokens"] == 200
assert usage["cache_hit_ratio"] == pytest.approx(700 / 1200)
def test_usage_extracts_deepseek_cache_hit_and_miss_tokens() -> None:
"""DeepSeek 原始 usage 应保留其显式缓存命中与未命中字段。"""
usage = UsageMiddleware._extract_usage(
AIMessage(
content="ok",
response_metadata={
"token_usage": {
"prompt_tokens": 1000,
"completion_tokens": 50,
"total_tokens": 1050,
"prompt_cache_hit_tokens": 800,
"prompt_cache_miss_tokens": 200,
}
},
)
)
assert usage["cache_usage_available"]
assert usage["cache_read_input_tokens"] == 800
assert usage["cache_write_input_tokens"] == 0
assert usage["uncached_input_tokens"] == 200
assert usage["cache_hit_ratio"] == pytest.approx(0.8)
def test_session_usage_aggregates_and_formats_cache_statistics() -> None:
"""会话状态应聚合缓存统计并在状态文本中展示。"""
agent = MoviePilotAgent(session_id="cache-session", user_id="user-1")
agent._record_usage(
{
"has_usage": True,
"cache_usage_available": True,
"input_tokens": 100,
"output_tokens": 10,
"total_tokens": 110,
"cache_read_input_tokens": 60,
"cache_write_input_tokens": 20,
"uncached_input_tokens": 20,
"cache_hit_ratio": 0.6,
}
)
agent._record_usage(
{
"has_usage": True,
"cache_usage_available": True,
"input_tokens": 50,
"output_tokens": 5,
"total_tokens": 55,
"cache_read_input_tokens": 20,
"cache_write_input_tokens": 0,
"uncached_input_tokens": 30,
"cache_hit_ratio": 0.4,
}
)
status = agent.get_session_status()
status.update({"is_processing": False, "pending_messages": 0})
status_text = MessageChain._format_session_status_text(status)
assert status["total_cache_read_input_tokens"] == 80
assert status["total_cache_write_input_tokens"] == 20
assert status["total_uncached_input_tokens"] == 50
assert status["total_cache_hit_ratio"] == pytest.approx(80 / 150)
assert "当前会话累计缓存: 命中 80 / 写入 20 / 未命中 50 (53.33%)" in status_text
def test_prompt_cache_key_is_stable_and_private() -> None:
"""提示词缓存键应在同一会话内稳定且不暴露原始标识。"""
first = MoviePilotAgent(session_id="private-session", user_id="private-user")
second = MoviePilotAgent(session_id="private-session", user_id="private-user")
other = MoviePilotAgent(session_id="other-session", user_id="private-user")
cache_key = first._build_prompt_cache_key()
assert cache_key == second._build_prompt_cache_key()
assert cache_key != other._build_prompt_cache_key()
assert "private-session" not in cache_key
assert "private-user" not in cache_key
def test_openai_prompt_cache_options_only_target_official_endpoints() -> None:
"""OpenAI 专属缓存参数不得泄露到第三方兼容端点。"""
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
provider="openai",
base_url="https://api.openai.com/v1",
use_responses_api=False,
prompt_cache_key="cache-key",
default_headers={"User-Agent": "MoviePilot"},
model_kwargs={"extra_body": {"existing": True}},
)
_, compatible_kwargs = LLMHelper._build_openai_prompt_cache_options(
provider="openai",
base_url="https://api.openai.com.example/v1",
use_responses_api=False,
prompt_cache_key="cache-key",
default_headers=None,
model_kwargs={},
)
assert headers == {"User-Agent": "MoviePilot"}
assert kwargs["extra_body"] == {
"existing": True,
"prompt_cache_key": "cache-key",
}
assert compatible_kwargs == {}
def test_xai_chat_completions_uses_stable_conversation_header() -> None:
"""xAI Chat Completions 应使用官方会话缓存路由请求头。"""
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
provider="xai",
base_url="https://api.x.ai/v1",
use_responses_api=None,
prompt_cache_key="cache-key",
default_headers=None,
model_kwargs={},
)
assert headers == {"x-grok-conv-id": "cache-key"}
assert kwargs == {}
def test_prompt_cache_adapter_preserves_control_after_tool_binding() -> None:
"""Provider 缓存参数应在 Agent 最终绑定工具时仍然存在。"""
class FakeModel:
"""模拟通过 bind_tools 再调用 bind 的 LangChain 模型。"""
def bind(self, **kwargs):
"""返回最终绑定参数。"""
return kwargs
def bind_tools(self, tools, **kwargs):
"""模拟模型的工具绑定流程。"""
return self.bind(tools=tools, **kwargs)
cached_model_cls = LLMHelper._with_prompt_cache_control(
FakeModel,
{"type": "default"},
)
result = cached_model_cls().bind_tools([{"name": "tool"}])
assert result["tools"] == [{"name": "tool"}]
assert result["cache_control"] == {"type": "default"}
def test_anthropic_cache_control_only_targets_official_endpoint() -> None:
"""Anthropic 原生缓存控制不得发送到第三方兼容端点。"""
official = LLMHelper._use_anthropic_prompt_cache(
provider="anthropic",
runtime={
"runtime": "anthropic_compatible",
"base_url": "https://api.anthropic.com/v1",
},
prompt_cache_key="cache-key",
)
compatible = LLMHelper._use_anthropic_prompt_cache(
provider="minimax",
runtime={
"runtime": "anthropic_compatible",
"base_url": "https://api.minimax.io/anthropic/v1",
},
prompt_cache_key="cache-key",
)
assert official
assert not compatible
def test_provider_metadata_declares_prompt_cache_without_model_allowlist() -> None:
"""Provider 应通过模型能力元数据判断缓存支持,不依赖模型 ID 白名单。"""
assert LLMProviderManager._metadata_supports_prompt_cache(
{"cost": {"input": 1, "cache_read": 0.1}}
)
assert LLMProviderManager._metadata_supports_prompt_cache(
{"capabilities": {"prompt_cache": True}}
)
assert not LLMProviderManager._metadata_supports_prompt_cache(
{"cost": {"input": 1, "output": 2}}
)
def test_bedrock_model_metadata_candidates_remove_region_prefix() -> None:
"""Bedrock 跨区域模型应自动回落到基础模型的能力元数据。"""
candidates = LLMProviderManager._models_dev_model_candidates(
"amazon-bedrock",
"us.vendor.model-version",
)
assert candidates == ("us.vendor.model-version", "vendor.model-version")
+19
View File
@@ -7,6 +7,7 @@ from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import SystemMessage
from app.agent.middleware.skills import (
MAX_SKILL_RESULT_CHARS,
SKILL_TOOL_NAME,
SkillsMiddleware,
_alist_skills,
@@ -80,6 +81,24 @@ async def test_skill_tool_loads_skill_by_id_and_name(tmp_path):
assert by_name["skill"]["name"] == "MoviePilot CLI"
@pytest.mark.anyio
async def test_skill_tool_caps_large_result_before_model_context(tmp_path):
"""超大 Skill 内容应在工具返回前限制到模型上下文上限。"""
_write_skill(tmp_path, "large-skill")
skill_path = tmp_path / "large-skill" / "SKILL.md"
with skill_path.open("a", encoding="utf-8") as file_handle:
file_handle.write("\n" + ("large-line\n" * 30000))
middleware = SkillsMiddleware(sources=[str(tmp_path)])
result = await middleware.tools[0].ainvoke({"name": "large-skill"})
payload = json.loads(result)
assert len(result) <= MAX_SKILL_RESULT_CHARS
assert payload["success"] is True
assert payload["truncated"] is True
assert "Skill 内容已截断" in payload["content"]
@pytest.mark.anyio
async def test_skill_tool_returns_not_found_for_unknown_skill(tmp_path):
"""skill 工具找不到技能时应返回结构化失败信息。"""
+12
View File
@@ -88,6 +88,8 @@ def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None:
use_proxy=True,
thinking_level="xhigh",
api_protocol="auto",
web_search_mode="local",
prompt_cache_key=agent._build_prompt_cache_key(),
)
assert agent._llm_provider_selection["selected_provider_id"] == "provider-1"
@@ -118,6 +120,11 @@ def test_execute_agent_broadcasts_usage_on_success() -> None:
"input_tokens": 12,
"output_tokens": 8,
"total_tokens": 20,
"cache_usage_available": True,
"cache_read_input_tokens": 8,
"cache_write_input_tokens": 2,
"uncached_input_tokens": 2,
"cache_hit_ratio": 8 / 12,
}
)
return _FakeAgent([AIMessage(content="ok")])
@@ -137,6 +144,11 @@ def test_execute_agent_broadcasts_usage_on_success() -> None:
assert usage.input_tokens == 12
assert usage.output_tokens == 8
assert usage.total_tokens == 20
assert usage.cache_read_input_tokens == 8
assert usage.cache_write_input_tokens == 2
assert usage.uncached_input_tokens == 2
assert usage.cache_hit_ratio == 8 / 12
assert usage.cache_usage_available
def test_execute_agent_broadcasts_usage_on_failure() -> None:
+15 -1
View File
@@ -26,9 +26,23 @@ class TestAgentToolResultLimits(unittest.TestCase):
self.assertTrue(payload["tool_result_truncated"])
self.assertEqual(payload["tool_name"], "oversized_result_tool")
self.assertEqual(payload["returned_chars"], DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertLessEqual(len(result), DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertEqual(payload["returned_chars"], len(payload["content_preview"]))
self.assertLess(payload["returned_chars"], DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertGreater(payload["total_chars"], payload["returned_chars"])
def test_formatter_keeps_escaped_preview_within_hard_limit(self):
"""大量换行转义后,最终工具结果仍不得超过配置的字符上限。"""
result = format_tool_result_for_agent(
"line\n" * DEFAULT_TOOL_RESULT_MAX_CHARS,
tool_name="escaped_result_tool",
)
payload = json.loads(result)
self.assertLessEqual(len(result), DEFAULT_TOOL_RESULT_MAX_CHARS)
self.assertTrue(payload["tool_result_truncated"])
self.assertEqual(payload["returned_chars"], len(payload["content_preview"]))
def test_formatter_preserves_sensitive_json_fields_for_agent_use(self):
result = format_tool_result_for_agent(
{
+94
View File
@@ -0,0 +1,94 @@
from unittest.mock import MagicMock, patch
import pytest
from app.modules.filemanager.storages import alist as alist_module
from app.modules.filemanager.storages.alist import Alist
from app.modules.filemanager.storages.alistgo import AlistGo
from app.schemas.types import StorageSchema
class _FakeResponse:
def __init__(self, payload: dict, status_code: int = 200):
self._payload = payload
self.status_code = status_code
def json(self):
return self._payload
@pytest.fixture
def clear_token_cache():
Alist._Alist__login_token.cache_clear() # noqa
yield
Alist._Alist__login_token.cache_clear() # noqa
def test_alistgo_schema_registered():
assert AlistGo.schema == StorageSchema.AlistGo
assert StorageSchema.AlistGo.value == "alistgo"
def test_alistgo_singleton_isolated_from_alist():
alist = Alist()
alistgo = AlistGo()
assert alistgo is not alist
assert isinstance(alistgo, Alist)
def test_alistgo_token_isolated_from_alist(clear_token_cache):
def _conf(storage):
return {
"url": f"http://{storage.schema.value}.test",
"username": "user",
"password": "pass",
}
responses = [
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alist"}}),
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alistgo"}}),
]
request_utils = MagicMock()
request_utils.post_res.side_effect = responses
alist = Alist()
alistgo = AlistGo()
with patch.object(Alist, "get_conf", _conf):
with patch.object(alist_module, "RequestUtils", return_value=request_utils):
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo" # noqa
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo" # noqa
assert request_utils.post_res.call_count == 2
def test_init_storage_keeps_other_storage_token(clear_token_cache):
def _conf(storage):
return {
"url": f"http://{storage.schema.value}.test",
"username": "user",
"password": "pass",
}
responses = [
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alist"}}),
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alistgo"}}),
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alistgo-new"}}),
]
request_utils = MagicMock()
request_utils.post_res.side_effect = responses
alist = Alist()
alistgo = AlistGo()
with patch.object(Alist, "get_conf", _conf):
with patch.object(alist_module, "RequestUtils", return_value=request_utils):
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo" # noqa
alistgo.init_storage()
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo-new" # noqa
assert request_utils.post_res.call_count == 3
+43
View File
@@ -284,3 +284,46 @@ def test_upload_avatar_rejects_other_user_for_non_superuser():
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "用户权限不足"
def test_upload_avatar_returns_filename_in_data(monkeypatch):
"""头像上传成功时应通过 data 返回文件名,message 只保留消息文本。"""
class FakeUser:
"""记录头像更新内容的用户桩。"""
def __init__(self):
self.values = None
async def async_update(self, db: object, values: dict[str, str]) -> None:
"""记录待写入的头像数据。"""
self.values = values
class FakeUserModel:
"""返回固定用户的模型桩。"""
@classmethod
async def async_get(cls, db: object, user_id: int) -> FakeUser:
"""按用户 ID 返回测试用户。"""
assert user_id == 1
return fake_user
fake_user = FakeUser()
current_user = SimpleNamespace(id=1, is_superuser=False)
upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png")
monkeypatch.setattr(user_endpoint, "User", FakeUserModel)
response = asyncio.run(
user_endpoint.upload_avatar(
user_id=1,
db=object(),
file=upload_file,
current_user=current_user,
)
)
assert response.success is True
assert response.data == {"filename": "avatar.png"}
assert response.message is None
assert response.message_i18n is None
assert fake_user.values == {"avatar": "data:image/ico;base64,b'YXZhdGFy'"}
+259
View File
@@ -0,0 +1,259 @@
from types import SimpleNamespace
import httpx
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from app.api.apiv2_utils import (
OPENAPI_V2_PATH,
V2ResponseMiddleware,
configure_v2_openapi,
)
from app.schemas.response import Response
pytestmark = pytest.mark.anyio
@pytest.fixture()
def anyio_backend():
"""使用 asyncio 运行异步接口测试。"""
return "asyncio"
@pytest.fixture()
def api_app() -> FastAPI:
"""构造同时包含 v1 和 v2 示例接口的测试应用。"""
app = FastAPI()
app.add_middleware(V2ResponseMiddleware)
@app.get("/api/v1/items")
async def get_v1_items() -> list[dict]:
"""返回未封装的 v1 列表。"""
return [{"id": 1}]
@app.get("/api/v2/items")
async def get_v2_items() -> list[dict]:
"""返回供 v2 适配器封装的列表。"""
return [{"id": 1}]
@app.get("/api/v2/wrapped", response_model=Response)
async def get_wrapped_response() -> Response:
"""返回已经使用通用结构封装的响应。"""
return Response(success=True, message="操作成功", data={"id": 1})
@app.get("/api/v2/error")
async def get_error() -> None:
"""返回供 v2 适配器转换的 HTTP 错误。"""
raise HTTPException(status_code=400, detail="请求参数错误")
@app.get("/api/v2/validated/{item_id}")
async def get_validated_item(item_id: int) -> dict:
"""返回带路径参数校验的示例数据。"""
return {"id": item_id}
@app.get("/api/v2/openai/v1/models")
async def get_openai_models() -> dict:
"""返回需要保持原始协议结构的 OpenAI 模型列表。"""
return {"object": "list", "data": []}
@app.get("/api/v2/events")
async def get_events() -> None:
"""返回不应封装的 SSE 流。"""
async def event_source():
"""生成一条测试事件。"""
yield "data: ok\n\n"
return StreamingResponse(event_source(), media_type="text/event-stream")
@app.get(OPENAPI_V2_PATH, include_in_schema=False)
async def get_v2_openapi_schema() -> dict:
"""返回不应被 v2 中间件封装的 OpenAPI 文档。"""
return {"openapi": "3.1.0", "info": {"title": "Test", "version": "1.0.0"}}
configure_v2_openapi(app)
return app
def make_client(app: FastAPI) -> httpx.AsyncClient:
"""创建不访问真实网络的 ASGI 测试客户端。"""
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
)
async def test_v2_wraps_raw_json_without_changing_v1(api_app: FastAPI):
"""v2 应封装原始 JSON 数据,同时保持 v1 返回结构不变。"""
async with make_client(api_app) as client:
v1_response = await client.get("/api/v1/items")
v2_response = await client.get("/api/v2/items")
assert v1_response.json() == [{"id": 1}]
assert v2_response.json() == {
"success": True,
"message": "",
"data": [{"id": 1}],
}
async def test_v2_keeps_existing_response_payload(api_app: FastAPI):
"""已经使用 Response 的接口不应被重复封装。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/wrapped")
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "操作成功"
assert payload["data"] == {"id": 1}
assert "success" not in payload["data"]
async def test_v2_moves_http_error_detail_to_message(api_app: FastAPI):
"""v2 HTTP 错误应保留状态码并把 detail 转换到 message。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/error")
assert response.status_code == 400
assert response.json() == {
"success": False,
"message": "请求参数错误",
"data": {},
}
async def test_v2_moves_validation_error_to_message(api_app: FastAPI):
"""v2 参数校验错误也应返回可直接展示的 message。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/validated/not-an-integer")
payload = response.json()
assert response.status_code == 422
assert payload["success"] is False
assert payload["message"]
assert payload["data"] == {}
async def test_v2_keeps_protocol_response_unwrapped(api_app: FastAPI):
"""OpenAI 等标准协议接口应保持原始响应结构。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/openai/v1/models")
assert response.json() == {"object": "list", "data": []}
async def test_v2_keeps_streaming_response_unwrapped(api_app: FastAPI):
"""v2 SSE 等非 JSON 响应应保持原始内容。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/events")
assert response.headers["content-type"].startswith("text/event-stream")
assert response.text == "data: ok\n\n"
async def test_v2_keeps_openapi_schema_unwrapped(api_app: FastAPI):
"""v2 OpenAPI 文档应保持 Swagger UI 可读取的原始结构。"""
async with make_client(api_app) as client:
response = await client.get(OPENAPI_V2_PATH)
assert response.json() == {
"openapi": "3.1.0",
"info": {"title": "Test", "version": "1.0.0"},
}
def test_v2_openapi_uses_response_schema(api_app: FastAPI):
"""v2 普通 JSON 接口的 OpenAPI 应声明通用 Response 模型。"""
schema = api_app.openapi()
raw_schema = schema["paths"]["/api/v2/items"]["get"]["responses"]["200"]
protocol_schema = schema["paths"]["/api/v2/openai/v1/models"]["get"]
stream_schema = schema["paths"]["/api/v2/events"]["get"]
assert raw_schema["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/Response"
}
assert protocol_schema["responses"]["200"]["content"]["application/json"][
"schema"
] != {"$ref": "#/components/schemas/Response"}
assert stream_schema["responses"]["200"]["content"]["application/json"].get(
"schema"
) != {"$ref": "#/components/schemas/Response"}
data_schema = schema["components"]["schemas"]["Response"]["properties"]["data"]
assert data_schema["title"] == "Data"
assert {} in data_schema["anyOf"]
def test_response_accepts_scalar_data():
"""通用 Response 的 data 应支持标量接口数据。"""
response = Response(success=True, data=1)
assert response.data == 1
def test_v2_router_reuses_v1_route_endpoints():
"""v2 路由应直接复用 v1 的端点函数,避免复制业务实现。"""
from fastapi.routing import APIRoute
from app.api.apiv1 import api_router
from app.api.apiv2 import api_router_v2
v1_routes = [route for route in api_router.routes if isinstance(route, APIRoute)]
v2_routes = [route for route in api_router_v2.routes if isinstance(route, APIRoute)]
assert len(v2_routes) == len(v1_routes)
assert all(
v2_route.path == v1_route.path
and v2_route.methods == v1_route.methods
and v2_route.endpoint is v1_route.endpoint
for v1_route, v2_route in zip(v1_routes, v2_routes)
)
def test_plugin_routes_are_mirrored_to_v2(monkeypatch):
"""插件动态路由注册和移除时应同步维护 v2 路径。"""
from app.api.endpoints import plugin as plugin_endpoint
class FakeApp:
"""记录动态注册路径的应用桩。"""
def __init__(self):
self.routes = []
self.openapi_schema = None
def add_api_route(self, **kwargs):
"""记录新增的路由路径。"""
self.routes.append(SimpleNamespace(path=kwargs["path"]))
def setup(self):
"""模拟 FastAPI 路由重建。"""
class FakePluginManager:
"""返回单个测试插件 API 的管理器桩。"""
def get_plugin_apis(self, plugin_id):
"""返回测试插件 API。"""
assert plugin_id == "DemoPlugin"
return [
{
"path": "/DemoPlugin/health",
"endpoint": lambda: {"ok": True},
"methods": ["GET"],
}
]
fake_app = FakeApp()
monkeypatch.setattr(plugin_endpoint, "app", fake_app)
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add")
assert [route.path for route in fake_app.routes] == [
"/api/v1/plugin/DemoPlugin/health",
"/api/v2/plugin/DemoPlugin/health",
]
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
assert fake_app.routes == []
+1 -1
View File
@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
expected_versions = {
"database-operation": "3",
"moviepilot-api": "7",
"moviepilot-api": "9",
"moviepilot-cli": "6",
"moviepilot-update": "3",
"transfer-failed-retry": "2",
+65 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timedelta
from types import SimpleNamespace
from app.core.config import settings
@@ -9,6 +10,11 @@ from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
from app.doctor.runner import DoctorRunner
def _current_log_timestamp() -> str:
"""返回 Doctor 近期日志测试使用的当前时间戳。"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
"""doctor JSON 报告应包含稳定状态、环境、汇总和发现列表。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
@@ -95,7 +101,7 @@ def test_doctor_plugin_log_error_does_not_degrade_report(tmp_path, monkeypatch):
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
plugin_log.write_text(
"【ERROR】2026-07-20 08:00:00 - demo.py - 插件任务执行异常\n",
f"【ERROR】{_current_log_timestamp()} - demo.py - 插件任务执行异常\n",
encoding="utf-8",
)
@@ -121,7 +127,7 @@ def test_doctor_plugin_load_error_in_main_log_does_not_degrade_report(
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
f"【ERROR】{_current_log_timestamp()} - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
"Exception: boom\n",
encoding="utf-8",
)
@@ -146,12 +152,12 @@ def test_doctor_plugin_error_mirrored_to_stdio_does_not_degrade_report(
plugin_log = settings.LOG_PATH / "plugins" / "DemoPlugin.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
plugin_log.write_text(
"【INFO】2026-07-20 08:00:00 - demo.py - 插件已启动\n",
f"【INFO】{_current_log_timestamp()} - demo.py - 插件已启动\n",
encoding="utf-8",
)
stdio_log = settings.LOG_PATH / "moviepilot.stdout.log"
stdio_log.write_text(
"ERROR: [demoplugin] 2026-07-20 08:01:00 demo.py - task exception\n",
f"ERROR: [demoplugin] {_current_log_timestamp()} demo.py - task exception\n",
encoding="utf-8",
)
@@ -171,7 +177,7 @@ def test_doctor_core_log_error_still_degrades_report(tmp_path, monkeypatch):
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
"【ERROR】2026-07-20 08:00:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
f"【ERROR】{_current_log_timestamp()} - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
"RuntimeError: boom\n",
encoding="utf-8",
)
@@ -195,9 +201,9 @@ def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status(
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
f"【ERROR】{_current_log_timestamp()} - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
"Exception: plugin boom\n"
"【ERROR】2026-07-20 08:01:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
f"【ERROR】{_current_log_timestamp()} - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
"Exception: core boom\n",
encoding="utf-8",
)
@@ -214,3 +220,55 @@ def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status(
assert "core boom" in core_finding.detail
assert "plugin boom" in plugin_finding.detail
assert runner.report.status.value == "degraded"
def test_doctor_deduplicates_mirrored_plugin_errors(tmp_path, monkeypatch):
"""同一插件错误出现在主日志和插件日志时应只生成一条聚合告警。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
app_log = settings.LOG_PATH / "moviepilot.log"
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
f"【ERROR】{timestamp} - plugin.py - 插件任务执行异常\n",
encoding="utf-8",
)
plugin_log.write_text(
f"【ERROR】{timestamp} - demo.py - 插件任务执行异常\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
plugin_findings = [
finding
for finding in runner.report.findings
if finding.title == "最近日志存在插件异常"
]
assert len(plugin_findings) == 1
finding = plugin_findings[0]
assert finding.context["matches"] == 2
assert finding.context["unique_matches"] == 1
assert len(finding.context["log_files"]) == 2
assert runner.report.summary["advisory"] == 1
assert runner.report.find("logs.recent") is not None
def test_doctor_ignores_errors_outside_log_window(tmp_path, monkeypatch):
"""超出日志诊断时间窗的历史错误不应污染当前 Doctor 结果。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
timestamp = (datetime.now() - timedelta(hours=25)).strftime("%Y-%m-%d %H:%M:%S")
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
f"【ERROR】{timestamp} - rss.py - 历史解析错误\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
assert runner.report.find("logs.moviepilot.recent_errors") is None
assert runner.report.find("logs.recent") is not None
assert runner.report.status.value == "healthy"
-166
View File
@@ -1,166 +0,0 @@
import asyncio
import inspect
from unittest.mock import Mock
from app.api.endpoints import douban as douban_endpoint
from app.db.user_oper import get_current_active_superuser_async
from app.modules.douban.douban_cache import DoubanCache
from app.schemas.types import MediaType, SystemConfigKey
class _MemoryCacheStub:
"""提供豆瓣缓存管理测试所需的最小内存后端。"""
def __init__(self, data: dict):
"""使用给定字典初始化测试缓存。"""
self.data = data
def items(self):
"""返回全部缓存条目。"""
return self.data.items()
def get(self, key: str):
"""读取指定缓存条目。"""
return self.data.get(key)
def delete(self, key: str):
"""删除指定缓存条目。"""
self.data.pop(key, None)
def set(self, key: str, value):
"""写入指定缓存条目。"""
self.data[key] = value
def clear(self):
"""清空全部缓存条目。"""
self.data.clear()
def _build_douban_cache(data: dict) -> DoubanCache:
"""构造绕过单例初始化的豆瓣缓存测试实例。"""
cache = object.__new__(DoubanCache)
cache._cache = _MemoryCacheStub(data)
cache.save = lambda force=False: None
return cache
def test_douban_cache_management_endpoints_require_superuser():
"""豆瓣识别缓存管理接口必须仅允许超级管理员访问。"""
endpoints = [
douban_endpoint.douban_recognition_cache,
douban_endpoint.delete_douban_recognition_cache,
douban_endpoint.clear_douban_recognition_cache,
]
for endpoint in endpoints:
dependency = inspect.signature(endpoint).parameters["_"].default.dependency
assert dependency is get_current_active_superuser_async
def test_douban_cache_list_items_normalizes_media_type_and_sorting():
"""豆瓣管理列表应输出稳定顺序和前端可识别的媒体类型。"""
cache = _build_douban_cache({
"[电视剧]Zulu-2024-1": {
"id": "2",
"title": "Zulu",
"type": MediaType.TV,
"year": "2024",
},
"[电影]Alpha-2023-None": {
"id": "1",
"title": "Alpha",
"type": "电影",
"year": "2023",
"poster_path": "https://example.com/alpha.jpg",
},
"[电影]Missing-2022-None": {"id": 0},
})
items = cache.list_items()
assert [item["title"] for item in items] == ["Alpha", "", "Zulu"]
assert [item["media_type"] for item in items] == ["movie", "unknown", "tv"]
assert items[0]["poster_path"] == "https://example.com/alpha.jpg"
assert items[1]["douban_id"] == 0
def test_douban_cache_infers_special_season_title_as_tv():
"""缺少显式类型时,S00 标题仍应按电视剧写入缓存。"""
cache = _build_douban_cache({})
cache.update(
meta=None,
info={"id": "special", "title": "测试剧 S00", "year": "2024"},
)
cached = next(iter(cache._cache.data.values()))
assert cached["type"] == MediaType.TV
def test_douban_cache_delete_and_clear_persist_immediately(monkeypatch):
"""豆瓣管理操作应修改运行时缓存并立即触发本地持久化。"""
cache = _build_douban_cache({"first": {"id": "1"}, "second": {"id": "2"}})
saved_forces = []
monkeypatch.setattr(cache, "save", lambda force=False: saved_forces.append(force))
assert cache.delete("first") == {"id": "1"}
assert cache.delete("missing") == {}
cache.clear()
assert cache.list_items() == []
assert saved_forces == [True, True]
def test_douban_cache_endpoint_returns_management_statistics(monkeypatch):
"""豆瓣查询接口应返回识别成功和失败条目的统计。"""
cache = _build_douban_cache({
"recognized": {"id": "1", "title": "Alpha", "type": MediaType.MOVIE},
"unrecognized": {"id": 0},
})
get_system_config = Mock(return_value=None)
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
monkeypatch.setattr(
douban_endpoint,
"SystemConfigOper",
lambda: type("SystemConfigStub", (), {"get": get_system_config})(),
)
monkeypatch.setattr(douban_endpoint.settings, "MEDIA_RECOGNIZE_SHARE", False)
response = asyncio.run(douban_endpoint.douban_recognition_cache(None))
assert response.success is True
assert response.data["count"] == 2
assert response.data["recognized"] == 1
assert response.data["unrecognized"] == 1
assert response.data["shared_recognized"] == 0
assert response.data["shared_recognize_enabled"] is False
get_system_config.assert_called_once_with(
SystemConfigKey.MediaRecognizeShareCount
)
def test_douban_cache_delete_endpoint_reports_missing_item(monkeypatch):
"""豆瓣删除接口应区分成功删除与缓存不存在。"""
cache = _build_douban_cache({"existing": {"id": "1"}})
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
deleted_response = asyncio.run(
douban_endpoint.delete_douban_recognition_cache("existing", None)
)
missing_response = asyncio.run(
douban_endpoint.delete_douban_recognition_cache("missing", None)
)
assert deleted_response.success is True
assert missing_response.success is False
def test_douban_cache_clear_endpoint_removes_all_items(monkeypatch):
"""豆瓣清空接口应删除全部识别缓存。"""
cache = _build_douban_cache({"existing": {"id": "1"}})
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
response = asyncio.run(douban_endpoint.clear_douban_recognition_cache(None))
assert response.success is True
assert cache.list_items() == []
+80
View File
@@ -0,0 +1,80 @@
import asyncio
from unittest.mock import Mock
from unittest.mock import AsyncMock
from app.core.meta import MetaBase
from app.modules.douban import DoubanModule
from app.schemas.types import MediaType
def test_douban_recognize_does_not_keep_dedicated_mapping_cache():
"""豆瓣识别应每次执行匹配,不再保留专用标题映射缓存。"""
module = DoubanModule()
meta = MetaBase("测试电影")
meta.name = "测试电影"
meta.type = MediaType.MOVIE
meta.year = "2024"
match_doubaninfo = Mock(return_value={"id": "200"})
douban_info = Mock(return_value={
"id": "200",
"title": "测试电影",
"type": "movie",
"year": "2024",
})
first_result = module._recognize_media_core(
meta=meta,
source="douban",
match_doubaninfo_func=match_doubaninfo,
douban_info_func=douban_info,
)
second_result = module._recognize_media_core(
meta=meta,
source="douban",
match_doubaninfo_func=match_doubaninfo,
douban_info_func=douban_info,
)
assert first_result.douban_id == "200"
assert second_result.douban_id == "200"
assert match_doubaninfo.call_count == 2
assert douban_info.call_count == 2
def test_async_douban_recognize_does_not_keep_dedicated_mapping_cache():
"""异步豆瓣识别也应每次执行匹配,不使用专用标题映射缓存。"""
module = DoubanModule()
meta = MetaBase("测试剧集")
meta.name = "测试剧集"
meta.type = MediaType.TV
meta.year = "2024"
match_doubaninfo = AsyncMock(return_value={"id": "201"})
douban_info = AsyncMock(return_value={
"id": "201",
"title": "测试剧集",
"type": "tv",
"year": "2024",
})
async def recognize_twice():
"""连续执行两次异步豆瓣识别。"""
first_result = await module._async_recognize_media_core(
meta=meta,
source="douban",
async_match_doubaninfo_func=match_doubaninfo,
async_douban_info_func=douban_info,
)
second_result = await module._async_recognize_media_core(
meta=meta,
source="douban",
async_match_doubaninfo_func=match_doubaninfo,
async_douban_info_func=douban_info,
)
return first_result, second_result
first_result, second_result = asyncio.run(recognize_twice())
assert first_result.douban_id == "201"
assert second_result.douban_id == "201"
assert match_doubaninfo.await_count == 2
assert douban_info.await_count == 2
+11 -7
View File
@@ -36,24 +36,28 @@ class TestExecuteCommandTool(unittest.TestCase):
return asyncio.run(tool.run(action="run", command=command, timeout=timeout))
def test_large_output_is_truncated_before_returning_to_agent(self):
"""大输出一次性命令只把预览返回给 Agent,并把完整内容写到临时文件。"""
"""大输出一次性命令返回头尾预览,并把完整内容写到临时文件。"""
command = _python_command(
"import sys; sys.stdout.write('x' * 200000); sys.stdout.flush()"
"import sys; sys.stdout.write('HEAD-' + 'x' * 200000 + '-TAIL'); sys.stdout.flush()"
)
result = self._run_command(command)
temp_file_path = self._temp_file_path_from_result(result)
self.addCleanup(lambda: os.path.exists(temp_file_path) and os.unlink(temp_file_path))
self.assertIn("命令输出超过 10KB", result)
self.assertIn("仅展示前 10KB 内容", result)
self.assertIn("命令输出超过 32KB", result)
self.assertIn("仅展示前后各 16KB 内容", result)
self.assertIn("如需完整内容,请继续读取该文件", result)
self.assertLess(len(result), MAX_OUTPUT_PREVIEW_BYTES + 600)
self.assertIn("HEAD-", result)
self.assertIn("-TAIL", result)
self.assertLess(len(result), MAX_OUTPUT_PREVIEW_BYTES + 1200)
with open(temp_file_path, encoding="utf-8") as file_handle:
file_content = file_handle.read()
self.assertIn("[标准输出]", file_content)
self.assertIn("HEAD-", file_content)
self.assertIn("-TAIL", file_content)
self.assertGreater(len(file_content), 100000)
def test_timeout_returns_partial_output_promptly(self):
@@ -106,7 +110,7 @@ class TestExecuteCommandTool(unittest.TestCase):
def test_timeout_with_large_output_writes_partial_full_log_to_temp_file(self):
"""超时且输出较大时,终止前完整输出应写入临时文件。"""
command = _python_command(
"import sys, time; sys.stdout.write('x' * 20000); sys.stdout.flush(); time.sleep(5)"
"import sys, time; sys.stdout.write('x' * 60000); sys.stdout.flush(); time.sleep(5)"
)
result = self._run_command(command, timeout=1)
@@ -120,7 +124,7 @@ class TestExecuteCommandTool(unittest.TestCase):
file_content = file_handle.read()
self.assertIn("[标准输出]", file_content)
self.assertGreaterEqual(file_content.count("x"), 20000)
self.assertGreaterEqual(file_content.count("x"), 60000)
def test_timeout_is_capped(self):
"""一次性执行的 timeout 参数超过上限时应自动限幅。"""
+111
View File
@@ -0,0 +1,111 @@
"""Feedback Issue 日志压缩和 Doctor 摘要聚合测试。"""
import importlib.util
import sys
from datetime import datetime, timedelta
from pathlib import Path
import pytest
SCRIPT_DIR = Path(__file__).parents[1] / "skills" / "feedback-issue" / "scripts"
def _load_module(name: str, path: Path):
"""从脚本路径加载测试模块。"""
spec = importlib.util.spec_from_file_location(name, path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture
def feedback_modules():
"""加载 feedback 脚本,并在测试后恢复进程模块和搜索路径。"""
old_path = list(sys.path)
module_names = ["feedback_issue_common", "feedback_issue_collect_quality_test"]
old_modules = {name: sys.modules.get(name) for name in module_names}
try:
sys.path.insert(0, str(SCRIPT_DIR))
common = _load_module(
"feedback_issue_common",
SCRIPT_DIR / "feedback_issue_common.py",
)
collect = _load_module(
"feedback_issue_collect_quality_test",
SCRIPT_DIR / "collect_feedback_diagnostics.py",
)
yield common, collect
finally:
sys.path[:] = old_path
for name, module in old_modules.items():
if module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = module
def test_filter_lines_compacts_consecutive_repeated_templates(feedback_modules):
"""关键词命中的连续轮询日志应压缩为首条、计数和末条。"""
_, collect = feedback_modules
now = datetime.now()
lines = [
(
f"【INFO】{(now - timedelta(seconds=20 - index)).strftime('%Y-%m-%d %H:%M:%S')},000 "
f"- transfer.py - 等待转存任务完成:{index}/20"
)
for index in range(1, 11)
]
filtered, matched_keywords = collect.filter_lines(
"\n".join(lines),
keywords=["等待转存"],
max_lines=80,
window_start=now - timedelta(minutes=5),
)
assert len(filtered) == 3
assert "1/20" in filtered[0]
assert "连续重复 10 次" in filtered[1]
assert "10/20" in filtered[2]
assert matched_keywords == ["等待转存"]
def test_doctor_summary_groups_legacy_duplicate_advisories(feedback_modules):
"""旧版 Doctor 的重复插件发现也应在反馈摘要中合并展示。"""
common, _ = feedback_modules
findings = [
{
"severity": "warn",
"title": "最近日志存在插件异常",
"recommendation": "检查插件配置。",
"affects_report_status": False,
"context": {
"log_file": f"/config/logs/plugins/plugin-{index}.log",
"matches": 2,
},
}
for index in range(5)
]
summary = common.format_doctor_summary({
"success": True,
"report": {
"status": "healthy",
"summary": {
"total": 5,
"error": 0,
"warn": 5,
"fixed": 0,
},
"findings": findings,
},
})
assert summary.count("最近日志存在插件异常") == 1
assert "advisory=5" in summary
assert "warn/advisory" in summary
assert "合并 5 项" in summary
assert "plugin-0.log" in summary
assert "命中:10 条" in summary
+9 -7
View File
@@ -1,5 +1,4 @@
import unittest
from unittest.mock import patch
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@@ -54,12 +53,15 @@ class DeepSeekCompatPatchTest(unittest.TestCase):
_FakeChatDeepSeek._get_request_payload = _ORIGINAL_GET_REQUEST_PAYLOAD
if hasattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched"):
delattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched")
# helper 的修补函数内部 `from langchain_deepseek import ChatDeepSeek`
# 这里临时把该名指向假类,使修补作用到 _FakeChatDeepSeekpatch 在用例结束自动还原。
patcher = patch("langchain_deepseek.ChatDeepSeek", _FakeChatDeepSeek)
patcher.start()
self.addCleanup(patcher.stop)
llm_module._patch_deepseek_reasoning_content_support()
llm_module._patch_interleaved_reasoning_request_support(
_FakeChatDeepSeek,
patch_marker="_moviepilot_reasoning_content_patched",
thinking_filter=lambda model_name, extra_body: (
llm_module._is_deepseek_thinking_enabled(model_name, extra_body)
),
normalize_deepseek_messages=True,
inject_missing_as_empty=True,
)
def test_injects_reasoning_content_for_assistant_tool_calls(self):
llm = _FakeChatDeepSeek("deepseek-v4-pro")
+63 -6
View File
@@ -245,6 +245,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
user_agent=None,
use_proxy=None,
api_protocol=None,
web_search_mode=None,
)
self.assertEqual(result["provider"], "deepseek")
self.assertEqual(result["model"], "deepseek-chat")
@@ -439,8 +440,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
), patch.object(
llm_module,
"_patch_deepseek_reasoning_content_support",
side_effect=lambda: patch_calls.append(True),
"_patch_interleaved_reasoning_request_support",
side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)),
):
asyncio.run(
llm_module.LLMHelper.get_llm(
@@ -457,7 +458,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
calls[0].get("extra_body"),
{"thinking": {"type": "enabled"}},
)
self.assertEqual(patch_calls, [True])
self.assertEqual(patch_calls[0][0][0], _FakeChatDeepSeek)
self.assertTrue(patch_calls[0][1]["normalize_deepseek_messages"])
self.assertEqual(calls[0].get("reasoning_effort"), "max")
self.assertEqual(calls[0].get("api_base"), "https://api.deepseek.com")
@@ -476,8 +478,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
), patch.object(
llm_module,
"_patch_deepseek_reasoning_content_support",
side_effect=lambda: patch_calls.append(True),
"_patch_interleaved_reasoning_request_support",
side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)),
):
asyncio.run(
llm_module.LLMHelper.get_llm(
@@ -494,10 +496,65 @@ class LlmHelperTestCallTest(unittest.TestCase):
calls[0].get("extra_body"),
{"thinking": {"type": "disabled"}},
)
self.assertEqual(patch_calls, [True])
self.assertEqual(patch_calls[0][0][0], _FakeChatDeepSeek)
self.assertTrue(patch_calls[0][1]["normalize_deepseek_messages"])
self.assertIsNone(calls[0].get("reasoning_effort"))
self.assertEqual(calls[0].get("api_base"), "https://proxy.example.com")
def test_get_llm_uses_common_responses_adapter_for_deepseek_web_search(self):
"""DeepSeek 服务端搜索应走通用 ChatOpenAI Responses 适配器。"""
calls = []
class _FakeChatOpenAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
openai_module = ModuleType("langchain_openai")
openai_module.ChatOpenAI = _FakeChatOpenAI
with patch.dict(sys.modules, {"langchain_openai": openai_module}), patch.object(
llm_module,
"_patch_openai_responses_instructions_support",
):
model = asyncio.run(
llm_module.LLMHelper.get_llm(
provider="deepseek",
model="deepseek-v4-flash",
thinking_level="off",
api_key="sk-test",
base_url="https://api.deepseek.com",
api_protocol="auto",
web_search_mode="builtin",
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["base_url"], "https://api.deepseek.com")
self.assertTrue(calls[0]["use_responses_api"])
self.assertEqual(calls[0]["output_version"], "responses/v1")
self.assertEqual(
llm_module.LLMHelper.get_server_tools(model),
[{"type": "web_search"}],
)
self.assertFalse(llm_module.LLMHelper.should_use_local_web_search(model))
def test_get_llm_rejects_unsupported_builtin_web_search(self):
"""强制服务端搜索不可用时应在构造模型前显式失败。"""
with self.assertRaisesRegex(ValueError, "不支持服务端联网搜索"):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="deepseek",
model="deepseek-chat",
thinking_level="off",
api_key="sk-test",
base_url="https://api.deepseek.com",
api_protocol="auto",
web_search_mode="builtin",
)
)
def test_get_llm_uses_openai_reasoning_effort_none_for_off(self):
calls = []
+305
View File
@@ -0,0 +1,305 @@
"""LLM 服务端工具能力解析测试。"""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from app.agent.llm import LLMHelper
from app.agent.llm.provider import LLMProviderManager
from app.agent.llm.server_tools import (
ServerToolRegistry,
ServerToolUnavailableError,
)
def test_deepseek_v4_flash_exposes_builtin_web_search() -> None:
"""DeepSeek V4 Flash 应声明 Responses 服务端联网搜索能力。"""
capabilities = ServerToolRegistry.list_capabilities(
provider="deepseek",
model="deepseek-v4-flash",
)
assert capabilities == [
{
"id": "web_search",
"required_api_protocol": "responses",
"client_adapter": "openai_responses",
}
]
@pytest.mark.parametrize(
(
"provider",
"model",
"base_url",
"expected_tool",
"required_api_protocol",
"client_adapter",
),
[
(
"chatgpt",
"gpt-5.6-sol",
"https://api.openai.com/v1",
{"type": "web_search"},
"responses",
"openai_responses",
),
(
"openai",
"gpt-4.1-mini",
"https://api.openai.com/v1",
{"type": "web_search"},
"responses",
"openai_responses",
),
(
"anthropic",
"claude-opus-5",
"https://api.anthropic.com/v1",
{"type": "web_search_20250305", "name": "web_search"},
"native",
"anthropic_native",
),
(
"google",
"models/gemini-3.6-flash-preview",
None,
{"google_search": {}},
"native",
"google_native",
),
(
"xai",
"grok-4.5",
"https://api.x.ai/v1",
{"type": "web_search"},
"responses",
"openai_responses",
),
],
)
def test_official_provider_models_expose_builtin_web_search(
provider: str,
model: str,
base_url: str | None,
expected_tool: dict,
required_api_protocol: str,
client_adapter: str,
) -> None:
"""官方文档声明支持的模型应返回各自原生服务端搜索工具。"""
resolution = ServerToolRegistry.resolve_web_search(
provider=provider,
model=model,
mode="builtin",
api_protocol="auto",
base_url=base_url,
)
assert resolution.server_tools == (expected_tool,)
assert resolution.required_api_protocol == required_api_protocol
assert resolution.client_adapter == client_adapter
assert resolution.use_local_web_search is False
assert resolution.available is True
def test_builtin_web_search_selects_responses_adapter() -> None:
"""服务端搜索应切换到通用 Responses 适配器并关闭本地搜索。"""
resolution = ServerToolRegistry.resolve_web_search(
provider="deepseek",
model="deepseek-v4-flash",
mode="builtin",
api_protocol="auto",
)
assert resolution.server_tools == ({"type": "web_search"},)
assert resolution.client_adapter == "openai_responses"
assert resolution.required_api_protocol == "responses"
assert resolution.use_local_web_search is False
def test_auto_web_search_falls_back_to_local_for_unsupported_model() -> None:
"""自动模式在模型不支持服务端搜索时应保留本地搜索。"""
resolution = ServerToolRegistry.resolve_web_search(
provider="deepseek",
model="deepseek-chat",
mode="auto",
api_protocol="auto",
)
assert resolution.server_tools == ()
assert resolution.use_local_web_search is True
assert resolution.reason == "builtin_web_search_unavailable"
def test_auto_web_search_respects_chat_completions_selection() -> None:
"""显式 Chat Completions 下自动模式应回退本地搜索。"""
resolution = ServerToolRegistry.resolve_web_search(
provider="deepseek",
model="deepseek-v4-flash",
mode="auto",
api_protocol="chat_completions",
)
assert resolution.server_tools == ()
assert resolution.use_local_web_search is True
assert resolution.available is True
def test_native_web_search_ignores_openai_chat_completions_selection() -> None:
"""原生 Gemini 服务端搜索不应被 OpenAI 协议选项误伤回退。"""
resolution = ServerToolRegistry.resolve_web_search(
provider="google",
model="gemini-3.6-flash-preview",
mode="auto",
api_protocol="chat_completions",
)
assert resolution.server_tools == ({"google_search": {}},)
assert resolution.use_local_web_search is False
assert resolution.available is True
def test_builtin_web_search_does_not_silently_fall_back() -> None:
"""强制服务端模式在模型不支持时不应静默启用本地搜索。"""
resolution = ServerToolRegistry.resolve_web_search(
provider="deepseek",
model="deepseek-v4-pro",
mode="builtin",
api_protocol="auto",
)
assert resolution.server_tools == ()
assert resolution.use_local_web_search is False
assert resolution.available is False
def test_deepseek_builtin_web_search_is_limited_to_official_endpoint() -> None:
"""自定义 DeepSeek 兼容端点不应被误判为官方托管搜索。"""
resolution = ServerToolRegistry.resolve_web_search(
provider="deepseek",
model="deepseek-v4-flash",
mode="auto",
api_protocol="auto",
base_url="https://deepseek-proxy.example.com/v1",
)
assert resolution.server_tools == ()
assert resolution.use_local_web_search is True
@pytest.mark.parametrize(
("provider", "model", "base_url"),
[
("openai", "gpt-5.6-sol", "https://openai-proxy.example.com/v1"),
("anthropic", "claude-opus-5", "https://anthropic-proxy.example.com/v1"),
("xai", "grok-4.5", "https://xai-proxy.example.com/v1"),
],
)
def test_provider_web_search_is_limited_to_official_endpoints(
provider: str,
model: str,
base_url: str,
) -> None:
"""第三方兼容端点不应被误判为厂商官方托管搜索。"""
resolution = ServerToolRegistry.resolve_web_search(
provider=provider,
model=model,
mode="auto",
api_protocol="auto",
base_url=base_url,
)
assert resolution.server_tools == ()
assert resolution.use_local_web_search is True
@pytest.mark.parametrize(
("provider", "model", "runtime_name", "base_url", "expected_tool"),
[
(
"chatgpt",
"gpt-5.6-sol",
"openai_compatible",
"https://api.openai.com/v1",
{"type": "web_search"},
),
(
"anthropic",
"claude-opus-5",
"anthropic_compatible",
"https://api.anthropic.com/v1",
{"type": "web_search_20250305", "name": "web_search"},
),
(
"google",
"gemini-3.6-flash-preview",
"google",
None,
{"google_search": {}},
),
(
"xai",
"grok-4.5",
"openai_compatible",
"https://api.x.ai/v1",
{"type": "web_search"},
),
],
)
def test_llm_helper_binds_each_native_server_search_tool_offline(
provider: str,
model: str,
runtime_name: str,
base_url: str | None,
expected_tool: dict,
) -> None:
"""LLM Helper 应能离线构造并绑定各厂商的原生搜索工具。"""
runtime = {
"provider_id": provider,
"runtime": runtime_name,
"model_id": model,
"api_key": "test-key",
"base_url": base_url,
"default_headers": None,
"use_responses_api": None,
"model_record": None,
"model_metadata": None,
}
with patch.object(
LLMProviderManager,
"resolve_runtime",
new=AsyncMock(return_value=runtime),
):
llm = asyncio.run(
LLMHelper.get_llm(
provider=provider,
model=model,
api_key="test-key",
base_url=base_url,
web_search_mode="builtin",
)
)
tools = LLMHelper.get_server_tools(llm)
assert tools == [expected_tool]
assert llm.bind_tools(tools) is not None
def test_unavailable_server_tool_error_guides_user_to_safe_modes() -> None:
"""服务端搜索不可用时应明确告知用户可选的回退模式。"""
error = ServerToolUnavailableError(
provider="deepseek",
model="deepseek-chat",
tool_id="web_search",
)
assert error.provider == "deepseek"
assert error.model == "deepseek-chat"
assert error.tool_id == "web_search"
assert "不支持服务端联网搜索" in str(error)
assert "自动" in str(error)
assert "MoviePilot 本地搜索" in str(error)
@@ -303,7 +303,7 @@ class LocalSetupLlmProviderPromptTests(unittest.TestCase):
), patch.object(
module, "_env_llm_thinking_level_default", return_value="auto"
), patch.object(
module, "_prompt_choice", side_effect=["auto", "minimax-cn-coding"]
module, "_prompt_choice", side_effect=["auto", "minimax-cn-coding", "local"]
):
config = module._collect_agent_config()
+19 -5
View File
@@ -6,11 +6,12 @@ from types import SimpleNamespace
from fastapi import HTTPException
from app.factory import localized_http_exception_handler
from app.factory import create_app, localized_http_exception_handler
from app.helper.locale import LocaleHelper
from app.helper.progress import ProgressHelper
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
from app.schemas.response import Response
from version import APP_VERSION
def _has_chinese(text: str) -> bool:
@@ -247,8 +248,8 @@ def test_response_auto_fills_message_i18n_from_locale_context():
assert response.message_i18n == "Module does not support testing"
def test_http_exception_handler_adds_detail_i18n_from_locale_context():
"""HTTPException 响应应补充多语言 detail 字段"""
def test_http_exception_handler_returns_untranslated_response_envelope():
"""HTTPException 响应应统一封装并保留原始错误文本"""
token = LocaleHelper.set_current_locale("en-US")
try:
response = asyncio.run(
@@ -260,9 +261,22 @@ def test_http_exception_handler_adds_detail_i18n_from_locale_context():
finally:
LocaleHelper.reset_current_locale(token)
assert response.status_code == 401
payload = json.loads(response.body)
assert payload["detail"] == "用户名或密码错误"
assert payload["detail_i18n"] == "Incorrect username or password"
assert payload == {
"success": False,
"message": "用户名或密码错误",
"data": {},
}
def test_application_docs_use_v2_openapi_and_backend_version():
"""默认接口文档应展示 v2 地址并使用真实后端版本号。"""
app = create_app()
assert app.openapi_url == "/api/v2/openapi.json"
assert app.version == APP_VERSION
assert "/api/v1/openapi.json" in {route.path for route in app.routes}
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
+20
View File
@@ -95,3 +95,23 @@ def test_login_invalid_password_does_not_expose_mfa_methods(monkeypatch):
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "用户名或密码错误"
assert "X-MFA-Required" not in (exc_info.value.headers or {})
def test_wallpaper_returns_url_in_data(monkeypatch):
"""登录壁纸地址应放入 data,message 只保留消息文本。"""
class FakeWallpaperHelper:
"""返回固定登录壁纸地址。"""
def get_wallpaper(self):
"""返回测试壁纸地址。"""
return "https://images.example/wallpaper.jpg"
monkeypatch.setattr(login_endpoint, "WallpaperHelper", FakeWallpaperHelper)
response = login_endpoint.wallpaper()
assert response.success is True
assert response.data == "https://images.example/wallpaper.jpg"
assert response.message is None
assert response.message_i18n is None
+60
View File
@@ -0,0 +1,60 @@
import asyncio
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.api.endpoints.media import recognize
from app.core.context import MediaInfo
from app.schemas.types import MediaType
@pytest.mark.parametrize(
"title",
[
"武神主宰 (2020)/武神主宰.S01E680.mp4",
r"D:\武神主宰 (2020)\武神主宰.S01E680.mp4",
],
)
def test_recognize_uses_parent_metadata_for_media_file_path(title: str) -> None:
"""标题参数为媒体文件路径时应合并父目录中的名称和年份。"""
chain = Mock()
chain.async_recognize_by_meta = AsyncMock(
return_value=MediaInfo(
title="武神主宰",
year="2020",
type=MediaType.TV,
)
)
with patch("app.api.endpoints.media.MediaChain", return_value=chain):
asyncio.run(recognize(title=title, _=Mock()))
metainfo = chain.async_recognize_by_meta.await_args.args[0]
assert metainfo.name == "武神主宰"
assert metainfo.year == "2020"
assert metainfo.begin_season == 1
assert metainfo.begin_episode == 680
assert metainfo.title == title
@pytest.mark.parametrize(
"title",
[
"Fate/stay night",
"https://example.com/武神主宰.S01E680.mp4",
],
)
def test_recognize_does_not_treat_non_path_title_as_file_path(title: str) -> None:
"""普通片名和网络地址不应误走文件路径解析。"""
chain = Mock()
chain.async_recognize_by_meta = AsyncMock(
return_value=MediaInfo(title="Fate/stay night", type=MediaType.TV)
)
with (
patch("app.api.endpoints.media.MediaChain", return_value=chain),
patch("app.api.endpoints.media.MetaInfoPath") as meta_info_path,
):
asyncio.run(recognize(title=title, _=Mock()))
meta_info_path.assert_not_called()
+129
View File
@@ -0,0 +1,129 @@
import ast
from pathlib import Path
import pytest
from app.helper.market import extract_plugin_market_repos_from_wiki
from scripts.generate_plugin_market_default import (
OFFICIAL_PLUGIN_MARKET,
_generate_plugin_market_default,
)
def _read_plugin_market_default(config_file: Path) -> str:
tree = ast.parse(config_file.read_text(encoding="utf-8"))
for node in tree.body:
if not isinstance(node, ast.ClassDef) or node.name != "ConfigModel":
continue
for item in node.body:
if not isinstance(item, ast.AnnAssign):
continue
if isinstance(item.target, ast.Name) and item.target.id == "PLUGIN_MARKET":
return ast.literal_eval(item.value)
raise AssertionError("未找到 PLUGIN_MARKET 默认值")
def test_extract_plugin_market_repos_uses_marked_section_and_deduplicates() -> None:
"""
Wiki 清单解析只读取标记区域并规范化去重仓库地址
"""
markdown = """
- https://github.com/outside/ignored
<!-- plugin-market-repos:start -->
- https://github.com/jxxghp/MoviePilot-Plugins/
- https://github.com/demo/Market.git
- https://github.com/demo/Market
<!-- plugin-market-repos:end -->
- https://github.com/outside/ignored-again
"""
assert extract_plugin_market_repos_from_wiki(
markdown, require_markers=True
) == [
OFFICIAL_PLUGIN_MARKET,
"https://github.com/demo/Market",
]
def test_extract_plugin_market_repos_requires_unique_markers_for_build() -> None:
"""
构建模式拒绝缺失或重复边界标记的 Wiki 文档
"""
with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"):
extract_plugin_market_repos_from_wiki(
f"- {OFFICIAL_PLUGIN_MARKET}", require_markers=True
)
markdown = f"""
<!-- plugin-market-repos:start -->
<!-- plugin-market-repos:start -->
- {OFFICIAL_PLUGIN_MARKET}
<!-- plugin-market-repos:end -->
"""
with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"):
extract_plugin_market_repos_from_wiki(markdown, require_markers=True)
def test_generate_plugin_market_default_updates_assignment_idempotently(
tmp_path: Path,
) -> None:
"""
生成脚本只替换 ConfigModel 默认值并保持重复执行结果一致
"""
wiki_file = tmp_path / "plugin.md"
wiki_file.write_text(
f"""
<!-- plugin-market-repos:start -->
- {OFFICIAL_PLUGIN_MARKET}
- https://github.com/demo/MoviePilot-Plugins
<!-- plugin-market-repos:end -->
""",
encoding="utf-8",
)
config_file = tmp_path / "config.py"
config_file.write_text(
"""class ConfigModel(BaseModel):
PLUGIN_MARKET: str = "https://github.com/old/Market"
OTHER_SETTING: bool = True
""",
encoding="utf-8",
)
repos = _generate_plugin_market_default(wiki_file, config_file)
first_result = config_file.read_text(encoding="utf-8")
_generate_plugin_market_default(wiki_file, config_file)
assert repos == [
OFFICIAL_PLUGIN_MARKET,
"https://github.com/demo/MoviePilot-Plugins",
]
assert _read_plugin_market_default(config_file) == ",".join(repos)
assert "OTHER_SETTING: bool = True" in first_result
assert config_file.read_text(encoding="utf-8") == first_result
def test_generate_plugin_market_default_requires_official_repo(
tmp_path: Path,
) -> None:
"""
发版默认清单缺少官方仓库时终止生成
"""
wiki_file = tmp_path / "plugin.md"
wiki_file.write_text(
"""
<!-- plugin-market-repos:start -->
- https://github.com/demo/MoviePilot-Plugins
<!-- plugin-market-repos:end -->
""",
encoding="utf-8",
)
config_file = tmp_path / "config.py"
config_file.write_text(
"""class ConfigModel(BaseModel):
PLUGIN_MARKET: str = "https://github.com/old/Market"
""",
encoding="utf-8",
)
with pytest.raises(ValueError, match="缺少 MoviePilot 官方插件仓库"):
_generate_plugin_market_default(wiki_file, config_file)
+79
View File
@@ -0,0 +1,79 @@
import threading
from unittest.mock import Mock
from app import scheduler as scheduler_module
from app.scheduler import Scheduler
class _BackgroundSchedulerStub:
"""记录系统定时任务注册结果的调度器替身。"""
def __init__(self):
"""初始化任务记录。"""
self.jobs = []
self.started = False
def add_job(self, func, trigger, **kwargs):
"""记录一次任务注册。"""
self.jobs.append({"func": func, "trigger": trigger, **kwargs})
def start(self):
"""记录调度器已启动。"""
self.started = True
def test_meta_cache_expire_does_not_schedule_bulk_cache_clear(monkeypatch):
"""单条缓存 TTL 不应再被用于注册整批缓存清理任务。"""
background_scheduler = _BackgroundSchedulerStub()
generic_chain = Mock()
for name in [
"MediaServerChain",
"RecommendChain",
"SchedulerChain",
"SiteChain",
"SubscribeChain",
"TransferChain",
"WallpaperHelper",
"WorkflowChain",
"PluginManager",
]:
monkeypatch.setattr(scheduler_module, name, lambda: generic_chain)
monkeypatch.setattr(
scheduler_module.ServiceConfigHelper,
"get_mediaserver_configs",
lambda: [],
)
monkeypatch.setattr(
scheduler_module,
"BackgroundScheduler",
lambda **kwargs: background_scheduler,
)
monkeypatch.setattr(Scheduler, "stop", lambda self: None)
monkeypatch.setattr(Scheduler, "init_workflow_jobs", lambda self: None)
monkeypatch.setattr(Scheduler, "init_agent_task_jobs", lambda self: None)
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
monkeypatch.setattr(scheduler_module.settings, "DEV", False)
monkeypatch.setattr(scheduler_module.settings, "COOKIECLOUD_INTERVAL", 0)
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_SEARCH", False)
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_MODE", "rss")
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_RSS_INTERVAL", 30)
monkeypatch.setattr(scheduler_module.settings, "SITEDATA_REFRESH_INTERVAL", 0)
monkeypatch.setattr(scheduler_module.settings, "MEMORY_GC_INTERVAL", 0)
monkeypatch.setattr(scheduler_module.settings, "AI_AGENT_ENABLE", False)
monkeypatch.setattr(scheduler_module.settings, "DATA_CLEANUP_ENABLE", False)
monkeypatch.setattr(scheduler_module.settings, "USAGE_STATISTIC_SHARE", False)
scheduler = object.__new__(Scheduler)
scheduler._scheduler = None
scheduler._event = threading.Event()
scheduler._lock = threading.RLock()
scheduler._jobs = {}
scheduler._auth_count = 0
scheduler._auth_message = False
scheduler.init()
scheduled_job_ids = {job["id"] for job in background_scheduler.jobs}
assert "clear_cache" not in scheduled_job_ids
assert "clear_cache" in scheduler._jobs
assert background_scheduler.started is True
+3
View File
@@ -138,6 +138,7 @@ class LlmTestEndpointTest(unittest.TestCase):
user_agent="MoviePilot-Test/1.0",
use_proxy=True,
api_protocol="responses",
web_search_mode="local",
)
self.assertTrue(resp.success)
self.assertEqual(resp.data["provider"], "deepseek")
@@ -190,6 +191,7 @@ class LlmTestEndpointTest(unittest.TestCase):
user_agent="MoviePilot-Custom/1.0",
use_proxy=False,
api_protocol=None,
web_search_mode=None,
)
self.assertTrue(resp.success)
self.assertEqual(resp.data["provider"], "openai")
@@ -233,6 +235,7 @@ class LlmTestEndpointTest(unittest.TestCase):
user_agent=None,
use_proxy=None,
api_protocol=None,
web_search_mode=None,
)
self.assertTrue(resp.success)
@@ -0,0 +1,46 @@
"""系统 LLM 服务端联网搜索配置测试。"""
import asyncio
from unittest.mock import patch
from app.api.endpoints import system as system_endpoint
def test_set_env_rejects_unsupported_builtin_web_search() -> None:
"""强制不可用的服务端搜索时应拒绝保存且不部分写入配置。"""
env = {
"LLM_PROVIDER": "deepseek",
"LLM_MODEL": "deepseek-chat",
"LLM_BASE_URL": "https://api.deepseek.com",
"LLM_WEB_SEARCH_MODE": "builtin",
}
with patch.object(type(system_endpoint.settings), "update_settings") as update_settings:
response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object()))
assert response.success is False
assert "不支持服务端联网搜索" in response.message
update_settings.assert_not_called()
def test_set_env_accepts_supported_deepseek_builtin_web_search() -> None:
"""DeepSeek V4 Flash 官方端点应允许保存强制服务端搜索。"""
env = {
"LLM_PROVIDER": "deepseek",
"LLM_MODEL": "deepseek-v4-flash",
"LLM_BASE_URL": "https://api.deepseek.com",
"LLM_WEB_SEARCH_MODE": "builtin",
}
with patch.object(
type(system_endpoint.settings),
"update_settings",
return_value={key: (True, None) for key in env},
) as update_settings, patch.object(
system_endpoint.eventmanager,
"async_send_event",
):
response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object()))
assert response.success is True
update_settings.assert_called_once_with(env=env)
+175
View File
@@ -1,9 +1,11 @@
import asyncio
import inspect
import pickle
from unittest.mock import Mock
from app.api.endpoints import tmdb as tmdb_endpoint
from app.db.user_oper import get_current_active_superuser_async
from app.modules.themoviedb import tmdb_cache as tmdb_cache_module
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.schemas.types import MediaType, SystemConfigKey
@@ -27,19 +29,83 @@ class _MemoryCacheStub:
"""删除指定缓存条目。"""
self.data.pop(key, None)
def set(self, key: str, value, ttl=None):
"""写入指定缓存条目。"""
self.data[key] = value
def clear(self):
"""清空全部缓存条目。"""
self.data.clear()
class _FileCacheStub:
"""提供 TMDB 持久化测试所需的统一文件缓存替身。"""
def __init__(self, content: bytes = None):
"""使用预置序列化内容初始化文件缓存。"""
self.content = content
self.set_calls = []
self.delete_calls = []
def get(self, key: str, region: str):
"""读取预置缓存内容。"""
return self.content
def set(self, key: str, value: bytes, region: str):
"""记录统一文件缓存写入。"""
self.content = value
self.set_calls.append((key, region))
def delete(self, key: str, region: str):
"""记录统一文件缓存删除。"""
self.content = None
self.delete_calls.append((key, region))
class _TTLCacheStub(_MemoryCacheStub):
"""记录每条数据恢复时剩余 TTL 的内存缓存替身。"""
def __init__(self):
"""初始化空缓存和 TTL 记录。"""
super().__init__({})
self.ttls = {}
@staticmethod
def is_redis() -> bool:
"""测试替身固定使用非 Redis 后端。"""
return False
def set(self, key: str, value, ttl=None):
"""写入缓存并记录本次设置的 TTL。"""
super().set(key, value, ttl=ttl)
self.ttls[key] = ttl
def _build_tmdb_cache(data: dict) -> TmdbCache:
"""构造绕过单例初始化的 TMDB 缓存测试实例。"""
cache = object.__new__(TmdbCache)
cache._cache = _MemoryCacheStub(data)
cache._expires_at = {key: float("inf") for key in data}
cache._dirty = False
cache._file_cache = None
cache._legacy_file_cache = None
cache._legacy_cache_found = False
cache.save = lambda force=False: None
return cache
def _build_initialized_tmdb_cache(monkeypatch, file_cache: _FileCacheStub,
runtime_cache: _TTLCacheStub,
now: float = 1000) -> TmdbCache:
"""使用可控时间和缓存替身初始化完整 TMDB 缓存实例。"""
monkeypatch.setattr(tmdb_cache_module, "time", lambda: now)
monkeypatch.setattr(tmdb_cache_module, "TTLCache", lambda **kwargs: runtime_cache)
monkeypatch.setattr(tmdb_cache_module, "FileCache", lambda **kwargs: file_cache)
cache = object.__new__(TmdbCache)
cache.__init__()
return cache
def test_tmdb_cache_management_endpoints_require_superuser():
"""识别缓存管理接口必须仅允许超级管理员访问。"""
endpoints = [
@@ -92,6 +158,115 @@ def test_tmdb_cache_delete_and_clear_persist_immediately(monkeypatch):
assert saved_forces == [True, True]
def test_tmdb_cache_restores_only_unexpired_persisted_items(monkeypatch):
"""TMDB 持久化恢复应保留每条数据原有期限并跳过已过期条目。"""
payload = {
"version": tmdb_cache_module.PERSISTENCE_VERSION,
"items": {
"fresh": {
"value": {"id": 1, "title": "有效"},
"expires_at": 1030,
},
"expired": {
"value": {"id": 2, "title": "过期"},
"expires_at": 999,
},
},
}
file_cache = _FileCacheStub(pickle.dumps(payload))
runtime_cache = _TTLCacheStub()
cache = _build_initialized_tmdb_cache(
monkeypatch=monkeypatch,
file_cache=file_cache,
runtime_cache=runtime_cache,
)
assert runtime_cache.data == {"fresh": {"id": 1, "title": "有效"}}
assert runtime_cache.ttls == {"fresh": 30}
assert cache._expires_at == {"fresh": 1030}
assert cache._dirty is True
def test_tmdb_cache_persists_individual_expiration_with_file_cache(monkeypatch):
"""TMDB 持久化应通过统一文件缓存保存每条数据的独立过期时间。"""
file_cache = _FileCacheStub()
runtime_cache = _TTLCacheStub()
cache = _build_initialized_tmdb_cache(
monkeypatch=monkeypatch,
file_cache=file_cache,
runtime_cache=runtime_cache,
)
runtime_cache.data = {
"recognized": {"id": 1, "title": "有效"},
"unrecognized": {"id": 0},
}
cache._expires_at = {
"recognized": 1060,
"unrecognized": 1070,
}
cache._dirty = True
cache.save()
payload = pickle.loads(file_cache.content)
assert file_cache.set_calls == [(
tmdb_cache_module.PERSISTENCE_KEY,
tmdb_cache_module.PERSISTENCE_REGION,
)]
assert payload == {
"version": tmdb_cache_module.PERSISTENCE_VERSION,
"items": {
"recognized": {
"value": {"id": 1, "title": "有效"},
"expires_at": 1060,
},
},
}
def test_tmdb_cache_migrates_legacy_file_to_global_file_cache(monkeypatch):
"""旧 TMDB 缓存应迁移到全局文件缓存并删除旧文件。"""
primary_cache = _FileCacheStub()
legacy_cache = _FileCacheStub(pickle.dumps({
"legacy": {"id": 1, "title": "旧缓存"},
}))
file_caches = iter([primary_cache, legacy_cache])
file_cache_calls = []
def build_file_cache(**kwargs):
"""记录全局文件缓存构造参数并返回对应替身。"""
file_cache_calls.append(kwargs)
return next(file_caches)
runtime_cache = _TTLCacheStub()
monkeypatch.setattr(tmdb_cache_module, "time", lambda: 1000)
monkeypatch.setattr(
tmdb_cache_module,
"TTLCache",
lambda **kwargs: runtime_cache,
)
monkeypatch.setattr(tmdb_cache_module, "FileCache", build_file_cache)
cache = object.__new__(TmdbCache)
cache.__init__()
cache.save()
assert file_cache_calls == [
{"base": tmdb_cache_module.settings.CACHE_PATH, "ttl": cache.ttl},
{"base": tmdb_cache_module.settings.TEMP_PATH.parent, "ttl": cache.ttl},
]
assert runtime_cache.data == {"legacy": {"id": 1, "title": "旧缓存"}}
assert primary_cache.set_calls == [(
tmdb_cache_module.PERSISTENCE_KEY,
tmdb_cache_module.PERSISTENCE_REGION,
)]
assert legacy_cache.delete_calls == [(
cache.region,
tmdb_cache_module.settings.TEMP_PATH.name,
)]
def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
"""查询接口应返回识别成功和失败条目的统计。"""
cache = _build_tmdb_cache({

Some files were not shown because too many files have changed in this diff Show More