Files
archived-MoviePilot/app/workflow/__init__.py

443 lines
18 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.
import threading
from time import monotonic, sleep
from typing import Any, Dict, List, Optional, Tuple
from app.core.config import global_vars
from app.core.event import eventmanager, Event
from app.db.models import Workflow
from app.db.workflow_oper import WorkflowOper
from app.helper.module import ModuleHelper
from app.log import logger
from app.schemas import ActionContext, Action, ActionResult
from app.schemas.types import EventType
from app.utils.singleton import Singleton
class WorkFlowManager(metaclass=Singleton):
"""
工作流管理器
"""
def __init__(self):
# 所有动作定义
self._lock = threading.Lock()
self._actions: Dict[str, Any] = {}
self._event_workflows: Dict[str, List[int]] = {}
self.init()
def init(self):
"""
初始化
"""
def filter_func(obj: Any):
"""
过滤函数,确保只加载新定义的类
"""
if not isinstance(obj, type):
return False
if not hasattr(obj, 'execute') or not hasattr(obj, "name"):
return False
if obj.__name__ == "BaseAction":
return False
return obj.__module__.startswith("app.workflow.actions")
# 加载所有动作
self._actions = {}
actions = ModuleHelper.load(
"app.workflow.actions",
filter_func=lambda _, obj: filter_func(obj)
)
for action in actions:
logger.debug(f"加载动作: {action.__name__}")
try:
self._actions[action.__name__] = action
except Exception as err:
logger.error(f"加载动作失败: {action.__name__} - {err}")
# 加载工作流事件触发器
self.load_workflow_events()
def stop(self):
"""
停止
"""
for event_type_str in list(self._event_workflows.keys()):
self.remove_workflow_event(event_type_str=event_type_str)
self._actions = {}
self._event_workflows = {}
def execute(self, workflow_id: int, action: Action, context: ActionContext = None,
inputs: Optional[dict] = None, runtime: Optional[dict] = None,
cancel_token: Optional[Any] = None) -> ActionResult:
"""
执行工作流动作
"""
if not context:
context = ActionContext()
if action.type not in self._actions:
logger.error(f"未找到动作: {action.type} - {action.name}")
return ActionResult(success=False, message=" ", context=context)
retry_config = self._get_retry_config(action)
max_attempts = retry_config["max_attempts"]
interval = retry_config["interval"]
backoff = retry_config["backoff"]
action_result = ActionResult(success=False, message="", context=context)
for attempt in range(1, max_attempts + 1):
if self._is_cancelled(workflow_id, cancel_token):
return ActionResult(success=False, message="工作流已取消", context=context)
runtime_data = {
**(runtime or {}),
"attempt": attempt,
"max_attempts": max_attempts,
"cancel_token": cancel_token,
}
action_result = self._execute_action_once(
workflow_id=workflow_id,
action=action,
context=context,
inputs=inputs or {},
runtime=runtime_data,
cancel_token=cancel_token
)
action_result.attempts = attempt
context = action_result.context or context
if action_result.success:
logger.info(f"{action.name} 执行成功")
return action_result
if attempt < max_attempts and not self._is_cancelled(workflow_id, cancel_token):
wait_seconds = interval * (backoff ** (attempt - 1))
logger.info(f"{action.name} 执行失败,{wait_seconds} 秒后重试({attempt}/{max_attempts}...")
self._sleep_with_cancel(workflow_id, wait_seconds, cancel_token)
logger.error(f"{action.name} 执行失败!")
return action_result
def excute(self, workflow_id: int, action: Action,
context: ActionContext = None) -> Tuple[bool, str, ActionContext]:
"""
执行工作流动作,兼容历史拼写错误的方法名。
"""
action_result = self.execute(workflow_id=workflow_id, action=action, context=context)
return bool(action_result.success), action_result.message or "", action_result.context or context or ActionContext()
@staticmethod
def _normalize_action_result(result: Any, action_obj: Any, fallback_context: ActionContext) -> ActionResult:
"""
将旧版动作上下文与新版结构化结果统一为动作执行结果。
"""
if isinstance(result, ActionResult):
result.context = result.context or fallback_context
if result.message is None:
result.message = action_obj.message
return result
return ActionResult(
success=action_obj.success,
message=action_obj.message,
context=result or fallback_context
)
def _execute_action_once(self, workflow_id: int, action: Action, context: ActionContext,
inputs: dict, runtime: dict, cancel_token: Optional[Any]) -> ActionResult:
action_obj = self._actions[action.type](action.id)
logger.info(f"执行动作: {action.id} - {action.name}")
try:
action_result = self._run_action_with_loop(
workflow_id=workflow_id,
action=action,
action_obj=action_obj,
context=context,
inputs=inputs,
runtime=runtime,
cancel_token=cancel_token
)
except Exception as err:
logger.error(f"{action.name} 执行失败: {err}")
return ActionResult(success=False, message=f"{err}", context=context)
return action_result
def _run_action_with_loop(self, workflow_id: int, action: Action, action_obj: Any,
context: ActionContext, inputs: dict, runtime: dict,
cancel_token: Optional[Any]) -> ActionResult:
timeout = self._get_action_timeout(action)
started_at = monotonic()
action_result = self._call_action(
workflow_id=workflow_id,
action=action,
action_obj=action_obj,
context=context,
inputs=inputs,
runtime=runtime
)
loop = self._get_action_data_value(action, "loop")
loop_interval = self._get_action_data_value(action, "loop_interval")
while loop and loop_interval and not action_obj.done:
if self._is_cancelled(workflow_id, cancel_token):
return ActionResult(success=False, message="工作流已取消", context=action_result.context or context)
if timeout and monotonic() - started_at >= timeout:
return ActionResult(success=False, message=f"动作执行超时({timeout}秒)", context=action_result.context or context)
logger.info(f"{action.name} 等待 {loop_interval} 秒后继续执行 ...")
self._sleep_with_cancel(workflow_id, loop_interval, cancel_token)
if self._is_cancelled(workflow_id, cancel_token):
return ActionResult(success=False, message="工作流已取消", context=action_result.context or context)
logger.info(f"继续执行动作: {action.id} - {action.name}")
action_result = self._call_action(
workflow_id=workflow_id,
action=action,
action_obj=action_obj,
context=action_result.context or context,
inputs=inputs,
runtime=runtime
)
return action_result
def _call_action(self, workflow_id: int, action: Action, action_obj: Any,
context: ActionContext, inputs: dict, runtime: dict) -> ActionResult:
if hasattr(action_obj, "execute_with_inputs"):
result = action_obj.execute_with_inputs(workflow_id, action.data, inputs, runtime, context)
else:
result = action_obj.execute(workflow_id, action.data, context)
return self._normalize_action_result(result, action_obj, context)
@staticmethod
def _get_action_data_value(action: Action, key: str) -> Any:
data = action.data or {}
return data.get(key) if isinstance(data, dict) else None
def _get_action_timeout(self, action: Action) -> Optional[int]:
timeout = action.timeout or self._get_action_data_value(action, "timeout")
return int(timeout) if timeout else None
def _get_retry_config(self, action: Action) -> dict:
retry_config = action.retry or self._get_action_data_value(action, "retry") or {}
if not isinstance(retry_config, dict):
retry_config = {}
return {
"max_attempts": max(int(retry_config.get("max_attempts") or 1), 1),
"interval": max(float(retry_config.get("interval") or 0), 0),
"backoff": max(float(retry_config.get("backoff") or 1), 1),
}
@staticmethod
def _is_cancelled(workflow_id: int, cancel_token: Optional[Any]) -> bool:
if cancel_token and cancel_token.is_cancelled():
return True
return global_vars.is_workflow_stopped(workflow_id)
def _sleep_with_cancel(self, workflow_id: int, seconds: float, cancel_token: Optional[Any]) -> None:
deadline = monotonic() + seconds
while monotonic() < deadline:
if self._is_cancelled(workflow_id, cancel_token):
return
sleep(min(0.1, deadline - monotonic()))
def list_actions(self) -> List[dict]:
"""
获取所有动作
"""
return [
{
"type": key,
"name": action.name,
"description": action.description,
"contract": action.get_contract(),
"data": {
"label": action.name,
**action.data
}
} for key, action in self._actions.items()
]
def get_action_contract(self, action_type: str) -> dict:
"""
获取动作输入输出契约。
"""
action = self._actions.get(action_type)
if not action or not hasattr(action, "get_contract"):
return {}
return action.get_contract()
def update_workflow_event(self, workflow: Workflow):
"""
更新工作流事件触发器
"""
# 工作流可能切换触发事件先按工作流ID从所有事件映射中移除。
self.remove_workflow_event(workflow_id=workflow.id)
# 如果工作流是事件触发类型且未被禁用
if workflow.trigger_type == "event" and workflow.state != 'P':
# 注册事件触发器
self.register_workflow_event(workflow.id, workflow.event_type)
def load_workflow_events(self, workflow_id: Optional[int] = None):
"""
加载工作流触发事件
"""
workflows = []
if workflow_id:
workflow = WorkflowOper().get(workflow_id)
if workflow:
workflows = [workflow]
else:
workflows = WorkflowOper().get_event_triggered_workflows()
try:
for workflow in workflows:
self.update_workflow_event(workflow)
except Exception as e:
logger.error(f"加载事件触发工作流失败: {e}")
def register_workflow_event(self, workflow_id: int, event_type_str: str):
"""
注册工作流事件触发器
"""
if not event_type_str:
return
try:
event_type = EventType(event_type_str)
except ValueError:
logger.error(f"无效的事件类型: {event_type_str}")
return
if event_type in EventType:
with self._lock:
if event_type.value not in self._event_workflows:
self._event_workflows[event_type.value] = []
eventmanager.add_event_listener(event_type, self._handle_event)
# 记录工作流事件触发器
if workflow_id not in self._event_workflows[event_type.value]:
self._event_workflows[event_type.value].append(workflow_id)
logger.info(f"已注册工作流 {workflow_id} 事件触发器: {event_type.value}")
def remove_workflow_event(self, workflow_id: Optional[int] = None, event_type_str: Optional[str] = None):
"""
移除工作流事件触发器
"""
event_type_values = [event_type_str] if event_type_str else list(self._event_workflows.keys())
for event_type_value in event_type_values:
try:
event_type = EventType(event_type_value)
except ValueError:
logger.error(f"无效的事件类型: {event_type_value}")
continue
with self._lock:
workflow_ids = self._event_workflows.get(event_type.value)
if not workflow_ids:
continue
if workflow_id is None:
workflow_ids.clear()
elif workflow_id in workflow_ids:
workflow_ids.remove(workflow_id)
if not workflow_ids:
self._event_workflows.pop(event_type.value, None)
eventmanager.remove_event_listener(event_type, self._handle_event)
logger.info(f"已移除工作流 {workflow_id or ''} 事件触发器")
def _handle_event(self, event: Event):
"""
处理事件,触发相应的工作流
"""
try:
event_type_str = str(event.event_type.value)
with self._lock:
if event_type_str not in self._event_workflows:
return
workflow_ids = self._event_workflows[event_type_str].copy()
for workflow_id in workflow_ids:
self._trigger_workflow(workflow_id, event)
except Exception as e:
logger.error(f"处理工作流事件失败: {e}")
def _trigger_workflow(self, workflow_id: int, event: Event):
"""
触发工作流执行
"""
try:
# 检查工作流是否存在且启用
workflow = WorkflowOper().get(workflow_id)
if not workflow or workflow.state == 'P':
return
# 检查事件条件
if not self._check_event_conditions(workflow, event):
logger.debug(f"工作流 {workflow.name} 事件条件不匹配,跳过执行")
return
# 检查工作流是否正在运行
if workflow.state == 'R':
logger.warning(f"工作流 {workflow.name} 正在运行中,跳过重复触发")
return
logger.info(f"事件 {event.event_type.value} 触发工作流: {workflow.name}")
# 发送工作流执行事件以启动工作流
eventmanager.send_event(EventType.WorkflowExecute, {
"workflow_id": workflow_id,
})
except Exception as e:
logger.error(f"触发工作流 {workflow_id} 失败: {e}")
def _check_event_conditions(self, workflow, event: Event) -> bool:
"""
检查事件是否满足工作流的触发条件
"""
if not workflow.event_conditions:
return True
conditions = workflow.event_conditions
event_data = event.event_data or {}
# 检查字段匹配条件
for field, expected_value in conditions.items():
if field not in event_data:
return False
actual_value = event_data[field]
# 支持多种条件匹配方式
if isinstance(expected_value, dict):
# 复杂条件匹配
if not self._check_complex_condition(actual_value, expected_value):
return False
else:
# 简单值匹配
if actual_value != expected_value:
return False
return True
@staticmethod
def _check_complex_condition(actual_value: any, condition: dict) -> bool:
"""
检查复杂条件匹配
支持的操作符equals, not_equals, contains, not_contains, in, not_in, regex
"""
for operator, expected_value in condition.items():
if operator == "equals":
if actual_value != expected_value:
return False
elif operator == "not_equals":
if actual_value == expected_value:
return False
elif operator == "contains":
if expected_value not in str(actual_value):
return False
elif operator == "not_contains":
if expected_value in str(actual_value):
return False
elif operator == "in":
if actual_value not in expected_value:
return False
elif operator == "not_in":
if actual_value in expected_value:
return False
elif operator == "regex":
import re
if not re.search(expected_value, str(actual_value)):
return False
return True
def get_event_workflows(self) -> dict:
"""
获取所有事件触发的工作流
"""
with self._lock:
return self._event_workflows.copy()