mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
"""插件市场候选库存读取与外部事实映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from typing import Any, TypeAlias
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
OFFICIAL_PLUGIN_SOURCE_KEY,
|
||||
TrustedPluginSourceType,
|
||||
validate_online_source_key,
|
||||
)
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
LocalCandidateRead,
|
||||
MarketRead,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
normalize_package_generation,
|
||||
)
|
||||
|
||||
PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1")
|
||||
PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]]
|
||||
PluginIndexLoaderResult: TypeAlias = PluginIndex | None
|
||||
LocalCandidateLoadPayload: TypeAlias = (
|
||||
Mapping[str, Mapping[str, Any]]
|
||||
| Iterable[Mapping[str, Any]]
|
||||
| None
|
||||
)
|
||||
MarketLoader: TypeAlias = Callable[
|
||||
[str, str | None, bool],
|
||||
PluginIndexLoaderResult,
|
||||
]
|
||||
AsyncMarketLoader: TypeAlias = Callable[
|
||||
[str, str | None, bool],
|
||||
Awaitable[PluginIndexLoaderResult],
|
||||
]
|
||||
LocalCandidateLoader: TypeAlias = Callable[
|
||||
[],
|
||||
LocalCandidateLoadPayload,
|
||||
]
|
||||
|
||||
|
||||
class PluginCandidateInventoryReader:
|
||||
"""按配置市场和 V3 代际顺序保留全部候选及读取终态。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
market_loader: MarketLoader,
|
||||
local_candidate_loader: LocalCandidateLoader | None = None,
|
||||
async_market_loader: AsyncMarketLoader | None = None,
|
||||
generations: Sequence[str] = PLUGIN_V3_GENERATIONS,
|
||||
max_concurrency: int = 12,
|
||||
) -> None:
|
||||
"""保存读取端口,并限制异步市场请求的进程内并发。"""
|
||||
normalized_generations = tuple(
|
||||
normalize_package_generation(generation)
|
||||
for generation in generations
|
||||
)
|
||||
if normalized_generations != PLUGIN_V3_GENERATIONS:
|
||||
raise ValueError("V3 候选库存必须按 v3、v2、v1 顺序读取")
|
||||
if max_concurrency < 1:
|
||||
raise ValueError("插件市场读取并发必须大于 0")
|
||||
self._market_loader = market_loader
|
||||
self._async_market_loader = async_market_loader
|
||||
self._local_candidate_loader = local_candidate_loader
|
||||
self._generations = normalized_generations
|
||||
self._max_concurrency = max_concurrency
|
||||
|
||||
def load(
|
||||
self,
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""同步读取全部配置市场,不把失败市场伪装成空仓库。"""
|
||||
normalized_markets = _normalize_markets(markets)
|
||||
reads = tuple(
|
||||
self._read_market_generation(market, generation, force=force)
|
||||
for market in normalized_markets
|
||||
for generation in self._generations
|
||||
)
|
||||
local_read = self._load_local_candidates()
|
||||
return CandidateInventory(
|
||||
reads,
|
||||
local_read.candidates,
|
||||
local_read=local_read,
|
||||
expected_markets=normalized_markets,
|
||||
expected_generations=self._generations,
|
||||
)
|
||||
|
||||
async def async_load(
|
||||
self,
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""有界并发读取全部市场,同时保持配置与代际的稳定顺序。"""
|
||||
normalized_markets = _normalize_markets(markets)
|
||||
semaphore = asyncio.Semaphore(self._max_concurrency)
|
||||
|
||||
async def read(market: str, generation: str) -> MarketRead:
|
||||
async with semaphore:
|
||||
return await self._async_read_market_generation(
|
||||
market,
|
||||
generation,
|
||||
force=force,
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
read(market, generation),
|
||||
name="plugin.inventory.read",
|
||||
)
|
||||
for market in normalized_markets
|
||||
for generation in self._generations
|
||||
]
|
||||
try:
|
||||
reads = tuple(await asyncio.gather(*tasks)) if tasks else ()
|
||||
finally:
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
local_read = await asyncio.to_thread(self._load_local_candidates)
|
||||
return CandidateInventory(
|
||||
reads,
|
||||
local_read.candidates,
|
||||
local_read=local_read,
|
||||
expected_markets=normalized_markets,
|
||||
expected_generations=self._generations,
|
||||
)
|
||||
|
||||
def _read_market_generation(
|
||||
self,
|
||||
market: str,
|
||||
generation: str,
|
||||
*,
|
||||
force: bool,
|
||||
) -> MarketRead:
|
||||
"""同步读取一个市场代际并映射为候选事实。"""
|
||||
try:
|
||||
source_key, repo_url, source_type = _market_source(market)
|
||||
payload = self._market_loader(
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
return _successful_read(
|
||||
market,
|
||||
generation,
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return MarketRead.failure(
|
||||
market,
|
||||
_error_message(error),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
async def _async_read_market_generation(
|
||||
self,
|
||||
market: str,
|
||||
generation: str,
|
||||
*,
|
||||
force: bool,
|
||||
) -> MarketRead:
|
||||
"""异步读取一个市场代际并映射为候选事实。"""
|
||||
try:
|
||||
source_key, repo_url, source_type = _market_source(market)
|
||||
if self._async_market_loader is None:
|
||||
payload = await asyncio.to_thread(
|
||||
self._market_loader,
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
else:
|
||||
payload = await self._async_market_loader(
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
return _successful_read(
|
||||
market,
|
||||
generation,
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return MarketRead.failure(
|
||||
market,
|
||||
_error_message(error),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
def _load_local_candidates(self) -> LocalCandidateRead:
|
||||
"""映射本地候选,并保留扫描失败而非伪装为空仓库。"""
|
||||
if self._local_candidate_loader is None:
|
||||
return LocalCandidateRead.absent()
|
||||
try:
|
||||
raw_candidates = self._local_candidate_loader()
|
||||
if raw_candidates is None:
|
||||
return LocalCandidateRead.failure(
|
||||
"本地插件仓库读取未返回可判定结果"
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return LocalCandidateRead.failure(_error_message(error))
|
||||
entries: Iterable[tuple[object, Mapping[str, Any]]]
|
||||
try:
|
||||
if isinstance(raw_candidates, Mapping):
|
||||
entries = raw_candidates.items()
|
||||
else:
|
||||
entries = (
|
||||
(plugin_info.get("id"), plugin_info)
|
||||
for plugin_info in raw_candidates
|
||||
if isinstance(plugin_info, Mapping)
|
||||
)
|
||||
|
||||
candidates: list[PluginLocalCandidate] = []
|
||||
for plugin_id, plugin_info in entries:
|
||||
if not isinstance(plugin_id, str) or not isinstance(plugin_info, Mapping):
|
||||
continue
|
||||
try:
|
||||
candidate = _local_candidate(plugin_id, plugin_info)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if candidate is not None:
|
||||
candidates.append(candidate)
|
||||
except Exception as error: # noqa: BLE001 - 迭代或映射失败也要保留状态
|
||||
return LocalCandidateRead.failure(_error_message(error))
|
||||
return LocalCandidateRead.present(candidates)
|
||||
|
||||
|
||||
def build_plugin_candidate_inventory(
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
market_loader: MarketLoader,
|
||||
local_candidate_loader: LocalCandidateLoader | None = None,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""使用注入的同步读取端口构建一次候选库存。"""
|
||||
return PluginCandidateInventoryReader(
|
||||
market_loader=market_loader,
|
||||
local_candidate_loader=local_candidate_loader,
|
||||
).load(markets, force=force)
|
||||
|
||||
|
||||
def normalize_github_plugin_source(value: str) -> tuple[str, str]:
|
||||
"""把 GitHub 仓库 URL 或来源键归一为持久来源键和公开地址。"""
|
||||
normalized = str(value).strip().rstrip("/")
|
||||
if normalized.lower().startswith("github:"):
|
||||
source_key = validate_online_source_key(normalized)
|
||||
owner, repository = source_key.removeprefix("github:").split("/", 1)
|
||||
return source_key, f"https://github.com/{owner}/{repository}"
|
||||
|
||||
parsed = urlsplit(normalized)
|
||||
if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
|
||||
raise ValueError("插件市场必须是 GitHub 仓库地址")
|
||||
if parsed.hostname.lower() != "github.com":
|
||||
raise ValueError("插件市场必须使用 github.com 仓库地址")
|
||||
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
||||
if len(parts) < 2:
|
||||
raise ValueError("插件市场 GitHub 地址缺少 owner 或 repository")
|
||||
owner, repository = parts[:2]
|
||||
repository = repository.removesuffix(".git")
|
||||
source_key = validate_online_source_key(f"github:{owner}/{repository}")
|
||||
return source_key, f"https://github.com/{owner}/{repository}"
|
||||
|
||||
|
||||
def _normalize_markets(markets: Iterable[str]) -> tuple[str, ...]:
|
||||
"""规范化并去除重复市场,保留无效配置供读取快照报错。"""
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for market in markets:
|
||||
value = str(market).strip().rstrip("/")
|
||||
if not value:
|
||||
continue
|
||||
key = value.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(value)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _market_source(
|
||||
market: str,
|
||||
) -> tuple[str, str, TrustedPluginSourceType]:
|
||||
"""解析 GitHub 市场来源,并固定官方仓库分类。"""
|
||||
source_key, repo_url = normalize_github_plugin_source(market)
|
||||
source_type = (
|
||||
TrustedPluginSourceType.OFFICIAL
|
||||
if source_key == OFFICIAL_PLUGIN_SOURCE_KEY
|
||||
else TrustedPluginSourceType.THIRD_PARTY
|
||||
)
|
||||
return source_key, repo_url, source_type
|
||||
|
||||
|
||||
def _successful_read(
|
||||
market: str,
|
||||
generation: str,
|
||||
payload: PluginIndexLoaderResult,
|
||||
*,
|
||||
source_key: str,
|
||||
source_type: TrustedPluginSourceType,
|
||||
repo_url: str,
|
||||
) -> MarketRead:
|
||||
"""把 Adapter 读取结果映射为一个市场代际的三态事实。"""
|
||||
if payload is None:
|
||||
return MarketRead.absent(
|
||||
market,
|
||||
package_generation=generation,
|
||||
)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise TypeError("插件市场索引必须是对象")
|
||||
return MarketRead.present(
|
||||
market,
|
||||
_market_candidates(
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation=generation,
|
||||
),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
|
||||
def _market_candidates(
|
||||
payload: PluginIndex,
|
||||
*,
|
||||
source_key: str,
|
||||
source_type: TrustedPluginSourceType,
|
||||
repo_url: str,
|
||||
package_generation: str,
|
||||
) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""过滤并映射一个代际索引中的 V3 可兼容条目。"""
|
||||
result: list[PluginMarketCandidate] = []
|
||||
for index_plugin_id, raw_info in payload.items():
|
||||
if not isinstance(raw_info, Mapping):
|
||||
continue
|
||||
plugin_id = raw_info.get("id") or index_plugin_id
|
||||
if not isinstance(plugin_id, str):
|
||||
continue
|
||||
if not _is_v3_compatible(raw_info, package_generation):
|
||||
continue
|
||||
plugin_version = raw_info.get("version")
|
||||
if plugin_version is None:
|
||||
plugin_version = raw_info.get("plugin_version")
|
||||
if plugin_version == "":
|
||||
plugin_version = None
|
||||
try:
|
||||
result.append(
|
||||
PluginMarketCandidate(
|
||||
plugin_id=plugin_id,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation=package_generation,
|
||||
plugin_version=plugin_version,
|
||||
dto=dict(raw_info),
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _is_v3_compatible(
|
||||
plugin_info: Mapping[str, Any],
|
||||
package_generation: str,
|
||||
) -> bool:
|
||||
"""按宿主 V3、兼容 V2、基础索引顺序判断候选兼容性。"""
|
||||
if plugin_info.get("v3") is False:
|
||||
return False
|
||||
if package_generation in {"v3", "v2"}:
|
||||
return True
|
||||
return plugin_info.get("v3") is True or plugin_info.get("v2") is True
|
||||
|
||||
|
||||
def _local_candidate(
|
||||
plugin_id: str,
|
||||
plugin_info: Mapping[str, Any],
|
||||
) -> PluginLocalCandidate | None:
|
||||
"""把本地插件索引条目转换为应用候选。"""
|
||||
generation = normalize_package_generation(
|
||||
str(
|
||||
plugin_info.get("package_version")
|
||||
or plugin_info.get("package_generation")
|
||||
or "v1"
|
||||
)
|
||||
)
|
||||
if not _is_v3_compatible(plugin_info, generation):
|
||||
return None
|
||||
repo_url = plugin_info.get("repo_url")
|
||||
if not isinstance(repo_url, str) or not repo_url.startswith("local://"):
|
||||
return None
|
||||
plugin_version = plugin_info.get("version")
|
||||
if plugin_version is None:
|
||||
plugin_version = plugin_info.get("plugin_version")
|
||||
if plugin_version == "":
|
||||
plugin_version = None
|
||||
return PluginLocalCandidate(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_generation=generation,
|
||||
plugin_version=plugin_version,
|
||||
dto=dict(plugin_info),
|
||||
)
|
||||
|
||||
|
||||
def _package_version(package_generation: str) -> str | None:
|
||||
"""把公共代际转换为市场索引文件参数。"""
|
||||
return None if package_generation == "v1" else package_generation
|
||||
|
||||
|
||||
def _error_message(error: Exception) -> str:
|
||||
"""保留可诊断的读取失败说明,并避免空异常丢失状态。"""
|
||||
message = str(error).strip()
|
||||
return message or error.__class__.__name__
|
||||
Reference in New Issue
Block a user