Fix: Use yt_dlp.utils.DownloadCancelled for proper download cancellation

- Import and use DownloadCancelled exception which YT-DLP properly handles
- Add InterruptedError handling throughout the call chain
- Fire 'cancelled' status event when download is cancelled
- Handle InterruptedError in DownloadService to set CANCELLED status
This commit is contained in:
Lukas 2025-12-27 19:38:12 +01:00
parent 08f816a954
commit 4780f68a23
4 changed files with 74 additions and 9 deletions

View File

@ -419,6 +419,29 @@ class SeriesApp:
return download_success 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 except Exception as e: # pylint: disable=broad-except
logger.error( logger.error(
"Download error: %s (key: %s) S%02dE%02d - %s", "Download error: %s (key: %s) S%02dE%02d - %s",

View File

@ -4,6 +4,8 @@ import logging
import os import os
import re import re
import shutil import shutil
import signal
import sys
import threading import threading
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -14,6 +16,7 @@ from fake_useragent import UserAgent
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
from yt_dlp import YoutubeDL from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadCancelled
from ..interfaces.providers import Providers from ..interfaces.providers import Providers
from .base_provider import Loader from .base_provider import Loader
@ -308,14 +311,19 @@ class AniworldLoader(Loader):
) )
logging.debug("Direct link obtained from provider") 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 cancel_flag = self._cancel_flag
def cancellation_check_hook(d): 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(): if cancel_flag.is_set():
logging.info("Cancellation detected in progress hook") logging.info("Cancellation detected in progress hook")
raise InterruptedError("Download cancelled") raise DownloadCancelled("Download cancelled by user")
ydl_opts = { ydl_opts = {
'fragment_retries': float('inf'), 'fragment_retries': float('inf'),
@ -334,10 +342,10 @@ class AniworldLoader(Loader):
if progress_callback: if progress_callback:
# Wrap the callback to add logging and keep cancellation check # Wrap the callback to add logging and keep cancellation check
def logged_progress_callback(d): def logged_progress_callback(d):
# Check cancellation first # Check cancellation first - use DownloadCancelled
if cancel_flag.is_set(): if cancel_flag.is_set():
logging.info("Cancellation detected in progress callback") logging.info("Cancellation detected in progress callback")
raise InterruptedError("Download cancelled") raise DownloadCancelled("Download cancelled by user")
logging.debug( logging.debug(
f"YT-DLP progress: status={d.get('status')}, " f"YT-DLP progress: status={d.get('status')}, "
f"downloaded={d.get('downloaded_bytes')}, " f"downloaded={d.get('downloaded_bytes')}, "
@ -385,16 +393,19 @@ class AniworldLoader(Loader):
) )
self.clear_cache() self.clear_cache()
return False return False
except InterruptedError: except (InterruptedError, DownloadCancelled) as e:
# Re-raise cancellation errors # 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 # Clean up temp file if exists
if os.path.exists(temp_path): if os.path.exists(temp_path):
try: try:
os.remove(temp_path) os.remove(temp_path)
except OSError: except OSError:
pass pass
raise raise InterruptedError("Download cancelled") from e
except BrokenPipeError as e: except BrokenPipeError as e:
logging.error( logging.error(
f"Broken pipe error with provider {provider}: {e}. " f"Broken pipe error with provider {provider}: {e}. "
@ -403,6 +414,15 @@ class AniworldLoader(Loader):
# Try next provider if available # Try next provider if available
continue continue
except Exception as e: 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( logging.error(
f"YoutubeDL download failed with provider {provider}: " f"YoutubeDL download failed with provider {provider}: "
f"{type(e).__name__}: {e}" f"{type(e).__name__}: {e}"

View File

@ -846,6 +846,7 @@ class AnimeService:
Raises: Raises:
AnimeServiceError: If download fails AnimeServiceError: If download fails
InterruptedError: If download was cancelled
Note: Note:
The 'key' parameter is the primary identifier used for all The 'key' parameter is the primary identifier used for all
@ -864,6 +865,10 @@ class AnimeService:
key=key, key=key,
item_id=item_id, 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: except Exception as exc:
logger.exception("download failed") logger.exception("download failed")
raise AnimeServiceError("Download failed") from exc raise AnimeServiceError("Download failed") from exc

View File

@ -952,7 +952,7 @@ class DownloadService:
except asyncio.CancelledError: except asyncio.CancelledError:
# Handle task cancellation during shutdown # Handle task cancellation during shutdown
logger.info( logger.info(
"Download cancelled during shutdown: item_id=%s", "Download task cancelled: item_id=%s",
item.id, item.id,
) )
item.status = DownloadStatus.CANCELLED item.status = DownloadStatus.CANCELLED
@ -965,6 +965,23 @@ class DownloadService:
# Re-save to database as pending # Re-save to database as pending
await self._save_to_database(item) await self._save_to_database(item)
raise # Re-raise to properly cancel the task 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: except Exception as e:
# Handle failure # Handle failure