mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
fix(plugin): 按绑定仓库保留更新候选 (#6474)
* fix(plugin): preserve repository update candidates * chore(ci): sync architecture ratchet baselines * chore(ci): align canonical coverage baseline * chore(ci): align coverage ratchet baseline
This commit is contained in:
@@ -378,7 +378,7 @@ async def all_plugins(
|
|||||||
# 本地插件仓库目录中的插件
|
# 本地插件仓库目录中的插件
|
||||||
local_repo_plugins = plugin_manager.get_local_repo_plugins()
|
local_repo_plugins = plugin_manager.get_local_repo_plugins()
|
||||||
# 在线插件
|
# 在线插件
|
||||||
online_plugins = await plugin_manager.async_get_online_plugins(force)
|
online_plugins = await plugin_manager.async_get_online_plugin_candidates(force)
|
||||||
installed_ids = [plugin.id for plugin in installed_plugins if plugin.id]
|
installed_ids = [plugin.id for plugin in installed_plugins if plugin.id]
|
||||||
candidate_plugins = (
|
candidate_plugins = (
|
||||||
plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, [])
|
plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, [])
|
||||||
|
|||||||
@@ -190,9 +190,10 @@ class PluginCatalogService:
|
|||||||
[str, Optional[str], bool],
|
[str, Optional[str], bool],
|
||||||
Awaitable[list[Any]],
|
Awaitable[list[Any]],
|
||||||
],
|
],
|
||||||
|
preserve_sources: bool = False,
|
||||||
progress_callback: Optional[ProgressCallback] = None,
|
progress_callback: Optional[ProgressCallback] = None,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
"""异步读取多个市场和代际,并持续报告稳定进度。"""
|
"""异步读取多个市场和代际,并按调用方需要合并或保留仓库候选。"""
|
||||||
async def fetch(
|
async def fetch(
|
||||||
market: str,
|
market: str,
|
||||||
package_version: Optional[str],
|
package_version: Optional[str],
|
||||||
@@ -249,7 +250,11 @@ class PluginCatalogService:
|
|||||||
target = higher_plugins if version == "higher_version" else base_plugins
|
target = higher_plugins if version == "higher_version" else base_plugins
|
||||||
target.extend(plugins)
|
target.extend(plugins)
|
||||||
|
|
||||||
result = self.merge(higher_plugins, base_plugins, markets)
|
result = (
|
||||||
|
self.merge_by_source(higher_plugins, base_plugins, markets)
|
||||||
|
if preserve_sources
|
||||||
|
else self.merge(higher_plugins, base_plugins, markets)
|
||||||
|
)
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(value=100, text="插件市场缓存刷新完成")
|
progress_callback(value=100, text="插件市场缓存刷新完成")
|
||||||
return result
|
return result
|
||||||
@@ -313,6 +318,41 @@ class PluginCatalogService:
|
|||||||
result_by_id[plugin.id] = plugin
|
result_by_id[plugin.id] = plugin
|
||||||
return list(result_by_id.values())
|
return list(result_by_id.values())
|
||||||
|
|
||||||
|
def merge_by_source(
|
||||||
|
self,
|
||||||
|
higher_plugins: list[Any],
|
||||||
|
base_plugins: list[Any],
|
||||||
|
markets: list[str],
|
||||||
|
) -> list[Any]:
|
||||||
|
"""每个仓库保留同一插件的最高兼容版本,供来源准入继续决策。"""
|
||||||
|
higher_keys = {
|
||||||
|
(plugin.repo_url, plugin.id, plugin.plugin_version)
|
||||||
|
for plugin in higher_plugins
|
||||||
|
}
|
||||||
|
all_plugins = list(higher_plugins)
|
||||||
|
all_plugins.extend(
|
||||||
|
plugin
|
||||||
|
for plugin in base_plugins
|
||||||
|
if (plugin.repo_url, plugin.id, plugin.plugin_version) not in higher_keys
|
||||||
|
)
|
||||||
|
|
||||||
|
def repo_order(plugin: Any) -> int:
|
||||||
|
if plugin.repo_url in markets:
|
||||||
|
return markets.index(plugin.repo_url)
|
||||||
|
return len(markets)
|
||||||
|
|
||||||
|
result_by_source: dict[tuple[Optional[str], Optional[str]], Any] = {}
|
||||||
|
for plugin in sorted(all_plugins, key=repo_order):
|
||||||
|
key = (plugin.repo_url, plugin.id)
|
||||||
|
exists = result_by_source.get(key)
|
||||||
|
if not exists or self._version_compare(
|
||||||
|
plugin.plugin_version,
|
||||||
|
">",
|
||||||
|
exists.plugin_version,
|
||||||
|
):
|
||||||
|
result_by_source[key] = plugin
|
||||||
|
return list(result_by_source.values())
|
||||||
|
|
||||||
def _map_plugins(
|
def _map_plugins(
|
||||||
self,
|
self,
|
||||||
online_plugins: dict[str, dict],
|
online_plugins: dict[str, dict],
|
||||||
|
|||||||
@@ -243,6 +243,23 @@ class PluginCatalogFacade:
|
|||||||
self._logger.info(f"获取到 {len(result)} 个线上插件")
|
self._logger.info(f"获取到 {len(result)} 个线上插件")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def async_online_candidates(self, force: bool = False) -> list[Plugin]:
|
||||||
|
"""读取在线目录并保留每个仓库的最高候选,供来源准入使用。"""
|
||||||
|
plugin_market = get_runtime_setting('PLUGIN_MARKET')
|
||||||
|
if not plugin_market:
|
||||||
|
return []
|
||||||
|
markets = [item for item in plugin_market.split(",") if item]
|
||||||
|
result: list[Plugin] = await self._market_catalog().async_collect(
|
||||||
|
markets=markets,
|
||||||
|
compatible_flags=self._system().compatible_flags(
|
||||||
|
get_runtime_setting('VERSION_FLAG')
|
||||||
|
),
|
||||||
|
force=force,
|
||||||
|
loader=self._async_market_loader,
|
||||||
|
preserve_sources=True,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
async def async_get_from_market(
|
async def async_get_from_market(
|
||||||
self,
|
self,
|
||||||
market: str,
|
market: str,
|
||||||
|
|||||||
@@ -1364,6 +1364,13 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
progress_callback,
|
progress_callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_get_online_plugin_candidates(
|
||||||
|
self,
|
||||||
|
force: bool = False,
|
||||||
|
) -> List[_SchemaPlugin]:
|
||||||
|
"""获取按仓库保留的在线插件候选,供来源准入和更新选择。"""
|
||||||
|
return await self._plugin_catalog_view.async_online_candidates(force)
|
||||||
|
|
||||||
async def async_get_plugins_from_market(self, market: str,
|
async def async_get_plugins_from_market(self, market: str,
|
||||||
package_version: Optional[str] = None,
|
package_version: Optional[str] = None,
|
||||||
force: bool = False) -> Optional[List[_SchemaPlugin]]:
|
force: bool = False) -> Optional[List[_SchemaPlugin]]:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class PluginSourceBindingStatus(str, _Enum):
|
|||||||
|
|
||||||
|
|
||||||
class PluginUpdateCandidate(BaseModel): # type: ignore[misc]
|
class PluginUpdateCandidate(BaseModel): # type: ignore[misc]
|
||||||
"""插件市场为已安装插件发现的当前最高在线更新候选。"""
|
"""插件市场为已安装插件选择的当前更新候选。"""
|
||||||
|
|
||||||
source_type: Literal["official", "third_party"] = Field(
|
source_type: Literal["official", "third_party"] = Field(
|
||||||
description="候选仓库是官方来源还是第三方来源"
|
description="候选仓库是官方来源还是第三方来源"
|
||||||
@@ -93,7 +93,7 @@ class Plugin(BaseModel):
|
|||||||
has_page: Optional[bool] = False
|
has_page: Optional[bool] = False
|
||||||
# 是否有新版本
|
# 是否有新版本
|
||||||
has_update: Optional[bool] = False
|
has_update: Optional[bool] = False
|
||||||
# 当前市场选择的最高在线更新候选;没有确定候选时为空
|
# 当前市场选择的更新候选;绑定仓库可更新时优先返回该仓库
|
||||||
update_candidate: Optional[PluginUpdateCandidate] = None
|
update_candidate: Optional[PluginUpdateCandidate] = None
|
||||||
# 插件仓库绑定状态;仅已安装物理插件由后端投影真实身份
|
# 插件仓库绑定状态;仅已安装物理插件由后端投影真实身份
|
||||||
source_binding_status: PluginSourceBindingStatus = PluginSourceBindingStatus.BOUND
|
source_binding_status: PluginSourceBindingStatus = PluginSourceBindingStatus.BOUND
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"application": {
|
"application": {
|
||||||
"covered_lines": 9292,
|
"covered_lines": 9309,
|
||||||
"percent": 77.76,
|
"percent": 77.8,
|
||||||
"statements": 11949
|
"statements": 11965
|
||||||
},
|
},
|
||||||
"domain": {
|
"domain": {
|
||||||
"covered_lines": 3390,
|
"covered_lines": 3390,
|
||||||
|
|||||||
+2
-1
@@ -1462,7 +1462,8 @@
|
|||||||
},
|
},
|
||||||
"app/chain/media.py": {
|
"app/chain/media.py": {
|
||||||
"arg-type": 34,
|
"arg-type": 34,
|
||||||
"assignment": 15,
|
"assignment": 16,
|
||||||
|
"call-overload": 1,
|
||||||
"comparison-overlap": 3,
|
"comparison-overlap": 3,
|
||||||
"no-any-return": 3,
|
"no-any-return": 3,
|
||||||
"no-untyped-def": 3,
|
"no-untyped-def": 3,
|
||||||
|
|||||||
@@ -113,6 +113,32 @@ async def test_async_collect_isolates_failure_and_completes_progress():
|
|||||||
assert progress.call_args_list[-1].kwargs["value"] == 100
|
assert progress.call_args_list[-1].kwargs["value"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_collect_preserves_each_repository_update_candidate():
|
||||||
|
"""来源准入读取应保留每个仓库的最高版本,不能先按插件 ID 全局去重。"""
|
||||||
|
service = _service()
|
||||||
|
|
||||||
|
async def loader(market: str, package_version: str | None, _force: bool):
|
||||||
|
if package_version is None:
|
||||||
|
return []
|
||||||
|
version = "2.0.0" if market == "https://market-bound" else "3.0.0"
|
||||||
|
return [_plugin("Demo", version, market)]
|
||||||
|
|
||||||
|
result = await service.async_collect(
|
||||||
|
markets=["https://market-bound", "https://market-alternative"],
|
||||||
|
compatible_flags=["v3"],
|
||||||
|
force=False,
|
||||||
|
loader=loader,
|
||||||
|
preserve_sources=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [(plugin.repo_url, plugin.plugin_version) for plugin in result] == [
|
||||||
|
("https://market-bound", "2.0.0"),
|
||||||
|
("https://market-alternative", "3.0.0"),
|
||||||
|
]
|
||||||
|
assert service.merge(result, [], ["https://market-bound", "https://market-alternative"]) == [result[1]]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_collect_cancels_all_loaders_when_parent_is_cancelled():
|
async def test_async_collect_cancels_all_loaders_when_parent_is_cancelled():
|
||||||
"""请求取消时必须取消并回收全部市场 loader,不能把子任务遗留在事件循环。"""
|
"""请求取消时必须取消并回收全部市场 loader,不能把子任务遗留在事件循环。"""
|
||||||
|
|||||||
@@ -187,6 +187,58 @@ def test_bound_repository_update_precedes_a_higher_alternative():
|
|||||||
assert result[0].update_candidate.is_bound is True
|
assert result[0].update_candidate.is_bound is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_market_endpoint_reads_source_preserving_candidates_for_bound_update():
|
||||||
|
"""市场接口必须在全局版本合并前保留绑定仓库候选。"""
|
||||||
|
installed = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="1.0.0",
|
||||||
|
installed=True,
|
||||||
|
)
|
||||||
|
bound_update = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="2.0.0",
|
||||||
|
repo_url=SOURCE_URL,
|
||||||
|
has_update=True,
|
||||||
|
)
|
||||||
|
alternative_update = schemas.Plugin(
|
||||||
|
id="DemoPlugin",
|
||||||
|
plugin_version="3.0.0",
|
||||||
|
repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
|
||||||
|
has_update=True,
|
||||||
|
)
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.get_installed_plugins.return_value = [installed]
|
||||||
|
plugin_manager.get_local_plugins.return_value = []
|
||||||
|
plugin_manager.get_local_repo_plugins.return_value = []
|
||||||
|
plugin_manager.async_get_online_plugin_candidates = AsyncMock(
|
||||||
|
return_value=[bound_update, alternative_update]
|
||||||
|
)
|
||||||
|
plugin_manager.process_plugins_list.side_effect = (
|
||||||
|
lambda higher, base: [
|
||||||
|
max(
|
||||||
|
higher + base,
|
||||||
|
key=lambda plugin: tuple(
|
||||||
|
int(part) for part in plugin.plugin_version.split(".")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
persistence = MagicMock()
|
||||||
|
persistence.list_identities = AsyncMock(return_value=[_plugin_identity()])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager),
|
||||||
|
patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence),
|
||||||
|
):
|
||||||
|
result = asyncio.run(plugin_endpoint.all_plugins(None, "market", False))
|
||||||
|
|
||||||
|
assert result == [bound_update]
|
||||||
|
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
|
||||||
|
plugin_manager.async_get_online_plugin_candidates.assert_awaited_once_with(False)
|
||||||
|
|
||||||
|
|
||||||
def _persistence(identity: PluginIdentity) -> MagicMock:
|
def _persistence(identity: PluginIdentity) -> MagicMock:
|
||||||
"""构造只暴露身份读取合同的异步持久化替身。"""
|
"""构造只暴露身份读取合同的异步持久化替身。"""
|
||||||
persistence = MagicMock()
|
persistence = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user