mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
feat: 完善数据库备份管理与版本读取 (#6450)
This commit is contained in:
Vendored
+5
-5
@@ -62,7 +62,7 @@ from app.foundation.singleton import WeakSingleton
|
||||
from app.foundation.version import compare_version
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.foundation.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
# 保留模块级可替换入口,代理默认读取组合根的最新 runtime 配置。
|
||||
settings = RuntimeSettingsCompat()
|
||||
@@ -289,9 +289,9 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。
|
||||
"""
|
||||
try:
|
||||
return Version(str(APP_VERSION))
|
||||
return Version(get_app_version())
|
||||
except InvalidVersion:
|
||||
logger.error(f"当前主程序版本号无法解析:{APP_VERSION}")
|
||||
logger.error(f"当前主程序版本号无法解析:{get_app_version()}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -402,7 +402,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
|
||||
system_version = cls.get_current_system_version()
|
||||
if system_version is None:
|
||||
return False, f"当前 MoviePilot 版本 {APP_VERSION} 无法解析,已拒绝安装带版本限制的插件"
|
||||
return False, f"当前 MoviePilot 版本 {get_app_version()} 无法解析,已拒绝安装带版本限制的插件"
|
||||
|
||||
try:
|
||||
specifier_set = SpecifierSet(raw_specifier)
|
||||
@@ -416,7 +416,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return True, ""
|
||||
|
||||
return False, (
|
||||
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {APP_VERSION} 不满足,已拒绝安装"
|
||||
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
Vendored
+3
-22
@@ -2,7 +2,6 @@ import asyncio
|
||||
import json
|
||||
import platform
|
||||
from collections.abc import Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import parse_qs, quote, urlparse, urlsplit
|
||||
|
||||
@@ -24,7 +23,7 @@ from app.adapters.network.http import AsyncRequestUtils, RequestUtils
|
||||
from app.domain.media import normalize_music_type
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from version import APP_VERSION, FRONTEND_VERSION
|
||||
from app.runtime.version import get_app_version, get_frontend_version
|
||||
|
||||
|
||||
# 保留旧插件可覆盖的模块级入口,默认通过 runtime 代理动态读取配置。
|
||||
@@ -270,24 +269,6 @@ class MoviePilotServerHelper:
|
||||
or permissions.get("workflow_share_manage")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_frontend_version() -> str:
|
||||
"""
|
||||
获取当前前端版本。
|
||||
"""
|
||||
if SystemUtils.is_frozen() and SystemUtils.is_windows():
|
||||
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
|
||||
else:
|
||||
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, "r", encoding="utf-8", errors="replace") as file:
|
||||
version = str(file.read()).strip()
|
||||
return version or FRONTEND_VERSION
|
||||
except Exception as err:
|
||||
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
return FRONTEND_VERSION
|
||||
|
||||
@classmethod
|
||||
def build_usage_payload(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -295,8 +276,8 @@ class MoviePilotServerHelper:
|
||||
"""
|
||||
return {
|
||||
"user_uid": cls.get_user_uid(),
|
||||
"backend_version": APP_VERSION,
|
||||
"frontend_version": cls.get_frontend_version(),
|
||||
"backend_version": get_app_version(),
|
||||
"frontend_version": get_frontend_version(),
|
||||
"version_flag": settings.VERSION_FLAG,
|
||||
"platform": f"{platform.system()} {platform.release()}".strip(),
|
||||
"arch": SystemUtils.cpu_arch(),
|
||||
|
||||
@@ -8,13 +8,16 @@ import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
_BACKUP_NAME = re.compile(
|
||||
r"^(?P<db_type>sqlite|postgresql)_"
|
||||
r"^(?:moviepilot_(?P<version>v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)_)?"
|
||||
r"(?P<db_type>sqlite|postgresql)_"
|
||||
r"(?P<timestamp>\d{8}_\d{6})"
|
||||
r"(?:_(?P<sequence>\d+))?"
|
||||
r"(?P<suffix>\.db|\.dump)$"
|
||||
)
|
||||
_RELEASE_VERSION = re.compile(r"^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
class BackupFiles:
|
||||
@@ -68,10 +71,19 @@ class BackupFiles:
|
||||
"""删除一个已通过名称约束的备份文件。"""
|
||||
self.resolve(name).unlink()
|
||||
|
||||
def available_name(self, *, db_type: str, created_at: datetime, suffix: str) -> str:
|
||||
def available_name(
|
||||
self,
|
||||
*,
|
||||
db_type: str,
|
||||
created_at: datetime,
|
||||
suffix: str,
|
||||
) -> str:
|
||||
"""生成包含数据库类型和秒级时间的简短可读文件名。"""
|
||||
timestamp = created_at.strftime("%Y%m%d_%H%M%S")
|
||||
base = f"{db_type}_{timestamp}"
|
||||
version = get_app_version().strip()
|
||||
if _RELEASE_VERSION.fullmatch(version) is None:
|
||||
raise ValueError("程序版本号无法用于数据库备份命名")
|
||||
base = f"moviepilot_{version}_{db_type}_{timestamp}"
|
||||
candidate = f"{base}{suffix}"
|
||||
sequence = 1
|
||||
while (self.root / candidate).exists():
|
||||
|
||||
@@ -26,7 +26,7 @@ import psutil
|
||||
from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo
|
||||
from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo
|
||||
from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
from app.foundation.environment import (
|
||||
is_aarch,
|
||||
is_aarch64,
|
||||
@@ -948,7 +948,7 @@ class SystemUtils:
|
||||
hostname=socket.gethostname(),
|
||||
operating_system=SystemUtils._operating_system_name(),
|
||||
runtime=max(0, int(time.time() - psutil.Process().create_time())),
|
||||
version=APP_VERSION,
|
||||
version=get_app_version(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -16,12 +16,12 @@ from typing import Any, Optional
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.foundation.singleton import SingletonClass
|
||||
from app.foundation.version import compare_version
|
||||
from app.runtime.version import get_app_version
|
||||
from app.foundation.environment import is_docker
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.runtime.thread import ThreadHelper
|
||||
from app.schemas.system import SystemUpdateStatus
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SystemUpdateManager(metaclass=SingletonClass):
|
||||
@@ -66,13 +66,17 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _default_state(self) -> dict[str, Any]:
|
||||
return SystemUpdateStatus(current_version=APP_VERSION).model_dump()
|
||||
return SystemUpdateStatus(current_version=get_app_version()).model_dump()
|
||||
|
||||
def _read_state(self) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(self._state_file.read_text(encoding="utf-8"))
|
||||
if isinstance(payload, dict):
|
||||
return {**self._default_state(), **payload, "current_version": APP_VERSION}
|
||||
return {
|
||||
**self._default_state(),
|
||||
**payload,
|
||||
"current_version": get_app_version(),
|
||||
}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return self._default_state()
|
||||
@@ -81,7 +85,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
with self._lock:
|
||||
state = self._read_state()
|
||||
state.update(changes)
|
||||
state["current_version"] = APP_VERSION
|
||||
state["current_version"] = get_app_version()
|
||||
state["progress"] = self._progress(
|
||||
state.get("downloaded_bytes", 0), state.get("total_bytes", 0)
|
||||
)
|
||||
@@ -117,7 +121,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
can_update=True,
|
||||
can_install=False,
|
||||
)
|
||||
if state.get("state") == "installing" and target == APP_VERSION:
|
||||
if state.get("state") == "installing" and target == get_app_version():
|
||||
self._install_file.unlink(missing_ok=True)
|
||||
state = self._write_state(
|
||||
state="idle",
|
||||
@@ -161,7 +165,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
if not release:
|
||||
raise RuntimeError("未找到可用的 v3 稳定版本")
|
||||
version = str(release["tag_name"])
|
||||
has_update = compare_version(version, "gt", APP_VERSION) is True
|
||||
has_update = compare_version(version, "gt", get_app_version()) is True
|
||||
return SystemUpdateStatus.model_validate(
|
||||
self._write_state(
|
||||
state="available" if has_update else "idle",
|
||||
|
||||
@@ -14,14 +14,9 @@ from app.schemas.response import Response as _SchemaResponse
|
||||
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||
from app.agent.tools.manager import moviepilot_tool_manager
|
||||
from app.adapters.web.security.access import verify_apikey
|
||||
from app.runtime.version import get_app_version
|
||||
from app.runtime.log import logger
|
||||
|
||||
# 导入版本号
|
||||
try:
|
||||
from version import APP_VERSION
|
||||
except ImportError:
|
||||
APP_VERSION = "unknown"
|
||||
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
# MCP 协议版本
|
||||
@@ -213,7 +208,7 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "MoviePilot",
|
||||
"version": APP_VERSION,
|
||||
"version": get_app_version(),
|
||||
"description": "MoviePilot MCP Server - 电影自动化管理工具",
|
||||
},
|
||||
"instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。",
|
||||
|
||||
+113
-5
@@ -23,6 +23,8 @@ from app.schemas.common import TimeData as _SchemaTimeData
|
||||
from app.schemas.common import ValueData as _SchemaValueData
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.system import NetTestTarget as _SchemaNetTestTarget
|
||||
from app.schemas.system import DatabaseBackupArtifactData as _SchemaDatabaseBackupArtifactData
|
||||
from app.schemas.system import DatabaseBackupVerificationData as _SchemaDatabaseBackupVerificationData
|
||||
from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncData
|
||||
from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest
|
||||
from app.schemas.system import RuleTestData as _SchemaRuleTestData
|
||||
@@ -46,6 +48,8 @@ from app.application.configuration import (
|
||||
get_configured_system_config,
|
||||
get_runtime_settings,
|
||||
)
|
||||
from app.application.backup import DatabaseBackupInProgressError
|
||||
from app.application.database import get_database_governance
|
||||
from app.application.plugin.runtime import plugin_system_config_mutation
|
||||
from app.api.dependencies.auth import (
|
||||
get_current_active_superuser,
|
||||
@@ -67,6 +71,7 @@ from app.application.rules import RuleHelper
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
from app.application.scheduling import get_scheduler
|
||||
from app.schemas.event import ConfigChangeEventData
|
||||
from app.schemas.exception import PluginMutationRejectedError
|
||||
@@ -79,7 +84,7 @@ from app.adapters.system.update import system_update_manager
|
||||
from app.application.security.url import SecurityUtils
|
||||
from app.application.network import NetworkTestService
|
||||
from app.foundation.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version, get_frontend_version
|
||||
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
@@ -110,6 +115,16 @@ _DATABASE_BACKUP_SETTING_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _database_backup_artifact_data(artifact: Any) -> _SchemaDatabaseBackupArtifactData:
|
||||
"""将内部备份制品映射为不含宿主路径的 Web DTO。"""
|
||||
return _SchemaDatabaseBackupArtifactData(
|
||||
name=artifact.name,
|
||||
db_type=artifact.db_type,
|
||||
created_at=artifact.created_at,
|
||||
size=artifact.size,
|
||||
)
|
||||
|
||||
|
||||
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
|
||||
"""校验强制服务端联网搜索配置,返回用户可读错误信息。"""
|
||||
from app.agent.llm.server_tools import (
|
||||
@@ -759,8 +774,8 @@ def get_global_setting(token: str):
|
||||
# 追加版本信息(用于版本检查)
|
||||
info.update(
|
||||
{
|
||||
"FRONTEND_VERSION": SystemChain.get_frontend_version(),
|
||||
"BACKEND_VERSION": APP_VERSION,
|
||||
"FRONTEND_VERSION": get_frontend_version(),
|
||||
"BACKEND_VERSION": get_app_version(),
|
||||
}
|
||||
)
|
||||
# 仅在后端开发模式下返回该标记,避免生产环境暴露无意义运行态信息
|
||||
@@ -812,6 +827,99 @@ async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_u
|
||||
return _SchemaResponse(success=True, data=info)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/database/backups",
|
||||
summary="查询受管数据库备份",
|
||||
response_model=list[_SchemaDatabaseBackupArtifactData],
|
||||
)
|
||||
async def list_database_backups(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> list[_SchemaDatabaseBackupArtifactData]:
|
||||
"""列出当前备份目录中的正式制品,不触发内容校验。"""
|
||||
try:
|
||||
artifacts = await run_in_threadpool_to_completion(
|
||||
get_database_governance().list_backups
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("读取数据库备份列表失败")
|
||||
raise HTTPException(status_code=500, detail="读取数据库备份列表失败,请查看日志") from error
|
||||
return [_database_backup_artifact_data(artifact) for artifact in artifacts]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/database/backups",
|
||||
summary="立即创建数据库备份",
|
||||
response_model=_SchemaDatabaseBackupArtifactData,
|
||||
)
|
||||
async def create_database_backup(
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> _SchemaDatabaseBackupArtifactData:
|
||||
"""创建、校验并原子发布当前活动数据库的一致快照。"""
|
||||
try:
|
||||
artifact = await run_in_threadpool_to_completion(
|
||||
get_database_governance().create_backup
|
||||
)
|
||||
except DatabaseBackupInProgressError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||
except Exception as error:
|
||||
logger.exception("创建数据库备份失败")
|
||||
raise HTTPException(status_code=500, detail="创建数据库备份失败,请查看日志") from error
|
||||
return _database_backup_artifact_data(artifact)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/database/backups/{name}/verify",
|
||||
summary="校验受管数据库备份",
|
||||
response_model=_SchemaDatabaseBackupVerificationData,
|
||||
)
|
||||
async def verify_database_backup(
|
||||
name: str,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> _SchemaDatabaseBackupVerificationData:
|
||||
"""校验一个受管制品,响应不包含宿主路径或适配器错误明细。"""
|
||||
try:
|
||||
verification = await run_in_threadpool_to_completion(
|
||||
get_database_governance().verify_backup,
|
||||
name,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail="数据库备份不存在") from error
|
||||
except Exception as error:
|
||||
logger.exception("校验数据库备份失败:%s", name)
|
||||
raise HTTPException(status_code=500, detail="校验数据库备份失败,请查看日志") from error
|
||||
return _SchemaDatabaseBackupVerificationData(
|
||||
valid=verification.valid,
|
||||
method=verification.method,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/database/backups/{name}",
|
||||
summary="删除受管数据库备份",
|
||||
response_model=_SchemaResponse[None],
|
||||
)
|
||||
async def delete_database_backup(
|
||||
name: str,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> _SchemaResponse[None]:
|
||||
"""删除一个受管制品,只接受备份目录内的合法文件名。"""
|
||||
try:
|
||||
await run_in_threadpool_to_completion(
|
||||
get_database_governance().delete_backup,
|
||||
name,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail="数据库备份不存在") from error
|
||||
except Exception as error:
|
||||
logger.exception("删除数据库备份失败:%s", name)
|
||||
raise HTTPException(status_code=500, detail="删除数据库备份失败,请查看日志") from error
|
||||
return _SchemaResponse(success=True)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/env",
|
||||
summary="查询系统配置",
|
||||
@@ -828,10 +936,10 @@ async def get_env_setting(
|
||||
)
|
||||
info.update(
|
||||
{
|
||||
"VERSION": APP_VERSION,
|
||||
"VERSION": get_app_version(),
|
||||
"AUTH_VERSION": SitesHelper().auth_version,
|
||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||
"FRONTEND_VERSION": SystemChain().get_frontend_version(),
|
||||
"FRONTEND_VERSION": get_frontend_version(),
|
||||
"RUST_ACCEL": rust_accel.is_config_enabled(),
|
||||
"RUST_ACCEL_AVAILABLE": rust_accel.is_available(),
|
||||
"RUST_ACCEL_ENABLED": rust_accel.is_enabled(),
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ from app.api.dependencies.subscription import get_servarr_subscription_service
|
||||
from app.schemas.servarr import RadarrMovie
|
||||
from app.schemas.servarr import SonarrSeries
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES)
|
||||
|
||||
@@ -74,7 +74,7 @@ async def arr_system_status(
|
||||
return _SchemaServarrSystemStatus.model_validate({
|
||||
"appName": "MoviePilot",
|
||||
"instanceName": "moviepilot",
|
||||
"version": APP_VERSION,
|
||||
"version": get_app_version(),
|
||||
"buildTime": "",
|
||||
"isDebug": False,
|
||||
"isProduction": True,
|
||||
|
||||
+42
-27
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.adapters.system.backup.files import BackupFiles
|
||||
@@ -44,6 +45,10 @@ class BackupVerification:
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class DatabaseBackupInProgressError(RuntimeError):
|
||||
"""同一宿主进程已有数据库备份正在创建。"""
|
||||
|
||||
|
||||
class BackupCheck(Protocol):
|
||||
"""数据库适配器校验结果的结构合同。"""
|
||||
|
||||
@@ -81,38 +86,44 @@ class DatabaseBackupService:
|
||||
self._backend = backend
|
||||
self._policy_reader = policy_reader
|
||||
self._clock = clock
|
||||
self._create_lock = Lock()
|
||||
|
||||
def create(self) -> BackupArtifact:
|
||||
"""创建、校验并发布一个在线一致快照。"""
|
||||
policy = self._policy_reader()
|
||||
files = BackupFiles(policy.root)
|
||||
created_at = self._clock()
|
||||
name = files.available_name(
|
||||
db_type=self._backend.db_type,
|
||||
created_at=created_at,
|
||||
suffix=self._backend.suffix,
|
||||
)
|
||||
temporary = files.create_temporary(self._backend.suffix)
|
||||
if not self._create_lock.acquire(blocking=False):
|
||||
raise DatabaseBackupInProgressError("已有数据库备份任务正在执行")
|
||||
try:
|
||||
self._backend.create(temporary)
|
||||
verification = self._backend.verify(temporary)
|
||||
if not verification.valid:
|
||||
detail = f":{verification.detail}" if verification.detail else ""
|
||||
raise RuntimeError(
|
||||
f"数据库备份校验失败({verification.method}){detail}"
|
||||
)
|
||||
path = files.publish(temporary, name)
|
||||
except Exception:
|
||||
files.discard(temporary)
|
||||
raise
|
||||
policy = self._policy_reader()
|
||||
files = BackupFiles(policy.root)
|
||||
created_at = self._clock()
|
||||
name = files.available_name(
|
||||
db_type=self._backend.db_type,
|
||||
created_at=created_at,
|
||||
suffix=self._backend.suffix,
|
||||
)
|
||||
temporary = files.create_temporary(self._backend.suffix)
|
||||
try:
|
||||
self._backend.create(temporary)
|
||||
verification = self._backend.verify(temporary)
|
||||
if not verification.valid:
|
||||
detail = f":{verification.detail}" if verification.detail else ""
|
||||
raise RuntimeError(
|
||||
f"数据库备份校验失败({verification.method}){detail}"
|
||||
)
|
||||
path = files.publish(temporary, name)
|
||||
except Exception:
|
||||
files.discard(temporary)
|
||||
raise
|
||||
|
||||
artifact = self._artifact(path, created_at=created_at)
|
||||
self._prune(files, policy, keep=artifact.name)
|
||||
logger.info(
|
||||
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type},"
|
||||
f"大小={artifact.size} bytes"
|
||||
)
|
||||
return artifact
|
||||
artifact = self._artifact(path, created_at=created_at)
|
||||
self._prune(files, policy, keep=artifact.name)
|
||||
logger.info(
|
||||
f"数据库备份完成:文件={artifact.name},类型={artifact.db_type},"
|
||||
f"大小={artifact.size} bytes"
|
||||
)
|
||||
return artifact
|
||||
finally:
|
||||
self._create_lock.release()
|
||||
|
||||
def list(self) -> tuple[BackupArtifact, ...]:
|
||||
"""按创建时间倒序列出受管数据库备份文件。"""
|
||||
@@ -126,6 +137,10 @@ class DatabaseBackupService:
|
||||
result = self._backend.verify(path)
|
||||
return BackupVerification(result.valid, result.method, result.detail)
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""删除一个受管数据库备份文件。"""
|
||||
BackupFiles(self._policy_reader().root).delete(name)
|
||||
|
||||
def restore(self, name: str) -> BackupArtifact:
|
||||
"""校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。"""
|
||||
path = BackupFiles(self._policy_reader().root).resolve(name)
|
||||
|
||||
@@ -79,6 +79,10 @@ class DatabaseGovernance:
|
||||
"""校验一个受管数据库备份文件。"""
|
||||
return self._backup.verify(name)
|
||||
|
||||
def delete_backup(self, name: str) -> None:
|
||||
"""删除一个受管数据库备份文件。"""
|
||||
self._backup.delete(name)
|
||||
|
||||
def restore_backup(self, name: str) -> BackupArtifact:
|
||||
"""在离线 CLI 进程中还原一个受管数据库备份。"""
|
||||
return self._backup.restore(name)
|
||||
|
||||
+7
-23
@@ -14,7 +14,7 @@ from app.schemas.message import Message
|
||||
from app.schemas.notification import NotificationChannel
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from version import FRONTEND_VERSION, APP_VERSION
|
||||
from app.runtime import version as runtime_version
|
||||
|
||||
|
||||
class SystemChain(ChainBase):
|
||||
@@ -313,8 +313,8 @@ class SystemChain(ChainBase):
|
||||
"""
|
||||
server_release_version = self.__get_server_release_version()
|
||||
front_release_version = self.__get_front_release_version()
|
||||
server_local_version = self.get_server_local_version()
|
||||
front_local_version = self.get_frontend_version()
|
||||
server_local_version = runtime_version.get_app_version()
|
||||
front_local_version = runtime_version.get_frontend_version()
|
||||
if server_release_version == server_local_version:
|
||||
title = f"当前后端版本:{server_local_version},已是最新版本\n"
|
||||
else:
|
||||
@@ -417,26 +417,10 @@ class SystemChain(ChainBase):
|
||||
|
||||
@staticmethod
|
||||
def get_server_local_version():
|
||||
"""
|
||||
查看当前版本
|
||||
"""
|
||||
return APP_VERSION
|
||||
"""返回当前后端构建版本。"""
|
||||
return runtime_version.get_app_version()
|
||||
|
||||
@staticmethod
|
||||
def get_frontend_version():
|
||||
"""
|
||||
获取前端版本
|
||||
"""
|
||||
if SystemUtils.is_frozen() and SystemUtils.is_windows():
|
||||
config = get_chain_runtime_config_snapshot()
|
||||
version_file = config.config_path.parent / "nginx" / "html" / "version.txt"
|
||||
else:
|
||||
version_file = get_chain_runtime_config_snapshot().frontend_path / "version.txt"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
version = str(f.read()).strip()
|
||||
return version
|
||||
except Exception as err:
|
||||
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
return FRONTEND_VERSION
|
||||
"""返回当前部署的前端资源版本。"""
|
||||
return runtime_version.get_frontend_version()
|
||||
|
||||
+12
-13
@@ -23,7 +23,7 @@ settings = RuntimeSettingsCompat()
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.application.backup import BackupArtifact
|
||||
from app.startup.composition.database import build_database_governance
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version, get_frontend_version
|
||||
|
||||
BACKEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||
BACKEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.stdout.log"
|
||||
@@ -334,7 +334,7 @@ def _apply_prepared_release_update() -> bool:
|
||||
def _resolve_auto_update_targets(mode: str) -> Optional[str]:
|
||||
if mode != "dev":
|
||||
return None
|
||||
backend_prefix = _release_prefix(APP_VERSION)
|
||||
backend_prefix = _release_prefix(get_app_version())
|
||||
current_branch = _git_current_branch()
|
||||
backend_ref = "latest"
|
||||
if not current_branch or current_branch == "HEAD":
|
||||
@@ -876,12 +876,7 @@ def _stop_frontend_service(timeout: int, force: bool) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _installed_frontend_version() -> Optional[str]:
|
||||
if not FRONTEND_VERSION_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return FRONTEND_VERSION_FILE.read_text(encoding="utf-8", errors="replace").strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
return get_frontend_version(fallback_to_declared=False)
|
||||
|
||||
|
||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||
@@ -991,7 +986,9 @@ def start(timeout: int, safe: bool) -> None:
|
||||
raise
|
||||
|
||||
backend_health = backend_result.get("health") or {}
|
||||
backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get("BACKEND_VERSION", APP_VERSION)
|
||||
backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get(
|
||||
"BACKEND_VERSION", get_app_version()
|
||||
)
|
||||
frontend_version = ((frontend_result.get("health") or {}) if isinstance(frontend_result.get("health"), dict) else {}).get("version") or _installed_frontend_version() or "unknown"
|
||||
|
||||
click.echo("MoviePilot 已启动" if backend_result.get("started") or frontend_result.get("started") else "MoviePilot 已在运行")
|
||||
@@ -1058,13 +1055,13 @@ def status() -> None:
|
||||
data = (backend_health or {}).get("data") or {}
|
||||
click.echo(" running (unmanaged)")
|
||||
click.echo(f" URL: {_backend_base_url()}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', get_app_version())}")
|
||||
else:
|
||||
data = (backend_health or {}).get("data") or {}
|
||||
click.echo(f" {'running' if backend_state == 'running' else 'starting'}")
|
||||
click.echo(f" PID: {backend_process.pid}")
|
||||
click.echo(f" URL: {_backend_base_url(backend_runtime)}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
click.echo(f" Version: {data.get('BACKEND_VERSION', get_app_version())}")
|
||||
click.echo(f" App Log: {BACKEND_APP_LOG_FILE}")
|
||||
click.echo(f" Stdout Log: {BACKEND_STDIO_LOG_FILE}")
|
||||
|
||||
@@ -1305,12 +1302,14 @@ def scheduler_run(job_id: str) -> None:
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
def version() -> None:
|
||||
"""显示版本信息"""
|
||||
click.echo(f"MoviePilot CLI: {APP_VERSION}")
|
||||
click.echo(f"MoviePilot CLI: {get_app_version()}")
|
||||
|
||||
healthy_backend, payload = _backend_health(runtime=_backend_runtime())
|
||||
if healthy_backend:
|
||||
data = (payload or {}).get("data") or {}
|
||||
click.echo(f"Backend Service: {data.get('BACKEND_VERSION', APP_VERSION)}")
|
||||
click.echo(
|
||||
f"Backend Service: {data.get('BACKEND_VERSION', get_app_version())}"
|
||||
)
|
||||
else:
|
||||
click.echo("Backend Service: not running")
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.doctor.models import (
|
||||
DoctorSeverity,
|
||||
)
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
|
||||
class DoctorRunner:
|
||||
@@ -36,7 +36,7 @@ class DoctorRunner:
|
||||
self.deep = deep
|
||||
self.report = DoctorReport(
|
||||
generated_at=datetime.now(),
|
||||
version=APP_VERSION,
|
||||
version=get_app_version(),
|
||||
environment=self._environment(),
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ from app.schemas.openai import (
|
||||
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
|
||||
from app.schemas.response import Response as ApiResponse, ValidationIssue
|
||||
from app.startup.lifecycle import lifespan
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
|
||||
def _get_http_exception_message(detail: Any) -> str:
|
||||
@@ -328,7 +328,7 @@ def create_app() -> FastAPI:
|
||||
configure_observation(build_observation_port())
|
||||
_app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=APP_VERSION,
|
||||
version=get_app_version(),
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""历史版本比较规则。"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
__all__ = ["compare_version"]
|
||||
|
||||
|
||||
_VERSION_LABELS = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
||||
_UNKNOWN_VERSION_LABEL = -5
|
||||
|
||||
@@ -125,6 +125,17 @@
|
||||
"音乐实体类型无效,仅支持 recording 或 album": "Invalid music entity type; only recording or album is supported",
|
||||
"音乐下载只能使用音乐元数据源": "Music downloads can only use music metadata sources",
|
||||
"音乐重新识别只能使用音乐元数据源": "Music re-identification can only use music metadata sources",
|
||||
"读取数据库备份列表失败,请查看日志": "Failed to load database backups. Check the logs for details",
|
||||
"创建数据库备份失败,请查看日志": "Failed to create the database backup. Check the logs for details",
|
||||
"已有数据库备份任务正在执行": "A database backup task is already in progress",
|
||||
"数据库备份文件名无效": "The database backup file name is invalid",
|
||||
"数据库备份不存在": "The database backup does not exist",
|
||||
"校验数据库备份失败,请查看日志": "Failed to verify the database backup. Check the logs for details",
|
||||
"删除数据库备份失败,请查看日志": "Failed to delete the database backup. Check the logs for details",
|
||||
"数据库备份周期格式不正确": "The database backup schedule format is invalid",
|
||||
"数据库备份目录必须是路径字符串": "The database backup directory must be a path string",
|
||||
"数据库备份过期天数必须是大于等于 0 的整数": "Database backup retention days must be an integer greater than or equal to 0",
|
||||
"数据库备份最大保留份数必须是大于等于 0 的整数": "The maximum number of database backups must be an integer greater than or equal to 0",
|
||||
"记录不存在": "Record does not exist",
|
||||
"MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled",
|
||||
"整理记录不存在": "Organization record does not exist",
|
||||
|
||||
@@ -121,6 +121,17 @@
|
||||
"音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album",
|
||||
"音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源",
|
||||
"音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源",
|
||||
"读取数据库备份列表失败,请查看日志": "讀取資料庫備份清單失敗,請查看日誌",
|
||||
"创建数据库备份失败,请查看日志": "建立資料庫備份失敗,請查看日誌",
|
||||
"已有数据库备份任务正在执行": "已有資料庫備份任務正在執行",
|
||||
"数据库备份文件名无效": "資料庫備份檔名無效",
|
||||
"数据库备份不存在": "資料庫備份不存在",
|
||||
"校验数据库备份失败,请查看日志": "驗證資料庫備份失敗,請查看日誌",
|
||||
"删除数据库备份失败,请查看日志": "刪除資料庫備份失敗,請查看日誌",
|
||||
"数据库备份周期格式不正确": "資料庫備份週期格式不正確",
|
||||
"数据库备份目录必须是路径字符串": "資料庫備份目錄必須是路徑字串",
|
||||
"数据库备份过期天数必须是大于等于 0 的整数": "資料庫備份過期天數必須是大於等於 0 的整數",
|
||||
"数据库备份最大保留份数必须是大于等于 0 的整数": "資料庫備份最大保留份數必須是大於等於 0 的整數",
|
||||
"记录不存在": "記錄不存在",
|
||||
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
|
||||
"整理记录不存在": "整理記錄不存在",
|
||||
|
||||
@@ -33,7 +33,7 @@ from app.foundation.environment import (
|
||||
is_frozen,
|
||||
)
|
||||
from app.foundation.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
|
||||
class SystemConfModel(BaseModel):
|
||||
@@ -1061,7 +1061,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
全局用户代理字符串
|
||||
"""
|
||||
return (
|
||||
f"{self.PROJECT_NAME}/{APP_VERSION[1:]} "
|
||||
f"{self.PROJECT_NAME}/{get_app_version()[1:]} "
|
||||
f"({platform.system()} {platform.release()}; {cpu_arch()})"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""当前 MoviePilot 部署的产品版本读取入口。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.foundation.environment import is_frozen, is_windows
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from version import APP_VERSION as _APP_VERSION
|
||||
from version import FRONTEND_VERSION as _FRONTEND_VERSION
|
||||
|
||||
|
||||
def get_app_version() -> str:
|
||||
"""返回当前后端构建的发布版本。"""
|
||||
return str(_APP_VERSION)
|
||||
|
||||
|
||||
def _read_version_file(path: Path) -> str | None:
|
||||
"""读取版本文件,文件缺失、内容为空或读取失败时返回空值。"""
|
||||
try:
|
||||
version = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
return version or None
|
||||
except OSError as error:
|
||||
if path.exists():
|
||||
logger.debug(f"加载版本文件 {path} 出错:{error}")
|
||||
return None
|
||||
|
||||
|
||||
def get_frontend_version(*, fallback_to_declared: bool = True) -> str | None:
|
||||
"""返回当前部署的前端资源版本,并可关闭发布声明回退。"""
|
||||
if is_frozen() and is_windows():
|
||||
version_file = (
|
||||
Path(get_runtime_setting("CONFIG_PATH")).parent
|
||||
/ "nginx"
|
||||
/ "html"
|
||||
/ "version.txt"
|
||||
)
|
||||
else:
|
||||
version_file = Path(get_runtime_setting("FRONTEND_PATH")) / "version.txt"
|
||||
installed_version = _read_version_file(version_file)
|
||||
if installed_version or not fallback_to_declared:
|
||||
return installed_version
|
||||
return str(_FRONTEND_VERSION)
|
||||
@@ -81,6 +81,8 @@ SCHEMA_EXPORTS = {
|
||||
'DashboardMemoryInfo': ('app.schemas.dashboard', 'DashboardMemoryInfo'),
|
||||
'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'),
|
||||
'DataT': ('app.schemas.response', 'DataT'),
|
||||
'DatabaseBackupArtifactData': ('app.schemas.system', 'DatabaseBackupArtifactData'),
|
||||
'DatabaseBackupVerificationData': ('app.schemas.system', 'DatabaseBackupVerificationData'),
|
||||
'Dict': ('app.schemas.mcp', 'Dict'),
|
||||
'DiscoverMediaSource': ('app.schemas.event', 'DiscoverMediaSource'),
|
||||
'DiscoverSourceEventData': ('app.schemas.event', 'DiscoverSourceEventData'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as _DateTime
|
||||
from typing import Optional, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
@@ -216,6 +217,22 @@ class SystemModuleListData(BaseModel):
|
||||
modules: list[SystemModuleInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DatabaseBackupArtifactData(BaseModel): # type: ignore[misc]
|
||||
"""Web 管理端可见的受管数据库备份摘要。"""
|
||||
|
||||
name: str # 受管文件名,不包含宿主目录
|
||||
db_type: str # 创建制品的数据库类型
|
||||
created_at: _DateTime # 从受管文件名解析出的创建时间
|
||||
size: int # 备份文件字节数
|
||||
|
||||
|
||||
class DatabaseBackupVerificationData(BaseModel): # type: ignore[misc]
|
||||
"""受管数据库备份的脱敏校验结果。"""
|
||||
|
||||
valid: bool # 是否通过当前数据库类型的内容校验
|
||||
method: str # SQLite integrity_check 或 PostgreSQL 归档目录校验
|
||||
|
||||
|
||||
class TransferDirectoryConf(BaseModel):
|
||||
"""
|
||||
文件整理目录配置
|
||||
|
||||
Reference in New Issue
Block a user