mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-28 19:21:47 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6db0a86f6 | ||
|
|
6e8bce3d04 | ||
|
|
ed1e31d379 | ||
|
|
3a233014de | ||
|
|
13cb1683ff | ||
|
|
ac9132cba6 |
@@ -53,7 +53,7 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
|
|||||||
清理错误信息中的敏感字段,避免回显密钥。
|
清理错误信息中的敏感字段,避免回显密钥。
|
||||||
"""
|
"""
|
||||||
if not message:
|
if not message:
|
||||||
return "LLM 调用失败"
|
return "LLM 没有返回任何内容"
|
||||||
|
|
||||||
sanitized = message
|
sanitized = message
|
||||||
if api_key:
|
if api_key:
|
||||||
@@ -68,6 +68,14 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
|
|||||||
"Authorization: ***",
|
"Authorization: ***",
|
||||||
sanitized,
|
sanitized,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
normalized_message = sanitized.lower().replace("_", "").replace(" ", "")
|
||||||
|
if "str" in normalized_message and "modeldump" in normalized_message:
|
||||||
|
return (
|
||||||
|
"服务返回内容不是兼容的模型响应,"
|
||||||
|
"请检查基础地址是否填写为 API Base URL,不要填写网页地址或完整的 "
|
||||||
|
"chat/completions 路径"
|
||||||
|
)
|
||||||
return sanitized
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ def plugin_dashboard(
|
|||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
user_agent: Annotated[str | None, Header()] = None,
|
user_agent: Annotated[str | None, Header()] = None,
|
||||||
_: User = Depends(get_current_active_superuser),
|
_: User = Depends(get_current_active_superuser),
|
||||||
) -> schemas.PluginDashboard:
|
) -> Optional[schemas.PluginDashboard]:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件仪表板
|
根据插件ID获取插件仪表板
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Union, Annotated
|
from typing import Any, Optional, Union, Annotated
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
@@ -62,6 +66,8 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
|
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
|
||||||
|
_LOG_DOWNLOAD_LIMIT = 10
|
||||||
|
_LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
|
||||||
def _match_nettest_prefix(url: str, prefix: str) -> bool:
|
def _match_nettest_prefix(url: str, prefix: str) -> bool:
|
||||||
@@ -285,6 +291,98 @@ def _build_nettest_rules() -> list[dict[str, Any]]:
|
|||||||
return rules
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_named_log_files(name: str) -> list[Path]:
|
||||||
|
"""
|
||||||
|
根据前端传入的日志标识收集可下载日志文件。
|
||||||
|
|
||||||
|
`moviepilot` 固定表示主程序日志,其余标识按插件 ID 处理并映射到
|
||||||
|
`plugins/<plugin_id>.log*`。这里不接收路径或后缀,避免下载入口变成任意
|
||||||
|
日志文件选择器;滚动日志按当前文件优先、备份文件按修改时间倒序补足。
|
||||||
|
"""
|
||||||
|
normalized_name = (name or "").strip().lower()
|
||||||
|
if not normalized_name or not _LOG_DOWNLOAD_NAME_PATTERN.fullmatch(normalized_name):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|
||||||
|
log_root = settings.LOG_PATH
|
||||||
|
if normalized_name == "moviepilot":
|
||||||
|
log_dir = log_root
|
||||||
|
log_prefix = "moviepilot.log"
|
||||||
|
else:
|
||||||
|
log_dir = log_root / "plugins"
|
||||||
|
log_prefix = f"{normalized_name}.log"
|
||||||
|
|
||||||
|
if not log_dir.exists() or not log_dir.is_dir():
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|
||||||
|
current_log = log_dir / log_prefix
|
||||||
|
backup_logs = [
|
||||||
|
item
|
||||||
|
for item in log_dir.iterdir()
|
||||||
|
if item.is_file() and item.name.startswith(f"{log_prefix}.")
|
||||||
|
]
|
||||||
|
backup_logs.sort(key=lambda item: item.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
log_files = []
|
||||||
|
if current_log.exists() and current_log.is_file():
|
||||||
|
log_files.append(current_log)
|
||||||
|
log_files.extend(backup_logs)
|
||||||
|
return log_files[:_LOG_DOWNLOAD_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_log_resource_superuser(
|
||||||
|
token_payload: schemas.TokenPayload = Depends(verify_resource_token),
|
||||||
|
) -> schemas.TokenPayload:
|
||||||
|
"""
|
||||||
|
校验日志资源访问权限。
|
||||||
|
|
||||||
|
日志接口通过浏览器新窗口和 EventSource 访问,不能依赖普通 API 请求头;
|
||||||
|
因此这里复用资源 Cookie 完成身份识别,再额外要求管理员身份,避免普通
|
||||||
|
登录用户读取可能包含敏感信息的日志。
|
||||||
|
"""
|
||||||
|
if not token_payload.super_user:
|
||||||
|
raise HTTPException(status_code=403, detail="用户权限不足")
|
||||||
|
return token_payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_log_zip_response(name: str) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
将指定日志标识对应的日志文件打包为 zip 响应。
|
||||||
|
|
||||||
|
打包前逐个校验文件仍位于日志根目录内,避免符号链接或并发文件变更绕过
|
||||||
|
`name` 到固定目录的映射约束。zip 内使用日志根目录相对路径,便于区分
|
||||||
|
主程序日志与插件日志。
|
||||||
|
"""
|
||||||
|
log_files = _collect_named_log_files(name)
|
||||||
|
if not log_files:
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|
||||||
|
log_root = settings.LOG_PATH
|
||||||
|
async_log_root = AsyncPath(log_root)
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
safe_name = (name or "logs").strip().lower() or "logs"
|
||||||
|
zip_stem = f"{safe_name}-logs-{filename_time}"
|
||||||
|
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for log_file in log_files:
|
||||||
|
if not await SecurityUtils.async_is_safe_path(
|
||||||
|
base_path=async_log_root,
|
||||||
|
user_path=AsyncPath(log_file),
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
arcname = f"{zip_stem}/{log_file.name}"
|
||||||
|
archive.write(log_file, arcname)
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'attachment; filename="{zip_stem}.zip"'
|
||||||
|
}
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([zip_buffer.getvalue()]),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_nettest_url(url: str) -> Optional[str]:
|
def _validate_nettest_url(url: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
对实际请求地址做基础安全校验。
|
对实际请求地址做基础安全校验。
|
||||||
@@ -705,7 +803,7 @@ async def get_logging(
|
|||||||
request: Request,
|
request: Request,
|
||||||
length: Optional[int] = 50,
|
length: Optional[int] = 50,
|
||||||
logfile: Optional[str] = "moviepilot.log",
|
logfile: Optional[str] = "moviepilot.log",
|
||||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
实时获取系统日志
|
实时获取系统日志
|
||||||
@@ -814,6 +912,17 @@ async def get_logging(
|
|||||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logging/download/{name}", summary="下载日志")
|
||||||
|
async def download_logging(
|
||||||
|
name: str,
|
||||||
|
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
按日志标识下载主程序或插件滚动日志,返回 zip 文件。
|
||||||
|
"""
|
||||||
|
return await _build_log_zip_response(name)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
|
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1080,7 +1080,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
||||||
return dashboard_meta
|
return dashboard_meta
|
||||||
|
|
||||||
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:
|
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> Optional[schemas.PluginDashboard]:
|
||||||
"""
|
"""
|
||||||
获取插件仪表盘
|
获取插件仪表盘
|
||||||
"""
|
"""
|
||||||
@@ -1113,6 +1113,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||||
|
if dashboard is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3:
|
||||||
|
logger.error(f"插件 {pid} 返回的仪表盘数据格式错误")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"插件 {pid} 返回的仪表盘数据格式错误")
|
||||||
cols, attrs, elements = dashboard
|
cols, attrs, elements = dashboard
|
||||||
return schemas.PluginDashboard(
|
return schemas.PluginDashboard(
|
||||||
id=pid,
|
id=pid,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: create-moviepilot-plugin
|
name: create-moviepilot-plugin
|
||||||
version: 1
|
version: 2
|
||||||
description: >-
|
description: >-
|
||||||
Use this skill when the user asks to create, modify, debug, validate, or
|
Use this skill when the user asks to create, modify, debug, validate, or
|
||||||
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
|
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
|
||||||
_PluginBase implementations, package.v2.json/package.json market metadata,
|
_PluginBase implementations, package.v2.json/package.json market metadata,
|
||||||
plugins.v2/plugins source layout, PLUGIN_LOCAL_REPO_PATHS local plugin
|
plugins.v2/plugins source layout, PLUGIN_LOCAL_REPO_PATHS local plugin
|
||||||
sources, plugin APIs, forms, pages, dashboards, commands, services, workflow
|
sources, plugin APIs, Vuetify JSON forms/pages/dashboards, Vue module
|
||||||
actions, agent tools, and local install/reload flows. Also use for Chinese
|
federation remote components, get_render_mode, get_sidebar_nav, plugin
|
||||||
requests mentioning 编写插件、本地插件源、插件开发、V2插件、插件市场、本地安装插件、插件热加载.
|
sidebar pages, commands, services, workflow actions, agent tools, and local
|
||||||
|
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
|
||||||
|
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
|
||||||
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
|
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,21 +24,45 @@ a local plugin source and installed into the running MoviePilot instance.
|
|||||||
- Host plugin contract: `app/plugins/__init__.py`, especially `_PluginBase`.
|
- Host plugin contract: `app/plugins/__init__.py`, especially `_PluginBase`.
|
||||||
- Host plugin discovery, local source sync, install, reload: `app/core/plugin.py`
|
- Host plugin discovery, local source sync, install, reload: `app/core/plugin.py`
|
||||||
and `app/helper/plugin.py`.
|
and `app/helper/plugin.py`.
|
||||||
|
- Host plugin endpoints, API auth, static files, remotes, and sidebar nav:
|
||||||
|
`app/api/endpoints/plugin.py`.
|
||||||
- Local development note: `docs/development-setup.md`.
|
- Local development note: `docs/development-setup.md`.
|
||||||
- Plugin repository conventions: `MoviePilot-Plugins` uses `plugins.v2/` with
|
- Plugin repository conventions: `MoviePilot-Plugins` uses `plugins.v2/` with
|
||||||
`package.v2.json` for V2 plugins; legacy or cross-generation entries may use
|
`package.v2.json` for V2 plugins; legacy or cross-generation entries may use
|
||||||
`plugins/` with `package.json`.
|
`plugins/` with `package.json`.
|
||||||
|
- When working in or from `MoviePilot-Plugins`, read its `README.md`,
|
||||||
|
`docs/Repository_Guide.md`, and `docs/V2_Plugin_Development.md`. For
|
||||||
|
scenario-specific extensions, read the matching `docs/faq/*.md`.
|
||||||
|
- When the plugin uses Vue federation, also read
|
||||||
|
`MoviePilot-Frontend/docs/module-federation-guide.md`,
|
||||||
|
`MoviePilot-Frontend/docs/federation-troubleshooting.md`,
|
||||||
|
`MoviePilot-Frontend/src/utils/federationLoader.ts`, and
|
||||||
|
`MoviePilot-Frontend/src/pages/plugin-app.vue`.
|
||||||
|
- Repository boundaries: `MoviePilot` owns runtime loading, API registration,
|
||||||
|
events, services, data, and permissions; `MoviePilot-Frontend` owns plugin UI
|
||||||
|
rendering, federation loading, and sidebar pages; `MoviePilot-Plugins` owns
|
||||||
|
plugin source, icons, package indexes, and release metadata.
|
||||||
|
|
||||||
## Pre-Flight
|
## Pre-Flight
|
||||||
|
|
||||||
1. Understand the user request: plugin purpose, trigger mode, configuration,
|
1. Understand the user request: plugin purpose, trigger mode, configuration,
|
||||||
output UI, whether it needs a scheduler, API, command, workflow action, or
|
output UI, whether it needs a scheduler, API, command, workflow action, or
|
||||||
agent tool.
|
agent tool.
|
||||||
2. Inspect existing plugins before creating a new one:
|
2. Run the UI Mode Selection Gate before writing any UI code.
|
||||||
|
- If the user already explicitly chose JSON config/Vuetify JSON or Vue
|
||||||
|
federation, follow that choice.
|
||||||
|
- If the plugin has any UI surface and the user has not chosen a mode, ask
|
||||||
|
them to choose between the two modes below and wait for the answer before
|
||||||
|
implementing UI files or schemas.
|
||||||
|
- Do not silently default to either mode just because one seems easier.
|
||||||
|
3. Inspect existing plugins before creating a new one:
|
||||||
- Local runtime examples: `app/plugins/<plugin>/__init__.py`
|
- Local runtime examples: `app/plugins/<plugin>/__init__.py`
|
||||||
- Market/local source candidates: use `query_market_plugins` when the
|
- Market/local source candidates: use `query_market_plugins` when the
|
||||||
running instance is available.
|
running instance is available.
|
||||||
3. Determine the target source path:
|
- For Vue federation examples, prefer current compliant plugins such as
|
||||||
|
`MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example
|
||||||
|
`MoviePilot-Frontend/examples/plugin-component/`.
|
||||||
|
4. Determine the target source path:
|
||||||
- Query `PLUGIN_LOCAL_REPO_PATHS` with `query_system_settings` when possible.
|
- Query `PLUGIN_LOCAL_REPO_PATHS` with `query_system_settings` when possible.
|
||||||
- If exactly one local plugin repository is configured, prefer that path.
|
- If exactly one local plugin repository is configured, prefer that path.
|
||||||
- If several are configured, choose the one the user named; otherwise ask
|
- If several are configured, choose the one the user named; otherwise ask
|
||||||
@@ -47,11 +73,41 @@ a local plugin source and installed into the running MoviePilot instance.
|
|||||||
plugin source loader. Create that source directory and write the plugin
|
plugin source loader. Create that source directory and write the plugin
|
||||||
under it; do not write new plugin source directly into `app/plugins/`
|
under it; do not write new plugin source directly into `app/plugins/`
|
||||||
unless the user explicitly asks for a runtime-only experiment.
|
unless the user explicitly asks for a runtime-only experiment.
|
||||||
4. Choose the plugin ID:
|
5. Choose the plugin ID:
|
||||||
- Class name is the plugin ID, for example `MyNotifier`.
|
- Class name is the plugin ID, for example `MyNotifier`.
|
||||||
- Directory name is the class name lowercased, for example `mynotifier`.
|
- Directory name is the class name lowercased, for example `mynotifier`.
|
||||||
- Avoid collisions with installed or market plugins unless the user is
|
- Avoid collisions with installed or market plugins unless the user is
|
||||||
explicitly modifying that plugin.
|
explicitly modifying that plugin.
|
||||||
|
- Do not hardcode the original plugin ID for data/config namespaces when the
|
||||||
|
plugin may support clones; use `self.__class__.__name__`.
|
||||||
|
|
||||||
|
## UI Mode Selection Gate
|
||||||
|
|
||||||
|
MoviePilot plugin UI has exactly two implementation modes. Make the user choose
|
||||||
|
one whenever the request includes configuration, detail pages, dashboards,
|
||||||
|
sidebar pages, or any other plugin UI and the mode is not already explicit.
|
||||||
|
|
||||||
|
Ask a concise question like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
这个插件 UI 用哪种方式实现?
|
||||||
|
1. JSON 配置:后端返回 Vuetify JSON,适合普通配置表单、简单详情页和轻量仪表板。
|
||||||
|
2. 联邦 UI:独立 Vue 远程组件,适合复杂交互、自定义布局、侧栏全页或多页面。
|
||||||
|
```
|
||||||
|
|
||||||
|
Selection rules:
|
||||||
|
|
||||||
|
- **JSON config / Vuetify JSON**: implement `get_form()`, `get_page()`, and
|
||||||
|
`get_dashboard()` with JSON component schemas. No frontend build or
|
||||||
|
`dist/assets/remoteEntry.js` is needed.
|
||||||
|
- **Federation UI / Vue remote component**: implement `get_render_mode()`,
|
||||||
|
expose Vue components through Vite federation, build frontend assets into the
|
||||||
|
plugin directory, and use `get_sidebar_nav()` only when a sidebar page is
|
||||||
|
requested.
|
||||||
|
- If the plugin truly has no user-facing UI, state that no UI mode is needed
|
||||||
|
and implement only the backend extension points the request requires.
|
||||||
|
- Backend-only work may proceed while waiting only if it cannot constrain or
|
||||||
|
preclude either UI mode.
|
||||||
|
|
||||||
## Local Source Layout
|
## Local Source Layout
|
||||||
|
|
||||||
@@ -67,6 +123,25 @@ Default to V2 layout for new local plugins:
|
|||||||
└── ... # helper modules, schemas, static assets
|
└── ... # helper modules, schemas, static assets
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a Vue federation plugin, the runtime requirement is the built remote assets
|
||||||
|
under the plugin directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins.v2/<plugin_id_lower>/
|
||||||
|
├── __init__.py
|
||||||
|
├── dist/
|
||||||
|
│ └── assets/
|
||||||
|
│ ├── remoteEntry.js
|
||||||
|
│ └── ... # JS/CSS/assets referenced by remoteEntry
|
||||||
|
├── package.json # optional frontend build project metadata
|
||||||
|
├── vite.config.js # optional frontend build config
|
||||||
|
└── src/ # optional source, not required at runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not rely on frontend source files at runtime. If the source is kept in the
|
||||||
|
plugin repository for maintainability, still build and ship the `dist/assets`
|
||||||
|
files required by `remoteEntry.js`.
|
||||||
|
|
||||||
Only use the legacy layout when the user explicitly needs it:
|
Only use the legacy layout when the user explicitly needs it:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -107,21 +182,29 @@ Rules:
|
|||||||
|
|
||||||
- The package object key must match the plugin class name.
|
- The package object key must match the plugin class name.
|
||||||
- `version` must match `plugin_version`.
|
- `version` must match `plugin_version`.
|
||||||
- `name`, `description`, `icon`, `author`, and `level` should match the plugin
|
- `name`, `description`, `icon`, `author`, `labels`, and `level` should match
|
||||||
class attributes when those attributes exist.
|
the plugin class attributes when those attributes exist (`plugin_name`,
|
||||||
|
`plugin_desc`, `plugin_icon`, `plugin_author`, `plugin_label`, `auth_level`).
|
||||||
- `history` should record user-readable changes for each published version.
|
- `history` should record user-readable changes for each published version.
|
||||||
- Use `system_version` when the plugin depends on a host capability introduced
|
- Use `system_version` when the plugin depends on a host capability introduced
|
||||||
in a specific MoviePilot version.
|
in a specific MoviePilot version, including new backend APIs, helpers, events,
|
||||||
|
Vue federation behavior, sidebar nav, dashboard behavior, or agent tools.
|
||||||
- Use `"release": true` only when the plugin is intentionally distributed by a
|
- Use `"release": true` only when the plugin is intentionally distributed by a
|
||||||
GitHub Release archive.
|
GitHub Release archive.
|
||||||
|
- New plugin entries should usually be appended to the package index so they
|
||||||
|
appear as newer marketplace items.
|
||||||
- Do not add dependencies unless they are actually required. If
|
- Do not add dependencies unless they are actually required. If
|
||||||
`requirements.txt` changes, the user must reinstall the plugin; hot reload is
|
`requirements.txt` changes, the user must reinstall the plugin; hot reload is
|
||||||
not enough to install dependencies.
|
not enough to install dependencies.
|
||||||
|
- Plugin dependencies are installed into the shared MoviePilot Python
|
||||||
|
environment. Do not pin or downgrade packages already provided by MoviePilot
|
||||||
|
unless the user has explicitly accepted the compatibility risk.
|
||||||
|
|
||||||
## Implementation Skeleton
|
## Implementation Skeleton
|
||||||
|
|
||||||
Implement all abstract methods from `_PluginBase`. All new public classes,
|
Implement all abstract methods from `_PluginBase`. All new functions and
|
||||||
public methods, and public functions need Chinese docstrings.
|
methods need Chinese docstrings; public classes, public methods, and public
|
||||||
|
functions are a hard review gate.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -224,7 +307,8 @@ Use only the extension points the requested plugin actually needs:
|
|||||||
- Notification: use `post_message()` instead of directly calling message
|
- Notification: use `post_message()` instead of directly calling message
|
||||||
modules.
|
modules.
|
||||||
- APIs: return route definitions from `get_api()`; default auth is `apikey`
|
- APIs: return route definitions from `get_api()`; default auth is `apikey`
|
||||||
when `auth` is omitted.
|
when `auth` is omitted. Vue component APIs should normally use
|
||||||
|
`auth: "bear"` and be called through the `api` prop passed by the frontend.
|
||||||
- Commands: return slash-command definitions from `get_command()` and dispatch
|
- Commands: return slash-command definitions from `get_command()` and dispatch
|
||||||
through MoviePilot events.
|
through MoviePilot events.
|
||||||
- Services: return scheduler services from `get_service()` and always clean
|
- Services: return scheduler services from `get_service()` and always clean
|
||||||
@@ -239,6 +323,118 @@ Use only the extension points the requested plugin actually needs:
|
|||||||
satisfy the request. Return `("vue", "<compiled-assets-path>")` and include
|
satisfy the request. Return `("vue", "<compiled-assets-path>")` and include
|
||||||
built frontend assets in the plugin directory.
|
built frontend assets in the plugin directory.
|
||||||
|
|
||||||
|
## Vue Federation UI
|
||||||
|
|
||||||
|
Use Vue federation only after the Pre-Flight UI decision says JSON schema is not
|
||||||
|
enough. A Vue plugin must align backend methods, built files, and federation
|
||||||
|
exposes.
|
||||||
|
|
||||||
|
Backend requirements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_render_mode() -> Tuple[str, str]:
|
||||||
|
"""声明插件使用 Vue 联邦组件渲染。"""
|
||||||
|
return "vue", "dist/assets"
|
||||||
|
|
||||||
|
|
||||||
|
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||||
|
"""Vue 模式下返回默认配置模型。"""
|
||||||
|
return [], self._current_config()
|
||||||
|
|
||||||
|
|
||||||
|
def get_page(self) -> List[dict]:
|
||||||
|
"""Vue 模式下详情页由远程 Page 组件渲染。"""
|
||||||
|
return []
|
||||||
|
```
|
||||||
|
|
||||||
|
When the plugin needs a main-layout sidebar page, also implement:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||||
|
"""声明插件在主界面左侧导航栏中的全页入口。"""
|
||||||
|
if not self.get_state():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"nav_key": "main",
|
||||||
|
"title": "我的插件",
|
||||||
|
"icon": "mdi-puzzle",
|
||||||
|
"section": "system",
|
||||||
|
"permission": "manage",
|
||||||
|
"order": 10,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sidebar rules:
|
||||||
|
|
||||||
|
- Sidebar entries are only aggregated for enabled plugins whose
|
||||||
|
`get_render_mode()` returns `"vue"`.
|
||||||
|
- `section` must be one of `start`, `discovery`, `subscribe`, `organize`,
|
||||||
|
`system`; invalid values fall back to `system`.
|
||||||
|
- `permission` may be `subscribe`, `discovery`, `search`, `manage`, or `admin`;
|
||||||
|
invalid values are ignored.
|
||||||
|
- `nav_key` defaults to `main` and must not contain `/`, `?`, `#`, or spaces.
|
||||||
|
- Multiple sidebar entries are allowed; each entry needs a stable `nav_key`.
|
||||||
|
|
||||||
|
Frontend federation requirements:
|
||||||
|
|
||||||
|
```js
|
||||||
|
federation({
|
||||||
|
name: 'MyPlugin',
|
||||||
|
filename: 'remoteEntry.js',
|
||||||
|
exposes: {
|
||||||
|
'./Page': './src/components/Page.vue',
|
||||||
|
'./Config': './src/components/Config.vue',
|
||||||
|
'./Dashboard': './src/components/Dashboard.vue',
|
||||||
|
'./AppPage': './src/components/AppPage.vue',
|
||||||
|
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||||
|
},
|
||||||
|
shared: {
|
||||||
|
vue: { requiredVersion: false, generate: false },
|
||||||
|
vuetify: { requiredVersion: false, generate: false, singleton: true },
|
||||||
|
'vuetify/styles': { requiredVersion: false, generate: false, singleton: true },
|
||||||
|
},
|
||||||
|
format: 'esm',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Build requirements:
|
||||||
|
|
||||||
|
- Set Vite `build.target` to `esnext` because federation uses top-level await.
|
||||||
|
- Use `cssCodeSplit: true` and scoped/component-local styles where possible.
|
||||||
|
- Build with the frontend project's documented command, then keep `remoteEntry.js`
|
||||||
|
and every JS/CSS/asset file it references under `dist/assets`.
|
||||||
|
- Do not add frontend runtime dependencies to the plugin Python
|
||||||
|
`requirements.txt`; keep frontend dependencies in the frontend build project.
|
||||||
|
|
||||||
|
Component contracts:
|
||||||
|
|
||||||
|
- `Page` renders the plugin detail dialog and may emit `action`, `switch`, and
|
||||||
|
`close`.
|
||||||
|
- `Config` renders plugin settings, receives `initialConfig` and `api`, and
|
||||||
|
emits `save`, `close`, and `switch`.
|
||||||
|
- `Dashboard` receives `config` and `allowRefresh`.
|
||||||
|
- `AppPage` renders the main-layout sidebar page and receives `api`, `pluginId`,
|
||||||
|
and `navKey`.
|
||||||
|
- For sidebar `nav_key=main`, the frontend loads `./AppPage` then `./Page`.
|
||||||
|
- For any other `nav_key`, the frontend loads `./AppPage{PascalCase(nav_key)}`,
|
||||||
|
then `./AppPage`, then `./Page`. Examples: `settings -> AppPageSettings`,
|
||||||
|
`my_tool -> AppPageMyTool`.
|
||||||
|
- A single `AppPage` may branch on `navKey`, or separate
|
||||||
|
`AppPage{PascalCase}` files may be exposed for specific entries.
|
||||||
|
|
||||||
|
Vue API calls:
|
||||||
|
|
||||||
|
- Define frontend-facing plugin APIs with `auth: "bear"`.
|
||||||
|
- Call them with the injected API object, for example
|
||||||
|
`props.api.get(\`plugin/${props.pluginId}/history\`)`.
|
||||||
|
- Do not pass `settings.API_TOKEN` into Vue components for browser-side calls.
|
||||||
|
|
||||||
## Local Install And Reload
|
## Local Install And Reload
|
||||||
|
|
||||||
1. After writing files in a configured local plugin repository, call
|
1. After writing files in a configured local plugin repository, call
|
||||||
@@ -258,6 +454,17 @@ Use only the extension points the requested plugin actually needs:
|
|||||||
and package version are consistent.
|
and package version are consistent.
|
||||||
- Confirm every public class, public method, and public function has a Chinese
|
- Confirm every public class, public method, and public function has a Chinese
|
||||||
docstring.
|
docstring.
|
||||||
|
- Confirm every newly written function or method has a Chinese docstring, even
|
||||||
|
when it is private helper code.
|
||||||
|
- For Vue federation plugins, confirm `get_render_mode()` returns
|
||||||
|
`("vue", "dist/assets")` or the actual built asset path, and that
|
||||||
|
`dist/assets/remoteEntry.js` exists.
|
||||||
|
- For sidebar plugins, confirm the plugin is enabled, `get_state()` returns
|
||||||
|
`True`, `get_sidebar_nav()` returns valid items, and matching `AppPage`
|
||||||
|
exposes exist for all non-main `nav_key` values or a generic `AppPage` handles
|
||||||
|
them.
|
||||||
|
- Confirm frontend-facing API routes use `auth: "bear"` and browser code calls
|
||||||
|
them through the provided `api` prop.
|
||||||
- Keep external HTTP calls behind MoviePilot utilities and avoid real network
|
- Keep external HTTP calls behind MoviePilot utilities and avoid real network
|
||||||
calls in tests.
|
calls in tests.
|
||||||
- If the plugin has non-trivial logic, add or update pytest-native tests. Plugin
|
- If the plugin has non-trivial logic, add or update pytest-native tests. Plugin
|
||||||
@@ -266,6 +473,27 @@ Use only the extension points the requested plugin actually needs:
|
|||||||
- Run the narrowest allowed validation for the touched area. In this repository,
|
- Run the narrowest allowed validation for the touched area. In this repository,
|
||||||
follow `docs/rules/03-commands.md`; for plugin-only repositories, follow their
|
follow `docs/rules/03-commands.md`; for plugin-only repositories, follow their
|
||||||
own documented validation commands.
|
own documented validation commands.
|
||||||
|
- For plugin repository Python changes, use the host Python environment when
|
||||||
|
possible and run at least syntax compilation for touched plugin files.
|
||||||
|
- For Vue federation changes, run the frontend project's documented typecheck
|
||||||
|
and build commands when available, then verify the built assets were copied to
|
||||||
|
the plugin directory.
|
||||||
|
|
||||||
|
## Vue Federation Troubleshooting
|
||||||
|
|
||||||
|
- `GET /api/v1/plugin/remotes?token=moviepilot` should include the plugin with a
|
||||||
|
URL ending in `/plugin/file/<plugin_id_lower>/<dist_path>/remoteEntry.js`.
|
||||||
|
- `GET /api/v1/plugin/sidebar_nav` should include sidebar entries for enabled
|
||||||
|
Vue plugins with valid `nav_key`, `section`, and `permission`.
|
||||||
|
- If the console says `Module name 'vue' does not resolve to a valid URL`, check
|
||||||
|
the federation `shared` config and use `requiredVersion: false`.
|
||||||
|
- If the console says top-level await is unavailable, set `build.target` to
|
||||||
|
`esnext`.
|
||||||
|
- If dynamic import fails, check the remote file request status, the computed
|
||||||
|
`remoteEntry.js` path, and whether the installed runtime plugin directory
|
||||||
|
actually contains the built assets.
|
||||||
|
- If a sidebar page is blank, check the expose name resolution for the current
|
||||||
|
`nav_key` and fallbacks (`AppPage{PascalCase}` -> `AppPage` -> `Page`).
|
||||||
|
|
||||||
## Final Report
|
## Final Report
|
||||||
|
|
||||||
@@ -273,5 +501,7 @@ Report:
|
|||||||
|
|
||||||
- Plugin ID, source path, and runtime path if installed.
|
- Plugin ID, source path, and runtime path if installed.
|
||||||
- Package file changed (`package.v2.json` or `package.json`).
|
- Package file changed (`package.v2.json` or `package.json`).
|
||||||
|
- UI mode used (`vuetify` JSON or `vue` federation), and for Vue plugins the
|
||||||
|
exposed components and built asset path.
|
||||||
- Whether the plugin was installed or reloaded.
|
- Whether the plugin was installed or reloaded.
|
||||||
- Validation commands run, or why validation was not run.
|
- Validation commands run, or why validation was not run.
|
||||||
|
|||||||
26
tests/test_llm_endpoint_error_messages.py
Normal file
26
tests/test_llm_endpoint_error_messages.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.api.endpoints import llm as llm_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_test_maps_internal_model_dump_error_to_base_url_hint():
|
||||||
|
"""LLM 测试遇到 SDK 内部响应解析错误时应提示检查基础地址。"""
|
||||||
|
with patch.object(llm_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||||
|
llm_endpoint.settings, "LLM_PROVIDER", "openai"
|
||||||
|
), patch.object(llm_endpoint.settings, "LLM_MODEL", "gpt-4o-mini"), patch.object(
|
||||||
|
llm_endpoint.settings, "LLM_API_KEY", "sk-test"
|
||||||
|
), patch.object(
|
||||||
|
llm_endpoint.settings, "LLM_BASE_URL", "https://example.com/not-api"
|
||||||
|
), patch.object(
|
||||||
|
llm_endpoint.LLMHelper,
|
||||||
|
"test_current_settings",
|
||||||
|
AsyncMock(side_effect=RuntimeError("'str' object has no attribute 'model_dump'")),
|
||||||
|
create=True,
|
||||||
|
):
|
||||||
|
resp = asyncio.run(llm_endpoint.llm_test(_="token"))
|
||||||
|
|
||||||
|
assert not resp.success
|
||||||
|
assert "基础地址" in resp.message
|
||||||
|
assert "API Base URL" in resp.message
|
||||||
|
assert "model_dump" not in resp.message
|
||||||
65
tests/test_plugin_dashboard.py
Normal file
65
tests/test_plugin_dashboard.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugin_manager() -> Iterator[PluginManager]:
|
||||||
|
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
|
||||||
|
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||||
|
manager = PluginManager()
|
||||||
|
yield manager
|
||||||
|
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_with_dashboard(dashboard: Any) -> SimpleNamespace:
|
||||||
|
"""构造仅包含仪表板接口的插件实例。"""
|
||||||
|
return SimpleNamespace(
|
||||||
|
plugin_name="演示插件",
|
||||||
|
get_render_mode=lambda: ("vue", "dist/assets"),
|
||||||
|
get_dashboard=lambda key=None, user_agent=None: dashboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_dashboard_keeps_vue_elements_none(plugin_manager: PluginManager) -> None:
|
||||||
|
"""Vue 仪表板的 elements=None 应原样返回给前端渲染远程组件。"""
|
||||||
|
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
|
||||||
|
(
|
||||||
|
{"cols": 12},
|
||||||
|
{"title": "演示插件", "border": True},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard = plugin_manager.get_plugin_dashboard("DemoPlugin", "usage")
|
||||||
|
|
||||||
|
assert dashboard.id == "DemoPlugin"
|
||||||
|
assert dashboard.render_mode == "vue"
|
||||||
|
assert dashboard.cols == {"cols": 12}
|
||||||
|
assert dashboard.attrs == {"title": "演示插件", "border": True}
|
||||||
|
assert dashboard.elements is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_dashboard_returns_none_when_plugin_has_no_dashboard(plugin_manager: PluginManager) -> None:
|
||||||
|
"""插件声明当前无仪表板时应返回 None,而不是触发解包异常。"""
|
||||||
|
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(None)
|
||||||
|
|
||||||
|
assert plugin_manager.get_plugin_dashboard("DemoPlugin", "missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_dashboard_rejects_invalid_dashboard_shape(plugin_manager: PluginManager) -> None:
|
||||||
|
"""非空但不符合三元组契约的仪表板数据应返回服务端错误。"""
|
||||||
|
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
|
||||||
|
{"cols": {}, "attrs": {}, "elements": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
plugin_manager.get_plugin_dashboard("DemoPlugin", "broken")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
assert "仪表盘数据格式错误" in exc_info.value.detail
|
||||||
116
tests/test_system_log_download.py
Normal file
116
tests/test_system_log_download.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""系统日志查看与下载接口的权限和打包行为测试。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.api.endpoints import system as system_endpoint
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_routes_use_superuser_dependency():
|
||||||
|
"""日志查看和下载路由都必须绑定管理员依赖,避免普通登录用户读取敏感日志。"""
|
||||||
|
routes = {route.path: route for route in system_endpoint.router.routes}
|
||||||
|
|
||||||
|
logging_dependencies = {dependency.call for dependency in routes["/logging"].dependant.dependencies}
|
||||||
|
download_dependencies = {dependency.call for dependency in routes["/logging/download/{name}"].dependant.dependencies}
|
||||||
|
|
||||||
|
assert system_endpoint._verify_log_resource_superuser in logging_dependencies
|
||||||
|
assert system_endpoint._verify_log_resource_superuser in download_dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_resource_dependency_rejects_normal_user():
|
||||||
|
"""日志资源依赖必须拒绝非管理员 resource token。"""
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
system_endpoint._verify_log_resource_superuser(
|
||||||
|
SimpleNamespace(super_user=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="isolated_log_path")
|
||||||
|
def fixture_isolated_log_path(monkeypatch, tmp_path: Path) -> Path:
|
||||||
|
"""将日志目录隔离到临时目录,避免测试读取或打包真实运行日志。"""
|
||||||
|
config_path = tmp_path / "config"
|
||||||
|
log_path = config_path / "logs"
|
||||||
|
log_path.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(settings, "CONFIG_DIR", str(config_path))
|
||||||
|
return log_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_requires_superuser_dependency(monkeypatch, isolated_log_path):
|
||||||
|
"""实时日志查看接口必须通过管理员依赖,普通资源令牌不能直接读取日志。"""
|
||||||
|
(isolated_log_path / "moviepilot.log").write_text("hello\n", encoding="utf-8")
|
||||||
|
response = asyncio.run(
|
||||||
|
system_endpoint.get_logging(
|
||||||
|
request=SimpleNamespace(is_disconnected=lambda: False),
|
||||||
|
length=-1,
|
||||||
|
logfile="moviepilot.log",
|
||||||
|
_=SimpleNamespace(id=1, name="admin", is_superuser=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(response, Response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_moviepilot_logs_packages_latest_ten_log_files(isolated_log_path):
|
||||||
|
"""传入 moviepilot 时下载主程序滚动日志,最多打包 10 个文件。"""
|
||||||
|
for index in range(12):
|
||||||
|
(isolated_log_path / f"moviepilot.log.{index}").write_text(f"old-{index}", encoding="utf-8")
|
||||||
|
(isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8")
|
||||||
|
(isolated_log_path / "moviepilot.txt").write_text("ignored", encoding="utf-8")
|
||||||
|
(isolated_log_path / "plugins").mkdir()
|
||||||
|
(isolated_log_path / "plugins" / "demo.log").write_text("plugin", encoding="utf-8")
|
||||||
|
|
||||||
|
response = asyncio.run(system_endpoint.download_logging(name="moviepilot", _=SimpleNamespace()))
|
||||||
|
body = asyncio.run(_read_streaming_body(response))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
|
||||||
|
moviepilot_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
|
||||||
|
assert response.media_type == "application/zip"
|
||||||
|
assert 'filename="moviepilot-logs-' in response.headers["Content-Disposition"]
|
||||||
|
assert "moviepilot-moviepilot-logs" not in response.headers["Content-Disposition"]
|
||||||
|
assert len(names) == 10
|
||||||
|
assert f"{moviepilot_zip_root}/moviepilot.log" in names
|
||||||
|
assert "moviepilot.log" not in names
|
||||||
|
assert "plugins/demo.log" not in names
|
||||||
|
assert "moviepilot.txt" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_plugin_logs_packages_plugin_files_only(isolated_log_path):
|
||||||
|
"""传入插件 ID 时只下载该插件滚动日志,最多打包 10 个文件。"""
|
||||||
|
plugin_dir = isolated_log_path / "plugins"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
for index in range(11):
|
||||||
|
(plugin_dir / f"demoplugin.log.{index}").write_text(f"plugin-{index}", encoding="utf-8")
|
||||||
|
(plugin_dir / "demoplugin.log").write_text("current", encoding="utf-8")
|
||||||
|
(plugin_dir / "other.log").write_text("other", encoding="utf-8")
|
||||||
|
(isolated_log_path / "moviepilot.log").write_text("main", encoding="utf-8")
|
||||||
|
|
||||||
|
response = asyncio.run(system_endpoint.download_logging(name="DemoPlugin", _=SimpleNamespace()))
|
||||||
|
body = asyncio.run(_read_streaming_body(response))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
|
||||||
|
plugin_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
|
||||||
|
assert len(names) == 10
|
||||||
|
assert f"{plugin_zip_root}/demoplugin.log" in names
|
||||||
|
assert "demoplugin.log" not in names
|
||||||
|
assert "plugins/demoplugin.log" not in names
|
||||||
|
assert "plugins/other.log" not in names
|
||||||
|
assert "moviepilot.log" not in names
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_streaming_body(response) -> bytes:
|
||||||
|
"""读取 StreamingResponse 内容,便于断言 zip 文件条目。"""
|
||||||
|
return b"".join([chunk async for chunk in response.body_iterator])
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = 'v2.13.8'
|
APP_VERSION = 'v2.13.8-1'
|
||||||
FRONTEND_VERSION = 'v2.13.8'
|
FRONTEND_VERSION = 'v2.13.8'
|
||||||
|
|||||||
Reference in New Issue
Block a user