mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-10 15:50:24 +08:00
* fix(plugin): bound market metadata inputs * test(architecture): sync plugin metadata baseline * fix(plugin): narrow validated metadata identifiers * test(architecture): sync coverage baseline
240 lines
8.2 KiB
Python
240 lines
8.2 KiB
Python
"""插件来源、代际与安装计划的纯领域规则。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections.abc import Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
||
|
||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||
from packaging.version import InvalidVersion, Version
|
||
|
||
LOCAL_PLUGIN_SOURCE_PREFIX = "local://"
|
||
PLUGIN_SYSTEM_VERSION_FIELD = "system_version"
|
||
_PHYSICAL_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,127}$")
|
||
PLUGIN_GENERATION_COMPATIBILITY: dict[str, tuple[str, ...]] = {
|
||
"v3": ("v2",),
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PluginReleaseInstallPlan:
|
||
"""描述远端插件内容准备模式,不持有同步或异步 I/O 实现。"""
|
||
|
||
release_tag: str | None
|
||
fallback_to_filelist: bool
|
||
|
||
|
||
def is_physical_plugin_id(plugin_id: object) -> bool:
|
||
"""判断值是否符合物理插件在市场、目录和来源身份中共用的 ID 规则。"""
|
||
return bool(
|
||
isinstance(plugin_id, str)
|
||
and plugin_id == plugin_id.strip()
|
||
and _PHYSICAL_PLUGIN_ID_PATTERN.fullmatch(plugin_id)
|
||
)
|
||
|
||
|
||
def is_local_plugin_source(repo_url: str | None) -> bool:
|
||
"""判断插件来源是否为宿主定义的不透明本地仓库标识。"""
|
||
return bool(repo_url and repo_url.startswith(LOCAL_PLUGIN_SOURCE_PREFIX))
|
||
|
||
|
||
def build_local_plugin_source(
|
||
plugin_id: str,
|
||
*,
|
||
repo_path: str | None = None,
|
||
package_generation: str | None = None,
|
||
) -> str:
|
||
"""生成本地插件来源标识,不解释或访问其中的宿主路径。"""
|
||
repo_url = f"{LOCAL_PLUGIN_SOURCE_PREFIX}{quote(plugin_id, safe='')}"
|
||
parameters: list[str] = []
|
||
if repo_path:
|
||
parameters.append(f"path={quote(repo_path, safe='/:~')}")
|
||
if package_generation:
|
||
parameters.append(f"version={quote(package_generation, safe='')}")
|
||
return f"{repo_url}?{'&'.join(parameters)}" if parameters else repo_url
|
||
|
||
|
||
def parse_local_plugin_reference(repo_url: str) -> str | None:
|
||
"""从不透明本地来源标识中提取插件 ID。"""
|
||
if not is_local_plugin_source(repo_url):
|
||
return None
|
||
try:
|
||
parsed = urlsplit(repo_url)
|
||
plugin_id = unquote(parsed.netloc or parsed.path.strip("/"))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return plugin_id or None
|
||
|
||
|
||
def parse_local_plugin_path(repo_url: str) -> str | None:
|
||
"""从本地来源标识中提取未解释的仓库路径字符串。"""
|
||
if not is_local_plugin_source(repo_url):
|
||
return None
|
||
try:
|
||
values = parse_qs(urlsplit(repo_url).query).get("path")
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return values[0] if values else None
|
||
|
||
|
||
def parse_local_plugin_generation(repo_url: str) -> str | None:
|
||
"""从本地来源标识中提取 package 代际。"""
|
||
if not is_local_plugin_source(repo_url):
|
||
return None
|
||
try:
|
||
values = parse_qs(urlsplit(repo_url).query).get("version")
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return values[0] if values else None
|
||
|
||
|
||
def compatible_plugin_generations(current_generation: str | None) -> tuple[str, ...]:
|
||
"""返回当前宿主允许读取的插件代际,按优先级去重。"""
|
||
if not current_generation:
|
||
return ()
|
||
return tuple(
|
||
dict.fromkeys(
|
||
(
|
||
current_generation,
|
||
*PLUGIN_GENERATION_COMPATIBILITY.get(current_generation, ()),
|
||
)
|
||
)
|
||
)
|
||
|
||
|
||
def plugin_generation_candidates(
|
||
requested_generation: str | None,
|
||
*,
|
||
current_generation: str | None,
|
||
) -> tuple[str, ...]:
|
||
"""返回安装读取唯一的代际候选顺序,并把基础索引固定在末尾。"""
|
||
preferred_generation = requested_generation or current_generation
|
||
candidates = [preferred_generation]
|
||
candidates.extend(
|
||
PLUGIN_GENERATION_COMPATIBILITY.get(preferred_generation or "", ())
|
||
)
|
||
candidates.append("")
|
||
return tuple(dict.fromkeys(candidate or "" for candidate in candidates))
|
||
|
||
|
||
def is_plugin_generation_compatible(
|
||
plugin_info: object,
|
||
package_generation: str | None,
|
||
*,
|
||
current_generation: str | None,
|
||
free_threaded: bool,
|
||
) -> bool:
|
||
"""按当前代际、兼容代际和基础索引规则判断一个市场条目。"""
|
||
if not isinstance(plugin_info, Mapping):
|
||
return False
|
||
if free_threaded and plugin_info.get("v3t") is False:
|
||
return False
|
||
if not current_generation:
|
||
return not package_generation
|
||
if plugin_info.get(current_generation) is False:
|
||
return False
|
||
if package_generation == current_generation:
|
||
return True
|
||
if package_generation in PLUGIN_GENERATION_COMPATIBILITY.get(
|
||
current_generation, ()
|
||
):
|
||
return plugin_info.get(current_generation) is not False
|
||
if package_generation:
|
||
return False
|
||
if plugin_info.get(current_generation) is True:
|
||
return True
|
||
return any(
|
||
plugin_info.get(generation) is True
|
||
for generation in PLUGIN_GENERATION_COMPATIBILITY.get(
|
||
current_generation, ()
|
||
)
|
||
)
|
||
|
||
|
||
def check_plugin_system_version(
|
||
plugin_info: object,
|
||
*,
|
||
current_version: str,
|
||
) -> tuple[bool, str]:
|
||
"""检查插件声明的 MoviePilot PEP 440 版本范围。"""
|
||
if not isinstance(plugin_info, Mapping):
|
||
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"
|
||
)
|
||
try:
|
||
system_version = Version(current_version)
|
||
except InvalidVersion:
|
||
return False, (
|
||
f"当前 MoviePilot 版本 {current_version} 无法解析,"
|
||
f"已拒绝安装带版本限制的插件"
|
||
)
|
||
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},当前版本 {current_version} 不满足,"
|
||
f"已拒绝安装"
|
||
)
|
||
|
||
|
||
def build_plugin_release_install_plan(
|
||
*,
|
||
plugin_id: str,
|
||
metadata: Mapping[str, Any],
|
||
release_version: str | None,
|
||
release_items: Sequence[Mapping[str, Any]],
|
||
current_version: str,
|
||
) -> tuple[PluginReleaseInstallPlan | None, str]:
|
||
"""统一选择指定 Release、可回退当前 Release 或文件列表安装模式。"""
|
||
is_release = metadata.get("release")
|
||
plugin_version = metadata.get("version")
|
||
if release_version:
|
||
if not is_release:
|
||
return None, f"{plugin_id} 未声明 Release 安装,无法安装指定版本"
|
||
if not any(
|
||
item.get("version") == release_version for item in release_items
|
||
):
|
||
return None, f"{plugin_id} 未找到可安装的 Release 版本:{release_version}"
|
||
if release_version == plugin_version:
|
||
compatible, message = check_plugin_system_version(
|
||
metadata,
|
||
current_version=current_version,
|
||
)
|
||
if not compatible:
|
||
return None, message
|
||
return PluginReleaseInstallPlan(
|
||
release_tag=f"{plugin_id}_v{release_version}",
|
||
fallback_to_filelist=False,
|
||
), ""
|
||
compatible, message = check_plugin_system_version(
|
||
metadata,
|
||
current_version=current_version,
|
||
)
|
||
if not compatible:
|
||
return None, message
|
||
if not is_release:
|
||
return PluginReleaseInstallPlan(None, False), ""
|
||
if not plugin_version:
|
||
return None, (
|
||
f"未在插件清单中找到 {plugin_id} 的版本号,无法进行 Release 安装"
|
||
)
|
||
return PluginReleaseInstallPlan(
|
||
release_tag=f"{plugin_id}_v{plugin_version}",
|
||
fallback_to_filelist=True,
|
||
), ""
|