From 33aeac01418bee73aabaf7088102d5a7e8774adf Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 1 Nov 2025 16:13:28 +0100 Subject: [PATCH] download the queue --- data/download_queue.json | 126 ++++++++++++------------ src/server/api/download.py | 25 ++--- src/server/services/download_service.py | 120 ++++++++++++++++------ src/server/web/static/js/queue.js | 13 ++- 4 files changed, 176 insertions(+), 108 deletions(-) diff --git a/data/download_queue.json b/data/download_queue.json index f5347ef..1f488d2 100644 --- a/data/download_queue.json +++ b/data/download_queue.json @@ -1,62 +1,5 @@ { "pending": [ - { - "id": "467b39ac-48be-48c5-a04c-ce94b6f6c0d9", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 5, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-11-01T14:30:56.881950Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "4c0b9bd0-52ad-4833-baf7-19e2c920f94f", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 6, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-11-01T14:30:56.881979Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "fabf8a46-6588-4dff-959d-de5e2bf48e29", - "serie_id": "highschool-dxd", - "serie_name": "Highschool DxD", - "episode": { - "season": 3, - "episode": 7, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-11-01T14:30:56.882013Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, { "id": "7a6a40ce-c5d9-4e7e-8f71-a08141e9ce6f", "serie_id": "highschool-dxd", @@ -1500,10 +1443,7 @@ "error": "Download failed", "retry_count": 0, "source_url": null - } - ], - "active": [], - "failed": [ + }, { "id": "55e21a17-ac91-4dfe-ba96-df5c4261a9a5", "serie_id": "highschool-dxd", @@ -1513,7 +1453,7 @@ "episode": 4, "title": null }, - "status": "failed", + "status": "pending", "priority": "normal", "added_at": "2025-11-01T14:30:56.881920Z", "started_at": "2025-11-01T15:04:26.488261Z", @@ -1524,5 +1464,65 @@ "source_url": null } ], - "timestamp": "2025-11-01T15:04:43.998876+00:00" + "active": [], + "failed": [ + { + "id": "467b39ac-48be-48c5-a04c-ce94b6f6c0d9", + "serie_id": "highschool-dxd", + "serie_name": "Highschool DxD", + "episode": { + "season": 3, + "episode": 5, + "title": null + }, + "status": "failed", + "priority": "normal", + "added_at": "2025-11-01T14:30:56.881950Z", + "started_at": "2025-11-01T15:12:21.534113Z", + "completed_at": "2025-11-01T15:12:30.481928Z", + "progress": null, + "error": "Download failed", + "retry_count": 0, + "source_url": null + }, + { + "id": "4c0b9bd0-52ad-4833-baf7-19e2c920f94f", + "serie_id": "highschool-dxd", + "serie_name": "Highschool DxD", + "episode": { + "season": 3, + "episode": 6, + "title": null + }, + "status": "failed", + "priority": "normal", + "added_at": "2025-11-01T14:30:56.881979Z", + "started_at": "2025-11-01T15:12:31.484945Z", + "completed_at": "2025-11-01T15:12:39.118803Z", + "progress": null, + "error": "Download failed", + "retry_count": 0, + "source_url": null + }, + { + "id": "fabf8a46-6588-4dff-959d-de5e2bf48e29", + "serie_id": "highschool-dxd", + "serie_name": "Highschool DxD", + "episode": { + "season": 3, + "episode": 7, + "title": null + }, + "status": "failed", + "priority": "normal", + "added_at": "2025-11-01T14:30:56.882013Z", + "started_at": "2025-11-01T15:12:40.122525Z", + "completed_at": "2025-11-01T15:12:46.419411Z", + "progress": null, + "error": "Download failed", + "retry_count": 0, + "source_url": null + } + ], + "timestamp": "2025-11-01T15:12:46.420241+00:00" } \ No newline at end of file diff --git a/src/server/api/download.py b/src/server/api/download.py index ac973bb..efd16e3 100644 --- a/src/server/api/download.py +++ b/src/server/api/download.py @@ -280,25 +280,29 @@ async def start_queue( _: dict = Depends(require_auth), download_service: DownloadService = Depends(get_download_service), ): - """Start the next download from pending queue. + """Start automatic queue processing. - Manually starts the first pending download in the queue. Only one download - can be active at a time. If the queue is empty or a download is already - active, an error is returned. + Starts processing all pending downloads sequentially, one at a time. + The queue will continue processing until all items are complete or + the queue is manually stopped. Processing continues even if the browser + is closed. + + Only one download can be active at a time. If a download is already + active or queue processing is running, an error is returned. Requires authentication. Returns: - dict: Status message with started item ID + dict: Status message confirming queue processing started Raises: HTTPException: 401 if not authenticated, 400 if queue is empty or - download already active, 500 on service error + processing already active, 500 on service error """ try: - item_id = await download_service.start_next_download() + result = await download_service.start_queue_processing() - if item_id is None: + if result is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No pending downloads in queue", @@ -306,8 +310,7 @@ async def start_queue( return { "status": "success", - "message": "Download started", - "item_id": item_id, + "message": "Queue processing started", } except DownloadServiceError as e: @@ -320,7 +323,7 @@ async def start_queue( except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to start download: {str(e)}", + detail=f"Failed to start queue processing: {str(e)}", ) diff --git a/src/server/services/download_service.py b/src/server/services/download_service.py index fb45b7e..9df7763 100644 --- a/src/server/services/download_service.py +++ b/src/server/services/download_service.py @@ -356,20 +356,24 @@ class DownloadService: f"Failed to remove items: {str(e)}" ) from e - async def start_next_download(self) -> Optional[str]: - """Manually start the next download from pending queue. + async def start_queue_processing(self) -> Optional[str]: + """Start automatic queue processing of all pending downloads. + + This will process all pending downloads one by one until the queue + is empty or stopped. The processing continues even if the browser + is closed. Returns: - Item ID of started download, or None if queue is empty + Item ID of first started download, or None if queue is empty Raises: - DownloadServiceError: If a download is already active + DownloadServiceError: If queue processing is already active """ try: # Check if download already active if self._active_download: raise DownloadServiceError( - "A download is already in progress" + "Queue processing is already active" ) # Check if queue is empty @@ -377,42 +381,98 @@ class DownloadService: logger.info("No pending downloads to start") return None - # Get first item from queue - item = self._pending_queue.popleft() - del self._pending_items_by_id[item.id] - # Mark queue as running self._is_stopped = False - # Start download in background - asyncio.create_task(self._process_download(item)) + # Start queue processing in background + asyncio.create_task(self._process_queue()) - logger.info( - "Started download manually", - item_id=item.id, - serie=item.serie_name - ) + logger.info("Queue processing started") - # Broadcast queue status update + return "queue_started" + + except Exception as e: + logger.error("Failed to start queue processing", error=str(e)) + raise DownloadServiceError( + f"Failed to start queue processing: {str(e)}" + ) from e + + async def _process_queue(self) -> None: + """Process all items in the queue sequentially. + + This runs continuously until the queue is empty or stopped. + Each download is processed one at a time, and the next one starts + automatically after the previous one completes. + """ + logger.info("Queue processor started") + + while not self._is_stopped and len(self._pending_queue) > 0: + try: + # Get next item from queue + item = self._pending_queue.popleft() + del self._pending_items_by_id[item.id] + + logger.info( + "Processing next item from queue", + item_id=item.id, + serie=item.serie_name, + remaining=len(self._pending_queue) + ) + + # Broadcast queue status update + queue_status = await self.get_queue_status() + await self._broadcast_update( + "download_started", + { + "item_id": item.id, + "serie_name": item.serie_name, + "season": item.episode.season, + "episode": item.episode.episode, + "queue_status": queue_status.model_dump(mode="json"), + }, + ) + + # Process the download (this will wait until complete) + await self._process_download(item) + + # Small delay between downloads + await asyncio.sleep(1) + + except Exception as e: + logger.error( + "Error in queue processing loop", + error=str(e), + exc_info=True + ) + # Continue with next item even if one fails + await asyncio.sleep(2) + + # Queue processing completed + self._is_stopped = True + + if len(self._pending_queue) == 0: + logger.info("Queue processing completed - all items processed") queue_status = await self.get_queue_status() await self._broadcast_update( - "download_started", + "queue_completed", { - "item_id": item.id, - "serie_name": item.serie_name, - "season": item.episode.season, - "episode": item.episode.episode, + "message": "All downloads completed", "queue_status": queue_status.model_dump(mode="json"), }, ) - - return item.id - - except Exception as e: - logger.error("Failed to start download", error=str(e)) - raise DownloadServiceError( - f"Failed to start download: {str(e)}" - ) from e + else: + logger.info("Queue processing stopped by user") + + async def start_next_download(self) -> Optional[str]: + """Legacy method - redirects to start_queue_processing. + + Returns: + Item ID of started download, or None if queue is empty + + Raises: + DownloadServiceError: If a download is already active + """ + return await self.start_queue_processing() async def stop_downloads(self) -> None: """Stop processing new downloads from queue. diff --git a/src/server/web/static/js/queue.js b/src/server/web/static/js/queue.js index dd30aa2..6d0459e 100644 --- a/src/server/web/static/js/queue.js +++ b/src/server/web/static/js/queue.js @@ -97,6 +97,11 @@ class QueueManager { this.showToast('All downloads completed!', 'success'); this.loadQueueData(); // Refresh data }); + + this.socket.on('queue_completed', () => { + this.showToast('All downloads completed!', 'success'); + this.loadQueueData(); // Refresh data + }); this.socket.on('download_stop_requested', () => { this.showToast('Stopping downloads...', 'info'); @@ -592,7 +597,7 @@ class QueueManager { const data = await response.json(); if (data.status === 'success') { - this.showToast('Download started', 'success'); + this.showToast('Queue processing started - all items will download automatically', 'success'); // Update UI document.getElementById('start-queue-btn').style.display = 'none'; @@ -601,11 +606,11 @@ class QueueManager { this.loadQueueData(); // Refresh display } else { - this.showToast(`Failed to start download: ${data.message || 'Unknown error'}`, 'error'); + this.showToast(`Failed to start queue: ${data.message || 'Unknown error'}`, 'error'); } } catch (error) { - console.error('Error starting download:', error); - this.showToast('Failed to start download', 'error'); + console.error('Error starting queue:', error); + this.showToast('Failed to start queue processing', 'error'); } }