From 40711d2e3205b60110f694ec31c26fab7d4be788 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 13 Oct 2026 09:19:37 +0800 Subject: [PATCH] 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. --- app/adapters/system/update.py | 47 ++++++-- docs/mcp-api.md | 2 + skills/moviepilot-update/SKILL.md | 3 +- tests/test_builtin_skill_boundaries.py | 2 +- tests/test_system_update_preservation.py | 145 +++++++++++++++++++++++ 5 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 tests/test_system_update_preservation.py diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 7b09bdad0..b94438ef8 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -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, diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 6ddd13b72..1455f8181 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。 diff --git a/skills/moviepilot-update/SKILL.md b/skills/moviepilot-update/SKILL.md index 60c6cb218..0e779ab93 100644 --- a/skills/moviepilot-update/SKILL.md +++ b/skills/moviepilot-update/SKILL.md @@ -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. diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 9e02559e8..a377a7d17 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -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", diff --git a/tests/test_system_update_preservation.py b/tests/test_system_update_preservation.py new file mode 100644 index 000000000..d5dc80790 --- /dev/null +++ b/tests/test_system_update_preservation.py @@ -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