diff --git a/src/core/SeriesApp.py b/src/core/SeriesApp.py index 93ff3aa..94573af 100644 --- a/src/core/SeriesApp.py +++ b/src/core/SeriesApp.py @@ -419,6 +419,29 @@ class SeriesApp: return download_success + except InterruptedError: + # Download was cancelled - propagate the cancellation + logger.info( + "Download cancelled: %s (key: %s) S%02dE%02d", + serie_folder, + key, + season, + episode, + ) + # Fire download cancelled event + self._events.download_status( + DownloadStatusEventArgs( + serie_folder=serie_folder, + key=key, + season=season, + episode=episode, + status="cancelled", + message="Download cancelled by user", + item_id=item_id, + ) + ) + raise # Re-raise to propagate cancellation + except Exception as e: # pylint: disable=broad-except logger.error( "Download error: %s (key: %s) S%02dE%02d - %s", diff --git a/src/core/providers/aniworld_provider.py b/src/core/providers/aniworld_provider.py index 72978cb..a0bfb45 100644 --- a/src/core/providers/aniworld_provider.py +++ b/src/core/providers/aniworld_provider.py @@ -4,6 +4,8 @@ import logging import os import re import shutil +import signal +import sys import threading from pathlib import Path from urllib.parse import quote @@ -14,6 +16,7 @@ from fake_useragent import UserAgent from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from yt_dlp import YoutubeDL +from yt_dlp.utils import DownloadCancelled from ..interfaces.providers import Providers from .base_provider import Loader @@ -308,14 +311,19 @@ class AniworldLoader(Loader): ) logging.debug("Direct link obtained from provider") - # Create a cancellation-aware progress hook + # Create a cancellation-aware progress hook using DownloadCancelled + # which YT-DLP properly handles cancel_flag = self._cancel_flag def cancellation_check_hook(d): - """Progress hook that checks for cancellation.""" + """Progress hook that checks for cancellation. + + Uses yt_dlp.utils.DownloadCancelled which is properly + handled by YT-DLP to abort downloads immediately. + """ if cancel_flag.is_set(): logging.info("Cancellation detected in progress hook") - raise InterruptedError("Download cancelled") + raise DownloadCancelled("Download cancelled by user") ydl_opts = { 'fragment_retries': float('inf'), @@ -334,10 +342,10 @@ class AniworldLoader(Loader): if progress_callback: # Wrap the callback to add logging and keep cancellation check def logged_progress_callback(d): - # Check cancellation first + # Check cancellation first - use DownloadCancelled if cancel_flag.is_set(): logging.info("Cancellation detected in progress callback") - raise InterruptedError("Download cancelled") + raise DownloadCancelled("Download cancelled by user") logging.debug( f"YT-DLP progress: status={d.get('status')}, " f"downloaded={d.get('downloaded_bytes')}, " @@ -385,16 +393,19 @@ class AniworldLoader(Loader): ) self.clear_cache() return False - except InterruptedError: + except (InterruptedError, DownloadCancelled) as e: # Re-raise cancellation errors - logging.info("Download interrupted, propagating cancellation") + logging.info( + "Download cancelled: %s, propagating cancellation", + type(e).__name__ + ) # Clean up temp file if exists if os.path.exists(temp_path): try: os.remove(temp_path) except OSError: pass - raise + raise InterruptedError("Download cancelled") from e except BrokenPipeError as e: logging.error( f"Broken pipe error with provider {provider}: {e}. " @@ -403,6 +414,15 @@ class AniworldLoader(Loader): # Try next provider if available continue except Exception as e: + # Check if this is a cancellation wrapped in another exception + if self.is_cancelled(): + logging.info("Download cancelled (detected in exception handler)") + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + raise InterruptedError("Download cancelled") from e logging.error( f"YoutubeDL download failed with provider {provider}: " f"{type(e).__name__}: {e}" diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py index 0936dbb..317110c 100644 --- a/src/server/services/anime_service.py +++ b/src/server/services/anime_service.py @@ -846,6 +846,7 @@ class AnimeService: Raises: AnimeServiceError: If download fails + InterruptedError: If download was cancelled Note: The 'key' parameter is the primary identifier used for all @@ -864,6 +865,10 @@ class AnimeService: key=key, item_id=item_id, ) + except InterruptedError: + # Download was cancelled - re-raise for proper handling + logger.info("Download cancelled, propagating cancellation") + raise except Exception as exc: logger.exception("download failed") raise AnimeServiceError("Download failed") from exc diff --git a/src/server/services/download_service.py b/src/server/services/download_service.py index 35d5325..60a011b 100644 --- a/src/server/services/download_service.py +++ b/src/server/services/download_service.py @@ -952,7 +952,7 @@ class DownloadService: except asyncio.CancelledError: # Handle task cancellation during shutdown logger.info( - "Download cancelled during shutdown: item_id=%s", + "Download task cancelled: item_id=%s", item.id, ) item.status = DownloadStatus.CANCELLED @@ -965,6 +965,23 @@ class DownloadService: # Re-save to database as pending await self._save_to_database(item) raise # Re-raise to properly cancel the task + + except InterruptedError: + # Handle download cancellation from provider + logger.info( + "Download interrupted/cancelled: item_id=%s", + item.id, + ) + item.status = DownloadStatus.CANCELLED + item.completed_at = datetime.now(timezone.utc) + # Delete cancelled item from database + await self._delete_from_database(item.id) + # Return item to pending queue if not shutting down + if not self._is_shutting_down: + self._add_to_pending_queue(item, front=True) + # Re-save to database as pending + await self._save_to_database(item) + # Don't re-raise - this is handled gracefully except Exception as e: # Handle failure