mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""
|
||
插件更新说明和 Release 版本查询用例。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
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 展示快照。
|
||
|
||
:param release_supported: 当前来源是否支持版本发布列表
|
||
:param latest_version: 市场可见的最新插件版本;未知时为 None
|
||
:param current_version: 宿主当前安装的插件版本;未安装或未知时为 None
|
||
:param items: 发布列表的版本和说明条目
|
||
:param refresh_required: 缓存不足时是否需要后台刷新,默认 False
|
||
"""
|
||
|
||
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 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),
|
||
}
|