Files
MoviePilot/app/runtime/extensions/plugin/clone.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

408 lines
18 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 threading
from collections.abc import Callable
from typing import Any, NamedTuple, Optional, Protocol
from pydantic import ValidationError
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
# 自动分配的后缀从 2 起算:源插件本体在用户眼里就是第 1 个实例,分身接着往下排
_FIRST_AUTO_SUFFIX = 2
# 在「已登记分身数」之上再多探测的号数,取值见 PluginCloneService._allocate_suffix
_AUTO_SUFFIX_PROBE_SLACK = 1000
def _first_validation_message(error: ValidationError) -> str:
"""
取校验错误里的首条说明,供回给用户的单行原因使用。
"""
details = error.errors()
return str(details[0].get("msg")) if details else str(error)
class InstanceIdTaken(Protocol):
"""
候选实例 ID 的占用判据。
"""
def __call__(
self,
instance_id: str,
*,
ignore_instance_row: bool = False,
) -> bool:
"""
判断该 ID 是否已被某个插件身份占住。
:param instance_id: 候选实例 ID
:param ignore_instance_row: 是否把「实例表里已有这一行」排除在占用之外。恢复
一个已停用的分身时为真:那一行正是本次要拿回来的东西,它自己不构成冲突
:return: 该 ID 是否已被占用
"""
class _Reservation(NamedTuple):
"""
占位阶段已经定下来的事实,供后续加载与回滚判断来历。
:param clone_id: 已预留的插件分身标识
:param restoring: 是否恢复已有停用实例,而非创建新记录
"""
clone_id: str
restoring: bool
class _ClonePlan(NamedTuple):
"""
落库之前定下的分身方案:写哪一行,以及这一行是新建还是恢复。
:param reservation: 本次创建或恢复操作取得的预留事实
:param instance: 待写入数据库的完整插件实例记录
"""
reservation: _Reservation
instance: PluginInstance
class PluginCloneService:
"""
创建共享源码的虚拟插件实例并协调失败回滚。
"""
def __init__(
self,
*,
plugin_class: Callable[[str], Optional[Any]],
instance_id_taken: InstanceIdTaken,
get_instance: Callable[[str], Optional[PluginInstance]],
instances_for_source: Callable[[str], list[PluginInstance]],
source_plugin_id: Callable[[str], str],
save_instance: Callable[[PluginInstance], Any],
delete_instance: Callable[[str], bool],
disable_instance: Callable[[str], bool],
read_config: Callable[[str], dict[str, object]],
save_config: Callable[[str, dict[str, object]], bool],
delete_config: Callable[[str], bool],
reload_plugin: Callable[[str], Any],
remove_plugin: Callable[[str], Any],
log: Any,
) -> None:
"""
保存实例描述、持久化和运行态端口。
"""
self._plugin_class = plugin_class
self._instance_id_taken = instance_id_taken
self._get_instance = get_instance
self._instances_for_source = instances_for_source
self._source_plugin_id = source_plugin_id
self._save_instance = save_instance
self._delete_instance = delete_instance
self._disable_instance = disable_instance
self._read_config = read_config
self._save_config = save_config
self._delete_config = delete_config
self._reload_plugin = reload_plugin
self._remove_plugin = remove_plugin
self._logger = log
# 「挑一个没被占用的 ID」与「把这个 ID 占下来」之间存在窗口,两个并发的创建
# 请求会在窗口里选出同一个 ID,随后一个覆盖另一个的实例行。创建是低频的管理
# 动作,用一把进程内互斥把占位串行化即可,代价远小于让两个分身共用一行
self._reservation_lock = threading.Lock()
def clone(
self,
*,
plugin_id: str,
suffix: Optional[str] = None,
name: str,
description: str,
version: Optional[str] = None,
icon: Optional[str] = None,
restore_previous: bool = True,
) -> tuple[bool, str]:
"""
创建虚拟分身,复制隔离配置并保持默认禁用语义。
:param plugin_id: 源插件ID
:param suffix: 追加到源插件ID后的分身后缀;留空时自动分配最小可用序号
:param name: 分身展示名称,恢复时留空表示沿用停用前登记的那一份
:param description: 分身展示描述,留空时的处理同 ``name``
:param version: 旧客户端仍会带上;分身始终跟随源插件版本,此处不消费
:param icon: 分身展示图标,留空时的处理同 ``name``
:param restore_previous: 该后缀名下留有一个已停用的分身时是否沿用它的业务
参数;为假时改按源插件模板重建一份配置
:return: 是否成功与分身ID或失败原因
"""
del version
rejection = self._reject_invalid_source(plugin_id)
if rejection:
return False, rejection
# 实例行落库、备配置与首次加载同属一次创建,共用这一个异常与清理边界:落库留在
# 边界之外时,写到一半失败既不会按约定回报 (False, 原因),也不会触发回滚,一行
# 没有配置、也没有运行态的实例就此留在表里
reservation: Optional[_Reservation] = None
try:
with self._reservation_lock:
plan, message = self._plan(
plugin_id=plugin_id,
suffix=suffix,
name=name,
description=description,
icon=icon,
restore_previous=restore_previous,
)
if plan is None:
return False, message
self._claim(plan)
# 记下来历的时机就是占位成功的时机:此后这一行确定归本次所有,锁外的
# 清理不会误伤别人。落库失败的清理由 _claim 在锁内自己做完
reservation = plan.reservation
self._activate(
reservation,
plugin_id=plugin_id,
restore_previous=restore_previous,
)
action = "恢复" if reservation.restoring else "创建"
self._logger.info(f"插件分身 {reservation.clone_id} {action}成功")
return True, reservation.clone_id
# 分身创建的任一步骤失败都要释放已取得的预留并回滚已写入状态。
except Exception as error: # noqa: BLE001
# 没占到位就没有任何东西落下来,无需也无从回滚
if reservation is not None:
self._rollback(
reservation.clone_id,
purge_instance=not reservation.restoring,
)
self._logger.error(f"创建插件分身失败:{error}")
return False, f"创建插件分身失败:{error}"
def _reject_invalid_source(self, plugin_id: str) -> str:
"""
判断给定插件能否作为分身来源,可以时返回空串。
分身不能再生分身:分身共享源插件的源码,它自己并不是一份源码。放行的话新实例
会挂到源插件名下,后缀却是按分身 ID 算的,自动分配因而会在一个与占用判据对不上
的号段里挑号。
"""
if not plugin_id:
return "插件ID不能为空"
if self._plugin_class(plugin_id) is None:
return f"原插件 {plugin_id} 不存在"
source_id = self._source_plugin_id(plugin_id)
if source_id != plugin_id:
return (
f"{plugin_id} 是分身实例,不能作为分身来源,"
f"请对源插件 {source_id} 创建分身"
)
return ""
def _plan(
self,
*,
plugin_id: str,
suffix: Optional[str],
name: str,
description: str,
icon: Optional[str],
restore_previous: bool,
) -> tuple[Optional[_ClonePlan], str]:
"""
定下分身 ID 与要写出的实例行,或给出拒绝原因;本方法自身不写任何东西。
调用方须持有占位锁:判存是「先读后写」的前半段,两个并发创建各自读到「未占用」
就会落到同一行上。
"""
resolved_suffix = (suffix or "").strip().lower() or self._allocate_suffix(plugin_id)
if not resolved_suffix:
return None, f"插件 {plugin_id} 的可用分身后缀已耗尽,请手动指定一个"
clone_id = f"{plugin_id}{resolved_suffix}"
# 已停用的分身仍留着自己那一行,业务参数原样挂在上面:同后缀重建就是把它连同
# 配置一起拿回来,而不是撞车。归属对不上的行不算可恢复——它是别的源插件的分身
previous = self._get_instance(clone_id)
restoring = (
previous is not None
and not previous.is_enabled
and previous.source_plugin_id == plugin_id
)
# 恢复只豁免待恢复的那一行自身。停用从不删行,那一行可以在表里躺很久,其间同名
# 的真实插件完全可能出现(装进类注册表、进安装清单、或在磁盘上留下插件包);此时
# 放行等于让分身顶掉一个真实插件的身份,那个插件此后连装都装不回来
if self._instance_id_taken(clone_id, ignore_instance_row=restoring):
return None, (
f"分身插件 {clone_id} 的 ID 已被其他插件占用,无法恢复"
if restoring
else f"分身插件 {clone_id} 已存在"
)
instance, message = self._build_instance(
clone_id=clone_id,
source_plugin_id=plugin_id,
name=name,
description=description,
icon=icon,
previous=previous if restoring else None,
inherit_display=restore_previous,
)
if instance is None:
return None, message
return _ClonePlan(
reservation=_Reservation(clone_id=clone_id, restoring=restoring),
instance=instance,
), ""
def _claim(self, plan: _ClonePlan) -> None:
"""
把实例行写出去占住这个 ID,写入失败时就地清理并把异常抛出。
调用方须持有占位锁,清理也必须在锁内做完:锁一放开,另一个同后缀请求立刻就能
把这个 ID 占下来并成功落库,而按 ID 清理分不出那一行是谁写的,删掉的会是它的
行、配置和运行态。占位成功之后则不必再持锁——实例行此时已经挡住同 ID 的后续
请求(判存认这一行),这个 ID 确定归本次所有,直到本次自己把它清掉。
:param plan: 本次要写出的实例行与它的来历
:raise Exception: 原样抛出持久化层的写入错误,交由 :meth:`clone` 统一回报
"""
try:
self._save_instance(plan.instance)
except Exception:
self._rollback(
plan.reservation.clone_id,
purge_instance=not plan.reservation.restoring,
)
raise
def _allocate_suffix(self, plugin_id: str) -> str:
"""
为新分身分配一个最小可用的数字后缀,无号可用时返回空串。
占用判据与显式指定后缀走的是同一个 ``instance_id_taken``,自动分配因此不可能
挑中一个手填时会被判成「已存在」的 ID。已停用的分身同样占位:重用它的 ID 是
「恢复」而不是新建,用户没点恢复就不该凭空拿到上一个分身留下的配置。
探测上限随该插件已登记的分身数一起抬高。定成一个常数的话,分身数量越过它之后
每次自动分配都报「后缀已耗尽」,而下一个号明明空着;按鸽巢原理,N 个已登记分身
最多占掉 N 个号,再多探测一段余量必然能撞上空位。上限仍然有限:占用判据若因端口
故障恒为真,无界的循环会让请求线程原地打转并一直扣着占位锁。
"""
probe_limit = len(self._instances_for_source(plugin_id)) + _AUTO_SUFFIX_PROBE_SLACK
for index in range(_FIRST_AUTO_SUFFIX, _FIRST_AUTO_SUFFIX + probe_limit):
if not self._instance_id_taken(f"{plugin_id}{index}"):
return str(index)
return ""
def _build_instance(
self,
*,
clone_id: str,
source_plugin_id: str,
name: str,
description: str,
icon: Optional[str],
previous: Optional[PluginInstance],
inherit_display: bool,
) -> tuple[Optional[PluginInstance], str]:
"""
构造实例描述,并在任何写入之前拦下不合法的实例 ID。
合法性由 ``PluginInstance`` 自己判定,这里不另抄一份规则;构造排在落库之前,
非法 ID 因而不会先写出一行再靠回滚擦掉。
:param previous: 正在被恢复的那一行,非恢复时为 None
:param inherit_display: 恢复时展示信息是否沿用停用前登记的那一份
"""
if previous is not None and inherit_display:
name = name or (previous.plugin_name or "")
description = description or (previous.plugin_desc or "")
icon = icon or (previous.plugin_icon or "")
try:
instance = PluginInstance(
instance_id=clone_id,
source_plugin_id=source_plugin_id,
plugin_name=name or None,
plugin_desc=description or None,
plugin_icon=icon or None,
# 恢复时保留该行原有的默认目标置位;新建的分身不该凭空成为默认目标
is_default_target=(
previous.is_default_target if previous is not None else False
),
# 新建的分身就是要拿去跑的;启用位是装载判据,留空会让它建出来却不加载
is_enabled=True,
)
except ValidationError as error:
return None, f"分身实例 ID {clone_id} 不合法:{_first_validation_message(error)}"
return instance, ""
def _activate(
self,
reservation: _Reservation,
*,
plugin_id: str,
restore_previous: bool,
) -> None:
"""
准备配置并完成首次加载,失败一律抛出,由 :meth:`clone` 统一回滚。
"""
# 恢复留存配置时不得用源插件模板盖掉它,那正是用户要拿回来的东西
if not (reservation.restoring and restore_previous):
self._rebuild_config(reservation.clone_id, source_plugin_id=plugin_id)
if self._reload_plugin(reservation.clone_id) is PluginRuntimeStatus.LOAD_FAILED:
raise RuntimeError("虚拟实例加载失败")
def _rebuild_config(self, clone_id: str, *, source_plugin_id: str) -> None:
"""
按源插件当前的配置模板重建分身配置,模板为空时清掉分身原有的那一份。
源插件没有配置与源插件配置为空字典都落在「模板里没有可继承的业务参数」这一档,
两者都必须清除:只是跳过写入的话,已停用分身留存的旧参数会原样活下来并在重载后
继续生效,用户要的「按模板重建一份」于是变成了「沿用旧配置」,且毫无提示。
:param clone_id: 分身实例 ID
:param source_plugin_id: 提供配置模板的源插件 ID
:raise RuntimeError: 配置写入被持久化层拒绝
"""
template = self._read_config(source_plugin_id)
if not template:
self._delete_config(clone_id)
return
clone_config = dict(template)
# 分身是照着源插件的参数建出来的,业务开关一律置假:它还没被用户配过,直接开跑
# 等于拿着别人的参数上线
clone_config["enable"] = False
clone_config["enabled"] = False
if not self._save_config(clone_id, clone_config):
raise RuntimeError("虚拟实例配置保存失败")
def _rollback(self, clone_id: str, *, purge_instance: bool) -> None:
"""
逐项清理失败实例,单个清理错误不得阻断其余回滚。
:param clone_id: 本次创建的分身ID
:param purge_instance: 实例行与配置是否由本次创建产生、可随之抹掉;恢复一个
已停用的分身时为假——那一行连同用户留在上面的业务参数是特意保留的,一次
加载失败不该把它毁掉,只需把启用位退回停用,下次仍可再试一遍恢复
"""
rollback_steps: list[tuple[str, Callable[[str], Any]]] = [
("运行态", self._remove_plugin),
]
if purge_instance:
rollback_steps.append(("实例描述", self._delete_instance))
rollback_steps.append(("配置", self._delete_config))
else:
rollback_steps.append(("启用位", self._disable_instance))
for label, rollback in rollback_steps:
try:
rollback(clone_id)
# 某一步回滚失败仍需继续其余清理步骤,并记录需要后续恢复的状态。
except Exception as rollback_error: # noqa: BLE001
self._logger.warning(
f"回滚插件分身 {clone_id} 的{label}失败:{rollback_error}"
)