Files
MoviePilot/app/application/plugin/release.py
2026-09-23 01:44:45 +08:00

248 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件更新说明和 Release 版本查询用例。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, cast
from app.application.plugin.catalog import apply_declared_metadata_fallback
from app.application.plugin.identity import PluginIdentity, PluginPayloadSourceType
from app.schemas.plugin import Plugin
MarketPluginLoader = Callable[[str, str | None, bool], Awaitable[list[Plugin] | None]]
ReleaseCacheProbe = Callable[[str], Awaitable[bool]]
ReleaseLoader = Callable[[str, str], Awaitable[list[dict[str, Any]]]]
ReleaseRefresher = Callable[[str, str], Awaitable[object]]
IdentityReader = Callable[[str], Awaitable[PluginIdentity | None]]
SourcePluginIdResolver = Callable[[str], str]
@dataclass(frozen=True, slots=True)
class PluginReleaseSnapshot:
"""描述指定插件仓库的 Release 展示快照。"""
release_supported: bool
latest_version: str | None
current_version: str | None
items: tuple[dict[str, Any], ...]
refresh_required: bool = False
class PluginReleaseService:
"""协调可信仓库元数据、Release 缓存和本地版本查询。"""
def __init__(
self,
*,
source_plugin_id: SourcePluginIdResolver,
installed_plugins: Callable[[], Sequence[Plugin]],
local_repo_plugins: Callable[[], Sequence[Plugin]],
market_plugins: MarketPluginLoader,
local_version: Callable[[str], str | None],
identity: IdentityReader,
version_flag: Callable[[], str | None],
compatible_flags: Callable[[str | None], Sequence[str]],
has_release_cache: ReleaseCacheProbe,
releases: ReleaseLoader,
refresh_releases: ReleaseRefresher,
free_threaded: Callable[[], bool],
) -> None:
"""保存源身份归一、运行态、来源身份和市场读取窄端口。"""
self._source_plugin_id = source_plugin_id
self._installed_plugins = installed_plugins
self._local_repo_plugins = local_repo_plugins
self._market_plugins = market_plugins
self._local_version = local_version
self._identity = identity
self._version_flag = version_flag
self._compatible_flags = compatible_flags
self._has_release_cache = has_release_cache
self._releases = releases
self._refresh_releases = refresh_releases
self._free_threaded = free_threaded
async def history(self, plugin_id: str, *, force: bool = True) -> Plugin | None:
"""按可信绑定仓库读取单个已安装插件的更新说明。
先把分身归一到源插件:分身只是共享同一份源码的运行实例,安装清单、本地插件
仓与来源身份都只登记在源插件名下,拿分身自身 ID(源插件 ID 加后缀)去查会一路
落空,端点据此判定「插件不存在或未安装」并返回 404。
"""
plugin_id = self._source_plugin_id(plugin_id)
installed_plugin = next(
(plugin for plugin in self._installed_plugins() if plugin.id == plugin_id),
None,
)
if installed_plugin is None:
return None
identity = await self._identity(plugin_id)
if identity is not None:
installed_plugin = apply_declared_metadata_fallback(
[installed_plugin],
{identity.normalized_plugin_id: identity},
free_threaded=self._free_threaded(),
)[0]
local_plugin = next(
(plugin for plugin in self._local_repo_plugins() if plugin.id == plugin_id),
None,
)
if identity is not None and identity.payload_source_type is PluginPayloadSourceType.LOCAL:
return (
_merge_market_metadata(installed_plugin, local_plugin)
if local_plugin is not None
else installed_plugin
)
repo_url = _trusted_repo_url(identity)
if repo_url is not None:
market_plugin = await self._market_plugin(plugin_id, repo_url, force)
return (
_merge_market_metadata(installed_plugin, market_plugin)
if market_plugin is not None
else installed_plugin
)
# 尚未建立来源身份的本地插件继续使用本地仓库元数据,兼容首次迁移前的运行态。
return (
_merge_market_metadata(installed_plugin, local_plugin)
if local_plugin is not None
else installed_plugin
)
async def versions(
self,
plugin_id: str,
repo_url: str,
*,
force: bool = False,
) -> PluginReleaseSnapshot:
"""读取 Release 快照,并标记是否需要后台强制刷新已有缓存。
先把分身归一到源插件:Release 记录与本地版本号都按源插件登记,拿分身自身 ID
去查市场索引与本地版本会一并落空,界面上表现为该分身没有任何可选版本。
"""
plugin_id = self._source_plugin_id(plugin_id)
if not repo_url:
return PluginReleaseSnapshot(False, None, None, ())
market_plugin = await self._market_plugin(plugin_id, repo_url, force)
latest_version = market_plugin.plugin_version if market_plugin else None
current_version = self._local_version(plugin_id)
if not getattr(market_plugin, "release", False):
return PluginReleaseSnapshot(
False,
latest_version,
current_version,
(),
)
refresh_required = force and await self._has_release_cache(repo_url)
release_items = await self._releases(plugin_id, repo_url)
items = tuple(
_project_release_item(item, latest_version, current_version)
for item in release_items
)
return PluginReleaseSnapshot(
bool(items),
latest_version,
current_version,
items,
refresh_required=refresh_required,
)
async def refresh(self, plugin_id: str, repo_url: str) -> None:
"""强制刷新指定仓库的 Release 缓存。"""
await self._refresh_releases(plugin_id, repo_url)
async def _market_plugin(
self, plugin_id: str, repo_url: str, force: bool
) -> Plugin | None:
"""按当前、兼容和基础索引顺序读取一个插件。"""
version_flag = self._version_flag()
package_versions: list[str | None] = [version_flag] if version_flag else []
package_versions.extend(self._compatible_flags(version_flag))
package_versions.append(None)
for package_version in dict.fromkeys(package_versions):
plugins = await self._market_plugins(repo_url, package_version, force)
matched = next(
(plugin for plugin in plugins or [] if plugin.id == plugin_id),
None,
)
if matched is not None:
return matched
return None
_release_service: PluginReleaseService | None = None
def configure_plugin_release_service(service: PluginReleaseService) -> None:
"""由启动组合根发布当前 lifespan 的 Release 查询服务。"""
global _release_service
_release_service = service
def get_plugin_release_service() -> PluginReleaseService:
"""返回已经由启动组合根装配的 Release 查询服务。"""
if _release_service is None:
raise RuntimeError("插件 Release 查询服务尚未完成初始化")
return _release_service
def reset_plugin_release_service() -> None:
"""清除当前 lifespan 的 Release 查询服务,供停机和隔离测试使用。"""
global _release_service
_release_service = None
def _trusted_repo_url(identity: PluginIdentity | None) -> str | None:
"""把持久化的 GitHub 来源键投影为仓库 URL。"""
if identity is None or not identity.trusted_source_key:
return None
owner_repo = identity.trusted_source_key.removeprefix("github:")
return f"https://github.com/{owner_repo}"
def _merge_market_metadata(plugin: Plugin, market_plugin: Plugin) -> Plugin:
"""在隔离副本上合并市场展示元数据。"""
return cast(
Plugin,
plugin.model_copy(
update={
"repo_url": market_plugin.repo_url or plugin.repo_url,
"history": market_plugin.history or {},
"release": market_plugin.release,
"has_update": market_plugin.has_update,
"system_version": market_plugin.system_version
or plugin.system_version,
"system_version_compatible": market_plugin.system_version_compatible,
"system_version_message": (
market_plugin.system_version_message
or plugin.system_version_message
),
# 版本历史弹窗的"更新到最新版"同样按运行时兼容状态禁用并说明原因
"runtime_compatible": market_plugin.runtime_compatible,
"runtime_message": (
market_plugin.runtime_message or plugin.runtime_message
),
}
),
)
def _project_release_item(
item: Mapping[str, Any],
latest_version: str | None,
current_version: str | None,
) -> dict[str, Any]:
"""复制并标记一个 Release 条目的最新和当前版本状态。"""
version = item.get("version")
return {
**item,
"is_latest": bool(latest_version and version == latest_version),
"is_current": bool(current_version and version == current_version),
}