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.
|