mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-14 07:26:50 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51189210c2 | ||
|
|
38933d5882 | ||
|
|
4619fc4042 | ||
|
|
ee7ba28235 | ||
|
|
409abb66be | ||
|
|
8aa8b1897b | ||
|
|
8c256d91bd | ||
|
|
d1d3fc7f30 | ||
|
|
ae15eac0f8 | ||
|
|
1282ad5004 | ||
|
|
6f6fcc79f2 | ||
|
|
e5c64e73b5 | ||
|
|
93a19b467b |
@@ -1,5 +1,7 @@
|
||||
# MoviePilot
|
||||
|
||||
简体中文 | [English](README_EN.md)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
77
README_EN.md
Normal file
77
README_EN.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# MoviePilot
|
||||
|
||||
[简体中文](README.md) | English
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '被替换词 => 替换词'; "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
217
app/cli.py
217
app/cli.py
@@ -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 已重启")
|
||||
|
||||
@@ -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密钥
|
||||
|
||||
@@ -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]:
|
||||
|
||||
12
app/log.py
12
app/log.py
@@ -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
|
||||
|
||||
19
app/main.py
19
app/main.py
@@ -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()
|
||||
|
||||
@@ -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
27
app/utils/identity.py
Normal 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
84
app/utils/stdio.py
Normal 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
|
||||
51
docs/cli.md
51
docs/cli.md
@@ -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` 到后端
|
||||
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
|
||||
|
||||
日志:
|
||||
|
||||
|
||||
64
moviepilot
64
moviepilot
@@ -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
@@ -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.Name为TMDB 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
|
||||
|
||||
196
tests/test_llm_helper_testcall.py
Normal file
196
tests/test_llm_helper_testcall.py
Normal 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()
|
||||
63
tests/test_local_setup_config_dir.py
Normal file
63
tests/test_local_setup_config_dir.py
Normal 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()
|
||||
167
tests/test_local_setup_uninstall.py
Normal file
167
tests/test_local_setup_uninstall.py
Normal 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()
|
||||
205
tests/test_rclone_storage.py
Normal file
205
tests/test_rclone_storage.py
Normal 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()
|
||||
247
tests/test_system_llm_test.py
Normal file
247
tests/test_system_llm_test.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
70
tests/test_system_notification_dispatch.py
Normal file
70
tests/test_system_notification_dispatch.py
Normal 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()
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.10.2'
|
||||
FRONTEND_VERSION = 'v2.10.2'
|
||||
APP_VERSION = 'v2.10.3'
|
||||
FRONTEND_VERSION = 'v2.10.3'
|
||||
|
||||
Reference in New Issue
Block a user