Files
MoviePilot/app/db/models/plugininstance.py
Aqr-K d44480d739 Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement
feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个
2026-09-13 16:37:27 +08:00

153 lines
7.2 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,
Boolean,
Index,
String,
UniqueConstraint,
column,
false,
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``,库里只存原样设置,不存已折算的结果。
``is_enabled`` 是「这份配置是否应当被实例化并启动」的唯一判据,也是卸载与恢复的
开关:置假即卸载,配置与展示信息原样留在这一行等待再次启用,因而不需要另设一个
卸载时间列去表达同一件事。行被删掉才是彻底清理。这一列的价值就是把「在册」与
「有配置」拆开——此前两者绑死,想保住配置就只能留着一个在跑的实例。
它与该实例此刻是否在运行无关——运行态由运行时持有、不落盘;也与插件自身在
``config_data`` 里按场景判定的业务开关无关。
``is_default_target`` 标记该实例是否为所属源插件的默认调用目标,即外部调用只给
插件 ID、没给实例 ID 时应当选中的那一行;与该实例是本体还是分身无关,两者都可能
被选为默认调用目标。「同一源插件至多一个默认调用目标」这条不变量由
``ux_plugininstance_default_target`` 条件唯一索引在数据库层强制,只索引置位的行,
不靠应用层纪律:置位要先清旧再置新,两条 DML 之间的窗口只有库层约束拦得住。
表名由 ``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))
# server_default 与迁移 DDL 保持一致:只写 Python 端 default 时 create_all 建出的
# 表不带 DEFAULT与迁移建出的表结构不同alembic 会一直报出差异
is_default_target: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
is_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
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"),
# 条件谓词按方言各给一份:布尔列与 True 比较时 SQLite 编译为 ``IS 1``、
# PostgreSQL 编译为 ``IS true``。谓词整个丢失会退化成「每个源插件只能有一行
# 实例」,把插件分身整个锁死,因此不能只给一份共用
Index(
"ux_plugininstance_default_target",
"source_plugin_id",
unique=True,
sqlite_where=column("is_default_target", Boolean).is_(True),
postgresql_where=column("is_default_target", Boolean).is_(True),
),
)
@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.is_default_target,
self.is_enabled,
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()
),
)