Merge pull request #512 from arabcoders/dev

Feat: set_preset key for condition to switch preset based on yt-dlp data
This commit is contained in:
Abdulmohsen 2025-12-08 20:47:37 +03:00 committed by GitHub
commit 2214bc66de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 813 additions and 397 deletions

View file

@ -117,10 +117,12 @@ jobs:
working-directory: ui
env:
NODE_ENV: production
run: pnpm install --frozen-lockfile --prod
run: pnpm install --frozen-lockfile --prod --ignore-scripts
- name: Build frontend
working-directory: ui
env:
NODE_ENV: production
run: pnpm run generate
- name: Install GitPython

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

@ -3,7 +3,12 @@ FROM node:lts-alpine AS node_builder
WORKDIR /app
COPY ui ./
RUN if [ ! -f "/app/exported/index.html" ]; then npm install --production --prefer-offline --frozen-lockfile && npm run generate; else echo "Skipping UI build, already built."; fi
ENV NODE_ENV=production
RUN if [ ! -f "/app/exported/index.html" ]; then \
npm install -g pnpm && \
NODE_ENV=production pnpm install --frozen-lockfile --prod --ignore-scripts && \
pnpm run generate; \
else echo "Skipping UI build, already built."; fi
FROM python:3.13-bookworm AS python_builder
@ -17,8 +22,7 @@ ENV UV_CACHE_DIR=/root/.cache/uv
ENV DEBIAN_FRONTEND=noninteractive
ENV UV_INSTALL_DIR=/usr/bin
SHELL ["/bin/bash","-lc"]
RUN echo 1 && curl -LsSf https://astral.sh/uv/install.sh | sh
COPY --from=astral/uv:latest /uv /usr/bin/
WORKDIR /opt/

View file

@ -828,6 +828,17 @@ class DownloadQueue(metaclass=Singleton):
)
return {"status": "ok"}
if condition.extras.get("set_preset") and (target_preset := condition.extras.get("set_preset")):
if Presets.get_instance().has(target_preset):
log_message: str = f"Switching preset from '{item.preset}' to '{target_preset}' for '{item_title}' as per condition '{condition.name}'."
LOG.info(log_message)
self._notify.emit(Events.LOG_INFO, data={}, title="Preset Switched", message=log_message)
item = item.new_with(preset=target_preset)
else:
LOG.warning(
f"Preset '{target_preset}' specified in condition '{condition.name}' does not exist. Ignoring set_preset."
)
return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
_status, _msg = ytdlp_reject(entry=entry, yt_params=yt_conf)

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

@ -156,7 +156,7 @@ class YTDLPCli:
if self.preset:
if self.preset.cookies and cookie_file is None:
cookie_file = self.preset.get_cookie_file(config=self._config)
cookie_file = self.preset.get_cookies_file(config=self._config)
if self.preset.folder and save_path is None:
save_path = str(Path(self._config.download_path) / self.preset.folder.lstrip("/"))

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

@ -38,18 +38,20 @@
},
"examples": [
{
"id": "c1e2d3f4-5678-1234-9abc-def012345678",
"name": "Audio Only",
"filter": "type=audio",
"cli": "--extract-audio",
"id": "a2b3c4d5-6789-2345-0bcd-ef1234567890",
"name": "Long Videos",
"filter": "duration>3600",
"extras": {
"priority": "high"
"ignore_download": true
}
},
{
"id": "a2b3c4d5-6789-2345-0bcd-ef1234567890",
"name": "Long Videos",
"filter": "duration>3600"
"id": "b3c4d5e6-7890-3456-1cde-f23456789012",
"name": "Use Audio Preset for Music",
"filter": "categories~='Music'",
"extras": {
"set_preset": "audio-only"
}
}
]
}

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

