Feat: Add new PATCH api for updating the task and improved the no timer messaging
This commit is contained in:
parent
b33cb0a845
commit
ddc71cc1bd
5 changed files with 396 additions and 43 deletions
73
API.md
73
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)
|
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
||||||
- [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark)
|
- [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark)
|
||||||
- [POST /api/tasks/{id}/metadata](#post-apitasksidmetadata)
|
- [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/](#get-apitask_definitions)
|
||||||
- [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier)
|
- [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier)
|
||||||
- [POST /api/task\_definitions/](#post-apitask_definitions)
|
- [POST /api/task\_definitions/](#post-apitask_definitions)
|
||||||
|
|
@ -783,11 +783,29 @@ or on error
|
||||||
{
|
{
|
||||||
"url": "https://example.com/feed", // required, validated URL
|
"url": "https://example.com/feed", // required, validated URL
|
||||||
"preset": "news-preset", // optional preset override - In real scenarios, the preset come from the task.
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"matched": true,
|
"matched": true,
|
||||||
|
|
@ -924,42 +942,63 @@ or
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### POST /api/tasks/{id}/toggle
|
### PATCH /api/tasks/{id}
|
||||||
**Purpose**: Toggle the enabled/disabled status of a scheduled task.
|
**Purpose**: Update specific fields of a scheduled task.
|
||||||
|
|
||||||
**Path Parameter**:
|
**Path Parameter**:
|
||||||
- `id`: Task ID.
|
- `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**:
|
**Response**:
|
||||||
Returns the updated task object:
|
Returns the updated task
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "task_id",
|
"id": "task_id",
|
||||||
"name": "Task Name",
|
"name": "Task Name",
|
||||||
"url": "https://example.com/playlist",
|
"url": "https://example.com/playlist",
|
||||||
"preset": "default",
|
"preset": "audio",
|
||||||
"folder": "",
|
"folder": "downloads/music",
|
||||||
"template": "",
|
"template": "%(title)s.%(ext)s",
|
||||||
"cli": "",
|
"cli": "--extract-audio",
|
||||||
"timer": "0 */6 * * *",
|
"timer": "0 */6 * * *",
|
||||||
"auto_start": true,
|
"auto_start": true,
|
||||||
"handler_enabled": true,
|
"handler_enabled": true,
|
||||||
"enabled": false
|
"enabled": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
or
|
|
||||||
|
**Error Responses**:
|
||||||
```json
|
```json
|
||||||
{ "error": "Task 'task_id' does not exist." }
|
{ "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**:
|
**Status Codes**:
|
||||||
- `200 OK` - Task status toggled successfully, returns updated task object
|
- `200 OK` - Task updated successfully
|
||||||
- `400 Bad Request` - Missing task ID
|
- `400 Bad Request` - Missing task ID, invalid JSON, no valid fields, or validation error
|
||||||
- `404 Not Found` - Task does not exist
|
- `404 Not Found` - Task does not exist
|
||||||
- `500 Internal Server Error` - Failed to update task status
|
- `500 Internal Server Error` - Failed to update task
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -884,6 +884,7 @@ class HandleTask:
|
||||||
url: str,
|
url: str,
|
||||||
preset: str | None = None,
|
preset: str | None = None,
|
||||||
handler_name: str | None = None,
|
handler_name: str | None = None,
|
||||||
|
static_only: bool = False,
|
||||||
) -> TaskResult | TaskFailure:
|
) -> TaskResult | TaskFailure:
|
||||||
if not self._handlers:
|
if not self._handlers:
|
||||||
self._handlers = self._discover()
|
self._handlers = self._discover()
|
||||||
|
|
@ -937,6 +938,9 @@ class HandleTask:
|
||||||
|
|
||||||
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
|
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
|
||||||
|
|
||||||
|
if static_only:
|
||||||
|
return TaskResult(items=[], metadata=base_metadata)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||||
handler=handler_cls.extract, task=task, config=self._config
|
handler=handler_cls.extract, task=task, config=self._config
|
||||||
|
|
|
||||||
|
|
@ -20,22 +20,38 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@route("POST", "api/tasks/inspect", "task_handler_inspect")
|
@route("POST", "api/tasks/inspect", "task_handler_inspect")
|
||||||
async def task_handler_inspect(request: Request, tasks: Tasks, encoder: Encoder, config: Config) -> Response:
|
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()
|
data = await request.json()
|
||||||
|
|
||||||
url: str | None = data.get("url") if isinstance(data, dict) else None
|
url: str | None = data.get("url") if isinstance(data, dict) else None
|
||||||
if not url:
|
if not url:
|
||||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
try:
|
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False
|
||||||
except ValueError as e:
|
if not static_only:
|
||||||
return web.json_response({"error": str(e)}, 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)
|
||||||
|
|
||||||
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
|
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
|
||||||
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
|
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result: TaskResult | TaskFailure = await tasks.get_handler().inspect(
|
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:
|
except Exception as e:
|
||||||
LOG.exception(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)
|
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
|
||||||
@route("POST", "api/tasks/{id}/toggle", "tasks_toggle_enabled")
|
@route("PATCH", "api/tasks/{id}", "tasks_update")
|
||||||
async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response:
|
async def task_update(request: Request, encoder: Encoder) -> Response:
|
||||||
"""
|
"""
|
||||||
Toggle the enabled status of a task.
|
Update specific fields of a task.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request (Request): The request object.
|
request (Request): The request object.
|
||||||
|
|
@ -358,11 +374,23 @@ async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response:
|
||||||
Response: The response object
|
Response: The response object
|
||||||
|
|
||||||
"""
|
"""
|
||||||
task_id: str = request.match_info.get("id", None)
|
if not (task_id := request.match_info.get("id", None)):
|
||||||
|
|
||||||
if not task_id:
|
|
||||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
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()
|
tasks: Tasks = Tasks.get_instance()
|
||||||
try:
|
try:
|
||||||
task: Task | None = tasks.get(task_id)
|
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
|
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()
|
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:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
return web.json_response(
|
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,
|
status=web.HTTPInternalServerError.status_code,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -921,3 +921,156 @@ class TestTasks:
|
||||||
|
|
||||||
# Verify download queue was NOT called (task was skipped)
|
# Verify download queue was NOT called (task was skipped)
|
||||||
mock_download_queue_instance.add.assert_not_called()
|
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
|
||||||
|
|
|
||||||
|
|
@ -170,9 +170,13 @@
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="icon-text">
|
<span class="icon-text is-clickable" @click="toggleHandlerEnabled(item)"
|
||||||
<span class="icon"><i class="fa-solid fa-rss" /></span>
|
v-tooltip="'Click to ' + (item.handler_enabled !== false ? 'disable' : 'enable') + ' task handler'">
|
||||||
<span>{{ item.handler_enabled ? 'Enabled' : 'Disabled' }}</span>
|
<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>
|
</span>
|
||||||
|
|
||||||
<span class="icon-text" v-if="item.preset">
|
<span class="icon-text" v-if="item.preset">
|
||||||
|
|
@ -185,9 +189,13 @@
|
||||||
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
|
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
|
||||||
{{ tryParse(item.timer) }}
|
{{ tryParse(item.timer) }}
|
||||||
</span>
|
</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>
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
<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 }" />
|
:class="{ 'fa-circle-pause has-text-success': item.auto_start, 'fa-circle-play has-text-danger': !item.auto_start }" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control is-clickable" @click="toggleHandlerEnabled(item)">
|
||||||
<span class="icon" v-tooltip="`Task handler is ${item.handler_enabled ? 'enabled' : 'disabled'}`">
|
<span class="icon"
|
||||||
|
v-tooltip="`Task handler is ${item.handler_enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||||
<i class="fa-solid fa-rss"
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-clickable" @click="toggleEnabled(item)">
|
<div class="control is-clickable" @click="toggleEnabled(item)">
|
||||||
|
|
@ -308,15 +317,19 @@
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p class="is-text-overflow">
|
<p class="is-text-overflow">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid"
|
<i class="fa-solid" :class="{
|
||||||
:class="{ 'fa-clock': item.timer, 'has-text-danger fa-exclamation': !item.timer }" />
|
'fa-clock': item.timer,
|
||||||
|
'fa-rss': !item.timer && willTaskBeProcessed(item),
|
||||||
|
'has-text-danger fa-exclamation': !willTaskBeProcessed(item)
|
||||||
|
}" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="item.timer">
|
<span v-if="item.timer">
|
||||||
<NuxtLink target="_blank" :to="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`">
|
<NuxtLink target="_blank" :to="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`">
|
||||||
{{ item.timer }} - {{ tryParse(item.timer) }}
|
{{ item.timer }} - {{ tryParse(item.timer) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</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>
|
||||||
<p class="is-text-overflow" v-if="item.folder">
|
<p class="is-text-overflow" v-if="item.folder">
|
||||||
<span class="icon"><i class="fa-solid fa-folder" /></span>
|
<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 TaskInspect from '~/components/TaskInspect.vue'
|
||||||
import type { task_item, exported_task, error_response } from '~/types/tasks'
|
import type { task_item, exported_task, error_response } from '~/types/tasks'
|
||||||
import { sleep } from '~/utils'
|
import { sleep } from '~/utils'
|
||||||
|
import { useSessionCache } from '~/utils/cache'
|
||||||
|
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const { confirmDialog: cDialog } = useDialog()
|
const { confirmDialog: cDialog } = useDialog()
|
||||||
|
const sessionCache = useSessionCache()
|
||||||
const display_style = useStorage<string>("tasks_display_style", "cards")
|
const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
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 filteredTasks = computed<task_item[]>(() => {
|
||||||
const q = query.value?.toLowerCase();
|
const q = query.value?.toLowerCase();
|
||||||
if (!q) return tasks.value;
|
if (!q) return tasks.value;
|
||||||
|
|
@ -551,6 +626,8 @@ const reloadContent = async (fromMounted: boolean = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.value = data
|
tasks.value = data
|
||||||
|
cleanStaleCache(data)
|
||||||
|
await recheckHandlerSupport(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fromMounted) {
|
if (fromMounted) {
|
||||||
return
|
return
|
||||||
|
|
@ -602,6 +679,8 @@ const updateTasks = async (items: Array<task_item>) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.value = data
|
tasks.value = data
|
||||||
|
cleanStaleCache(data)
|
||||||
|
await recheckHandlerSupport(data)
|
||||||
resetForm(true)
|
resetForm(true)
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -686,7 +765,11 @@ const toggleEnabled = async (item: task_item) => {
|
||||||
const actionText = newStatus ? 'enable' : 'disable'
|
const actionText = newStatus ? 'enable' : 'disable'
|
||||||
|
|
||||||
try {
|
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()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data?.error) {
|
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 }) => {
|
const updateItem = async ({ reference, task, archive_all }: { reference?: string | null | undefined, task: task_item, archive_all?: boolean }) => {
|
||||||
if (reference) {
|
if (reference) {
|
||||||
// -- find the task index.
|
// -- find the task index.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue