640 lines
20 KiB
Python
640 lines
20 KiB
Python
"""
|
|
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('/<int:download_id>', 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('/<int:download_id>/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('/<int:download_id>/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('/<int:download_id>/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('/<int:download_id>/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'
|
|
) |