diff --git a/app/library/Tasks.py b/app/library/Tasks.py index d91beb58..2d1ea8df 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -38,6 +38,7 @@ class Task: cli: str = "" auto_start: bool = True handler_enabled: bool = True + enabled: bool = True def serialize(self) -> dict: return self.__dict__ @@ -301,7 +302,14 @@ class Tasks(metaclass=Singleton): "The tasks file." self._encoder: Encoder = encoder or Encoder() "The JSON encoder." - self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + if loop: + self._loop: asyncio.AbstractEventLoop = loop + else: + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) "The event loop." self._scheduler: Scheduler = scheduler or Scheduler.get_instance() "The scheduler." @@ -402,9 +410,14 @@ class Tasks(metaclass=Singleton): if not tasks or len(tasks) < 1: return self + needs_update: bool = False for i, task in enumerate(tasks): try: Tasks.validate(task) + if "enabled" not in task: + task["enabled"] = True + needs_update = True + task: Task = init_class(Task, task) except Exception as e: LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.") @@ -432,6 +445,9 @@ class Tasks(metaclass=Singleton): LOG.exception(e) LOG.error(f"Failed to queue '{i}: {task.name}'. '{e!s}'.") + if needs_update: + self.save(self._tasks) + return self def clear(self, shutdown: bool = False) -> "Tasks": @@ -565,7 +581,18 @@ class Tasks(metaclass=Singleton): """ timeNow: str = datetime.now(UTC).isoformat() try: + if not self.get(task.id): + LOG.info(f"Task '{task.name}' no longer exists.") + if self._scheduler.has(task.id): + self._scheduler.remove(task.id) + return + started: float = time.time() + + if not task.enabled: + LOG.debug(f"Task '{task.name}' is disabled. Skipping execution.") + return + if not task.url: LOG.error(f"Failed to dispatch '{task.name}'. No URL found.") return @@ -677,6 +704,9 @@ class HandleTask: s: dict[list[str]] = {"h": [], "d": [], "u": [], "f": []} for task in self._tasks.get_all(): + if not task.enabled: + continue + if not task.handler_enabled: s["d"].append(task.name) continue diff --git a/app/routes/api/tasks.py b/app/routes/api/tasks.py index 7a01054e..58a29081 100644 --- a/app/routes/api/tasks.py +++ b/app/routes/api/tasks.py @@ -343,3 +343,41 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode) except ValueError as e: return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code) + + +@route("POST", "api/tasks/{id}/toggle", "tasks_toggle_enabled") +async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response: + """ + Toggle the enabled status of a task. + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object + + """ + task_id: str = request.match_info.get("id", None) + + if not task_id: + return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code) + + tasks: Tasks = Tasks.get_instance() + try: + task: Task | None = tasks.get(task_id) + if not task: + return web.json_response( + data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code + ) + + task.enabled = not task.enabled + tasks.save(tasks=tasks.get_all()).load() + + return web.json_response(data=tasks.get(task_id), status=web.HTTPOk.status_code, dumps=encoder.encode) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": "Failed to toggle task status.", "message": str(e)}, + status=web.HTTPInternalServerError.status_code, + ) diff --git a/app/schema/tasks.json b/app/schema/tasks.json index 74396c7e..b2da2a62 100644 --- a/app/schema/tasks.json +++ b/app/schema/tasks.json @@ -67,6 +67,11 @@ "type": "boolean", "description": "Controls whether the matched handler remains enabled for this task.", "default": true + }, + "enabled": { + "type": "boolean", + "description": "Controls whether the task is enabled and will be executed by the scheduler.", + "default": true } }, "examples": [ diff --git a/app/tests/test_tasks.py b/app/tests/test_tasks.py index 6be34e0b..96292506 100644 --- a/app/tests/test_tasks.py +++ b/app/tests/test_tasks.py @@ -24,6 +24,7 @@ class TestTask: assert task.cli == "" assert task.auto_start is True assert task.handler_enabled is True + assert task.enabled is True def test_task_creation_with_all_fields(self): """Test creating a task with all fields specified.""" @@ -38,6 +39,7 @@ class TestTask: cli="--extract-flat", auto_start=False, handler_enabled=False, + enabled=False, ) assert task.id == "test_id" @@ -50,6 +52,7 @@ class TestTask: assert task.cli == "--extract-flat" assert task.auto_start is False assert task.handler_enabled is False + assert task.enabled is False def test_task_serialize(self): """Test task serialization to dictionary.""" @@ -757,8 +760,12 @@ class TestTasks: template="test_template", cli="--write-info-json", auto_start=True, + enabled=True, ) + # Add task to tasks list so it can be found by _runner + tasks._tasks.append(test_task) + await tasks._runner(test_task) # Verify download queue was called @@ -813,9 +820,13 @@ class TestTasks: test_task = Task( id="task1", name="Test Task", - url="", # Empty URL + url="", # Empty URL to trigger error + enabled=True, ) + # Add task to tasks list so it can be found by _runner + tasks._tasks.append(test_task) + await tasks._runner(test_task) # Verify download queue was NOT called @@ -857,8 +868,12 @@ class TestTasks: id="task1", name="Test Task", url="https://example.com/video", + enabled=True, ) + # Add task to tasks list so it can be found by _runner + tasks._tasks.append(test_task) + await tasks._runner(test_task) # Verify error event was emitted (check last call) @@ -867,3 +882,42 @@ class TestTasks: # Verify the last call was an error event last_call = calls[-1] assert "Task failed" in str(last_call) + + @pytest.mark.asyncio + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + async def test_tasks_runner_disabled_task( + self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config + ): + """Test that disabled tasks are skipped by the runner.""" + mock_config_instance = Mock( + debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *" + ) + mock_config.get_instance.return_value = mock_config_instance + mock_eventbus_instance = Mock() + mock_eventbus.get_instance.return_value = mock_eventbus_instance + mock_scheduler.get_instance.return_value = Mock() + + # Mock download queue + mock_download_queue_instance = Mock() + mock_download_queue_instance.add = AsyncMock(return_value={"success": True, "id": "download123"}) + mock_download_queue.get_instance.return_value = mock_download_queue_instance + + tasks = Tasks(file=None, config=mock_config_instance) + + test_task = Task( + id="task1", + name="Test Task", + url="https://example.com/video", + enabled=False, # Task is disabled + ) + + # Add task to tasks list so it can be found by _runner + tasks._tasks.append(test_task) + + await tasks._runner(test_task) + + # Verify download queue was NOT called (task was skipped) + mock_download_queue_instance.add.assert_not_called() diff --git a/ui/app/components/TaskForm.vue b/ui/app/components/TaskForm.vue index d0089766..9e9a439b 100644 --- a/ui/app/components/TaskForm.vue +++ b/ui/app/components/TaskForm.vue @@ -103,6 +103,46 @@ +
channel_id or playlist_id URLs. Other link
@@ -386,6 +405,10 @@ onMounted(() => {
form.handler_enabled = true
}
+ if (typeof form.enabled === 'undefined' || null === form.enabled) {
+ form.enabled = true
+ }
+
})
const checkInfo = async (): Promise