mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
fix(plugin): restrict update badges to bound repositories
This commit is contained in:
@@ -427,6 +427,7 @@ class PluginCatalogService:
|
||||
)
|
||||
|
||||
def repo_order(plugin: Any) -> int:
|
||||
"""保留配置的仓库顺序,未配置来源排在末尾。"""
|
||||
if plugin.repo_url in markets:
|
||||
return markets.index(plugin.repo_url)
|
||||
return len(markets)
|
||||
@@ -543,7 +544,7 @@ class PluginCatalogQuery:
|
||||
plugins: list[Plugin],
|
||||
installed_ids: list[str],
|
||||
) -> list[Plugin]:
|
||||
"""优先选择绑定仓库更新并投影候选来源信息。"""
|
||||
"""已绑定插件仅提示绑定仓库更新,并投影候选来源信息。"""
|
||||
identities = await self._identities(installed_ids)
|
||||
identity_by_id = {item.normalized_plugin_id: item for item in identities}
|
||||
installed_keys = {plugin_id.lower() for plugin_id in installed_ids}
|
||||
@@ -580,7 +581,7 @@ class PluginCatalogQuery:
|
||||
online_plugins: list[Plugin],
|
||||
identity: PluginIdentity | None,
|
||||
) -> Plugin:
|
||||
"""绑定仓库存在更新时优先返回其候选。"""
|
||||
"""按绑定来源收敛更新标记,不修改共享的市场候选快照。"""
|
||||
if identity is None or not identity.trusted_source_key:
|
||||
return plugin
|
||||
bound_updates = [
|
||||
@@ -589,7 +590,9 @@ class PluginCatalogQuery:
|
||||
if _matches_trusted_source(candidate, plugin.id, identity.trusted_source_key)
|
||||
]
|
||||
preferred = self._process_plugins(bound_updates, []) if bound_updates else []
|
||||
return preferred[0] if preferred else plugin
|
||||
return preferred[0] if preferred else cast(
|
||||
Plugin, plugin.model_copy(update={"has_update": False})
|
||||
)
|
||||
|
||||
|
||||
_catalog_query: PluginCatalogQuery | None = None
|
||||
|
||||
@@ -1114,6 +1114,12 @@ Web Agent 直接调用 `moviepilot_api` 时,宿主会自动加载 `moviepilot-
|
||||
`library.latest` 已从 Agent operation 目录删除,避免与 provider Skill 重复。供前端和
|
||||
其它宿主使用的普通 REST 端点仍然保留。
|
||||
|
||||
`plugin.market` 对已安装且已绑定来源的插件,只把绑定仓库的更高版本列为更新。
|
||||
绑定仓库版本持平、较低或未返回候选时,其他仓库的高版本不会触发 `has_update`,
|
||||
也不会作为更新条目出现在 `state=market` 或 `state=all` 的市场部分。
|
||||
查看其他可选来源使用 `plugin.source.options`;切换来源仍须显式执行来源变更。
|
||||
尚未绑定来源的已安装插件保留市场候选,供用户选择绑定来源。
|
||||
|
||||
#### Agent 自主定时任务与人格
|
||||
|
||||
`agent_task` 是唯一的自主任务工具,要求管理员权限:
|
||||
|
||||
@@ -14,7 +14,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
MOVIEPILOT_API_SKILL_VERSION = "55"
|
||||
MOVIEPILOT_API_SKILL_VERSION = "56"
|
||||
|
||||
|
||||
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 55
|
||||
version: 56
|
||||
description: >-
|
||||
Use this skill for MoviePilot product operations such as media search, torrent
|
||||
search, downloads, subscriptions, library checks, sites, storage, workflows,
|
||||
@@ -209,6 +209,12 @@ frontmatter allowlist, and the matching gateway contract together.
|
||||
|
||||
## API Surface Scope
|
||||
|
||||
For installed plugins with a bound source, `plugin.market` only lists an update
|
||||
when that repository has a newer version. A higher version in another repository
|
||||
does not trigger `has_update`. Use `plugin.source.options` to inspect alternative
|
||||
sources; switching sources still requires an explicit source change. Installed
|
||||
plugins without a bound source retain market candidates for source selection.
|
||||
|
||||
Operations declare an identity scope: `public` reads external public metadata without requiring a channel account binding; `bound_user` uses the active MoviePilot user bound to the stable channel sender ID (Web uses its authenticated user ID); `admin` requires administrator authority. Read-only does not mean public. Channel nicknames never establish identity. Personal records and subscription writes retain their existing ownership and permission checks.
|
||||
|
||||
When identity resolution fails, relay the gateway's actual remediation message and sender ID. Channel users bind their own MoviePilot account via avatar > Personal Information > Account Binding, fill the named channel field, and save. Do not invent a Settings > Users binding page, substitute nicknames/group/bot IDs, diagnose an ambiguous binding failure as a network issue, or ask Web users to bind a notification channel. Public operations do not require binding.
|
||||
|
||||
@@ -117,7 +117,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"command-dispatch": "2",
|
||||
"database-operation": "11",
|
||||
"feedback-issue": "11",
|
||||
"moviepilot-api": "55",
|
||||
"moviepilot-api": "56",
|
||||
"moviepilot-update": "7",
|
||||
"organize-files": "5",
|
||||
"transfer-failed-retry": "5",
|
||||
|
||||
@@ -160,17 +160,21 @@ def test_update_candidates_report_source_and_binding_relationship():
|
||||
assert result[0].update_candidate is not None
|
||||
assert result[0].update_candidate.source_type == "official"
|
||||
assert result[0].update_candidate.is_bound is True
|
||||
assert result[0].has_update is True
|
||||
assert result[1].update_candidate is not None
|
||||
assert result[1].update_candidate.source_type == "third_party"
|
||||
assert result[1].update_candidate.is_bound is True
|
||||
assert result[1].has_update is True
|
||||
assert result[2].update_candidate is not None
|
||||
assert result[2].update_candidate.source_type == "official"
|
||||
assert result[2].update_candidate.is_bound is False
|
||||
assert result[2].has_update is False
|
||||
assert plugins[2].has_update is True
|
||||
assert result[3].update_candidate is None
|
||||
|
||||
|
||||
def test_bound_repository_update_precedes_a_higher_alternative():
|
||||
"""绑定仓库仍有更新时先完成可信更新,下一轮再提示其他仓库版本。"""
|
||||
"""绑定仓库存在更新时只提示可信更新,不受其他仓库更高版本影响。"""
|
||||
bound_update = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_version="2.0.0",
|
||||
@@ -200,6 +204,85 @@ def test_bound_repository_update_precedes_a_higher_alternative():
|
||||
assert result[0].update_candidate is not None
|
||||
assert result[0].update_candidate.version == "2.0.0"
|
||||
assert result[0].update_candidate.is_bound is True
|
||||
assert result[0].has_update is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["market", "all"])
|
||||
@pytest.mark.parametrize("bound_version", [None, "1.0.0", "0.9.0"])
|
||||
def test_catalog_excludes_alternative_update_when_bound_source_has_no_update(
|
||||
state, bound_version,
|
||||
):
|
||||
"""绑定源版本持平、较低或缺失时,其他仓库不能产生市场更新条目。"""
|
||||
installed = schemas.Plugin(
|
||||
id="DemoPlugin", plugin_version="1.0.0", installed=True,
|
||||
)
|
||||
alternative = schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_version="3.0.0",
|
||||
repo_url="https://github.com/alternative/plugins",
|
||||
has_update=True,
|
||||
)
|
||||
online = [alternative]
|
||||
if bound_version is not None:
|
||||
online.append(schemas.Plugin(
|
||||
id="DemoPlugin",
|
||||
plugin_version=bound_version,
|
||||
repo_url=SOURCE_URL,
|
||||
has_update=False,
|
||||
))
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_installed_plugins.return_value = [installed]
|
||||
plugin_manager.get_local_plugins.return_value = [installed]
|
||||
plugin_manager.get_local_repo_plugins.return_value = []
|
||||
plugin_manager.async_get_online_plugin_candidates = AsyncMock(return_value=online)
|
||||
plugin_manager.process_plugins_list.side_effect = lambda higher, base: [
|
||||
max(higher + base, key=lambda plugin: plugin.plugin_version)
|
||||
]
|
||||
persistence = MagicMock()
|
||||
persistence.list_identities = AsyncMock(return_value=[_plugin_identity()])
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.plugin.get_plugin_catalog_query",
|
||||
return_value=_catalog_query(plugin_manager, persistence),
|
||||
):
|
||||
response = Response()
|
||||
result = asyncio.run(plugin_endpoint.all_plugins(
|
||||
None, state, False, response=response,
|
||||
))
|
||||
|
||||
assert [plugin.id for plugin in result] == (["DemoPlugin"] if state == "all" else [])
|
||||
assert response.headers["X-Total-Count"] == str(len(result))
|
||||
assert all(not plugin.has_update for plugin in result)
|
||||
assert all(plugin.plugin_version == "1.0.0" for plugin in result)
|
||||
assert alternative.has_update is True
|
||||
assert alternative.update_candidate is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_identity", [False, True])
|
||||
def test_unbound_plugin_keeps_market_candidate_for_source_selection(has_identity):
|
||||
"""尚未绑定来源的插件仍能发现市场候选,供用户显式选择来源。"""
|
||||
candidate = schemas.Plugin(
|
||||
id="DemoPlugin", plugin_version="2.0.0", repo_url=SOURCE_URL, has_update=True,
|
||||
)
|
||||
identity = replace(
|
||||
_plugin_identity(),
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
|
||||
bound_at=None,
|
||||
)
|
||||
persistence = MagicMock()
|
||||
persistence.list_identities = AsyncMock(return_value=[identity] if has_identity else [])
|
||||
|
||||
result = asyncio.run(
|
||||
_catalog_query(MagicMock(), persistence).project_update_candidates(
|
||||
[candidate], [candidate], ["DemoPlugin"],
|
||||
)
|
||||
)
|
||||
|
||||
assert result[0].has_update is True
|
||||
assert result[0].update_candidate is not None
|
||||
assert result[0].update_candidate.is_bound is False
|
||||
|
||||
|
||||
def test_market_endpoint_reads_source_preserving_candidates_for_bound_update():
|
||||
|
||||
Reference in New Issue
Block a user