youtube-cli/youtube_tui/services/download_manager.py
2026-02-28 02:02:45 -06:00

221 lines
7.7 KiB
Python

#!/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