mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
修复插件来源仲裁与数据库备份 review 问题
合并 PR #6658。业务相关检查已通过;剩余失败为 PR-Agent 外部 API 日限额与上游既有复杂度 ratchet,不属于本次改动。
This commit is contained in:
@@ -11,8 +11,8 @@ from pathlib import Path
|
||||
from app.runtime.version import get_app_version
|
||||
|
||||
_BACKUP_NAME = re.compile(
|
||||
r"^(?:(?P<target>[0-9A-Za-z][0-9A-Za-z_-]*)_"
|
||||
r"(?P<version>v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)_)?"
|
||||
r"^(?:(?P<target>[0-9A-Za-z][0-9A-Za-z_-]*)"
|
||||
r"(?:_(?P<version>v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?))?_)?"
|
||||
r"(?P<db_type>sqlite|postgresql)_"
|
||||
r"(?P<timestamp>\d{8}_\d{6})"
|
||||
r"(?:_(?P<sequence>\d+))?"
|
||||
@@ -85,16 +85,21 @@ class BackupFiles:
|
||||
target: str = "moviepilot",
|
||||
version: str | None = None,
|
||||
) -> str:
|
||||
"""生成包含目标、版本、数据库类型和时间的可读文件名。"""
|
||||
"""生成包含目标、可选版本、数据库类型和时间的可读文件名。"""
|
||||
timestamp = created_at.strftime("%Y%m%d_%H%M%S")
|
||||
version = (version or get_app_version()).strip()
|
||||
if not version.startswith("v"):
|
||||
version = f"v{version}"
|
||||
if _RELEASE_VERSION.fullmatch(version) is None or not re.fullmatch(
|
||||
r"[0-9A-Za-z][0-9A-Za-z_-]*", target
|
||||
):
|
||||
if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z_-]*", target):
|
||||
raise ValueError("数据库备份目标或版本号无法用于文件命名")
|
||||
base = f"{target}_{version}_{db_type}_{timestamp}"
|
||||
normalized_version = version.strip() if version else None
|
||||
if normalized_version is None and target == "moviepilot":
|
||||
normalized_version = get_app_version().strip()
|
||||
if normalized_version:
|
||||
if not normalized_version.startswith("v"):
|
||||
normalized_version = f"v{normalized_version}"
|
||||
if _RELEASE_VERSION.fullmatch(normalized_version) is None:
|
||||
raise ValueError("数据库备份目标或版本号无法用于文件命名")
|
||||
base = f"{target}_{normalized_version}_{db_type}_{timestamp}"
|
||||
else:
|
||||
base = f"{target}_{db_type}_{timestamp}"
|
||||
candidate = f"{base}{suffix}"
|
||||
sequence = 1
|
||||
while (self.root / candidate).exists():
|
||||
|
||||
@@ -20,7 +20,10 @@ from app.application.plugin.source import (
|
||||
PluginSelectionStatus,
|
||||
select_plugin_candidate,
|
||||
)
|
||||
from app.domain.plugin import parse_local_plugin_reference
|
||||
from app.domain.plugin import (
|
||||
parse_local_plugin_generation,
|
||||
parse_local_plugin_reference,
|
||||
)
|
||||
|
||||
|
||||
class PluginSourceAdmissionError(RuntimeError):
|
||||
@@ -147,10 +150,14 @@ def admit_plugin_install(
|
||||
available_local_candidates = inventory.local_candidates_for(
|
||||
request.plugin_id
|
||||
)
|
||||
requested_generation = parse_local_plugin_generation(
|
||||
request.requested_repo_url
|
||||
)
|
||||
exact_candidates = tuple(
|
||||
candidate
|
||||
for candidate in available_local_candidates
|
||||
if candidate.repo_url == request.requested_repo_url
|
||||
if requested_generation is None
|
||||
or candidate.package_generation == requested_generation
|
||||
)
|
||||
local_candidates = exact_candidates or available_local_candidates
|
||||
if not local_candidates:
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any, TypeAlias
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginIdentity,
|
||||
@@ -20,6 +21,15 @@ from app.application.plugin.identity import (
|
||||
from app.foundation.version import compare_version
|
||||
|
||||
PLUGIN_GENERATIONS = ("v1", "v2", "v3")
|
||||
LOCAL_PLUGIN_SOURCE_PREFIX = "local://"
|
||||
|
||||
|
||||
def _build_public_local_source(plugin_id: str, package_generation: str) -> str:
|
||||
"""生成不携带宿主路径的本地候选标识。"""
|
||||
source = f"{LOCAL_PLUGIN_SOURCE_PREFIX}{quote(plugin_id, safe='')}"
|
||||
if package_generation != "v1":
|
||||
source += f"?version={quote(package_generation, safe='')}"
|
||||
return source
|
||||
|
||||
|
||||
class MarketReadStatus(StrEnum):
|
||||
@@ -126,11 +136,15 @@ class PluginLocalCandidate:
|
||||
return None
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成本地候选的公共投影,保留管理员配置的仓库路径。"""
|
||||
"""生成不包含本地仓库路径的公共候选投影。"""
|
||||
public_repo_url = _build_public_local_source(
|
||||
self.plugin_id,
|
||||
self.package_generation,
|
||||
)
|
||||
return {
|
||||
"plugin_id": self.plugin_id,
|
||||
"source_type": PluginPayloadSourceType.LOCAL.value,
|
||||
"repo_url": self.repo_url,
|
||||
"repo_url": public_repo_url,
|
||||
"package_generation": self.package_generation,
|
||||
"plugin_version": self.plugin_version,
|
||||
}
|
||||
|
||||
@@ -82,11 +82,12 @@ class PluginSyncService:
|
||||
if restore_plugin_ids or (missing_ids - local_plugin_ids)
|
||||
else []
|
||||
)
|
||||
# 启动同步只负责恢复缺失载荷;候选来源按本地优先建立索引,避免
|
||||
# 市场目录合并结果把本地候选替换成在线候选。
|
||||
# 先沿用目录服务对在线候选的版本仲裁,再以本地候选覆盖同 ID 的在线候选。
|
||||
# 这样既保持本地载荷优先,也不会因字典遍历顺序把高版本在线候选覆盖掉。
|
||||
merged_online = self._merge_plugins(online, [], []) if online else []
|
||||
candidates_by_id: dict[str, Any] = {
|
||||
plugin.id.lower(): plugin
|
||||
for plugin in online
|
||||
for plugin in merged_online
|
||||
if getattr(plugin, "id", None)
|
||||
}
|
||||
candidates_by_id.update(
|
||||
|
||||
@@ -215,10 +215,9 @@ def build_database_governance() -> DatabaseGovernance:
|
||||
version = manager.get_local_plugin_version(handle.plugin_id)
|
||||
if not version:
|
||||
logger.warning(
|
||||
"跳过插件 %s 的数据库备份:未找到插件版本",
|
||||
"插件 %s 未找到版本,将使用无版本文件名继续备份数据库",
|
||||
handle.plugin_id,
|
||||
)
|
||||
continue
|
||||
plugin_service = DatabaseBackupService(
|
||||
backend=SQLiteBackupBackend(handle.engine),
|
||||
artifact_store_factory=BackupFiles,
|
||||
|
||||
@@ -171,6 +171,26 @@ def test_backup_name_uses_plugin_target_and_version(tmp_path: Path) -> None:
|
||||
assert artifact.target == "DemoPlugin"
|
||||
|
||||
|
||||
def test_plugin_backup_without_version_uses_target_without_version_segment(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""插件版本元数据缺失时仍应备份,并使用可解析的无版本文件名。"""
|
||||
service = DatabaseBackupService(
|
||||
backend=_Backend(),
|
||||
artifact_store_factory=BackupFiles,
|
||||
policy_reader=lambda: BackupPolicy(tmp_path),
|
||||
clock=lambda: datetime(2026, 8, 19, 13, 45, 26),
|
||||
target="DemoPlugin",
|
||||
version=None,
|
||||
)
|
||||
|
||||
artifact = service.create()
|
||||
|
||||
assert artifact.name == "DemoPlugin_sqlite_20260819_134526.db"
|
||||
assert artifact.target == "DemoPlugin"
|
||||
assert BackupFiles.target(artifact.name) == "DemoPlugin"
|
||||
|
||||
|
||||
def test_retention_applies_after_new_artifact_is_available(tmp_path: Path) -> None:
|
||||
old = _service(tmp_path, now=datetime(2026, 8, 1, 3, 0, 0)).create()
|
||||
current = _service(
|
||||
|
||||
@@ -222,15 +222,15 @@ def test_local_scan_preserves_absent_present_and_failed_states() -> None:
|
||||
assert failed.local_read.error == "local repository unavailable"
|
||||
|
||||
|
||||
def test_local_candidates_keep_source_marker_in_inventory_projection() -> None:
|
||||
"""本地候选可参与库存,并保留可供来源选择使用的来源标识。"""
|
||||
def test_local_candidates_hide_path_in_inventory_projection() -> None:
|
||||
"""本地候选可参与库存,但库存公共投影不包含仓库路径。"""
|
||||
reader = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {},
|
||||
local_candidate_loader=lambda: {
|
||||
"LocalPlugin": {
|
||||
"version": "3.0.0",
|
||||
"package_version": "v3",
|
||||
"repo_url": "local://LocalPlugin?path=/private/local&version=v3",
|
||||
"repo_url": "local://LocalPlugin?version=v3",
|
||||
"path": "/private/local/plugins/LocalPlugin",
|
||||
"repo_path": "/private/local",
|
||||
},
|
||||
@@ -244,10 +244,11 @@ def test_local_candidates_keep_source_marker_in_inventory_projection() -> None:
|
||||
assert public["local_candidates"] == [{
|
||||
"plugin_id": "LocalPlugin",
|
||||
"source_type": "local",
|
||||
"repo_url": "local://LocalPlugin?path=/private/local&version=v3",
|
||||
"repo_url": "local://LocalPlugin?version=v3",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "3.0.0",
|
||||
}]
|
||||
assert "/private/local" not in str(public)
|
||||
|
||||
|
||||
def test_invalid_local_candidate_does_not_abort_online_inventory() -> None:
|
||||
|
||||
@@ -322,8 +322,8 @@ def test_first_local_payload_creates_local_only_identity() -> None:
|
||||
assert target.trusted_source_type is TrustedPluginSourceType.UNKNOWN
|
||||
|
||||
|
||||
def test_local_reference_selects_configured_candidate_with_source_marker() -> None:
|
||||
"""本地来源标识仍能选择配置内候选,并保留可回传的来源标识。"""
|
||||
def test_local_reference_selects_configured_candidate_without_path() -> None:
|
||||
"""无路径本地来源标识仍能选择配置内候选。"""
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=(
|
||||
@@ -350,7 +350,7 @@ def test_local_reference_selects_configured_candidate_with_source_marker() -> No
|
||||
assert public == {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"source_type": "local",
|
||||
"repo_url": "local://DemoPlugin?path=/private/secret/plugins&version=v3",
|
||||
"repo_url": "local://DemoPlugin?version=v3",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "2.0.0-dev",
|
||||
}
|
||||
|
||||
@@ -388,8 +388,8 @@ async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_source_inspection_preserves_sources_and_local_repo_url() -> None:
|
||||
"""来源查询按在线仓归并版本,并保留本地候选仓库标识。"""
|
||||
async def test_gateway_source_inspection_preserves_sources_without_local_path() -> None:
|
||||
"""来源查询按在线仓归并版本,但不返回本地仓库路径。"""
|
||||
official_v3 = _inventory().online_candidates[0]
|
||||
official_v2 = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
@@ -445,7 +445,9 @@ async def test_gateway_source_inspection_preserves_sources_and_local_repo_url()
|
||||
]
|
||||
assert inspection.online_candidates[0].package_generation == "v3"
|
||||
assert inspection.local_candidate is local
|
||||
assert inspection.local_candidate.public_dict()["repo_url"] == local.repo_url
|
||||
public_repo_url = inspection.local_candidate.public_dict()["repo_url"]
|
||||
assert public_repo_url == "local://DemoPlugin?version=v3"
|
||||
assert "/private/plugins" not in public_repo_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -480,8 +480,8 @@ def test_uninstalled_unique_and_multiple_sources_are_distinct() -> None:
|
||||
assert set(conflict.conflict_source_keys) == {THIRD_PARTY_SOURCE, OTHER_SOURCE}
|
||||
|
||||
|
||||
def test_official_candidate_is_selectable_and_local_projection_keeps_repo_url() -> None:
|
||||
"""官方来源可正常选择,本地公共投影保留可回传的仓库标识。"""
|
||||
def test_official_candidate_is_selectable_and_local_projection_hides_path() -> None:
|
||||
"""官方来源可正常选择,本地公共投影不包含仓库路径。"""
|
||||
official = select_plugin_candidate(
|
||||
_inventory(
|
||||
MarketRead.present(
|
||||
@@ -518,4 +518,5 @@ def test_official_candidate_is_selectable_and_local_projection_keeps_repo_url()
|
||||
assert local.source_key is None
|
||||
assert local_result.candidate is local
|
||||
public = local_result.public_dict()
|
||||
assert public["candidate"]["repo_url"] == local.repo_url
|
||||
assert public["candidate"]["repo_url"] == "local://DemoPlugin?version=v3"
|
||||
assert "/private/secret/plugins" not in str(public)
|
||||
|
||||
@@ -76,6 +76,42 @@ def test_market_sync_keeps_install_rollback_enabled() -> None:
|
||||
install.assert_called_once_with(plugin.id, None, False, None)
|
||||
|
||||
|
||||
def test_market_sync_arbitrates_online_versions_before_local_overlay() -> None:
|
||||
"""在线候选先按版本仲裁,不能因输入顺序选择旧版本。"""
|
||||
old = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url="https://example.com/old",
|
||||
plugin_name="Demo old",
|
||||
plugin_version="1.0.0",
|
||||
system_version_compatible=True,
|
||||
)
|
||||
new = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url="https://example.com/new",
|
||||
plugin_name="Demo new",
|
||||
plugin_version="2.0.0",
|
||||
system_version_compatible=True,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
|
||||
def merge_online(items, *_args):
|
||||
return [max(items, key=lambda item: Version(item.plugin_version))]
|
||||
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
installed_plugins=lambda: [old.id],
|
||||
online_plugins=lambda: [new, old],
|
||||
local_plugins=lambda: [],
|
||||
merge_plugins=merge_online,
|
||||
plugin_exists=lambda *_args: False,
|
||||
install=install,
|
||||
log=Mock(),
|
||||
)
|
||||
|
||||
assert service.sync() == [new.id]
|
||||
install.assert_called_once_with(new.id, None, False, None)
|
||||
|
||||
|
||||
def test_market_sync_restores_trusted_online_payload_after_local_source_removed() -> None:
|
||||
"""本地高版本来源消失后,启动同步仍恢复已绑定的在线载荷。"""
|
||||
plugin = SimpleNamespace(
|
||||
|
||||
Reference in New Issue
Block a user