diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 248af6eab..174e71d4f 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -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 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 24a088606..43e0839f5 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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` 是唯一的自主任务工具,要求管理员权限: diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index 8b4b62460..61dda207f 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -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]] = { diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 8fc0f5a78..4ab8c1a68 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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. diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index fd349c84b..ede589e08 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -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", diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index c1680721c..4c3792a7b 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -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():