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": [
{
"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"
}

View File

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

View File

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

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)
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,
}

View File

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

View File

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

View File

@ -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 {
<div class="empty-state">
<i class="fas fa-list"></i>
<p>No items in queue</p>
<small>Add episodes from the main page to start downloading</small>
</div>
`;
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 `
<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="download-header">
<div class="download-info">
@ -420,7 +438,7 @@ class QueueManager {
const retryCount = download.retry_count || 0;
return `
<div class="download-card failed">
<div class="download-card failed" data-id="${download.id}">
<div class="download-header">
<div class="download-info">
<h4>${this.escapeHtml(download.serie_name)}</h4>
@ -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 = {}) {

View File

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