mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
391 lines
16 KiB
Python
391 lines
16 KiB
Python
"""
|
|
插件文件夹查询、变更和插件归属维护用例。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from contextlib import AbstractContextManager
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from app.application.configuration import get_configured_system_config
|
|
from app.application.plugin.runtime import get_plugin_manager
|
|
from app.runtime.log import logger
|
|
from app.schemas.exception import (
|
|
PersistenceUnavailableError,
|
|
PluginMutationRejectedError,
|
|
)
|
|
from app.schemas.types import SystemConfigKey
|
|
|
|
PluginFolders = dict[str, Any]
|
|
FolderReader = Callable[[], PluginFolders]
|
|
FolderWriter = Callable[[PluginFolders], Awaitable[object]]
|
|
FolderSyncWriter = Callable[[PluginFolders], object]
|
|
FolderMutation = Callable[[str], AbstractContextManager[None]]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PluginFolderResult:
|
|
"""
|
|
描述一次插件文件夹变更结果和用户提示。
|
|
|
|
:param success: 操作是否完成其预期目标
|
|
:param message: 操作结果或失败原因的说明,默认空字符串
|
|
"""
|
|
|
|
success: bool
|
|
message: str = ""
|
|
|
|
|
|
FolderChange = Callable[[PluginFolders], tuple[PluginFolderResult, PluginFolders]]
|
|
FolderAtomicWriter = Callable[[FolderChange], Awaitable[PluginFolderResult]]
|
|
|
|
|
|
class PluginFolderService:
|
|
"""
|
|
集中管理插件文件夹快照和带准入的持久化变更。
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
read: FolderReader,
|
|
write: FolderWriter,
|
|
write_sync: FolderSyncWriter,
|
|
mutation: FolderMutation,
|
|
update: FolderAtomicWriter | None = None,
|
|
) -> None:
|
|
"""
|
|
保存配置读写和插件运行态变更准入端口。
|
|
"""
|
|
self._read = read
|
|
self._write = write
|
|
self._write_sync = write_sync
|
|
self._mutation = mutation
|
|
self._update = update
|
|
|
|
def get(self) -> PluginFolders:
|
|
"""
|
|
返回与配置存储隔离的当前文件夹快照。
|
|
"""
|
|
return deepcopy(self._read())
|
|
|
|
def get_or_empty(self) -> PluginFolders:
|
|
"""
|
|
读取失败时记录诊断并返回兼容的空文件夹快照。
|
|
"""
|
|
try:
|
|
return self.get()
|
|
# 查询入口保留旧空结果语义
|
|
except Exception as error: # noqa: BLE001
|
|
logger.error(f"[文件夹API] 获取文件夹配置失败: {error}")
|
|
return {}
|
|
|
|
async def save(self, folders: PluginFolders) -> PluginFolderResult:
|
|
"""
|
|
完整替换文件夹配置并取得插件变更准入。
|
|
"""
|
|
try:
|
|
with self._mutation("保存插件文件夹配置"):
|
|
await self._write(deepcopy(folders))
|
|
return PluginFolderResult(True)
|
|
except PersistenceUnavailableError:
|
|
raise
|
|
except PluginMutationRejectedError as error:
|
|
return PluginFolderResult(False, str(error))
|
|
# 兼容入口以结果表达保存失败
|
|
except Exception as error: # noqa: BLE001
|
|
logger.error(f"[文件夹API] 保存文件夹配置失败: {error}")
|
|
return PluginFolderResult(False, str(error))
|
|
|
|
async def create(self, folder_name: str) -> PluginFolderResult:
|
|
"""
|
|
创建不存在的文件夹,保留旧列表格式的兼容形态。
|
|
"""
|
|
folder_name = folder_name.strip()
|
|
if not folder_name:
|
|
return PluginFolderResult(False, "文件夹名称不能为空")
|
|
|
|
def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]:
|
|
"""
|
|
只在名称尚未占用时向最新快照追加空文件夹。
|
|
"""
|
|
if folder_name in folders:
|
|
return PluginFolderResult(False, f"文件夹 '{folder_name}' 已存在"), folders
|
|
folders[folder_name] = []
|
|
return PluginFolderResult(True, f"文件夹 '{folder_name}' 创建成功"), folders
|
|
|
|
return await self._change(f"创建插件文件夹 {folder_name}", change)
|
|
|
|
async def delete(self, folder_name: str) -> PluginFolderResult:
|
|
"""
|
|
删除存在的文件夹并返回稳定业务结果。
|
|
"""
|
|
def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]:
|
|
"""
|
|
只从最新快照移除目标文件夹。
|
|
"""
|
|
if folder_name not in folders:
|
|
return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders
|
|
del folders[folder_name]
|
|
return PluginFolderResult(True, f"文件夹 '{folder_name}' 删除成功"), folders
|
|
|
|
return await self._change(f"删除插件文件夹 {folder_name}", change)
|
|
|
|
async def update_folder(
|
|
self,
|
|
folder_name: str,
|
|
*,
|
|
new_name: str | None = None,
|
|
changes: dict[str, Any] | None = None,
|
|
) -> PluginFolderResult:
|
|
"""
|
|
增量更新文件夹名称或展示配置,同时保留成员与未修改字段。
|
|
"""
|
|
normalized_name = new_name.strip() if new_name is not None else folder_name
|
|
if not normalized_name:
|
|
return PluginFolderResult(False, "文件夹名称不能为空")
|
|
folder_changes = deepcopy(changes or {})
|
|
|
|
def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]:
|
|
"""
|
|
在最新快照中合并展示字段,并保持重命名前的字典位置。
|
|
"""
|
|
if folder_name not in folders:
|
|
return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders
|
|
if normalized_name != folder_name and normalized_name in folders:
|
|
return PluginFolderResult(False, f"文件夹 '{normalized_name}' 已存在"), folders
|
|
|
|
current = folders[folder_name]
|
|
if folder_changes:
|
|
current = (
|
|
{"plugins": list(current)}
|
|
if isinstance(current, list)
|
|
else deepcopy(current) if isinstance(current, dict) else {"plugins": []}
|
|
)
|
|
current.update(folder_changes)
|
|
|
|
if normalized_name == folder_name:
|
|
folders[folder_name] = current
|
|
else:
|
|
folders = {
|
|
(normalized_name if name == folder_name else name): (current if name == folder_name else value)
|
|
for name, value in folders.items()
|
|
}
|
|
return PluginFolderResult(True, f"文件夹 '{normalized_name}' 已更新"), folders
|
|
|
|
return await self._change(f"更新插件文件夹 {folder_name}", change)
|
|
|
|
async def update_plugins(
|
|
self,
|
|
folder_name: str,
|
|
plugin_ids: list[str],
|
|
expected_plugin_ids: list[str] | None = None,
|
|
) -> PluginFolderResult:
|
|
"""
|
|
条件更新指定文件夹的插件顺序和成员,并保留展示配置。
|
|
"""
|
|
next_plugin_ids = list(plugin_ids)
|
|
|
|
def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]:
|
|
"""
|
|
基于最新成员列表检查预期快照并替换目标列表。
|
|
"""
|
|
if folder_name not in folders:
|
|
return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders
|
|
folder_data = folders[folder_name]
|
|
current_plugin_ids = _folder_plugins(folder_data) or []
|
|
if expected_plugin_ids is not None and current_plugin_ids != expected_plugin_ids:
|
|
return PluginFolderResult(False, "插件文件夹已被其他请求修改,请重新读取后再试"), folders
|
|
folders[folder_name] = _with_folder_plugins(folder_data, next_plugin_ids)
|
|
return PluginFolderResult(True, f"文件夹 '{folder_name}' 中的插件已更新"), folders
|
|
|
|
return await self._change(f"更新插件文件夹 {folder_name}", change)
|
|
|
|
async def assign_plugin(self, folder_name: str, plugin_id: str) -> PluginFolderResult:
|
|
"""
|
|
把一个插件原子迁移到目标文件夹,并从其他文件夹移除。
|
|
"""
|
|
def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]:
|
|
"""
|
|
在同一最新快照内完成跨文件夹成员迁移。
|
|
"""
|
|
if folder_name not in folders:
|
|
return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders
|
|
|
|
for name, folder_data in folders.items():
|
|
plugins = _folder_plugins(folder_data)
|
|
if plugins is None:
|
|
if name == folder_name:
|
|
folders[name] = _with_folder_plugins(folder_data, [])
|
|
continue
|
|
folders[name] = _with_folder_plugins(
|
|
folder_data,
|
|
[item for item in plugins if item != plugin_id],
|
|
)
|
|
|
|
target = folders[folder_name]
|
|
target_plugins = list(_folder_plugins(target) or [])
|
|
target_plugins.append(plugin_id)
|
|
folders[folder_name] = _with_folder_plugins(target, target_plugins)
|
|
return PluginFolderResult(True, f"插件已移动到文件夹 '{folder_name}'"), folders
|
|
|
|
return await self._change(f"移动插件到文件夹 {folder_name}", change)
|
|
|
|
async def remove_plugin_from_folder(
|
|
self,
|
|
folder_name: str,
|
|
plugin_id: str,
|
|
) -> PluginFolderResult:
|
|
"""
|
|
只从指定文件夹移除一个插件,不影响其他文件夹。
|
|
"""
|
|
def change(folders: PluginFolders) -> tuple[PluginFolderResult, PluginFolders]:
|
|
"""
|
|
在最新快照内删除目标文件夹中的指定成员。
|
|
"""
|
|
if folder_name not in folders:
|
|
return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在"), folders
|
|
folder_data = folders[folder_name]
|
|
plugins = _folder_plugins(folder_data) or []
|
|
if plugin_id not in plugins:
|
|
return PluginFolderResult(False, f"插件不在文件夹 '{folder_name}' 中"), folders
|
|
folders[folder_name] = _with_folder_plugins(
|
|
folder_data,
|
|
[item for item in plugins if item != plugin_id],
|
|
)
|
|
return PluginFolderResult(True, f"插件已从文件夹 '{folder_name}' 移除"), folders
|
|
|
|
return await self._change(f"从文件夹 {folder_name} 移除插件", change)
|
|
|
|
async def _change(
|
|
self,
|
|
operation: str,
|
|
change: FolderChange,
|
|
) -> PluginFolderResult:
|
|
"""
|
|
统一执行带运行时准入的增量文件夹写入并映射稳定失败结果。
|
|
"""
|
|
try:
|
|
with self._mutation(operation):
|
|
if self._update is not None:
|
|
return await self._update(change)
|
|
folders = self.get()
|
|
result, changed_folders = change(folders)
|
|
if result.success:
|
|
await self._write(changed_folders)
|
|
return result
|
|
except PersistenceUnavailableError:
|
|
raise
|
|
except PluginMutationRejectedError as error:
|
|
return PluginFolderResult(False, str(error))
|
|
# HTTP 兼容入口以业务结果表达失败
|
|
except Exception as error: # noqa: BLE001
|
|
logger.error(f"[文件夹API] {operation}失败: {error}")
|
|
return PluginFolderResult(False, str(error))
|
|
|
|
def remove_plugin(self, plugin_id: str) -> None:
|
|
"""
|
|
从当前和旧版文件夹形态中移除插件且不阻断卸载。
|
|
"""
|
|
try:
|
|
folders = self.get()
|
|
modified = False
|
|
for folder_name, folder_data in folders.items():
|
|
plugins = _folder_plugins(folder_data)
|
|
if plugins is None or plugin_id not in plugins:
|
|
continue
|
|
plugins.remove(plugin_id)
|
|
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
|
modified = True
|
|
if modified:
|
|
self._write_sync(folders)
|
|
else:
|
|
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
|
|
# 文件夹配置损坏不应阻断插件代码、数据和定时任务的卸载流程。
|
|
except Exception as error: # noqa: BLE001
|
|
logger.error(f"从文件夹中移除插件时出错:{error}")
|
|
|
|
def add_clone(self, original_plugin_id: str, clone_plugin_id: str) -> None:
|
|
"""
|
|
让分身继承原插件所在的首个文件夹且不阻断创建。
|
|
"""
|
|
try:
|
|
folders = self.get()
|
|
for folder_name, folder_data in folders.items():
|
|
plugins = _folder_plugins(folder_data)
|
|
if plugins is None or original_plugin_id not in plugins:
|
|
continue
|
|
if clone_plugin_id not in plugins:
|
|
plugins.append(clone_plugin_id)
|
|
self._write_sync(folders)
|
|
logger.info(
|
|
f"已将分身插件 {clone_plugin_id} 添加到文件夹 "
|
|
f"'{folder_name}' 中"
|
|
)
|
|
return
|
|
logger.info(
|
|
f"原插件 {original_plugin_id} 不在任何文件夹中,"
|
|
f"分身插件 {clone_plugin_id} 将保持独立"
|
|
)
|
|
# 文件夹处理失败不影响插件分身创建的整体流程。
|
|
except Exception as error: # noqa: BLE001
|
|
logger.error(f"处理插件文件夹时出错:{error}")
|
|
|
|
|
|
def get_plugin_folder_service() -> PluginFolderService:
|
|
"""
|
|
基于当前 lifespan 的 Application 配置和插件 Runtime 构造文件夹服务。
|
|
"""
|
|
return PluginFolderService(
|
|
read=lambda: (
|
|
get_configured_system_config().get(SystemConfigKey.PluginFolders) or {}
|
|
),
|
|
write=lambda folders: get_configured_system_config().async_set(
|
|
SystemConfigKey.PluginFolders, folders
|
|
),
|
|
write_sync=lambda folders: get_configured_system_config().set(
|
|
SystemConfigKey.PluginFolders, folders
|
|
),
|
|
mutation=lambda operation: get_plugin_manager().mutation(operation),
|
|
update=lambda change: get_configured_system_config().async_update_atomically(
|
|
SystemConfigKey.PluginFolders,
|
|
lambda current: change(deepcopy(current) if isinstance(current, dict) else {}),
|
|
),
|
|
)
|
|
|
|
|
|
def remove_plugin_from_folders(plugin_id: str) -> None:
|
|
"""
|
|
保留卸载调用方使用的稳定 Application 入口。
|
|
"""
|
|
get_plugin_folder_service().remove_plugin(plugin_id)
|
|
|
|
|
|
def add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str) -> None:
|
|
"""
|
|
保留分身创建调用方使用的稳定 Application 入口。
|
|
"""
|
|
get_plugin_folder_service().add_clone(original_plugin_id, clone_plugin_id)
|
|
|
|
|
|
def _folder_plugins(folder_data: Any) -> list[str] | None:
|
|
"""
|
|
返回当前字典形态或旧列表形态中的可变插件列表。
|
|
"""
|
|
if isinstance(folder_data, dict):
|
|
plugins = folder_data.get("plugins")
|
|
return plugins if isinstance(plugins, list) else None
|
|
return folder_data if isinstance(folder_data, list) else None
|
|
|
|
|
|
def _with_folder_plugins(folder_data: Any, plugin_ids: list[str]) -> Any:
|
|
"""
|
|
替换成员列表,同时保留对象格式中的展示配置。
|
|
"""
|
|
if isinstance(folder_data, dict):
|
|
return {**folder_data, "plugins": list(plugin_ids)}
|
|
return list(plugin_ids)
|