Files
MoviePilot/app/chain/system.py
T

427 lines
17 KiB
Python

import errno
import json
import re
import shutil
import uuid
from pathlib import Path
from typing import Optional, Union
from app.adapters.network.http import RequestUtils
from app.adapters.system.host import SystemUtils
from app.application.configuration import get_chain_runtime_config_snapshot
from app.chain import ChainBase
from app.runtime import version as runtime_version
from app.runtime.log import logger
from app.runtime.state import SystemHelper
from app.schemas.message import Message
from app.schemas.notification import NotificationChannel
class SystemChain(ChainBase):
"""
系统级处理链
"""
_restart_file = "__system_restart__"
_plugin_restore_pending_file = "__plugin_restore_pending__"
def remote_clear_cache(self, channel: NotificationChannel, userid: Union[int, str], source: Optional[str] = None):
"""
清理系统缓存
"""
self.clear_cache()
self.post_message(Message(
channel=channel,
source=source,
title="缓存清理完成!",
userid=userid,
save_history=False))
def restart(self, channel: NotificationChannel, userid: Union[int, str], source: Optional[str] = None):
"""
重启系统
"""
if channel and userid:
self.post_message(Message(
channel=channel,
source=source,
title="系统正在重启,请耐心等候!",
userid=userid,
save_history=False))
# 保存重启信息
self.save_cache({
"channel": channel.value,
"userid": userid
}, self._restart_file)
# 主动备份一次插件
self.backup_plugins()
# 重启
SystemHelper.restart()
@staticmethod
def backup_plugins():
"""
备份插件到用户配置目录(仅docker环境)
"""
# 非docker环境不处理
if not SystemUtils.is_docker():
return
try:
# 使用绝对路径确保准确性
config = get_chain_runtime_config_snapshot()
plugins_dir = config.root_path / "app" / "plugins"
backup_dir = config.config_path / "plugins_backup"
if not plugins_dir.exists():
logger.info("插件目录不存在,跳过备份")
return
# 确保备份目录存在
backup_dir.mkdir(parents=True, exist_ok=True)
pending_file = backup_dir / SystemChain._plugin_restore_pending_file
pending_items = (
SystemChain.__read_plugin_restore_pending(pending_file)
if pending_file.exists()
else None
)
# 需要排除的文件和目录
exclude_items = {"__init__.py", "__pycache__", ".DS_Store"}
backup_failed = False
# 遍历插件目录,备份除排除项外的所有内容
for item in plugins_dir.iterdir():
if item.name in exclude_items:
continue
# 失败项目的原快照是下一次恢复的唯一材料,关停备份不能覆盖它。
if pending_file.exists() and (
pending_items is None or item.name in pending_items
):
logger.debug(f"插件 {item.name} 有待重试恢复标记,保留原快照")
continue
target_path = backup_dir / item.name
try:
SystemChain.__replace_snapshot(
item,
target_path,
ignore=shutil.ignore_patterns(
"__pycache__", "*.pyc", ".DS_Store"
) if item.is_dir() else None,
)
logger.debug(f"已备份插件项目: {item.name}")
except Exception as e:
backup_failed = True
logger.error(f"备份插件 {item.name} 失败: {e}")
if backup_failed:
logger.warning(f"插件备份部分失败,保留可用旧快照: {backup_dir}")
else:
logger.info(f"插件备份完成,备份位置: {backup_dir}")
except Exception as e:
logger.error(f"插件备份失败: {str(e)}")
@staticmethod
def restore_plugins():
"""
从备份恢复插件到app/plugins目录,恢复完成后删除备份(仅docker环境)
"""
# 非docker环境不处理
if not SystemUtils.is_docker():
return
# 使用绝对路径确保准确性
config = get_chain_runtime_config_snapshot()
plugins_dir = config.root_path / "app" / "plugins"
backup_dir = config.config_path / "plugins_backup"
if not backup_dir.exists():
logger.info("插件备份目录不存在,跳过恢复")
return
pending_file = backup_dir / SystemChain._plugin_restore_pending_file
# 系统重置或上次恢复未完成时才消费备份。
system_reset = SystemHelper().is_system_reset()
should_restore = system_reset or pending_file.exists()
if not should_restore:
logger.info("当前不是系统重置,保留插件备份供后续重置使用")
return
# 确保插件目录存在
plugins_dir.mkdir(parents=True, exist_ok=True)
# 遍历备份目录,恢复所有内容
restored_count = 0
restore_failed = False
failed_items: dict[str, bool] = {}
pending_items = (
SystemChain.__read_plugin_restore_pending(pending_file)
if pending_file.exists() and not system_reset
else None
)
for item in backup_dir.iterdir():
if (
item.name == SystemChain._plugin_restore_pending_file
or SystemChain.__is_snapshot_artifact(item.name)
):
continue
target_path = plugins_dir / item.name
if pending_items is not None:
if item.name not in pending_items:
continue
if not pending_items[item.name] and target_path.exists():
logger.info(f"插件 {item.name} 已在恢复失败后重新安装,跳过备份覆盖")
continue
target_existed = target_path.exists()
try:
if item.is_dir() or item.is_file():
SystemChain.__replace_snapshot(item, target_path)
logger.debug(f"已恢复插件文件: {item.name}")
restored_count += 1
except Exception as e:
restore_failed = True
failed_items[item.name] = target_existed
logger.error(f"恢复插件 {item.name} 时发生错误: {str(e)}")
continue
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
if restore_failed:
if SystemChain.__write_plugin_restore_pending(pending_file, failed_items):
logger.warning("插件恢复未完成,保留备份并标记为下次启动重试")
else:
logger.warning("插件恢复未完成,已保留备份,但无法写入下次启动重试标记")
return
# 源码恢复完成后即可消费备份;依赖由启动后的统一后台任务处理。
try:
shutil.rmtree(backup_dir)
logger.info(f"已删除插件备份目录: {backup_dir}")
except Exception as e:
logger.warning(f"删除备份目录失败: {str(e)}")
if backup_dir.exists():
SystemChain.__write_plugin_restore_pending(pending_file, {})
@staticmethod
def __is_snapshot_artifact(name: str) -> bool:
"""识别快照替换过程中生成的临时或旧快照条目。"""
return bool(re.fullmatch(r"\..+\.(?:tmp|old)-[0-9a-f]{32}", name))
@staticmethod
def __read_plugin_restore_pending(pending_file: Path) -> Optional[dict[str, bool]]:
"""读取仍需恢复的插件项目;无效内容按全部项目重试。"""
try:
payload = json.loads(pending_file.read_text(encoding="utf-8"))
failed_items = payload.get("failed_items")
if not isinstance(failed_items, dict):
return None
return {
str(name): target_existed
for name, target_existed in failed_items.items()
if isinstance(name, str) and isinstance(target_existed, bool)
}
except (OSError, ValueError, TypeError):
return None
@staticmethod
def __write_plugin_restore_pending(
pending_file: Path,
failed_items: dict[str, bool],
) -> bool:
"""记录失败项目及其原目标状态,供普通重启继续未完成恢复。"""
try:
pending_file.write_text(
json.dumps({"failed_items": failed_items}, ensure_ascii=False),
encoding="utf-8",
)
return True
except Exception as e:
logger.error(f"写入插件恢复重试标记失败: {e}")
return False
@staticmethod
def __replace_snapshot(source: Path, target: Path, *, ignore=None) -> None:
"""复制到同级临时路径后替换目标,避免失败时丢失旧快照。"""
target.parent.mkdir(parents=True, exist_ok=True)
suffix = uuid.uuid4().hex
staging = target.with_name(f".{target.name}.tmp-{suffix}")
previous = target.with_name(f".{target.name}.old-{suffix}")
previous_available = False
published = False
try:
if source.is_dir():
shutil.copytree(source, staging, ignore=ignore)
else:
shutil.copy2(source, staging)
if target.exists():
try:
target.replace(previous)
except OSError as error:
if error.errno != errno.EXDEV:
raise
# overlayfs 可能拒绝把镜像层目录直接 rename 到可写层,
# 先复制旧目标保留恢复材料,再删除旧目录继续发布快照。
if target.is_dir():
shutil.copytree(target, previous, symlinks=True)
else:
shutil.copy2(target, previous, follow_symlinks=False)
previous_available = True
SystemChain.__remove_snapshot_path(target)
else:
previous_available = True
staging.replace(target)
published = True
except Exception:
if previous_available and not published:
try:
SystemChain.__remove_snapshot_path(target)
previous.replace(target)
previous_available = False
except Exception as rollback_error:
logger.error(
f"恢复旧快照失败,已保留恢复材料 {previous}: "
f"{rollback_error}"
)
raise
finally:
if staging.is_dir():
shutil.rmtree(staging, ignore_errors=True)
elif staging.exists():
staging.unlink(missing_ok=True)
if published and previous.exists():
if previous.is_dir():
shutil.rmtree(previous, ignore_errors=True)
else:
previous.unlink(missing_ok=True)
@staticmethod
def __remove_snapshot_path(path: Path) -> None:
"""删除待替换目标,保留失败回滚所需的旧快照副本。"""
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
elif path.exists() or path.is_symlink():
path.unlink()
def __get_version_message(self) -> str:
"""
获取版本信息文本
"""
server_release_version = self.__get_server_release_version()
front_release_version = self.__get_front_release_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:
title = f"当前后端版本:{server_local_version},远程版本:{server_release_version}\n"
if front_release_version == front_local_version:
title += f"当前前端版本:{front_local_version},已是最新版本"
else:
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
return title
def version(self, channel: NotificationChannel, userid: Union[int, str], source: Optional[str] = None):
"""
查看当前版本、远程版本
"""
self.post_message(Message(
channel=channel,
source=source,
title=self.__get_version_message(),
userid=userid,
save_history=False))
def restart_finish(self):
"""
如通过交互命令重启,
重启完发送msg
"""
# 重启消息
restart_channel = self.load_cache(self._restart_file)
if restart_channel:
# 发送重启完成msg
if not isinstance(restart_channel, dict):
restart_channel = json.loads(restart_channel)
channel = next(
(channel for channel in NotificationChannel.__members__.values() if
channel.value == restart_channel.get('channel')), None)
userid = restart_channel.get('userid')
# 版本号
title = self.__get_version_message()
self.post_message(Message(
channel=channel,
title=f"系统已重启完成!\n{title}",
userid=userid,
save_history=False))
self.remove_cache(self._restart_file)
@staticmethod
def __get_server_release_version():
"""
获取后端V2最新版本
"""
try:
# 获取所有发布的版本列表
response = RequestUtils(
proxies=get_chain_runtime_config_snapshot().proxy,
headers=get_chain_runtime_config_snapshot().github_headers,
).get_res("https://api.github.com/repos/jxxghp/MoviePilot/releases")
if response:
releases = [release['tag_name'] for release in response.json()]
v2_releases = [tag for tag in releases if re.match(r"^v2\.", tag)]
if not v2_releases:
logger.warn("获取v2后端最新版本版本出错!")
else:
# 找到最新的v2版本
latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\d+', s))))[-1]
logger.info(f"获取到后端最新版本:{latest_v2}")
return latest_v2
else:
logger.error("无法获取后端版本信息,请检查网络连接或GitHub API请求。")
except Exception as err:
logger.error(f"获取后端最新版本失败:{str(err)}")
return None
@staticmethod
def __get_front_release_version():
"""
获取前端V2最新版本
"""
try:
# 获取所有发布的版本列表
response = RequestUtils(
proxies=get_chain_runtime_config_snapshot().proxy,
headers=get_chain_runtime_config_snapshot().github_headers,
).get_res("https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases")
if response:
releases = [release['tag_name'] for release in response.json()]
v2_releases = [tag for tag in releases if re.match(r"^v2\.", tag)]
if not v2_releases:
logger.warn("获取v2前端最新版本版本出错!")
else:
# 找到最新的v2版本
latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\d+', s))))[-1]
logger.info(f"获取到前端最新版本:{latest_v2}")
return latest_v2
else:
logger.error("无法获取前端版本信息,请检查网络连接或GitHub API请求。")
except Exception as err:
logger.error(f"获取前端最新版本失败:{str(err)}")
return None
@staticmethod
def get_server_local_version():
"""返回当前后端构建版本。"""
return runtime_version.get_app_version()
@staticmethod
def get_frontend_version():
"""返回当前部署的前端资源版本。"""
return runtime_version.get_frontend_version()