mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
feat: 完善数据库备份管理与版本读取 (#6450)
This commit is contained in:
+2
-11
@@ -15,17 +15,8 @@ app/application/site/*.bin
|
|||||||
app/helper/*.bin
|
app/helper/*.bin
|
||||||
app/plugins/**
|
app/plugins/**
|
||||||
!app/plugins/__init__.py
|
!app/plugins/__init__.py
|
||||||
config/cookies/
|
config/*
|
||||||
config/app.env
|
!config/category.yaml
|
||||||
config/user.db*
|
|
||||||
config/systemconfig.db*
|
|
||||||
config/sites/**
|
|
||||||
config/agent/
|
|
||||||
config/logs/
|
|
||||||
config/plugins/
|
|
||||||
config/temp/
|
|
||||||
config/cache/
|
|
||||||
config/.cache/
|
|
||||||
# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物
|
# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物
|
||||||
app/config/
|
app/config/
|
||||||
.verify_tmp/
|
.verify_tmp/
|
||||||
|
|||||||
Vendored
+5
-5
@@ -62,7 +62,7 @@ from app.foundation.singleton import WeakSingleton
|
|||||||
from app.foundation.version import compare_version
|
from app.foundation.version import compare_version
|
||||||
from app.adapters.system.host import SystemUtils
|
from app.adapters.system.host import SystemUtils
|
||||||
from app.foundation.url import UrlUtils
|
from app.foundation.url import UrlUtils
|
||||||
from version import APP_VERSION
|
from app.runtime.version import get_app_version
|
||||||
|
|
||||||
# 保留模块级可替换入口,代理默认读取组合根的最新 runtime 配置。
|
# 保留模块级可替换入口,代理默认读取组合根的最新 runtime 配置。
|
||||||
settings = RuntimeSettingsCompat()
|
settings = RuntimeSettingsCompat()
|
||||||
@@ -289,9 +289,9 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。
|
解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return Version(str(APP_VERSION))
|
return Version(get_app_version())
|
||||||
except InvalidVersion:
|
except InvalidVersion:
|
||||||
logger.error(f"当前主程序版本号无法解析:{APP_VERSION}")
|
logger.error(f"当前主程序版本号无法解析:{get_app_version()}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -402,7 +402,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
|
|
||||||
system_version = cls.get_current_system_version()
|
system_version = cls.get_current_system_version()
|
||||||
if system_version is None:
|
if system_version is None:
|
||||||
return False, f"当前 MoviePilot 版本 {APP_VERSION} 无法解析,已拒绝安装带版本限制的插件"
|
return False, f"当前 MoviePilot 版本 {get_app_version()} 无法解析,已拒绝安装带版本限制的插件"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
specifier_set = SpecifierSet(raw_specifier)
|
specifier_set = SpecifierSet(raw_specifier)
|
||||||
@@ -416,7 +416,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
return False, (
|
return False, (
|
||||||
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {APP_VERSION} 不满足,已拒绝安装"
|
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装"
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
Vendored
+3
-22
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import platform
|
import platform
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||||
from urllib.parse import parse_qs, quote, urlparse, urlsplit
|
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.domain.media import normalize_music_type
|
||||||
from app.schemas.media import resolve_media_identity
|
from app.schemas.media import resolve_media_identity
|
||||||
from app.adapters.system.host import SystemUtils
|
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 代理动态读取配置。
|
# 保留旧插件可覆盖的模块级入口,默认通过 runtime 代理动态读取配置。
|
||||||
@@ -270,24 +269,6 @@ class MoviePilotServerHelper:
|
|||||||
or permissions.get("workflow_share_manage")
|
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
|
@classmethod
|
||||||
def build_usage_payload(cls) -> Dict[str, Any]:
|
def build_usage_payload(cls) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -295,8 +276,8 @@ class MoviePilotServerHelper:
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"user_uid": cls.get_user_uid(),
|
"user_uid": cls.get_user_uid(),
|
||||||
"backend_version": APP_VERSION,
|
"backend_version": get_app_version(),
|
||||||
"frontend_version": cls.get_frontend_version(),
|
"frontend_version": get_frontend_version(),
|
||||||
"version_flag": settings.VERSION_FLAG,
|
"version_flag": settings.VERSION_FLAG,
|
||||||
"platform": f"{platform.system()} {platform.release()}".strip(),
|
"platform": f"{platform.system()} {platform.release()}".strip(),
|
||||||
"arch": SystemUtils.cpu_arch(),
|
"arch": SystemUtils.cpu_arch(),
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import tempfile
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.runtime.version import get_app_version
|
||||||
|
|
||||||
_BACKUP_NAME = re.compile(
|
_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<timestamp>\d{8}_\d{6})"
|
||||||
r"(?:_(?P<sequence>\d+))?"
|
r"(?:_(?P<sequence>\d+))?"
|
||||||
r"(?P<suffix>\.db|\.dump)$"
|
r"(?P<suffix>\.db|\.dump)$"
|
||||||
)
|
)
|
||||||
|
_RELEASE_VERSION = re.compile(r"^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||||
|
|
||||||
|
|
||||||
class BackupFiles:
|
class BackupFiles:
|
||||||
@@ -68,10 +71,19 @@ class BackupFiles:
|
|||||||
"""删除一个已通过名称约束的备份文件。"""
|
"""删除一个已通过名称约束的备份文件。"""
|
||||||
self.resolve(name).unlink()
|
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")
|
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}"
|
candidate = f"{base}{suffix}"
|
||||||
sequence = 1
|
sequence = 1
|
||||||
while (self.root / candidate).exists():
|
while (self.root / candidate).exists():
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import psutil
|
|||||||
from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo
|
from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo
|
||||||
from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo
|
from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo
|
||||||
from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo
|
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 (
|
from app.foundation.environment import (
|
||||||
is_aarch,
|
is_aarch,
|
||||||
is_aarch64,
|
is_aarch64,
|
||||||
@@ -948,7 +948,7 @@ class SystemUtils:
|
|||||||
hostname=socket.gethostname(),
|
hostname=socket.gethostname(),
|
||||||
operating_system=SystemUtils._operating_system_name(),
|
operating_system=SystemUtils._operating_system_name(),
|
||||||
runtime=max(0, int(time.time() - psutil.Process().create_time())),
|
runtime=max(0, int(time.time() - psutil.Process().create_time())),
|
||||||
version=APP_VERSION,
|
version=get_app_version(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ from typing import Any, Optional
|
|||||||
from app.adapters.network.http import RequestUtils
|
from app.adapters.network.http import RequestUtils
|
||||||
from app.foundation.singleton import SingletonClass
|
from app.foundation.singleton import SingletonClass
|
||||||
from app.foundation.version import compare_version
|
from app.foundation.version import compare_version
|
||||||
|
from app.runtime.version import get_app_version
|
||||||
from app.foundation.environment import is_docker
|
from app.foundation.environment import is_docker
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.runtime.settings import get_runtime_setting
|
from app.runtime.settings import get_runtime_setting
|
||||||
from app.runtime.thread import ThreadHelper
|
from app.runtime.thread import ThreadHelper
|
||||||
from app.schemas.system import SystemUpdateStatus
|
from app.schemas.system import SystemUpdateStatus
|
||||||
from version import APP_VERSION
|
|
||||||
|
|
||||||
|
|
||||||
class SystemUpdateManager(metaclass=SingletonClass):
|
class SystemUpdateManager(metaclass=SingletonClass):
|
||||||
@@ -66,13 +66,17 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
def _default_state(self) -> dict[str, Any]:
|
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]:
|
def _read_state(self) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(self._state_file.read_text(encoding="utf-8"))
|
payload = json.loads(self._state_file.read_text(encoding="utf-8"))
|
||||||
if isinstance(payload, dict):
|
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):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
return self._default_state()
|
return self._default_state()
|
||||||
@@ -81,7 +85,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
state = self._read_state()
|
state = self._read_state()
|
||||||
state.update(changes)
|
state.update(changes)
|
||||||
state["current_version"] = APP_VERSION
|
state["current_version"] = get_app_version()
|
||||||
state["progress"] = self._progress(
|
state["progress"] = self._progress(
|
||||||
state.get("downloaded_bytes", 0), state.get("total_bytes", 0)
|
state.get("downloaded_bytes", 0), state.get("total_bytes", 0)
|
||||||
)
|
)
|
||||||
@@ -117,7 +121,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
|||||||
can_update=True,
|
can_update=True,
|
||||||
can_install=False,
|
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)
|
self._install_file.unlink(missing_ok=True)
|
||||||
state = self._write_state(
|
state = self._write_state(
|
||||||
state="idle",
|
state="idle",
|
||||||
@@ -161,7 +165,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
|||||||
if not release:
|
if not release:
|
||||||
raise RuntimeError("未找到可用的 v3 稳定版本")
|
raise RuntimeError("未找到可用的 v3 稳定版本")
|
||||||
version = str(release["tag_name"])
|
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(
|
return SystemUpdateStatus.model_validate(
|
||||||
self._write_state(
|
self._write_state(
|
||||||
state="available" if has_update else "idle",
|
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.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||||
from app.agent.tools.manager import moviepilot_tool_manager
|
from app.agent.tools.manager import moviepilot_tool_manager
|
||||||
from app.adapters.web.security.access import verify_apikey
|
from app.adapters.web.security.access import verify_apikey
|
||||||
|
from app.runtime.version import get_app_version
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
|
|
||||||
# 导入版本号
|
|
||||||
try:
|
|
||||||
from version import APP_VERSION
|
|
||||||
except ImportError:
|
|
||||||
APP_VERSION = "unknown"
|
|
||||||
|
|
||||||
router = ResponseAPIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
# MCP 协议版本
|
# MCP 协议版本
|
||||||
@@ -213,7 +208,7 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
},
|
},
|
||||||
"serverInfo": {
|
"serverInfo": {
|
||||||
"name": "MoviePilot",
|
"name": "MoviePilot",
|
||||||
"version": APP_VERSION,
|
"version": get_app_version(),
|
||||||
"description": "MoviePilot MCP Server - 电影自动化管理工具",
|
"description": "MoviePilot MCP Server - 电影自动化管理工具",
|
||||||
},
|
},
|
||||||
"instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。",
|
"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.common import ValueData as _SchemaValueData
|
||||||
from app.schemas.response import Response as _SchemaResponse
|
from app.schemas.response import Response as _SchemaResponse
|
||||||
from app.schemas.system import NetTestTarget as _SchemaNetTestTarget
|
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 PluginMarketSyncData as _SchemaPluginMarketSyncData
|
||||||
from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest
|
from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest
|
||||||
from app.schemas.system import RuleTestData as _SchemaRuleTestData
|
from app.schemas.system import RuleTestData as _SchemaRuleTestData
|
||||||
@@ -46,6 +48,8 @@ from app.application.configuration import (
|
|||||||
get_configured_system_config,
|
get_configured_system_config,
|
||||||
get_runtime_settings,
|
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.application.plugin.runtime import plugin_system_config_mutation
|
||||||
from app.api.dependencies.auth import (
|
from app.api.dependencies.auth import (
|
||||||
get_current_active_superuser,
|
get_current_active_superuser,
|
||||||
@@ -67,6 +71,7 @@ from app.application.rules import RuleHelper
|
|||||||
from app.adapters.external.server import MoviePilotServerHelper
|
from app.adapters.external.server import MoviePilotServerHelper
|
||||||
from app.runtime.state import SystemHelper
|
from app.runtime.state import SystemHelper
|
||||||
from app.runtime.log import logger
|
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.application.scheduling import get_scheduler
|
||||||
from app.schemas.event import ConfigChangeEventData
|
from app.schemas.event import ConfigChangeEventData
|
||||||
from app.schemas.exception import PluginMutationRejectedError
|
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.security.url import SecurityUtils
|
||||||
from app.application.network import NetworkTestService
|
from app.application.network import NetworkTestService
|
||||||
from app.foundation.url import UrlUtils
|
from app.foundation.url import UrlUtils
|
||||||
from version import APP_VERSION
|
from app.runtime.version import get_app_version, get_frontend_version
|
||||||
|
|
||||||
router = ResponseAPIRouter()
|
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]:
|
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
|
||||||
"""校验强制服务端联网搜索配置,返回用户可读错误信息。"""
|
"""校验强制服务端联网搜索配置,返回用户可读错误信息。"""
|
||||||
from app.agent.llm.server_tools import (
|
from app.agent.llm.server_tools import (
|
||||||
@@ -759,8 +774,8 @@ def get_global_setting(token: str):
|
|||||||
# 追加版本信息(用于版本检查)
|
# 追加版本信息(用于版本检查)
|
||||||
info.update(
|
info.update(
|
||||||
{
|
{
|
||||||
"FRONTEND_VERSION": SystemChain.get_frontend_version(),
|
"FRONTEND_VERSION": get_frontend_version(),
|
||||||
"BACKEND_VERSION": APP_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)
|
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(
|
@router.get(
|
||||||
"/env",
|
"/env",
|
||||||
summary="查询系统配置",
|
summary="查询系统配置",
|
||||||
@@ -828,10 +936,10 @@ async def get_env_setting(
|
|||||||
)
|
)
|
||||||
info.update(
|
info.update(
|
||||||
{
|
{
|
||||||
"VERSION": APP_VERSION,
|
"VERSION": get_app_version(),
|
||||||
"AUTH_VERSION": SitesHelper().auth_version,
|
"AUTH_VERSION": SitesHelper().auth_version,
|
||||||
"INDEXER_VERSION": SitesHelper().indexer_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": rust_accel.is_config_enabled(),
|
||||||
"RUST_ACCEL_AVAILABLE": rust_accel.is_available(),
|
"RUST_ACCEL_AVAILABLE": rust_accel.is_available(),
|
||||||
"RUST_ACCEL_ENABLED": rust_accel.is_enabled(),
|
"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 RadarrMovie
|
||||||
from app.schemas.servarr import SonarrSeries
|
from app.schemas.servarr import SonarrSeries
|
||||||
from app.schemas.types import MediaSource, MediaType
|
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)
|
arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES)
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ async def arr_system_status(
|
|||||||
return _SchemaServarrSystemStatus.model_validate({
|
return _SchemaServarrSystemStatus.model_validate({
|
||||||
"appName": "MoviePilot",
|
"appName": "MoviePilot",
|
||||||
"instanceName": "moviepilot",
|
"instanceName": "moviepilot",
|
||||||
"version": APP_VERSION,
|
"version": get_app_version(),
|
||||||
"buildTime": "",
|
"buildTime": "",
|
||||||
"isDebug": False,
|
"isDebug": False,
|
||||||
"isProduction": True,
|
"isProduction": True,
|
||||||
|
|||||||
+42
-27
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
from typing import Callable, Protocol
|
from typing import Callable, Protocol
|
||||||
|
|
||||||
from app.adapters.system.backup.files import BackupFiles
|
from app.adapters.system.backup.files import BackupFiles
|
||||||
@@ -44,6 +45,10 @@ class BackupVerification:
|
|||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseBackupInProgressError(RuntimeError):
|
||||||
|
"""同一宿主进程已有数据库备份正在创建。"""
|
||||||
|
|
||||||
|
|
||||||
class BackupCheck(Protocol):
|
class BackupCheck(Protocol):
|
||||||
"""数据库适配器校验结果的结构合同。"""
|
"""数据库适配器校验结果的结构合同。"""
|
||||||
|
|
||||||
@@ -81,38 +86,44 @@ class DatabaseBackupService:
|
|||||||
self._backend = backend
|
self._backend = backend
|
||||||
self._policy_reader = policy_reader
|
self._policy_reader = policy_reader
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
|
self._create_lock = Lock()
|
||||||
|
|
||||||
def create(self) -> BackupArtifact:
|
def create(self) -> BackupArtifact:
|
||||||
"""创建、校验并发布一个在线一致快照。"""
|
"""创建、校验并发布一个在线一致快照。"""
|
||||||
policy = self._policy_reader()
|
if not self._create_lock.acquire(blocking=False):
|
||||||
files = BackupFiles(policy.root)
|
raise DatabaseBackupInProgressError("已有数据库备份任务正在执行")
|
||||||
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:
|
try:
|
||||||
self._backend.create(temporary)
|
policy = self._policy_reader()
|
||||||
verification = self._backend.verify(temporary)
|
files = BackupFiles(policy.root)
|
||||||
if not verification.valid:
|
created_at = self._clock()
|
||||||
detail = f":{verification.detail}" if verification.detail else ""
|
name = files.available_name(
|
||||||
raise RuntimeError(
|
db_type=self._backend.db_type,
|
||||||
f"数据库备份校验失败({verification.method}){detail}"
|
created_at=created_at,
|
||||||
)
|
suffix=self._backend.suffix,
|
||||||
path = files.publish(temporary, name)
|
)
|
||||||
except Exception:
|
temporary = files.create_temporary(self._backend.suffix)
|
||||||
files.discard(temporary)
|
try:
|
||||||
raise
|
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)
|
artifact = self._artifact(path, created_at=created_at)
|
||||||
self._prune(files, policy, keep=artifact.name)
|
self._prune(files, policy, keep=artifact.name)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type},"
|
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type},"
|
||||||
f"大小={artifact.size} bytes"
|
f"大小={artifact.size} bytes"
|
||||||
)
|
)
|
||||||
return artifact
|
return artifact
|
||||||
|
finally:
|
||||||
|
self._create_lock.release()
|
||||||
|
|
||||||
def list(self) -> tuple[BackupArtifact, ...]:
|
def list(self) -> tuple[BackupArtifact, ...]:
|
||||||
"""按创建时间倒序列出受管数据库备份文件。"""
|
"""按创建时间倒序列出受管数据库备份文件。"""
|
||||||
@@ -126,6 +137,10 @@ class DatabaseBackupService:
|
|||||||
result = self._backend.verify(path)
|
result = self._backend.verify(path)
|
||||||
return BackupVerification(result.valid, result.method, result.detail)
|
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:
|
def restore(self, name: str) -> BackupArtifact:
|
||||||
"""校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。"""
|
"""校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。"""
|
||||||
path = BackupFiles(self._policy_reader().root).resolve(name)
|
path = BackupFiles(self._policy_reader().root).resolve(name)
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ class DatabaseGovernance:
|
|||||||
"""校验一个受管数据库备份文件。"""
|
"""校验一个受管数据库备份文件。"""
|
||||||
return self._backup.verify(name)
|
return self._backup.verify(name)
|
||||||
|
|
||||||
|
def delete_backup(self, name: str) -> None:
|
||||||
|
"""删除一个受管数据库备份文件。"""
|
||||||
|
self._backup.delete(name)
|
||||||
|
|
||||||
def restore_backup(self, name: str) -> BackupArtifact:
|
def restore_backup(self, name: str) -> BackupArtifact:
|
||||||
"""在离线 CLI 进程中还原一个受管数据库备份。"""
|
"""在离线 CLI 进程中还原一个受管数据库备份。"""
|
||||||
return self._backup.restore(name)
|
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.schemas.notification import NotificationChannel
|
||||||
from app.adapters.network.http import RequestUtils
|
from app.adapters.network.http import RequestUtils
|
||||||
from app.adapters.system.host import SystemUtils
|
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):
|
class SystemChain(ChainBase):
|
||||||
@@ -313,8 +313,8 @@ class SystemChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
server_release_version = self.__get_server_release_version()
|
server_release_version = self.__get_server_release_version()
|
||||||
front_release_version = self.__get_front_release_version()
|
front_release_version = self.__get_front_release_version()
|
||||||
server_local_version = self.get_server_local_version()
|
server_local_version = runtime_version.get_app_version()
|
||||||
front_local_version = self.get_frontend_version()
|
front_local_version = runtime_version.get_frontend_version()
|
||||||
if server_release_version == server_local_version:
|
if server_release_version == server_local_version:
|
||||||
title = f"当前后端版本:{server_local_version},已是最新版本\n"
|
title = f"当前后端版本:{server_local_version},已是最新版本\n"
|
||||||
else:
|
else:
|
||||||
@@ -417,26 +417,10 @@ class SystemChain(ChainBase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_server_local_version():
|
def get_server_local_version():
|
||||||
"""
|
"""返回当前后端构建版本。"""
|
||||||
查看当前版本
|
return runtime_version.get_app_version()
|
||||||
"""
|
|
||||||
return APP_VERSION
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_frontend_version():
|
def get_frontend_version():
|
||||||
"""
|
"""返回当前部署的前端资源版本。"""
|
||||||
获取前端版本
|
return runtime_version.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
|
|
||||||
|
|||||||
+12
-13
@@ -23,7 +23,7 @@ settings = RuntimeSettingsCompat()
|
|||||||
from app.runtime.state import SystemHelper
|
from app.runtime.state import SystemHelper
|
||||||
from app.application.backup import BackupArtifact
|
from app.application.backup import BackupArtifact
|
||||||
from app.startup.composition.database import build_database_governance
|
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_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||||
BACKEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.stdout.log"
|
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]:
|
def _resolve_auto_update_targets(mode: str) -> Optional[str]:
|
||||||
if mode != "dev":
|
if mode != "dev":
|
||||||
return None
|
return None
|
||||||
backend_prefix = _release_prefix(APP_VERSION)
|
backend_prefix = _release_prefix(get_app_version())
|
||||||
current_branch = _git_current_branch()
|
current_branch = _git_current_branch()
|
||||||
backend_ref = "latest"
|
backend_ref = "latest"
|
||||||
if not current_branch or current_branch == "HEAD":
|
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]:
|
def _installed_frontend_version() -> Optional[str]:
|
||||||
if not FRONTEND_VERSION_FILE.exists():
|
return get_frontend_version(fallback_to_declared=False)
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return FRONTEND_VERSION_FILE.read_text(encoding="utf-8", errors="replace").strip() or None
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||||
@@ -991,7 +986,9 @@ def start(timeout: int, safe: bool) -> None:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
backend_health = backend_result.get("health") or {}
|
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"
|
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 已在运行")
|
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 {}
|
data = (backend_health or {}).get("data") or {}
|
||||||
click.echo(" running (unmanaged)")
|
click.echo(" running (unmanaged)")
|
||||||
click.echo(f" URL: {_backend_base_url()}")
|
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:
|
else:
|
||||||
data = (backend_health or {}).get("data") or {}
|
data = (backend_health or {}).get("data") or {}
|
||||||
click.echo(f" {'running' if backend_state == 'running' else 'starting'}")
|
click.echo(f" {'running' if backend_state == 'running' else 'starting'}")
|
||||||
click.echo(f" PID: {backend_process.pid}")
|
click.echo(f" PID: {backend_process.pid}")
|
||||||
click.echo(f" URL: {_backend_base_url(backend_runtime)}")
|
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" App Log: {BACKEND_APP_LOG_FILE}")
|
||||||
click.echo(f" Stdout Log: {BACKEND_STDIO_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)
|
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||||
def version() -> None:
|
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())
|
healthy_backend, payload = _backend_health(runtime=_backend_runtime())
|
||||||
if healthy_backend:
|
if healthy_backend:
|
||||||
data = (payload or {}).get("data") or {}
|
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:
|
else:
|
||||||
click.echo("Backend Service: not running")
|
click.echo("Backend Service: not running")
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.doctor.models import (
|
|||||||
DoctorSeverity,
|
DoctorSeverity,
|
||||||
)
|
)
|
||||||
from app.adapters.system.host import SystemUtils
|
from app.adapters.system.host import SystemUtils
|
||||||
from version import APP_VERSION
|
from app.runtime.version import get_app_version
|
||||||
|
|
||||||
|
|
||||||
class DoctorRunner:
|
class DoctorRunner:
|
||||||
@@ -36,7 +36,7 @@ class DoctorRunner:
|
|||||||
self.deep = deep
|
self.deep = deep
|
||||||
self.report = DoctorReport(
|
self.report = DoctorReport(
|
||||||
generated_at=datetime.now(),
|
generated_at=datetime.now(),
|
||||||
version=APP_VERSION,
|
version=get_app_version(),
|
||||||
environment=self._environment(),
|
environment=self._environment(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -41,7 +41,7 @@ from app.schemas.openai import (
|
|||||||
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
|
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
|
||||||
from app.schemas.response import Response as ApiResponse, ValidationIssue
|
from app.schemas.response import Response as ApiResponse, ValidationIssue
|
||||||
from app.startup.lifecycle import lifespan
|
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:
|
def _get_http_exception_message(detail: Any) -> str:
|
||||||
@@ -328,7 +328,7 @@ def create_app() -> FastAPI:
|
|||||||
configure_observation(build_observation_port())
|
configure_observation(build_observation_port())
|
||||||
_app = FastAPI(
|
_app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
version=APP_VERSION,
|
version=get_app_version(),
|
||||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
"""历史版本比较规则。"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
__all__ = ["compare_version"]
|
||||||
|
|
||||||
|
|
||||||
_VERSION_LABELS = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
_VERSION_LABELS = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
||||||
_UNKNOWN_VERSION_LABEL = -5
|
_UNKNOWN_VERSION_LABEL = -5
|
||||||
|
|||||||
@@ -125,6 +125,17 @@
|
|||||||
"音乐实体类型无效,仅支持 recording 或 album": "Invalid music entity type; only recording or album is supported",
|
"音乐实体类型无效,仅支持 recording 或 album": "Invalid music entity type; only recording or album is supported",
|
||||||
"音乐下载只能使用音乐元数据源": "Music downloads can only use music metadata sources",
|
"音乐下载只能使用音乐元数据源": "Music downloads can only use music metadata sources",
|
||||||
"音乐重新识别只能使用音乐元数据源": "Music re-identification 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",
|
"记录不存在": "Record does not exist",
|
||||||
"MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled",
|
"MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled",
|
||||||
"整理记录不存在": "Organization record does not exist",
|
"整理记录不存在": "Organization record does not exist",
|
||||||
|
|||||||
@@ -121,6 +121,17 @@
|
|||||||
"音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album",
|
"音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album",
|
||||||
"音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源",
|
"音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源",
|
||||||
"音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源",
|
"音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源",
|
||||||
|
"读取数据库备份列表失败,请查看日志": "讀取資料庫備份清單失敗,請查看日誌",
|
||||||
|
"创建数据库备份失败,请查看日志": "建立資料庫備份失敗,請查看日誌",
|
||||||
|
"已有数据库备份任务正在执行": "已有資料庫備份任務正在執行",
|
||||||
|
"数据库备份文件名无效": "資料庫備份檔名無效",
|
||||||
|
"数据库备份不存在": "資料庫備份不存在",
|
||||||
|
"校验数据库备份失败,请查看日志": "驗證資料庫備份失敗,請查看日誌",
|
||||||
|
"删除数据库备份失败,请查看日志": "刪除資料庫備份失敗,請查看日誌",
|
||||||
|
"数据库备份周期格式不正确": "資料庫備份週期格式不正確",
|
||||||
|
"数据库备份目录必须是路径字符串": "資料庫備份目錄必須是路徑字串",
|
||||||
|
"数据库备份过期天数必须是大于等于 0 的整数": "資料庫備份過期天數必須是大於等於 0 的整數",
|
||||||
|
"数据库备份最大保留份数必须是大于等于 0 的整数": "資料庫備份最大保留份數必須是大於等於 0 的整數",
|
||||||
"记录不存在": "記錄不存在",
|
"记录不存在": "記錄不存在",
|
||||||
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
|
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
|
||||||
"整理记录不存在": "整理記錄不存在",
|
"整理记录不存在": "整理記錄不存在",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from app.foundation.environment import (
|
|||||||
is_frozen,
|
is_frozen,
|
||||||
)
|
)
|
||||||
from app.foundation.url import UrlUtils
|
from app.foundation.url import UrlUtils
|
||||||
from version import APP_VERSION
|
from app.runtime.version import get_app_version
|
||||||
|
|
||||||
|
|
||||||
class SystemConfModel(BaseModel):
|
class SystemConfModel(BaseModel):
|
||||||
@@ -1061,7 +1061,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
|||||||
全局用户代理字符串
|
全局用户代理字符串
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
f"{self.PROJECT_NAME}/{APP_VERSION[1:]} "
|
f"{self.PROJECT_NAME}/{get_app_version()[1:]} "
|
||||||
f"({platform.system()} {platform.release()}; {cpu_arch()})"
|
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'),
|
'DashboardMemoryInfo': ('app.schemas.dashboard', 'DashboardMemoryInfo'),
|
||||||
'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'),
|
'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'),
|
||||||
'DataT': ('app.schemas.response', 'DataT'),
|
'DataT': ('app.schemas.response', 'DataT'),
|
||||||
|
'DatabaseBackupArtifactData': ('app.schemas.system', 'DatabaseBackupArtifactData'),
|
||||||
|
'DatabaseBackupVerificationData': ('app.schemas.system', 'DatabaseBackupVerificationData'),
|
||||||
'Dict': ('app.schemas.mcp', 'Dict'),
|
'Dict': ('app.schemas.mcp', 'Dict'),
|
||||||
'DiscoverMediaSource': ('app.schemas.event', 'DiscoverMediaSource'),
|
'DiscoverMediaSource': ('app.schemas.event', 'DiscoverMediaSource'),
|
||||||
'DiscoverSourceEventData': ('app.schemas.event', 'DiscoverSourceEventData'),
|
'DiscoverSourceEventData': ('app.schemas.event', 'DiscoverSourceEventData'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime as _DateTime
|
||||||
from typing import Optional, Any, Literal
|
from typing import Optional, Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
@@ -216,6 +217,22 @@ class SystemModuleListData(BaseModel):
|
|||||||
modules: list[SystemModuleInfo] = Field(default_factory=list)
|
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):
|
class TransferDirectoryConf(BaseModel):
|
||||||
"""
|
"""
|
||||||
文件整理目录配置
|
文件整理目录配置
|
||||||
|
|||||||
+10
-1
@@ -493,12 +493,21 @@ MoviePilot 停止运行后,可通过明确确认执行离线还原:
|
|||||||
moviepilot database restore <filename> --confirm
|
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
|
- SQLite 使用在线备份 API,PostgreSQL 使用镜像内置的 `pg_dump` custom format
|
||||||
- 源码部署使用 PostgreSQL 时,宿主机需安装 `pg_dump` 和 `pg_restore` 并加入 `PATH`;Docker 镜像已内置
|
- 源码部署使用 PostgreSQL 时,宿主机需安装 `pg_dump` 和 `pg_restore` 并加入 `PATH`;Docker 镜像已内置
|
||||||
- 默认目录为配置目录下的 `database_backup/`,可通过 `DB_BACKUP_PATH` 调整
|
- 默认目录为配置目录下的 `database_backup/`,可通过 `DB_BACKUP_PATH` 调整
|
||||||
- 文件名包含数据库类型和创建时间,例如 `sqlite_20260819_030000.db`
|
|
||||||
- 备份、列举和校验可独立通过 CLI 执行
|
- 备份、列举和校验可独立通过 CLI 执行
|
||||||
- 还原会覆盖当前数据库,执行前必须停止 MoviePilot;运行中的 Web API 和插件 SDK 不提供还原入口
|
- 还原会覆盖当前数据库,执行前必须停止 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:
|
def run_mypy() -> str:
|
||||||
"""以当前解释器运行全量 mypy 并返回 stdout(非零退出码属于预期结果)。"""
|
"""以当前解释器运行全量 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:
|
for pattern in MYPY_EXCLUDES:
|
||||||
command += ["--exclude", pattern]
|
command += ["--exclude", pattern]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|||||||
+28
-3
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6620,
|
"edge_count": 6644,
|
||||||
"edge_sha256": "da3e1ea903ff03ab300e43cd517e141365c65bf0304c091c22ff989ec8b9e1ae",
|
"edge_sha256": "6d011ba65df8afe21dec208fd09a3a2a314743ae282db5ce411307b9d967fd00",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
"app.adapters.external.market -> app.runtime.observability",
|
"app.adapters.external.market -> app.runtime.observability",
|
||||||
"app.adapters.external.market -> app.runtime.settings",
|
"app.adapters.external.market -> app.runtime.settings",
|
||||||
"app.adapters.external.market -> app.runtime.tasks",
|
"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",
|
||||||
"app.adapters.external.ocr -> app.adapters.network",
|
"app.adapters.external.ocr -> app.adapters.network",
|
||||||
"app.adapters.external.ocr -> app.adapters.network.http",
|
"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.observability",
|
||||||
"app.adapters.external.server -> app.runtime.settings",
|
"app.adapters.external.server -> app.runtime.settings",
|
||||||
"app.adapters.external.server -> app.runtime.tasks",
|
"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",
|
||||||
"app.adapters.external.server -> app.schemas.media",
|
"app.adapters.external.server -> app.schemas.media",
|
||||||
"app.adapters.external.server -> app.schemas.types",
|
"app.adapters.external.server -> app.schemas.types",
|
||||||
@@ -118,6 +120,8 @@
|
|||||||
"app.adapters.network.http -> app.runtime.correlation",
|
"app.adapters.network.http -> app.runtime.correlation",
|
||||||
"app.adapters.observability.otel -> app.runtime",
|
"app.adapters.observability.otel -> app.runtime",
|
||||||
"app.adapters.observability.otel -> app.runtime.observability",
|
"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",
|
||||||
"app.adapters.system.display -> app.foundation.singleton",
|
"app.adapters.system.display -> app.foundation.singleton",
|
||||||
"app.adapters.system.display -> app.runtime",
|
"app.adapters.system.display -> app.runtime",
|
||||||
@@ -133,6 +137,8 @@
|
|||||||
"app.adapters.system.fsproxy -> app.runtime.settings",
|
"app.adapters.system.fsproxy -> app.runtime.settings",
|
||||||
"app.adapters.system.host -> app.foundation",
|
"app.adapters.system.host -> app.foundation",
|
||||||
"app.adapters.system.host -> app.foundation.environment",
|
"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",
|
||||||
"app.adapters.system.host -> app.schemas.dashboard",
|
"app.adapters.system.host -> app.schemas.dashboard",
|
||||||
"app.adapters.system.package -> app.runtime",
|
"app.adapters.system.package -> app.runtime",
|
||||||
@@ -182,6 +188,7 @@
|
|||||||
"app.adapters.system.update -> app.runtime.log",
|
"app.adapters.system.update -> app.runtime.log",
|
||||||
"app.adapters.system.update -> app.runtime.settings",
|
"app.adapters.system.update -> app.runtime.settings",
|
||||||
"app.adapters.system.update -> app.runtime.thread",
|
"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",
|
||||||
"app.adapters.system.update -> app.schemas.system",
|
"app.adapters.system.update -> app.schemas.system",
|
||||||
"app.adapters.web.correlation -> app.runtime",
|
"app.adapters.web.correlation -> app.runtime",
|
||||||
@@ -1924,6 +1931,7 @@
|
|||||||
"app.api.endpoints.mcp -> app.api.response",
|
"app.api.endpoints.mcp -> app.api.response",
|
||||||
"app.api.endpoints.mcp -> app.runtime",
|
"app.api.endpoints.mcp -> app.runtime",
|
||||||
"app.api.endpoints.mcp -> app.runtime.log",
|
"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",
|
||||||
"app.api.endpoints.mcp -> app.schemas.mcp",
|
"app.api.endpoints.mcp -> app.schemas.mcp",
|
||||||
"app.api.endpoints.mcp -> app.schemas.response",
|
"app.api.endpoints.mcp -> app.schemas.response",
|
||||||
@@ -2289,7 +2297,9 @@
|
|||||||
"app.api.endpoints.system -> app.api.principal",
|
"app.api.endpoints.system -> app.api.principal",
|
||||||
"app.api.endpoints.system -> app.api.response",
|
"app.api.endpoints.system -> app.api.response",
|
||||||
"app.api.endpoints.system -> app.application",
|
"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.configuration",
|
||||||
|
"app.api.endpoints.system -> app.application.database",
|
||||||
"app.api.endpoints.system -> app.application.image",
|
"app.api.endpoints.system -> app.application.image",
|
||||||
"app.api.endpoints.system -> app.application.messaging",
|
"app.api.endpoints.system -> app.application.messaging",
|
||||||
"app.api.endpoints.system -> app.application.messaging.message",
|
"app.api.endpoints.system -> app.application.messaging.message",
|
||||||
@@ -2316,11 +2326,13 @@
|
|||||||
"app.api.endpoints.system -> app.runtime",
|
"app.api.endpoints.system -> app.runtime",
|
||||||
"app.api.endpoints.system -> app.runtime.config",
|
"app.api.endpoints.system -> app.runtime.config",
|
||||||
"app.api.endpoints.system -> app.runtime.events",
|
"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.localization",
|
||||||
"app.api.endpoints.system -> app.runtime.log",
|
"app.api.endpoints.system -> app.runtime.log",
|
||||||
"app.api.endpoints.system -> app.runtime.progress",
|
"app.api.endpoints.system -> app.runtime.progress",
|
||||||
"app.api.endpoints.system -> app.runtime.scheduling",
|
"app.api.endpoints.system -> app.runtime.scheduling",
|
||||||
"app.api.endpoints.system -> app.runtime.state",
|
"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",
|
||||||
"app.api.endpoints.system -> app.schemas.common",
|
"app.api.endpoints.system -> app.schemas.common",
|
||||||
"app.api.endpoints.system -> app.schemas.event",
|
"app.api.endpoints.system -> app.schemas.event",
|
||||||
@@ -2491,6 +2503,8 @@
|
|||||||
"app.api.servarr -> app.domain",
|
"app.api.servarr -> app.domain",
|
||||||
"app.api.servarr -> app.domain.context",
|
"app.api.servarr -> app.domain.context",
|
||||||
"app.api.servarr -> app.domain.metainfo",
|
"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",
|
||||||
"app.api.servarr -> app.schemas.response",
|
"app.api.servarr -> app.schemas.response",
|
||||||
"app.api.servarr -> app.schemas.servarr",
|
"app.api.servarr -> app.schemas.servarr",
|
||||||
@@ -3432,6 +3446,7 @@
|
|||||||
"app.chain.system -> app.runtime",
|
"app.chain.system -> app.runtime",
|
||||||
"app.chain.system -> app.runtime.log",
|
"app.chain.system -> app.runtime.log",
|
||||||
"app.chain.system -> app.runtime.state",
|
"app.chain.system -> app.runtime.state",
|
||||||
|
"app.chain.system -> app.runtime.version",
|
||||||
"app.chain.system -> app.schemas",
|
"app.chain.system -> app.schemas",
|
||||||
"app.chain.system -> app.schemas.message",
|
"app.chain.system -> app.schemas.message",
|
||||||
"app.chain.system -> app.schemas.notification",
|
"app.chain.system -> app.schemas.notification",
|
||||||
@@ -3545,6 +3560,7 @@
|
|||||||
"app.cli -> app.runtime.config",
|
"app.cli -> app.runtime.config",
|
||||||
"app.cli -> app.runtime.settings",
|
"app.cli -> app.runtime.settings",
|
||||||
"app.cli -> app.runtime.state",
|
"app.cli -> app.runtime.state",
|
||||||
|
"app.cli -> app.runtime.version",
|
||||||
"app.cli -> app.startup",
|
"app.cli -> app.startup",
|
||||||
"app.cli -> app.startup.composition",
|
"app.cli -> app.startup.composition",
|
||||||
"app.cli -> app.startup.composition.database",
|
"app.cli -> app.startup.composition.database",
|
||||||
@@ -3846,6 +3862,7 @@
|
|||||||
"app.doctor.runner -> app.doctor.models",
|
"app.doctor.runner -> app.doctor.models",
|
||||||
"app.doctor.runner -> app.runtime",
|
"app.doctor.runner -> app.runtime",
|
||||||
"app.doctor.runner -> app.runtime.settings",
|
"app.doctor.runner -> app.runtime.settings",
|
||||||
|
"app.doctor.runner -> app.runtime.version",
|
||||||
"app.domain.context -> app.domain",
|
"app.domain.context -> app.domain",
|
||||||
"app.domain.context -> app.domain.meta",
|
"app.domain.context -> app.domain.meta",
|
||||||
"app.domain.context -> app.domain.meta.metabase",
|
"app.domain.context -> app.domain.meta.metabase",
|
||||||
@@ -3960,6 +3977,7 @@
|
|||||||
"app.factory -> app.runtime.log",
|
"app.factory -> app.runtime.log",
|
||||||
"app.factory -> app.runtime.observability",
|
"app.factory -> app.runtime.observability",
|
||||||
"app.factory -> app.runtime.settings",
|
"app.factory -> app.runtime.settings",
|
||||||
|
"app.factory -> app.runtime.version",
|
||||||
"app.factory -> app.schemas",
|
"app.factory -> app.schemas",
|
||||||
"app.factory -> app.schemas.exception",
|
"app.factory -> app.schemas.exception",
|
||||||
"app.factory -> app.schemas.mcp",
|
"app.factory -> app.schemas.mcp",
|
||||||
@@ -5635,6 +5653,7 @@
|
|||||||
"app.runtime.config -> app.foundation.url",
|
"app.runtime.config -> app.foundation.url",
|
||||||
"app.runtime.config -> app.runtime",
|
"app.runtime.config -> app.runtime",
|
||||||
"app.runtime.config -> app.runtime.log",
|
"app.runtime.config -> app.runtime.log",
|
||||||
|
"app.runtime.config -> app.runtime.version",
|
||||||
"app.runtime.config -> app.schemas",
|
"app.runtime.config -> app.schemas",
|
||||||
"app.runtime.config -> app.schemas.types",
|
"app.runtime.config -> app.schemas.types",
|
||||||
"app.runtime.debounce -> app.runtime",
|
"app.runtime.debounce -> app.runtime",
|
||||||
@@ -5859,6 +5878,11 @@
|
|||||||
"app.runtime.thread -> app.runtime",
|
"app.runtime.thread -> app.runtime",
|
||||||
"app.runtime.thread -> app.runtime.execution",
|
"app.runtime.thread -> app.runtime.execution",
|
||||||
"app.runtime.thread -> app.runtime.settings",
|
"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",
|
||||||
"app.scheduler -> app.adapters.external",
|
"app.scheduler -> app.adapters.external",
|
||||||
"app.scheduler -> app.adapters.external.server",
|
"app.scheduler -> app.adapters.external.server",
|
||||||
@@ -6637,7 +6661,7 @@
|
|||||||
"app.workflow.actions.transfer_file -> app.workflow",
|
"app.workflow.actions.transfer_file -> app.workflow",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 815,
|
"module_count": 816,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -7352,6 +7376,7 @@
|
|||||||
"app.runtime.tasks",
|
"app.runtime.tasks",
|
||||||
"app.runtime.thread",
|
"app.runtime.thread",
|
||||||
"app.runtime.topology",
|
"app.runtime.topology",
|
||||||
|
"app.runtime.version",
|
||||||
"app.scheduler",
|
"app.scheduler",
|
||||||
"app.schemas",
|
"app.schemas",
|
||||||
"app.schemas.agent",
|
"app.schemas.agent",
|
||||||
|
|||||||
+1106
-299
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
"repeat": 3,
|
"repeat": 3,
|
||||||
"targets": {
|
"targets": {
|
||||||
"app.startup.lifecycle": {
|
"app.startup.lifecycle": {
|
||||||
"loaded_app_module_count": 361,
|
"loaded_app_module_count": 362,
|
||||||
"max_ms": 904.069,
|
"max_ms": 904.069,
|
||||||
"median_ms": 898.164,
|
"median_ms": 898.164,
|
||||||
"min_ms": 896.39,
|
"min_ms": 896.39,
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.factory": {
|
"app.factory": {
|
||||||
"loaded_app_module_count": 373,
|
"loaded_app_module_count": 374,
|
||||||
"max_ms": 923.165,
|
"max_ms": 923.165,
|
||||||
"median_ms": 921.768,
|
"median_ms": 921.768,
|
||||||
"min_ms": 921.249,
|
"min_ms": 921.249,
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.main": {
|
"app.main": {
|
||||||
"loaded_app_module_count": 375,
|
"loaded_app_module_count": 376,
|
||||||
"max_ms": 1069.392,
|
"max_ms": 1069.392,
|
||||||
"median_ms": 1037.036,
|
"median_ms": 1037.036,
|
||||||
"min_ms": 1027.603,
|
"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_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.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():
|
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.db.models.transferhistory import TransferHistory
|
||||||
from app.schemas.types import MediaSource, MediaType
|
from app.schemas.types import MediaSource, MediaType
|
||||||
from app.adapters.system import host as system_module
|
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.time, "time", lambda: 1000.0)
|
||||||
monkeypatch.setattr(system_module.psutil, "Process", FakeProcess)
|
monkeypatch.setattr(system_module.psutil, "Process", FakeProcess)
|
||||||
monkeypatch.setattr(SystemUtils, "_operating_system_name", staticmethod(lambda: "Ubuntu 24.04.4 LTS"))
|
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()
|
result = SystemUtils.dashboard_system_info()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.cli import cli
|
|||||||
from app.sdk import database as database_sdk
|
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:
|
def _artifact(tmp_path: Path) -> BackupArtifact:
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import stat
|
import stat
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from threading import Event
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.application.backup import BackupPolicy, DatabaseBackupService
|
from app.application.backup import (
|
||||||
|
BackupPolicy,
|
||||||
|
DatabaseBackupInProgressError,
|
||||||
|
DatabaseBackupService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -35,6 +41,21 @@ class _Backend:
|
|||||||
self.restored = artifact
|
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(
|
def _service(
|
||||||
root: Path,
|
root: Path,
|
||||||
*,
|
*,
|
||||||
@@ -53,7 +74,7 @@ def _service(
|
|||||||
def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
|
def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
|
||||||
artifact = _service(tmp_path).create()
|
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 artifact.path.read_bytes() == b"database snapshot"
|
||||||
assert stat.S_IMODE(tmp_path.stat().st_mode) == 0o700
|
assert stat.S_IMODE(tmp_path.stat().st_mode) == 0o700
|
||||||
assert stat.S_IMODE(artifact.path.stat().st_mode) == 0o600
|
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:
|
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="数据库备份校验失败"):
|
with pytest.raises(RuntimeError, match="数据库备份校验失败"):
|
||||||
_service(tmp_path, backend=_Backend(valid=False)).create()
|
service.create()
|
||||||
|
|
||||||
assert list(tmp_path.iterdir()) == []
|
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:
|
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()
|
first = service.create()
|
||||||
second = service.create()
|
second = service.create()
|
||||||
|
|
||||||
assert first.name == "sqlite_20260819_134526.db"
|
assert first.name == "moviepilot_v3.0.0_sqlite_20260819_134526.db"
|
||||||
assert second.name == "sqlite_20260819_134526_1.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:
|
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")
|
_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:
|
def test_restore_requires_matching_database_type(tmp_path: Path) -> None:
|
||||||
backend = _Backend()
|
backend = _Backend()
|
||||||
service = _service(tmp_path, 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 restored.name == artifact.name
|
||||||
assert backend.restored == artifact.path
|
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")
|
postgres.write_bytes(b"database snapshot")
|
||||||
with pytest.raises(ValueError, match="当前数据库类型"):
|
with pytest.raises(ValueError, match="当前数据库类型"):
|
||||||
service.restore(postgres.name)
|
service.restore(postgres.name)
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ def downgrade():
|
|||||||
|
|
||||||
db_init.prepare_database()
|
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
|
assert len(artifacts) == 1
|
||||||
with create_engine(f"sqlite:///{artifacts[0]}").connect() as connection:
|
with create_engine(f"sqlite:///{artifacts[0]}").connect() as connection:
|
||||||
backup_revision = connection.execute(
|
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")
|
(tmp_path / "user.db").write_bytes(b"not a sqlite database")
|
||||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||||
backup_dir.mkdir(parents=True)
|
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:
|
with sqlite3.connect(backup) as connection:
|
||||||
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
|
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()
|
runner = DoctorRunner()
|
||||||
checks._check_database(runner)
|
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"][0]["valid"] is False
|
||||||
assert finding.context["backups"][1]["valid"] is True
|
assert finding.context["backups"][1]["valid"] is True
|
||||||
assert finding.context["restore_command"] == (
|
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
|
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 = settings.DATABASE_BACKUP_PATH
|
||||||
backup_dir.mkdir(parents=True)
|
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()
|
runner = DoctorRunner()
|
||||||
checks._check_database_backups(runner)
|
checks._check_database_backups(runner)
|
||||||
mismatched = runner.report.find("database.backup_recovery")
|
mismatched = runner.report.find("database.backup_recovery")
|
||||||
assert mismatched is not None
|
assert mismatched is not None
|
||||||
assert mismatched.status == DoctorFindingStatus.Degraded
|
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):
|
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))
|
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||||
backup_dir.mkdir(parents=True)
|
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"
|
original = b"invalid sqlite backup"
|
||||||
backup.write_bytes(original)
|
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")
|
monkeypatch.setattr(settings, "DB_TYPE", "postgresql")
|
||||||
backup_dir = settings.DATABASE_BACKUP_PATH
|
backup_dir = settings.DATABASE_BACKUP_PATH
|
||||||
backup_dir.mkdir(parents=True)
|
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)
|
monkeypatch.setattr(checks, "verify_database_backup", missing_pg_restore)
|
||||||
|
|
||||||
runner = DoctorRunner()
|
runner = DoctorRunner()
|
||||||
|
|||||||
@@ -236,6 +236,63 @@ def test_locale_helper_translates_common_backend_response_messages():
|
|||||||
assert LocaleHelper.translate_text(message, locale="en-US") == expected
|
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():
|
def test_response_localizes_message_from_locale_context():
|
||||||
"""通用 Response 应根据请求语言上下文直接翻译消息。"""
|
"""通用 Response 应根据请求语言上下文直接翻译消息。"""
|
||||||
token = LocaleHelper.set_current_locale("en-US")
|
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 = """
|
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:
|
def test_parse_errors_aggregates_per_file_and_code() -> None:
|
||||||
"""错误行按文件与错误码聚合,源码上下文与摘要行不计入。"""
|
"""错误行按文件与错误码聚合,源码上下文与摘要行不计入。"""
|
||||||
report = parse_errors(MYPY_SAMPLE)
|
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(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()
|
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