Feat: Add new PATCH api for updating the task and improved the no timer messaging

This commit is contained in:
arabcoders 2025-12-08 19:43:06 +03:00
parent b33cb0a845
commit ddc71cc1bd
5 changed files with 396 additions and 43 deletions

73
API.md
View file

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

View file

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

View file

@ -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,
)

View file

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

View file

@ -170,9 +170,13 @@
</span>
</span>
&nbsp;
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-rss" /></span>
<span>{{ item.handler_enabled ? 'Enabled' : 'Disabled' }}</span>
<span class="icon-text is-clickable" @click="toggleHandlerEnabled(item)"
v-tooltip="'Click to ' + (item.handler_enabled !== false ? 'disable' : 'enable') + ' task handler'">
<span class="icon">
<i class="fa-solid fa-rss"
:class="{ 'has-text-success': item.handler_enabled !== false, 'has-text-danger': item.handler_enabled === false }" />
</span>
<span>{{ item.handler_enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</span>
&nbsp;
<span class="icon-text" v-if="item.preset">
@ -185,9 +189,13 @@
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
{{ tryParse(item.timer) }}
</span>
<span v-else class="has-text-danger">
<span v-else-if="!willTaskBeProcessed(item)" class="has-text-danger">
<span class="icon"> <i class="fa-solid fa-exclamation" /> </span>
No timer is set
No timer or handler
</span>
<span v-else>
<span class="icon"> <i class="fa-solid fa-rss" /> </span>
Handler only
</span>
</td>
<td class="is-vcentered is-items-center">
@ -278,10 +286,11 @@
:class="{ 'fa-circle-pause has-text-success': item.auto_start, 'fa-circle-play has-text-danger': !item.auto_start }" />
</span>
</div>
<div class="control">
<span class="icon" v-tooltip="`Task handler is ${item.handler_enabled ? 'enabled' : 'disabled'}`">
<div class="control is-clickable" @click="toggleHandlerEnabled(item)">
<span class="icon"
v-tooltip="`Task handler is ${item.handler_enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
<i class="fa-solid fa-rss"
:class="{ 'has-text-success': item.handler_enabled, 'has-text-danger': !item.handler_enabled }" />
:class="{ 'has-text-success': item.handler_enabled !== false, 'has-text-danger': item.handler_enabled === false }" />
</span>
</div>
<div class="control is-clickable" @click="toggleEnabled(item)">
@ -308,15 +317,19 @@
<div class="content">
<p class="is-text-overflow">
<span class="icon">
<i class="fa-solid"
:class="{ 'fa-clock': item.timer, 'has-text-danger fa-exclamation': !item.timer }" />
<i class="fa-solid" :class="{
'fa-clock': item.timer,
'fa-rss': !item.timer && willTaskBeProcessed(item),
'has-text-danger fa-exclamation': !willTaskBeProcessed(item)
}" />
</span>
<span v-if="item.timer">
<NuxtLink target="_blank" :to="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`">
{{ item.timer }} - {{ tryParse(item.timer) }}
</NuxtLink>
</span>
<span class="has-text-danger" v-else>No timer is set</span>
<span v-else-if="willTaskBeProcessed(item)">Handler only</span>
<span v-else class="has-text-danger">No timer or handler</span>
</p>
<p class="is-text-overflow" v-if="item.folder">
<span class="icon"><i class="fa-solid fa-folder" /></span>
@ -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<string>("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<Record<string, boolean>>(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<task_item>) => {
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<task_item>) => {
for (const task of updatedTasks) {
if (!task.timer && false !== task.handler_enabled) {
await checkHandlerSupport(task)
}
}
}
const checkHandlerSupport = async (task: task_item): Promise<boolean> => {
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<task_item[]>(() => {
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<task_item>) => {
}
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.