fix: tasks should require timer when there are no handler support.

This commit is contained in:
arabcoders 2026-05-05 10:08:44 +03:00
parent 7ac7039592
commit b438947b0d
5 changed files with 384 additions and 11 deletions

View file

@ -151,6 +151,22 @@ def _serialize(model: Any) -> dict:
return _model(model).model_dump()
async def _require_timer_or_handler(task: Task, handler: TaskHandle) -> None:
if task.timer:
return
if task.handler_enabled is False:
msg = "Task requires a timer when the handler is disabled."
raise ValueError(msg)
result = await handler.inspect(url=task.url, preset=task.preset, static_only=True)
if isinstance(result, TaskResult) and result.metadata.get("matched") is True:
return
msg = "Task requires a timer when no supported handler matches the URL."
raise ValueError(msg)
@route("GET", "api/tasks/", "tasks_list")
async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
page, per_page = normalize_pagination(request)
@ -166,7 +182,13 @@ async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder)
@route("POST", "api/tasks/", "tasks_add")
async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
async def tasks_add(
request: Request,
repo: TasksRepository,
encoder: Encoder,
notify: EventBus,
handler: TaskHandle,
) -> Response:
data = await request.json()
if isinstance(data, dict):
@ -205,6 +227,7 @@ async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, n
status=web.HTTPConflict.status_code,
)
pending_tasks: list[dict[str, Any]] = []
created_tasks: list[dict[str, Any]] = []
base_settings = first_task.model_dump(exclude={"id", "name", "url", "created_at", "updated_at"})
@ -243,11 +266,23 @@ async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, n
try:
validated = Task.model_validate(task_dict)
task_data = validated.model_dump()
except ValidationError as exc:
LOG.warning(f"Skipping task {idx}: validation failed - {exc}")
continue
return web.json_response(
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
status=web.HTTPBadRequest.status_code,
)
try:
await _require_timer_or_handler(validated, handler)
except ValueError as exc:
return web.json_response(
data={"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
pending_tasks.append(validated.model_dump())
for idx, task_data in enumerate(pending_tasks):
try:
created = await repo.create(task_data)
saved = _serialize(created)
@ -304,7 +339,13 @@ async def tasks_delete(request: Request, repo: TasksRepository, encoder: Encoder
@route("PATCH", r"api/tasks/{id:\d+}", "tasks_patch")
async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
async def tasks_patch(
request: Request,
repo: TasksRepository,
encoder: Encoder,
notify: EventBus,
handler: TaskHandle,
) -> Response:
if not (identifier := request.match_info.get("id")):
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
@ -334,6 +375,16 @@ async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder,
status=web.HTTPConflict.status_code,
)
merged = _model(model).model_copy(update=validated.model_dump(exclude_unset=True))
try:
await _require_timer_or_handler(merged, handler)
except ValueError as exc:
return web.json_response(
data={"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
return web.json_response(
@ -344,7 +395,13 @@ async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder,
@route("PUT", r"api/tasks/{id:\d+}", "tasks_update")
async def tasks_update(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
async def tasks_update(
request: Request,
repo: TasksRepository,
encoder: Encoder,
notify: EventBus,
handler: TaskHandle,
) -> Response:
if not (identifier := request.match_info.get("id")):
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
@ -374,6 +431,14 @@ async def tasks_update(request: Request, repo: TasksRepository, encoder: Encoder
status=web.HTTPConflict.status_code,
)
try:
await _require_timer_or_handler(validated, handler)
except ValueError as exc:
return web.json_response(
data={"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))

View file

@ -0,0 +1,183 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from app.features.tasks import router
from app.features.tasks.definitions.results import TaskFailure, TaskResult
from app.features.tasks.repository import TasksRepository
from app.library.encoder import Encoder
from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture
async def repo():
TasksRepository._reset_singleton()
SqliteStore._reset_singleton()
store = SqliteStore(db_path=make_in_memory_db_path("tasks-router"))
await store.get_connection()
repository = TasksRepository.get_instance()
yield repository
await store.close()
TasksRepository._reset_singleton()
SqliteStore._reset_singleton()
@pytest.fixture(autouse=True)
def patch_get_info(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_get_info(_url: str, _preset: str) -> tuple[None, None]:
return (None, None)
monkeypatch.setattr(router, "_get_info", _fake_get_info)
def _json_request(method: str, path: str, payload: object, *, match_info: dict[str, str] | None = None) -> web.Request:
request = make_mocked_request(method, path, match_info=match_info or {}, app=SimpleNamespace())
async def _json() -> object:
return payload
request.json = _json # type: ignore[attr-defined]
return request
class _Notify:
def emit(self, *_args, **_kwargs) -> None:
return None
class _Handler:
def __init__(self, matched: bool | dict[str, bool]) -> None:
self._matched = matched
async def inspect(self, *, url: str, preset: str | None = None, static_only: bool = False, **_kwargs):
del preset, static_only
if isinstance(self._matched, dict):
matched = self._matched.get(url, False)
else:
matched = self._matched
if matched:
return TaskResult(metadata={"matched": True, "handler": "TestHandler"})
return TaskFailure(message="No handler", metadata={"matched": False, "handler": None})
@pytest.mark.asyncio
async def test_add_requires_timer_without_handler(repo) -> None:
request = _json_request(
"POST",
"/api/tasks/",
{"name": "No Timer", "url": "https://example.com/channel"},
)
response = await router.tasks_add(request, repo, Encoder(), _Notify(), _Handler(matched=False))
assert response.status == web.HTTPBadRequest.status_code
assert b"requires a timer" in response.body
assert await repo.list() == []
@pytest.mark.asyncio
async def test_add_all_or_nothing(repo) -> None:
request = _json_request(
"POST",
"/api/tasks/",
[
{"name": "First", "url": "https://example.com/first"},
{"url": "https://example.com/second"},
],
)
response = await router.tasks_add(
request,
repo,
Encoder(),
_Notify(),
_Handler({"https://example.com/first": True, "https://example.com/second": False}),
)
assert response.status == web.HTTPBadRequest.status_code
assert b"requires a timer" in response.body
assert await repo.list() == []
@pytest.mark.asyncio
async def test_add_allows_handler_only(repo) -> None:
request = _json_request(
"POST",
"/api/tasks/",
{"name": "Handler Only", "url": "https://example.com/feed"},
)
response = await router.tasks_add(request, repo, Encoder(), _Notify(), _Handler(matched=True))
assert response.status == web.HTTPOk.status_code
items = await repo.list()
assert len(items) == 1
assert items[0].name == "Handler Only"
@pytest.mark.asyncio
async def test_update_requires_timer_without_handler(repo) -> None:
item = await repo.create({"name": "Needs Timer", "url": "https://example.com/a", "timer": "0 0 * * *"})
request = _json_request(
"PUT",
f"/api/tasks/{item.id}",
{"name": item.name, "url": item.url, "timer": "", "preset": "", "folder": "", "template": "", "cli": ""},
match_info={"id": str(item.id)},
)
response = await router.tasks_update(request, repo, Encoder(), _Notify(), _Handler(matched=False))
assert response.status == web.HTTPBadRequest.status_code
refreshed = await repo.get(item.id)
assert refreshed is not None
assert refreshed.timer == "0 0 * * *"
@pytest.mark.asyncio
async def test_patch_requires_timer_without_handler(repo) -> None:
item = await repo.create({"name": "Patch Timer", "url": "https://example.com/b", "timer": "0 0 * * *"})
request = _json_request(
"PATCH",
f"/api/tasks/{item.id}",
{"timer": ""},
match_info={"id": str(item.id)},
)
response = await router.tasks_patch(request, repo, Encoder(), _Notify(), _Handler(matched=False))
assert response.status == web.HTTPBadRequest.status_code
refreshed = await repo.get(item.id)
assert refreshed is not None
assert refreshed.timer == "0 0 * * *"
@pytest.mark.asyncio
async def test_patch_requires_timer_when_handler_disabled(repo) -> None:
item = await repo.create({"name": "Disabled Handler", "url": "https://example.com/c", "timer": "0 0 * * *"})
request = _json_request(
"PATCH",
f"/api/tasks/{item.id}",
{"timer": "", "handler_enabled": False},
match_info={"id": str(item.id)},
)
response = await router.tasks_patch(request, repo, Encoder(), _Notify(), _Handler(matched=True))
assert response.status == web.HTTPBadRequest.status_code
assert b"handler is disabled" in response.body

View file

@ -367,7 +367,9 @@
<UIcon name="i-lucide-rss" class="size-4 text-toned" />
<p class="text-sm font-semibold text-default">Enable Handler</p>
</div>
<p class="text-xs text-toned">Handlers run regardless of task timer.</p>
<p class="text-xs text-toned">
Tasks without a supported handler must use a timer.
</p>
</div>
<USwitch v-model="form.handler_enabled" :disabled="addInProgress" />
</div>
@ -443,6 +445,10 @@
scheduled downloads to yt-dlp. Disable <code>Enable Handler</code> to disable RSS
monitoring.
</li>
<li>
<strong>Timer Requirement:</strong> if no supported handler matches the URL, you must
set a CRON timer before saving.
</li>
</ul>
</template>
</UAlert>
@ -523,6 +529,7 @@ const emitter = defineEmits<{
const toast = useNotification();
const config = useYtpConfig();
const dialog = useDialog();
const tasksComposable = useTasks();
const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
const showImport = useStorage('showTaskImport', false);
@ -786,6 +793,13 @@ const checkInfo = async (): Promise<void> => {
form.cli = form.cli.trim();
}
try {
await requireTimerForTask(form);
} catch (error: any) {
toast.error(error.message);
return;
}
if (urls.length === 1) {
emitter('submit', {
reference: toRaw(props.reference),
@ -811,12 +825,32 @@ const checkInfo = async (): Promise<void> => {
} as Task;
}
return { url } as Task;
return {
url,
preset: form.preset,
timer: form.timer,
handler_enabled: form.handler_enabled,
} as Task;
});
try {
await Promise.all(tasks.map((item) => requireTimerForTask(item)));
} catch (error: any) {
toast.error(error.message);
return;
}
const submitTasks: Task[] = tasks.map((item, idx) => {
if (idx === 0) {
return item;
}
return { url: item.url } as Task;
});
emitter('submit', {
reference: toRaw(props.reference),
task: tasks,
task: submitTasks,
archive_all: archiveAllAfterAdd.value,
});
};
@ -943,6 +977,34 @@ const convertCurrentUrl = async (): Promise<void> => {
form.url = await convert_url(form.url);
};
const requireTimerForTask = async (
item: Pick<Task, 'url' | 'preset' | 'timer' | 'handler_enabled'>,
): Promise<void> => {
if (item.timer?.trim()) {
return;
}
if (item.handler_enabled === false) {
throw new Error('This task needs a CRON timer because the handler is disabled.');
}
const result = await tasksComposable.inspectTaskHandler({
url: item.url,
preset: item.preset,
static_only: true,
});
if (!result) {
throw new Error('Failed to verify handler support. Set a CRON timer or try again.');
}
if (result?.matched) {
return;
}
throw new Error('This task needs a CRON timer because no supported handler matches the URL.');
};
const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string = '') => {
if (false !== hasFormatInConfig.value || !form.preset) {
return ret;

View file

@ -661,8 +661,8 @@
<code>Actions &gt; Archive All</code> to mark existing items as already downloaded.
</li>
<li>
<strong>Custom Handlers:</strong> Leave timer empty for custom handler definitions. The
handler runs hourly and doesn't require timer.
<strong>Timer Requirement:</strong> Tasks must have either a valid timer or a supported
handler. If the URL does not match a handler, set a CRON timer.
</li>
<li>
<strong>Generate metadata:</strong> will attempt first to save metadata based on the

View file

@ -258,6 +258,29 @@ describe('useTasks', () => {
requestSpy.mockRestore()
})
it('store_timer_required_error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { error: 'Task requires a timer when no supported handler matches the URL.' },
}),
)
const tasks = useTasks()
const newTask = { ...mockTask }
delete (newTask as any).id
const result = await tasks.createTask(newTask)
expect(result).toBeNull()
expect(tasks.lastError.value).toBe(
'Task requires a timer when no supported handler matches the URL.',
)
requestSpy.mockRestore()
})
})
describe('updateTask', () => {
@ -280,6 +303,26 @@ describe('useTasks', () => {
requestSpy.mockRestore()
})
it('store_update_timer_error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { error: 'Task requires a timer when the handler is disabled.' },
}),
)
const tasks = useTasks()
const updated = { ...mockTask, timer: '', handler_enabled: false }
const result = await tasks.updateTask(1, updated)
expect(result).toBeNull()
expect(tasks.lastError.value).toBe('Task requires a timer when the handler is disabled.')
requestSpy.mockRestore()
})
it('strip_update_id', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
@ -318,6 +361,26 @@ describe('useTasks', () => {
requestSpy.mockRestore()
})
it('store_patch_timer_error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { error: 'Task requires a timer when no supported handler matches the URL.' },
}),
)
const tasks = useTasks()
const result = await tasks.patchTask(1, { timer: '' })
expect(result).toBeNull()
expect(tasks.lastError.value).toBe(
'Task requires a timer when no supported handler matches the URL.',
)
requestSpy.mockRestore()
})
})
describe('deleteTask', () => {