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

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