From 727486795c097502bca6b60cd69144b735e9f97b Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 30 Oct 2025 21:13:08 +0100 Subject: [PATCH] fix download --- data/download_queue.json | 936 +----------------------- features.md | 9 +- infrastructure.md | 25 +- src/server/api/download.py | 111 ++- src/server/services/download_service.py | 24 + src/server/web/static/css/styles.css | 65 +- src/server/web/static/js/queue.js | 237 +++++- src/server/web/templates/queue.html | 21 +- tests/api/test_queue_features.py | 466 ++++++++++++ 9 files changed, 901 insertions(+), 993 deletions(-) create mode 100644 tests/api/test_queue_features.py diff --git a/data/download_queue.json b/data/download_queue.json index 67a44f2..62d4a00 100644 --- a/data/download_queue.json +++ b/data/download_queue.json @@ -1,938 +1,6 @@ { - "pending": [ - { - "id": "9fb9775e-b32a-4135-a6ed-1d1036346f18", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 1, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.943728Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "8aa3bf69-c03d-4989-a55d-02f866d58cd8", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 2, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.943812Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "898eb7fb-db00-4145-b238-367f1618201e", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 3, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.943853Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "6df03270-6ae3-4840-a7ff-68b5f93d3506", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 4, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.943886Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "b3e75c0a-ab96-49a8-9535-f6a14495068f", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 5, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.943917Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "e52a8e4e-6312-4963-9d71-0170240f809b", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 6, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.943947Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "9c09d211-8b9f-406f-9791-4c117c4f3f79", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 7, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.943977Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "915fe96b-0b7c-499b-892b-4f0329582da3", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 8, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944005Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "733a10da-6dad-42cf-b704-d9600289717a", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 9, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944033Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "189406ee-0c60-44c9-9171-ed1c97a9a891", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 10, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944077Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "8ee756c0-3bb9-4f53-a146-680f53e38fec", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 11, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944106Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "15ca7253-ae2e-4d9c-bcf0-8139af247b06", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 12, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944133Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "72f5f99e-a7f0-4314-ba29-3e97a1c9010e", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 1, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944161Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "1b2ee092-eeed-4ec2-bbdb-521193025be6", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 2, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944189Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "4f7efd63-d484-491e-a774-c2895a4cd643", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 3, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944215Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "032b88f3-c213-4148-8f6f-8600eb7099d8", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 4, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944246Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "50df5a01-a27d-4f5b-9916-57224a53ec99", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 5, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944283Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "649e57ab-b03a-449c-aba7-3aeea4cba8da", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 6, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944311Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "78bfa2a4-3791-4676-939e-749c787498e9", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 7, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944339Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "9ee61590-c2b1-43bb-bb13-79615082946c", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 8, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944367Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "58e96572-1114-471d-a00f-0c626c73f4b2", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 9, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944394Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "e3b17f57-dd32-479a-8aab-12b1513073dd", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 10, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944422Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "2fc7ae5a-8d22-4150-9b83-dd35bef7ba4c", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 11, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944450Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "58eb4fae-b54a-4e45-a6ce-279e9840b984", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 2, - "episode": 12, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944477Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "013d610b-10ad-410d-be0e-27ef8be0c5e0", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 1, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944505Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "15fa6d6e-60eb-460f-92d3-7fc9e36e5b8c", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 2, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944533Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "1df97d61-3d97-40d3-9c23-0d2b0de0329b", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 3, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944559Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "bff1235d-812d-46ac-9608-f464277e224f", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 4, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944590Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "f606397b-ef34-4e60-8580-0491faf33d48", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 5, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944618Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "8b2b5bec-df11-4472-b92f-683c6895f231", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 6, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944644Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "5adb2c51-4a9d-41c7-b449-dbd75dea3c7d", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 7, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944672Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "bd9e959d-07bb-4001-991b-37c33cf9ff0a", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 8, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944699Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "420bb3a9-3ea2-4ab3-9342-fd39f1be21ca", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 9, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944726Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "47730909-e8e6-45f1-b5b8-834905a40f25", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 10, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944753Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "5253e107-1bdf-45ad-bc46-215af06f8ae5", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 11, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944779Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "2aba5952-96ea-481b-b53f-911c147e0c2d", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 12, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944806Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "4ab012b9-b33b-4b6d-8edd-b5bfbc7845fb", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 1, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944833Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "f3bac2f8-01af-4b91-96c8-858788a28e50", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 2, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944860Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "2642f866-0f48-486b-a6e1-9c3b60dfa454", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 3, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944887Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "6c457ef9-a9de-4f34-9dd4-d58d61a9c05a", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 4, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944914Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "5eb8c695-ef64-44ef-9508-b9acd3d7308b", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 5, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944941Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "7937162b-813b-4d18-a8df-f90118fcdad9", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 6, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944968Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "36091ee6-763a-4a7b-af3d-faae03cf8aa3", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 7, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.944994Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "2c74156f-7a8b-4d86-a018-ea70ff0f8920", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 8, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.945021Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "2ed313c6-13ff-4118-8a3c-0a88f5834430", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 9, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.945053Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "80ff29a6-129a-4d94-9d44-2fab1993aeb6", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 10, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.945081Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "11e0d511-fc6b-4fe3-8100-d36237dd8108", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 11, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.945108Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "dc115071-bde0-4b95-999e-26b20fd0edce", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 12, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.945134Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "bfaf6a34-bb10-4607-a189-0d6bda974770", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 4, - "episode": 13, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-30T19:42:01.945161Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - } - ], + "pending": [], "active": [], "failed": [], - "timestamp": "2025-10-30T19:42:01.945400+00:00" + "timestamp": "2025-10-30T20:10:45.815431+00:00" } \ No newline at end of file diff --git a/features.md b/features.md index 4ae50b8..b861153 100644 --- a/features.md +++ b/features.md @@ -28,12 +28,19 @@ ## Download Management -- **Download Queue Page**: View and manage the current download queue +- **Download Queue Page**: View and manage the current download queue with organized sections +- **Queue List Display**: Pending downloads shown in an ordered, draggable list +- **Drag-and-Drop Reordering**: Reorder pending items by dragging them to new positions - **Download Status Display**: Real-time status updates and progress of current downloads - **Queue Operations**: Add, remove, prioritize, and reorder items in the download queue - **Queue Control**: Start, stop, pause, and resume download processing +- **Completed Downloads List**: Separate section for completed downloads with clear button +- **Failed Downloads List**: Separate section for failed downloads with retry and clear options - **Retry Failed Downloads**: Automatically retry failed downloads with configurable limits - **Clear Completed**: Remove completed downloads from the queue +- **Clear Failed**: Remove failed downloads from the queue +- **Bulk Operations**: Select and manage multiple queue items at once +- **Queue Statistics**: Real-time counters for pending, active, completed, and failed items ## Real-time Communication diff --git a/infrastructure.md b/infrastructure.md index 3449e21..1cb7ac6 100644 --- a/infrastructure.md +++ b/infrastructure.md @@ -238,10 +238,33 @@ initialization. - `POST /api/queue/stop` - Stop download queue processing - `POST /api/queue/pause` - Pause queue processing - `POST /api/queue/resume` - Resume queue processing -- `POST /api/queue/reorder` - Reorder pending queue items +- `POST /api/queue/reorder` - Reorder pending queue items (bulk or single) - `DELETE /api/queue/completed` - Clear completed downloads +- `DELETE /api/queue/failed` - Clear failed downloads - `POST /api/queue/retry` - Retry failed downloads +**Queue Reordering:** + +- Supports bulk reordering with `{"item_ids": ["id1", "id2", ...]}` payload +- Items are reordered in the exact order provided in the array +- Only affects pending (non-active) downloads +- Real-time drag-and-drop UI with visual feedback + +**Queue Organization:** + +- **Pending Queue**: Items waiting to be downloaded, displayed in order with drag handles +- **Active Downloads**: Currently downloading items with progress bars +- **Completed Downloads**: Successfully downloaded items with completion timestamps +- **Failed Downloads**: Failed items with error messages and retry options + +**Queue Display Features:** + +- Numbered position indicators for pending items +- Drag handle icons for visual reordering cues +- Real-time statistics counters (pending, active, completed, failed) +- Empty state messages with helpful hints +- Per-section action buttons (clear, retry all) + ### WebSocket - `WS /api/ws` - WebSocket connection for real-time updates diff --git a/src/server/api/download.py b/src/server/api/download.py index af4b31f..2631b87 100644 --- a/src/server/api/download.py +++ b/src/server/api/download.py @@ -208,6 +208,40 @@ async def clear_completed( ) +@router.delete("/failed", status_code=status.HTTP_200_OK) +async def clear_failed( + _: dict = Depends(require_auth), + download_service: DownloadService = Depends(get_download_service), +): + """Clear failed downloads from history. + + Removes all failed download items from the queue history. This helps + keep the queue display clean and manageable. + + Requires authentication. + + Returns: + dict: Status message with count of cleared items + + Raises: + HTTPException: 401 if not authenticated, 500 on service error + """ + try: + cleared_count = await download_service.clear_failed() + + return { + "status": "success", + "message": f"Cleared {cleared_count} failed item(s)", + "count": cleared_count, + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to clear failed items: {str(e)}", + ) + + @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_from_queue( item_id: str = Path(..., description="Download item ID to remove"), @@ -485,28 +519,50 @@ async def reorder_queue( _: dict = Depends(require_auth), download_service: DownloadService = Depends(get_download_service), ): - """Reorder an item in the pending queue. + """Reorder items in the pending queue. - Changes the position of a pending download item in the queue. This only - affects items that haven't started downloading yet. The position is - 0-based. + Changes the order of pending download items in the queue. This only + affects items that haven't started downloading yet. Supports both + bulk reordering with item_ids array and single item reorder. Requires authentication. Args: - request: Item ID and new position in queue + request: Either {"item_ids": ["id1", "id2", ...]} for bulk reorder + or {"item_id": "id", "new_position": 0} for single item Returns: - dict: Status message indicating item has been reordered + dict: Status message indicating items have been reordered Raises: HTTPException: 401 if not authenticated, 404 if item not found, 400 for invalid request, 500 on service error """ try: - # Support legacy bulk reorder payload used by some integration tests: - # {"item_order": ["id1", "id2", ...]} - if "item_order" in request: + # Support new bulk reorder payload: {"item_ids": ["id1", "id2", ...]} + if "item_ids" in request: + item_order = request.get("item_ids", []) + if not isinstance(item_order, list): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="item_ids must be a list of item IDs", + ) + + success = await download_service.reorder_queue_bulk(item_order) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="One or more items in item_ids were not found in pending queue", + ) + + return { + "status": "success", + "message": "Queue reordered successfully", + } + + # Support legacy bulk reorder payload: {"item_order": ["id1", "id2", ...]} + elif "item_order" in request: item_order = request.get("item_order", []) if not isinstance(item_order, list): raise HTTPException( @@ -515,6 +571,17 @@ async def reorder_queue( ) success = await download_service.reorder_queue_bulk(item_order) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="One or more items in item_order were not found in pending queue", + ) + + return { + "status": "success", + "message": "Queue item reordered successfully", + } else: # Fallback to single-item reorder shape # Validate request @@ -531,25 +598,16 @@ async def reorder_queue( new_position=req.new_position, ) - if not success: - # Provide an appropriate 404 message depending on request shape - if "item_order" in request: - detail = ( - "One or more items in item_order were not " - "found in pending queue" + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item {req.item_id} not found in pending queue", ) - else: - detail = f"Item {req.item_id} not found in pending queue" - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=detail, - ) - - return { - "status": "success", - "message": "Queue item reordered successfully", - } + return { + "status": "success", + "message": "Queue item reordered successfully", + } except DownloadServiceError as e: raise HTTPException( @@ -596,6 +654,7 @@ async def retry_failed( return { "status": "success", "message": f"Retrying {len(retried_ids)} failed item(s)", + "retried_count": len(retried_ids), "retried_ids": retried_ids, } diff --git a/src/server/services/download_service.py b/src/server/services/download_service.py index 2ee76d1..9da8bd8 100644 --- a/src/server/services/download_service.py +++ b/src/server/services/download_service.py @@ -586,6 +586,30 @@ class DownloadService: return count + async def clear_failed(self) -> int: + """Clear failed downloads from history. + + Returns: + Number of items cleared + """ + count = len(self._failed_items) + self._failed_items.clear() + logger.info("Cleared failed items", count=count) + + # Broadcast queue status update + if count > 0: + queue_status = await self.get_queue_status() + await self._broadcast_update( + "queue_status", + { + "action": "failed_cleared", + "cleared_count": count, + "queue_status": queue_status.model_dump(mode="json"), + }, + ) + + return count + async def retry_failed( self, item_ids: Optional[List[str]] = None ) -> List[str]: diff --git a/src/server/web/static/css/styles.css b/src/server/web/static/css/styles.css index 0e4ad5b..0630cae 100644 --- a/src/server/web/static/css/styles.css +++ b/src/server/web/static/css/styles.css @@ -1218,6 +1218,52 @@ body { background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.05) 0%, transparent 10%); } +/* Drag and Drop Styles */ +.draggable-item { + cursor: move; + user-select: none; +} + +.draggable-item.dragging { + opacity: 0.5; + transform: scale(0.98); + cursor: grabbing; +} + +.draggable-item.drag-over { + border-top: 3px solid var(--color-primary); + margin-top: 8px; +} + +.drag-handle { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-tertiary); + cursor: grab; + font-size: 1.2rem; + padding: var(--spacing-xs); + transition: color var(--transition-duration); +} + +.drag-handle:hover { + color: var(--color-primary); +} + +.drag-handle:active { + cursor: grabbing; +} + +.sortable-list { + position: relative; + min-height: 100px; +} + +.pending-queue-list { + position: relative; +} + .download-header { display: flex; justify-content: space-between; @@ -1261,11 +1307,11 @@ body { .queue-position { position: absolute; top: var(--spacing-sm); - left: var(--spacing-sm); + left: 48px; background: var(--color-warning); color: white; - width: 24px; - height: 24px; + width: 28px; + height: 28px; border-radius: 50%; display: flex; align-items: center; @@ -1275,7 +1321,18 @@ body { } .download-card.pending .download-info { - margin-left: 40px; + margin-left: 80px; +} + +.download-card.pending .download-header { + padding-left: 0; +} + +.empty-state small { + display: block; + margin-top: var(--spacing-sm); + font-size: var(--font-size-small); + opacity: 0.7; } /* Progress Bars */ diff --git a/src/server/web/static/js/queue.js b/src/server/web/static/js/queue.js index 8a9b91c..37ae169 100644 --- a/src/server/web/static/js/queue.js +++ b/src/server/web/static/js/queue.js @@ -7,6 +7,8 @@ class QueueManager { this.socket = null; this.refreshInterval = null; this.isReordering = false; + this.draggedElement = null; + this.draggedId = null; this.init(); } @@ -17,6 +19,7 @@ class QueueManager { this.initTheme(); this.startRefreshTimer(); this.loadQueueData(); + this.initDragAndDrop(); } initSocket() { @@ -249,6 +252,11 @@ class QueueManager { document.getElementById('completed-items').textContent = stats.completed_items || 0; document.getElementById('failed-items').textContent = stats.failed_items || 0; + // Update section counts + document.getElementById('queue-count').textContent = (data.pending_queue || []).length; + document.getElementById('completed-count').textContent = stats.completed_items || 0; + document.getElementById('failed-count').textContent = stats.failed_items || 0; + document.getElementById('current-speed').textContent = stats.current_speed || '0 MB/s'; document.getElementById('average-speed').textContent = stats.average_speed || '0 MB/s'; @@ -331,12 +339,16 @@ class QueueManager {

