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:
@@ -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,
|
||||
|
||||
@@ -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}. "
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user