fix download

This commit is contained in:
Lukas 2025-10-30 21:13:08 +01:00
parent dbb5701660
commit 727486795c
9 changed files with 901 additions and 993 deletions

View File

@ -1,938 +1,6 @@
{ {
"pending": [ "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
}
],
"active": [], "active": [],
"failed": [], "failed": [],
"timestamp": "2025-10-30T19:42:01.945400+00:00" "timestamp": "2025-10-30T20:10:45.815431+00:00"
} }

View File

@ -28,12 +28,19 @@
## Download Management ## 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 - **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 Operations**: Add, remove, prioritize, and reorder items in the download queue
- **Queue Control**: Start, stop, pause, and resume download processing - **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 - **Retry Failed Downloads**: Automatically retry failed downloads with configurable limits
- **Clear Completed**: Remove completed downloads from the queue - **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 ## Real-time Communication

View File

@ -238,10 +238,33 @@ initialization.
- `POST /api/queue/stop` - Stop download queue processing - `POST /api/queue/stop` - Stop download queue processing
- `POST /api/queue/pause` - Pause queue processing - `POST /api/queue/pause` - Pause queue processing
- `POST /api/queue/resume` - Resume 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/completed` - Clear completed downloads
- `DELETE /api/queue/failed` - Clear failed downloads
- `POST /api/queue/retry` - Retry 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 ### WebSocket
- `WS /api/ws` - WebSocket connection for real-time updates - `WS /api/ws` - WebSocket connection for real-time updates

View File

@ -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) @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_from_queue( async def remove_from_queue(
item_id: str = Path(..., description="Download item ID to remove"), item_id: str = Path(..., description="Download item ID to remove"),
@ -485,28 +519,50 @@ async def reorder_queue(
_: dict = Depends(require_auth), _: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service), 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 Changes the order of pending download items in the queue. This only
affects items that haven't started downloading yet. The position is affects items that haven't started downloading yet. Supports both
0-based. bulk reordering with item_ids array and single item reorder.
Requires authentication. Requires authentication.
Args: 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: Returns:
dict: Status message indicating item has been reordered dict: Status message indicating items have been reordered
Raises: Raises:
HTTPException: 401 if not authenticated, 404 if item not found, HTTPException: 401 if not authenticated, 404 if item not found,
400 for invalid request, 500 on service error 400 for invalid request, 500 on service error
""" """
try: try:
# Support legacy bulk reorder payload used by some integration tests: # Support new bulk reorder payload: {"item_ids": ["id1", "id2", ...]}
# {"item_order": ["id1", "id2", ...]} if "item_ids" in request:
if "item_order" 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", []) item_order = request.get("item_order", [])
if not isinstance(item_order, list): if not isinstance(item_order, list):
raise HTTPException( raise HTTPException(
@ -515,6 +571,17 @@ async def reorder_queue(
) )
success = await download_service.reorder_queue_bulk(item_order) 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: else:
# Fallback to single-item reorder shape # Fallback to single-item reorder shape
# Validate request # Validate request
@ -531,25 +598,16 @@ async def reorder_queue(
new_position=req.new_position, new_position=req.new_position,
) )
if not success: if not success:
# Provide an appropriate 404 message depending on request shape raise HTTPException(
if "item_order" in request: status_code=status.HTTP_404_NOT_FOUND,
detail = ( detail=f"Item {req.item_id} not found in pending queue",
"One or more items in item_order were not "
"found in pending queue"
) )
else:
detail = f"Item {req.item_id} not found in pending queue"
raise HTTPException( return {
status_code=status.HTTP_404_NOT_FOUND, "status": "success",
detail=detail, "message": "Queue item reordered successfully",
) }
return {
"status": "success",
"message": "Queue item reordered successfully",
}
except DownloadServiceError as e: except DownloadServiceError as e:
raise HTTPException( raise HTTPException(
@ -596,6 +654,7 @@ async def retry_failed(
return { return {
"status": "success", "status": "success",
"message": f"Retrying {len(retried_ids)} failed item(s)", "message": f"Retrying {len(retried_ids)} failed item(s)",
"retried_count": len(retried_ids),
"retried_ids": retried_ids, "retried_ids": retried_ids,
} }

View File

@ -586,6 +586,30 @@ class DownloadService:
return count 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( async def retry_failed(
self, item_ids: Optional[List[str]] = None self, item_ids: Optional[List[str]] = None
) -> List[str]: ) -> List[str]:

View File

@ -1218,6 +1218,52 @@ body {
background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.05) 0%, transparent 10%); 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 { .download-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -1261,11 +1307,11 @@ body {
.queue-position { .queue-position {
position: absolute; position: absolute;
top: var(--spacing-sm); top: var(--spacing-sm);
left: var(--spacing-sm); left: 48px;
background: var(--color-warning); background: var(--color-warning);
color: white; color: white;
width: 24px; width: 28px;
height: 24px; height: 28px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -1275,7 +1321,18 @@ body {
} }
.download-card.pending .download-info { .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 */ /* Progress Bars */

View File

@ -7,6 +7,8 @@ class QueueManager {
this.socket = null; this.socket = null;
this.refreshInterval = null; this.refreshInterval = null;
this.isReordering = false; this.isReordering = false;
this.draggedElement = null;
this.draggedId = null;
this.init(); this.init();
} }
@ -17,6 +19,7 @@ class QueueManager {
this.initTheme(); this.initTheme();
this.startRefreshTimer(); this.startRefreshTimer();
this.loadQueueData(); this.loadQueueData();
this.initDragAndDrop();
} }
initSocket() { initSocket() {
@ -249,6 +252,11 @@ class QueueManager {
document.getElementById('completed-items').textContent = stats.completed_items || 0; document.getElementById('completed-items').textContent = stats.completed_items || 0;
document.getElementById('failed-items').textContent = stats.failed_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('current-speed').textContent = stats.current_speed || '0 MB/s';
document.getElementById('average-speed').textContent = stats.average_speed || '0 MB/s'; document.getElementById('average-speed').textContent = stats.average_speed || '0 MB/s';
@ -331,12 +339,16 @@ class QueueManager {
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-list"></i> <i class="fas fa-list"></i>
<p>No items in queue</p> <p>No items in queue</p>
<small>Add episodes from the main page to start downloading</small>
</div> </div>
`; `;
return; return;
} }
container.innerHTML = queue.map((item, index) => this.createPendingQueueCard(item, index)).join(''); container.innerHTML = queue.map((item, index) => this.createPendingQueueCard(item, index)).join('');
// Re-attach drag and drop event listeners
this.attachDragListeners();
} }
createPendingQueueCard(download, index) { createPendingQueueCard(download, index) {
@ -344,7 +356,13 @@ class QueueManager {
const priorityClass = download.priority === 'high' ? 'high-priority' : ''; const priorityClass = download.priority === 'high' ? 'high-priority' : '';
return ` return `
<div class="download-card pending ${priorityClass}" data-id="${download.id}"> <div class="download-card pending ${priorityClass} draggable-item"
data-id="${download.id}"
data-index="${index}"
draggable="true">
<div class="drag-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="queue-position">${index + 1}</div> <div class="queue-position">${index + 1}</div>
<div class="download-header"> <div class="download-header">
<div class="download-info"> <div class="download-info">
@ -420,7 +438,7 @@ class QueueManager {
const retryCount = download.retry_count || 0; const retryCount = download.retry_count || 0;
return ` return `
<div class="download-card failed"> <div class="download-card failed" data-id="${download.id}">
<div class="download-header"> <div class="download-header">
<div class="download-info"> <div class="download-info">
<h4>${this.escapeHtml(download.serie_name)}</h4> <h4>${this.escapeHtml(download.serie_name)}</h4>
@ -441,10 +459,15 @@ class QueueManager {
`; `;
} }
async removeFailedDownload(downloadId) {
await this.removeFromQueue(downloadId);
}
updateButtonStates(data) { updateButtonStates(data) {
const hasActive = (data.active_downloads || []).length > 0; const hasActive = (data.active_downloads || []).length > 0;
const hasPending = (data.pending_queue || []).length > 0; const hasPending = (data.pending_queue || []).length > 0;
const hasFailed = (data.failed_downloads || []).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 // Enable start button only if there are pending items and no active downloads
document.getElementById('start-queue-btn').disabled = !hasPending || hasActive; document.getElementById('start-queue-btn').disabled = !hasPending || hasActive;
@ -461,8 +484,9 @@ class QueueManager {
document.getElementById('pause-all-btn').disabled = !hasActive; document.getElementById('pause-all-btn').disabled = !hasActive;
document.getElementById('clear-queue-btn').disabled = !hasPending; 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('retry-all-btn').disabled = !hasFailed;
document.getElementById('clear-completed-btn').disabled = !hasCompleted;
document.getElementById('clear-failed-btn').disabled = !hasFailed;
} }
async clearQueue(type) { async clearQueue(type) {
@ -483,7 +507,6 @@ class QueueManager {
try { try {
if (type === 'completed') { if (type === 'completed') {
// Use the new DELETE /api/queue/completed endpoint
const response = await this.makeAuthenticatedRequest('/api/queue/completed', { const response = await this.makeAuthenticatedRequest('/api/queue/completed', {
method: 'DELETE' method: 'DELETE'
}); });
@ -491,11 +514,38 @@ class QueueManager {
if (!response) return; if (!response) return;
const data = await response.json(); 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(); 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) { } 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?'); const confirmed = await this.showConfirmModal('Retry All Failed Downloads', 'Are you sure you want to retry all failed downloads?');
if (!confirmed) return; if (!confirmed) return;
// Get all failed downloads and retry them individually try {
const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed'); // 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) { if (itemIds.length === 0) {
const downloadId = card.dataset.id; this.showToast('No failed downloads to retry', 'info');
if (downloadId) { return;
await this.retryDownload(downloadId);
} }
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() { toggleReorderMode() {
// TODO: Implement reorder functionality // Drag and drop is always enabled, no need for toggle mode
this.showToast('Reorder functionality not yet implemented', 'info'); 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 = {}) { async makeAuthenticatedRequest(url, options = {}) {

View File

@ -131,7 +131,7 @@
<div class="section-header"> <div class="section-header">
<h2> <h2>
<i class="fas fa-clock"></i> <i class="fas fa-clock"></i>
Download Queue Download Queue (<span id="queue-count">0</span>)
</h2> </h2>
<div class="section-actions"> <div class="section-actions">
<button id="start-queue-btn" class="btn btn-primary" disabled> <button id="start-queue-btn" class="btn btn-primary" disabled>
@ -146,17 +146,14 @@
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
Clear Queue Clear Queue
</button> </button>
<button id="reorder-queue-btn" class="btn btn-secondary" disabled>
<i class="fas fa-sort"></i>
Reorder
</button>
</div> </div>
</div> </div>
<div class="pending-queue-list" id="pending-queue"> <div class="pending-queue-list sortable-list" id="pending-queue" data-sortable="true">
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-list"></i> <i class="fas fa-list"></i>
<p>No items in queue</p> <p>No items in queue</p>
<small>Add episodes from the main page to start downloading</small>
</div> </div>
</div> </div>
</section> </section>
@ -166,10 +163,10 @@
<div class="section-header"> <div class="section-header">
<h2> <h2>
<i class="fas fa-check-circle"></i> <i class="fas fa-check-circle"></i>
Recent Completed Completed (<span id="completed-count">0</span>)
</h2> </h2>
<div class="section-actions"> <div class="section-actions">
<button id="clear-completed-btn" class="btn btn-secondary"> <button id="clear-completed-btn" class="btn btn-secondary" disabled>
<i class="fas fa-broom"></i> <i class="fas fa-broom"></i>
Clear Completed Clear Completed
</button> </button>
@ -178,8 +175,9 @@
<div class="completed-downloads-list" id="completed-downloads"> <div class="completed-downloads-list" id="completed-downloads">
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-check-circle"></i> <i class="fas fa-check-circle text-success"></i>
<p>No completed downloads</p> <p>No completed downloads</p>
<small>Completed episodes will appear here</small>
</div> </div>
</div> </div>
</section> </section>
@ -189,14 +187,14 @@
<div class="section-header"> <div class="section-header">
<h2> <h2>
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
Failed Downloads Failed (<span id="failed-count">0</span>)
</h2> </h2>
<div class="section-actions"> <div class="section-actions">
<button id="retry-all-btn" class="btn btn-warning" disabled> <button id="retry-all-btn" class="btn btn-warning" disabled>
<i class="fas fa-redo"></i> <i class="fas fa-redo"></i>
Retry All Retry All
</button> </button>
<button id="clear-failed-btn" class="btn btn-secondary"> <button id="clear-failed-btn" class="btn btn-secondary" disabled>
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
Clear Failed Clear Failed
</button> </button>
@ -207,6 +205,7 @@
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-check-circle text-success"></i> <i class="fas fa-check-circle text-success"></i>
<p>No failed downloads</p> <p>No failed downloads</p>
<small>Failed episodes can be retried or removed</small>
</div> </div>
</div> </div>
</section> </section>

View File

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