No items in queue

+ Add episodes from the main page to start downloading
`; return; } container.innerHTML = queue.map((item, index) => this.createPendingQueueCard(item, index)).join(''); + + // Re-attach drag and drop event listeners + this.attachDragListeners(); } createPendingQueueCard(download, index) { @@ -344,7 +356,13 @@ class QueueManager { const priorityClass = download.priority === 'high' ? 'high-priority' : ''; return ` -
+
+
+ +
${index + 1}
@@ -420,7 +438,7 @@ class QueueManager { const retryCount = download.retry_count || 0; return ` -
+

${this.escapeHtml(download.serie_name)}

@@ -441,10 +459,15 @@ class QueueManager { `; } + async removeFailedDownload(downloadId) { + await this.removeFromQueue(downloadId); + } + updateButtonStates(data) { const hasActive = (data.active_downloads || []).length > 0; const hasPending = (data.pending_queue || []).length > 0; const hasFailed = (data.failed_downloads || []).length > 0; + const hasCompleted = (data.completed_downloads || []).length > 0; // Enable start button only if there are pending items and no active downloads document.getElementById('start-queue-btn').disabled = !hasPending || hasActive; @@ -461,8 +484,9 @@ class QueueManager { document.getElementById('pause-all-btn').disabled = !hasActive; document.getElementById('clear-queue-btn').disabled = !hasPending; - document.getElementById('reorder-queue-btn').disabled = !hasPending || (data.pending_queue || []).length < 2; document.getElementById('retry-all-btn').disabled = !hasFailed; + document.getElementById('clear-completed-btn').disabled = !hasCompleted; + document.getElementById('clear-failed-btn').disabled = !hasFailed; } async clearQueue(type) { @@ -483,7 +507,6 @@ class QueueManager { try { if (type === 'completed') { - // Use the new DELETE /api/queue/completed endpoint const response = await this.makeAuthenticatedRequest('/api/queue/completed', { method: 'DELETE' }); @@ -491,11 +514,38 @@ class QueueManager { if (!response) return; const data = await response.json(); - this.showToast(`Cleared ${data.cleared_count} completed downloads`, 'success'); + this.showToast(`Cleared ${data.count} completed downloads`, 'success'); + this.loadQueueData(); + } else if (type === 'failed') { + const response = await this.makeAuthenticatedRequest('/api/queue/failed', { + method: 'DELETE' + }); + + if (!response) return; + const data = await response.json(); + + this.showToast(`Cleared ${data.count} failed downloads`, 'success'); + this.loadQueueData(); + } else if (type === 'pending') { + // Get all pending items + const pendingCards = document.querySelectorAll('#pending-queue .download-card.pending'); + const itemIds = Array.from(pendingCards).map(card => card.dataset.id).filter(id => id); + + if (itemIds.length === 0) { + this.showToast('No pending items to clear', 'info'); + return; + } + + const response = await this.makeAuthenticatedRequest('/api/queue/', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ item_ids: itemIds }) + }); + + if (!response) return; + + this.showToast(`Cleared ${itemIds.length} pending items`, 'success'); this.loadQueueData(); - } else { - // For pending and failed, use the old logic (TODO: implement backend endpoints) - this.showToast(`Clear ${type} not yet implemented`, 'warning'); } } catch (error) { @@ -528,14 +578,31 @@ class QueueManager { const confirmed = await this.showConfirmModal('Retry All Failed Downloads', 'Are you sure you want to retry all failed downloads?'); if (!confirmed) return; - // Get all failed downloads and retry them individually - const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed'); + try { + // Get all failed download IDs + const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed'); + const itemIds = Array.from(failedCards).map(card => card.dataset.id).filter(id => id); - for (const card of failedCards) { - const downloadId = card.dataset.id; - if (downloadId) { - await this.retryDownload(downloadId); + if (itemIds.length === 0) { + this.showToast('No failed downloads to retry', 'info'); + return; } + + const response = await this.makeAuthenticatedRequest('/api/queue/retry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ item_ids: itemIds }) + }); + + if (!response) return; + const data = await response.json(); + + this.showToast(`Retried ${data.retried_count || itemIds.length} download(s)`, 'success'); + this.loadQueueData(); + + } catch (error) { + console.error('Error retrying failed downloads:', error); + this.showToast('Failed to retry downloads', 'error'); } } @@ -632,8 +699,146 @@ class QueueManager { } toggleReorderMode() { - // TODO: Implement reorder functionality - this.showToast('Reorder functionality not yet implemented', 'info'); + // Drag and drop is always enabled, no need for toggle mode + this.showToast('Drag items to reorder the queue', 'info'); + } + + initDragAndDrop() { + // Initialize drag and drop on the pending queue container + const container = document.getElementById('pending-queue'); + if (container) { + container.addEventListener('dragover', this.handleDragOver.bind(this)); + container.addEventListener('drop', this.handleDrop.bind(this)); + } + } + + attachDragListeners() { + // Attach listeners to all draggable items + const items = document.querySelectorAll('.draggable-item'); + items.forEach(item => { + item.addEventListener('dragstart', this.handleDragStart.bind(this)); + item.addEventListener('dragend', this.handleDragEnd.bind(this)); + item.addEventListener('dragenter', this.handleDragEnter.bind(this)); + item.addEventListener('dragleave', this.handleDragLeave.bind(this)); + }); + } + + handleDragStart(e) { + this.draggedElement = e.currentTarget; + this.draggedId = e.currentTarget.dataset.id; + e.currentTarget.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', e.currentTarget.innerHTML); + } + + handleDragEnd(e) { + e.currentTarget.classList.remove('dragging'); + + // Remove all drag-over classes + document.querySelectorAll('.drag-over').forEach(item => { + item.classList.remove('drag-over'); + }); + } + + handleDragOver(e) { + if (e.preventDefault) { + e.preventDefault(); + } + e.dataTransfer.dropEffect = 'move'; + return false; + } + + handleDragEnter(e) { + if (e.currentTarget.classList.contains('draggable-item') && + e.currentTarget !== this.draggedElement) { + e.currentTarget.classList.add('drag-over'); + } + } + + handleDragLeave(e) { + e.currentTarget.classList.remove('drag-over'); + } + + async handleDrop(e) { + if (e.stopPropagation) { + e.stopPropagation(); + } + e.preventDefault(); + + // Get the target element (the item we dropped onto) + let target = e.target; + while (target && !target.classList.contains('draggable-item')) { + target = target.parentElement; + if (target === document.getElementById('pending-queue')) { + return false; + } + } + + if (!target || target === this.draggedElement) { + return false; + } + + // Get all items to determine new order + const container = document.getElementById('pending-queue'); + const items = Array.from(container.querySelectorAll('.draggable-item')); + + const draggedIndex = items.indexOf(this.draggedElement); + const targetIndex = items.indexOf(target); + + if (draggedIndex === targetIndex) { + return false; + } + + // Reorder visually + if (draggedIndex < targetIndex) { + target.parentNode.insertBefore(this.draggedElement, target.nextSibling); + } else { + target.parentNode.insertBefore(this.draggedElement, target); + } + + // Update position numbers + const updatedItems = Array.from(container.querySelectorAll('.draggable-item')); + updatedItems.forEach((item, index) => { + const posElement = item.querySelector('.queue-position'); + if (posElement) { + posElement.textContent = index + 1; + } + item.dataset.index = index; + }); + + // Get the new order of IDs + const newOrder = updatedItems.map(item => item.dataset.id); + + // Send reorder request to backend + await this.reorderQueue(newOrder); + + return false; + } + + async reorderQueue(newOrder) { + try { + const response = await this.makeAuthenticatedRequest('/api/queue/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ item_ids: newOrder }) + }); + + if (!response) return; + + if (response.ok) { + this.showToast('Queue reordered successfully', 'success'); + } else { + const data = await response.json(); + this.showToast(`Failed to reorder: ${data.detail || 'Unknown error'}`, 'error'); + // Reload to restore correct order + this.loadQueueData(); + } + } catch (error) { + console.error('Error reordering queue:', error); + this.showToast('Failed to reorder queue', 'error'); + // Reload to restore correct order + this.loadQueueData(); + } } async makeAuthenticatedRequest(url, options = {}) { diff --git a/src/server/web/templates/queue.html b/src/server/web/templates/queue.html index e60e0b5..ed37bbe 100644 --- a/src/server/web/templates/queue.html +++ b/src/server/web/templates/queue.html @@ -131,7 +131,7 @@

- Download Queue + Download Queue (0)

-
-
+

No items in queue

+ Add episodes from the main page to start downloading
@@ -166,10 +163,10 @@

- Recent Completed + Completed (0)

- @@ -178,8 +175,9 @@
- +

No completed downloads

+ Completed episodes will appear here
@@ -189,14 +187,14 @@

- Failed Downloads + Failed (0)

- @@ -207,6 +205,7 @@

No failed downloads

+ Failed episodes can be retried or removed
diff --git a/tests/api/test_queue_features.py b/tests/api/test_queue_features.py new file mode 100644 index 0000000..b514f8d --- /dev/null +++ b/tests/api/test_queue_features.py @@ -0,0 +1,466 @@ +"""Tests for queue management features. + +This module tests the queue page functionality including: +- Display of queued items in organized lists +- Drag-and-drop reordering +- Starting and stopping queue processing +- Filtering completed and failed downloads +""" +import pytest +from httpx import ASGITransport, AsyncClient + +from src.server.fastapi_app import app + + +@pytest.fixture +async def client(): + """Create an async test client.""" + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://test" + ) as client: + yield client + + +@pytest.fixture +async def auth_headers(client: AsyncClient): + """Get authentication headers with valid JWT token.""" + # Setup auth + await client.post( + "/api/auth/setup", + json={"master_password": "TestPass123!"} + ) + + # Login + response = await client.post( + "/api/auth/login", + json={"password": "TestPass123!"} + ) + + data = response.json() + token = data["access_token"] + + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def sample_download_request(): + """Sample download request for testing.""" + return { + "serie_id": "test-series", + "serie_name": "Test Series", + "episodes": [ + {"season": 1, "episode": 1}, + {"season": 1, "episode": 2} + ], + "priority": "normal" + } + + +class TestQueueDisplay: + """Test queue display and organization.""" + + @pytest.mark.asyncio + async def test_queue_status_includes_all_sections( + self, client: AsyncClient, auth_headers: dict + ): + """Test queue status includes all sections.""" + response = await client.get( + "/api/queue/status", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + + # Verify structure + assert "status" in data + assert "statistics" in data + + status = data["status"] + assert "active" in status + assert "pending" in status + assert "completed" in status + assert "failed" in status + assert "is_running" in status + assert "is_paused" in status + + @pytest.mark.asyncio + async def test_queue_items_have_required_fields( + self, client: AsyncClient, auth_headers: dict, + sample_download_request: dict + ): + """Test queue items have required display fields.""" + # Add an item to the queue + add_response = await client.post( + "/api/queue/add", + json=sample_download_request, + headers=auth_headers + ) + assert add_response.status_code == 201 + + # Get queue status + response = await client.get( + "/api/queue/status", + headers=auth_headers + ) + assert response.status_code == 200 + + data = response.json() + pending = data["status"]["pending"] + + assert len(pending) > 0 + item = pending[0] + + # Verify required fields for display + assert "id" in item + assert "serie_name" in item + assert "episode" in item + assert "priority" in item + assert "added_at" in item + + # Verify episode structure + episode = item["episode"] + assert "season" in episode + assert "episode" in episode + + +class TestQueueReordering: + """Test queue reordering functionality.""" + + @pytest.mark.asyncio + async def test_reorder_queue_with_item_ids( + self, client: AsyncClient, auth_headers: dict + ): + """Test reordering queue using item_ids array.""" + # Clear existing queue first + status_response = await client.get( + "/api/queue/status", + headers=auth_headers + ) + existing_items = [ + item["id"] + for item in status_response.json()["status"]["pending"] + ] + if existing_items: + await client.request( + "DELETE", + "/api/queue/", + json={"item_ids": existing_items}, + headers=auth_headers + ) + + # Add exactly 3 items + added_ids = [] + for i in range(3): + response = await client.post( + "/api/queue/add", + json={ + "serie_id": f"test-{i}", + "serie_name": f"Test Series {i}", + "episodes": [{"season": 1, "episode": i+1}], + "priority": "normal" + }, + headers=auth_headers + ) + if response.status_code == 201: + data = response.json() + if "added_items" in data and data["added_items"]: + added_ids.extend(data["added_items"]) + + assert len(added_ids) == 3, f"Expected 3 items, got {len(added_ids)}" + + # Reverse the order + new_order = list(reversed(added_ids)) + + # Reorder + reorder_response = await client.post( + "/api/queue/reorder", + json={"item_ids": new_order}, + headers=auth_headers + ) + + assert reorder_response.status_code == 200 + assert reorder_response.json()["status"] == "success" + + # Verify new order + status_response = await client.get( + "/api/queue/status", + headers=auth_headers + ) + current_order = [ + item["id"] + for item in status_response.json()["status"]["pending"] + ] + + assert current_order == new_order + + @pytest.mark.asyncio + async def test_reorder_with_invalid_ids( + self, client: AsyncClient, auth_headers: dict + ): + """Test reordering with non-existent IDs succeeds (idempotent).""" + response = await client.post( + "/api/queue/reorder", + json={"item_ids": ["invalid-id-1", "invalid-id-2"]}, + headers=auth_headers + ) + + # Bulk reorder is idempotent and succeeds even with invalid IDs + # It just ignores items that don't exist + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_reorder_empty_list( + self, client: AsyncClient, auth_headers: dict + ): + """Test reordering with empty list.""" + response = await client.post( + "/api/queue/reorder", + json={"item_ids": []}, + headers=auth_headers + ) + + # Should succeed but do nothing + assert response.status_code in [200, 404] + + +class TestQueueControl: + """Test queue start/stop functionality.""" + + @pytest.mark.asyncio + async def test_start_queue( + self, client: AsyncClient, auth_headers: dict + ): + """Test starting the download queue.""" + response = await client.post( + "/api/queue/start", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + @pytest.mark.asyncio + async def test_stop_queue( + self, client: AsyncClient, auth_headers: dict + ): + """Test stopping the download queue.""" + # Start first + await client.post("/api/queue/start", headers=auth_headers) + + # Then stop + response = await client.post( + "/api/queue/stop", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + @pytest.mark.asyncio + async def test_queue_status_reflects_running_state( + self, client: AsyncClient, auth_headers: dict + ): + """Test queue status reflects running state.""" + # Initially not running + status = await client.get( + "/api/queue/status", + headers=auth_headers + ) + assert status.json()["status"]["is_running"] is False + + # Start queue + await client.post("/api/queue/start", headers=auth_headers) + + # Should be running + status = await client.get( + "/api/queue/status", + headers=auth_headers + ) + assert status.json()["status"]["is_running"] is True + + # Stop queue + await client.post("/api/queue/stop", headers=auth_headers) + + # Should not be running + status = await client.get( + "/api/queue/status", + headers=auth_headers + ) + assert status.json()["status"]["is_running"] is False + + +class TestCompletedDownloads: + """Test completed downloads management.""" + + @pytest.mark.asyncio + async def test_clear_completed_downloads( + self, client: AsyncClient, auth_headers: dict + ): + """Test clearing completed downloads.""" + response = await client.delete( + "/api/queue/completed", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert "count" in data + assert data["status"] == "success" + + @pytest.mark.asyncio + async def test_completed_section_count( + self, client: AsyncClient, auth_headers: dict + ): + """Test that completed count is accurate.""" + status = await client.get( + "/api/queue/status", + headers=auth_headers + ) + data = status.json() + + completed_count = data["statistics"]["completed_count"] + completed_list = len(data["status"]["completed"]) + + # Count should match list length + assert completed_count == completed_list + + +class TestFailedDownloads: + """Test failed downloads management.""" + + @pytest.mark.asyncio + async def test_clear_failed_downloads( + self, client: AsyncClient, auth_headers: dict + ): + """Test clearing failed downloads.""" + response = await client.delete( + "/api/queue/failed", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert "count" in data + assert data["status"] == "success" + + @pytest.mark.asyncio + async def test_retry_failed_downloads( + self, client: AsyncClient, auth_headers: dict + ): + """Test retrying failed downloads.""" + response = await client.post( + "/api/queue/retry", + json={"item_ids": []}, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert "retried_count" in data + assert data["status"] == "success" + + @pytest.mark.asyncio + async def test_retry_specific_failed_download( + self, client: AsyncClient, auth_headers: dict + ): + """Test retrying a specific failed download.""" + # Test the endpoint accepts the format + response = await client.post( + "/api/queue/retry", + json={"item_ids": ["some-id"]}, + headers=auth_headers + ) + + # Should succeed even if ID doesn't exist (idempotent) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_failed_section_count( + self, client: AsyncClient, auth_headers: dict + ): + """Test that failed count is accurate.""" + status = await client.get( + "/api/queue/status", + headers=auth_headers + ) + data = status.json() + + failed_count = data["statistics"]["failed_count"] + failed_list = len(data["status"]["failed"]) + + # Count should match list length + assert failed_count == failed_list + + +class TestBulkOperations: + """Test bulk queue operations.""" + + @pytest.mark.asyncio + async def test_remove_multiple_items( + self, client: AsyncClient, auth_headers: dict + ): + """Test removing multiple items from queue.""" + # Add multiple items + item_ids = [] + for i in range(3): + add_response = await client.post( + "/api/queue/add", + json={ + "serie_id": f"bulk-test-{i}", + "serie_name": f"Bulk Test {i}", + "episodes": [{"season": 1, "episode": i+1}], + "priority": "normal" + }, + headers=auth_headers + ) + if add_response.status_code == 201: + data = add_response.json() + if "added_items" in data and len(data["added_items"]) > 0: + item_ids.append(data["added_items"][0]) + + # Remove all at once + if item_ids: + response = await client.request( + "DELETE", + "/api/queue/", + json={"item_ids": item_ids}, + headers=auth_headers + ) + + assert response.status_code == 204 + + @pytest.mark.asyncio + async def test_clear_entire_pending_queue( + self, client: AsyncClient, auth_headers: dict + ): + """Test clearing entire pending queue.""" + # Get all pending items + status = await client.get( + "/api/queue/status", + headers=auth_headers + ) + pending = status.json()["status"]["pending"] + + if pending: + item_ids = [item["id"] for item in pending] + + # Remove all + response = await client.request( + "DELETE", + "/api/queue/", + json={"item_ids": item_ids}, + headers=auth_headers + ) + + assert response.status_code == 204 + + # Verify queue is empty + status = await client.get( + "/api/queue/status", + headers=auth_headers + ) + assert len(status.json()["status"]["pending"]) == 0