feat: 完善数据库备份管理与版本读取 (#6450)

This commit is contained in:
InfinityPacer
2026-08-25 16:05:45 +08:00
committed by GitHub
parent b952a3e407
commit d58c8d2b17
38 changed files with 1846 additions and 459 deletions
+2 -11
View File
@@ -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/
+5 -5
View File
@@ -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
+3 -22
View File
@@ -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(),
+15 -3
View File
@@ -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():
+2 -2
View File
@@ -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
+10 -6
View File
@@ -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",
+2 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+2 -2
View File
@@ -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
View File
@@ -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
)
+4
View File
@@ -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
+11
View File
@@ -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",
+11
View File
@@ -121,6 +121,17 @@
"音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album",
"音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源",
"音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源",
"读取数据库备份列表失败,请查看日志": "讀取資料庫備份清單失敗,請查看日誌",
"创建数据库备份失败,请查看日志": "建立資料庫備份失敗,請查看日誌",
"已有数据库备份任务正在执行": "已有資料庫備份任務正在執行",
"数据库备份文件名无效": "資料庫備份檔名無效",
"数据库备份不存在": "資料庫備份不存在",
"校验数据库备份失败,请查看日志": "驗證資料庫備份失敗,請查看日誌",
"删除数据库备份失败,请查看日志": "刪除資料庫備份失敗,請查看日誌",
"数据库备份周期格式不正确": "資料庫備份週期格式不正確",
"数据库备份目录必须是路径字符串": "資料庫備份目錄必須是路徑字串",
"数据库备份过期天数必须是大于等于 0 的整数": "資料庫備份過期天數必須是大於等於 0 的整數",
"数据库备份最大保留份数必须是大于等于 0 的整数": "資料庫備份最大保留份數必須是大於等於 0 的整數",
"记录不存在": "記錄不存在",
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
"整理记录不存在": "整理記錄不存在",
+2 -2
View File
@@ -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()})"
)
+42
View File
@@ -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)
+2
View File
@@ -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'),
+17
View File
@@ -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
View File
@@ -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 使用在线备份 APIPostgreSQL 使用镜像内置的 `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 不提供还原入口
+8 -1
View File
@@ -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
View File
@@ -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",
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,
+4
View File
@@ -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():
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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:
+81 -6
View File
@@ -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)
+1 -1
View File
@@ -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(
+7 -7
View File
@@ -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()
+57
View File
@@ -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
View File
@@ -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)
+167
View File
@@ -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
+1 -1
View File
@@ -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()
+42
View File
@@ -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
)