mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
插件配置此前寄存在 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.* 配置)。
依赖基线与启动模块数随两个新模块重算,架构文档与数据库技能表目录同步登记新表。
166 lines
6.4 KiB
Python
166 lines
6.4 KiB
Python
"""插件实例的数据访问原语。"""
|
||
|
||
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))
|