Fix: Add graceful download cancellation on Ctrl+C

- Add cancellation flag to AniworldLoader with request_cancel/reset_cancel/is_cancelled methods
- Update base_provider.Loader interface with cancellation abstract methods
- Integrate cancellation check in YT-DLP progress hooks
- Add request_download_cancel method to SeriesApp and AnimeService
- Update DownloadService.stop() to request cancellation before shutdown
- Clean up temp files on cancellation
This commit is contained in:
2025-12-27 19:31:57 +01:00
parent 778d16b21a
commit 08f816a954
14 changed files with 145 additions and 900 deletions

View File

@@ -199,6 +199,24 @@ class SeriesApp:
"""Set scan_status event handler."""
self._events.scan_status = value
def request_download_cancel(self) -> None:
"""Request cancellation of any ongoing download.
This method signals the download provider to stop any active
downloads. The actual cancellation happens asynchronously in
the progress hook of the downloader.
"""
logger.info("Requesting download cancellation")
self.loader.request_cancel()
def reset_download_cancel(self) -> None:
"""Reset the download cancellation flag.
Should be called before starting a new download to ensure
it's not immediately cancelled.
"""
self.loader.reset_cancel()
def load_series_from_list(self, series: list) -> None:
"""
Load series into the in-memory list.
@@ -286,6 +304,9 @@ class SeriesApp:
lookups. The 'serie_folder' parameter is only used for
filesystem operations.
"""
# Reset cancel flag before starting new download
self.reset_download_cancel()
logger.info(
"Starting download: %s (key: %s) S%02dE%02d",
serie_folder,

View File

@@ -4,6 +4,7 @@ import logging
import os
import re
import shutil
import threading
from pathlib import Path
from urllib.parse import quote
@@ -70,6 +71,9 @@ class AniworldLoader(Loader):
}
self.ANIWORLD_TO = "https://aniworld.to"
self.session = requests.Session()
# Cancellation flag for graceful shutdown
self._cancel_flag = threading.Event()
# Configure retries with backoff
retries = Retry(
@@ -198,6 +202,30 @@ class AniworldLoader(Loader):
logging.debug(f"Available languages for S{season:02}E{episode:03}: {languages}, requested: {language_code}, available: {is_available}")
return is_available
def request_cancel(self) -> None:
"""Request cancellation of any ongoing download.
Sets the internal cancellation flag. Downloads will check this
flag periodically and abort if set.
"""
logging.info("Download cancellation requested")
self._cancel_flag.set()
def reset_cancel(self) -> None:
"""Reset the cancellation flag.
Should be called before starting a new download.
"""
self._cancel_flag.clear()
def is_cancelled(self) -> bool:
"""Check if cancellation has been requested.
Returns:
bool: True if cancellation was requested
"""
return self._cancel_flag.is_set()
def download(
self,
base_directory: str,
@@ -223,7 +251,15 @@ class AniworldLoader(Loader):
Returns:
bool: True if download succeeded, False otherwise
Raises:
asyncio.CancelledError: If download was cancelled via request_cancel()
"""
# Check cancellation before starting
if self.is_cancelled():
logging.info("Download cancelled before starting")
raise InterruptedError("Download cancelled")
logging.info(
f"Starting download for S{season:02}E{episode:03} "
f"({key}) in {language}"
@@ -261,11 +297,26 @@ class AniworldLoader(Loader):
logging.debug(f"Temporary path: {temp_path}")
for provider in self.SUPPORTED_PROVIDERS:
# Check cancellation before each provider attempt
if self.is_cancelled():
logging.info("Download cancelled during provider selection")
raise InterruptedError("Download cancelled")
logging.debug(f"Attempting download with provider: {provider}")
link, header = self._get_direct_link_from_provider(
season, episode, key, language
)
logging.debug("Direct link obtained from provider")
# Create a cancellation-aware progress hook
cancel_flag = self._cancel_flag
def cancellation_check_hook(d):
"""Progress hook that checks for cancellation."""
if cancel_flag.is_set():
logging.info("Cancellation detected in progress hook")
raise InterruptedError("Download cancelled")
ydl_opts = {
'fragment_retries': float('inf'),
'outtmpl': temp_path,
@@ -273,14 +324,20 @@ class AniworldLoader(Loader):
'no_warnings': True,
'progress_with_newline': False,
'nocheckcertificate': True,
# Add cancellation check as a progress hook
'progress_hooks': [cancellation_check_hook],
}
if header:
ydl_opts['http_headers'] = header
logging.debug("Using custom headers for download")
if progress_callback:
# Wrap the callback to add logging
# Wrap the callback to add logging and keep cancellation check
def logged_progress_callback(d):
# Check cancellation first
if cancel_flag.is_set():
logging.info("Cancellation detected in progress callback")
raise InterruptedError("Download cancelled")
logging.debug(
f"YT-DLP progress: status={d.get('status')}, "
f"downloaded={d.get('downloaded_bytes')}, "
@@ -305,6 +362,14 @@ class AniworldLoader(Loader):
f"filesize={info.get('filesize')}"
)
# Check cancellation after download completes
if self.is_cancelled():
logging.info("Download cancelled after completion")
# Clean up temp file if exists
if os.path.exists(temp_path):
os.remove(temp_path)
raise InterruptedError("Download cancelled")
if os.path.exists(temp_path):
logging.debug("Moving file from temp to final destination")
shutil.copy(temp_path, output_path)
@@ -320,6 +385,16 @@ class AniworldLoader(Loader):
)
self.clear_cache()
return False
except InterruptedError:
# Re-raise cancellation errors
logging.info("Download interrupted, propagating cancellation")
# Clean up temp file if exists
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
raise
except BrokenPipeError as e:
logging.error(
f"Broken pipe error with provider {provider}: {e}. "

View File

@@ -5,6 +5,30 @@ from typing import Any, Callable, Dict, List, Optional
class Loader(ABC):
"""Abstract base class for anime data loaders/providers."""
@abstractmethod
def request_cancel(self) -> None:
"""Request cancellation of any ongoing download.
Sets an internal flag that downloads should check periodically
and abort if set. This enables graceful shutdown.
"""
@abstractmethod
def reset_cancel(self) -> None:
"""Reset the cancellation flag.
Should be called before starting a new download to ensure
it's not immediately cancelled.
"""
@abstractmethod
def is_cancelled(self) -> bool:
"""Check if cancellation has been requested.
Returns:
bool: True if cancellation was requested
"""
@abstractmethod
def search(self, word: str) -> List[Dict[str, Any]]:
"""Search for anime series by name.

View File

@@ -72,6 +72,21 @@ class AnimeService:
logger.exception("Failed to subscribe to SeriesApp events")
raise AnimeServiceError("Initialization failed") from e
def request_download_cancel(self) -> None:
"""Request cancellation of any ongoing download.
This method signals the underlying download provider to stop
any active downloads. The cancellation happens asynchronously
via progress hooks in the downloader.
Should be called during shutdown to stop in-progress downloads.
"""
logger.info("Requesting download cancellation via AnimeService")
try:
self._app.request_download_cancel()
except Exception as e:
logger.warning("Failed to request download cancellation: %s", e)
def _on_download_status(self, args) -> None:
"""Handle download status events from SeriesApp.

View File

@@ -1012,6 +1012,13 @@ class DownloadService:
self._is_shutting_down = True
self._is_stopped = True
# Request cancellation from AnimeService (signals the download thread)
try:
self._anime_service.request_download_cancel()
logger.info("Requested download cancellation from AnimeService")
except Exception as e:
logger.warning("Failed to request download cancellation: %s", e)
# Persist active download back to pending state if one exists
if self._active_download:
logger.info(