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

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