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:
jxxghp
2026-10-13 09:19:37 +08:00
parent d315377cfb
commit 40711d2e32
5 changed files with 190 additions and 9 deletions

View File

@@ -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,

View File

@@ -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:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。

View File

@@ -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.

View File

@@ -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",

View 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