Files
MoviePilot/app/db/models/plugininstance.py
Aqr-K 3d594ecee3 refactor(plugin): 插件配置改走独立通道,SystemConfig 不再按键值分支
插件配置此前借用 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 迁移一行未改。
2026-09-13 01:41:57 -04:00

107 lines
4.7 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
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()
),
)