mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-10 07:39:24 +08:00
插件配置此前借用 systemconfig 的 plugin.<实例ID> 单键,为了换表又在
SystemConfigOper 的 set/delete/load_snapshot 里按该前缀开分支。这是反向依赖:
一个对所有键一视同仁的通用键值存储被迫知道「插件」这个概念,读写路径依键值而
异,「这个键落在哪张表」只有读代码才说得清;规范方法按值加特例,也让此后任何新
键都要先确认自己会不会被拦截。
本次把插件配置整条链拆出,形状与本分支日志等级那条链一致:
- SystemConfigOper 整体回退到官方实现,与 f70d7c141 逐字节相同,对所有键一视同仁。
- 配置端口改按实例 ID 寻址:PluginStorage 新增 read_config / write_config /
async_write_config / delete_config,PluginConfigStore 去掉 key_prefix 与 _key,
不再有任何字符串键经过;plugin_exists 存在性规则、空键过滤与 force 语义不变。
- PluginInstanceOper 补齐读取与异步写入原语 get_config_data /
async_save_config_data,同步与异步共用同一份落库决策,避免两条路径对「值未变化
不写」和「建行用哪个源插件」各自分叉。
- _PluginBase 的 get_config / update_config 直连 PluginInstanceOper,方法名、签名、
返回与 docstring 不变;self.systemconfig 保留,插件读 SystemConfigKey 枚举值仍
然合法,插件公开面没有扩大。
插件直接用 SystemConfig 存取自身配置不予支持。它本就不是封装过的插件配置通道,
为它设计兼容或加守卫拦截,等于再把「插件」塞回通用存储里——那正是本次要拆掉的东
西。拆开后 plugin.<ID> 只是一个与插件无关的普通 systemconfig 键:迁移已经把旧的
plugin.* 行搬进插件实例表,读它拿到 None,写它会新增一条谁也不读的 systemconfig
记录,插件配置通道既不读也不写它,两边不会再有任何交叉。
代价是读取从内存快照变成一次按实例 ID 的主键查询。调用点都不在热路径上:宿主侧
只有插件启动/重载、配置表单接口与分身创建三处,插件侧 get_config() 惯例在
init_plugin 里取一次。写入侧没有变化,本来就是一次事务。
回归防线:新增用例断言 SystemConfigOper 对 plugin. 前缀没有任何特殊行为——写进去
就是一行普通 systemconfig 记录,读出来就是刚写的值,不会被路由到插件实例表。
兼容层零改动,三个 alembic 迁移一行未改。
107 lines
4.7 KiB
Python
107 lines
4.7 KiB
Python
"""共享源码插件的实例描述符与配置持久化模型。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, List, Optional, Self, cast
|
||
|
||
from sqlalchemy import JSON, Index, String, UniqueConstraint, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||
|
||
from app.db.base import Base, get_id_column
|
||
|
||
|
||
class PluginInstance(Base):
|
||
"""持久化一个共享源码插件的运行实例,一实例一行。
|
||
|
||
``instance_id`` 既是分身的实例 ID,也可以等于 ``source_plugin_id`` 表示源插件
|
||
本体自身;两者相等即本体,不等即分身,不再另设模式列——该列会是从这个等式复制
|
||
出来的冗余副本,两者一旦失步就会让同一行在不同读取口被判成不同角色。
|
||
|
||
身份(``instance_id`` 与 ``source_plugin_id``)之外的列都是这个实例的设置:
|
||
展示信息与业务参数同属一个生命周期,应当随卸载一起消失、随重建一起重来,因此
|
||
存放在同一行而非分表协调。
|
||
|
||
业务参数存进 ``config_data`` 而不是 ``systemconfig`` 的 ``plugin.<实例ID>`` 单键:
|
||
那个键由插件 ID 决定、条目数随安装量增长,混在系统设置里会把主程序自己的设置项
|
||
淹掉;而且它存的其实是实例 ID,表里没有任何一列说得出它属于哪个插件,想列出
|
||
某个插件的全部实例配置就只能靠字符串前缀去猜。
|
||
|
||
``log_level`` 为空表示该实例跟随全局日志等级;非空且未过期时覆盖全局等级,
|
||
``log_expires_at`` 为空表示覆盖不过期。过期判定在读取时惰性执行,实现见
|
||
``app.runtime.log``,库里只存原样设置,不存已折算的结果。
|
||
|
||
表名由 ``Base`` 按类名自动派生为小写 ``plugininstance``。
|
||
"""
|
||
|
||
id = get_id_column()
|
||
instance_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||
source_plugin_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||
plugin_name: Mapped[Optional[str]] = mapped_column(String(255))
|
||
plugin_desc: Mapped[Optional[str]] = mapped_column(String(255))
|
||
plugin_icon: Mapped[Optional[str]] = mapped_column(String(255))
|
||
log_level: Mapped[Optional[str]] = mapped_column(String(16))
|
||
log_expires_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||
config_data: Mapped[Optional[Any]] = mapped_column(JSON)
|
||
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("instance_id", name="uq_plugininstance_instance_id"),
|
||
Index("ix_plugininstance_source_plugin_id", "source_plugin_id"),
|
||
)
|
||
|
||
@property
|
||
def is_host(self) -> bool:
|
||
"""该行是否为源插件本体自身,而非共享其源码的分身。"""
|
||
return bool(self.instance_id == self.source_plugin_id)
|
||
|
||
@property
|
||
def carries_only_identity(self) -> bool:
|
||
"""除一对身份列外是否已不承载任何设置。
|
||
|
||
本体行会在插件首次存配置时被隐式建出,配置被删掉后若各列皆空就只剩身份列,
|
||
留着会让「有哪些插件登记过本体设置」的枚举逐步失真,因而可以回收。分身行不
|
||
适用:分身的存在本身就由这一行表达,清空设置不等于删除分身。
|
||
"""
|
||
return not any(
|
||
(
|
||
self.config_data is not None,
|
||
self.log_level,
|
||
self.log_expires_at,
|
||
self.plugin_name,
|
||
self.plugin_desc,
|
||
self.plugin_icon,
|
||
)
|
||
)
|
||
|
||
@classmethod
|
||
def get_by_instance_id(cls, db: Session, instance_id: str) -> Optional[Self]:
|
||
"""在调用方 Session 中按实例 ID 查询单行。"""
|
||
return cast(
|
||
Optional[Self],
|
||
db.execute(select(cls).where(cls.instance_id == instance_id)).scalars().first(),
|
||
)
|
||
|
||
@classmethod
|
||
async def async_get_by_instance_id(
|
||
cls,
|
||
db: AsyncSession,
|
||
instance_id: str,
|
||
) -> Optional[Self]:
|
||
"""在调用方 AsyncSession 中按实例 ID 查询单行。"""
|
||
result = await db.execute(select(cls).where(cls.instance_id == instance_id))
|
||
return cast(Optional[Self], result.scalars().first())
|
||
|
||
@classmethod
|
||
def list_by_source_plugin_id(cls, db: Session, source_plugin_id: str) -> List[Self]:
|
||
"""在调用方 Session 中列出某个源插件名下的全部实例,含本体与各个分身。"""
|
||
return cast(
|
||
List[Self],
|
||
list(
|
||
db.execute(
|
||
select(cls).where(cls.source_plugin_id == source_plugin_id)
|
||
).scalars()
|
||
),
|
||
)
|