""" Download Management API Endpoints This module provides REST API endpoints for download operations, including queue management, progress tracking, and download history. """ from flask import Blueprint, request from typing import Dict, List, Any, Optional import uuid from datetime import datetime from ...shared.auth_decorators import require_auth, optional_auth from ...shared.error_handlers import handle_api_errors, APIException, NotFoundError, ValidationError from ...shared.validators import validate_json_input, validate_id_parameter, validate_pagination_params from ...shared.response_helpers import ( create_success_response, create_paginated_response, format_download_response, extract_pagination_params, create_batch_response ) # Import download components (these imports would need to be adjusted based on actual structure) try: from download_manager import download_queue, download_manager, DownloadItem from database_manager import episode_repository, anime_repository except ImportError: # Fallback for development/testing download_queue = None download_manager = None DownloadItem = None episode_repository = None anime_repository = None # Blueprint for download management endpoints downloads_bp = Blueprint('downloads', __name__, url_prefix='/api/v1/downloads') @downloads_bp.route('', methods=['GET']) @handle_api_errors @validate_pagination_params @optional_auth def list_downloads() -> Dict[str, Any]: """ Get all downloads with optional filtering and pagination. Query Parameters: - status: Filter by download status (pending, downloading, completed, failed, paused) - anime_id: Filter by anime ID - episode_id: Filter by episode ID - active_only: Show only active downloads (true/false) - page: Page number (default: 1) - per_page: Items per page (default: 50, max: 1000) Returns: Paginated list of downloads """ if not download_manager: raise APIException("Download manager not available", 503) # Extract filters status_filter = request.args.get('status') anime_id = request.args.get('anime_id') episode_id = request.args.get('episode_id') active_only = request.args.get('active_only', 'false').lower() == 'true' # Validate filters valid_statuses = ['pending', 'downloading', 'completed', 'failed', 'paused', 'cancelled'] if status_filter and status_filter not in valid_statuses: raise ValidationError(f"Status must be one of: {', '.join(valid_statuses)}") if anime_id: try: anime_id = int(anime_id) except ValueError: raise ValidationError("anime_id must be a valid integer") if episode_id: try: episode_id = int(episode_id) except ValueError: raise ValidationError("episode_id must be a valid integer") # Get pagination parameters page, per_page = extract_pagination_params() # Get downloads with filters downloads = download_manager.get_downloads( status_filter=status_filter, anime_id=anime_id, episode_id=episode_id, active_only=active_only ) # Format download data formatted_downloads = [format_download_response(download.__dict__) for download in downloads] # Apply pagination total = len(formatted_downloads) start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_downloads = formatted_downloads[start_idx:end_idx] return create_paginated_response( data=paginated_downloads, page=page, per_page=per_page, total=total, endpoint='downloads.list_downloads' ) @downloads_bp.route('/', methods=['GET']) @handle_api_errors @validate_id_parameter('download_id') @optional_auth def get_download(download_id: int) -> Dict[str, Any]: """ Get specific download by ID. Args: download_id: Unique identifier for the download Returns: Download details with progress information """ if not download_manager: raise APIException("Download manager not available", 503) download = download_manager.get_download_by_id(download_id) if not download: raise NotFoundError("Download not found") # Format download data download_data = format_download_response(download.__dict__) # Add detailed progress information progress_info = download_manager.get_download_progress(download_id) if progress_info: download_data['progress_details'] = progress_info return create_success_response(download_data) @downloads_bp.route('', methods=['POST']) @handle_api_errors @validate_json_input( required_fields=['episode_id'], optional_fields=['priority', 'quality', 'subtitle_language', 'download_path'], field_types={ 'episode_id': int, 'priority': int, 'quality': str, 'subtitle_language': str, 'download_path': str } ) @require_auth def create_download() -> Dict[str, Any]: """ Create a new download request. Required Fields: - episode_id: ID of the episode to download Optional Fields: - priority: Download priority (1-10, higher is more priority) - quality: Preferred quality (720p, 1080p, etc.) - subtitle_language: Preferred subtitle language - download_path: Custom download path Returns: Created download details """ if not download_manager or not episode_repository: raise APIException("Download manager not available", 503) data = request.get_json() episode_id = data['episode_id'] # Validate episode exists episode = episode_repository.get_episode_by_id(episode_id) if not episode: raise ValidationError("Episode not found") # Check if episode is already downloaded if episode.status == 'downloaded': raise ValidationError("Episode is already downloaded") # Check if download already exists for this episode existing_download = download_manager.get_download_by_episode(episode_id) if existing_download and existing_download.status in ['pending', 'downloading']: raise ValidationError("Download already in progress for this episode") # Validate priority priority = data.get('priority', 5) if not 1 <= priority <= 10: raise ValidationError("Priority must be between 1 and 10") # Create download item try: download_item = DownloadItem( download_id=str(uuid.uuid4()), episode_id=episode_id, anime_id=episode.anime_id, priority=priority, quality=data.get('quality'), subtitle_language=data.get('subtitle_language'), download_path=data.get('download_path'), status='pending', created_at=datetime.utcnow() ) except Exception as e: raise ValidationError(f"Invalid download data: {str(e)}") # Add to download queue success = download_queue.add_download(download_item) if not success: raise APIException("Failed to create download", 500) # Return created download download_data = format_download_response(download_item.__dict__) return create_success_response( data=download_data, message="Download queued successfully", status_code=201 ) @downloads_bp.route('//pause', methods=['POST']) @handle_api_errors @validate_id_parameter('download_id') @require_auth def pause_download(download_id: int) -> Dict[str, Any]: """ Pause a download. Args: download_id: Unique identifier for the download Returns: Updated download status """ if not download_manager: raise APIException("Download manager not available", 503) download = download_manager.get_download_by_id(download_id) if not download: raise NotFoundError("Download not found") if download.status not in ['pending', 'downloading']: raise ValidationError(f"Cannot pause download with status '{download.status}'") success = download_manager.pause_download(download_id) if not success: raise APIException("Failed to pause download", 500) # Get updated download updated_download = download_manager.get_download_by_id(download_id) download_data = format_download_response(updated_download.__dict__) return create_success_response( data=download_data, message="Download paused successfully" ) @downloads_bp.route('//resume', methods=['POST']) @handle_api_errors @validate_id_parameter('download_id') @require_auth def resume_download(download_id: int) -> Dict[str, Any]: """ Resume a paused download. Args: download_id: Unique identifier for the download Returns: Updated download status """ if not download_manager: raise APIException("Download manager not available", 503) download = download_manager.get_download_by_id(download_id) if not download: raise NotFoundError("Download not found") if download.status != 'paused': raise ValidationError(f"Cannot resume download with status '{download.status}'") success = download_manager.resume_download(download_id) if not success: raise APIException("Failed to resume download", 500) # Get updated download updated_download = download_manager.get_download_by_id(download_id) download_data = format_download_response(updated_download.__dict__) return create_success_response( data=download_data, message="Download resumed successfully" ) @downloads_bp.route('//cancel', methods=['POST']) @handle_api_errors @validate_id_parameter('download_id') @require_auth def cancel_download(download_id: int) -> Dict[str, Any]: """ Cancel a download. Args: download_id: Unique identifier for the download Query Parameters: - delete_partial: Set to 'true' to delete partially downloaded files Returns: Cancellation confirmation """ if not download_manager: raise APIException("Download manager not available", 503) download = download_manager.get_download_by_id(download_id) if not download: raise NotFoundError("Download not found") if download.status in ['completed', 'cancelled']: raise ValidationError(f"Cannot cancel download with status '{download.status}'") delete_partial = request.args.get('delete_partial', 'false').lower() == 'true' success = download_manager.cancel_download(download_id, delete_partial=delete_partial) if not success: raise APIException("Failed to cancel download", 500) message = "Download cancelled successfully" if delete_partial: message += " (partial files deleted)" return create_success_response(message=message) @downloads_bp.route('//retry', methods=['POST']) @handle_api_errors @validate_id_parameter('download_id') @require_auth def retry_download(download_id: int) -> Dict[str, Any]: """ Retry a failed download. Args: download_id: Unique identifier for the download Returns: Updated download status """ if not download_manager: raise APIException("Download manager not available", 503) download = download_manager.get_download_by_id(download_id) if not download: raise NotFoundError("Download not found") if download.status != 'failed': raise ValidationError(f"Cannot retry download with status '{download.status}'") success = download_manager.retry_download(download_id) if not success: raise APIException("Failed to retry download", 500) # Get updated download updated_download = download_manager.get_download_by_id(download_id) download_data = format_download_response(updated_download.__dict__) return create_success_response( data=download_data, message="Download queued for retry" ) @downloads_bp.route('/bulk', methods=['POST']) @handle_api_errors @validate_json_input( required_fields=['action', 'download_ids'], optional_fields=['delete_partial'], field_types={ 'action': str, 'download_ids': list, 'delete_partial': bool } ) @require_auth def bulk_download_operation() -> Dict[str, Any]: """ Perform bulk operations on multiple downloads. Required Fields: - action: Operation to perform (pause, resume, cancel, retry) - download_ids: List of download IDs to operate on Optional Fields: - delete_partial: For cancel action, whether to delete partial files Returns: Results of the bulk operation """ if not download_manager: raise APIException("Download manager not available", 503) data = request.get_json() action = data['action'] download_ids = data['download_ids'] delete_partial = data.get('delete_partial', False) # Validate action valid_actions = ['pause', 'resume', 'cancel', 'retry'] if action not in valid_actions: raise ValidationError(f"Invalid action. Must be one of: {', '.join(valid_actions)}") # Validate download_ids if not isinstance(download_ids, list) or not download_ids: raise ValidationError("download_ids must be a non-empty list") if len(download_ids) > 50: raise ValidationError("Cannot operate on more than 50 downloads at once") # Validate download IDs are integers try: download_ids = [int(did) for did in download_ids] except ValueError: raise ValidationError("All download_ids must be valid integers") # Perform bulk operation successful_items = [] failed_items = [] for download_id in download_ids: try: if action == 'pause': success = download_manager.pause_download(download_id) elif action == 'resume': success = download_manager.resume_download(download_id) elif action == 'cancel': success = download_manager.cancel_download(download_id, delete_partial=delete_partial) elif action == 'retry': success = download_manager.retry_download(download_id) if success: successful_items.append({'download_id': download_id, 'action': action}) else: failed_items.append({'download_id': download_id, 'error': 'Operation failed'}) except Exception as e: failed_items.append({'download_id': download_id, 'error': str(e)}) return create_batch_response( successful_items=successful_items, failed_items=failed_items, message=f"Bulk {action} operation completed" ) @downloads_bp.route('/queue', methods=['GET']) @handle_api_errors @optional_auth def get_download_queue() -> Dict[str, Any]: """ Get current download queue status. Returns: Download queue information including active downloads and queue statistics """ if not download_queue: raise APIException("Download queue not available", 503) queue_info = download_queue.get_queue_status() return create_success_response( data={ 'queue_size': queue_info.get('queue_size', 0), 'active_downloads': queue_info.get('active_downloads', 0), 'max_concurrent': queue_info.get('max_concurrent', 0), 'paused_downloads': queue_info.get('paused_downloads', 0), 'failed_downloads': queue_info.get('failed_downloads', 0), 'completed_today': queue_info.get('completed_today', 0), 'queue_items': queue_info.get('queue_items', []) } ) @downloads_bp.route('/queue/pause', methods=['POST']) @handle_api_errors @require_auth def pause_download_queue() -> Dict[str, Any]: """ Pause the entire download queue. Returns: Queue pause confirmation """ if not download_queue: raise APIException("Download queue not available", 503) success = download_queue.pause_queue() if not success: raise APIException("Failed to pause download queue", 500) return create_success_response(message="Download queue paused") @downloads_bp.route('/queue/resume', methods=['POST']) @handle_api_errors @require_auth def resume_download_queue() -> Dict[str, Any]: """ Resume the download queue. Returns: Queue resume confirmation """ if not download_queue: raise APIException("Download queue not available", 503) success = download_queue.resume_queue() if not success: raise APIException("Failed to resume download queue", 500) return create_success_response(message="Download queue resumed") @downloads_bp.route('/queue/clear', methods=['POST']) @handle_api_errors @require_auth def clear_download_queue() -> Dict[str, Any]: """ Clear completed and failed downloads from the queue. Query Parameters: - include_failed: Set to 'true' to also clear failed downloads Returns: Queue clear confirmation """ if not download_queue: raise APIException("Download queue not available", 503) include_failed = request.args.get('include_failed', 'false').lower() == 'true' cleared_count = download_queue.clear_completed(include_failed=include_failed) message = f"Cleared {cleared_count} completed downloads" if include_failed: message += " and failed downloads" return create_success_response( data={'cleared_count': cleared_count}, message=message ) @downloads_bp.route('/history', methods=['GET']) @handle_api_errors @validate_pagination_params @optional_auth def get_download_history() -> Dict[str, Any]: """ Get download history with optional filtering. Query Parameters: - status: Filter by status (completed, failed) - anime_id: Filter by anime ID - date_from: Filter from date (ISO format) - date_to: Filter to date (ISO format) - page: Page number (default: 1) - per_page: Items per page (default: 50, max: 1000) Returns: Paginated download history """ if not download_manager: raise APIException("Download manager not available", 503) # Extract filters status_filter = request.args.get('status') anime_id = request.args.get('anime_id') date_from = request.args.get('date_from') date_to = request.args.get('date_to') # Validate filters if status_filter and status_filter not in ['completed', 'failed']: raise ValidationError("Status filter must be 'completed' or 'failed'") if anime_id: try: anime_id = int(anime_id) except ValueError: raise ValidationError("anime_id must be a valid integer") # Validate dates if date_from: try: datetime.fromisoformat(date_from.replace('Z', '+00:00')) except ValueError: raise ValidationError("date_from must be in ISO format") if date_to: try: datetime.fromisoformat(date_to.replace('Z', '+00:00')) except ValueError: raise ValidationError("date_to must be in ISO format") # Get pagination parameters page, per_page = extract_pagination_params() # Get download history history = download_manager.get_download_history( status_filter=status_filter, anime_id=anime_id, date_from=date_from, date_to=date_to ) # Format history data formatted_history = [format_download_response(download.__dict__) for download in history] # Apply pagination total = len(formatted_history) start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_history = formatted_history[start_idx:end_idx] return create_paginated_response( data=paginated_history, page=page, per_page=per_page, total=total, endpoint='downloads.get_download_history' )