Files
MoviePilot/app/adapters/external/plugin/client.py
2026-09-19 23:19:00 +08:00

2096 lines
81 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.

"""插件市场查询客户端。"""
from __future__ import annotations
import asyncio
import json
import re
import threading
import time
import traceback
from collections import Counter
from concurrent.futures import Future
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, cast
from urllib.parse import urlsplit
import httpx2
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from requests import Response
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.domain.plugin import (
build_local_plugin_source,
check_plugin_system_version,
is_local_plugin_source,
is_physical_plugin_id,
is_plugin_generation_compatible,
parse_local_plugin_generation,
parse_local_plugin_path,
parse_local_plugin_reference,
)
from app.domain.plugin import (
normalize_plugin_market_repo_url as _normalize_plugin_market_repo_url,
)
from app.domain.plugin import (
split_plugin_market_repo_urls as _split_plugin_market_repo_urls,
)
from app.foundation.environment import is_free_threaded_runtime
from app.foundation.singleton import WeakSingleton
from app.foundation.url import UrlUtils
from app.foundation.version import compare_version
from app.runtime.cache import async_fresh, cached, fresh, is_fresh
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.runtime.tasks import get_task_registry
from app.runtime.version import get_app_version
PluginPayload = dict[str, Any]
PluginIndex = dict[str, PluginPayload]
PluginReleaseList = list[PluginPayload]
PluginRequestOptions = dict[str, Any]
PluginIndexTaskResult = tuple[Optional[PluginIndex], Optional[Exception]]
PluginReleaseTask = asyncio.Task[Optional[PluginReleaseList]]
PLUGIN_INDEX_MAX_BYTES = 1024 * 1024
PLUGIN_INDEX_MAX_ENTRIES = 4096
PLUGIN_INDEX_MAX_HISTORY_ENTRIES = 512
PLUGIN_INDEX_MAX_NESTING = 64
PLUGIN_INDEX_READ_CHUNK_SIZE = 64 * 1024
PLUGIN_INDEX_REQUEST_TIMEOUT = 15
DEFAULT_PLUGIN_INDEX_FETCH_CONCURRENCY = 5
PLUGIN_INDEX_COMPATIBILITY_FLAG_PATTERN = re.compile(r"^v\d+t?$")
PLUGIN_INDEX_TEXT_LIMITS = {
"name": 256,
"author": 256,
"description": 4096,
"icon": 2048,
"author_url": 2048,
"authorUrl": 2048,
"project_url": 2048,
"homepage": 2048,
"repo_url": 2048,
}
# 目录服务和来源库存可能在同一进程内同时刷新;调用方各自创建的信号量
# 只能限制单次刷新,不能限制真实出站请求总量。索引传输层统一持有闸门。
_PLUGIN_INDEX_FETCH_GATE = threading.BoundedSemaphore(
DEFAULT_PLUGIN_INDEX_FETCH_CONCURRENCY
)
class _PluginIndexTooLargeError(RuntimeError):
"""插件索引的声明长度或实际读取字节超过资源边界。"""
def _format_request_error(error: Exception) -> str:
"""保留异常类型,避免无消息异常在运行日志中显示为空白。"""
detail = str(error).strip()
return f"{type(error).__name__}: {detail}" if detail else type(error).__name__
def build_local_repo_url(
plugin_id: str,
*,
repo_path: Optional[Path] = None,
package_version: Optional[str] = None,
) -> str:
"""生成兼容插件 API 使用的本地仓库来源标识。"""
return build_local_plugin_source(
plugin_id,
repo_path=str(repo_path) if repo_path is not None else None,
package_generation=package_version,
)
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""判断插件来源是否为本地仓库标识。"""
return is_local_plugin_source(repo_url)
def parse_local_repo_plugin_id(repo_url: str) -> Optional[str]:
"""从本地仓库来源标识提取插件 ID。"""
return parse_local_plugin_reference(repo_url)
def parse_local_repo_path(
repo_url: str,
*,
root_path: Optional[Path] = None,
) -> Optional[Path]:
"""解析本地来源中的仓库路径,并相对宿主根目录完成定位。"""
try:
raw_path = parse_local_plugin_path(repo_url)
if not raw_path:
return None
path = Path(raw_path).expanduser()
if not path.is_absolute():
if root_path is None:
return None
path = root_path / path
return path.resolve()
except (OSError, TypeError, ValueError):
return None
def parse_local_repo_generation(repo_url: str) -> Optional[str]:
"""解析本地来源标识中的 package 代际参数。"""
return parse_local_plugin_generation(repo_url)
PLUGIN_SYSTEM_VERSION_FIELD = "system_version"
PLUGIN_MARKET_WIKI_START = "<!-- plugin-market-repos:start -->"
PLUGIN_MARKET_WIKI_END = "<!-- plugin-market-repos:end -->"
PLUGIN_MARKET_WIKI_URL = (
"https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
)
PLUGIN_MARKET_REPO_PATTERN = re.compile(
r"https?://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?",
re.IGNORECASE,
)
# 主程序重大版本可扫描的旧索引V3 临时默认兼容 V2条目可用 v3:false 排除。
VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = {
"v3": ["v2"],
}
# 保留历史兼容模块的导出路径;规范化规则的唯一实现位于纯领域模块。
normalize_plugin_market_repo_url = _normalize_plugin_market_repo_url
split_plugin_market_repo_urls = _split_plugin_market_repo_urls
def extract_plugin_market_repos_from_wiki(
markdown: str, require_markers: bool = False
) -> list[str]:
"""
从 Wiki 插件文档中提取插件仓库地址。
:param markdown: Wiki 插件文档 Markdown 内容
:param require_markers: 是否要求文档包含唯一且有序的清单边界标记
:return: 规范化并按文档顺序去重的插件仓库地址
"""
content = markdown or ""
start_count = content.count(PLUGIN_MARKET_WIKI_START)
end_count = content.count(PLUGIN_MARKET_WIKI_END)
start_index = content.find(PLUGIN_MARKET_WIKI_START)
end_index = content.find(PLUGIN_MARKET_WIKI_END)
if start_count == 1 and end_count == 1 and start_index < end_index:
content = content[
start_index + len(PLUGIN_MARKET_WIKI_START):end_index
]
elif require_markers:
raise ValueError("Wiki 插件仓库清单必须包含唯一且有序的开始和结束标记")
repos: list[str] = []
seen_repos = set()
for item in PLUGIN_MARKET_REPO_PATTERN.findall(content):
normalized_repo = normalize_plugin_market_repo_url(item)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return repos
def merge_plugin_market_repos(
local_repos: list[str], wiki_repos: list[str]
) -> list[str]:
"""合并本地与 Wiki 插件仓库地址,并保持来源中的既有顺序。"""
merged_repos: list[str] = []
seen_repos = set()
for repo in local_repos + wiki_repos:
normalized_repo = normalize_plugin_market_repo_url(repo)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
merged_repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return merged_repos
class PluginMarketTransport(metaclass=WeakSingleton):
"""负责插件市场、本地仓库和 GitHub 元数据读取。"""
_base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/"
_index_future_lock = threading.Lock()
_index_futures: dict[tuple[str, str], Future[Optional[PluginIndex]]] = {}
_index_task_lock = threading.Lock()
_index_tasks: dict[
tuple[asyncio.AbstractEventLoop, str, str],
asyncio.Task[PluginIndexTaskResult],
] = {}
_release_task_lock = threading.Lock()
_release_tasks: dict[
tuple[asyncio.AbstractEventLoop, str, bool],
PluginReleaseTask,
] = {}
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""
判断是否为本地插件来源标识
"""
return is_local_repo_url(repo_url)
@staticmethod
def make_local_repo_url(pid: str, repo_path: Optional[Path] = None,
package_version: Optional[str] = None) -> str:
"""
生成本地插件安装来源标识
"""
return build_local_repo_url(
pid,
repo_path=repo_path,
package_version=package_version,
)
@staticmethod
def parse_local_repo_url(repo_url: str) -> Optional[str]:
"""
从本地插件来源标识中解析插件ID
"""
return parse_local_repo_plugin_id(repo_url)
@staticmethod
def parse_local_repo_path(repo_url: str) -> Optional[Path]:
"""
从本地插件来源标识中解析仓库路径
"""
return parse_local_repo_path(
repo_url,
root_path=(
Path(root_path)
if (root_path := get_runtime_setting('ROOT_PATH')) is not None
else None
),
)
@staticmethod
def parse_local_repo_package_version(repo_url: str) -> Optional[str]:
"""
从本地插件来源标识中解析 package 版本
"""
return parse_local_repo_generation(repo_url)
@staticmethod
def get_current_system_version() -> Optional[Version]:
"""
解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。
"""
try:
return Version(get_app_version())
except InvalidVersion:
logger.error(f"当前主程序版本号无法解析:{get_app_version()}")
return None
@classmethod
def get_compatible_version_flags(cls) -> list[str]:
"""
返回当前主程序版本可兼容的全部版本标识,包含自身及向后兼容的低版本,按优先级降序。
未启用 VERSION_FLAGv1时返回空列表表示仅使用 package.json 基础索引。
"""
flags: list[str] = []
if get_runtime_setting('VERSION_FLAG'):
flags.append(get_runtime_setting('VERSION_FLAG'))
flags.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), []))
return flags
@classmethod
def is_plugin_info_compatible(
cls, plugin_info: Optional[PluginPayload]
) -> bool:
"""
判断 package.json 中的插件元数据是否兼容当前主程序版本。
默认索引需要声明当前版本V3 临时兼容已声明 V2 的共享实现,
但显式 ``v3: false`` 始终优先拒绝。
"""
if not isinstance(plugin_info, dict):
return False
if is_free_threaded_runtime() and plugin_info.get("v3t") is False:
return False
if not get_runtime_setting('VERSION_FLAG'):
return True
current_flag = get_runtime_setting('VERSION_FLAG')
if plugin_info.get(current_flag) is False:
return False
if plugin_info.get(current_flag) is True:
return True
return any(
plugin_info.get(flag) is True
for flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(
current_flag, []
)
)
@classmethod
def is_package_plugin_compatible(
cls,
plugin_info: Optional[PluginPayload],
package_version: Optional[str],
) -> bool:
"""
判断指定索引中的插件条目能否在当前主程序版本使用。
当前代专用索引直接兼容。V3 临时默认兼容 V2 专用索引,
除非条目显式声明 ``v3: false``;默认索引仍需先声明 ``v2: true``。
"""
if not isinstance(plugin_info, dict):
return False
if is_free_threaded_runtime() and plugin_info.get("v3t") is False:
return False
current_flag = get_runtime_setting('VERSION_FLAG')
if not current_flag:
return not package_version
if package_version == current_flag:
return True
if package_version in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(
current_flag, []
):
return plugin_info.get(current_flag) is not False
if not package_version:
return cls.is_plugin_info_compatible(plugin_info)
return False
@staticmethod
def _package_version_candidates(
package_version: Optional[str],
) -> tuple[str, ...]:
"""返回插件安装唯一的代际候选顺序,并去除重复的基础索引。"""
preferred_version = package_version or get_runtime_setting('VERSION_FLAG')
candidates = [preferred_version]
candidates.extend(
VERSION_BACKWARD_COMPATIBLE_FLAGS.get(preferred_version, [])
)
candidates.append("")
return tuple(dict.fromkeys(candidates))
@classmethod
def _select_compatible_package_version(
cls,
pid: str,
package_version: str,
plugins: Optional[PluginIndex],
) -> Optional[str]:
"""从一个索引结果选择目标插件,并复用统一的代际兼容判定。"""
plugin = (plugins or {}).get(pid)
if plugin and cls.is_package_plugin_compatible(plugin, package_version):
return package_version
return None
@classmethod
def check_plugin_system_version(
cls, plugin_info: Optional[PluginPayload]
) -> tuple[bool, str]:
"""
检查插件 package 元数据中的主系统版本范围是否满足当前 MoviePilot 版本。
"""
if not isinstance(plugin_info, dict):
return True, ""
raw_specifier = plugin_info.get(PLUGIN_SYSTEM_VERSION_FIELD)
if raw_specifier is None or raw_specifier == "":
return True, ""
if not isinstance(raw_specifier, str):
return False, (
f"插件限定的系统版本范围 {PLUGIN_SYSTEM_VERSION_FIELD} 必须是字符串,"
f"请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3"
)
system_version = cls.get_current_system_version()
if system_version is None:
return False, f"当前 MoviePilot 版本 {get_app_version()} 无法解析,已拒绝安装带版本限制的插件"
try:
specifier_set = SpecifierSet(raw_specifier)
except InvalidSpecifier:
return False, (
f"插件限定的系统版本范围格式不正确:{raw_specifier}"
f"请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3"
)
if specifier_set.contains(system_version, prereleases=True):
return True, ""
return False, (
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装"
)
@classmethod
def annotate_plugin_system_version(
cls, plugin_info: PluginPayload
) -> PluginPayload:
"""
为插件 package 元数据补充系统版本兼容状态,便于市场展示和安装流程复用。
"""
if not isinstance(plugin_info, dict):
return plugin_info
compatible, message = cls.check_plugin_system_version(plugin_info)
plugin_info["system_version_compatible"] = compatible
plugin_info["system_version_message"] = message
return plugin_info
@staticmethod
def get_local_repo_paths() -> list[Path]:
"""
获取本地插件仓库目录列表
"""
if not get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS'):
return []
paths = []
for item in get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS').split(","):
local_repo_path = item.strip()
if not local_repo_path:
continue
path = Path(local_repo_path).expanduser()
if not path.is_absolute():
path = get_runtime_setting('ROOT_PATH') / path
paths.append(path.resolve())
return paths
@classmethod
def __get_local_package(
cls, repo_path: Path, package_version: Optional[str] = None
) -> Optional[PluginIndex]:
"""
从本地插件仓库读取 package.json 或 package.{version}.json
"""
package_file = repo_path / (
f"package.{package_version}.json" if package_version else "package.json"
)
if not package_file.exists():
return {}
try:
if package_file.stat().st_size > PLUGIN_INDEX_MAX_BYTES:
raise _PluginIndexTooLargeError(
f"插件索引超过 {PLUGIN_INDEX_MAX_BYTES} 字节"
)
with package_file.open("rb") as file_handle:
raw_content = file_handle.read(PLUGIN_INDEX_MAX_BYTES + 1)
if len(raw_content) > PLUGIN_INDEX_MAX_BYTES:
raise _PluginIndexTooLargeError(
f"插件索引超过 {PLUGIN_INDEX_MAX_BYTES} 字节"
)
content = raw_content.decode("utf-8", errors="replace")
except OSError as error:
raise RuntimeError(f"读取本地插件包 {package_file} 失败:{error}") from error
payload = cls.__parse_plugin_index_response(content)
if payload is None:
raise RuntimeError(f"本地插件包 {package_file} 格式无效")
return payload
@staticmethod
def __get_local_plugin_dir(repo_path: Path, pid: str, package_version: Optional[str]) -> Path:
"""按插件包版本计算本地插件源码目录。"""
plugin_root = f"plugins.{package_version}" if package_version else "plugins"
return repo_path / plugin_root / pid.lower()
def get_local_plugin_candidates(self) -> PluginIndex:
"""
扫描本地插件仓库按插件ID保留版本号最高的候选
"""
candidates: PluginIndex = {}
for repo_order, repo_path in enumerate(self.get_local_repo_paths()):
if not repo_path.exists() or not repo_path.is_dir():
raise RuntimeError(f"本地插件仓库目录不存在或不可读:{repo_path}")
package_candidates = []
if get_runtime_setting('VERSION_FLAG'):
package_candidates.append((get_runtime_setting('VERSION_FLAG'), self.__get_local_package(repo_path,
get_runtime_setting('VERSION_FLAG'))))
# 向后兼容:补充扫描更低版本的 package 文件,便于本地仓库复用历史版本插件。
for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), []):
package_candidates.append((backward_flag, self.__get_local_package(repo_path, backward_flag)))
package_candidates.append(("", self.__get_local_package(repo_path)))
for package_version, local_plugins in package_candidates:
if local_plugins is None:
continue
for pid, plugin_info in local_plugins.items():
if not isinstance(plugin_info, dict):
continue
if not self.is_package_plugin_compatible(
plugin_info, package_version
):
continue
plugin_dir = self.__get_local_plugin_dir(repo_path, pid, package_version)
if not plugin_dir.is_dir():
logger.debug(f"跳过本地插件 {pid}:插件目录不存在 {plugin_dir}")
continue
candidate = plugin_info.copy()
candidate["id"] = pid
candidate["package_version"] = package_version
candidate["repo_order"] = repo_order
candidate["repo_path"] = repo_path
candidate["path"] = plugin_dir
candidate["repo_url"] = self.make_local_repo_url(
pid,
repo_path,
package_version or None,
)
self.annotate_plugin_system_version(candidate)
candidate_version = str(candidate.get("version") or "0")
existing = candidates.get(pid)
if not existing:
candidates[pid] = candidate
continue
existing_version = str(existing.get("version") or "0")
if compare_version(candidate_version, ">", existing_version):
candidates[pid] = candidate
elif (
candidate_version == existing_version
and repo_order < int(existing.get("repo_order", repo_order))
):
logger.info(f"本地插件 {pid} 存在同版本来源,使用靠前目录:{repo_path}")
candidates[pid] = candidate
return candidates
def get_local_plugin_candidate(self, pid: str, package_version: Optional[str] = None,
repo_path: Optional[Path] = None,
strict_compat: bool = True,
strict_system_version: bool = True) -> Optional[PluginPayload]:
"""
获取指定插件ID的本地插件候选
:param strict_system_version: 是否将主系统版本范围不匹配视为不可用候选
"""
if not pid:
return None
if package_version is not None or repo_path is not None:
repo_paths = [repo_path.resolve()] if repo_path else self.get_local_repo_paths()
package_versions = [package_version] if package_version is not None else []
if package_version is None:
if get_runtime_setting('VERSION_FLAG'):
package_versions.append(get_runtime_setting('VERSION_FLAG'))
package_versions.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), []))
package_versions.append("")
selected_candidate = None
for repo_order, local_repo_path in enumerate(self.get_local_repo_paths()):
if local_repo_path not in repo_paths:
continue
for current_package_version in package_versions:
local_plugins = self.__get_local_package(local_repo_path, current_package_version or "")
if not local_plugins:
continue
for candidate_pid, plugin_info in local_plugins.items():
if candidate_pid.lower() != pid.lower() or not isinstance(plugin_info, dict):
continue
is_compatible = self.is_package_plugin_compatible(
plugin_info,
current_package_version or "",
)
if not is_compatible and strict_compat:
continue
plugin_dir = self.__get_local_plugin_dir(local_repo_path, candidate_pid,
current_package_version or "")
if not plugin_dir.is_dir():
continue
candidate = plugin_info.copy()
candidate["id"] = candidate_pid
candidate["package_version"] = current_package_version or ""
candidate["repo_order"] = repo_order
candidate["repo_path"] = local_repo_path
candidate["path"] = plugin_dir
candidate["repo_url"] = self.make_local_repo_url(
candidate_pid,
local_repo_path,
current_package_version or None,
)
if not is_compatible:
candidate["compatible"] = False
candidate["skip_reason"] = (
f"插件索引条目不兼容 {get_runtime_setting('VERSION_FLAG')}"
)
self.annotate_plugin_system_version(candidate)
if strict_system_version and candidate.get("system_version_compatible") is False:
candidate["compatible"] = False
candidate["skip_reason"] = candidate.get("system_version_message")
elif not strict_system_version and is_compatible:
candidate.pop("compatible", None)
candidate.pop("skip_reason", None)
if package_version is not None:
return candidate
if not selected_candidate:
selected_candidate = candidate
continue
selected_version = str(selected_candidate.get("version") or "0")
candidate_version = str(candidate.get("version") or "0")
if compare_version(candidate_version, ">", selected_version):
selected_candidate = candidate
return selected_candidate
candidates = self.get_local_plugin_candidates()
for candidate_pid, candidate in candidates.items():
if candidate_pid.lower() == pid.lower():
if strict_system_version and candidate.get("system_version_compatible") is False:
candidate = candidate.copy()
candidate["compatible"] = False
candidate["skip_reason"] = candidate.get("system_version_message")
return candidate
return None
@staticmethod
def __append_cache_buster(url: str) -> str:
"""
强制刷新插件库索引时追加时间戳,绕过 GitHub 镜像或中间代理的缓存。
"""
if not is_fresh():
return url
parts = urlsplit(url)
refresh_param = f"_refresh={time.time_ns()}"
query = f"{parts.query}&{refresh_param}" if parts.query else refresh_param
return parts._replace(query=query).geturl()
@staticmethod
def __safe_plugin_id(plugin_id: object) -> str:
"""生成适合日志展示的有界插件 ID不回显完整外部载荷。"""
if not isinstance(plugin_id, str):
return f"<{type(plugin_id).__name__}>"
return json.dumps(plugin_id[:64], ensure_ascii=True)
@staticmethod
def __plugin_index_nesting_is_valid(payload: object) -> bool:
"""限制外部 JSON 的容器深度,避免后续序列化递归耗尽调用栈。"""
pending = [(payload, 0)]
while pending:
value, depth = pending.pop()
if depth > PLUGIN_INDEX_MAX_NESTING:
return False
if isinstance(value, dict):
pending.extend((item, depth + 1) for item in value.values())
elif isinstance(value, list):
pending.extend((item, depth + 1) for item in value)
return True
@classmethod
def __normalize_plugin_index_entry(
cls,
plugin_id: object,
plugin_info: object,
) -> tuple[Optional[PluginPayload], Optional[str], list[str]]:
"""校验影响安装决策的字段,并宽容丢弃异常展示字段。"""
if not isinstance(plugin_id, str) or not is_physical_plugin_id(plugin_id):
return None, "插件 ID 非法", []
if not isinstance(plugin_info, dict):
return None, "条目不是对象", []
declared_id = plugin_info.get("id")
if "id" in plugin_info and (
not isinstance(declared_id, str)
or not is_physical_plugin_id(declared_id)
or declared_id.lower() != plugin_id.lower()
):
return None, "id 与索引键不一致", []
version = plugin_info.get("version")
if not isinstance(version, str) or not version or len(version) > 64:
return None, "version 非法", []
system_version = plugin_info.get("system_version")
if "system_version" in plugin_info and (
not isinstance(system_version, str) or len(system_version) > 256
):
return None, "system_version 非法", []
level = plugin_info.get("level")
if "level" in plugin_info and (
isinstance(level, bool)
or not isinstance(level, int)
or not 0 <= level <= 99
):
return None, "level 非法", []
public_key = plugin_info.get("key")
if "key" in plugin_info and (
not isinstance(public_key, str) or len(public_key) > 16 * 1024
):
return None, "key 非法", []
for field, value in plugin_info.items():
if field == "release" and not isinstance(value, bool):
return None, "release 非法", []
if (
PLUGIN_INDEX_COMPATIBILITY_FLAG_PATTERN.fullmatch(field)
and not isinstance(value, bool)
):
return None, "兼容标志非法", []
normalized = plugin_info.copy()
if declared_id is not None:
normalized["id"] = plugin_id
dropped_fields: list[str] = []
for field, max_length in PLUGIN_INDEX_TEXT_LIMITS.items():
if field not in normalized:
continue
value = normalized[field]
if not isinstance(value, str) or len(value) > max_length:
normalized.pop(field)
dropped_fields.append(field)
labels = normalized.get("labels")
if labels is not None:
valid_labels = (
isinstance(labels, str)
and len(labels) <= 1024
) or (
isinstance(labels, list)
and len(labels) <= 64
and all(
isinstance(item, str) and len(item) <= 128
for item in labels
)
)
if not valid_labels:
normalized.pop("labels")
dropped_fields.append("labels")
history = normalized.get("history")
if history is not None and (
not isinstance(history, dict)
or len(history) > PLUGIN_INDEX_MAX_HISTORY_ENTRIES
):
normalized.pop("history")
dropped_fields.append("history")
return normalized, None, dropped_fields
@classmethod
def __parse_plugin_index_response(cls, content: str) -> Optional[PluginIndex]:
"""解析并规范化插件索引,兼容 BOM 与 plugins 列表包装格式。"""
try:
payload = json.loads(content.lstrip("\ufeff"))
except (ValueError, RecursionError):
logger.warning("插件包数据解析失败:响应不是有效 JSON")
return None
if not isinstance(payload, dict):
logger.warning(
f"插件包数据格式不正确,期望 dict实际为 {type(payload).__name__}"
)
return None
if not cls.__plugin_index_nesting_is_valid(payload):
logger.warning(
f"插件包 JSON 嵌套超过上限:{PLUGIN_INDEX_MAX_NESTING}"
)
return None
plugin_entries: list[tuple[object, object]]
wrapped_plugins = payload.get("plugins")
if isinstance(wrapped_plugins, list):
plugin_entries = [
(
plugin_info.get("id")
if isinstance(plugin_info, dict)
else None,
plugin_info,
)
for plugin_info in wrapped_plugins
]
else:
plugin_entries = list(payload.items())
if len(plugin_entries) > PLUGIN_INDEX_MAX_ENTRIES:
logger.warning(
f"插件包条目超过上限:{len(plugin_entries)} > "
f"{PLUGIN_INDEX_MAX_ENTRIES}"
)
return None
normalized: PluginIndex = {}
skipped_reasons: Counter[str] = Counter()
dropped_fields: Counter[str] = Counter()
skipped_examples: list[str] = []
for plugin_id, plugin_info in plugin_entries:
item, reason, dropped = cls.__normalize_plugin_index_entry(
plugin_id,
plugin_info,
)
if item is None:
skipped_reasons[reason or "未知原因"] += 1
if len(skipped_examples) < 5:
skipped_examples.append(cls.__safe_plugin_id(plugin_id))
continue
assert isinstance(plugin_id, str)
normalized[plugin_id] = item
dropped_fields.update(dropped)
summaries = []
if skipped_reasons:
reasons = "".join(
f"{reason} {count}"
for reason, count in sorted(skipped_reasons.items())
)
examples = "".join(skipped_examples)
summaries.append(
f"跳过 {sum(skipped_reasons.values())} 个条目({reasons};示例 {examples}"
)
if dropped_fields:
fields = "".join(
f"{field} {count}"
for field, count in sorted(dropped_fields.items())
)
summaries.append(f"丢弃异常展示字段({fields}")
if summaries:
logger.warning(f"插件包数据已隔离异常内容:{''.join(summaries)}")
return normalized
@staticmethod
def __declared_plugin_index_too_large(headers: Any) -> bool:
"""仅把合法的 Content-Length 用作流式读取前的快速拒绝依据。"""
try:
value = headers.get("Content-Length")
return value is not None and int(value) > PLUGIN_INDEX_MAX_BYTES
except (AttributeError, TypeError, ValueError):
return False
@classmethod
def __read_plugin_index_response(cls, response: Response) -> tuple[int, str]:
"""从同步流式响应读取有界的解压后索引文本。"""
status_code = response.status_code
if status_code != 200:
return status_code, ""
if cls.__declared_plugin_index_too_large(response.headers):
raise _PluginIndexTooLargeError(
f"插件索引响应超过 {PLUGIN_INDEX_MAX_BYTES} 字节"
)
content = bytearray()
for chunk in response.iter_content(chunk_size=PLUGIN_INDEX_READ_CHUNK_SIZE):
if not chunk:
continue
content.extend(chunk)
if len(content) > PLUGIN_INDEX_MAX_BYTES:
raise _PluginIndexTooLargeError(
f"插件索引响应超过 {PLUGIN_INDEX_MAX_BYTES} 字节"
)
return status_code, content.decode("utf-8", errors="replace")
@classmethod
async def __async_read_plugin_index_response(
cls,
response: httpx2.Response,
) -> tuple[int, str]:
"""从异步流式响应读取有界的解压后索引文本。"""
status_code = response.status_code
if status_code != 200:
return status_code, ""
if cls.__declared_plugin_index_too_large(response.headers):
raise _PluginIndexTooLargeError(
f"插件索引响应超过 {PLUGIN_INDEX_MAX_BYTES} 字节"
)
content = bytearray()
async for chunk in response.aiter_bytes(
chunk_size=PLUGIN_INDEX_READ_CHUNK_SIZE
):
if not chunk:
continue
content.extend(chunk)
if len(content) > PLUGIN_INDEX_MAX_BYTES:
raise _PluginIndexTooLargeError(
f"插件索引响应超过 {PLUGIN_INDEX_MAX_BYTES} 字节"
)
return status_code, content.decode("utf-8", errors="replace")
@classmethod
def _build_plugin_index_request(
cls,
repo_url: str,
package_version: Optional[str] = None,
) -> Optional[tuple[str, PluginRequestOptions]]:
"""构造插件索引请求,统一仓库解析、代际文件名和鉴权请求头。"""
if not repo_url:
return None
repo_url = normalize_plugin_market_repo_url(repo_url) or repo_url
user, repo = cls.get_repo_info(repo_url)
if not user or not repo:
return None
raw_url = cls._base_url.format(user=user, repo=repo)
package_file = (
f"package.{package_version}.json"
if package_version
else "package.json"
)
package_url = cls.__append_cache_buster(f"{raw_url}{package_file}")
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=f"{user}/{repo}")
return package_url, headers
@classmethod
def __request_plugin_index_with_fallback(
cls,
url: str,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = PLUGIN_INDEX_REQUEST_TIMEOUT,
) -> Optional[tuple[int, str]]:
"""按 GitHub 降级顺序流式读取同步插件索引,并限制解压后字节数。"""
strategies = cls._build_github_request_strategies(
url=url,
headers=headers,
timeout=timeout,
)
for strategy_name, target_url, request_params in strategies:
logger.debug(
f"[GitHub] 尝试使用策略:{strategy_name} 请求插件索引:{target_url}"
)
try:
request = RequestUtils(**request_params)
with request.get_stream(
url=target_url,
raise_exception=True,
) as response:
if response is None:
continue
return cls.__read_plugin_index_response(response)
except _PluginIndexTooLargeError:
raise
except Exception as error: # noqa: BLE001 - 失败后尝试下一传输策略
logger.error(
f"[GitHub] 插件索引请求失败,策略:{strategy_name}"
f"URL{target_url},错误:{_format_request_error(error)}"
)
logger.error(f"[GitHub] 所有策略均无法读取插件索引URL{url}")
return None
@classmethod
async def __async_request_plugin_index_with_fallback(
cls,
url: str,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = PLUGIN_INDEX_REQUEST_TIMEOUT,
) -> Optional[tuple[int, str]]:
"""按 GitHub 降级顺序流式读取异步插件索引,并限制解压后字节数。"""
strategies = cls._build_github_request_strategies(
url=url,
headers=headers,
timeout=timeout,
)
for strategy_name, target_url, request_params in strategies:
logger.debug(
f"[GitHub] 尝试使用策略:{strategy_name} 请求插件索引:{target_url}"
)
try:
request = AsyncRequestUtils(**request_params)
async with request.get_stream(
url=target_url,
raise_exception=True,
) as response:
if response is None:
continue
return await cls.__async_read_plugin_index_response(response)
except _PluginIndexTooLargeError:
raise
except Exception as error: # noqa: BLE001 - 失败后尝试下一传输策略
logger.error(
f"[GitHub] 插件索引请求失败,策略:{strategy_name}"
f"URL{target_url},错误:{_format_request_error(error)}"
)
logger.error(f"[GitHub] 所有策略均无法读取插件索引URL{url}")
return None
@classmethod
def _resolve_plugin_index_response(
cls,
status_code: int,
content: str,
) -> Optional[PluginIndex]:
"""统一解释插件索引 HTTP 响应,保留不存在、失败和有效索引三态。"""
try:
payload = cls._resolve_plugin_index_result((status_code, content))
except RuntimeError:
return None
return payload if payload is not None else {}
@classmethod
def _resolve_plugin_index_result(
cls,
response: Optional[tuple[int, str]],
) -> Optional[PluginIndex]:
"""
把传输结果统一映射为索引读取合同。
:param response: 传输层返回的状态码和已受限响应文本None 表示连接失败
:return: 有效索引,或以 None 表示仓库明确不存在该索引
:raises RuntimeError: 连接失败、非成功状态或响应格式无效
"""
if response is None:
raise RuntimeError("插件索引请求失败:连接失败")
status_code, content = response
if status_code == 404:
return None
if status_code != 200:
raise RuntimeError(f"插件索引请求失败HTTP {status_code}")
payload = cls.__parse_plugin_index_response(content)
if payload is None:
raise RuntimeError("插件索引响应格式无效")
return payload
@staticmethod
def __build_plugin_release_item(
pid: str, release_info: PluginPayload
) -> Optional[PluginPayload]:
"""
从 GitHub release 响应中提取可安装版本,仅接受规范 tag 与同名 zip 资产。
"""
if not isinstance(release_info, dict):
return None
tag_name = release_info.get("tag_name")
if not isinstance(tag_name, str):
return None
tag_prefix = f"{pid}_v"
if not tag_name.startswith(tag_prefix):
return None
version = tag_name[len(tag_prefix):]
if not version:
return None
asset_name = f"{tag_name.lower()}.zip"
assets = release_info.get("assets") or []
if not any(isinstance(asset, dict) and asset.get("name") == asset_name for asset in assets):
return None
return {
"version": version,
"tag_name": tag_name,
"name": release_info.get("name") or tag_name,
"published_at": release_info.get("published_at"),
"body": release_info.get("body") or "",
"asset_name": asset_name,
}
@staticmethod
def __parse_plugin_release_response(
pid: str, payload: object
) -> PluginReleaseList:
"""
解析 GitHub release 列表,过滤出当前插件可直接安装的 release 资产。
"""
if not isinstance(payload, list):
return []
releases: PluginReleaseList = []
for release_info in payload:
item = PluginMarketTransport.__build_plugin_release_item(pid, release_info)
if item:
releases.append(item)
return releases
@staticmethod
def __normalize_plugin_release_response(
payload: object,
) -> PluginReleaseList:
"""仅保留版本展示和资产匹配所需字段,控制仓库级缓存体积。"""
if not isinstance(payload, list):
return []
return [
{
"tag_name": release_info.get("tag_name"),
"name": release_info.get("name"),
"published_at": release_info.get("published_at"),
"body": release_info.get("body"),
"assets": [
{"name": asset.get("name")}
for asset in release_info.get("assets") or []
if isinstance(asset, dict)
],
}
for release_info in payload
if isinstance(release_info, dict)
]
@classmethod
def _iter_plugin_release_page_requests(
cls,
repo_url: str,
) -> Iterator[tuple[str, PluginRequestOptions]]:
"""按需生成仓库 Release 分页请求,统一仓库解析、请求头和页数上限。"""
if not repo_url:
return
user, repo = cls.get_repo_info(repo_url)
if not user or not repo:
return
user_repo = f"{user}/{repo}"
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo)
for page in range(1, 11):
release_api = (
f"https://api.github.com/repos/{user_repo}/releases"
f"?per_page=100&page={page}"
)
yield cls.__append_cache_buster(release_api), headers
@classmethod
def _merge_plugin_release_page(
cls,
repo_url: str,
response: Any,
releases: PluginReleaseList,
) -> Optional[bool]:
"""合并一页 Release 响应返回真继续、假结束None 表示整次读取失败。"""
if response is None or response.status_code != 200:
return None
try:
payload = response.json()
except Exception as error:
logger.error(f"解析插件仓库 {repo_url} Release 列表失败:{error}")
return None
if not payload:
return False
if not isinstance(payload, list):
return None
releases.extend(cls.__normalize_plugin_release_response(payload))
return len(payload) >= 100
@staticmethod
def _plugin_index_key(
repo_url: str,
package_version: Optional[str],
) -> tuple[str, str]:
"""生成跨 URL 表示形式稳定的插件索引请求键。"""
normalized_repo = normalize_plugin_market_repo_url(repo_url)
return (
(normalized_repo or str(repo_url).strip().rstrip("/")).lower(),
package_version or "",
)
@classmethod
def _remove_index_future(
cls,
key: tuple[str, str],
future: Future[Optional[PluginIndex]],
) -> None:
"""同步索引请求结束后释放 single-flight 占位。"""
with cls._index_future_lock:
if cls._index_futures.get(key) is future:
cls._index_futures.pop(key, None)
@classmethod
def _remove_index_task(
cls,
key: tuple[asyncio.AbstractEventLoop, str, str],
task: asyncio.Future[PluginIndexTaskResult],
) -> None:
"""异步索引请求结束后释放事件循环和仓库引用。"""
with cls._index_task_lock:
if cls._index_tasks.get(key) is task:
cls._index_tasks.pop(key, None)
# 调用方可能在请求完成前取消等待;主动读取异常避免事件循环告警,
# 不影响其他仍在等待同一个任务的调用方取得原始异常。
try:
task.exception()
except BaseException: # noqa: BLE001 - 仅消费已完成任务的异常
pass
@staticmethod
async def _acquire_index_fetch_slot() -> None:
"""以可取消方式等待跨同步/异步调用共享的索引请求闸门。"""
while not _PLUGIN_INDEX_FETCH_GATE.acquire(blocking=False):
await asyncio.sleep(0.01)
def _fetch_plugin_index_sync(
self,
package_url: str,
headers: Optional[dict[str, str]],
) -> Optional[PluginIndex]:
"""在共享进程级闸门内完成一次同步索引读取。"""
with _PLUGIN_INDEX_FETCH_GATE:
response = self.__request_plugin_index_with_fallback(
package_url,
headers=headers,
)
return self._resolve_plugin_index_result(response)
async def _fetch_plugin_index_async(
self,
package_url: str,
headers: Optional[dict[str, str]],
) -> Optional[PluginIndex]:
"""在共享进程级闸门内完成一次异步索引读取。"""
await self._acquire_index_fetch_slot()
try:
response = await self.__async_request_plugin_index_with_fallback(
package_url,
headers=headers,
)
finally:
_PLUGIN_INDEX_FETCH_GATE.release()
return self._resolve_plugin_index_result(response)
async def _fetch_plugin_index_async_task(
self,
package_url: str,
headers: Optional[dict[str, str]],
) -> PluginIndexTaskResult:
"""把预期的索引读取失败转成共享任务结果,交由调用方记录仓库失败。"""
try:
return await self._fetch_plugin_index_async(package_url, headers), None
except Exception as error: # noqa: BLE001 - 结果由读取调用方统一转换
return None, error
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
def get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
) -> Optional[PluginIndex]:
"""读取插件索引404 返回 None读取失败由调用方记录。"""
request = self._build_plugin_index_request(repo_url, package_version)
if request is None:
raise ValueError("插件仓库地址无效")
package_url, headers = request
key = self._plugin_index_key(repo_url, package_version)
with self._index_future_lock:
future = self._index_futures.get(key)
if future is None:
future = Future[Optional[PluginIndex]]()
self._index_futures[key] = future
owner = True
else:
owner = False
assert future is not None
if not owner:
return future.result()
try:
result = self._fetch_plugin_index_sync(package_url, headers)
except BaseException as error: # noqa: BLE001 - 共享结果必须传播原始异常
future.set_exception(error)
future.exception()
raise
else:
future.set_result(result)
return result
finally:
self._remove_index_future(key, future)
def get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[PluginIndex]:
"""
获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
:param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
"""
try:
payload = self.get_plugin_index_result(repo_url, package_version)
except (ValueError, RuntimeError):
return None
return payload if payload is not None else {}
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases") # type: ignore[misc]
def _get_plugin_repo_releases(
self, repo_url: str
) -> Optional[PluginReleaseList]:
"""
按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。
"""
releases: PluginReleaseList = []
for release_api, headers in self._iter_plugin_release_page_requests(
repo_url
):
res = self.__request_with_fallback(
release_api,
headers=headers,
timeout=30,
is_api=True,
)
should_continue = self._merge_plugin_release_page(
repo_url,
res,
releases,
)
if should_continue is None:
return None
if not should_continue:
break
return releases
def get_plugin_release_versions(
self, pid: str, repo_url: str
) -> PluginReleaseList:
"""
获取插件可安装的 GitHub Release 版本列表。
GitHub 分页结果按仓库缓存,插件 ID 只参与本地过滤,避免同仓库重复分页。
"""
if not pid or not repo_url:
return []
return self.__parse_plugin_release_response(pid, self._get_plugin_repo_releases(repo_url.rstrip("/")))
def get_plugin_package_version(self, pid: str, repo_url: str,
package_version: Optional[str] = None) -> Optional[str]:
"""
检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测
1. 如果未指定版本,则使用系统配置的默认版本(通过 get_runtime_setting('VERSION_FLAG') 设置)
2. 优先检查指定版本的插件(如 `package.v2.json`
3. 检查更低版本的 package 文件,并应用版本兼容标志
4. 检查 `package.json` 文件,并应用共享实现兼容标志
5. 如果插件不存在或不兼容指定版本,返回 `None`
:param pid: 插件 ID用于在插件列表中查找
:param repo_url: 插件仓库的 URL指定用于获取插件信息的 GitHub 仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
:return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None
"""
for candidate in self._package_version_candidates(package_version):
selected = self._select_compatible_package_version(
pid=pid,
package_version=candidate,
plugins=self.get_plugins(repo_url, candidate or None),
)
if selected is not None:
return selected
# 如果所有版本都不存在或插件不兼容,返回 None表示插件不可用
return None
@staticmethod
def get_repo_info(repo_url: str) -> tuple[Optional[str], Optional[str]]:
"""
获取GitHub仓库信息
"""
if not repo_url:
return None, None
if not repo_url.endswith("/"):
repo_url += "/"
if repo_url.count("/") < 6:
repo_url = f"{repo_url}main/"
try:
user, repo = repo_url.split("/")[-4:-2]
except Exception as e:
logger.error(f"解析GitHub仓库地址失败{str(e)} - {traceback.format_exc()}")
return None, None
return user, repo
@staticmethod
def _build_github_request_strategies(
url: str,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = 60,
is_api: bool = False,
) -> list[tuple[str, str, PluginRequestOptions]]:
"""构造同步与异步 GitHub 请求共用的镜像和单一出口顺序。"""
strategies: list[tuple[str, str, PluginRequestOptions]] = []
if not is_api and get_runtime_setting('GITHUB_PROXY'):
proxy_url = (
f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{url}"
)
strategies.append(
("镜像站", proxy_url, {"headers": headers, "timeout": timeout})
)
if get_runtime_setting('PROXY_HOST'):
strategies.append(
(
"代理",
url,
{
"headers": headers,
"proxies": get_runtime_setting('PROXY'),
"timeout": timeout,
},
)
)
else:
strategies.append(
("直连", url, {"headers": headers, "timeout": timeout})
)
return strategies
@staticmethod
def __request_with_fallback(url: str,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = 60,
is_api: bool = False) -> Optional[Response]:
"""
使用自动降级策略,请求资源:可选镜像站后只使用一个出口;
显式配置代理时不再追加无效直连。
:param url: 目标URL
:param headers: 请求头信息
:param timeout: 请求超时时间
:param is_api: 是否为GitHub API请求API请求不走镜像站
:return: 请求成功则返回 Response失败返回 None
"""
strategies = PluginMarketTransport._build_github_request_strategies(
url=url,
headers=headers,
timeout=timeout,
is_api=is_api,
)
# 遍历策略并尝试请求
for strategy_name, target_url, request_params in strategies:
logger.debug(f"[GitHub] 尝试使用策略:{strategy_name} 请求 URL{target_url}")
try:
res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}")
return res
except Exception as e:
logger.error(
f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url}"
f"错误:{_format_request_error(e)}"
)
logger.error(f"[GitHub] 所有策略均请求失败URL: {url},请检查网络连接或 GitHub 配置")
return None
@staticmethod
def request_with_fallback(
url: str,
*,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = 60,
is_api: bool = False,
) -> Optional[Response]:
"""向包来源客户端公开既有同步 GitHub 降级传输。"""
return PluginMarketTransport.__request_with_fallback(
url, headers=headers, timeout=timeout, is_api=is_api
)
def __get_plugin_meta(
self, pid: str, repo_url: str, package_version: Optional[str]
) -> PluginPayload:
"""读取远端插件元数据,供兼容性查询保留旧私有入口。"""
try:
plugins = self.get_plugins(repo_url, package_version or None) or {}
meta = plugins.get(pid)
return meta if isinstance(meta, dict) else {}
except Exception as error:
logger.error(f"获取插件 {pid} 元数据失败:{error}")
return {}
def get_plugin_system_version_check_message(self, pid: str, repo_url: str) -> Optional[str]:
"""
获取指定插件来源的主系统版本兼容错误;兼容或无法定位元数据时返回 None。
"""
if not pid or not repo_url:
return None
if self.is_local_repo_url(repo_url):
candidate = self.get_local_plugin_candidate(
pid=pid,
package_version=self.parse_local_repo_package_version(repo_url),
repo_path=self.parse_local_repo_path(repo_url),
strict_compat=False
)
if not candidate:
return None
compatible, message = self.check_plugin_system_version(candidate)
return None if compatible else message
package_version = self.get_plugin_package_version(pid, repo_url, get_runtime_setting('VERSION_FLAG'))
if package_version is None:
return None
meta = self.__get_plugin_meta(pid, repo_url, package_version)
compatible, message = self.check_plugin_system_version(meta)
return None if compatible else message
async def async_get_plugin_system_version_check_message(self, pid: str, repo_url: str) -> Optional[str]:
"""
异步获取指定插件来源的主系统版本兼容错误;兼容或无法定位元数据时返回 None。
"""
if not pid or not repo_url:
return None
if self.is_local_repo_url(repo_url):
return await asyncio.to_thread(self.get_plugin_system_version_check_message, pid, repo_url)
package_version = await self.async_get_plugin_package_version(pid, repo_url, get_runtime_setting('VERSION_FLAG'))
if package_version is None:
return None
meta = await self.__async_get_plugin_meta(pid, repo_url, package_version)
compatible, message = self.check_plugin_system_version(meta)
return None if compatible else message
async def async_get_plugin_package_version(self, pid: str, repo_url: str,
package_version: Optional[str] = None) -> Optional[str]:
"""
异步版本的获取插件版本方法,功能同 get_plugin_package_version
"""
for candidate in self._package_version_candidates(package_version):
selected = self._select_compatible_package_version(
pid=pid,
package_version=candidate,
plugins=await self.async_get_plugins(
repo_url,
candidate or None,
),
)
if selected is not None:
return selected
return None
@staticmethod
async def __async_request_with_fallback(url: str,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = 60,
is_api: bool = False) -> Optional[httpx2.Response]:
"""
使用自动降级策略,异步请求资源:可选镜像站后只使用一个出口;
显式配置代理时不再追加无效直连。
:param url: 目标URL
:param headers: 请求头信息
:param timeout: 请求超时时间
:param is_api: 是否为GitHub API请求API请求不走镜像站
:return: 请求成功则返回 Response失败返回 None
"""
strategies = PluginMarketTransport._build_github_request_strategies(
url=url,
headers=headers,
timeout=timeout,
is_api=is_api,
)
# 遍历策略并尝试请求
for strategy_name, target_url, request_params in strategies:
logger.debug(f"[GitHub] 尝试使用策略:{strategy_name} 请求 URL{target_url}")
try:
res = await AsyncRequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}")
return res
except Exception as e:
logger.error(
f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url}"
f"错误:{_format_request_error(e)}"
)
logger.error(f"[GitHub] 所有策略均请求失败URL: {url},请检查网络连接或 GitHub 配置")
return None
@staticmethod
async def async_request_with_fallback(
url: str,
*,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = 60,
is_api: bool = False,
) -> Optional[httpx2.Response]:
"""向包来源客户端公开既有异步 GitHub 降级传输。"""
return await PluginMarketTransport.__async_request_with_fallback(
url, headers=headers, timeout=timeout, is_api=is_api
)
async def __async_get_plugin_meta(
self, pid: str, repo_url: str, package_version: Optional[str]
) -> PluginPayload:
"""异步读取远端插件元数据,供兼容性查询保留旧私有入口。"""
try:
plugins = await self.async_get_plugins(
repo_url, package_version or None
) or {}
meta = plugins.get(pid)
return meta if isinstance(meta, dict) else {}
except Exception as error:
logger.warning(f"获取插件 {pid} 元数据失败:{error}")
return {}
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
async def async_get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
) -> Optional[PluginIndex]:
"""异步读取插件索引404 返回 None读取失败由调用方记录。"""
request = self._build_plugin_index_request(repo_url, package_version)
if request is None:
raise ValueError("插件仓库地址无效")
package_url, headers = request
repo_key, generation_key = self._plugin_index_key(
repo_url,
package_version,
)
loop = asyncio.get_running_loop()
task_key = (loop, repo_key, generation_key)
with self._index_task_lock:
task = self._index_tasks.get(task_key)
if task is None:
task = cast(
asyncio.Task[PluginIndexTaskResult],
get_task_registry().create(
self._fetch_plugin_index_async_task(
package_url,
headers,
),
owner="plugin.market.index",
),
)
self._index_tasks[task_key] = task
def on_index_task_done(
completed_task: asyncio.Future[PluginIndexTaskResult],
) -> None:
self._remove_index_task(task_key, completed_task)
task.add_done_callback(on_index_task_done)
# 单个调用方取消等待时不能连带取消共享请求。
result, error = await asyncio.shield(task)
if error is not None:
raise error
return result
async def async_get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[PluginIndex]:
"""
异步获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
:param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
"""
try:
payload = await self.async_get_plugin_index_result(
repo_url,
package_version,
)
except (ValueError, RuntimeError):
return None
return payload if payload is not None else {}
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases") # type: ignore[misc]
async def _async_get_plugin_repo_releases(
self, repo_url: str
) -> Optional[PluginReleaseList]:
"""
异步按仓库获取 GitHub Release 原始分页数据。
"""
releases: PluginReleaseList = []
for release_api, headers in self._iter_plugin_release_page_requests(
repo_url
):
res = await self.__async_request_with_fallback(
release_api,
headers=headers,
timeout=30,
is_api=True,
)
should_continue = self._merge_plugin_release_page(
repo_url,
res,
releases,
)
if should_continue is None:
return None
if not should_continue:
break
return releases
async def async_get_plugin_release_versions(
self, pid: str, repo_url: str
) -> PluginReleaseList:
"""
异步获取插件可安装的 GitHub Release 版本列表。
同一事件循环内,同仓库的并发读取和强制刷新共享一个请求任务。
"""
if not pid or not repo_url:
return []
loop = asyncio.get_running_loop()
normalized_repo_url = repo_url.rstrip("/")
normal_task_key = (loop, normalized_repo_url, False)
force_task_key = (loop, normalized_repo_url, True)
with self._release_task_lock:
if is_fresh():
force_task = self._release_tasks.get(force_task_key)
if force_task and not force_task.done():
task_key = force_task_key
task = force_task
else:
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task and pending_normal_task.done():
pending_normal_task = None
task_key = force_task_key
task = get_task_registry().create(
self._async_refresh_plugin_repo_releases(
normalized_repo_url,
pending_normal_task,
),
owner="plugin.market.release_refresh",
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task_key = normal_task_key
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task is None or pending_normal_task.done():
task = get_task_registry().create(
self._async_get_plugin_repo_releases(normalized_repo_url),
owner="plugin.market.release_read",
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task = pending_normal_task
payload = await asyncio.shield(task)
return self.__parse_plugin_release_response(pid, payload)
async def async_has_plugin_release_cache(self, repo_url: str) -> bool:
"""
判断指定仓库的 Release 列表缓存是否已经存在。
"""
if not repo_url:
return False
return bool(
await self._async_get_plugin_repo_releases.cache_exists(
self, repo_url.rstrip("/")
)
)
async def _async_refresh_plugin_repo_releases(
self,
repo_url: str,
pending_normal_task: Optional[PluginReleaseTask],
) -> Optional[PluginReleaseList]:
"""等待在途普通读取落盘后执行强刷,确保旧结果不会覆盖强刷缓存。"""
if pending_normal_task:
try:
await asyncio.shield(pending_normal_task)
except (Exception, asyncio.CancelledError):
pass
return cast(
Optional[PluginReleaseList],
await self._async_get_plugin_repo_releases(repo_url),
)
@classmethod
def _remove_release_task(
cls,
task_key: tuple[asyncio.AbstractEventLoop, str, bool],
task: PluginReleaseTask,
) -> None:
"""请求任务完成后释放事件循环和仓库引用。"""
with cls._release_task_lock:
if cls._release_tasks.get(task_key) is task:
cls._release_tasks.pop(task_key, None)
class PluginMarketClient:
"""把插件市场、版本元数据和本地仓库查询隔离为只读客户端。"""
def __init__(self, transport: Optional[PluginMarketTransport] = None) -> None:
"""保存索引传输端口;默认实现必须先由兼容市场模块显式注册。"""
self._transport = transport or PluginMarketTransport()
def get_plugins(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[PluginIndex]:
"""同步读取指定仓库和代际的插件索引。"""
with fresh(force):
return self._transport.get_plugins(repo_url, package_version)
async def async_get_plugins(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[PluginIndex]:
"""异步读取指定仓库和代际的插件索引。"""
async with async_fresh(force):
return await self._transport.async_get_plugins(repo_url, package_version)
def get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[PluginIndex]:
"""读取插件索引的三态结果,供库存读取保留失败事实。"""
with fresh(force):
return cast(
Optional[PluginIndex],
self._transport.get_plugin_index_result(repo_url, package_version),
)
async def async_get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[PluginIndex]:
"""异步读取插件索引的三态结果,供库存读取保留失败事实。"""
async with async_fresh(force):
return cast(
Optional[PluginIndex],
await self._transport.async_get_plugin_index_result(
repo_url,
package_version,
),
)
def get_local_candidates(self) -> PluginIndex:
"""返回全部本地插件仓库候选。"""
return self._transport.get_local_plugin_candidates()
def get_local_candidate(
self,
plugin_id: str,
package_version: Optional[str] = None,
repo_path: Optional[Path] = None,
**kwargs: Any,
) -> Optional[PluginPayload]:
"""返回指定插件的本地仓库候选。"""
return self._transport.get_local_plugin_candidate(
pid=plugin_id,
package_version=package_version,
repo_path=repo_path,
**kwargs,
)
@staticmethod
def get_local_repo_paths() -> list[Path]:
"""返回配置中有效的本地插件仓库目录。"""
value = get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS')
if not value:
return []
root_path = Path(get_runtime_setting('ROOT_PATH'))
paths: list[Path] = []
for item in value.split(","):
local_repo_path = item.strip()
if not local_repo_path:
continue
path = Path(local_repo_path).expanduser()
if not path.is_absolute():
path = root_path / path
paths.append(path.resolve())
return paths
@staticmethod
def make_local_repo_url(
plugin_id: str,
repo_path: Optional[object] = None,
package_version: Optional[str] = None,
) -> str:
"""生成兼容旧入口的本地插件来源标识。"""
return build_local_repo_url(
plugin_id,
repo_path=cast(Optional[Path], repo_path),
package_version=package_version,
)
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""判断插件来源是否为本地仓库标识。"""
return is_local_plugin_source(repo_url)
@staticmethod
def annotate_system_version(plugin_info: PluginPayload) -> PluginPayload:
"""补充插件所需 MoviePilot 版本兼容状态。"""
compatible, message = check_plugin_system_version(
plugin_info,
current_version=get_app_version(),
)
plugin_info["system_version_compatible"] = compatible
plugin_info["system_version_message"] = message
return plugin_info
@staticmethod
def is_package_compatible(
plugin_info: PluginPayload,
package_version: Optional[str],
) -> bool:
"""判断插件条目是否兼容目标插件包代际。"""
return is_plugin_generation_compatible(
plugin_info,
package_version,
current_generation=get_runtime_setting('VERSION_FLAG'),
free_threaded=is_free_threaded_runtime(),
)
class PluginPackageSourceClient:
"""为系统包 owner 提供只读市场元数据和远端制品传输。"""
def __init__(self, transport: Optional[PluginMarketTransport] = None) -> None:
"""保存由组合根注入的市场传输,不导入兼容 Helper 实现。"""
self._transport = transport or PluginMarketTransport()
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""判断来源是否为本地仓库标识。"""
return is_local_repo_url(repo_url)
@staticmethod
def parse_local_repo_url(repo_url: str) -> Optional[str]:
"""解析本地仓库来源中的插件标识。"""
return parse_local_repo_plugin_id(repo_url)
@staticmethod
def parse_local_repo_path(repo_url: str) -> Optional[Path]:
"""解析本地仓库来源中的物理路径。"""
root = get_runtime_setting('ROOT_PATH')
return parse_local_repo_path(
repo_url,
root_path=Path(root) if root is not None else None,
)
@staticmethod
def parse_local_repo_package_version(repo_url: str) -> Optional[str]:
"""解析本地仓库来源中的包代际。"""
return parse_local_repo_generation(repo_url)
@staticmethod
def make_local_repo_url(
plugin_id: str,
repo_path: Optional[Path] = None,
package_version: Optional[str] = None,
) -> str:
"""生成本地仓库来源标识。"""
return build_local_repo_url(
plugin_id,
repo_path=repo_path,
package_version=package_version,
)
def get_local_plugin_candidate(
self,
pid: str,
package_version: Optional[str] = None,
repo_path: Optional[Path] = None,
strict_compat: bool = True,
strict_system_version: bool = True,
) -> Optional[PluginPayload]:
"""读取一个本地插件候选。"""
return self._transport.get_local_plugin_candidate(
pid=pid,
package_version=package_version,
repo_path=repo_path,
strict_compat=strict_compat,
strict_system_version=strict_system_version,
)
@staticmethod
def check_plugin_system_version(
plugin_info: PluginPayload,
) -> tuple[bool, str]:
"""校验插件声明的宿主版本约束。"""
return check_plugin_system_version(
plugin_info, current_version=get_app_version()
)
def get_plugin_package_version(
self,
plugin_id: str,
repo_url: str,
package_version: Optional[str],
) -> Optional[str]:
"""选择适用于当前宿主的远端索引代际。"""
return self._transport.get_plugin_package_version(
plugin_id, repo_url, package_version
)
async def async_get_plugin_package_version(
self,
plugin_id: str,
repo_url: str,
package_version: Optional[str],
) -> Optional[str]:
"""异步选择适用于当前宿主的远端索引代际。"""
return await self._transport.async_get_plugin_package_version(
plugin_id, repo_url, package_version
)
def get_plugins(
self, repo_url: str, package_version: Optional[str] = None
) -> Optional[PluginIndex]:
"""读取远端插件索引。"""
return self._transport.get_plugins(repo_url, package_version)
async def async_get_plugins(
self, repo_url: str, package_version: Optional[str] = None
) -> Optional[PluginIndex]:
"""异步读取远端插件索引。"""
return await self._transport.async_get_plugins(repo_url, package_version)
def get_plugin_release_versions(
self, plugin_id: str, repo_url: str
) -> list[dict[str, Any]]:
"""读取插件可安装 Release。"""
return self._transport.get_plugin_release_versions(plugin_id, repo_url)
async def async_get_plugin_release_versions(
self, plugin_id: str, repo_url: str
) -> list[dict[str, Any]]:
"""异步读取插件可安装 Release。"""
return await self._transport.async_get_plugin_release_versions(
plugin_id, repo_url
)
@staticmethod
def get_repo_info(repo_url: str) -> tuple[Optional[str], Optional[str]]:
"""解析 GitHub 仓库所有者和仓库名。"""
if not repo_url:
return None, None
parts = repo_url.rstrip("/").split("/")
if len(parts) < 5:
return None, None
return parts[-2], parts[-1]
def request(
self,
url: str,
*,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = 60,
is_api: bool = False,
) -> Any:
"""同步读取 GitHub 元数据或制品。"""
return self._transport.request_with_fallback(
url, headers=headers, timeout=timeout, is_api=is_api
)
async def async_request(
self,
url: str,
*,
headers: Optional[dict[str, str]] = None,
timeout: Optional[int] = 60,
is_api: bool = False,
) -> Any:
"""异步读取 GitHub 元数据或制品。"""
return await self._transport.async_request_with_fallback(
url, headers=headers, timeout=timeout, is_api=is_api
)
setattr(
PluginMarketTransport.get_plugin_release_versions,
"cache_clear",
getattr(PluginMarketTransport._get_plugin_repo_releases, "cache_clear"),
)
setattr(
PluginMarketTransport.get_plugin_release_versions,
"cache_region",
getattr(PluginMarketTransport._get_plugin_repo_releases, "cache_region"),
)
setattr(
PluginMarketTransport.async_get_plugin_release_versions,
"cache_clear",
getattr(PluginMarketTransport._async_get_plugin_repo_releases, "cache_clear"),
)
setattr(
PluginMarketTransport.async_get_plugin_release_versions,
"cache_region",
getattr(PluginMarketTransport._async_get_plugin_repo_releases, "cache_region"),
)