mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 04:27:40 +08:00
feat: 完善数据库备份管理与版本读取 (#6450)
This commit is contained in:
+2
-11
@@ -15,17 +15,8 @@ app/application/site/*.bin
|
||||
app/helper/*.bin
|
||||
app/plugins/**
|
||||
!app/plugins/__init__.py
|
||||
config/cookies/
|
||||
config/app.env
|
||||
config/user.db*
|
||||
config/systemconfig.db*
|
||||
config/sites/**
|
||||
config/agent/
|
||||
config/logs/
|
||||
config/plugins/
|
||||
config/temp/
|
||||
config/cache/
|
||||
config/.cache/
|
||||
config/*
|
||||
!config/category.yaml
|
||||
# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物
|
||||
app/config/
|
||||
.verify_tmp/
|
||||
|
||||
Vendored
+5
-5
@@ -62,7 +62,7 @@ from app.foundation.singleton import WeakSingleton
|
||||
from app.foundation.version import compare_version
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.foundation.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
# 保留模块级可替换入口,代理默认读取组合根的最新 runtime 配置。
|
||||
settings = RuntimeSettingsCompat()
|
||||
@@ -289,9 +289,9 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。
|
||||
"""
|
||||
try:
|
||||
return Version(str(APP_VERSION))
|
||||
return Version(get_app_version())
|
||||
except InvalidVersion:
|
||||
logger.error(f"当前主程序版本号无法解析:{APP_VERSION}")
|
||||
logger.error(f"当前主程序版本号无法解析:{get_app_version()}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -402,7 +402,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
|
||||
system_version = cls.get_current_system_version()
|
||||
if system_version is None:
|
||||
return False, f"当前 MoviePilot 版本 {APP_VERSION} 无法解析,已拒绝安装带版本限制的插件"
|
||||
return False, f"当前 MoviePilot 版本 {get_app_version()} 无法解析,已拒绝安装带版本限制的插件"
|
||||
|
||||
try:
|
||||
specifier_set = SpecifierSet(raw_specifier)
|
||||
@@ -416,7 +416,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return True, ""
|
||||
|
||||
return False, (
|
||||
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {APP_VERSION} 不满足,已拒绝安装"
|
||||
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
Vendored
+3
-22
@@ -2,7 +2,6 @@ import asyncio
|
||||
import json
|
||||
import platform
|
||||
from collections.abc import Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import parse_qs, quote, urlparse, urlsplit
|
||||
|
||||
@@ -24,7 +23,7 @@ from app.adapters.network.http import AsyncRequestUtils, RequestUtils
|
||||
from app.domain.media import normalize_music_type
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from version import APP_VERSION, FRONTEND_VERSION
|
||||
from app.runtime.version import get_app_version, get_frontend_version
|
||||
|
||||
|
||||
# 保留旧插件可覆盖的模块级入口,默认通过 runtime 代理动态读取配置。
|
||||
@@ -270,24 +269,6 @@ class MoviePilotServerHelper:
|
||||
or permissions.get("workflow_share_manage")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_frontend_version() -> str:
|
||||
"""
|
||||
获取当前前端版本。
|
||||
"""
|
||||
if SystemUtils.is_frozen() and SystemUtils.is_windows():
|
||||
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
|
||||
else:
|
||||
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, "r", encoding="utf-8", errors="replace") as file:
|
||||
version = str(file.read()).strip()
|
||||
return version or FRONTEND_VERSION
|
||||
except Exception as err:
|
||||
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
return FRONTEND_VERSION
|
||||
|
||||
@classmethod
|
||||
def build_usage_payload(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -295,8 +276,8 @@ class MoviePilotServerHelper:
|
||||
"""
|
||||
return {
|
||||
"user_uid": cls.get_user_uid(),
|
||||
"backend_version": APP_VERSION,
|
||||
"frontend_version": cls.get_frontend_version(),
|
||||
"backend_version": get_app_version(),
|
||||
"frontend_version": get_frontend_version(),
|
||||
"version_flag": settings.VERSION_FLAG,
|
||||
"platform": f"{platform.system()} {platform.release()}".strip(),
|
||||
"arch": SystemUtils.cpu_arch(),
|
||||
|
||||
@@ -8,13 +8,16 @@ import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
_BACKUP_NAME = re.compile(
|
||||
r"^(?P<db_type>sqlite|postgresql)_"
|
||||
r"^(?:moviepilot_(?P<version>v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)_)?"
|
||||
r"(?P<db_type>sqlite|postgresql)_"
|
||||
r"(?P<timestamp>\d{8}_\d{6})"
|
||||
r"(?:_(?P<sequence>\d+))?"
|
||||
r"(?P<suffix>\.db|\.dump)$"
|
||||
)
|
||||
_RELEASE_VERSION = re.compile(r"^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
class BackupFiles:
|
||||
@@ -68,10 +71,19 @@ class BackupFiles:
|
||||
"""删除一个已通过名称约束的备份文件。"""
|
||||
self.resolve(name).unlink()
|
||||
|
||||
def available_name(self, *, db_type: str, created_at: datetime, suffix: str) -> str:
|
||||
def available_name(
|
||||
self,
|
||||
*,
|
||||
db_type: str,
|
||||
created_at: datetime,
|
||||
suffix: str,
|
||||
) -> str:
|
||||
"""生成包含数据库类型和秒级时间的简短可读文件名。"""
|
||||
timestamp = created_at.strftime("%Y%m%d_%H%M%S")
|
||||
base = f"{db_type}_{timestamp}"
|
||||
version = get_app_version().strip()
|
||||
if _RELEASE_VERSION.fullmatch(version) is None:
|
||||
raise ValueError("程序版本号无法用于数据库备份命名")
|
||||
base = f"moviepilot_{version}_{db_type}_{timestamp}"
|
||||
candidate = f"{base}{suffix}"
|
||||
sequence = 1
|
||||
while (self.root / candidate).exists():
|
||||
|
||||
@@ -26,7 +26,7 @@ import psutil
|
||||
from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo
|
||||
from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo
|
||||
from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
from app.foundation.environment import (
|
||||
is_aarch,
|
||||
is_aarch64,
|
||||
@@ -948,7 +948,7 @@ class SystemUtils:
|
||||
hostname=socket.gethostname(),
|
||||
operating_system=SystemUtils._operating_system_name(),
|
||||
runtime=max(0, int(time.time() - psutil.Process().create_time())),
|
||||
version=APP_VERSION,
|
||||
version=get_app_version(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -16,12 +16,12 @@ from typing import Any, Optional
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.foundation.singleton import SingletonClass
|
||||
from app.foundation.version import compare_version
|
||||
from app.runtime.version import get_app_version
|
||||
from app.foundation.environment import is_docker
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.runtime.thread import ThreadHelper
|
||||
from app.schemas.system import SystemUpdateStatus
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SystemUpdateManager(metaclass=SingletonClass):
|
||||
@@ -66,13 +66,17 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _default_state(self) -> dict[str, Any]:
|
||||
return SystemUpdateStatus(current_version=APP_VERSION).model_dump()
|
||||
return SystemUpdateStatus(current_version=get_app_version()).model_dump()
|
||||
|
||||
def _read_state(self) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(self._state_file.read_text(encoding="utf-8"))
|
||||
if isinstance(payload, dict):
|
||||
return {**self._default_state(), **payload, "current_version": APP_VERSION}
|
||||
return {
|
||||
**self._default_state(),
|
||||
**payload,
|
||||
"current_version": get_app_version(),
|
||||
}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return self._default_state()
|
||||
@@ -81,7 +85,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
with self._lock:
|
||||
state = self._read_state()
|
||||
state.update(changes)
|
||||
state["current_version"] = APP_VERSION
|
||||
state["current_version"] = get_app_version()
|
||||
state["progress"] = self._progress(
|
||||
state.get("downloaded_bytes", 0), state.get("total_bytes", 0)
|
||||
)
|
||||
@@ -117,7 +121,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
can_update=True,
|
||||
can_install=False,
|
||||
)
|
||||
if state.get("state") == "installing" and target == APP_VERSION:
|
||||
if state.get("state") == "installing" and target == get_app_version():
|
||||
self._install_file.unlink(missing_ok=True)
|
||||
state = self._write_state(
|
||||
state="idle",
|
||||
@@ -161,7 +165,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
if not release:
|
||||
raise RuntimeError("未找到可用的 v3 稳定版本")
|
||||
version = str(release["tag_name"])
|
||||
has_update = compare_version(version, "gt", APP_VERSION) is True
|
||||
has_update = compare_version(version, "gt", get_app_version()) is True
|
||||
return SystemUpdateStatus.model_validate(
|
||||
self._write_state(
|
||||
state="available" if has_update else "idle",
|
||||
|
||||
@@ -14,14 +14,9 @@ from app.schemas.response import Response as _SchemaResponse
|
||||
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||
from app.agent.tools.manager import moviepilot_tool_manager
|
||||
from app.adapters.web.security.access import verify_apikey
|
||||
from app.runtime.version import get_app_version
|
||||
from app.runtime.log import logger
|
||||
|
||||
# 导入版本号
|
||||
try:
|
||||
from version import APP_VERSION
|
||||
except ImportError:
|
||||
APP_VERSION = "unknown"
|
||||
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
# MCP 协议版本
|
||||
@@ -213,7 +208,7 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "MoviePilot",
|
||||
"version": APP_VERSION,
|
||||
"version": get_app_version(),
|
||||
"description": "MoviePilot MCP Server - 电影自动化管理工具",
|
||||
},
|
||||
"instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。",
|
||||
|
||||
+113
-5
@@ -23,6 +23,8 @@ from app.schemas.common import TimeData as _SchemaTimeData
|
||||
from app.schemas.common import ValueData as _SchemaValueData
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.system import NetTestTarget as _SchemaNetTestTarget
|
||||
from app.schemas.system import DatabaseBackupArtifactData as _SchemaDatabaseBackupArtifactData
|
||||
from app.schemas.system import DatabaseBackupVerificationData as _SchemaDatabaseBackupVerificationData
|
||||
from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncData
|
||||
from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest
|
||||
from app.schemas.system import RuleTestData as _SchemaRuleTestData
|
||||
@@ -46,6 +48,8 @@ from app.application.configuration import (
|
||||
get_configured_system_config,
|
||||
get_runtime_settings,
|
||||
)
|
||||
from app.application.backup import DatabaseBackupInProgressError
|
||||
from app.application.database import get_database_governance
|
||||
from app.application.plugin.runtime import plugin_system_config_mutation
|
||||
from app.api.dependencies.auth import (
|
||||
get_current_active_superuser,
|
||||
@@ -67,6 +71,7 @@ from app.application.rules import RuleHelper
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
from app.application.scheduling import get_scheduler
|
||||
from app.schemas.event import ConfigChangeEventData
|
||||
from app.schemas.exception import PluginMutationRejectedError
|
||||
@@ -79,7 +84,7 @@ from app.adapters.system.update import system_update_manager
|
||||
from app.application.security.url import SecurityUtils
|
||||
from app.application.network import NetworkTestService
|
||||
from app.foundation.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version, get_frontend_version
|
||||
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
@@ -110,6 +115,16 @@ _DATABASE_BACKUP_SETTING_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _database_backup_artifact_data(artifact: Any) -> _SchemaDatabaseBackupArtifactData:
|
||||
"""将内部备份制品映射为不含宿主路径的 Web DTO。"""
|
||||
return _SchemaDatabaseBackupArtifactData(
|
||||
name=artifact.name,
|
||||
db_type=artifact.db_type,
|
||||
created_at=artifact.created_at,
|
||||
size=artifact.size,
|
||||
)
|
||||
|
||||
|
||||
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
|
||||
"""校验强制服务端联网搜索配置,返回用户可读错误信息。"""
|
||||
from app.agent.llm.server_tools import (
|
||||
@@ -759,8 +774,8 @@ def get_global_setting(token: str):
|
||||
# 追加版本信息(用于版本检查)
|
||||
info.update(
|
||||
{
|
||||
"FRONTEND_VERSION": SystemChain.get_frontend_version(),
|
||||
"BACKEND_VERSION": APP_VERSION,
|
||||
"FRONTEND_VERSION": get_frontend_version(),
|
||||
"BACKEND_VERSION": get_app_version(),
|
||||
}
|
||||
)
|
||||
# 仅在后端开发模式下返回该标记,避免生产环境暴露无意义运行态信息
|
||||
@@ -812,6 +827,99 @@ async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_u
|
||||
return _SchemaResponse(success=True, data=info)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/database/backups",
|
||||
summary="查询受管数据库备份",
|
||||
response_model=list[_SchemaDatabaseBackupArtifactData],
|
||||
)
|
||||
async def list_database_backups(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> list[_SchemaDatabaseBackupArtifactData]:
|
||||
"""列出当前备份目录中的正式制品,不触发内容校验。"""
|
||||
try:
|
||||
artifacts = await run_in_threadpool_to_completion(
|
||||
get_database_governance().list_backups
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("读取数据库备份列表失败")
|
||||
raise HTTPException(status_code=500, detail="读取数据库备份列表失败,请查看日志") from error
|
||||
return [_database_backup_artifact_data(artifact) for artifact in artifacts]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/database/backups",
|
||||
summary="立即创建数据库备份",
|
||||
response_model=_SchemaDatabaseBackupArtifactData,
|
||||
)
|
||||
async def create_database_backup(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> _SchemaDatabaseBackupArtifactData:
|
||||
"""创建、校验并原子发布当前活动数据库的一致快照。"""
|
||||
try:
|
||||
artifact = await run_in_threadpool_to_completion(
|
||||
get_database_governance().create_backup
|
||||
)
|
||||
except DatabaseBackupInProgressError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||
except Exception as error:
|
||||
logger.exception("创建数据库备份失败")
|
||||
raise HTTPException(status_code=500, detail="创建数据库备份失败,请查看日志") from error
|
||||
return _database_backup_artifact_data(artifact)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/database/backups/{name}/verify",
|
||||
summary="校验受管数据库备份",
|
||||
response_model=_SchemaDatabaseBackupVerificationData,
|
||||
)
|
||||
async def verify_database_backup(
|
||||
name: str,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> _SchemaDatabaseBackupVerificationData:
|
||||
"""校验一个受管制品,响应不包含宿主路径或适配器错误明细。"""
|
||||
try:
|
||||
verification = await run_in_threadpool_to_completion(
|
||||
get_database_governance().verify_backup,
|
||||
name,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail="数据库备份不存在") from error
|
||||
except Exception as error:
|
||||
logger.exception("校验数据库备份失败:%s", name)
|
||||
raise HTTPException(status_code=500, detail="校验数据库备份失败,请查看日志") from error
|
||||
return _SchemaDatabaseBackupVerificationData(
|
||||
valid=verification.valid,
|
||||
method=verification.method,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/database/backups/{name}",
|
||||
summary="删除受管数据库备份",
|
||||
response_model=_SchemaResponse[None],
|
||||
)
|
||||
async def delete_database_backup(
|
||||
name: str,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> _SchemaResponse[None]:
|
||||
"""删除一个受管制品,只接受备份目录内的合法文件名。"""
|
||||
try:
|
||||
await run_in_threadpool_to_completion(
|
||||
get_database_governance().delete_backup,
|
||||
name,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail="数据库备份不存在") from error
|
||||
except Exception as error:
|
||||
logger.exception("删除数据库备份失败:%s", name)
|
||||
raise HTTPException(status_code=500, detail="删除数据库备份失败,请查看日志") from error
|
||||
return _SchemaResponse(success=True)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/env",
|
||||
summary="查询系统配置",
|
||||
@@ -828,10 +936,10 @@ async def get_env_setting(
|
||||
)
|
||||
info.update(
|
||||
{
|
||||
"VERSION": APP_VERSION,
|
||||
"VERSION": get_app_version(),
|
||||
"AUTH_VERSION": SitesHelper().auth_version,
|
||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||
"FRONTEND_VERSION": SystemChain().get_frontend_version(),
|
||||
"FRONTEND_VERSION": get_frontend_version(),
|
||||
"RUST_ACCEL": rust_accel.is_config_enabled(),
|
||||
"RUST_ACCEL_AVAILABLE": rust_accel.is_available(),
|
||||
"RUST_ACCEL_ENABLED": rust_accel.is_enabled(),
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ from app.api.dependencies.subscription import get_servarr_subscription_service
|
||||
from app.schemas.servarr import RadarrMovie
|
||||
from app.schemas.servarr import SonarrSeries
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES)
|
||||
|
||||
@@ -74,7 +74,7 @@ async def arr_system_status(
|
||||
return _SchemaServarrSystemStatus.model_validate({
|
||||
"appName": "MoviePilot",
|
||||
"instanceName": "moviepilot",
|
||||
"version": APP_VERSION,
|
||||
"version": get_app_version(),
|
||||
"buildTime": "",
|
||||
"isDebug": False,
|
||||
"isProduction": True,
|
||||
|
||||
+42
-27
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.adapters.system.backup.files import BackupFiles
|
||||
@@ -44,6 +45,10 @@ class BackupVerification:
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class DatabaseBackupInProgressError(RuntimeError):
|
||||
"""同一宿主进程已有数据库备份正在创建。"""
|
||||
|
||||
|
||||
class BackupCheck(Protocol):
|
||||
"""数据库适配器校验结果的结构合同。"""
|
||||
|
||||
@@ -81,38 +86,44 @@ class DatabaseBackupService:
|
||||
self._backend = backend
|
||||
self._policy_reader = policy_reader
|
||||
self._clock = clock
|
||||
self._create_lock = Lock()
|
||||
|
||||
def create(self) -> BackupArtifact:
|
||||
"""创建、校验并发布一个在线一致快照。"""
|
||||
policy = self._policy_reader()
|
||||
files = BackupFiles(policy.root)
|
||||
created_at = self._clock()
|
||||
name = files.available_name(
|
||||
db_type=self._backend.db_type,
|
||||
created_at=created_at,
|
||||
suffix=self._backend.suffix,
|
||||
)
|
||||
temporary = files.create_temporary(self._backend.suffix)
|
||||
if not self._create_lock.acquire(blocking=False):
|
||||
raise DatabaseBackupInProgressError("已有数据库备份任务正在执行")
|
||||
try:
|
||||
self._backend.create(temporary)
|
||||
verification = self._backend.verify(temporary)
|
||||
if not verification.valid:
|
||||
detail = f":{verification.detail}" if verification.detail else ""
|
||||
raise RuntimeError(
|
||||
f"数据库备份校验失败({verification.method}){detail}"
|
||||
)
|
||||
path = files.publish(temporary, name)
|
||||
except Exception:
|
||||
files.discard(temporary)
|
||||
raise
|
||||
policy = self._policy_reader()
|
||||
files = BackupFiles(policy.root)
|
||||
created_at = self._clock()
|
||||
name = files.available_name(
|
||||
db_type=self._backend.db_type,
|
||||
created_at=created_at,
|
||||
suffix=self._backend.suffix,
|
||||
)
|
||||
temporary = files.create_temporary(self._backend.suffix)
|
||||
try:
|
||||
self._backend.create(temporary)
|
||||
verification = self._backend.verify(temporary)
|
||||
if not verification.valid:
|
||||
detail = f":{verification.detail}" if verification.detail else ""
|
||||
raise RuntimeError(
|
||||
f"数据库备份校验失败({verification.method}){detail}"
|
||||
)
|
||||
path = files.publish(temporary, name)
|
||||
except Exception:
|
||||
files.discard(temporary)
|
||||
raise
|
||||
|
||||
artifact = self._artifact(path, created_at=created_at)
|
||||
self._prune(files, policy, keep=artifact.name)
|
||||
logger.info(
|
||||
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type},"
|
||||
f"大小={artifact.size} bytes"
|
||||
)
|
||||
return artifact
|
||||
artifact = self._artifact(path, created_at=created_at)
|
||||
self._prune(files, policy, keep=artifact.name)
|
||||
logger.info(
|
||||
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type},"
|
||||
f"大小={artifact.size} bytes"
|
||||
)
|
||||
return artifact
|
||||
finally:
|
||||
self._create_lock.release()
|
||||
|
||||
def list(self) -> tuple[BackupArtifact, ...]:
|
||||
"""按创建时间倒序列出受管数据库备份文件。"""
|
||||
@@ -126,6 +137,10 @@ class DatabaseBackupService:
|
||||
result = self._backend.verify(path)
|
||||
return BackupVerification(result.valid, result.method, result.detail)
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""删除一个受管数据库备份文件。"""
|
||||
BackupFiles(self._policy_reader().root).delete(name)
|
||||
|
||||
def restore(self, name: str) -> BackupArtifact:
|
||||
"""校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。"""
|
||||
path = BackupFiles(self._policy_reader().root).resolve(name)
|
||||
|
||||
@@ -79,6 +79,10 @@ class DatabaseGovernance:
|
||||
"""校验一个受管数据库备份文件。"""
|
||||
return self._backup.verify(name)
|
||||
|
||||
def delete_backup(self, name: str) -> None:
|
||||
"""删除一个受管数据库备份文件。"""
|
||||
self._backup.delete(name)
|
||||
|
||||
def restore_backup(self, name: str) -> BackupArtifact:
|
||||
"""在离线 CLI 进程中还原一个受管数据库备份。"""
|
||||
return self._backup.restore(name)
|
||||
|
||||
+7
-23
@@ -14,7 +14,7 @@ from app.schemas.message import Message
|
||||
from app.schemas.notification import NotificationChannel
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from version import FRONTEND_VERSION, APP_VERSION
|
||||
from app.runtime import version as runtime_version
|
||||
|
||||
|
||||
class SystemChain(ChainBase):
|
||||
@@ -313,8 +313,8 @@ class SystemChain(ChainBase):
|
||||
"""
|
||||
server_release_version = self.__get_server_release_version()
|
||||
front_release_version = self.__get_front_release_version()
|
||||
server_local_version = self.get_server_local_version()
|
||||
front_local_version = self.get_frontend_version()
|
||||
server_local_version = runtime_version.get_app_version()
|
||||
front_local_version = runtime_version.get_frontend_version()
|
||||
if server_release_version == server_local_version:
|
||||
title = f"当前后端版本:{server_local_version},已是最新版本\n"
|
||||
else:
|
||||
@@ -417,26 +417,10 @@ class SystemChain(ChainBase):
|
||||
|
||||
@staticmethod
|
||||
def get_server_local_version():
|
||||
"""
|
||||
查看当前版本
|
||||
"""
|
||||
return APP_VERSION
|
||||
"""返回当前后端构建版本。"""
|
||||
return runtime_version.get_app_version()
|
||||
|
||||
@staticmethod
|
||||
def get_frontend_version():
|
||||
"""
|
||||
获取前端版本
|
||||
"""
|
||||
if SystemUtils.is_frozen() and SystemUtils.is_windows():
|
||||
config = get_chain_runtime_config_snapshot()
|
||||
version_file = config.config_path.parent / "nginx" / "html" / "version.txt"
|
||||
else:
|
||||
version_file = get_chain_runtime_config_snapshot().frontend_path / "version.txt"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
version = str(f.read()).strip()
|
||||
return version
|
||||
except Exception as err:
|
||||
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
return FRONTEND_VERSION
|
||||
"""返回当前部署的前端资源版本。"""
|
||||
return runtime_version.get_frontend_version()
|
||||
|
||||
+12
-13
@@ -23,7 +23,7 @@ settings = RuntimeSettingsCompat()
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.application.backup import BackupArtifact
|
||||
from app.startup.composition.database import build_database_governance
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version, get_frontend_version
|
||||
|
||||
BACKEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||
BACKEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.stdout.log"
|
||||
@@ -334,7 +334,7 @@ def _apply_prepared_release_update() -> bool:
|
||||
def _resolve_auto_update_targets(mode: str) -> Optional[str]:
|
||||
if mode != "dev":
|
||||
return None
|
||||
backend_prefix = _release_prefix(APP_VERSION)
|
||||
backend_prefix = _release_prefix(get_app_version())
|
||||
current_branch = _git_current_branch()
|
||||
backend_ref = "latest"
|
||||
if not current_branch or current_branch == "HEAD":
|
||||
@@ -876,12 +876,7 @@ def _stop_frontend_service(timeout: int, force: bool) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _installed_frontend_version() -> Optional[str]:
|
||||
if not FRONTEND_VERSION_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return FRONTEND_VERSION_FILE.read_text(encoding="utf-8", errors="replace").strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
return get_frontend_version(fallback_to_declared=False)
|
||||
|
||||
|
||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||
@@ -991,7 +986,9 @@ def start(timeout: int, safe: bool) -> None:
|
||||
raise
|
||||
|
||||
backend_health = backend_result.get("health") or {}
|
||||
backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get("BACKEND_VERSION", APP_VERSION)
|
||||
backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get(
|
||||
"BACKEND_VERSION", get_app_version()
|
||||
)
|
||||
frontend_version = ((frontend_result.get("health") or {}) if isinstance(frontend_result.get("health"), dict) else {}).get("version") or _installed_frontend_version() or "unknown"
|
||||
|
||||
click.echo("MoviePilot 已启动" if backend_result.get("started") or frontend_result.get("started") else "MoviePilot 已在运行")
|
||||
@@ -1058,13 +1055,13 @@ def status() -> None:
|
||||
data = (backend_health or {}).get("data") or {}
|
||||
click.echo(" running (unmanaged)")
|
||||
click.echo(f" URL: {_backend_base_url()}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', get_app_version())}")
|
||||
else:
|
||||
data = (backend_health or {}).get("data") or {}
|
||||
click.echo(f" {'running' if backend_state == 'running' else 'starting'}")
|
||||
click.echo(f" PID: {backend_process.pid}")
|
||||
click.echo(f" URL: {_backend_base_url(backend_runtime)}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', get_app_version())}")
|
||||
click.echo(f" App Log: {BACKEND_APP_LOG_FILE}")
|
||||
click.echo(f" Stdout Log: {BACKEND_STDIO_LOG_FILE}")
|
||||
|
||||
@@ -1305,12 +1302,14 @@ def scheduler_run(job_id: str) -> None:
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
def version() -> None:
|
||||
"""显示版本信息"""
|
||||
click.echo(f"MoviePilot CLI: {APP_VERSION}")
|
||||
click.echo(f"MoviePilot CLI: {get_app_version()}")
|
||||
|
||||
healthy_backend, payload = _backend_health(runtime=_backend_runtime())
|
||||
if healthy_backend:
|
||||
data = (payload or {}).get("data") or {}
|
||||
click.echo(f"Backend Service: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
click.echo(
|
||||
f"Backend Service: {data.get('BACKEND_VERSION', get_app_version())}"
|
||||
)
|
||||
else:
|
||||
click.echo("Backend Service: not running")
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.doctor.models import (
|
||||
DoctorSeverity,
|
||||
)
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
|
||||
class DoctorRunner:
|
||||
@@ -36,7 +36,7 @@ class DoctorRunner:
|
||||
self.deep = deep
|
||||
self.report = DoctorReport(
|
||||
generated_at=datetime.now(),
|
||||
version=APP_VERSION,
|
||||
version=get_app_version(),
|
||||
environment=self._environment(),
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ from app.schemas.openai import (
|
||||
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
|
||||
from app.schemas.response import Response as ApiResponse, ValidationIssue
|
||||
from app.startup.lifecycle import lifespan
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
|
||||
def _get_http_exception_message(detail: Any) -> str:
|
||||
@@ -328,7 +328,7 @@ def create_app() -> FastAPI:
|
||||
configure_observation(build_observation_port())
|
||||
_app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=APP_VERSION,
|
||||
version=get_app_version(),
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""历史版本比较规则。"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
__all__ = ["compare_version"]
|
||||
|
||||
|
||||
_VERSION_LABELS = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
||||
_UNKNOWN_VERSION_LABEL = -5
|
||||
|
||||
@@ -125,6 +125,17 @@
|
||||
"音乐实体类型无效,仅支持 recording 或 album": "Invalid music entity type; only recording or album is supported",
|
||||
"音乐下载只能使用音乐元数据源": "Music downloads can only use music metadata sources",
|
||||
"音乐重新识别只能使用音乐元数据源": "Music re-identification can only use music metadata sources",
|
||||
"读取数据库备份列表失败,请查看日志": "Failed to load database backups. Check the logs for details",
|
||||
"创建数据库备份失败,请查看日志": "Failed to create the database backup. Check the logs for details",
|
||||
"已有数据库备份任务正在执行": "A database backup task is already in progress",
|
||||
"数据库备份文件名无效": "The database backup file name is invalid",
|
||||
"数据库备份不存在": "The database backup does not exist",
|
||||
"校验数据库备份失败,请查看日志": "Failed to verify the database backup. Check the logs for details",
|
||||
"删除数据库备份失败,请查看日志": "Failed to delete the database backup. Check the logs for details",
|
||||
"数据库备份周期格式不正确": "The database backup schedule format is invalid",
|
||||
"数据库备份目录必须是路径字符串": "The database backup directory must be a path string",
|
||||
"数据库备份过期天数必须是大于等于 0 的整数": "Database backup retention days must be an integer greater than or equal to 0",
|
||||
"数据库备份最大保留份数必须是大于等于 0 的整数": "The maximum number of database backups must be an integer greater than or equal to 0",
|
||||
"记录不存在": "Record does not exist",
|
||||
"MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled",
|
||||
"整理记录不存在": "Organization record does not exist",
|
||||
|
||||
@@ -121,6 +121,17 @@
|
||||
"音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album",
|
||||
"音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源",
|
||||
"音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源",
|
||||
"读取数据库备份列表失败,请查看日志": "讀取資料庫備份清單失敗,請查看日誌",
|
||||
"创建数据库备份失败,请查看日志": "建立資料庫備份失敗,請查看日誌",
|
||||
"已有数据库备份任务正在执行": "已有資料庫備份任務正在執行",
|
||||
"数据库备份文件名无效": "資料庫備份檔名無效",
|
||||
"数据库备份不存在": "資料庫備份不存在",
|
||||
"校验数据库备份失败,请查看日志": "驗證資料庫備份失敗,請查看日誌",
|
||||
"删除数据库备份失败,请查看日志": "刪除資料庫備份失敗,請查看日誌",
|
||||
"数据库备份周期格式不正确": "資料庫備份週期格式不正確",
|
||||
"数据库备份目录必须是路径字符串": "資料庫備份目錄必須是路徑字串",
|
||||
"数据库备份过期天数必须是大于等于 0 的整数": "資料庫備份過期天數必須是大於等於 0 的整數",
|
||||
"数据库备份最大保留份数必须是大于等于 0 的整数": "資料庫備份最大保留份數必須是大於等於 0 的整數",
|
||||
"记录不存在": "記錄不存在",
|
||||
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
|
||||
"整理记录不存在": "整理記錄不存在",
|
||||
|
||||
@@ -33,7 +33,7 @@ from app.foundation.environment import (
|
||||
is_frozen,
|
||||
)
|
||||
from app.foundation.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
|
||||
class SystemConfModel(BaseModel):
|
||||
@@ -1061,7 +1061,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
全局用户代理字符串
|
||||
"""
|
||||
return (
|
||||
f"{self.PROJECT_NAME}/{APP_VERSION[1:]} "
|
||||
f"{self.PROJECT_NAME}/{get_app_version()[1:]} "
|
||||
f"({platform.system()} {platform.release()}; {cpu_arch()})"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""当前 MoviePilot 部署的产品版本读取入口。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.foundation.environment import is_frozen, is_windows
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from version import APP_VERSION as _APP_VERSION
|
||||
from version import FRONTEND_VERSION as _FRONTEND_VERSION
|
||||
|
||||
|
||||
def get_app_version() -> str:
|
||||
"""返回当前后端构建的发布版本。"""
|
||||
return str(_APP_VERSION)
|
||||
|
||||
|
||||
def _read_version_file(path: Path) -> str | None:
|
||||
"""读取版本文件,文件缺失、内容为空或读取失败时返回空值。"""
|
||||
try:
|
||||
version = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
return version or None
|
||||
except OSError as error:
|
||||
if path.exists():
|
||||
logger.debug(f"加载版本文件 {path} 出错:{error}")
|
||||
return None
|
||||
|
||||
|
||||
def get_frontend_version(*, fallback_to_declared: bool = True) -> str | None:
|
||||
"""返回当前部署的前端资源版本,并可关闭发布声明回退。"""
|
||||
if is_frozen() and is_windows():
|
||||
version_file = (
|
||||
Path(get_runtime_setting("CONFIG_PATH")).parent
|
||||
/ "nginx"
|
||||
/ "html"
|
||||
/ "version.txt"
|
||||
)
|
||||
else:
|
||||
version_file = Path(get_runtime_setting("FRONTEND_PATH")) / "version.txt"
|
||||
installed_version = _read_version_file(version_file)
|
||||
if installed_version or not fallback_to_declared:
|
||||
return installed_version
|
||||
return str(_FRONTEND_VERSION)
|
||||
@@ -81,6 +81,8 @@ SCHEMA_EXPORTS = {
|
||||
'DashboardMemoryInfo': ('app.schemas.dashboard', 'DashboardMemoryInfo'),
|
||||
'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'),
|
||||
'DataT': ('app.schemas.response', 'DataT'),
|
||||
'DatabaseBackupArtifactData': ('app.schemas.system', 'DatabaseBackupArtifactData'),
|
||||
'DatabaseBackupVerificationData': ('app.schemas.system', 'DatabaseBackupVerificationData'),
|
||||
'Dict': ('app.schemas.mcp', 'Dict'),
|
||||
'DiscoverMediaSource': ('app.schemas.event', 'DiscoverMediaSource'),
|
||||
'DiscoverSourceEventData': ('app.schemas.event', 'DiscoverSourceEventData'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as _DateTime
|
||||
from typing import Optional, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
@@ -216,6 +217,22 @@ class SystemModuleListData(BaseModel):
|
||||
modules: list[SystemModuleInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DatabaseBackupArtifactData(BaseModel): # type: ignore[misc]
|
||||
"""Web 管理端可见的受管数据库备份摘要。"""
|
||||
|
||||
name: str # 受管文件名,不包含宿主目录
|
||||
db_type: str # 创建制品的数据库类型
|
||||
created_at: _DateTime # 从受管文件名解析出的创建时间
|
||||
size: int # 备份文件字节数
|
||||
|
||||
|
||||
class DatabaseBackupVerificationData(BaseModel): # type: ignore[misc]
|
||||
"""受管数据库备份的脱敏校验结果。"""
|
||||
|
||||
valid: bool # 是否通过当前数据库类型的内容校验
|
||||
method: str # SQLite integrity_check 或 PostgreSQL 归档目录校验
|
||||
|
||||
|
||||
class TransferDirectoryConf(BaseModel):
|
||||
"""
|
||||
文件整理目录配置
|
||||
|
||||
+10
-1
@@ -493,12 +493,21 @@ MoviePilot 停止运行后,可通过明确确认执行离线还原:
|
||||
moviepilot database restore <filename> --confirm
|
||||
```
|
||||
|
||||
Docker Compose 部署应复用原服务的环境变量和 `/config` 挂载,在服务停止后运行一次性 CLI:
|
||||
|
||||
```shell
|
||||
docker compose stop <service>
|
||||
docker compose run --rm --no-deps --entrypoint moviepilot <service> database restore <filename> --confirm
|
||||
docker compose start <service>
|
||||
```
|
||||
|
||||
`<service>` 是 Compose 文件中的 MoviePilot 服务名,不是容器名。
|
||||
|
||||
说明:
|
||||
|
||||
- SQLite 使用在线备份 API,PostgreSQL 使用镜像内置的 `pg_dump` custom format
|
||||
- 源码部署使用 PostgreSQL 时,宿主机需安装 `pg_dump` 和 `pg_restore` 并加入 `PATH`;Docker 镜像已内置
|
||||
- 默认目录为配置目录下的 `database_backup/`,可通过 `DB_BACKUP_PATH` 调整
|
||||
- 文件名包含数据库类型和创建时间,例如 `sqlite_20260819_030000.db`
|
||||
- 备份、列举和校验可独立通过 CLI 执行
|
||||
- 还原会覆盖当前数据库,执行前必须停止 MoviePilot;运行中的 Web API 和插件 SDK 不提供还原入口
|
||||
|
||||
|
||||
@@ -29,7 +29,14 @@ _ERROR_LINE = re.compile(r"^(?P<path>.+?):\d+(?::\d+)?: error: .+?(?:\s+\[(?P<co
|
||||
|
||||
def run_mypy() -> str:
|
||||
"""以当前解释器运行全量 mypy 并返回 stdout(非零退出码属于预期结果)。"""
|
||||
command = [sys.executable, "-m", "mypy", *MYPY_TARGETS]
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mypy",
|
||||
"--no-incremental",
|
||||
"--no-pretty",
|
||||
*MYPY_TARGETS,
|
||||
]
|
||||
for pattern in MYPY_EXCLUDES:
|
||||
command += ["--exclude", pattern]
|
||||
result = subprocess.run(
|
||||
|
||||
+28
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6620,
|
||||
"edge_sha256": "da3e1ea903ff03ab300e43cd517e141365c65bf0304c091c22ff989ec8b9e1ae",
|
||||
"edge_count": 6644,
|
||||
"edge_sha256": "6d011ba65df8afe21dec208fd09a3a2a314743ae282db5ce411307b9d967fd00",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -68,6 +68,7 @@
|
||||
"app.adapters.external.market -> app.runtime.observability",
|
||||
"app.adapters.external.market -> app.runtime.settings",
|
||||
"app.adapters.external.market -> app.runtime.tasks",
|
||||
"app.adapters.external.market -> app.runtime.version",
|
||||
"app.adapters.external.ocr -> app.adapters",
|
||||
"app.adapters.external.ocr -> app.adapters.network",
|
||||
"app.adapters.external.ocr -> app.adapters.network.http",
|
||||
@@ -95,6 +96,7 @@
|
||||
"app.adapters.external.server -> app.runtime.observability",
|
||||
"app.adapters.external.server -> app.runtime.settings",
|
||||
"app.adapters.external.server -> app.runtime.tasks",
|
||||
"app.adapters.external.server -> app.runtime.version",
|
||||
"app.adapters.external.server -> app.schemas",
|
||||
"app.adapters.external.server -> app.schemas.media",
|
||||
"app.adapters.external.server -> app.schemas.types",
|
||||
@@ -118,6 +120,8 @@
|
||||
"app.adapters.network.http -> app.runtime.correlation",
|
||||
"app.adapters.observability.otel -> app.runtime",
|
||||
"app.adapters.observability.otel -> app.runtime.observability",
|
||||
"app.adapters.system.backup.files -> app.runtime",
|
||||
"app.adapters.system.backup.files -> app.runtime.version",
|
||||
"app.adapters.system.display -> app.foundation",
|
||||
"app.adapters.system.display -> app.foundation.singleton",
|
||||
"app.adapters.system.display -> app.runtime",
|
||||
@@ -133,6 +137,8 @@
|
||||
"app.adapters.system.fsproxy -> app.runtime.settings",
|
||||
"app.adapters.system.host -> app.foundation",
|
||||
"app.adapters.system.host -> app.foundation.environment",
|
||||
"app.adapters.system.host -> app.runtime",
|
||||
"app.adapters.system.host -> app.runtime.version",
|
||||
"app.adapters.system.host -> app.schemas",
|
||||
"app.adapters.system.host -> app.schemas.dashboard",
|
||||
"app.adapters.system.package -> app.runtime",
|
||||
@@ -182,6 +188,7 @@
|
||||
"app.adapters.system.update -> app.runtime.log",
|
||||
"app.adapters.system.update -> app.runtime.settings",
|
||||
"app.adapters.system.update -> app.runtime.thread",
|
||||
"app.adapters.system.update -> app.runtime.version",
|
||||
"app.adapters.system.update -> app.schemas",
|
||||
"app.adapters.system.update -> app.schemas.system",
|
||||
"app.adapters.web.correlation -> app.runtime",
|
||||
@@ -1924,6 +1931,7 @@
|
||||
"app.api.endpoints.mcp -> app.api.response",
|
||||
"app.api.endpoints.mcp -> app.runtime",
|
||||
"app.api.endpoints.mcp -> app.runtime.log",
|
||||
"app.api.endpoints.mcp -> app.runtime.version",
|
||||
"app.api.endpoints.mcp -> app.schemas",
|
||||
"app.api.endpoints.mcp -> app.schemas.mcp",
|
||||
"app.api.endpoints.mcp -> app.schemas.response",
|
||||
@@ -2289,7 +2297,9 @@
|
||||
"app.api.endpoints.system -> app.api.principal",
|
||||
"app.api.endpoints.system -> app.api.response",
|
||||
"app.api.endpoints.system -> app.application",
|
||||
"app.api.endpoints.system -> app.application.backup",
|
||||
"app.api.endpoints.system -> app.application.configuration",
|
||||
"app.api.endpoints.system -> app.application.database",
|
||||
"app.api.endpoints.system -> app.application.image",
|
||||
"app.api.endpoints.system -> app.application.messaging",
|
||||
"app.api.endpoints.system -> app.application.messaging.message",
|
||||
@@ -2316,11 +2326,13 @@
|
||||
"app.api.endpoints.system -> app.runtime",
|
||||
"app.api.endpoints.system -> app.runtime.config",
|
||||
"app.api.endpoints.system -> app.runtime.events",
|
||||
"app.api.endpoints.system -> app.runtime.execution",
|
||||
"app.api.endpoints.system -> app.runtime.localization",
|
||||
"app.api.endpoints.system -> app.runtime.log",
|
||||
"app.api.endpoints.system -> app.runtime.progress",
|
||||
"app.api.endpoints.system -> app.runtime.scheduling",
|
||||
"app.api.endpoints.system -> app.runtime.state",
|
||||
"app.api.endpoints.system -> app.runtime.version",
|
||||
"app.api.endpoints.system -> app.schemas",
|
||||
"app.api.endpoints.system -> app.schemas.common",
|
||||
"app.api.endpoints.system -> app.schemas.event",
|
||||
@@ -2491,6 +2503,8 @@
|
||||
"app.api.servarr -> app.domain",
|
||||
"app.api.servarr -> app.domain.context",
|
||||
"app.api.servarr -> app.domain.metainfo",
|
||||
"app.api.servarr -> app.runtime",
|
||||
"app.api.servarr -> app.runtime.version",
|
||||
"app.api.servarr -> app.schemas",
|
||||
"app.api.servarr -> app.schemas.response",
|
||||
"app.api.servarr -> app.schemas.servarr",
|
||||
@@ -3432,6 +3446,7 @@
|
||||
"app.chain.system -> app.runtime",
|
||||
"app.chain.system -> app.runtime.log",
|
||||
"app.chain.system -> app.runtime.state",
|
||||
"app.chain.system -> app.runtime.version",
|
||||
"app.chain.system -> app.schemas",
|
||||
"app.chain.system -> app.schemas.message",
|
||||
"app.chain.system -> app.schemas.notification",
|
||||
@@ -3545,6 +3560,7 @@
|
||||
"app.cli -> app.runtime.config",
|
||||
"app.cli -> app.runtime.settings",
|
||||
"app.cli -> app.runtime.state",
|
||||
"app.cli -> app.runtime.version",
|
||||
"app.cli -> app.startup",
|
||||
"app.cli -> app.startup.composition",
|
||||
"app.cli -> app.startup.composition.database",
|
||||
@@ -3846,6 +3862,7 @@
|
||||
"app.doctor.runner -> app.doctor.models",
|
||||
"app.doctor.runner -> app.runtime",
|
||||
"app.doctor.runner -> app.runtime.settings",
|
||||
"app.doctor.runner -> app.runtime.version",
|
||||
"app.domain.context -> app.domain",
|
||||
"app.domain.context -> app.domain.meta",
|
||||
"app.domain.context -> app.domain.meta.metabase",
|
||||
@@ -3960,6 +3977,7 @@
|
||||
"app.factory -> app.runtime.log",
|
||||
"app.factory -> app.runtime.observability",
|
||||
"app.factory -> app.runtime.settings",
|
||||
"app.factory -> app.runtime.version",
|
||||
"app.factory -> app.schemas",
|
||||
"app.factory -> app.schemas.exception",
|
||||
"app.factory -> app.schemas.mcp",
|
||||
@@ -5635,6 +5653,7 @@
|
||||
"app.runtime.config -> app.foundation.url",
|
||||
"app.runtime.config -> app.runtime",
|
||||
"app.runtime.config -> app.runtime.log",
|
||||
"app.runtime.config -> app.runtime.version",
|
||||
"app.runtime.config -> app.schemas",
|
||||
"app.runtime.config -> app.schemas.types",
|
||||
"app.runtime.debounce -> app.runtime",
|
||||
@@ -5859,6 +5878,11 @@
|
||||
"app.runtime.thread -> app.runtime",
|
||||
"app.runtime.thread -> app.runtime.execution",
|
||||
"app.runtime.thread -> app.runtime.settings",
|
||||
"app.runtime.version -> app.foundation",
|
||||
"app.runtime.version -> app.foundation.environment",
|
||||
"app.runtime.version -> app.runtime",
|
||||
"app.runtime.version -> app.runtime.log",
|
||||
"app.runtime.version -> app.runtime.settings",
|
||||
"app.scheduler -> app.adapters",
|
||||
"app.scheduler -> app.adapters.external",
|
||||
"app.scheduler -> app.adapters.external.server",
|
||||
@@ -6637,7 +6661,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 815,
|
||||
"module_count": 816,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -7352,6 +7376,7 @@
|
||||
"app.runtime.tasks",
|
||||
"app.runtime.thread",
|
||||
"app.runtime.topology",
|
||||
"app.runtime.version",
|
||||
"app.scheduler",
|
||||
"app.schemas",
|
||||
"app.schemas.agent",
|
||||
|
||||
+1106
-299
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_app_module_count": 361,
|
||||
"loaded_app_module_count": 362,
|
||||
"max_ms": 904.069,
|
||||
"median_ms": 898.164,
|
||||
"min_ms": 896.39,
|
||||
@@ -17,7 +17,7 @@
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_app_module_count": 373,
|
||||
"loaded_app_module_count": 374,
|
||||
"max_ms": 923.165,
|
||||
"median_ms": 921.768,
|
||||
"min_ms": 921.249,
|
||||
@@ -28,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_app_module_count": 375,
|
||||
"loaded_app_module_count": 376,
|
||||
"max_ms": 1069.392,
|
||||
"median_ms": 1037.036,
|
||||
"min_ms": 1027.603,
|
||||
|
||||
@@ -52,6 +52,10 @@ def test_system_sensitive_read_endpoints_require_superuser():
|
||||
"""系统敏感读取接口必须只允许管理员访问。"""
|
||||
assert _dependency_of(system_endpoint.get_env_setting, "_") is get_current_active_superuser_async
|
||||
assert _dependency_of(system_endpoint.get_setting, "_") is get_current_active_superuser_async
|
||||
assert _dependency_of(system_endpoint.list_database_backups, "_") is get_current_active_superuser_async
|
||||
assert _dependency_of(system_endpoint.create_database_backup, "_") is get_current_active_superuser_async
|
||||
assert _dependency_of(system_endpoint.verify_database_backup, "_") is get_current_active_superuser_async
|
||||
assert _dependency_of(system_endpoint.delete_database_backup, "_") is get_current_active_superuser_async
|
||||
|
||||
|
||||
def test_system_public_read_endpoints_require_active_user():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.db import SessionFactory
|
||||
from app.db.session import SessionFactory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.adapters.system import host as system_module
|
||||
@@ -20,7 +20,7 @@ def test_dashboard_system_info_returns_runtime_environment(monkeypatch):
|
||||
monkeypatch.setattr(system_module.time, "time", lambda: 1000.0)
|
||||
monkeypatch.setattr(system_module.psutil, "Process", FakeProcess)
|
||||
monkeypatch.setattr(SystemUtils, "_operating_system_name", staticmethod(lambda: "Ubuntu 24.04.4 LTS"))
|
||||
monkeypatch.setattr(system_module, "APP_VERSION", "v2.13.16")
|
||||
monkeypatch.setattr(system_module, "get_app_version", lambda: "v2.13.16")
|
||||
|
||||
result = SystemUtils.dashboard_system_info()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.cli import cli
|
||||
from app.sdk import database as database_sdk
|
||||
|
||||
|
||||
NAME = "sqlite_20260819_030000.db"
|
||||
NAME = "moviepilot_v3.0.0_sqlite_20260819_030000.db"
|
||||
|
||||
|
||||
def _artifact(tmp_path: Path) -> BackupArtifact:
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.backup import BackupPolicy, DatabaseBackupService
|
||||
from app.application.backup import (
|
||||
BackupPolicy,
|
||||
DatabaseBackupInProgressError,
|
||||
DatabaseBackupService,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -35,6 +41,21 @@ class _Backend:
|
||||
self.restored = artifact
|
||||
|
||||
|
||||
class _BlockingBackend(_Backend):
|
||||
"""让首个创建停在后端写入阶段,以验证共享服务的并发约束。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.started = Event()
|
||||
self.release = Event()
|
||||
|
||||
def create(self, destination: Path) -> None:
|
||||
self.started.set()
|
||||
if not self.release.wait(timeout=2):
|
||||
raise TimeoutError("测试未释放数据库备份")
|
||||
super().create(destination)
|
||||
|
||||
|
||||
def _service(
|
||||
root: Path,
|
||||
*,
|
||||
@@ -53,7 +74,7 @@ def _service(
|
||||
def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
|
||||
artifact = _service(tmp_path).create()
|
||||
|
||||
assert artifact.name == "sqlite_20260819_134526.db"
|
||||
assert artifact.name == "moviepilot_v3.0.0_sqlite_20260819_134526.db"
|
||||
assert artifact.path.read_bytes() == b"database snapshot"
|
||||
assert stat.S_IMODE(tmp_path.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(artifact.path.stat().st_mode) == 0o600
|
||||
@@ -61,10 +82,29 @@ def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_failed_verification_does_not_publish_artifact(tmp_path: Path) -> None:
|
||||
backend = _Backend(valid=False)
|
||||
service = _service(tmp_path, backend=backend)
|
||||
with pytest.raises(RuntimeError, match="数据库备份校验失败"):
|
||||
_service(tmp_path, backend=_Backend(valid=False)).create()
|
||||
service.create()
|
||||
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
backend.valid = True
|
||||
assert service.create().path.is_file()
|
||||
|
||||
|
||||
def test_concurrent_create_is_rejected_without_waiting(tmp_path: Path) -> None:
|
||||
backend = _BlockingBackend()
|
||||
service = _service(tmp_path, backend=backend)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
first = executor.submit(service.create)
|
||||
assert backend.started.wait(timeout=1)
|
||||
with pytest.raises(DatabaseBackupInProgressError, match="正在执行"):
|
||||
service.create()
|
||||
backend.release.set()
|
||||
artifact = first.result(timeout=2)
|
||||
|
||||
assert artifact.path.is_file()
|
||||
|
||||
|
||||
def test_same_second_backups_receive_short_sequence_suffix(tmp_path: Path) -> None:
|
||||
@@ -73,8 +113,17 @@ def test_same_second_backups_receive_short_sequence_suffix(tmp_path: Path) -> No
|
||||
first = service.create()
|
||||
second = service.create()
|
||||
|
||||
assert first.name == "sqlite_20260819_134526.db"
|
||||
assert second.name == "sqlite_20260819_134526_1.db"
|
||||
assert first.name == "moviepilot_v3.0.0_sqlite_20260819_134526.db"
|
||||
assert second.name == "moviepilot_v3.0.0_sqlite_20260819_134526_1.db"
|
||||
|
||||
|
||||
def test_backup_name_uses_application_release_version(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.backup.files.get_app_version",
|
||||
lambda: "v4.2.1",
|
||||
)
|
||||
|
||||
assert _service(tmp_path).create().name == "moviepilot_v4.2.1_sqlite_20260819_134526.db"
|
||||
|
||||
|
||||
def test_retention_applies_after_new_artifact_is_available(tmp_path: Path) -> None:
|
||||
@@ -99,6 +148,32 @@ def test_list_ignores_unmanaged_files_and_rejects_paths(tmp_path: Path) -> None:
|
||||
_service(tmp_path).verify("../user.db")
|
||||
|
||||
|
||||
def test_legacy_backup_name_remains_managed(tmp_path: Path) -> None:
|
||||
"""升级前生成的无代际前缀备份仍可列出、校验和删除。"""
|
||||
legacy = tmp_path / "sqlite_20260818_030000.db"
|
||||
legacy.write_bytes(b"database snapshot")
|
||||
service = _service(tmp_path)
|
||||
|
||||
assert [item.name for item in service.list()] == [legacy.name]
|
||||
assert service.verify(legacy.name).valid is True
|
||||
service.delete(legacy.name)
|
||||
assert legacy.exists() is False
|
||||
|
||||
|
||||
def test_delete_removes_only_named_managed_backup(tmp_path: Path) -> None:
|
||||
service = _service(tmp_path)
|
||||
artifact = service.create()
|
||||
unmanaged = tmp_path / "notes.txt"
|
||||
unmanaged.write_text("keep", encoding="utf-8")
|
||||
|
||||
service.delete(artifact.name)
|
||||
|
||||
assert artifact.path.exists() is False
|
||||
assert unmanaged.exists() is True
|
||||
with pytest.raises(ValueError, match="文件名"):
|
||||
service.delete("../notes.txt")
|
||||
|
||||
|
||||
def test_restore_requires_matching_database_type(tmp_path: Path) -> None:
|
||||
backend = _Backend()
|
||||
service = _service(tmp_path, backend=backend)
|
||||
@@ -109,7 +184,7 @@ def test_restore_requires_matching_database_type(tmp_path: Path) -> None:
|
||||
assert restored.name == artifact.name
|
||||
assert backend.restored == artifact.path
|
||||
|
||||
postgres = tmp_path / "postgresql_20260819_134526.dump"
|
||||
postgres = tmp_path / "moviepilot_v3.0.0_postgresql_20260819_134526.dump"
|
||||
postgres.write_bytes(b"database snapshot")
|
||||
with pytest.raises(ValueError, match="当前数据库类型"):
|
||||
service.restore(postgres.name)
|
||||
|
||||
@@ -437,7 +437,7 @@ def downgrade():
|
||||
|
||||
db_init.prepare_database()
|
||||
|
||||
artifacts = sorted((tmp_path / "backups").glob("sqlite_*.db"))
|
||||
artifacts = sorted((tmp_path / "backups").glob("moviepilot_*_sqlite_*.db"))
|
||||
assert len(artifacts) == 1
|
||||
with create_engine(f"sqlite:///{artifacts[0]}").connect() as connection:
|
||||
backup_revision = connection.execute(
|
||||
|
||||
@@ -43,10 +43,10 @@ def test_doctor_reports_valid_backup_when_sqlite_database_is_corrupt(
|
||||
(tmp_path / "user.db").write_bytes(b"not a sqlite database")
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
backup = backup_dir / "sqlite_20260822_030000.db"
|
||||
backup = backup_dir / "moviepilot_v3.0.0_sqlite_20260822_030000.db"
|
||||
with sqlite3.connect(backup) as connection:
|
||||
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
|
||||
(backup_dir / "sqlite_20260822_040000.db").write_bytes(b"invalid newer backup")
|
||||
(backup_dir / "moviepilot_v3.0.0_sqlite_20260822_040000.db").write_bytes(b"invalid newer backup")
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_database(runner)
|
||||
@@ -58,7 +58,7 @@ def test_doctor_reports_valid_backup_when_sqlite_database_is_corrupt(
|
||||
assert finding.context["backups"][0]["valid"] is False
|
||||
assert finding.context["backups"][1]["valid"] is True
|
||||
assert finding.context["restore_command"] == (
|
||||
"moviepilot database restore sqlite_20260822_030000.db --confirm"
|
||||
"moviepilot database restore moviepilot_v3.0.0_sqlite_20260822_030000.db --confirm"
|
||||
)
|
||||
assert finding.context["restore_command"] in finding.recommendation
|
||||
|
||||
@@ -74,13 +74,13 @@ def test_doctor_distinguishes_missing_and_mismatched_backups(tmp_path, monkeypat
|
||||
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
(backup_dir / "postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
|
||||
(backup_dir / "moviepilot_v3.0.0_postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
|
||||
runner = DoctorRunner()
|
||||
checks._check_database_backups(runner)
|
||||
mismatched = runner.report.find("database.backup_recovery")
|
||||
assert mismatched is not None
|
||||
assert mismatched.status == DoctorFindingStatus.Degraded
|
||||
assert mismatched.context["mismatched"] == ["postgresql_20260822_030000.dump"]
|
||||
assert mismatched.context["mismatched"] == ["moviepilot_v3.0.0_postgresql_20260822_030000.dump"]
|
||||
|
||||
|
||||
def test_doctor_reports_invalid_backup_without_modifying_it(tmp_path, monkeypatch):
|
||||
@@ -88,7 +88,7 @@ def test_doctor_reports_invalid_backup_without_modifying_it(tmp_path, monkeypatc
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
backup = backup_dir / "sqlite_20260822_030000.db"
|
||||
backup = backup_dir / "moviepilot_v3.0.0_sqlite_20260822_030000.db"
|
||||
original = b"invalid sqlite backup"
|
||||
backup.write_bytes(original)
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_doctor_exposes_missing_pg_restore_in_text_finding(tmp_path, monkeypatch
|
||||
monkeypatch.setattr(settings, "DB_TYPE", "postgresql")
|
||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||
backup_dir.mkdir(parents=True)
|
||||
(backup_dir / "postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
|
||||
(backup_dir / "moviepilot_v3.0.0_postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
|
||||
monkeypatch.setattr(checks, "verify_database_backup", missing_pg_restore)
|
||||
|
||||
runner = DoctorRunner()
|
||||
|
||||
@@ -236,6 +236,63 @@ def test_locale_helper_translates_common_backend_response_messages():
|
||||
assert LocaleHelper.translate_text(message, locale="en-US") == expected
|
||||
|
||||
|
||||
def test_database_backup_messages_have_english_and_traditional_chinese() -> None:
|
||||
"""数据库备份 API 与配置校验文案应覆盖所有受支持的非默认语言。"""
|
||||
samples = {
|
||||
"读取数据库备份列表失败,请查看日志": (
|
||||
"Failed to load database backups. Check the logs for details",
|
||||
"讀取資料庫備份清單失敗,請查看日誌",
|
||||
),
|
||||
"创建数据库备份失败,请查看日志": (
|
||||
"Failed to create the database backup. Check the logs for details",
|
||||
"建立資料庫備份失敗,請查看日誌",
|
||||
),
|
||||
"已有数据库备份任务正在执行": (
|
||||
"A database backup task is already in progress",
|
||||
"已有資料庫備份任務正在執行",
|
||||
),
|
||||
"数据库备份文件名无效": (
|
||||
"The database backup file name is invalid",
|
||||
"資料庫備份檔名無效",
|
||||
),
|
||||
"数据库备份不存在": (
|
||||
"The database backup does not exist",
|
||||
"資料庫備份不存在",
|
||||
),
|
||||
"校验数据库备份失败,请查看日志": (
|
||||
"Failed to verify the database backup. Check the logs for details",
|
||||
"驗證資料庫備份失敗,請查看日誌",
|
||||
),
|
||||
"删除数据库备份失败,请查看日志": (
|
||||
"Failed to delete the database backup. Check the logs for details",
|
||||
"刪除資料庫備份失敗,請查看日誌",
|
||||
),
|
||||
"数据库备份周期格式不正确": (
|
||||
"The database backup schedule format is invalid",
|
||||
"資料庫備份週期格式不正確",
|
||||
),
|
||||
"数据库备份目录必须是路径字符串": (
|
||||
"The database backup directory must be a path string",
|
||||
"資料庫備份目錄必須是路徑字串",
|
||||
),
|
||||
"数据库备份过期天数必须是大于等于 0 的整数": (
|
||||
"Database backup retention days must be an integer greater than or equal to 0",
|
||||
"資料庫備份過期天數必須是大於等於 0 的整數",
|
||||
),
|
||||
"数据库备份最大保留份数必须是大于等于 0 的整数": (
|
||||
"The maximum number of database backups must be an integer greater than or equal to 0",
|
||||
"資料庫備份最大保留份數必須是大於等於 0 的整數",
|
||||
),
|
||||
}
|
||||
|
||||
for message, (english, traditional_chinese) in samples.items():
|
||||
assert LocaleHelper.translate_text(message, locale="en-US") == english
|
||||
assert (
|
||||
LocaleHelper.translate_text(message, locale="zh-TW")
|
||||
== traditional_chinese
|
||||
)
|
||||
|
||||
|
||||
def test_response_localizes_message_from_locale_context():
|
||||
"""通用 Response 应根据请求语言上下文直接翻译消息。"""
|
||||
token = LocaleHelper.set_current_locale("en-US")
|
||||
|
||||
+16
-2
@@ -1,6 +1,8 @@
|
||||
"""mypy 错误数只降不增 ratchet 的解析与对比逻辑测试。"""
|
||||
"""mypy 错误数只降不增 ratchet 的执行、解析与对比逻辑测试。"""
|
||||
|
||||
from scripts.architecture.mypy_ratchet import compare_counts, parse_errors
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts.architecture.mypy_ratchet import compare_counts, parse_errors, run_mypy
|
||||
|
||||
|
||||
MYPY_SAMPLE = """
|
||||
@@ -13,6 +15,18 @@ Found 3 errors in 1 file (checked 500 source files)
|
||||
"""
|
||||
|
||||
|
||||
def test_run_mypy_uses_stable_full_analysis() -> None:
|
||||
"""全量门禁不得复用前序检查缓存,也不得输出换行错误码。"""
|
||||
with patch("scripts.architecture.mypy_ratchet.subprocess.run") as run:
|
||||
run.return_value.stdout = ""
|
||||
|
||||
run_mypy()
|
||||
|
||||
command = run.call_args.args[0]
|
||||
assert "--no-incremental" in command
|
||||
assert "--no-pretty" in command
|
||||
|
||||
|
||||
def test_parse_errors_aggregates_per_file_and_code() -> None:
|
||||
"""错误行按文件与错误码聚合,源码上下文与摘要行不计入。"""
|
||||
report = parse_errors(MYPY_SAMPLE)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""系统数据库备份 Web 管理端点测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
from app.api.dependencies.auth import get_current_active_superuser_async
|
||||
from app.application.backup import (
|
||||
BackupVerification,
|
||||
DatabaseBackupInProgressError,
|
||||
)
|
||||
|
||||
|
||||
class _Governance:
|
||||
"""提供端点测试所需的最小数据库治理合同。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.artifact = SimpleNamespace(
|
||||
name="moviepilot_v3.0.0_sqlite_20260825_120000.db",
|
||||
db_type="sqlite",
|
||||
created_at=datetime(2026, 8, 25, 12, 0, 0),
|
||||
path=Path("/private/database_backup/moviepilot_v3.0.0_sqlite_20260825_120000.db"),
|
||||
size=4096,
|
||||
)
|
||||
|
||||
def list_backups(self):
|
||||
return (self.artifact,)
|
||||
|
||||
def create_backup(self):
|
||||
return self.artifact
|
||||
|
||||
def verify_backup(self, name: str):
|
||||
if "/" in name:
|
||||
raise ValueError("数据库备份文件名不能包含路径")
|
||||
return BackupVerification(True, "PRAGMA integrity_check", "private detail")
|
||||
|
||||
def delete_backup(self, name: str):
|
||||
if "/" in name:
|
||||
raise ValueError("数据库备份文件名不能包含路径")
|
||||
if name == "missing.db":
|
||||
raise FileNotFoundError(name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_database_backups_maps_public_fields(monkeypatch) -> None:
|
||||
"""列表不得把内部备份路径投影到 Web 响应。"""
|
||||
monkeypatch.setattr(system_endpoint, "get_database_governance", _Governance)
|
||||
|
||||
result = await system_endpoint.list_database_backups(_=object())
|
||||
|
||||
assert [item.model_dump() for item in result] == [
|
||||
{
|
||||
"name": "moviepilot_v3.0.0_sqlite_20260825_120000.db",
|
||||
"db_type": "sqlite",
|
||||
"created_at": datetime(2026, 8, 25, 12, 0, 0),
|
||||
"size": 4096,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_database_backup_reports_busy_state(monkeypatch) -> None:
|
||||
"""重复创建应返回冲突,不排队创建第二份备份。"""
|
||||
governance = _Governance()
|
||||
monkeypatch.setattr(
|
||||
governance,
|
||||
"create_backup",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
DatabaseBackupInProgressError("已有数据库备份任务正在执行")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance)
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await system_endpoint.create_database_backup(_=object())
|
||||
|
||||
assert error.value.status_code == 409
|
||||
assert error.value.detail == "已有数据库备份任务正在执行"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_database_backup_rejects_path_input(monkeypatch) -> None:
|
||||
"""校验端点只接受受管文件名。"""
|
||||
monkeypatch.setattr(system_endpoint, "get_database_governance", _Governance)
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await system_endpoint.verify_database_backup("../user.db", _=object())
|
||||
|
||||
assert error.value.status_code == 400
|
||||
assert error.value.detail == "数据库备份文件名无效"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_database_backup_accepts_only_managed_name(monkeypatch) -> None:
|
||||
governance = _Governance()
|
||||
monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance)
|
||||
|
||||
response = await system_endpoint.delete_database_backup(
|
||||
governance.artifact.name,
|
||||
_=object(),
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
|
||||
with pytest.raises(HTTPException) as invalid:
|
||||
await system_endpoint.delete_database_backup("../user.db", _=object())
|
||||
assert invalid.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_database_backup_uses_host_response_envelope(monkeypatch) -> None:
|
||||
"""Web 客户端必须收到统一响应,不能把成功删除误判为空响应错误。"""
|
||||
governance = _Governance()
|
||||
monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance)
|
||||
app = FastAPI()
|
||||
app.include_router(system_endpoint.router, prefix="/api/v1/system")
|
||||
app.dependency_overrides[get_current_active_superuser_async] = lambda: object()
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.delete(
|
||||
f"/api/v1/system/database/backups/{governance.artifact.name}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"success": True, "message": "", "data": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_database_backups_does_not_block_event_loop(monkeypatch) -> None:
|
||||
"""文件系统或数据库工具等待必须在线程边界内执行。"""
|
||||
started = Event()
|
||||
release = Event()
|
||||
governance = _Governance()
|
||||
|
||||
def blocking_list():
|
||||
started.set()
|
||||
if not release.wait(timeout=2):
|
||||
raise TimeoutError("测试未释放数据库备份列表")
|
||||
return (governance.artifact,)
|
||||
|
||||
monkeypatch.setattr(governance, "list_backups", blocking_list)
|
||||
monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance)
|
||||
|
||||
task = asyncio.create_task(system_endpoint.list_database_backups(_=object()))
|
||||
for _ in range(100):
|
||||
if started.is_set():
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
assert started.is_set()
|
||||
assert task.done() is False
|
||||
release.set()
|
||||
|
||||
assert len(await task) == 1
|
||||
@@ -39,7 +39,7 @@ def test_check_exposes_new_stable_release(monkeypatch, tmp_path):
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(manager, "_request", lambda: SimpleNamespace(get_res=lambda _url: _response(releases)))
|
||||
monkeypatch.setattr(update_module, "APP_VERSION", "v3.0.0")
|
||||
monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.0.0")
|
||||
|
||||
status = manager.check()
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.chain.system import SystemChain
|
||||
from app.runtime import version as runtime_version
|
||||
|
||||
|
||||
def test_installed_frontend_version_prefers_deployed_resource(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
frontend_path = tmp_path / "public"
|
||||
frontend_path.mkdir()
|
||||
(frontend_path / "version.txt").write_text("v3.2.1\n", encoding="utf-8")
|
||||
monkeypatch.setattr(runtime_version, "is_frozen", lambda: False)
|
||||
monkeypatch.setattr(runtime_version, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
runtime_version,
|
||||
"get_runtime_setting",
|
||||
lambda key: frontend_path if key == "FRONTEND_PATH" else tmp_path / "config",
|
||||
)
|
||||
|
||||
assert runtime_version.get_frontend_version() == "v3.2.1"
|
||||
assert SystemChain.get_frontend_version() == "v3.2.1"
|
||||
|
||||
|
||||
def test_installed_frontend_version_falls_back_to_release_declaration(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(runtime_version, "is_frozen", lambda: False)
|
||||
monkeypatch.setattr(runtime_version, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(runtime_version, "_FRONTEND_VERSION", "v3.0.0")
|
||||
monkeypatch.setattr(
|
||||
runtime_version,
|
||||
"get_runtime_setting",
|
||||
lambda key: tmp_path / key.lower(),
|
||||
)
|
||||
|
||||
assert runtime_version.get_frontend_version() == "v3.0.0"
|
||||
assert (
|
||||
runtime_version.get_frontend_version(fallback_to_declared=False) is None
|
||||
)
|
||||
Reference in New Issue
Block a user