mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 22:59:46 +08:00
fix(update): preserve pending update state across checks
Re-check the target state after a check request returns so a download or install that started meanwhile cannot be overwritten. When an older check already turned a staged resource package into available, validate the complete staged files and their digests against the same package version and restore ready, full progress and the install entry instead of forcing a repeated download.
This commit is contained in:
@@ -32,6 +32,8 @@ from app.schemas.system import SystemUpdateStatus, SystemUpdateType
|
||||
_APPLICATION: SystemUpdateType = "application"
|
||||
_RESOURCES: SystemUpdateType = "resources"
|
||||
_TARGETS: tuple[SystemUpdateType, ...] = (_APPLICATION, _RESOURCES)
|
||||
# 下载中或等待用户确认安装的状态:检查不得覆盖,也无须重复发起检查
|
||||
_PENDING_STATES = frozenset({"downloading", "ready", "installing"})
|
||||
_ITEM_FIELDS = {
|
||||
"state",
|
||||
"current_version",
|
||||
@@ -69,6 +71,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
_STABLE_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:-\d+)?$", re.I)
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化状态写入锁与当前进程的下载任务标记。"""
|
||||
self._lock = threading.RLock()
|
||||
self._download_active = False
|
||||
self._active_target: SystemUpdateType | None = None
|
||||
@@ -220,6 +223,14 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
self._get_item(state, target).update(changes)
|
||||
return self._persist_state(self._sync_aggregate(state))
|
||||
|
||||
def _write_check_result(self, target: SystemUpdateType, **changes: Any) -> None:
|
||||
"""检查请求返回后重新核对状态,避免覆盖请求期间开始或完成的下载、安装。"""
|
||||
with self._lock:
|
||||
item = self._get_item(self.get_status().model_dump(), target)
|
||||
if item["state"] in _PENDING_STATES:
|
||||
return
|
||||
self._write_item(target, **changes)
|
||||
|
||||
def _persist_state(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""校验并原子替换状态 JSON。"""
|
||||
validated = cast(dict[str, Any], SystemUpdateStatus.model_validate(state).model_dump())
|
||||
@@ -322,12 +333,14 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
item.get("state") in {"available", "ready", "installing", "failed"}
|
||||
and self._is_install_applied(item, target)
|
||||
):
|
||||
if item.get("state") in {"ready", "failed"}:
|
||||
if item.get("state") in {"available", "ready", "failed"}:
|
||||
# 容器替换或手工安装可能先让运行版本达到目标,需丢弃残留待安装包;
|
||||
# 失败记录同样保留了供重试的下载包,外部升级后已无意义。
|
||||
# 失败或旧检查覆盖后的可用记录也可能保留下载包,外部升级后已无意义。
|
||||
self._discard_prepared_target(target)
|
||||
self._reset_item_after_install(item)
|
||||
changed = True
|
||||
if resources.get("state") == "available":
|
||||
changed = self._restore_prepared_resources(resources) or changed
|
||||
resources_changed = (
|
||||
previous_auth_version != auth_version
|
||||
or previous_indexer_version != indexer_version
|
||||
@@ -340,6 +353,26 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
state["auto_update_resource"] = get_runtime_setting("AUTO_UPDATE_RESOURCE") is True
|
||||
return cast(SystemUpdateStatus, SystemUpdateStatus.model_validate(state))
|
||||
|
||||
def _restore_prepared_resources(self, item: dict[str, Any]) -> bool:
|
||||
"""旧检查覆盖状态后,仅恢复目标包版本一致且文件完整、摘要正确的待安装资源。"""
|
||||
prepared = self._read_prepared_manifest_optional()
|
||||
if not item.get("version") or prepared.get("resource_package_version") != item["version"]:
|
||||
return False
|
||||
try:
|
||||
self._validate_resource_manifest(prepared)
|
||||
size = sum(Path(file["path"]).stat().st_size for file in prepared["resource_files"])
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
item.update(
|
||||
state="ready",
|
||||
downloaded_bytes=size,
|
||||
total_bytes=size,
|
||||
error=None,
|
||||
can_update=False,
|
||||
can_install=True,
|
||||
)
|
||||
return True
|
||||
|
||||
def _is_install_applied(self, item: dict[str, Any], target: SystemUpdateType) -> bool:
|
||||
"""判断启动器应用后的当前版本是否已经达到安装目标。"""
|
||||
if target == _APPLICATION:
|
||||
@@ -397,7 +430,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
for update_target in targets:
|
||||
current = self.get_status()
|
||||
item = self._get_item(current.model_dump(), update_target)
|
||||
if item["state"] in {"downloading", "ready", "installing"}:
|
||||
if item["state"] in _PENDING_STATES:
|
||||
continue
|
||||
try:
|
||||
if update_target == _APPLICATION:
|
||||
@@ -406,7 +439,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
self._check_resources()
|
||||
except Exception as error: # noqa: BLE001 定时检查不得打扰当前任务
|
||||
logger.warning(f"检查 {update_target} 更新失败:{error}")
|
||||
self._write_item(
|
||||
self._write_check_result(
|
||||
update_target,
|
||||
state="idle",
|
||||
checked_at=self._now(),
|
||||
@@ -438,7 +471,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
version = str(release["tag_name"])
|
||||
current_version = get_app_version()
|
||||
has_update = compare_version(version, "gt", current_version) is True
|
||||
self._write_item(
|
||||
self._write_check_result(
|
||||
_APPLICATION,
|
||||
state="available" if has_update else "idle",
|
||||
version=version if has_update else None,
|
||||
@@ -466,7 +499,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
indexer_version=current_indexer,
|
||||
)
|
||||
if info is None:
|
||||
self._write_item(
|
||||
self._write_check_result(
|
||||
_RESOURCES,
|
||||
state="idle",
|
||||
version=None,
|
||||
@@ -482,7 +515,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
can_install=False,
|
||||
)
|
||||
return
|
||||
self._write_item(
|
||||
self._write_check_result(
|
||||
_RESOURCES,
|
||||
state="available",
|
||||
version=info.get("package_version") or None,
|
||||
|
||||
@@ -426,6 +426,8 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
|
||||
| POST | `/api/v1/system/update/install` | 请求体可传 `{"target":"application"}` 或 `{"target":"resources"}`;再次校验对应制品,写入安装意图并重启 |
|
||||
| POST | `/api/v1/system/upgrade` | 保留 Dev 更新并重启,请求体只能为 `"dev"` |
|
||||
|
||||
定时与手动检查均保留正在下载、已下载待安装及安装中的状态;检查请求返回时会再次核对,避免覆盖请求期间发生的状态转换。若旧检查已把资源包状态覆盖为 `available`,状态查询会校验同一目标包版本的完整暂存文件与摘要,恢复 `ready`、完整进度及安装入口,无需重复下载。下载完成后仍需确认「重启应用完成更新」。
|
||||
|
||||
#### 媒体识别 / 整理
|
||||
|
||||
媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。内置来源通过 `MediaSource` 提供 `themoviedb`、`douban`、`bangumi`、`anilist`、`imdb`、`tvdb`、`musicbrainz`、`theaudiodb`、`doubanmusic` 九个具有宿主模块实现的常量;该列表不是插件来源白名单,插件可以注册符合 OpenAPI 格式约束的稳定扩展标识。`media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-update
|
||||
version: 5
|
||||
version: 6
|
||||
description: Use this skill to check MoviePilot versions, inspect Release update state, download a Release update in the background, confirm installation, restart MoviePilot, or retain the existing Dev branch update flow. Prefer the built-in system APIs instead of container commands or manual file replacement.
|
||||
allowed-tools: moviepilot_api
|
||||
allowed-api-operations: >-
|
||||
@@ -67,4 +67,5 @@ The body must be the exact JSON string `"dev"`. Stable Release updates must use
|
||||
|
||||
- All operations require a MoviePilot administrator or a verified notification-channel administrator. The host performs authorization; the model must never invent an administrator flag.
|
||||
- Only restart, Release installation, and Dev upgrade interrupt the current agent session. Checking and downloading remain online.
|
||||
- Checks preserve downloads and pending installations. A previously overwritten resource state can recover to `ready` after validating the matching staged package; use `updates` to inspect each target and request installation confirmation instead of downloading it again.
|
||||
- Prefer the API flow above. Only fall back to manual container commands when the API is unavailable.
|
||||
|
||||
@@ -118,7 +118,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"database-operation": "10",
|
||||
"feedback-issue": "11",
|
||||
"moviepilot-api": "53",
|
||||
"moviepilot-update": "5",
|
||||
"moviepilot-update": "6",
|
||||
"organize-files": "5",
|
||||
"transfer-failed-retry": "5",
|
||||
"generate-identifiers": "4",
|
||||
|
||||
145
tests/test_system_update_preservation.py
Normal file
145
tests/test_system_update_preservation.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""检查结果与下载状态交错、旧资源暂存包恢复的回归测试。"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.system import update as update_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(monkeypatch, tmp_path):
|
||||
"""隔离更新目录、运行版本和配置,避免访问真实资源与运行状态。"""
|
||||
monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.1.4")
|
||||
monkeypatch.setattr(update_module, "get_resource_versions", lambda: ("3.0.5", "3.0.19"))
|
||||
monkeypatch.setattr(
|
||||
update_module, "get_runtime_setting",
|
||||
lambda key: tmp_path if key == "TEMP_PATH" else True,
|
||||
)
|
||||
instance = object.__new__(update_module.SystemUpdateManager)
|
||||
instance._lock = threading.RLock()
|
||||
instance._download_active = False
|
||||
instance._active_target = None
|
||||
return instance
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["resources", "application"])
|
||||
@pytest.mark.parametrize("state", ["downloading", "ready", "installing"])
|
||||
@pytest.mark.parametrize("result", ["update", "current", "error"])
|
||||
def test_check_cannot_overwrite_concurrent_download_or_install(
|
||||
monkeypatch, manager, target, state, result
|
||||
):
|
||||
"""检查请求阻塞期间发生的状态转换,在成功、无更新及失败响应后均须保留。"""
|
||||
def response(*_args, **_kwargs):
|
||||
"""在请求返回前模拟另一个执行者推进下载或安装。"""
|
||||
manager._download_active = state == "downloading"
|
||||
manager._write_item(
|
||||
target, state=state, version="v3.1.5" if target == "application" else "20",
|
||||
indexer_version="3.0.20" if target == "resources" else None,
|
||||
downloaded_bytes=64, total_bytes=64,
|
||||
can_update=False, can_install=state == "ready",
|
||||
)
|
||||
if result == "error":
|
||||
raise RuntimeError("检查请求失败")
|
||||
if target == "resources":
|
||||
return {"package_version": "21", "indexer_version": "3.0.21"} if result == "update" else None
|
||||
return SimpleNamespace(
|
||||
status_code=200,
|
||||
json=lambda: [{"tag_name": "v3.1.6" if result == "update" else "v3.1.4"}],
|
||||
)
|
||||
|
||||
if target == "resources":
|
||||
monkeypatch.setattr(update_module.ResourceHelper, "get_update_info", response)
|
||||
else:
|
||||
monkeypatch.setattr(manager, "_request", lambda: SimpleNamespace(get_res=response))
|
||||
|
||||
status = manager.check(target)
|
||||
item = next(item for item in status.updates if item.type == target)
|
||||
assert item.state == state
|
||||
assert item.version == ("v3.1.5" if target == "application" else "20")
|
||||
assert item.downloaded_bytes == item.total_bytes == 64
|
||||
assert item.can_install is (state == "ready")
|
||||
assert item.error is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prepared_resources(monkeypatch, manager):
|
||||
"""创建当前平台完整资源包及可信摘要,复现旧检查留下的 available 状态。"""
|
||||
names = ["user.sites.v3.bin", "sites.cpython-test.so"]
|
||||
monkeypatch.setattr(
|
||||
update_module.ResourceHelper, "_get_needed_files", classmethod(lambda cls: names),
|
||||
)
|
||||
manager._resource_dir.mkdir(parents=True)
|
||||
files = []
|
||||
for name in names:
|
||||
path = manager._resource_dir / name
|
||||
path.write_bytes(b"resource")
|
||||
files.append({"name": name, "path": str(path), "sha256": manager._sha256(path)})
|
||||
manager._merge_prepared_manifest(
|
||||
{"resource_package_version": "20", "resource_files": files, "version": "v3.1.5"},
|
||||
)
|
||||
manager._write_item(
|
||||
"resources", state="available", version="20", indexer_version="3.0.20",
|
||||
can_update=True, can_install=False,
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def test_recover_prepared_resources_and_install_without_downloading(
|
||||
monkeypatch, manager, prepared_resources
|
||||
):
|
||||
"""跨进程恢复后,定时及手动检查、重复下载按钮均保留制品,可继续确认安装。"""
|
||||
request = Mock(side_effect=AssertionError("已下载资源不应重新检查或下载"))
|
||||
monkeypatch.setattr(update_module.ResourceHelper, "get_update_info", request)
|
||||
monkeypatch.setattr(manager, "_check_application", lambda: None)
|
||||
manifest = (manager._root / "prepared.json").read_bytes()
|
||||
for status in (manager.get_status(), manager.check("resources"), manager.check_scheduled(), manager.start_download("resources")):
|
||||
item = next(item for item in status.updates if item.type == "resources")
|
||||
assert item.state == "ready"
|
||||
assert item.can_install is True
|
||||
assert item.can_update is False
|
||||
assert item.downloaded_bytes == item.total_bytes == 16
|
||||
assert item.progress == 100
|
||||
assert (manager._root / "prepared.json").read_bytes() == manifest
|
||||
request.assert_not_called()
|
||||
assert len(prepared_resources) == 2
|
||||
success, _message = manager.request_install("resources")
|
||||
assert success is True
|
||||
assert json.loads(manager._install_file.read_text())["targets"] == ["resources"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["missing", "modified", "incomplete", "version", "path"])
|
||||
def test_unusable_prepared_resources_are_not_restored(manager, prepared_resources, invalid):
|
||||
"""残缺、被修改、越界或版本不匹配的资源包不能恢复成可安装状态。"""
|
||||
path = manager._resource_dir / prepared_resources[0]["name"]
|
||||
if invalid == "missing":
|
||||
path.unlink()
|
||||
elif invalid == "modified":
|
||||
path.write_bytes(b"changed")
|
||||
elif invalid == "incomplete":
|
||||
manager._merge_prepared_manifest({"resource_files": prepared_resources[:1]})
|
||||
elif invalid == "version":
|
||||
manager._merge_prepared_manifest({"resource_package_version": "19"})
|
||||
else:
|
||||
prepared_resources[0]["path"] = str(manager._root.parent / "outside.bin")
|
||||
manager._merge_prepared_manifest({"resource_files": prepared_resources})
|
||||
item = next(item for item in manager.get_status().updates if item.type == "resources")
|
||||
assert item.state == "available"
|
||||
assert item.can_install is False
|
||||
|
||||
|
||||
def test_applied_resources_clear_recoverable_package(monkeypatch, manager, prepared_resources):
|
||||
"""外部安装已达到目标时,应清理旧资源清单并保留主程序制品。"""
|
||||
monkeypatch.setattr(update_module, "get_resource_versions", lambda: ("3.0.5", "3.0.20"))
|
||||
item = next(item for item in manager.get_status().updates if item.type == "resources")
|
||||
assert item.state == "idle"
|
||||
assert item.can_install is False
|
||||
# 再次读取不得把已安装的旧清单恢复成待安装。
|
||||
assert next(item for item in manager.get_status().updates if item.type == "resources").state == "idle"
|
||||
prepared = manager._read_prepared_manifest()
|
||||
assert "resource_files" not in prepared
|
||||
assert prepared["version"] == "v3.1.5"
|
||||
assert len(prepared_resources) == 2
|
||||
Reference in New Issue
Block a user