@ -960,7 +960,7 @@ class TestYTDLPCli:
preset = Mock(spec=Preset)
preset.name = "test_preset"
preset.cookies = "preset_cookies"
preset.get_cookie_file = Mock(return_value=mock_cookie_path)
preset.get_cookies_file = Mock(return_value=mock_cookie_path)
preset.folder = None
preset.template = None
preset.cli = None
@ -972,7 +972,7 @@ class TestYTDLPCli:
cli = YTDLPCli(item=item)
command, _ = cli.build()
preset.get_cookie_file.assert_called_once_with(config=mock_config_instance)
preset.get_cookies_file.assert_called_once_with(config=mock_config_instance)
assert "--cookies" in command
assert "/preset/cookies.txt" in command

View file

@ -164,6 +164,10 @@
<b>YTPTube</b> to ignore the download and directly mark the item as archived. this is useful
to skip certain kind of downloads.
</li>
<li>The key <code>set_preset</code> with the name of an existing preset will instruct
<b>YTPTube</b> to switch the download to use the specified preset. This is useful
to apply different download settings based on content type or source.
</li>
</ul>
</div>
</div>

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.

View file

@ -69,7 +69,7 @@ export default defineNuxtConfig({
'@vueuse/nuxt',
'floating-vue/nuxt',
process.env.NODE_ENV === 'development' ? '@nuxt/eslint' : '',
],
].filter(Boolean),
nitro: {
output: {

View file

@ -54,10 +54,10 @@
"devDependencies": {
"@nuxt/eslint": "^1.11.0",
"@nuxt/eslint-config": "^1.11.0",
"@typescript-eslint/parser": "^8.48.0",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
"typescript": "^5.9.3",
"vitest": "^4.0.14",
"vitest": "^4.0.15",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.5"
}

File diff suppressed because it is too large Load diff

42
uv.lock
View file

@ -128,7 +128,7 @@ wheels = [
[[package]]
name = "apprise"
version = "1.9.5"
version = "1.9.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@ -139,9 +139,9 @@ dependencies = [
{ name = "requests-oauthlib" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/16/e39338b8310af9466fab6f4482b542e24cb1fcbb7e36bf00c089c4e015e7/apprise-1.9.5.tar.gz", hash = "sha256:8f3be318bb429c2017470e33928a2e313cbf7600fc74b8184782a37060db366a", size = 1877134 }
sdist = { url = "https://files.pythonhosted.org/packages/a9/a7/bb182d81f35c3fe405505f0976da4b74f942cfdd53c7193b0fe50412aa27/apprise-1.9.6.tar.gz", hash = "sha256:4206be9cb5694a3d08dd8e0393bbb9b36212ac3a7769c2633620055e75c6caef", size = 1921714 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/f1/318762320d966e528dfb9e6be5953fe7df2952156f15ba857cbccafb630c/apprise-1.9.5-py3-none-any.whl", hash = "sha256:1873a8a1b8cf9e44fcbefe0486ed260b590652aea12427f545b37c8566142961", size = 1421011 },
{ url = "https://files.pythonhosted.org/packages/39/df/343d125241f8cd3c9af58fd09688cf2bf59cc1edfd609adafef3556ce8ec/apprise-1.9.6-py3-none-any.whl", hash = "sha256:2fd18e8a5251b6a12f6f9d169f1d895d458d1de36a5faee4db149cedcce51674", size = 1452059 },
]
[[package]]
@ -866,11 +866,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.5.0"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632 }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651 },
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 },
]
[[package]]
@ -1167,7 +1167,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.1"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@ -1176,9 +1176,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125 }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668 },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
]
[[package]]
@ -1510,7 +1510,7 @@ wheels = [
[[package]]
name = "selenium"
version = "4.38.0"
version = "4.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@ -1520,9 +1520,9 @@ dependencies = [
{ name = "urllib3", extra = ["socks"] },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/a0/60a5e7e946420786d57816f64536e21a29f0554706b36f3cba348107024c/selenium-4.38.0.tar.gz", hash = "sha256:c117af6727859d50f622d6d0785b945c5db3e28a45ec12ad85cee2e7cc84fc4c", size = 924101 }
sdist = { url = "https://files.pythonhosted.org/packages/af/19/27c1bf9eb1f7025632d35a956b50746efb4b10aa87f961b263fa7081f4c5/selenium-4.39.0.tar.gz", hash = "sha256:12f3325f02d43b6c24030fc9602b34a3c6865abbb1db9406641d13d108aa1889", size = 928575 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/d3/76c8f4a8d99b9f1ebcf9a611b4dd992bf5ee082a6093cfc649af3d10f35b/selenium-4.38.0-py3-none-any.whl", hash = "sha256:ed47563f188130a6fd486b327ca7ba48c5b11fb900e07d6457befdde320e35fd", size = 9694571 },
{ url = "https://files.pythonhosted.org/packages/58/d0/55a6b7c6f35aad4c8a54be0eb7a52c1ff29a59542fc3e655f0ecbb14456d/selenium-4.39.0-py3-none-any.whl", hash = "sha256:c85f65d5610642ca0f47dae9d5cc117cd9e831f74038bc09fe1af126288200f9", size = 9655249 },
]
[[package]]
@ -1645,11 +1645,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.5.0"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 }
sdist = { url = "https://files.pythonhosted.org/packages/1c/43/554c2569b62f49350597348fc3ac70f786e3c32e7f19d266e19817812dd3/urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1", size = 432585 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
{ url = "https://files.pythonhosted.org/packages/56/1a/9ffe814d317c5224166b23e7c47f606d6e473712a2fad0f704ea9b99f246/urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f", size = 131083 },
]
[package.optional-dependencies]
@ -1787,11 +1787,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.11.12"
version = "2025.12.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/41/53ad8c6e74d6627bd598dfbb8ad7c19d5405e438210ad0bbaf1b288387e7/yt_dlp-2025.11.12.tar.gz", hash = "sha256:5f0795a6b8fc57a5c23332d67d6c6acf819a0b46b91a6324bae29414fa97f052", size = 3076928 }
sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/16/fdebbee6473473a1c0576bd165a50e4a70762484d638c1d59fa9074e175b/yt_dlp-2025.11.12-py3-none-any.whl", hash = "sha256:b47af37bbb16b08efebb36825a280ea25a507c051f93bf413a6e4a0e586c6e79", size = 3279151 },
{ url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464 },
]
[package.optional-dependencies]
@ -1809,11 +1809,11 @@ default = [
[[package]]
name = "yt-dlp-ejs"
version = "0.3.1"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/02/58b16dee54ad7f9f8c4b5b490960478dbbd31a27da4be2c876d8c09ac8e3/yt_dlp_ejs-0.3.1.tar.gz", hash = "sha256:7f2119eb02864800f651fa33825ddfe13d152a1f730fa103d9864f091df24227", size = 33805 }
sdist = { url = "https://files.pythonhosted.org/packages/de/72/57d02cf78eb45126bd171298d6a58a5bd48ce1a398b6b7ff00fc904f1f0c/yt_dlp_ejs-0.3.2.tar.gz", hash = "sha256:31a41292799992bdc913e03c9fac2a8c90c82a5cbbc792b2e3373b01da841e3e", size = 34678 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/fd/34fbdaf0d53386c47e219c532a479766cd9336fde34c00834c8e0123df7a/yt_dlp_ejs-0.3.1-py3-none-any.whl", hash = "sha256:a6e3548874db7c774388931752bb46c7f4642c044b2a189e56968f3d5ecab622", size = 53155 },
{ url = "https://files.pythonhosted.org/packages/9d/0d/1f0d7a735ca60b87953271b15d00eff5eef05f6118390ddf6f81982526ed/yt_dlp_ejs-0.3.2-py3-none-any.whl", hash = "sha256:f2dc6b3d1b909af1f13e021621b0af048056fca5fb07c4db6aa9bbb37a4f66a9", size = 53252 },
]
[[package]]