Compare commits

...

13 Commits

Author SHA1 Message Date
jxxghp
51189210c2 更新 config.py 2026-04-22 10:39:25 +08:00
jxxghp
38933d5882 feat(agent): support disabling model thinking 2026-04-22 10:36:36 +08:00
jxxghp
4619fc4042 更新 version.py 2026-04-21 22:25:57 +08:00
jxxghp
ee7ba28235 Allow LLM test to use request payload 2026-04-21 22:14:19 +08:00
笨笨
409abb66be test: remove absolute path from llm helper test 2026-04-21 20:39:32 +08:00
笨笨
8aa8b1897b feat: add llm test endpoint 2026-04-21 20:39:32 +08:00
jxxghp
8c256d91bd refine custom identifier skill scope 2026-04-21 17:31:37 +08:00
jxxghp
d1d3fc7f30 更新 media.py 2026-04-21 14:38:16 +08:00
jxxghp
ae15eac0f8 feat: normalize internal system user ID in notification dispatch
- Add SYSTEM_INTERNAL_USER_ID constant and helpers to app.utils.identity
- Ensure internal user ID is normalized to None before dispatching notifications, preventing misrouting to external channels
- Refactor MessageChain to use normalization for all message dispatch methods
- Add tests for internal user ID normalization and notification dispatch behavior
2026-04-21 14:32:14 +08:00
jxxghp
1282ad5004 feat: improve local CLI startup management 2026-04-21 11:26:56 +08:00
笨笨
6f6fcc79f2 fix: serialize rclone folder creation during concurrent transfers 2026-04-20 21:34:35 +08:00
jxxghp
e5c64e73b5 docs: add English README 2026-04-20 19:46:34 +08:00
jxxghp
93a19b467b Add uninstall workflow to local CLI 2026-04-20 13:38:06 +08:00
27 changed files with 3212 additions and 100 deletions

View File

@@ -1,5 +1,7 @@
# MoviePilot
简体中文 | [English](README_EN.md)
![GitHub Repo stars](https://img.shields.io/github/stars/jxxghp/MoviePilot?style=for-the-badge)
![GitHub forks](https://img.shields.io/github/forks/jxxghp/MoviePilot?style=for-the-badge)
![GitHub contributors](https://img.shields.io/github/contributors/jxxghp/MoviePilot?style=for-the-badge)

77
README_EN.md Normal file
View File

@@ -0,0 +1,77 @@
# MoviePilot
[简体中文](README.md) | English
![GitHub Repo stars](https://img.shields.io/github/stars/jxxghp/MoviePilot?style=for-the-badge)
![GitHub forks](https://img.shields.io/github/forks/jxxghp/MoviePilot?style=for-the-badge)
![GitHub contributors](https://img.shields.io/github/contributors/jxxghp/MoviePilot?style=for-the-badge)
![GitHub repo size](https://img.shields.io/github/repo-size/jxxghp/MoviePilot?style=for-the-badge)
![GitHub issues](https://img.shields.io/github/issues/jxxghp/MoviePilot?style=for-the-badge)
![Docker Pulls](https://img.shields.io/docker/pulls/jxxghp/moviepilot?style=for-the-badge)
![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)
Redesigned from parts of [NAStool](https://github.com/NAStool/nas-tools), with a stronger focus on core automation scenarios while reducing issues and making the project easier to extend and maintain.
# For learning and personal communication only. Please do not promote this project on platforms in mainland China.
Release channel: https://t.me/moviepilot_channel
## Key Features
- Frontend/backend separation based on FastApi + Vue3.
- Focuses on core needs, simplifies features and settings, and allows some options to work well with sensible defaults.
- Reworked user interface for a cleaner and more practical experience.
## Installation
Official wiki: https://wiki.movie-pilot.org
## Local CLI
One-command bootstrap script:
```shell
curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootstrap-local.sh | bash
```
Manage MoviePilot with the `moviepilot` command. Full CLI documentation: [`docs/cli.md`](docs/cli.md)
## Add Skills for AI Agents
```shell
npx skills add https://github.com/jxxghp/MoviePilot
```
## Development
API documentation: https://api.movie-pilot.org
MCP tool API documentation: see [docs/mcp-api.md](docs/mcp-api.md)
Development environment setup and local source-run guide: [`docs/development-setup.md`](docs/development-setup.md)
Plugin development guide: <https://wiki.movie-pilot.org/zh/plugindev>
## Related Projects
- [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
## Disclaimer
- This software is for learning and personal communication only. It must not be used for commercial purposes or illegal activities. The software does not know how users choose to use it, and all responsibility rests with the user.
- The source code is open source and derived from other open-source code. If someone removes the relevant restrictions and redistributes or publishes modified versions that lead to liability events, the publisher of those modifications bears full responsibility. Public releases that bypass or alter the user authentication mechanism are not recommended.
- This project does not accept donations and has not published any donation page anywhere. The software itself is free of charge and does not provide paid services. Please verify information carefully to avoid being misled.
## Contributors
<a href="https://github.com/jxxghp/MoviePilot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=jxxghp/MoviePilot" />
</a>

View File

@@ -34,6 +34,7 @@ from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
from app.schemas.types import MessageChannel
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
class AgentChain(ChainBase):
@@ -543,16 +544,12 @@ class MoviePilotAgent:
"""
通过原渠道发送消息给用户
"""
user_id = self.user_id
if self.user_id == "system":
user_id = None
await AgentChain().async_post_message(
Notification(
channel=self.channel,
source=self.source,
mtype=NotificationType.Agent,
userid=user_id,
userid=self.user_id,
username=self.username,
title=title,
text=message,
@@ -853,7 +850,7 @@ class AgentManager:
try:
# 每次使用唯一的 session_id避免共享上下文
session_id = f"__agent_heartbeat_{uuid.uuid4().hex[:12]}__"
user_id = "system"
user_id = SYSTEM_INTERNAL_USER_ID
logger.info("智能体心跳唤醒:开始检查待处理任务...")
@@ -948,7 +945,7 @@ class AgentManager:
return
session_id = f"__agent_retry_transfer_batch_{uuid.uuid4().hex[:8]}__"
user_id = "system"
user_id = SYSTEM_INTERNAL_USER_ID
ids_str = ", ".join(str(i) for i in history_ids)
logger.info(
@@ -1107,7 +1104,7 @@ class AgentManager:
手动触发单条历史记录的 AI 整理。
"""
session_id = f"__agent_manual_redo_{history_id}_{uuid.uuid4().hex[:8]}__"
user_id = "system"
user_id = SYSTEM_INTERNAL_USER_ID
agent = MoviePilotAgent(
session_id=session_id,
user_id=user_id,

View File

@@ -23,7 +23,12 @@ class UpdateCustomIdentifiersInput(BaseModel):
description=(
"The complete list of custom identifier rules to save. "
"This REPLACES the entire existing list. "
"Always query existing identifiers first, merge new rules, then pass the full list."
"Always query existing identifiers first, merge new rules, then pass the full list. "
"These rules are global and affect future recognition for all torrents/files. "
"When adding a rule for a user-provided sample, prefer narrow regex patterns that include "
"sample-specific anchors such as the title alias, year, season/episode marker, group tag, "
"resolution, or other distinctive fragments. Avoid overly broad patterns like bare generic "
"tags, pure episode numbers, or common release words unless the user explicitly wants a global rule."
),
)
@@ -35,6 +40,10 @@ class UpdateCustomIdentifiersTool(MoviePilotTool):
"This tool REPLACES all existing identifier rules with the provided list. "
"IMPORTANT: Always use 'query_custom_identifiers' first to get existing rules, "
"then merge new rules into the list before calling this tool to avoid accidentally deleting existing rules. "
"IMPORTANT: New identifier rules are global. When the rule is created from a specific torrent/file name, "
"make the regex as narrow as possible and include distinctive elements from that sample so unrelated titles "
"are not affected. Prefer contextual replacements with capture groups/backreferences over bare block words "
"when a generic word like REPACK, WEB-DL, 1080p, 字幕, or a simple episode marker would otherwise match too broadly. "
"Supported rule formats (spaces around operators are required): "
"1) Block word: just the word/regex to remove; "
"2) Replacement: '被替换词 => 替换词'; "

View File

@@ -29,7 +29,7 @@ from app.db.user_oper import (
get_current_active_superuser_async,
get_current_active_user_async,
)
from app.helper.llm import LLMHelper
from app.helper.llm import LLMHelper, LLMTestError, LLMTestTimeout
from app.helper.mediaserver import MediaServerHelper
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
@@ -45,6 +45,7 @@ from app.utils.crypto import HashUtils
from app.utils.http import RequestUtils, AsyncRequestUtils
from app.utils.security import SecurityUtils
from app.utils.url import UrlUtils
from pydantic import BaseModel
from version import APP_VERSION
router = APIRouter()
@@ -52,6 +53,15 @@ router = APIRouter()
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
class LlmTestRequest(BaseModel):
enabled: Optional[bool] = None
provider: Optional[str] = None
model: Optional[str] = None
disable_thinking: Optional[bool] = None
api_key: Optional[str] = None
base_url: Optional[str] = None
def _match_nettest_prefix(url: str, prefix: str) -> bool:
"""
判断目标URL是否仍然落在允许的协议、主机、端口和路径前缀内。
@@ -259,6 +269,97 @@ def _build_nettest_rules() -> list[dict[str, Any]]:
return rules
def _build_llm_test_data(
duration_ms: Optional[int] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
) -> dict[str, Any]:
"""
构造 LLM 测试接口的基础返回数据。
"""
data = {
"provider": provider if provider is not None else settings.LLM_PROVIDER,
"model": model if model is not None else settings.LLM_MODEL,
}
if duration_ms is not None:
data["duration_ms"] = duration_ms
return data
def _normalize_llm_test_value(
value: Optional[str], *, empty_as_none: bool = False
) -> Optional[str]:
"""
清理来自前端的 LLM 测试字段。
"""
if value is None:
return None
stripped = value.strip()
if empty_as_none and not stripped:
return None
return stripped
def _build_llm_test_snapshot(payload: Optional[LlmTestRequest] = None) -> dict[str, Any]:
"""
冻结当前 LLM 测试所需配置。
优先使用前端传入的临时参数;未传入时回退到已保存配置,兼容旧调用。
"""
provider = settings.LLM_PROVIDER
model = settings.LLM_MODEL
disable_thinking = bool(getattr(settings, "LLM_DISABLE_THINKING", False))
api_key = settings.LLM_API_KEY
base_url = settings.LLM_BASE_URL
enabled = bool(settings.AI_AGENT_ENABLE)
if payload:
if payload.enabled is not None:
enabled = bool(payload.enabled)
if payload.provider is not None:
provider = _normalize_llm_test_value(payload.provider) or ""
if payload.model is not None:
model = _normalize_llm_test_value(payload.model) or ""
if payload.disable_thinking is not None:
disable_thinking = bool(payload.disable_thinking)
if payload.api_key is not None:
api_key = _normalize_llm_test_value(payload.api_key, empty_as_none=True)
if payload.base_url is not None:
base_url = _normalize_llm_test_value(payload.base_url, empty_as_none=True)
return {
"enabled": enabled,
"provider": provider,
"model": model,
"disable_thinking": disable_thinking,
"api_key": api_key,
"base_url": base_url,
}
def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str:
"""
清理错误信息中的敏感字段,避免回显密钥。
"""
if not message:
return "LLM 调用失败"
sanitized = message
if api_key:
sanitized = sanitized.replace(api_key, "***")
sanitized = re.sub(
r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)",
r"\1***",
sanitized,
)
sanitized = re.sub(
r"(?i)authorization\s*:\s*bearer\s+[^\s,;]+",
"Authorization: ***",
sanitized,
)
return sanitized
def _validate_nettest_url(url: str) -> Optional[str]:
"""
对实际请求地址做基础安全校验。
@@ -625,6 +726,77 @@ async def get_llm_models(
return schemas.Response(success=False, message=str(e))
@router.post("/llm-test", summary="测试LLM调用", response_model=schemas.Response)
async def llm_test(
payload: Annotated[Optional[LlmTestRequest], Body()] = None,
_: User = Depends(get_current_active_superuser_async),
):
"""
使用传入配置或当前已保存配置执行一次最小 LLM 调用。
"""
snapshot = _build_llm_test_snapshot(payload)
data = _build_llm_test_data(
provider=snapshot["provider"],
model=snapshot["model"],
)
if not snapshot["enabled"]:
return schemas.Response(success=False, message="请先启用智能助手", data=data)
if not snapshot["api_key"]:
return schemas.Response(
success=False,
message="请先配置 LLM API Key",
data=data,
)
if not (snapshot["model"] or "").strip():
return schemas.Response(
success=False,
message="请先配置 LLM 模型",
data=data,
)
try:
result = await LLMHelper.test_current_settings(
provider=snapshot["provider"],
model=snapshot["model"],
disable_thinking=snapshot["disable_thinking"],
api_key=snapshot["api_key"],
base_url=snapshot["base_url"],
)
if not result.get("reply_preview"):
return schemas.Response(
success=False,
message="模型响应为空",
data=_build_llm_test_data(
result.get("duration_ms"),
provider=snapshot["provider"],
model=snapshot["model"],
),
)
return schemas.Response(success=True, data=result)
except (LLMTestTimeout, TimeoutError) as err:
return schemas.Response(
success=False,
message="LLM 调用超时",
data=_build_llm_test_data(
getattr(err, "duration_ms", None),
provider=snapshot["provider"],
model=snapshot["model"],
),
)
except Exception as err:
return schemas.Response(
success=False,
message=_sanitize_llm_test_error(str(err), snapshot["api_key"]),
data=_build_llm_test_data(
getattr(err, "duration_ms", None),
provider=snapshot["provider"],
model=snapshot["model"],
),
)
@router.get("/message", summary="实时消息")
async def get_message(
request: Request,

View File

@@ -38,6 +38,7 @@ from app.schemas import (
TransferDirectoryConf,
MessageResponse,
)
from app.utils.identity import normalize_internal_user_id
from app.schemas.category import CategoryConfig
from app.schemas.types import (
TorrentStatus,
@@ -119,6 +120,21 @@ class ChainBase(metaclass=ABCMeta):
"""
self.filecache.delete(filename)
@staticmethod
def _normalize_notification_for_dispatch(
message: Notification
) -> Notification:
"""
规范化待发送的通知消息。
后台任务会复用内部占位用户ID作为会话身份这里在真正发送前清空
让消息重新走默认通知路由或基于 targets 的目标解析。
"""
dispatch_message = copy.deepcopy(message)
dispatch_message.userid = normalize_internal_user_id(
dispatch_message.userid
)
return dispatch_message
async def async_remove_cache(self, filename: str) -> None:
"""
异步删除缓存同时删除Redis和本地缓存
@@ -1119,10 +1135,13 @@ class ChainBase(metaclass=ABCMeta):
# 保存消息
self.messagehelper.put(message, role="user", title=message.title)
self.messageoper.add(**message.model_dump())
dispatch_message = self._normalize_notification_for_dispatch(message)
# 发送消息按设置隔离
if not message.userid and message.mtype:
if not dispatch_message.userid and dispatch_message.mtype:
# 消息隔离设置
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
notify_action = ServiceConfigHelper.get_notification_switch(
dispatch_message.mtype
)
if notify_action:
# 'admin' 'user,admin' 'user' 'all'
actions = notify_action.split(",")
@@ -1131,7 +1150,7 @@ class ChainBase(metaclass=ABCMeta):
send_orignal = False
useroper = UserOper()
for action in actions:
send_message = copy.deepcopy(message)
send_message = copy.deepcopy(dispatch_message)
if action == "admin" and not admin_sended:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
@@ -1186,13 +1205,13 @@ class ChainBase(metaclass=ABCMeta):
# 发送消息事件
self.eventmanager.send_event(
etype=EventType.NoticeMessage,
data={**message.model_dump(), "type": message.mtype},
data={**dispatch_message.model_dump(), "type": dispatch_message.mtype},
)
# 按原消息发送
self.messagequeue.send_message(
"post_message",
message=message,
immediately=True if message.userid else False,
message=dispatch_message,
immediately=True if dispatch_message.userid else False,
**kwargs,
)
@@ -1233,10 +1252,13 @@ class ChainBase(metaclass=ABCMeta):
# 保存消息
self.messagehelper.put(message, role="user", title=message.title)
await self.messageoper.async_add(**message.model_dump())
dispatch_message = self._normalize_notification_for_dispatch(message)
# 发送消息按设置隔离
if not message.userid and message.mtype:
if not dispatch_message.userid and dispatch_message.mtype:
# 消息隔离设置
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
notify_action = ServiceConfigHelper.get_notification_switch(
dispatch_message.mtype
)
if notify_action:
# 'admin' 'user,admin' 'user' 'all'
actions = notify_action.split(",")
@@ -1245,7 +1267,7 @@ class ChainBase(metaclass=ABCMeta):
send_orignal = False
useroper = UserOper()
for action in actions:
send_message = copy.deepcopy(message)
send_message = copy.deepcopy(dispatch_message)
if action == "admin" and not admin_sended:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
@@ -1300,13 +1322,13 @@ class ChainBase(metaclass=ABCMeta):
# 发送消息事件
await self.eventmanager.async_send_event(
etype=EventType.NoticeMessage,
data={**message.model_dump(), "type": message.mtype},
data={**dispatch_message.model_dump(), "type": dispatch_message.mtype},
)
# 按原消息发送
await self.messagequeue.async_send_message(
"post_message",
message=message,
immediately=True if message.userid else False,
message=dispatch_message,
immediately=True if dispatch_message.userid else False,
**kwargs,
)
@@ -1324,11 +1346,12 @@ class ChainBase(metaclass=ABCMeta):
message, role="user", note=note_list, title=message.title
)
self.messageoper.add(**message.model_dump(), note=note_list)
dispatch_message = self._normalize_notification_for_dispatch(message)
return self.messagequeue.send_message(
"post_medias_message",
message=message,
message=dispatch_message,
medias=medias,
immediately=True if message.userid else False,
immediately=True if dispatch_message.userid else False,
)
def post_torrents_message(
@@ -1345,11 +1368,12 @@ class ChainBase(metaclass=ABCMeta):
message, role="user", note=note_list, title=message.title
)
self.messageoper.add(**message.model_dump(), note=note_list)
dispatch_message = self._normalize_notification_for_dispatch(message)
return self.messagequeue.send_message(
"post_torrents_message",
message=message,
message=dispatch_message,
torrents=torrents,
immediately=True if message.userid else False,
immediately=True if dispatch_message.userid else False,
)
def delete_message(
@@ -1411,7 +1435,10 @@ class ChainBase(metaclass=ABCMeta):
:param message: 消息体
:return: 消息响应包含message_id, chat_id等
"""
return self.run_module("send_direct_message", message=message)
return self.run_module(
"send_direct_message",
message=self._normalize_notification_for_dispatch(message),
)
def metadata_img(
self,

View File

@@ -1320,7 +1320,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
mediainfo = await native_fn()
else:
# 原生优先
logger.info(f"插件优先模式未开启。尝试原生识别标题:{log_name} ...")
logger.info(f"识别标题:{log_name} ...")
mediainfo = await native_fn()
if not mediainfo and plugin_available:
logger.info(

View File

@@ -1,5 +1,6 @@
import json
import os
import re
import shutil
import subprocess
import sys
@@ -9,7 +10,7 @@ from pathlib import Path
from typing import Any, Dict, Iterable, Optional, get_args, get_origin
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.request import ProxyHandler, Request, build_opener, urlopen
import click
import psutil
@@ -28,7 +29,11 @@ FRONTEND_VERSION_FILE = FRONTEND_DIR / "version.txt"
HEALTH_PATH = "/api/v1/system/global"
HEALTH_TOKEN = "moviepilot"
FRONTEND_HEALTH_PATH = "/version.txt"
BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
FRONTEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases"
LOCAL_HOSTS = {"0.0.0.0", "::", "::1", "", "localhost"}
MANAGED_ACTIVE_STATES = {"running", "starting"}
AUTO_UPDATE_ENABLED_VALUES = {"true", "release", "dev"}
MASKED_FIELDS = {
"API_TOKEN",
"DB_POSTGRESQL_PASSWORD",
@@ -199,6 +204,173 @@ def _frontend_health(runtime: Optional[Dict[str, Any]] = None, timeout: float =
return False, None
def _warn(message: str) -> None:
click.secho(message, fg="yellow")
def _release_prefix(version: Optional[str]) -> str:
"""
从版本号中提取主版本前缀,用于把本地自动更新限制在当前主版本线上。
"""
matched = re.match(r"^(v\d+)", str(version or "").strip())
return matched.group(1) if matched else "v2"
def _release_sort_key(tag: str) -> tuple[int, ...]:
return tuple(int(part) for part in re.findall(r"\d+", tag))
def _github_api_json(url: str, *, repo: str) -> Any:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "MoviePilot-CLI",
}
headers.update(settings.REPO_GITHUB_HEADERS(repo))
opener = build_opener(ProxyHandler(settings.PROXY or {}))
request = Request(url=url, headers=headers, method="GET")
try:
with opener.open(request, timeout=10.0) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"访问 GitHub API 失败HTTP {exc.code}: {detail or url}") from exc
except URLError as exc:
raise RuntimeError(f"访问 GitHub API 失败:{exc.reason}") from exc
except json.JSONDecodeError as exc:
raise RuntimeError(f"GitHub API 返回了无法解析的响应:{url}") from exc
def _latest_release_tag(url: str, *, repo: str, prefix: str) -> Optional[str]:
payload = _github_api_json(url, repo=repo)
if not isinstance(payload, list):
raise RuntimeError(f"GitHub API 返回格式异常:{url}")
matched_tags = []
for item in payload:
if not isinstance(item, dict):
continue
tag_name = str(item.get("tag_name") or "").strip()
if tag_name.startswith(f"{prefix}."):
matched_tags.append(tag_name)
if not matched_tags:
return None
return sorted(matched_tags, key=_release_sort_key)[-1]
def _git_current_branch() -> Optional[str]:
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=str(_repo_root()),
text=True,
).strip()
except (OSError, subprocess.CalledProcessError):
return None
return branch or None
def _auto_update_mode() -> str:
return str(getattr(settings, "MOVIEPILOT_AUTO_UPDATE", "") or "").strip().lower()
def _resolve_auto_update_targets(mode: str) -> tuple[Optional[str], Optional[str]]:
backend_prefix = _release_prefix(APP_VERSION)
frontend_prefix = _release_prefix(_installed_frontend_version() or APP_VERSION)
if mode == "dev":
current_branch = _git_current_branch()
backend_ref = "latest"
if not current_branch or current_branch == "HEAD":
# 从 release 模式切回 dev 时detached HEAD 需要一个明确分支。
backend_ref = backend_prefix
else:
backend_ref = _latest_release_tag(
BACKEND_RELEASES_API,
repo="jxxghp/MoviePilot",
prefix=backend_prefix,
)
frontend_version = _latest_release_tag(
FRONTEND_RELEASES_API,
repo="jxxghp/MoviePilot-Frontend",
prefix=frontend_prefix,
)
return backend_ref, frontend_version
def _best_effort_auto_update() -> None:
mode = _auto_update_mode()
if mode not in AUTO_UPDATE_ENABLED_VALUES:
return
try:
backend_ref, frontend_version = _resolve_auto_update_targets(mode)
except RuntimeError as exc:
_warn(f"自动更新准备失败,继续使用当前版本启动:{exc}")
return
if not backend_ref or not frontend_version:
_warn("自动更新准备失败,未能解析当前主版本对应的远端版本,继续使用当前版本启动")
return
update_command = [
sys.executable,
str(_repo_root() / "scripts" / "local_setup.py"),
"update",
"all",
"--ref",
backend_ref,
"--frontend-version",
frontend_version,
"--venv",
str(_repo_root() / "venv"),
"--config-dir",
str(settings.CONFIG_PATH),
]
update_env = os.environ.copy()
if settings.PROXY_HOST:
update_env.setdefault("http_proxy", settings.PROXY_HOST)
update_env.setdefault("https_proxy", settings.PROXY_HOST)
update_env.setdefault("HTTP_PROXY", settings.PROXY_HOST)
update_env.setdefault("HTTPS_PROXY", settings.PROXY_HOST)
if settings.GITHUB_TOKEN:
update_env.setdefault("GITHUB_TOKEN", settings.GITHUB_TOKEN)
click.echo(f"检测到 MOVIEPILOT_AUTO_UPDATE={mode},启动前执行本地自动更新")
result = subprocess.run(
update_command,
cwd=str(_repo_root()),
env=update_env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="ignore",
check=False,
)
if result.returncode == 0:
click.echo("本地自动更新完成")
return
output_lines = [line for line in (result.stdout or "").splitlines() if line.strip()]
tail = output_lines[-1] if output_lines else "未知错误"
_warn(f"本地自动更新失败,继续使用当前版本启动:{tail}")
def _ensure_frontend_not_running_alone(timeout: int) -> None:
"""
如果只检测到 CLI 管理的前端仍在运行,则先停掉它,再按统一顺序重启前后端。
"""
backend_state, _, _, _ = _managed_backend_status()
frontend_state, _, _, _ = _managed_frontend_status()
if backend_state == "stopped" and frontend_state in MANAGED_ACTIVE_STATES:
click.echo("检测到仅前端仍在运行,先停止前端后再整体启动")
_stop_frontend_service(timeout=timeout, force=True)
def _managed_backend_status() -> tuple[str, Optional[Dict[str, Any]], Optional[psutil.Process], Optional[Dict[str, Any]]]:
runtime = _backend_runtime()
process = _get_process(runtime)
@@ -431,18 +603,27 @@ def _ensure_local_api_token() -> bool:
return result is True
def _spawn_process(command: list[str], *, cwd: Path, log_file: Path, env: Optional[Dict[str, str]] = None) -> subprocess.Popen:
log_file.parent.mkdir(parents=True, exist_ok=True)
log_handle = log_file.open("a", encoding="utf-8")
def _spawn_process(
command: list[str],
*,
cwd: Path,
log_file: Optional[Path],
env: Optional[Dict[str, str]] = None,
) -> subprocess.Popen:
kwargs: Dict[str, Any] = {
"cwd": str(cwd),
"stdout": log_handle,
"stderr": subprocess.STDOUT,
"stdin": subprocess.DEVNULL,
"close_fds": True,
"env": env or os.environ.copy(),
}
if log_file:
log_file.parent.mkdir(parents=True, exist_ok=True)
log_handle = log_file.open("a", encoding="utf-8")
kwargs["stdout"] = log_handle
kwargs["stderr"] = subprocess.STDOUT
else:
kwargs["stdout"] = subprocess.DEVNULL
kwargs["stderr"] = subprocess.DEVNULL
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
else:
@@ -454,8 +635,19 @@ def _spawn_backend_process() -> subprocess.Popen:
return _spawn_process(
[sys.executable, "-m", "app.main"],
cwd=_repo_root(),
log_file=BACKEND_STDIO_LOG_FILE,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
log_file=None,
env={
**os.environ,
"PYTHONUNBUFFERED": "1",
"MOVIEPILOT_DISABLE_CONSOLE_LOG": "1",
"MOVIEPILOT_STDIO_LOG_FILE": str(BACKEND_STDIO_LOG_FILE),
"MOVIEPILOT_STDIO_LOG_MAX_BYTES": str(
max(int(settings.LOG_MAX_FILE_SIZE or 0), 1) * 1024 * 1024
),
"MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str(
max(int(settings.LOG_BACKUP_COUNT or 0), 0)
),
},
)
@@ -649,6 +841,12 @@ def cli() -> None:
@click.option("--timeout", default=60, show_default=True, help="等待后端与前端就绪的秒数")
def start(timeout: int) -> None:
"""后台启动本地 MoviePilot 前后端服务"""
_ensure_frontend_not_running_alone(timeout=min(timeout, 15))
backend_state, _, _, _ = _managed_backend_status()
frontend_state, _, _, _ = _managed_frontend_status()
if backend_state == "stopped" and frontend_state == "stopped":
_best_effort_auto_update()
backend_result = _start_backend_service(timeout=timeout)
backend_runtime = backend_result["runtime"]
try:
@@ -699,6 +897,7 @@ def restart(start_timeout: int, stop_timeout: int, force: bool) -> None:
"""重启本地 MoviePilot 前后端服务"""
_stop_frontend_service(timeout=stop_timeout, force=force)
_stop_backend_service(timeout=stop_timeout, force=force)
_best_effort_auto_update()
backend_result = _start_backend_service(timeout=start_timeout)
frontend_result = _start_frontend_service(timeout=start_timeout, backend_port=int(backend_result["runtime"]["port"]))
click.echo("MoviePilot 已重启")

View File

@@ -496,6 +496,8 @@ class ConfigModel(BaseModel):
LLM_PROVIDER: str = "deepseek"
# LLM模型名称
LLM_MODEL: str = "deepseek-chat"
# 是否尽量关闭模型的思考/推理能力(按各 provider/model 支持情况自动适配)
LLM_DISABLE_THINKING: bool = True
# LLM是否支持图片输入开启后消息图片会按多模态输入发送给模型
LLM_SUPPORT_IMAGE_INPUT: bool = True
# LLM API密钥

View File

@@ -1,12 +1,30 @@
"""LLM模型相关辅助功能"""
import asyncio
import inspect
from typing import List
import time
from typing import Any, List
from app.core.config import settings
from app.log import logger
class LLMTestError(RuntimeError):
"""LLM 测试调用异常,附带请求耗时。"""
def __init__(self, message: str, duration_ms: int | None = None):
super().__init__(message)
self.duration_ms = duration_ms
class LLMTestTimeout(TimeoutError):
"""LLM 测试调用超时,附带请求耗时。"""
def __init__(self, message: str, duration_ms: int | None = None):
super().__init__(message)
self.duration_ms = duration_ms
def _patch_gemini_thought_signature():
"""
修复 langchain-google-genai 中 Gemini 2.5 思考模型的 thought_signature 兼容问题。
@@ -59,6 +77,68 @@ def _get_httpx_proxy_key() -> str:
class LLMHelper:
"""LLM模型相关辅助功能"""
@staticmethod
def _should_disable_thinking(disable_thinking: bool | None = None) -> bool:
"""
判断本次调用是否应尝试关闭模型思考能力。
"""
if disable_thinking is not None:
return bool(disable_thinking)
return bool(getattr(settings, "LLM_DISABLE_THINKING", False))
@staticmethod
def _normalize_model_name(model_name: str | None) -> str:
"""
统一清理模型名称,便于按模型族做能力映射。
"""
return (model_name or "").strip().lower()
@classmethod
def _build_disabled_thinking_kwargs(
cls,
provider: str,
model: str | None,
disable_thinking: bool | None = None,
) -> dict[str, Any]:
"""
按 provider/model 生成“禁用思考”相关参数。
优先使用 LangChain/OpenAI SDK 已支持的原生字段;仅在 provider
明确要求自定义请求体时,才回退到 extra_body。
"""
if not cls._should_disable_thinking(disable_thinking):
return {}
provider_name = (provider or "").strip().lower()
model_name = cls._normalize_model_name(model)
if not model_name:
return {}
# Moonshot Kimi K2.5/K2.6 需要在请求体显式声明 thinking.disabled。
if model_name.startswith(("kimi-k2.5", "kimi-k2.6")):
return {"extra_body": {"thinking": {"type": "disabled"}}}
# OpenAI 原生推理模型优先走 LangChain 内置 reasoning_effort。
if provider_name == "openai" and model_name.startswith(
("gpt-5", "o1", "o3", "o4")
):
return {"reasoning_effort": "none"}
# Gemini 使用 google-genai / langchain-google-genai 内置思考控制参数。
if provider_name == "google":
if "gemini-2.5" in model_name:
return {
"thinking_budget": 0,
"include_thoughts": False,
}
if "gemini-3" in model_name:
return {
"thinking_level": "minimal",
"include_thoughts": False,
}
return {}
@staticmethod
def supports_image_input() -> bool:
"""
@@ -67,19 +147,35 @@ class LLMHelper:
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
@staticmethod
def get_llm(streaming: bool = False):
def get_llm(
streaming: bool = False,
provider: str | None = None,
model: str | None = None,
disable_thinking: bool | None = None,
api_key: str | None = None,
base_url: str | None = None,
):
"""
获取LLM实例
:param streaming: 是否启用流式输出
:return: LLM实例
"""
provider = settings.LLM_PROVIDER.lower()
api_key = settings.LLM_API_KEY
provider_name = str(
provider if provider is not None else settings.LLM_PROVIDER
).lower()
model_name = model if model is not None else settings.LLM_MODEL
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
thinking_kwargs = LLMHelper._build_disabled_thinking_kwargs(
provider=provider_name,
model=model_name,
disable_thinking=disable_thinking,
)
if not api_key:
if not api_key_value:
raise ValueError("未配置LLM API Key")
if provider == "google":
if provider_name == "google":
# 修补 Gemini 2.5 思考模型的 thought_signature 兼容性
_patch_gemini_thought_signature()
@@ -94,36 +190,39 @@ class LLMHelper:
client_args = {proxy_key: settings.PROXY_HOST}
model = ChatGoogleGenerativeAI(
model=settings.LLM_MODEL,
api_key=api_key,
model=model_name,
api_key=api_key_value,
retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
client_args=client_args,
**thinking_kwargs,
)
elif provider == "deepseek":
elif provider_name == "deepseek":
from langchain_deepseek import ChatDeepSeek
model = ChatDeepSeek(
model=settings.LLM_MODEL,
api_key=api_key,
model=model_name,
api_key=api_key_value,
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
**thinking_kwargs,
)
else:
from langchain_openai import ChatOpenAI
model = ChatOpenAI(
model=settings.LLM_MODEL,
api_key=api_key,
model=model_name,
api_key=api_key_value,
max_retries=3,
base_url=settings.LLM_BASE_URL,
base_url=base_url_value,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
stream_usage=True,
openai_proxy=settings.PROXY_HOST,
**thinking_kwargs,
)
# 检查是否有profile
@@ -137,6 +236,95 @@ class LLMHelper:
return model
@staticmethod
def _extract_text_content(content) -> str:
"""
从响应内容中提取纯文本,仅保留真实文本块。
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for block in content:
if isinstance(block, str):
text_parts.append(block)
continue
if isinstance(block, dict) or hasattr(block, "get"):
block_type = block.get("type")
if block.get("thought") or block_type in (
"thinking",
"reasoning_content",
"reasoning",
"thought",
):
continue
if block_type == "text":
text_parts.append(block.get("text", ""))
continue
if not block_type and isinstance(block.get("text"), str):
text_parts.append(block.get("text", ""))
return "".join(text_parts)
if isinstance(content, dict) or hasattr(content, "get"):
if content.get("thought"):
return ""
if content.get("type") == "text":
return content.get("text", "")
if not content.get("type") and isinstance(content.get("text"), str):
return content.get("text", "")
return ""
@staticmethod
async def test_current_settings(
prompt: str = "请只回复 OK",
timeout: int = 20,
provider: str | None = None,
model: str | None = None,
disable_thinking: bool | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> dict:
"""
使用当前已保存配置执行一次最小 LLM 调用。
"""
provider_name = provider if provider is not None else settings.LLM_PROVIDER
model_name = model if model is not None else settings.LLM_MODEL
api_key_value = api_key if api_key is not None else settings.LLM_API_KEY
base_url_value = base_url if base_url is not None else settings.LLM_BASE_URL
start = time.perf_counter()
llm = LLMHelper.get_llm(
streaming=False,
provider=provider_name,
model=model_name,
disable_thinking=disable_thinking,
api_key=api_key_value,
base_url=base_url_value,
)
try:
response = await asyncio.wait_for(llm.ainvoke(prompt), timeout=timeout)
except TimeoutError as err:
duration_ms = round((time.perf_counter() - start) * 1000)
raise LLMTestTimeout("LLM 调用超时", duration_ms=duration_ms) from err
except Exception as err:
duration_ms = round((time.perf_counter() - start) * 1000)
raise LLMTestError(str(err), duration_ms=duration_ms) from err
reply_text = LLMHelper._extract_text_content(
getattr(response, "content", response)
).strip()
duration_ms = round((time.perf_counter() - start) * 1000)
data = {
"provider": provider_name,
"model": model_name,
"duration_ms": duration_ms,
}
if reply_text:
data["reply_preview"] = reply_text[:120]
return data
def get_models(
self, provider: str, api_key: str, base_url: str = None
) -> List[str]:

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
import os
import queue
import sys
import threading
@@ -407,11 +408,12 @@ class LoggerManager:
for handler in _logger.handlers:
_logger.removeHandler(handler)
# 只设置终端日志(文件日志由 NonBlockingFileHandler 处理)
console_handler = logging.StreamHandler()
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
console_handler.setFormatter(console_formatter)
_logger.addHandler(console_handler)
# 本地 CLI 已经有独立的 stdio 滚动日志时,不再把业务日志重复打一份到控制台。
if os.getenv("MOVIEPILOT_DISABLE_CONSOLE_LOG") != "1":
console_handler = logging.StreamHandler()
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
console_handler.setFormatter(console_formatter)
_logger.addHandler(console_handler)
# 禁止向父级log传递
_logger.propagate = False

View File

@@ -4,19 +4,32 @@ import setproctitle
import signal
import sys
import threading
from pathlib import Path
import uvicorn as uvicorn
from PIL import Image
from uvicorn import Config
from app.factory import app
from app.utils.stdio import configure_rotating_stdio
from app.utils.system import SystemUtils
# 禁用输出
if SystemUtils.is_frozen():
stdio_log_file = os.getenv("MOVIEPILOT_STDIO_LOG_FILE")
if stdio_log_file:
# 本地 CLI 会把 stdout/stderr 切到滚动日志,避免无限追加单独的大文件。
configure_rotating_stdio(
log_file=Path(stdio_log_file),
max_bytes=max(int(os.getenv("MOVIEPILOT_STDIO_LOG_MAX_BYTES", "0") or 0), 1),
backup_count=max(
int(os.getenv("MOVIEPILOT_STDIO_LOG_BACKUP_COUNT", "0") or 0),
0,
),
)
elif SystemUtils.is_frozen():
sys.stdout = open(os.devnull, 'w')
sys.stderr = open(os.devnull, 'w')
from app.factory import app
from app.core.config import settings
from app.db.init import init_db, update_db
@@ -95,4 +108,4 @@ if __name__ == '__main__':
# 更新数据库
update_db()
# 启动API服务
Server.run()
Server.run()

View File

@@ -1,7 +1,9 @@
import json
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional, List
from typing import Optional, List, Union
from app import schemas
from app.core.config import settings
@@ -11,6 +13,9 @@ from app.schemas.types import StorageSchema
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
_folder_locks: dict[str, threading.Lock] = {}
_folder_locks_guard = threading.Lock()
class Rclone(StorageBase):
"""
@@ -120,6 +125,43 @@ class Rclone(StorageBase):
modify_time=StringUtils.str_to_timestamp(item.get("ModTime"))
)
@staticmethod
def __normalize_remote_path(path: Union[Path, str]) -> str:
"""
规范化远端路径,统一目录锁键值。
"""
path_str = Path(str(path or "/")).as_posix()
if not path_str.startswith("/"):
path_str = f"/{path_str}"
if path_str != "/":
path_str = path_str.rstrip("/")
return path_str or "/"
@staticmethod
def __get_path_lock(path: Union[Path, str]) -> threading.Lock:
"""
获取指定远端路径的模块级锁。
"""
normalized = Rclone.__normalize_remote_path(path)
with _folder_locks_guard:
if normalized not in _folder_locks:
_folder_locks[normalized] = threading.Lock()
return _folder_locks[normalized]
def __wait_for_item(
self, path: Path, retries: int = 3, delay: float = 0.2
) -> Optional[schemas.FileItem]:
"""
等待目录或文件在远端可见,兼容云盘最终一致性延迟。
"""
for attempt in range(retries):
item = self.get_item(path)
if item:
return item
if attempt < retries - 1:
time.sleep(delay)
return None
def check(self) -> bool:
"""
检查存储是否可用
@@ -163,50 +205,53 @@ class Rclone(StorageBase):
:param fileitem: 父目录
:param name: 目录名
"""
path = Path(self.__normalize_remote_path(Path(fileitem.path) / name))
try:
retcode = subprocess.run(
[
'rclone', 'mkdir',
f'MP:{Path(fileitem.path) / name}'
f'MP:{path}'
],
startupinfo=self.__get_hidden_shell()
).returncode
if retcode == 0:
return self.get_item(Path(fileitem.path) / name)
folder = self.__wait_for_item(path)
if folder:
return folder
logger.warn(f"【rclone】目录 {path} 创建成功后暂未可见")
return None
folder = self.__wait_for_item(path, retries=2)
if folder:
logger.info(f"【rclone】目录 {path} 已存在,忽略重复创建")
return folder
except Exception as err:
logger.error(f"【rclone】创建目录失败{err}")
folder = self.__wait_for_item(path, retries=2)
if folder:
logger.info(f"【rclone】目录 {path} 已存在,忽略创建异常")
return folder
return None
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
"""
根据文件路程获取目录,不存在则创建
"""
def __find_dir(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]:
"""
查找下级目录中匹配名称的目录
"""
for sub_folder in self.list(_fileitem):
if sub_folder.type != "dir":
continue
if sub_folder.name == _name:
return sub_folder
return None
normalized = Path(self.__normalize_remote_path(path))
# 是否已存在
folder = self.get_item(path)
folder = self.get_item(normalized)
if folder:
return folder
# 逐级查找和创建目录
fileitem = schemas.FileItem(storage=self.schema.value, path="/")
for part in path.parts[1:]:
dir_file = __find_dir(fileitem, part)
if dir_file:
fileitem = dir_file
else:
dir_file = self.create_folder(fileitem, part)
fileitem = schemas.FileItem(storage=self.schema.value, type="dir", path="/")
for part in normalized.parts[1:]:
current_path = Path(self.__normalize_remote_path(Path(fileitem.path) / part))
with self.__get_path_lock(current_path):
dir_file = self.get_item(current_path)
if not dir_file:
logger.warn(f"【rclone】创建目录 {fileitem.path}{part} 失败!")
dir_file = self.create_folder(fileitem, part)
if not dir_file:
logger.warn(f"【rclone】创建目录 {current_path} 失败!")
return None
fileitem = dir_file
return fileitem

27
app/utils/identity.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Optional, Union
# 后台任务会话使用的内部占位用户ID。
# 它只用于在 agent/memory/session 侧标识“系统触发的任务”,
# 不能直接作为真实消息接收人下发到 Telegram/企业微信 等通知渠道。
SYSTEM_INTERNAL_USER_ID = "system"
def is_internal_user_id(userid: Optional[Union[str, int]]) -> bool:
"""
判断是否为系统内部占位用户ID。
"""
return (
isinstance(userid, str)
and userid.strip().lower() == SYSTEM_INTERNAL_USER_ID
)
def normalize_internal_user_id(
userid: Optional[Union[str, int]]
) -> Optional[Union[str, int]]:
"""
将系统内部占位用户ID归一化为 None避免被通知渠道误认为真实接收人。
"""
if is_internal_user_id(userid):
return None
return userid

84
app/utils/stdio.py Normal file
View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import io
import logging
import sys
import threading
from logging.handlers import RotatingFileHandler
from pathlib import Path
class RotatingLineStream(io.TextIOBase):
"""
将 stdout/stderr 按行写入滚动日志文件。
这里不复用业务 logger避免 stdout 日志再次回流到控制台或普通业务日志文件,
同时保证启动阶段的 print/uvicorn 输出也能按配置滚动。
"""
def __init__(self, log_file: Path, max_bytes: int, backup_count: int):
super().__init__()
self._buffer = ""
self._lock = threading.Lock()
logger_name = f"moviepilot-stdio::{log_file}"
self._logger = logging.getLogger(logger_name)
self._logger.setLevel(logging.INFO)
self._logger.propagate = False
self._logger.handlers.clear()
handler = RotatingFileHandler(
filename=str(log_file),
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(message)s"))
self._logger.addHandler(handler)
@property
def encoding(self) -> str:
return "utf-8"
def writable(self) -> bool:
return True
def isatty(self) -> bool:
return False
def write(self, message: str) -> int:
if not message:
return 0
with self._lock:
self._buffer += message.replace("\r\n", "\n")
while "\n" in self._buffer:
line, self._buffer = self._buffer.split("\n", 1)
self._logger.info(line)
return len(message)
def flush(self) -> None:
with self._lock:
if self._buffer:
self._logger.info(self._buffer)
self._buffer = ""
for handler in self._logger.handlers:
handler.flush()
def configure_rotating_stdio(
*, log_file: Path, max_bytes: int, backup_count: int
) -> RotatingLineStream:
"""
将当前进程的 stdout/stderr 统一重定向到同一个滚动日志流。
"""
log_file.parent.mkdir(parents=True, exist_ok=True)
stream = RotatingLineStream(
log_file=log_file,
max_bytes=max_bytes,
backup_count=backup_count,
)
sys.stdout = stream
sys.stderr = stream
return stream

View File

@@ -41,6 +41,8 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- macOS`~/Library/Application Support/MoviePilot`
- Linux`${XDG_CONFIG_HOME:-~/.config}/moviepilot`
如果在交互式终端中执行一键安装脚本,或直接执行 `moviepilot setup` / `moviepilot init` 且未传入 `--config-dir`,程序会先询问配置目录,并把上面的默认路径作为默认值展示出来。
可以在安装或初始化时手动指定:
```shell
@@ -62,6 +64,7 @@ moviepilot config path
- 前端本地 Node 运行时:`.runtime/node/`
- 后端日志:`<Config Dir>/logs/moviepilot.log`
- 后端启动日志:`<Config Dir>/logs/moviepilot.stdout.log`
该文件同样受 `LOG_MAX_FILE_SIZE``LOG_BACKUP_COUNT` 控制
- 前端启动日志:`<Config Dir>/logs/moviepilot.frontend.stdout.log`
## 帮助与发现
@@ -80,6 +83,7 @@ moviepilot commands
moviepilot help install
moviepilot help init
moviepilot help setup
moviepilot help uninstall
moviepilot help update
moviepilot help agent
moviepilot help config
@@ -111,9 +115,13 @@ moviepilot install frontend
moviepilot install resources
moviepilot init
moviepilot setup
moviepilot uninstall
moviepilot update backend
moviepilot update frontend
moviepilot update all
moviepilot startup enable
moviepilot startup disable
moviepilot startup status
moviepilot agent
moviepilot start
moviepilot stop
@@ -228,6 +236,8 @@ moviepilot setup --config-dir /path/to/moviepilot-config
可按需启用,并配置 `LLM_PROVIDER``LLM_MODEL``LLM_API_KEY``LLM_BASE_URL`
- 用户站点认证
可按需选择认证站点并按站点要求填写用户名、UID、Passkey 等参数
- 开机自启
可按需启用MoviePilot 会根据当前操作系统注册登录自启动
- 下载器
- 媒体服务器
- 消息通知渠道
@@ -244,6 +254,45 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
- 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码
## 开机自启命令
管理当前本地安装的开机自启:
```shell
moviepilot startup status
moviepilot startup enable
moviepilot startup disable
moviepilot startup enable --venv /path/to/venv
moviepilot startup enable --config-dir /path/to/moviepilot-config
```
说明:
- macOS 使用 `LaunchAgent`
- Linux 优先使用 `systemd --user`,当前环境不可用时自动回退到 `XDG autostart`
- Windows 使用当前用户的 Startup 启动目录
- 注册的启动项会调用本地 CLI 的统一启动入口,因此会同时拉起后端与前端
## 卸载命令
卸载本地安装产物:
```shell
moviepilot uninstall
moviepilot uninstall --venv /path/to/venv
moviepilot uninstall --config-dir /path/to/moviepilot-config
```
说明:
- 卸载时会先停止当前 CLI 管理的前后端服务
- 会删除本地虚拟环境、前端运行时、本地 Node 运行时、全局 `moviepilot` 软链接,以及同步到 `app/helper` 的资源文件
- 如果之前注册过开机自启,卸载时也会一并取消
- 会询问是否同时删除配置目录,默认不删除
- 如果当前使用的是仓库内 legacy `config/` 目录,确认删除后其中的 `category.yaml` 等配置文件也会一起删除
- 整个卸载流程包含两次确认
- 源码目录会保留,如需彻底移除仓库请在确认后手动删除项目目录
## 更新命令
更新后端:
@@ -315,10 +364,12 @@ moviepilot version
说明:
- `start` 会先启动后端,再启动前端
- 如果开启了 `MOVIEPILOT_AUTO_UPDATE=release|true|dev``start/restart` 会在启动前尽力执行一次本地自动更新;更新失败只告警,不阻断当前启动
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
- 后端默认监听 `PORT`,默认值 `3001`
- 前端通过 `service.js` 代理 `/api``/cookiecloud` 到后端
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
日志:

View File

@@ -14,7 +14,9 @@ Bootstrap Commands:
moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH]
moviepilot init [--skip-resources] [--force-token] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
moviepilot uninstall [--venv PATH] [--config-dir PATH]
moviepilot update {backend|frontend|all} [OPTIONS]
moviepilot startup {enable|disable|status} [--venv PATH] [--config-dir PATH]
moviepilot agent [OPTIONS] MESSAGE...
Runtime Commands:
@@ -27,7 +29,9 @@ Discovery Commands:
moviepilot help
moviepilot help config
moviepilot help install
moviepilot help uninstall
moviepilot help update
moviepilot help startup
moviepilot commands
Examples:
@@ -35,7 +39,9 @@ Examples:
moviepilot install frontend
moviepilot install resources
moviepilot setup --wizard
moviepilot uninstall
moviepilot update all
moviepilot startup enable
moviepilot agent 帮我分析最近一次搜索失败
moviepilot help config
moviepilot config keys
@@ -52,9 +58,13 @@ Bootstrap Commands
install resources
init
setup
uninstall
update backend
update frontend
update all
startup enable
startup disable
startup status
agent
Runtime Commands
@@ -145,6 +155,22 @@ Options:
EOF
}
show_uninstall_help() {
cat <<'EOF'
Usage: moviepilot uninstall [OPTIONS]
Options:
--venv PATH 虚拟环境目录,默认 ./venv
--config-dir PATH 指定配置目录,默认使用当前安装配置
-h, --help 显示帮助
说明:
- 默认保留配置目录,过程中会询问是否删除
- 卸载时会进行两次确认
- 源码目录不会被删除
EOF
}
show_update_help() {
cat <<'EOF'
Usage:
@@ -165,6 +191,25 @@ Options:
EOF
}
show_startup_help() {
cat <<'EOF'
Usage:
moviepilot startup enable [OPTIONS]
moviepilot startup disable [OPTIONS]
moviepilot startup status [OPTIONS]
Options:
--venv PATH 虚拟环境目录,默认 ./venv
--config-dir PATH 指定配置目录,默认使用当前安装配置
-h, --help 显示帮助
说明:
- macOS 使用 LaunchAgent
- Linux 优先使用 systemd --user不可用时回退到 XDG autostart
- Windows 使用当前用户的 Startup 启动目录
EOF
}
show_agent_help() {
cat <<'EOF'
Usage:
@@ -296,6 +341,10 @@ show_command_help() {
show_setup_help
exit 0
;;
uninstall)
show_uninstall_help
exit 0
;;
agent)
show_agent_help
exit 0
@@ -304,6 +353,10 @@ show_command_help() {
show_update_help
exit 0
;;
startup)
show_startup_help
exit 0
;;
commands)
show_commands
exit 0
@@ -397,11 +450,22 @@ case "${1:-}" in
require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@"
;;
uninstall)
shift
require_bootstrap_python
COMMAND_PATH="$(command -v moviepilot 2>/dev/null || true)"
MOVIEPILOT_LAUNCH_PATH="$0" MOVIEPILOT_COMMAND_PATH="$COMMAND_PATH" exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" uninstall "$@"
;;
update)
shift
require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" update "$@"
;;
startup)
shift
require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" startup "$@"
;;
agent)
shift
require_bootstrap_python

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,13 @@
---
name: generate-identifiers
version: 1
version: 2
description: >-
Use this skill when a user provides a torrent name or file name and wants to fix recognition issues,
or asks to add/manage custom identifiers (自定义识别词).
This skill generates identifier rules based on the WordsMatcher preprocessing logic,
checks for duplicates against existing rules, and saves them via MCP tools.
Because custom identifiers are global, generated rules must default to conservative,
sample-specific regex patterns instead of broad matches unless the user explicitly wants global cleanup.
Applicable scenarios include:
1) A torrent or file name is incorrectly recognized (wrong title, season, episode, etc.);
2) The user wants to block unwanted keywords from torrent names;
@@ -34,9 +36,11 @@ There are **four formats**. Operators must have spaces on both sides.
Removes matched text from the title. Supports regex.
```
REPACK
SomeUniqueAlias
```
Use a bare block word only when the token itself is specific enough globally, or when the user explicitly wants a global cleanup rule.
### 2. Replacement (被替换词 => 替换词)
Regex substitution. The left side is a regex pattern, the right side is the replacement (supports backreferences).
@@ -84,6 +88,40 @@ Lines starting with `#` are comments and will be skipped during processing.
5. **Chinese number support**: Episode offset handles Chinese numbers (一二三四五六七八九十).
6. **Empty replacement**: Using nothing after `=>` is equivalent to a block word.
## Global Scope Guardrails
Custom identifiers are **global**. A new rule affects all future torrent/file recognition, not just the sample provided by the user.
When generating a new rule, default to **the narrowest regex that still fixes the user's sample**:
- Extract the sample's unique anchors first: wrong title alias, year, season/episode marker, group tag, source, resolution, release tag, file extension, or other distinctive fragments.
- The matching side should usually contain **at least two meaningful anchors**, and one of them should normally be the title alias or another highly distinctive identifier from the user-provided sample.
- Prefer matching the **full wrong alias or a stable unique fragment** from the sample, not a short generic substring.
- Avoid generic global rules such as bare `1080p`, `WEB-DL`, `中字`, `国配`, `REPACK`, `S01E01`, or pure numbers unless the user explicitly wants a global cleanup rule.
- If the rule only needs to fix one specific naming pattern, prefer a **contextual replacement** with capture groups/backreferences over a bare block word.
- For episode offset rules, the `前定位词` and `后定位词` should use sample-specific context so the offset only runs on the intended naming pattern.
- For direct TMDB/Douban binding, the left side should match the user's specific wrong alias or naming pattern, not a broad season/episode pattern that could hit other media.
### Narrow vs Broad Examples
Bad (too broad for a global rule):
```
REPACK
1080p
S01E01 => {[tmdbid=12345;type=tv;s=1;e=1]}
```
Better (scoped to the user's sample pattern):
```
(\[SubGroup\].*?My\.Show.*?2024.*?)REPACK => \1
Some\.Weird\.Name(?:\.2024)?(?:\.S01E\d+)? => {[tmdbid=12345;type=tv;s=1]}
\[Baha\] <> \[1080P\] >> EP-12
```
Before saving, mentally test the rule against:
- the user's sample: it should match
- unrelated titles with common release tags: it should usually **not** match
## Workflow
### Step 1: Analyze the Problem
@@ -92,6 +130,7 @@ Parse the torrent/file name provided by the user. Identify:
- What is being incorrectly recognized (title, season, episode, year, quality, etc.)
- What the correct recognition result should be
- Which identifier format(s) will solve the problem
- Which fragments in the provided sample are unique enough to use as regex anchors, so the rule does not accidentally affect unrelated titles
### Step 2: Generate the Identifier Rule(s)
@@ -99,6 +138,7 @@ Write the rule using the appropriate format. Ensure:
- Regex special characters are properly escaped
- Add a comment line (starting with `#`) above the rule to describe what it does
- Test the regex mentally against the provided name to verify correctness
- Because the rule is global, prefer the most specific viable match; if a bare block word would be too broad, rewrite it as a contextual replacement that includes sample-specific anchors
### Step 3: Query Existing Identifiers
@@ -159,30 +199,30 @@ Tell the user:
**User**: "种子名 `My.Show.2024.REPACK.1080p.mkv`REPACK导致识别异常"
**Solution**: Block word:
**Solution**: Contextual replacement, scoped to this title pattern:
```
# 屏蔽REPACK标记
REPACK
# 仅在 My.Show.2024 命名中移除 REPACK
(My\.Show\.2024\.)REPACK(\.1080p) => \1\2
```
### Non-Standard Naming
**User**: "文件名 `[OldName] EP01.mkv`,应该识别为 NewName"
**Solution**: Replacement:
**Solution**: Replacement scoped to the wrong alias:
```
# OldName替换为NewName
OldName => NewName
# 将特定错误别名 OldName 替换为 NewName
\[OldName\] => [NewName]
```
### Force TMDB ID Recognition
**User**: "种子名 `Some.Weird.Name.S01E01.1080p.mkv`识别不到TMDB ID是12345是电视剧"
**Solution**: Direct ID specification:
**Solution**: Direct ID specification with a sample-specific alias pattern:
```
# 强制识别Some.Weird.NameTMDB ID 12345
Some\.Weird\.Name => {[tmdbid=12345;type=tv;s=1]}
# 仅在 Some.Weird.Name 这一命名模式下强制绑定 TMDB ID 12345
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;s=1]}
```
### Combined Fix
@@ -224,4 +264,5 @@ The `WordsMatcher.prepare()` method (in `app/core/meta/words.py`) processes each
- Always query existing rules first before updating
- Never remove existing rules unless the user explicitly asks
- Add comment lines before new rules for maintainability
- Remember that new rules are global. If a rule looks broad, rewrite it to include more sample-specific anchors before saving.
- When uncertain about the correct approach, present multiple options and let the user choose

View File

@@ -0,0 +1,196 @@
import asyncio
import importlib.util
import sys
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock, patch
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
class _FakeModel:
def __init__(self, content):
self._content = content
async def ainvoke(self, _prompt):
return SimpleNamespace(content=self._content)
sys.modules.pop("app.helper.llm", None)
_stub_module(
"app.core.config",
settings=SimpleNamespace(
LLM_PROVIDER="global-provider",
LLM_MODEL="global-model",
LLM_API_KEY="global-key",
LLM_BASE_URL="https://global.example.com",
LLM_DISABLE_THINKING=False,
LLM_TEMPERATURE=0.1,
LLM_MAX_CONTEXT_TOKENS=64,
PROXY_HOST=None,
),
)
_stub_module("app.log", logger=_DummyLogger())
module_path = Path(__file__).resolve().parents[1] / "app" / "helper" / "llm.py"
spec = importlib.util.spec_from_file_location("test_llm_module", module_path)
llm_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(llm_module)
class LlmHelperTestCallTest(unittest.TestCase):
def test_extract_text_content_ignores_non_text_blocks(self):
content = [
{"type": "reasoning", "text": "internal"},
{"type": "tool_use", "name": "search"},
{"type": "text", "text": "OK"},
]
result = llm_module.LLMHelper._extract_text_content(content)
self.assertEqual(result, "OK")
def test_test_current_settings_uses_explicit_snapshot(self):
fake_model = _FakeModel("OK")
get_llm_mock = Mock(return_value=fake_model)
with patch.object(llm_module.LLMHelper, "get_llm", get_llm_mock):
result = asyncio.run(
llm_module.LLMHelper.test_current_settings(
provider="deepseek",
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com",
)
)
get_llm_mock.assert_called_once_with(
streaming=False,
provider="deepseek",
model="deepseek-chat",
disable_thinking=None,
api_key="sk-test",
base_url="https://api.deepseek.com",
)
self.assertEqual(result["provider"], "deepseek")
self.assertEqual(result["model"], "deepseek-chat")
self.assertEqual(result["reply_preview"], "OK")
def test_test_current_settings_does_not_promote_non_text_blocks(self):
fake_model = _FakeModel(
[
{"type": "tool_use", "name": "lookup"},
{"type": "reasoning", "text": "thinking"},
]
)
with patch.object(llm_module.LLMHelper, "get_llm", return_value=fake_model):
result = asyncio.run(
llm_module.LLMHelper.test_current_settings(
provider="deepseek",
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com",
)
)
self.assertNotIn("reply_preview", result)
def test_get_llm_uses_kimi_extra_body_to_disable_thinking(self):
calls = []
class _FakeChatOpenAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)},
):
llm_module.LLMHelper.get_llm(
provider="openai",
model="kimi-k2.6",
disable_thinking=True,
api_key="sk-test",
base_url="https://kimi.example.com/v1",
)
self.assertEqual(len(calls), 1)
self.assertEqual(
calls[0].get("extra_body"),
{"thinking": {"type": "disabled"}},
)
def test_get_llm_uses_openai_reasoning_effort_none(self):
calls = []
class _FakeChatOpenAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)},
):
llm_module.LLMHelper.get_llm(
provider="openai",
model="gpt-5-mini",
disable_thinking=True,
api_key="sk-test",
base_url="https://api.openai.com/v1",
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("reasoning_effort"), "none")
def test_get_llm_uses_gemini_builtin_thinking_controls(self):
calls = []
class _FakeChatGoogleGenerativeAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{
"langchain_google_genai": SimpleNamespace(
ChatGoogleGenerativeAI=_FakeChatGoogleGenerativeAI
)
},
):
llm_module.LLMHelper.get_llm(
provider="google",
model="gemini-2.5-flash",
disable_thinking=True,
api_key="sk-test",
base_url=None,
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("thinking_budget"), 0)
self.assertFalse(calls[0].get("include_thoughts"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import importlib.util
import unittest
import uuid
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
def load_local_setup_module():
module_name = f"moviepilot_local_setup_config_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class LocalSetupConfigDirTests(unittest.TestCase):
def test_setup_prompts_for_config_dir_when_not_provided(self):
module = load_local_setup_module()
default_dir = Path("/tmp/default-moviepilot-config")
custom_dir = Path("/tmp/custom-moviepilot-config")
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module, "resolve_config_dir", return_value=default_dir
), patch.object(
module, "_prompt_path", return_value=str(custom_dir)
):
result = module._resolve_interactive_config_dir("setup", None)
self.assertEqual(result, custom_dir)
def test_setup_keeps_default_config_dir_when_user_accepts_default(self):
module = load_local_setup_module()
default_dir = Path("/tmp/default-moviepilot-config")
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module, "resolve_config_dir", return_value=default_dir
), patch.object(
module, "_prompt_path", return_value=str(default_dir)
):
result = module._resolve_interactive_config_dir("init", None)
self.assertEqual(result, default_dir)
def test_non_setup_command_does_not_prompt_for_config_dir(self):
module = load_local_setup_module()
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module, "_prompt_path"
) as prompt_mock:
result = module._resolve_interactive_config_dir("install-deps", None)
self.assertIsNone(result)
prompt_mock.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
import uuid
from contextlib import ExitStack
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
def load_local_setup_module():
module_name = f"moviepilot_local_setup_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class LocalSetupUninstallTests(unittest.TestCase):
def prepare_install_tree(self, *, legacy_config: bool = False):
module = load_local_setup_module()
temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
temp_path = Path(temp_dir.name)
root_dir = temp_path / "MoviePilot"
helper_dir = root_dir / "app" / "helper"
runtime_dir = root_dir / ".runtime"
public_dir = root_dir / "public"
venv_dir = root_dir / "venv"
install_env_file = root_dir / ".moviepilot.env"
config_dir = root_dir / "config" if legacy_config else temp_path / "moviepilot-config"
temp_config_dir = config_dir / "temp"
helper_dir.mkdir(parents=True)
runtime_dir.mkdir(parents=True)
public_dir.mkdir(parents=True)
venv_dir.mkdir(parents=True)
temp_config_dir.mkdir(parents=True)
install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8")
(root_dir / "moviepilot").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
(helper_dir / "sites.py").write_text("generated\n", encoding="utf-8")
(helper_dir / "user.sites.v2.bin").write_bytes(b"binary")
(temp_config_dir / "moviepilot.runtime.json").write_text("{}", encoding="utf-8")
(temp_config_dir / "moviepilot.frontend.runtime.json").write_text(
"{}", encoding="utf-8"
)
stack = ExitStack()
self.addCleanup(stack.close)
stack.enter_context(patch.object(module, "ROOT", root_dir))
stack.enter_context(patch.object(module, "HELPER_DIR", helper_dir))
stack.enter_context(patch.object(module, "RUNTIME_DIR", runtime_dir))
stack.enter_context(patch.object(module, "PUBLIC_DIR", public_dir))
stack.enter_context(patch.object(module, "INSTALL_ENV_FILE", install_env_file))
stack.enter_context(patch.object(module, "LEGACY_CONFIG_DIR", root_dir / "config"))
stack.enter_context(patch.object(module, "CONFIG_DIR", config_dir))
stack.enter_context(patch.object(module, "TEMP_DIR", temp_config_dir))
return module, root_dir, config_dir, venv_dir, install_env_file
def test_remove_config_data_deletes_legacy_config_directory(self):
module, _, config_dir, _, _ = self.prepare_install_tree(legacy_config=True)
category_file = config_dir / "category.yaml"
category_file.write_text("seed\n", encoding="utf-8")
(config_dir / "logs").mkdir(exist_ok=True)
(config_dir / "user.db").write_text("db\n", encoding="utf-8")
removed = module._remove_config_data(config_dir)
self.assertFalse(config_dir.exists())
self.assertFalse(category_file.exists())
self.assertIn(
str(config_dir.resolve()),
{str(path.resolve()) for path in removed},
)
def test_uninstall_keeps_config_by_default(self):
module, root_dir, config_dir, venv_dir, install_env_file = self.prepare_install_tree()
cli_dir = root_dir.parent / "bin"
cli_dir.mkdir()
cli_link = cli_dir / "moviepilot"
cli_link.symlink_to(root_dir / "moviepilot")
yes_no_answers = iter([False, True])
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module,
"_prompt_yes_no",
side_effect=lambda label, default=False: next(yes_no_answers),
), patch.object(
module,
"_prompt_text",
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
), patch.object(module, "_stop_managed_services", return_value=None):
result = module.uninstall_local(
venv_dir=venv_dir,
config_dir=config_dir,
launch_path=str(cli_link),
)
self.assertFalse(result["cancelled"])
self.assertTrue(config_dir.exists())
self.assertTrue(install_env_file.exists())
self.assertFalse(venv_dir.exists())
self.assertFalse((root_dir / ".runtime").exists())
self.assertFalse((root_dir / "public").exists())
self.assertFalse((root_dir / "app" / "helper" / "sites.py").exists())
self.assertFalse((root_dir / "app" / "helper" / "user.sites.v2.bin").exists())
self.assertFalse(cli_link.exists())
def test_uninstall_deletes_external_config_when_requested(self):
module, _, config_dir, venv_dir, install_env_file = self.prepare_install_tree()
yes_no_answers = iter([True, True])
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module,
"_prompt_yes_no",
side_effect=lambda label, default=False: next(yes_no_answers),
), patch.object(
module,
"_prompt_text",
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
), patch.object(module, "_stop_managed_services", return_value=None):
result = module.uninstall_local(
venv_dir=venv_dir,
config_dir=config_dir,
)
self.assertFalse(result["cancelled"])
self.assertTrue(result["config_deleted"])
self.assertFalse(config_dir.exists())
self.assertFalse(install_env_file.exists())
def test_uninstall_deletes_legacy_config_when_requested(self):
module, _, config_dir, venv_dir, install_env_file = self.prepare_install_tree(
legacy_config=True
)
(config_dir / "category.yaml").write_text("seed\n", encoding="utf-8")
yes_no_answers = iter([True, True])
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module,
"_prompt_yes_no",
side_effect=lambda label, default=False: next(yes_no_answers),
), patch.object(
module,
"_prompt_text",
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
), patch.object(module, "_stop_managed_services", return_value=None):
result = module.uninstall_local(
venv_dir=venv_dir,
config_dir=config_dir,
)
self.assertFalse(result["cancelled"])
self.assertTrue(result["config_deleted"])
self.assertFalse(config_dir.exists())
self.assertFalse(install_env_file.exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,205 @@
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
from typing import Union
from unittest.mock import patch
from app import schemas
from app.modules.filemanager.storages import rclone as rclone_module
from app.modules.filemanager.storages.rclone import Rclone
class RcloneStorageTest(unittest.TestCase):
def setUp(self):
with rclone_module._folder_locks_guard:
rclone_module._folder_locks.clear()
@staticmethod
def _normalize(path: Union[Path, str]) -> str:
return Rclone._Rclone__normalize_remote_path(path)
def _make_dir_item(self, path: Union[Path, str]) -> schemas.FileItem:
normalized = self._normalize(path)
name = Path(normalized).name or "/"
return schemas.FileItem(
storage="rclone",
type="dir",
path="/" if normalized == "/" else f"{normalized}/",
name=name,
basename=name,
)
def test_get_folder_serializes_same_target_directory_creation(self):
storage = Rclone()
thread_count = 4
start_event = threading.Event()
missing_barrier = threading.Barrier(thread_count)
state_lock = threading.Lock()
existing_paths = {"/"}
mkdir_calls = []
results = []
errors = []
def fake_get_item(_self, path: Path):
normalized = self._normalize(path)
with state_lock:
exists = normalized in existing_paths
if not exists and normalized == "/Show":
try:
missing_barrier.wait(timeout=0.1)
except threading.BrokenBarrierError:
pass
with state_lock:
exists = normalized in existing_paths
if exists:
return self._make_dir_item(normalized)
return None
def fake_run(cmd, *args, **kwargs):
target = self._normalize(cmd[-1].removeprefix("MP:"))
with state_lock:
mkdir_calls.append(target)
existing_paths.add(target)
return SimpleNamespace(returncode=0)
def worker():
try:
start_event.wait()
results.append(storage.get_folder(Path("/Show/Season 1")))
except Exception as err: # pragma: no cover - 仅用于调试失败
errors.append(err)
threads = [threading.Thread(target=worker) for _ in range(thread_count)]
with patch.object(Rclone, "get_item", autospec=True, side_effect=fake_get_item):
with patch(
"app.modules.filemanager.storages.rclone.subprocess.run",
side_effect=fake_run,
):
for thread in threads:
thread.start()
start_event.set()
for thread in threads:
thread.join(timeout=1)
self.assertFalse(errors)
self.assertTrue(all(not thread.is_alive() for thread in threads))
self.assertEqual(thread_count, len(results))
self.assertTrue(all(result and result.path == "/Show/Season 1/" for result in results))
self.assertEqual(1, mkdir_calls.count("/Show"))
self.assertEqual(1, mkdir_calls.count("/Show/Season 1"))
def test_get_folder_serializes_shared_parent_creation(self):
storage = Rclone()
thread_count = 4
start_event = threading.Event()
missing_barrier = threading.Barrier(thread_count)
state_lock = threading.Lock()
existing_paths = {"/"}
mkdir_calls = []
results = []
errors = []
targets = [
Path("/Show/Season 1"),
Path("/Show/Season 2"),
Path("/Show/Season 1"),
Path("/Show/Season 2"),
]
def fake_get_item(_self, path: Path):
normalized = self._normalize(path)
with state_lock:
exists = normalized in existing_paths
if not exists and normalized == "/Show":
try:
missing_barrier.wait(timeout=0.1)
except threading.BrokenBarrierError:
pass
with state_lock:
exists = normalized in existing_paths
if exists:
return self._make_dir_item(normalized)
return None
def fake_run(cmd, *args, **kwargs):
target = self._normalize(cmd[-1].removeprefix("MP:"))
with state_lock:
mkdir_calls.append(target)
existing_paths.add(target)
return SimpleNamespace(returncode=0)
def worker(target: Path):
try:
start_event.wait()
results.append(storage.get_folder(target))
except Exception as err: # pragma: no cover - 仅用于调试失败
errors.append(err)
threads = [threading.Thread(target=worker, args=(target,)) for target in targets]
with patch.object(Rclone, "get_item", autospec=True, side_effect=fake_get_item):
with patch(
"app.modules.filemanager.storages.rclone.subprocess.run",
side_effect=fake_run,
):
for thread in threads:
thread.start()
start_event.set()
for thread in threads:
thread.join(timeout=1)
self.assertFalse(errors)
self.assertTrue(all(not thread.is_alive() for thread in threads))
self.assertEqual(4, len(results))
self.assertEqual(1, mkdir_calls.count("/Show"))
self.assertEqual(1, mkdir_calls.count("/Show/Season 1"))
self.assertEqual(1, mkdir_calls.count("/Show/Season 2"))
def test_create_folder_retries_visibility_after_successful_mkdir(self):
storage = Rclone()
expected = self._make_dir_item("/Show")
responses = [None, expected]
def fake_get_item(_self, path: Path):
return responses.pop(0)
with patch.object(Rclone, "get_item", autospec=True, side_effect=fake_get_item):
with patch(
"app.modules.filemanager.storages.rclone.subprocess.run",
return_value=SimpleNamespace(returncode=0),
) as run_mock:
with patch("app.modules.filemanager.storages.rclone.time.sleep", return_value=None):
folder = storage.create_folder(
schemas.FileItem(storage="rclone", type="dir", path="/"),
"Show",
)
self.assertEqual("/Show/", folder.path)
run_mock.assert_called_once()
def test_create_folder_accepts_existing_directory_after_failed_mkdir(self):
storage = Rclone()
expected = self._make_dir_item("/Show")
responses = [None, expected]
def fake_get_item(_self, path: Path):
return responses.pop(0)
with patch.object(Rclone, "get_item", autospec=True, side_effect=fake_get_item):
with patch(
"app.modules.filemanager.storages.rclone.subprocess.run",
return_value=SimpleNamespace(returncode=1),
) as run_mock:
with patch("app.modules.filemanager.storages.rclone.time.sleep", return_value=None):
folder = storage.create_folder(
schemas.FileItem(storage="rclone", type="dir", path="/"),
"Show",
)
self.assertEqual("/Show/", folder.path)
run_mock.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,247 @@
import asyncio
import sys
import unittest
from types import ModuleType
from unittest.mock import AsyncMock, patch
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _Dummy:
def __init__(self, *args, **kwargs):
pass
def __getattr__(self, _name):
return lambda *args, **kwargs: None
class _DummyError(Exception):
def __init__(self, message="", duration_ms=None):
super().__init__(message)
self.duration_ms = duration_ms
for _module_name in ("pillow_avif", "aiofiles", "psutil"):
_stub_module(_module_name)
_stub_module("app.helper.sites", SitesHelper=_Dummy)
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
_stub_module("app.chain.search", SearchChain=_Dummy)
_stub_module("app.chain.system", SystemChain=_Dummy)
_stub_module("app.core.event", eventmanager=_Dummy())
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
_stub_module("app.core.module", ModuleManager=_Dummy)
_stub_module(
"app.core.security",
verify_apitoken=_Dummy,
verify_resource_token=_Dummy,
verify_token=_Dummy,
)
_stub_module("app.db.models", User=_Dummy)
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_Dummy)
_stub_module(
"app.db.user_oper",
get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy,
get_current_active_user_async=_Dummy,
)
_stub_module(
"app.helper.llm",
LLMHelper=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
)
_stub_module("app.helper.mediaserver", MediaServerHelper=_Dummy)
_stub_module("app.helper.message", MessageHelper=_Dummy)
_stub_module("app.helper.progress", ProgressHelper=_Dummy)
_stub_module("app.helper.rule", RuleHelper=_Dummy)
_stub_module("app.helper.subscribe", SubscribeHelper=_Dummy)
_stub_module("app.helper.system", SystemHelper=_Dummy)
_stub_module("app.helper.image", ImageHelper=_Dummy)
_stub_module("app.scheduler", Scheduler=_Dummy)
_stub_module(
"app.log",
logger=_Dummy(),
log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("app.utils.crypto", HashUtils=_Dummy)
_stub_module("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy)
_stub_module("version", APP_VERSION="test")
from app.api.endpoints import system as system_endpoint
class LlmTestEndpointTest(unittest.TestCase):
def test_llm_test_requires_ai_agent_enabled(self):
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False):
resp = asyncio.run(system_endpoint.llm_test(_="token"))
self.assertFalse(resp.success)
self.assertEqual(resp.message, "请先启用智能助手")
def test_llm_test_requires_api_key(self):
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
system_endpoint.settings, "LLM_API_KEY", None
), patch.object(system_endpoint.settings, "LLM_MODEL", "deepseek-chat"):
resp = asyncio.run(system_endpoint.llm_test(_="token"))
self.assertFalse(resp.success)
self.assertEqual(resp.message, "请先配置 LLM API Key")
self.assertEqual(resp.data["model"], "deepseek-chat")
def test_llm_test_requires_model(self):
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
system_endpoint.settings, "LLM_API_KEY", "sk-test"
), patch.object(system_endpoint.settings, "LLM_MODEL", ""):
resp = asyncio.run(system_endpoint.llm_test(_="token"))
self.assertFalse(resp.success)
self.assertEqual(resp.message, "请先配置 LLM 模型")
def test_llm_test_returns_successful_reply_preview(self):
llm_test_mock = AsyncMock(
return_value={
"provider": "deepseek",
"model": "deepseek-chat",
"duration_ms": 321,
"reply_preview": "OK",
}
)
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
system_endpoint.settings, "LLM_PROVIDER", "deepseek"
), patch.object(system_endpoint.settings, "LLM_MODEL", "deepseek-chat"), patch.object(
system_endpoint.settings, "LLM_API_KEY", "sk-test"
), patch.object(
system_endpoint.settings, "LLM_BASE_URL", "https://api.deepseek.com"
), patch.object(
system_endpoint.LLMHelper,
"test_current_settings",
llm_test_mock,
create=True,
):
resp = asyncio.run(system_endpoint.llm_test(_="token"))
llm_test_mock.assert_awaited_once_with(
provider="deepseek",
model="deepseek-chat",
disable_thinking=False,
api_key="sk-test",
base_url="https://api.deepseek.com",
)
self.assertTrue(resp.success)
self.assertEqual(resp.data["provider"], "deepseek")
self.assertEqual(resp.data["model"], "deepseek-chat")
self.assertEqual(resp.data["duration_ms"], 321)
self.assertEqual(resp.data["reply_preview"], "OK")
def test_llm_test_prefers_request_payload_over_saved_settings(self):
llm_test_mock = AsyncMock(
return_value={
"provider": "openai",
"model": "gpt-4.1-mini",
"duration_ms": 123,
"reply_preview": "OK",
}
)
payload = system_endpoint.LlmTestRequest(
enabled=True,
provider="openai",
model="gpt-4.1-mini",
disable_thinking=True,
api_key="sk-live",
base_url="https://example.com/v1",
)
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", False), patch.object(
system_endpoint.settings, "LLM_PROVIDER", "deepseek"
), patch.object(system_endpoint.settings, "LLM_MODEL", "deepseek-chat"), patch.object(
system_endpoint.settings, "LLM_API_KEY", "sk-saved"
), patch.object(
system_endpoint.settings, "LLM_BASE_URL", "https://api.deepseek.com"
), patch.object(
system_endpoint.LLMHelper,
"test_current_settings",
llm_test_mock,
create=True,
):
resp = asyncio.run(system_endpoint.llm_test(payload=payload, _="token"))
llm_test_mock.assert_awaited_once_with(
provider="openai",
model="gpt-4.1-mini",
disable_thinking=True,
api_key="sk-live",
base_url="https://example.com/v1",
)
self.assertTrue(resp.success)
self.assertEqual(resp.data["provider"], "openai")
self.assertEqual(resp.data["model"], "gpt-4.1-mini")
def test_llm_test_rejects_empty_reply(self):
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
system_endpoint.settings, "LLM_PROVIDER", "deepseek"
), patch.object(system_endpoint.settings, "LLM_MODEL", "deepseek-chat"), patch.object(
system_endpoint.settings, "LLM_API_KEY", "sk-test"
), patch.object(
system_endpoint.LLMHelper,
"test_current_settings",
AsyncMock(return_value={"provider": "deepseek", "model": "deepseek-chat", "duration_ms": 12}),
create=True,
):
resp = asyncio.run(system_endpoint.llm_test(_="token"))
self.assertFalse(resp.success)
self.assertEqual(resp.message, "模型响应为空")
self.assertEqual(resp.data["duration_ms"], 12)
def test_llm_test_maps_timeout_error(self):
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
system_endpoint.settings, "LLM_PROVIDER", "deepseek"
), patch.object(system_endpoint.settings, "LLM_MODEL", "deepseek-chat"), patch.object(
system_endpoint.settings, "LLM_API_KEY", "sk-test"
), patch.object(
system_endpoint.LLMHelper,
"test_current_settings",
AsyncMock(side_effect=TimeoutError("request timed out")),
create=True,
):
resp = asyncio.run(system_endpoint.llm_test(_="token"))
self.assertFalse(resp.success)
self.assertEqual(resp.message, "LLM 调用超时")
def test_llm_test_sanitizes_error_message(self):
raw_error = (
"request failed api_key=sk-secret "
"Authorization: Bearer sk-secret "
"base error sk-secret"
)
with patch.object(system_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
system_endpoint.settings, "LLM_API_KEY", "sk-secret"
), patch.object(system_endpoint.settings, "LLM_PROVIDER", "deepseek"), patch.object(
system_endpoint.settings, "LLM_MODEL", "deepseek-chat"
), patch.object(
system_endpoint.LLMHelper,
"test_current_settings",
AsyncMock(side_effect=RuntimeError(raw_error)),
create=True,
):
resp = asyncio.run(system_endpoint.llm_test(_="token"))
self.assertFalse(resp.success)
self.assertNotIn("sk-secret", resp.message)
self.assertNotIn("Authorization: Bearer", resp.message)
self.assertIn("***", resp.message)
if __name__ == "__main__":
unittest.main()

