Files
MoviePilot/app/db/oper/plugininstance.py
Aqr-K 4dbb8b8204 feat(plugin): 插件配置与实例描述符迁入独立表
插件配置此前寄存在 systemconfig 的 plugin.<实例ID> 单键下,实例描述符则整份挤在
PluginInstances 这一个 JSON 键里,两者都不成立:

- plugin.<ID> 是裸字符串 key,而仓库规则本身禁止用裸字符串做 SystemConfig key,
  只允许先定义 SystemConfigKey 枚举项;插件 ID 由用户安装决定,永远枚举不出常量。
  条目数随安装量增长,混在系统设置表里会把主程序自己的设置项淹掉。
- 那个键存的其实是实例 ID,表里没有任何一列说得出它属于哪个插件,想列出某个插件
  的全部实例配置只能靠字符串前缀去猜。
- 实例描述符整份读出再整份写回,改一个分身要重写全部分身。

改为 plugininstance 一实例一行:instance_id 与 source_plugin_id 构成身份,本体与
分身由两者是否相等派生而不设模式列——模式列会是这个等式的冗余副本,两者一旦失步
同一行就会在不同读取口被判成不同角色。展示信息与业务参数同存一行,属于同一个
生命周期,分表只会让建分身、删分身退化成两张表之间的协调问题。

读写路由落在 SystemConfigOper.get/set/delete,而不是在各个调用方各改一处:第三方
插件可能直接用 self.systemconfig.get("plugin.xxx") 读写自己的配置,只改
PluginConfigStore 与 _PluginBase.get_config/update_config 必然漏掉它们。路由只做
前缀识别,插入、更新与空本体行回收都委托 PluginInstanceOper,不在配置层重抄一遍。

PluginInstances 旧键迁移后不删,留作回滚依据,并以
SystemConfigKey.PluginInstancesImported 标志防止重复导入;判据不能是「表当前为空」,
否则用户把分身全部删光后,下次启动会把它们整批导回来。

迁移链:b2d4f6a8c1e3 -> 281965691a20(3.0.34 建表并搬描述符)
-> c4e1a7b9d2f6(3.0.35 加 config_data 并搬 plugin.* 配置)。

依赖基线与启动模块数随两个新模块重算,架构文档与数据库技能表目录同步登记新表。
2026-09-12 17:39:36 -04:00

166 lines
6.4 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 copy
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.plugininstance import PluginInstance
def _now() -> str:
"""返回实例行使用的 ISO-8601 时间戳。"""
return datetime.now(timezone.utc).isoformat()
class PluginInstanceOper(DbOper):
"""在调用方 Session 或独占事务中查询并暂存插件实例。
一行由 ``instance_id`` 唯一确定,``source_plugin_id`` 指向提供代码的源插件;
两者相等即源插件本体,不等即共享其源码的分身。
"""
def get(self, instance_id: str) -> Optional[PluginInstance]:
"""按实例 ID 查询单行,分身与本体共用同一张表和同一个查询。"""
return self._execute_sync_query(
lambda session: PluginInstance.get_by_instance_id(session, instance_id)
)
def list_by_source(self, source_plugin_id: str) -> list[PluginInstance]:
"""按源插件 ID 列举其全部实例,含分身与本体。"""
return list(
self._execute_sync_query(
lambda session: PluginInstance.list_by_source_plugin_id(
session, source_plugin_id
)
)
or []
)
def list_all(self) -> list[PluginInstance]:
"""列举全部实例,供目录投影与兜底导入判空使用。"""
return list(
self._execute_sync_query(
lambda session: session.execute(select(PluginInstance)).scalars().all()
)
)
def save(self, **fields: Any) -> PluginInstance:
"""按 ``instance_id`` 新增或更新一行,只写入本次给出的列。
查询与写入收在同一事务内,避免两个并发的首次写入各自读到空、双双插入而
撞上 ``instance_id`` 唯一键。
已存在的行不允许改写 ``source_plugin_id``:它连同 ``instance_id`` 构成这一行
的身份,改写会把整行的归属换掉,同时把本体与分身的角色判定一并翻转,只可能
来自调用方错把分身自身实例 ID 当作源插件 ID 使用,因而在持久化层直接拒绝,
不依赖上层纪律。
:param fields: 实例字段,须含 ``instance_id``
:return: 写入后的实例行
:raise ValueError: 已存在的行与本次写入的 ``source_plugin_id`` 不一致
"""
now = _now()
instance_id = fields["instance_id"]
def stage(session: Session) -> PluginInstance:
"""在同一事务内查询并新增或更新实例行。"""
existing = PluginInstance.get_by_instance_id(session, instance_id)
if existing is None:
record = PluginInstance(**fields, created_at=now, updated_at=now)
session.add(record)
return record
incoming_source = fields.get("source_plugin_id")
if incoming_source is not None and existing.source_plugin_id != incoming_source:
raise ValueError(
f"插件实例 {instance_id} 已归属于 {existing.source_plugin_id}"
f"不能改写为 {incoming_source}"
)
for key, value in {**fields, "updated_at": now}.items():
setattr(existing, key, value)
return existing
return self._execute_sync_write(stage)
def delete(self, instance_id: str) -> bool:
"""按实例 ID 删除整行,返回删除前是否存在。
这会连同该实例的业务参数与展示信息一起清除:分身的存在本身就由这一行表达,
删行即卸载分身。
"""
def stage(session: Session) -> bool:
"""在同一事务内查询并删除,避免读写跨两个独立事务。"""
existing = PluginInstance.get_by_instance_id(session, instance_id)
if existing is None:
return False
session.delete(existing)
return True
return bool(self._execute_sync_write(stage))
def save_config_data(
self,
*,
instance_id: str,
source_plugin_id: str,
config_data: Any,
) -> Optional[bool]:
"""写入业务参数,保留该行已有的身份与展示信息。
:param instance_id: 实例 ID
:param source_plugin_id: 该行不存在时用于建行的源插件 ID
:param config_data: 业务参数
:return: True 已写入None 值未变化无需写入
"""
def stage(session: Session) -> Optional[bool]:
"""在同一事务内创建或更新业务参数。"""
record = PluginInstance.get_by_instance_id(session, instance_id)
if record is None:
now = _now()
session.add(
PluginInstance(
instance_id=instance_id,
source_plugin_id=source_plugin_id,
config_data=copy.deepcopy(config_data),
created_at=now,
updated_at=now,
)
)
return True
if record.config_data == config_data:
return None
record.config_data = copy.deepcopy(config_data)
record.updated_at = _now()
return True
return self._execute_sync_write(stage)
def clear_config_data(self, instance_id: str) -> bool:
"""清空某实例的业务参数,保留其身份与展示信息。
清空后若该行是本体且各列皆空,整行一并移除,避免只剩一对身份列的空行堆积。
:param instance_id: 实例 ID
:return: 该行存在并已处理
"""
def stage(session: Session) -> bool:
"""在同一事务内清空业务参数并回收空的本体行。"""
record = PluginInstance.get_by_instance_id(session, instance_id)
if record is None:
return False
record.config_data = None
record.updated_at = _now()
if record.is_host and record.carries_only_identity:
session.delete(record)
return True
return bool(self._execute_sync_write(stage))