From ddc71cc1bdcbf4596699f7ee4b539729c02ef241 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 8 Dec 2025 19:43:06 +0300 Subject: [PATCH] Feat: Add new PATCH api for updating the task and improved the no timer messaging --- API.md | 73 ++++++++++++++----- app/library/Tasks.py | 4 ++ app/routes/api/tasks.py | 77 ++++++++++++++++---- app/tests/test_tasks.py | 153 ++++++++++++++++++++++++++++++++++++++++ ui/app/pages/tasks.vue | 132 ++++++++++++++++++++++++++++++---- 5 files changed, 396 insertions(+), 43 deletions(-) diff --git a/API.md b/API.md index 58ba2dd1..ad89365b 100644 --- a/API.md +++ b/API.md @@ -37,7 +37,7 @@ This document describes the available endpoints and their usage. All endpoints r - [POST /api/tasks/{id}/mark](#post-apitasksidmark) - [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark) - [POST /api/tasks/{id}/metadata](#post-apitasksidmetadata) - - [POST /api/tasks/{id}/toggle](#post-apitasksidtoggle) + - [PATCH /api/tasks/{id}](#patch-apitasksid) - [GET /api/task\_definitions/](#get-apitask_definitions) - [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier) - [POST /api/task\_definitions/](#post-apitask_definitions) @@ -783,11 +783,29 @@ or on error { "url": "https://example.com/feed", // required, validated URL "preset": "news-preset", // optional preset override - In real scenarios, the preset come from the task. - "handler": "GenericTaskHandler" // optional explicit handler class name, in real scenarios, the handler is resolved automatically. + "handler": "GenericTaskHandler", // optional explicit handler class name, in real scenarios, the handler is resolved automatically. + "static_only": false // optional, if true performs only can_handle check without extracting items (faster, lightweight) } ``` -**Response (success)**: +**Notes**: +- When `static_only` is `true`, the endpoint only checks if a handler can process the URL using the `can_handle` method +- This is much faster and lighter than the full inspection which calls the handler's `extract` method +- Use `static_only: true` for quick validation checks (e.g., UI indicators) +- Use `static_only: false` (default) for full inspection with item extraction + +**Response (success with static_only: true)**: +```json +{ + "items": [], + "metadata": { + "matched": true, + "handler": "GenericTaskHandler" + } +} +``` + +**Response (success with static_only: false)**: ```json { "matched": true, @@ -924,42 +942,63 @@ or --- -### POST /api/tasks/{id}/toggle -**Purpose**: Toggle the enabled/disabled status of a scheduled task. +### PATCH /api/tasks/{id} +**Purpose**: Update specific fields of a scheduled task. **Path Parameter**: - `id`: Task ID. +**Request Body**: +JSON object with fields to update: +```json +{ + "enabled": false, + "handler_enabled": true, + "timer": "0 */6 * * *", + "preset": "audio", + "folder": "downloads/music", + "template": "%(title)s.%(ext)s", + "cli": "--extract-audio", + "auto_start": true +} +``` + +**Notes**: +- Only include fields you want to update +- All fields are optional + **Response**: -Returns the updated task object: +Returns the updated task ```json { "id": "task_id", "name": "Task Name", "url": "https://example.com/playlist", - "preset": "default", - "folder": "", - "template": "", - "cli": "", + "preset": "audio", + "folder": "downloads/music", + "template": "%(title)s.%(ext)s", + "cli": "--extract-audio", "timer": "0 */6 * * *", "auto_start": true, "handler_enabled": true, "enabled": false } ``` -or + +**Error Responses**: ```json { "error": "Task 'task_id' does not exist." } +{ "error": "Invalid JSON in request body.", "message": "..." } +{ "error": "Request body must be a JSON object." } +{ "error": "No valid fields to update." } +{ "error": "Validation failed: Invalid timer format." } ``` -**Notes**: -- When a task is disabled (`enabled: false`), it will not be executed by the scheduler - **Status Codes**: -- `200 OK` - Task status toggled successfully, returns updated task object -- `400 Bad Request` - Missing task ID +- `200 OK` - Task updated successfully +- `400 Bad Request` - Missing task ID, invalid JSON, no valid fields, or validation error - `404 Not Found` - Task does not exist -- `500 Internal Server Error` - Failed to update task status +- `500 Internal Server Error` - Failed to update task --- diff --git a/app/library/Tasks.py b/app/library/Tasks.py index d1dea321..e6cf86da 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -884,6 +884,7 @@ class HandleTask: url: str, preset: str | None = None, handler_name: str | None = None, + static_only: bool = False, ) -> TaskResult | TaskFailure: if not self._handlers: self._handlers = self._discover() @@ -937,6 +938,9 @@ class HandleTask: base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__} + if static_only: + return TaskResult(items=[], metadata=base_metadata) + try: extraction: TaskResult | TaskFailure = await services.handle_async( handler=handler_cls.extract, task=task, config=self._config diff --git a/app/routes/api/tasks.py b/app/routes/api/tasks.py index 58a29081..e6de60d9 100644 --- a/app/routes/api/tasks.py +++ b/app/routes/api/tasks.py @@ -20,22 +20,38 @@ LOG: logging.Logger = logging.getLogger(__name__) @route("POST", "api/tasks/inspect", "task_handler_inspect") async def task_handler_inspect(request: Request, tasks: Tasks, encoder: Encoder, config: Config) -> Response: + """ + Check if handler can process the given URL. + + Args: + request (Request): The request object. + tasks (Tasks): The tasks instance. + encoder (Encoder): The encoder instance. + config (Config): The config instance. + + Returns: + Response: The response object. + + """ data = await request.json() url: str | None = data.get("url") if isinstance(data, dict) else None if not url: return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code) - try: - validate_url(url, allow_internal=config.allow_internal_urls) - except ValueError as e: - return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code) + + static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False + if not static_only: + try: + validate_url(url, allow_internal=config.allow_internal_urls) + except ValueError as e: + return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code) preset: str = data.get("preset", "") if isinstance(data, dict) else "" handler_name: str | None = data.get("handler") if isinstance(data, dict) else None try: result: TaskResult | TaskFailure = await tasks.get_handler().inspect( - url=url, preset=preset, handler_name=handler_name + url=url, preset=preset, handler_name=handler_name, static_only=static_only ) except Exception as e: LOG.exception(e) @@ -345,10 +361,10 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R 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: +@route("PATCH", "api/tasks/{id}", "tasks_update") +async def task_update(request: Request, encoder: Encoder) -> Response: """ - Toggle the enabled status of a task. + Update specific fields of a task. Args: request (Request): The request object. @@ -358,11 +374,23 @@ async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response: Response: The response object """ - task_id: str = request.match_info.get("id", None) - - if not task_id: + if not (task_id := request.match_info.get("id", None)): return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code) + try: + data = await request.json() + except Exception as e: + return web.json_response( + data={"error": "Invalid JSON in request body.", "message": str(e)}, + status=web.HTTPBadRequest.status_code, + ) + + if not isinstance(data, dict): + return web.json_response( + {"error": "Request body must be a JSON object."}, + status=web.HTTPBadRequest.status_code, + ) + tasks: Tasks = Tasks.get_instance() try: task: Task | None = tasks.get(task_id) @@ -371,13 +399,34 @@ async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response: data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code ) - task.enabled = not task.enabled + task_dict: dict = task.serialize() + protected_fields: set[str] = {"id"} + updated: bool = False + + for key, value in data.items(): + if key in protected_fields or key not in task_dict: + continue + + setattr(task, key, value) + updated = True + + if not updated: + return web.json_response( + data={"error": "No valid fields to update."}, + status=web.HTTPBadRequest.status_code, + ) + + try: + Tasks.validate(task) + except ValueError as e: + return web.json_response({"error": f"Validation failed: {e!s}"}, status=web.HTTPBadRequest.status_code) + tasks.save(tasks=tasks.get_all()).load() - return web.json_response(data=tasks.get(task_id), status=web.HTTPOk.status_code, dumps=encoder.encode) + return web.json_response(data=task, 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)}, + data={"error": "Failed to update task.", "message": str(e)}, status=web.HTTPInternalServerError.status_code, ) diff --git a/app/tests/test_tasks.py b/app/tests/test_tasks.py index 96292506..74c32c0c 100644 --- a/app/tests/test_tasks.py +++ b/app/tests/test_tasks.py @@ -921,3 +921,156 @@ class TestTasks: # Verify download queue was NOT called (task was skipped) mock_download_queue_instance.add.assert_not_called() + + +class TestHandleTaskInspect: + """Test the HandleTask inspect method with static_only parameter.""" + + @pytest.mark.asyncio + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + @patch("app.library.Tasks.Services") + async def test_inspect_with_static_only_true( + self, mock_services, mock_download_queue, mock_scheduler, mock_eventbus, mock_config + ): + """Test inspect with static_only=True returns immediately after can_handle check.""" + 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.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + # Mock Services to simulate successful can_handle + mock_services_instance = Mock() + mock_services_instance.handle_sync = Mock(return_value=True) + mock_services_instance.handle_async = AsyncMock() # Should NOT be called with static_only=True + mock_services.get_instance.return_value = mock_services_instance + + # Create a mock handler + mock_handler = Mock() + mock_handler.__name__ = "TestHandler" + mock_handler.can_handle = Mock(return_value=True) + mock_handler.extract = AsyncMock() # Should NOT be called with static_only=True + + tasks = Tasks(file=None, config=mock_config_instance) + handler = tasks.get_handler() + handler._handlers = [mock_handler] + + # Call inspect with static_only=True + result = await handler.inspect( + url="https://example.com/feed", preset="default", handler_name=None, static_only=True + ) + + # Verify result structure + assert hasattr(result, "items") + assert hasattr(result, "metadata") + assert result.items == [] + assert result.metadata["matched"] is True + assert result.metadata["handler"] == "TestHandler" + + # Verify handle_async (extract) was NOT called + mock_services_instance.handle_async.assert_not_called() + + @pytest.mark.asyncio + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + @patch("app.library.Tasks.Services") + async def test_inspect_with_static_only_false( + self, mock_services, mock_download_queue, mock_scheduler, mock_eventbus, mock_config + ): + """Test inspect with static_only=False performs full extraction.""" + from app.library.Tasks import TaskResult + + 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.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + # Mock Services + mock_services_instance = Mock() + mock_services_instance.handle_sync = Mock(return_value=True) + + # Mock successful extract result + mock_extract_result = TaskResult( + items=[{"url": "https://example.com/video1", "title": "Video 1"}], + metadata={"raw_count": 1}, + ) + mock_services_instance.handle_async = AsyncMock(return_value=mock_extract_result) + mock_services.get_instance.return_value = mock_services_instance + + # Create a mock handler + mock_handler = Mock() + mock_handler.__name__ = "TestHandler" + mock_handler.can_handle = Mock(return_value=True) + mock_handler.extract = AsyncMock(return_value=mock_extract_result) + + tasks = Tasks(file=None, config=mock_config_instance) + handler = tasks.get_handler() + handler._handlers = [mock_handler] + + # Call inspect with static_only=False (default) + result = await handler.inspect(url="https://example.com/feed", preset="default", handler_name=None) + + # Verify result structure includes extracted items + assert hasattr(result, "items") + assert hasattr(result, "metadata") + assert len(result.items) > 0 + assert result.metadata["matched"] is True + assert result.metadata["handler"] == "TestHandler" + assert result.metadata["supported"] is True + + # Verify handle_async (extract) WAS called + mock_services_instance.handle_async.assert_called_once() + + @pytest.mark.asyncio + @patch("app.library.Tasks.Config") + @patch("app.library.Tasks.EventBus") + @patch("app.library.Tasks.Scheduler") + @patch("app.library.Tasks.DownloadQueue") + @patch("app.library.Tasks.Services") + async def test_inspect_static_only_with_no_handler_match( + self, mock_services, mock_download_queue, mock_scheduler, mock_eventbus, mock_config + ): + """Test inspect with static_only=True when no handler matches.""" + from app.library.Tasks import TaskFailure + + 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.get_instance.return_value = Mock() + mock_scheduler.get_instance.return_value = Mock() + mock_download_queue.get_instance.return_value = Mock() + + # Mock Services to simulate failed can_handle + mock_services_instance = Mock() + mock_services_instance.handle_sync = Mock(return_value=False) + mock_services.get_instance.return_value = mock_services_instance + + # Create a mock handler that won't match + mock_handler = Mock() + mock_handler.__name__ = "TestHandler" + mock_handler.can_handle = Mock(return_value=False) + + tasks = Tasks(file=None, config=mock_config_instance) + handler = tasks.get_handler() + handler._handlers = [mock_handler] + + # Call inspect with static_only=True + result = await handler.inspect( + url="https://unsupported.com/feed", preset="default", handler_name=None, static_only=True + ) + + # Verify result is a TaskFailure + assert isinstance(result, TaskFailure) + assert result.metadata["matched"] is False + assert result.metadata["handler"] is None diff --git a/ui/app/pages/tasks.vue b/ui/app/pages/tasks.vue index 26673545..5bec5916 100644 --- a/ui/app/pages/tasks.vue +++ b/ui/app/pages/tasks.vue @@ -170,9 +170,13 @@   - - - {{ item.handler_enabled ? 'Enabled' : 'Disabled' }} + + + + + {{ item.handler_enabled !== false ? 'Enabled' : 'Disabled' }}   @@ -185,9 +189,13 @@ {{ tryParse(item.timer) }} - + - No timer is set + No timer or handler + + + + Handler only @@ -278,10 +286,11 @@ :class="{ 'fa-circle-pause has-text-success': item.auto_start, 'fa-circle-play has-text-danger': !item.auto_start }" /> -
- +
+ + :class="{ 'has-text-success': item.handler_enabled !== false, 'has-text-danger': item.handler_enabled === false }" />
@@ -308,15 +317,19 @@

- + {{ item.timer }} - {{ tryParse(item.timer) }} - No timer is set + Handler only + No timer or handler

@@ -458,12 +471,14 @@ import { useConfirm } from '~/composables/useConfirm' import TaskInspect from '~/components/TaskInspect.vue' import type { task_item, exported_task, error_response } from '~/types/tasks' import { sleep } from '~/utils' +import { useSessionCache } from '~/utils/cache' const box = useConfirm() const toast = useNotification() const config = useConfigStore() const socket = useSocketStore() const { confirmDialog: cDialog } = useDialog() +const sessionCache = useSessionCache() const display_style = useStorage("tasks_display_style", "cards") const isMobile = useMediaQuery({ maxWidth: 1024 }) @@ -527,6 +542,66 @@ watch(() => socket.isConnected, async () => { } }) +const CACHE_KEY = 'tasks:handler_support' +const taskHandlerSupport = ref>(sessionCache.get(CACHE_KEY) || {}) +watch(taskHandlerSupport, (newValue) => sessionCache.set(CACHE_KEY, newValue), { deep: true }) + +const getCacheKey = (task: task_item): string => `${task.id}:${task.url}` + +const cleanStaleCache = (currentTasks: Array) => { + const validKeys = new Set(currentTasks.map(task => getCacheKey(task))) + const cacheKeys = Object.keys(taskHandlerSupport.value) + + for (const key of cacheKeys) { + if (!validKeys.has(key)) { + taskHandlerSupport.value[key] = undefined as any + } + } +} + +const recheckHandlerSupport = async (updatedTasks: Array) => { + for (const task of updatedTasks) { + if (!task.timer && false !== task.handler_enabled) { + await checkHandlerSupport(task) + } + } +} + +const checkHandlerSupport = async (task: task_item): Promise => { + const cacheKey = getCacheKey(task) + + if (undefined !== taskHandlerSupport.value[cacheKey]) { + return taskHandlerSupport.value[cacheKey] as boolean + } + + try { + const response = await request('/api/tasks/inspect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: task.url, static_only: true }) + }) + const data = await response.json() + const supported = true === data?.matched + taskHandlerSupport.value[cacheKey] = supported + return supported + } catch { + taskHandlerSupport.value[cacheKey] = false + return false + } +} + +const willTaskBeProcessed = (task: task_item): boolean => { + if (false === task.enabled) { + return false + } + + const hasTimer = !!(task.timer && task.timer.trim()) + const cacheKey = getCacheKey(task) + const hasHandler = false !== task.handler_enabled && true === taskHandlerSupport.value[cacheKey] + + return hasTimer || hasHandler +} + const filteredTasks = computed(() => { const q = query.value?.toLowerCase(); if (!q) return tasks.value; @@ -551,6 +626,8 @@ const reloadContent = async (fromMounted: boolean = false) => { } tasks.value = data + cleanStaleCache(data) + await recheckHandlerSupport(data) } catch (e) { if (fromMounted) { return @@ -602,6 +679,8 @@ const updateTasks = async (items: Array) => { } tasks.value = data + cleanStaleCache(data) + await recheckHandlerSupport(data) resetForm(true) return true } catch (e: any) { @@ -686,7 +765,11 @@ const toggleEnabled = async (item: task_item) => { const actionText = newStatus ? 'enable' : 'disable' try { - const response = await request(`/api/tasks/${item.id}/toggle`, { method: 'POST' }) + const response = await request(`/api/tasks/${item.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: newStatus }) + }) const data = await response.json() if (data?.error) { @@ -702,6 +785,31 @@ const toggleEnabled = async (item: task_item) => { } } +const toggleHandlerEnabled = async (item: task_item) => { + const newStatus = !item.handler_enabled + const actionText = newStatus ? 'enable' : 'disable' + + try { + const response = await request(`/api/tasks/${item.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handler_enabled: newStatus }) + }) + const data = await response.json() + + if (data?.error) { + toast.error(data.error) + return + } + + item.handler_enabled = data.handler_enabled + const func = data.handler_enabled ? 'success' : 'warning' + toast[func](`Task handler for '${item.name}' ${data.handler_enabled ? 'enabled' : 'disabled'}.`) + } catch (e: any) { + toast.error(`Failed to ${actionText} task handler. ${e.message || 'Unknown error.'}`) + } +} + const updateItem = async ({ reference, task, archive_all }: { reference?: string | null | undefined, task: task_item, archive_all?: boolean }) => { if (reference) { // -- find the task index.