View File

@@ -19,6 +19,15 @@ class _Dummy:
def __init__(self, *args, **kwargs):
pass
def __getattr__(self, _name):
return lambda *args, **kwargs: None
class _DummyError(Exception):
def __init__(self, message="", duration_ms=None):
super().__init__(message)
self.duration_ms = duration_ms
for _module_name in ("pillow_avif", "aiofiles", "psutil"):
_stub_module(_module_name)
@@ -44,7 +53,12 @@ _stub_module(
get_current_active_superuser_async=_Dummy,
get_current_active_user_async=_Dummy,
)
_stub_module("app.helper.llm", LLMHelper=_Dummy)
_stub_module(
"app.helper.llm",
LLMHelper=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
)
_stub_module("app.helper.mediaserver", MediaServerHelper=_Dummy)
_stub_module("app.helper.message", MessageHelper=_Dummy)
_stub_module("app.helper.progress", ProgressHelper=_Dummy)
@@ -53,6 +67,12 @@ _stub_module("app.helper.subscribe", SubscribeHelper=_Dummy)
_stub_module("app.helper.system", SystemHelper=_Dummy)
_stub_module("app.helper.image", ImageHelper=_Dummy)
_stub_module("app.scheduler", Scheduler=_Dummy)
_stub_module(
"app.log",
logger=_Dummy(),
log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("app.utils.crypto", HashUtils=_Dummy)
_stub_module("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy)
_stub_module("version", APP_VERSION="test")

View File

@@ -0,0 +1,70 @@
import sys
import unittest
from types import ModuleType
from unittest.mock import patch
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
setattr(sys.modules["qbittorrentapi"], "TorrentFilesList", list)
sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc"))
setattr(sys.modules["transmission_rpc"], "File", object)
sys.modules.setdefault("psutil", ModuleType("psutil"))
from app.chain.message import MessageChain
from app.schemas import Notification
from app.utils.identity import (
SYSTEM_INTERNAL_USER_ID,
is_internal_user_id,
normalize_internal_user_id,
)
class TestSystemNotificationDispatch(unittest.TestCase):
def test_internal_userid_identity_helpers(self):
self.assertTrue(is_internal_user_id(SYSTEM_INTERNAL_USER_ID))
self.assertTrue(is_internal_user_id(" System "))
self.assertIsNone(normalize_internal_user_id(SYSTEM_INTERNAL_USER_ID))
self.assertEqual(normalize_internal_user_id("10001"), "10001")
def test_post_message_normalizes_internal_userid_before_queueing(self):
chain = MessageChain()
message = Notification(
userid=SYSTEM_INTERNAL_USER_ID,
username="admin",
title="后台报告",
text="任务完成",
)
with patch("app.chain.MessageTemplateHelper.render", return_value=message), patch.object(
chain.messagehelper, "put"
), patch.object(chain.messageoper, "add"), patch.object(
chain.eventmanager, "send_event"
) as send_event, patch.object(
chain.messagequeue, "send_message"
) as send_message:
chain.post_message(message)
event_payload = send_event.call_args.kwargs["data"]
queued_message = send_message.call_args.kwargs["message"]
self.assertIsNone(event_payload["userid"])
self.assertIsNone(queued_message.userid)
self.assertFalse(send_message.call_args.kwargs["immediately"])
def test_send_direct_message_normalizes_internal_userid(self):
chain = MessageChain()
message = Notification(
userid=SYSTEM_INTERNAL_USER_ID,
username="admin",
title="后台报告",
text="任务完成",
)
with patch.object(chain, "run_module") as run_module:
chain.send_direct_message(message)
sent_message = run_module.call_args.kwargs["message"]
self.assertIsNone(sent_message.userid)
if __name__ == "__main__":
unittest.main()

View File

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