Files
MoviePilot/app/application/plugin/inventory.py
T

431 lines
15 KiB
Python

"""插件市场候选库存读取与外部事实映射。"""
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__