mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
431 lines
15 KiB
Python
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 = 24,
|
|
) -> 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__
|