feat: implement cron-based automation task scheduling and update task configuration

This commit is contained in:
shiyu
2026-01-15 15:04:10 +08:00
parent 3e1b75d81a
commit ab89451b2d
14 changed files with 232 additions and 60 deletions

View File

@@ -1,4 +1,5 @@
from .service import TaskService
from .scheduler import task_scheduler
from .task_queue import Task, TaskProgress, TaskStatus, task_queue_service
from .types import (
AutomationTaskBase,
@@ -15,6 +16,7 @@ __all__ = [
"TaskProgress",
"TaskStatus",
"task_queue_service",
"task_scheduler",
"AutomationTaskBase",
"AutomationTaskCreate",
"AutomationTaskRead",

View File

@@ -59,8 +59,7 @@ async def get_task_status(task_id: str, request: Request, current_user: CurrentU
body_fields=[
"name",
"event",
"path_pattern",
"filename_regex",
"trigger_config",
"processor_type",
"processor_config",
"enabled",
@@ -93,8 +92,7 @@ async def list_tasks(request: Request, current_user: CurrentUser):
body_fields=[
"name",
"event",
"path_pattern",
"filename_regex",
"trigger_config",
"processor_type",
"processor_config",
"enabled",

102
domain/tasks/scheduler.py Normal file
View File

@@ -0,0 +1,102 @@
import asyncio
from dataclasses import dataclass
from datetime import datetime
from croniter import croniter
from models.database import AutomationTask
from .task_queue import task_queue_service
@dataclass
class CronTaskItem:
task_id: int
processor_type: str
path: str
cron: croniter
next_run: datetime
class AutomationTaskScheduler:
def __init__(self):
self._items: list[CronTaskItem] = []
self._worker: asyncio.Task | None = None
self._reload_event = asyncio.Event()
self._stop_event = asyncio.Event()
async def start(self) -> None:
if self._worker and not self._worker.done():
return
self._stop_event.clear()
await self._load_tasks()
self._worker = asyncio.create_task(self._run_loop())
async def stop(self) -> None:
if not self._worker:
return
self._stop_event.set()
self._reload_event.set()
await self._worker
self._worker = None
def refresh(self) -> None:
if self._worker and not self._worker.done():
self._reload_event.set()
async def _load_tasks(self) -> None:
tasks = await AutomationTask.filter(event="cron", enabled=True)
items: list[CronTaskItem] = []
now = datetime.now()
for task in tasks:
trigger = task.trigger_config or {}
if not isinstance(trigger, dict):
continue
cron_expr = trigger.get("cron_expr")
path = trigger.get("path")
if not cron_expr or not path:
continue
cron = self._build_cron(cron_expr, now)
if not cron:
continue
next_run = cron.get_next(datetime)
items.append(
CronTaskItem(
task_id=task.id,
processor_type=task.processor_type,
path=path,
cron=cron,
next_run=next_run,
)
)
self._items = items
def _build_cron(self, expr: str, base_time: datetime) -> croniter | None:
expr = str(expr or "").strip()
if not expr:
return None
parts = [p for p in expr.split() if p]
if len(parts) not in (5, 6):
return None
second_at_beginning = len(parts) == 6
try:
return croniter(expr, base_time, second_at_beginning=second_at_beginning)
except Exception:
return None
async def _run_loop(self) -> None:
while not self._stop_event.is_set():
if self._reload_event.is_set():
self._reload_event.clear()
await self._load_tasks()
now = datetime.now()
for item in list(self._items):
if item.next_run <= now:
await task_queue_service.add_task(
item.processor_type,
{"task_id": item.task_id, "path": item.path},
)
item.next_run = item.cron.get_next(datetime)
await asyncio.sleep(1)
task_scheduler = AutomationTaskScheduler()

View File

@@ -5,6 +5,7 @@ from fastapi import Depends, HTTPException
from domain.auth import User, get_current_active_user
from domain.config import ConfigService
from .scheduler import task_scheduler
from .task_queue import task_queue_service
from .types import (
AutomationTaskCreate,
@@ -46,6 +47,7 @@ class TaskService:
@classmethod
async def create_task(cls, payload: AutomationTaskCreate, user: Optional[User]) -> AutomationTask:
task = await AutomationTask.create(**payload.model_dump())
task_scheduler.refresh()
return task
@classmethod
@@ -69,6 +71,7 @@ class TaskService:
for key, value in update_data.items():
setattr(task, key, value)
await task.save()
task_scheduler.refresh()
return task
@classmethod
@@ -76,6 +79,7 @@ class TaskService:
deleted_count = await AutomationTask.filter(id=task_id).delete()
if not deleted_count:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
task_scheduler.refresh()
@classmethod
async def trigger_tasks(cls, event: str, path: str):
@@ -86,11 +90,16 @@ class TaskService:
@classmethod
def match(cls, task: AutomationTask, path: str) -> bool:
if task.path_pattern and not path.startswith(task.path_pattern):
trigger_config = task.trigger_config or {}
if not isinstance(trigger_config, dict):
trigger_config = {}
path_prefix = trigger_config.get("path_prefix")
filename_regex = trigger_config.get("filename_regex")
if path_prefix and not path.startswith(path_prefix):
return False
if task.filename_regex:
if filename_regex:
filename = path.split("/")[-1]
if not re.match(task.filename_regex, filename):
if not re.match(filename_regex, filename):
return False
return True

View File

@@ -88,32 +88,27 @@ class TaskQueueService:
task.result = result
elif task.name == "automation_task" or self._is_processor_task(task.name):
from models.database import AutomationTask
from domain.processors import get_processor
params = task.task_info
auto_task = await AutomationTask.get(id=params["task_id"])
path = params["path"]
processor_type = auto_task.processor_type if task.name == "automation_task" else task.name
processor = get_processor(processor_type)
if not processor:
raise ValueError(f"Processor {processor_type} not found for task {auto_task.id}")
if processor_type != auto_task.processor_type:
processor_type = auto_task.processor_type
processor = get_processor(processor_type)
if not processor:
raise ValueError(f"Processor {processor_type} not found for task {auto_task.id}")
requires_input_bytes = bool(getattr(processor, "requires_input_bytes", True))
file_content = b""
if requires_input_bytes:
file_content = await VirtualFSService.read_file(path)
result = await processor.process(file_content, path, auto_task.processor_config)
save_to = auto_task.processor_config.get("save_to")
if save_to and getattr(processor, "produces_file", False):
await VirtualFSService.write_file(save_to, result)
processor_type = auto_task.processor_type
config = auto_task.processor_config or {}
save_to = config.get("save_to") if isinstance(config, dict) else None
overwrite = bool(config.get("overwrite")) if isinstance(config, dict) else False
try:
if await VirtualFSService.path_is_directory(path):
overwrite = True
except Exception:
pass
await VirtualFSService.process_file(
path=path,
processor_type=processor_type,
config=config if isinstance(config, dict) else {},
save_to=save_to,
overwrite=overwrite,
)
task.result = "Automation task completed"
elif task.name == "offline_http_download":
from domain.offline_downloads import OfflineDownloadService
@@ -129,7 +124,6 @@ class TaskQueueService:
task.result = "Email sent"
else:
raise ValueError(f"Unknown task name: {task.name}")
task.status = TaskStatus.SUCCESS
except Exception as e:

View File

@@ -6,8 +6,7 @@ from pydantic import BaseModel, Field
class AutomationTaskBase(BaseModel):
name: str
event: str
path_pattern: Optional[str] = None
filename_regex: Optional[str] = None
trigger_config: Dict[str, Any] = {}
processor_type: str
processor_config: Dict[str, Any] = {}
enabled: bool = True
@@ -22,6 +21,7 @@ class AutomationTaskUpdate(AutomationTaskBase):
event: Optional[str] = None
processor_type: Optional[str] = None
processor_config: Optional[Dict[str, Any]] = None
trigger_config: Optional[Dict[str, Any]] = None
enabled: Optional[bool] = None

View File

@@ -20,7 +20,7 @@ from middleware.exception_handler import (
)
import httpx
from dotenv import load_dotenv
from domain.tasks import task_queue_service
from domain.tasks import task_queue_service, task_scheduler
load_dotenv()
@@ -73,6 +73,7 @@ async def lifespan(app: FastAPI):
# 加载已安装的插件
from domain.plugins import init_plugins
await init_plugins(app)
await task_scheduler.start()
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
@@ -80,6 +81,7 @@ async def lifespan(app: FastAPI):
try:
yield
finally:
await task_scheduler.stop()
await task_queue_service.stop_worker()
await close_db()

View File

@@ -116,8 +116,7 @@ class AutomationTask(Model):
name = fields.CharField(max_length=100)
event = fields.CharField(max_length=50)
path_pattern = fields.CharField(max_length=1024, null=True)
filename_regex = fields.CharField(max_length=255, null=True)
trigger_config = fields.JSONField(null=True)
processor_type = fields.CharField(max_length=100)
processor_config = fields.JSONField()

View File

@@ -7,6 +7,7 @@ requires-python = ">=3.14"
dependencies = [
"aioboto3>=15.5.0",
"bcrypt>=5.0.0",
"croniter>=6.0.0",
"fastapi>=0.127.0",
"paramiko>=4.0.0",
"pillow>=12.0.0",

15
uv.lock generated
View File

@@ -318,6 +318,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "croniter"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "pytz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -418,6 +431,7 @@ source = { virtual = "." }
dependencies = [
{ name = "aioboto3" },
{ name = "bcrypt" },
{ name = "croniter" },
{ name = "fastapi" },
{ name = "paramiko" },
{ name = "pillow" },
@@ -437,6 +451,7 @@ dependencies = [
requires-dist = [
{ name = "aioboto3", specifier = ">=15.5.0" },
{ name = "bcrypt", specifier = ">=5.0.0" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "fastapi", specifier = ">=0.127.0" },
{ name = "paramiko", specifier = ">=4.0.0" },
{ name = "pillow", specifier = ">=12.0.0" },

View File

@@ -5,8 +5,7 @@ export interface AutomationTask {
id: number;
name: string;
event: string;
path_pattern?: string;
filename_regex?: string;
trigger_config?: Record<string, any>;
processor_type: string;
processor_config: Record<string, any>;
enabled: boolean;

View File

@@ -521,9 +521,12 @@
"Trigger Event": "Trigger Event",
"File Written": "File Written",
"File Deleted": "File Deleted",
"Scheduled": "Scheduled",
"Matching Rules": "Matching Rules",
"Path Prefix (optional)": "Path Prefix (optional)",
"Filename Regex (optional)": "Filename Regex (optional)",
"Schedule": "Schedule",
"Cron Expression": "Cron Expression",
"Action": "Action",
"Current Task Queue": "Current Task Queue",
"Params": "Params",

View File

@@ -512,9 +512,12 @@
"Trigger Event": "触发事件",
"File Written": "文件写入",
"File Deleted": "文件删除",
"Scheduled": "定时任务",
"Matching Rules": "匹配规则",
"Path Prefix (optional)": "路径前缀 (可选)",
"Filename Regex (optional)": "文件名正则 (可选)",
"Schedule": "定时设置",
"Cron Expression": "Cron 表达式",
"Action": "执行动作",
"Current Task Queue": "当前任务队列",
"Params": "参数",
@@ -646,7 +649,6 @@
"Created (newest)": "创建时间(最新)",
"Installed already": "已安装",
"No results": "暂无结果",
"Downloading": "下载中",
"Download and Install": "下载并安装",
"Loading apps": "加载应用中",
"Failed to load apps": "加载应用失败",

View File

@@ -15,7 +15,7 @@ const TasksPage = memo(function TasksPage() {
const [form] = Form.useForm();
const [availableProcessors, setAvailableProcessors] = useState<ProcessorTypeMeta[]>([]);
const { t } = useI18n();
const [pathPickerOpen, setPathPickerOpen] = useState(false);
const [pathPickerField, setPathPickerField] = useState<'path_prefix' | 'cron_path' | null>(null);
const fetchList = useCallback(async () => {
setLoading(true);
@@ -42,7 +42,8 @@ const TasksPage = memo(function TasksPage() {
name: '',
event: 'file_written',
enabled: true,
processor_config: {}
processor_config: {},
trigger_config: {}
});
setOpen(true);
};
@@ -52,7 +53,8 @@ const TasksPage = memo(function TasksPage() {
form.resetFields();
form.setFieldsValue({
...rec,
processor_config: rec.processor_config || {}
processor_config: rec.processor_config || {},
trigger_config: rec.trigger_config || {}
});
setOpen(true);
};
@@ -60,7 +62,15 @@ const TasksPage = memo(function TasksPage() {
const submit = async () => {
try {
const values = await form.validateFields();
const body = { ...values };
const triggerConfig = { ...(values.trigger_config || {}) };
if (values.event === 'cron') {
delete triggerConfig.path_prefix;
delete triggerConfig.filename_regex;
} else {
delete triggerConfig.cron_expr;
delete triggerConfig.path;
}
const body = { ...values, trigger_config: triggerConfig };
setLoading(true);
if (editing) {
await tasksApi.update(editing.id, body);
@@ -133,7 +143,10 @@ const TasksPage = memo(function TasksPage() {
const selectedProcessor = Form.useWatch('processor_type', form);
const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor);
const watchedPathPattern = Form.useWatch('path_pattern', form);
const selectedEvent = Form.useWatch('event', form);
const watchedPathPrefix = Form.useWatch(['trigger_config', 'path_prefix'], form);
const watchedCronPath = Form.useWatch(['trigger_config', 'path'], form);
const isCron = selectedEvent === 'cron';
return (
@@ -158,11 +171,11 @@ const TasksPage = memo(function TasksPage() {
title={editing ? `${t('Edit Task')}: ${editing.name}` : t('Create Automation Task')}
width={480}
open={open}
onClose={() => { setOpen(false); setEditing(null); }}
onClose={() => { setOpen(false); setEditing(null); setPathPickerField(null); }}
destroyOnHidden
extra={
<Space>
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
<Button onClick={() => { setOpen(false); setEditing(null); setPathPickerField(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
</Space>
}
@@ -174,19 +187,45 @@ const TasksPage = memo(function TasksPage() {
<Form.Item name="event" label={t('Trigger Event')} rules={[{ required: true }]}>
<Select options={[
{ value: 'file_written', label: t('File Written') },
{ value: 'file_deleted', label: t('File Deleted') },
{ value: 'file_deleted', label: t('File Deleted') },
{ value: 'cron', label: t('Scheduled') },
]} />
</Form.Item>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
<Form.Item name="path_pattern" label={t('Path Prefix (optional)')}>
<Input
placeholder="/images/screenshots"
addonAfter={<Button size="small" onClick={() => setPathPickerOpen(true)}>{t('Select')}</Button>}
/>
</Form.Item>
<Form.Item name="filename_regex" label={t('Filename Regex (optional)')}>
<Input placeholder=".*\.png$" />
</Form.Item>
{isCron ? (
<>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Schedule')}</Typography.Title>
<Form.Item
name={['trigger_config', 'cron_expr']}
label={t('Cron Expression')}
rules={[{ required: true }]}
>
<Input placeholder="*/5 * * * * *" />
</Form.Item>
<Form.Item
name={['trigger_config', 'path']}
label={t('Target Path')}
rules={[{ required: true }]}
>
<Input
placeholder="/images"
addonAfter={<Button size="small" onClick={() => setPathPickerField('cron_path')}>{t('Select')}</Button>}
/>
</Form.Item>
</>
) : (
<>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
<Form.Item name={['trigger_config', 'path_prefix']} label={t('Path Prefix (optional)')}>
<Input
placeholder="/images/screenshots"
addonAfter={<Button size="small" onClick={() => setPathPickerField('path_prefix')}>{t('Select')}</Button>}
/>
</Form.Item>
<Form.Item name={['trigger_config', 'filename_regex']} label={t('Filename Regex (optional)')}>
<Input placeholder=".*\\.png$" />
</Form.Item>
</>
)}
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
<Switch />
</Form.Item>
@@ -205,11 +244,18 @@ const TasksPage = memo(function TasksPage() {
</Form>
</Drawer>
<PathSelectorModal
open={pathPickerOpen}
mode="directory"
initialPath={watchedPathPattern || '/'}
onCancel={() => setPathPickerOpen(false)}
onOk={(p) => { form.setFieldsValue({ path_pattern: p }); setPathPickerOpen(false); }}
open={!!pathPickerField}
mode={pathPickerField === 'cron_path' ? 'any' : 'directory'}
initialPath={(pathPickerField === 'cron_path' ? watchedCronPath : watchedPathPrefix) || '/'}
onCancel={() => setPathPickerField(null)}
onOk={(p) => {
if (pathPickerField === 'cron_path') {
form.setFieldValue(['trigger_config', 'path'], p);
} else if (pathPickerField === 'path_prefix') {
form.setFieldValue(['trigger_config', 'path_prefix'], p);
}
setPathPickerField(null);
}}
/>
</PageCard>
);