#!/usr/bin/env python3 """ Download Manager for YouTube TUI Handles background downloads sequentially """ import asyncio import logging from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Optional from rich.console import Console from youtube_tui.models.queue_item import QueueItem, QueueStatus from youtube_tui.models.video import Video from youtube_tui.services.queue import DownloadQueue from youtube_tui.services.youtube import YouTubeService console = Console() # Configure logging LOG_DIR = Path.home() / ".config" / "youtube_cli" / "logs" LOG_DIR.mkdir(parents=True, exist_ok=True) LOG_FILE = LOG_DIR / "app.log" # Use RotatingFileHandler for log rotation (10MB, 5 backups) file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter( logging.Formatter( "%(asctime)s | %(name)s | %(levelname)s | %(message)s", "%Y-%m-%d %H:%M:%S", ) ) # Create console handler console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_handler.setFormatter(logging.Formatter("%(message)s")) # Configure root logger logging.basicConfig( level=logging.DEBUG, handlers=[ file_handler, console_handler, ], ) logger = logging.getLogger(__name__) class DownloadManager: """Manages background downloads from the queue""" def __init__(self, queue: DownloadQueue, youtube_service: YouTubeService): self._queue: DownloadQueue = queue self._active_task: Optional[asyncio.Task] = None self._current_item: Optional[QueueItem] = None self._is_running = False self._cancel_requested = False self._youtube_service = youtube_service def add_to_queue( self, video: Video, category: Optional[str] = None, network_folder: Optional[str] = None, ) -> QueueItem: """Add a video to the download queue""" return self._queue.add_video(video, category, network_folder) def remove_from_queue(self, item_id: str) -> bool: """Remove an item from the queue by UUID string""" return self._queue.remove_item(item_id) def cancel_active_download(self) -> None: """Cancel the currently active download""" self._cancel_requested = True if self._active_task: self._active_task.cancel() def get_queue_status(self) -> dict: """Get queue status information""" stats = self._queue.get_stats() # Use _current_item to determine if there's an active download has_active_download = self._current_item is not None return { "pending_count": stats["pending"], "downloading_count": 1 if has_active_download else stats["downloading"], "total_count": stats["total"], "has_active_download": has_active_download, "active_item": self._current_item.to_dict() if self._current_item else None, } def get_active_item(self) -> Optional[QueueItem]: """Get the currently downloading item""" return self._current_item def start_processing(self) -> None: """Start the background download processing task""" if not self._is_running: self._is_running = True self._active_task = asyncio.create_task(self._process_queue()) def stop_processing(self) -> None: """Stop the background download processing task""" self._is_running = False if self._active_task: self._active_task.cancel() async def _process_queue(self) -> None: """Process the download queue sequentially""" # Loop is available via asyncio.run() in main context while self._is_running: try: # Check if we have a pending item item = self._queue.get_next_pending() if item is None: await asyncio.sleep(1) # Wait for new items continue # Mark item as current self._current_item = item self._cancel_requested = False # Start downloading await self._download_item(item) # Clear current item after completion self._current_item = None except asyncio.CancelledError: # Task was cancelled logger.warning("Download manager cancelled") break except Exception as e: logger.warning(f"Error in download manager: {e}") await asyncio.sleep(1) async def _download_item(self, item: QueueItem) -> None: """Download a single queue item""" # Update status to downloading item.start_download() if item.video: self._queue.update_item_status(str(item.id), QueueStatus.DOWNLOADING) try: # Determine if it's a playlist or video is_playlist = item.video and ( "/playlist" in item.video.url.lower() or "list=" in item.video.url.lower() ) # Download with progress callback async def progress_callback(percentage: int) -> bool: """Progress callback that checks for cancellation""" # Update progress if item.video: self._queue.update_progress(str(item.id), percentage) item.update_progress(percentage) # Check for cancellation if self._cancel_requested: raise asyncio.CancelledError("Download cancelled by user") return True if is_playlist and item.video: success = await self._youtube_service.download_playlist( item.video, category=item.category, network_folder=item.network_folder, progress_callback=progress_callback, ) elif item.video: success = await self._youtube_service.download_video( item.video, category=item.category, network_folder=item.network_folder, progress_callback=progress_callback, ) else: # No video to download if item.video is None: item.fail(error_message="No video data available") success = False # Check final status if self._cancel_requested: # Download was cancelled if item.video: self._queue.update_item_status(str(item.id), QueueStatus.CANCELLED) item.cancel() elif success: # Download succeeded if item.video: self._queue.update_item_status(str(item.id), QueueStatus.COMPLETED) item.complete() else: # Download failed if item.video: self._queue.update_item_status(str(item.id), QueueStatus.FAILED) item.fail(error_message="Download failed") except asyncio.CancelledError: # Task was cancelled if item.video: self._queue.update_item_status(str(item.id), QueueStatus.CANCELLED) item.cancel() except Exception as e: logger.error(f"Download error: {e}") if item.video: self._queue.update_item_status(str(item.id), QueueStatus.FAILED) item.fail(error_message=str(e)) def is_processing(self) -> bool: """Check if download manager is processing queue""" return self._is_running