mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
fix(update): preserve new Docker modules during upgrades
This commit is contained in:
@@ -878,6 +878,28 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
resource_dir = legacy_dir
|
||||
return resource_dir
|
||||
|
||||
@staticmethod
|
||||
def _is_site_runtime_resource(path: Path) -> bool:
|
||||
"""判断文件是否属于需要跨 Docker 应用升级继承的站点运行时资源。"""
|
||||
return path.is_file() and (
|
||||
(path.name.startswith("user.sites.") and path.suffix == ".bin")
|
||||
or (
|
||||
path.name.startswith("sites.")
|
||||
and path.suffix in {".so", ".pyd", ".dylib"}
|
||||
)
|
||||
)
|
||||
|
||||
def _copy_site_runtime_resources(
|
||||
self, source_dir: Path, destination_dir: Path
|
||||
) -> None:
|
||||
"""只把旧版本的站点索引和原生资源叠加到新版源码目录。"""
|
||||
if not source_dir.is_dir():
|
||||
return
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
for path in source_dir.iterdir():
|
||||
if self._is_site_runtime_resource(path):
|
||||
shutil.copy2(path, destination_dir / path.name)
|
||||
|
||||
def _copy_prepared_resources(
|
||||
self, prepared: dict[str, Any], resource_dir: Path
|
||||
) -> None:
|
||||
@@ -896,7 +918,12 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
*,
|
||||
include_resources: bool,
|
||||
) -> tuple[Path, Path]:
|
||||
"""解压并组装待切换的 Docker 后端、前端和插件资源载荷。"""
|
||||
"""
|
||||
解压并组装待切换的 Docker 后端、前端和插件资源载荷。
|
||||
|
||||
新版本归档是 Python 源码的唯一来源,旧版本目录只叠加插件和站点运行时资源,
|
||||
避免把新版新增的应用模块覆盖掉。
|
||||
"""
|
||||
backend_extract = temporary_root / "backend"
|
||||
frontend_extract = temporary_root / "frontend"
|
||||
backend_extract.mkdir()
|
||||
@@ -926,13 +953,8 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口")
|
||||
|
||||
stage_resources = stage_app / "app" / "application" / "site"
|
||||
if stage_resources.exists() or stage_resources.is_symlink():
|
||||
self._remove_path(stage_resources)
|
||||
current_resources = self._resource_source_dir(current_app)
|
||||
if current_resources.is_dir():
|
||||
shutil.copytree(current_resources, stage_resources, symlinks=True)
|
||||
else:
|
||||
stage_resources.mkdir(parents=True, exist_ok=True)
|
||||
self._copy_site_runtime_resources(current_resources, stage_resources)
|
||||
if include_resources:
|
||||
self._copy_prepared_resources(prepared, stage_resources)
|
||||
return stage_app, stage_public
|
||||
@@ -1348,20 +1370,27 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
raise RuntimeError(f"站点资源文件校验失败:{item.get('name')}")
|
||||
|
||||
def _request(self) -> RequestUtils:
|
||||
"""创建访问 GitHub Release 的请求客户端。"""
|
||||
"""创建访问 GitHub Release API 的认证请求客户端。"""
|
||||
return RequestUtils(
|
||||
proxies=get_runtime_setting("PROXY"),
|
||||
headers=get_runtime_setting("GITHUB_HEADERS"),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
def _download_request(self) -> RequestUtils:
|
||||
"""创建公开 GitHub 归档下载客户端,避免认证头传递到 codeload。"""
|
||||
return RequestUtils(
|
||||
proxies=get_runtime_setting("PROXY"),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
def _download_file(
|
||||
self, url: str, destination: Path, downloaded_before: int, total_hint: int
|
||||
) -> tuple[int, int]:
|
||||
"""流式下载文件并把进度写入当前升级类型。"""
|
||||
"""使用公开下载客户端流式下载文件并把进度写入当前升级类型。"""
|
||||
temporary = destination.with_suffix(".part")
|
||||
temporary.unlink(missing_ok=True)
|
||||
with self._request().get_stream(url) as response:
|
||||
with self._download_request().get_stream(url) as response:
|
||||
if response is None or response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"下载更新包失败:HTTP {getattr(response, 'status_code', '无响应')}"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""系统后台更新状态机测试。"""
|
||||
|
||||
from contextlib import nullcontext
|
||||
import errno
|
||||
import json
|
||||
import threading
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, call
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -151,6 +153,46 @@ def test_check_logs_when_application_is_current(monkeypatch, tmp_path):
|
||||
assert logs == ["MoviePilot 主程序已是最新版本:v3.0.0"]
|
||||
|
||||
|
||||
def test_public_download_request_omits_github_authorization(monkeypatch, tmp_path):
|
||||
"""公开归档下载不得复用 GitHub API 的认证请求头。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
settings = {
|
||||
"TEMP_PATH": tmp_path,
|
||||
"PROXY": {"https": "http://proxy.example:7890"},
|
||||
"GITHUB_HEADERS": {"Authorization": "Bearer github-token"},
|
||||
}
|
||||
monkeypatch.setattr(update_module, "get_runtime_setting", settings.__getitem__)
|
||||
request_utils = Mock()
|
||||
request_utils.return_value.get_stream.return_value = nullcontext(
|
||||
SimpleNamespace(
|
||||
status_code=200,
|
||||
headers={"content-length": "7"},
|
||||
iter_content=lambda **_kwargs: [b"archive"],
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(update_module, "RequestUtils", request_utils)
|
||||
|
||||
manager._request()
|
||||
destination = tmp_path / "backend.zip"
|
||||
downloaded, content_length = manager._download_file(
|
||||
"https://github.com/jxxghp/MoviePilot/archive/refs/tags/v3.0.2.zip",
|
||||
destination,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
assert request_utils.call_args_list == [
|
||||
call(
|
||||
proxies=settings["PROXY"],
|
||||
headers=settings["GITHUB_HEADERS"],
|
||||
timeout=60,
|
||||
),
|
||||
call(proxies=settings["PROXY"], timeout=60),
|
||||
]
|
||||
assert downloaded == content_length == 7
|
||||
assert destination.read_bytes() == b"archive"
|
||||
|
||||
|
||||
def test_scheduled_check_failure_stays_silent(monkeypatch, tmp_path):
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
@@ -433,6 +475,7 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin
|
||||
(plugin_dir / "__init__.py").write_text("# compatibility\n", encoding="utf-8")
|
||||
(plugin_dir / "local_plugin.py").write_text("local\n", encoding="utf-8")
|
||||
(resource_dir / "user.sites.v3.bin").write_text("old-resource\n", encoding="utf-8")
|
||||
(resource_dir / "legacy.py").write_text("old-source\n", encoding="utf-8")
|
||||
(app_dir / "old.py").write_text("old\n", encoding="utf-8")
|
||||
(app_dir / "pyproject.toml").write_text("old-project\n", encoding="utf-8")
|
||||
(app_dir / "uv.lock").write_text("old-lock\n", encoding="utf-8")
|
||||
@@ -447,6 +490,14 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin
|
||||
archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n")
|
||||
archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n")
|
||||
archive.writestr("MoviePilot-v3.1.0/new.py", "new\n")
|
||||
archive.writestr(
|
||||
"MoviePilot-v3.1.0/app/application/site/__init__.py",
|
||||
"",
|
||||
)
|
||||
archive.writestr(
|
||||
"MoviePilot-v3.1.0/app/application/site/auth.py",
|
||||
"new-auth\n",
|
||||
)
|
||||
with zipfile.ZipFile(manager._frontend_archive, "w") as archive:
|
||||
archive.writestr("dist/index.html", "new-front\n")
|
||||
archive.writestr("dist/version.txt", "v3.1.0\n")
|
||||
@@ -482,6 +533,8 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin
|
||||
assert not (app_dir / "old.py").exists()
|
||||
assert (app_dir / "app" / "plugins" / "local_plugin.py").exists()
|
||||
assert (resource_dir / "user.sites.v3.bin").read_text(encoding="utf-8") == "old-resource\n"
|
||||
assert (resource_dir / "auth.py").read_text(encoding="utf-8") == "new-auth\n"
|
||||
assert not (resource_dir / "legacy.py").exists()
|
||||
assert (public_dir / "index.html").read_text(encoding="utf-8") == "new-front\n"
|
||||
assert not manager._install_file.exists()
|
||||
assert not (manager._root / "prepared.json").exists()
|
||||
|
||||
Reference in New Issue
Block a user