Aniworld/src/server/api_endpoints.py

570 lines
18 KiB
Python

"""
API Integration Endpoints
This module provides REST API endpoints for external integrations,
webhooks, exports, and notifications.
"""
import json
from flask import Blueprint, request, jsonify, make_response, current_app
from auth import require_auth, optional_auth
from error_handler import handle_api_errors, RetryableError, NonRetryableError
from api_integration import (
api_key_manager, webhook_manager, export_manager, notification_service,
require_api_key
)
# Blueprint for API integration endpoints
api_integration_bp = Blueprint('api_integration', __name__)
# API Key Management Endpoints
@api_integration_bp.route('/api/keys', methods=['GET'])
@handle_api_errors
@require_auth
def list_api_keys():
"""List all API keys."""
try:
keys = api_key_manager.list_api_keys()
return jsonify({
'status': 'success',
'data': {
'api_keys': keys,
'count': len(keys)
}
})
except Exception as e:
raise RetryableError(f"Failed to list API keys: {e}")
@api_integration_bp.route('/api/keys', methods=['POST'])
@handle_api_errors
@require_auth
def create_api_key():
"""Create a new API key."""
try:
data = request.get_json()
name = data.get('name')
permissions = data.get('permissions', [])
rate_limit = data.get('rate_limit', 1000)
if not name:
return jsonify({
'status': 'error',
'message': 'Name is required'
}), 400
if not isinstance(permissions, list):
return jsonify({
'status': 'error',
'message': 'Permissions must be a list'
}), 400
# Validate permissions
valid_permissions = ['read', 'write', 'admin', 'download', 'export']
invalid_permissions = set(permissions) - set(valid_permissions)
if invalid_permissions:
return jsonify({
'status': 'error',
'message': f'Invalid permissions: {", ".join(invalid_permissions)}'
}), 400
api_key, key_id = api_key_manager.create_api_key(name, permissions, rate_limit)
return jsonify({
'status': 'success',
'message': 'API key created successfully',
'data': {
'api_key': api_key, # Only returned once!
'key_id': key_id,
'name': name,
'permissions': permissions,
'rate_limit': rate_limit
}
}), 201
except Exception as e:
raise RetryableError(f"Failed to create API key: {e}")
@api_integration_bp.route('/api/keys/<key_id>', methods=['DELETE'])
@handle_api_errors
@require_auth
def revoke_api_key(key_id):
"""Revoke an API key."""
try:
success = api_key_manager.revoke_api_key(key_id)
if success:
return jsonify({
'status': 'success',
'message': 'API key revoked successfully'
})
else:
return jsonify({
'status': 'error',
'message': 'API key not found'
}), 404
except Exception as e:
raise RetryableError(f"Failed to revoke API key: {e}")
# Webhook Management Endpoints
@api_integration_bp.route('/api/webhooks', methods=['GET'])
@handle_api_errors
@require_auth
def list_webhooks():
"""List all webhook endpoints."""
try:
webhooks = webhook_manager.list_webhooks()
return jsonify({
'status': 'success',
'data': {
'webhooks': webhooks,
'count': len(webhooks)
}
})
except Exception as e:
raise RetryableError(f"Failed to list webhooks: {e}")
@api_integration_bp.route('/api/webhooks', methods=['POST'])
@handle_api_errors
@require_auth
def create_webhook():
"""Create a new webhook endpoint."""
try:
data = request.get_json()
name = data.get('name')
url = data.get('url')
events = data.get('events', [])
secret = data.get('secret')
if not name or not url:
return jsonify({
'status': 'error',
'message': 'Name and URL are required'
}), 400
if not isinstance(events, list) or not events:
return jsonify({
'status': 'error',
'message': 'At least one event must be specified'
}), 400
# Validate events
valid_events = [
'download.started', 'download.completed', 'download.failed',
'scan.started', 'scan.completed', 'scan.failed',
'series.added', 'series.removed'
]
invalid_events = set(events) - set(valid_events)
if invalid_events:
return jsonify({
'status': 'error',
'message': f'Invalid events: {", ".join(invalid_events)}'
}), 400
webhook_id = webhook_manager.create_webhook(name, url, events, secret)
return jsonify({
'status': 'success',
'message': 'Webhook created successfully',
'data': {
'webhook_id': webhook_id,
'name': name,
'url': url,
'events': events
}
}), 201
except Exception as e:
raise RetryableError(f"Failed to create webhook: {e}")
@api_integration_bp.route('/api/webhooks/<webhook_id>', methods=['DELETE'])
@handle_api_errors
@require_auth
def delete_webhook(webhook_id):
"""Delete a webhook endpoint."""
try:
success = webhook_manager.delete_webhook(webhook_id)
if success:
return jsonify({
'status': 'success',
'message': 'Webhook deleted successfully'
})
else:
return jsonify({
'status': 'error',
'message': 'Webhook not found'
}), 404
except Exception as e:
raise RetryableError(f"Failed to delete webhook: {e}")
@api_integration_bp.route('/api/webhooks/test', methods=['POST'])
@handle_api_errors
@require_auth
def test_webhook():
"""Test webhook delivery."""
try:
data = request.get_json()
webhook_id = data.get('webhook_id')
if not webhook_id:
return jsonify({
'status': 'error',
'message': 'webhook_id is required'
}), 400
# Send test event
test_data = {
'message': 'This is a test webhook delivery',
'test': True
}
webhook_manager.trigger_event('test.webhook', test_data)
return jsonify({
'status': 'success',
'message': 'Test webhook triggered'
})
except Exception as e:
raise RetryableError(f"Failed to test webhook: {e}")
# Export Endpoints
@api_integration_bp.route('/api/export/anime-list')
@handle_api_errors
@require_api_key(['read', 'export'])
def export_anime_list():
"""Export anime list in JSON or CSV format."""
try:
format_type = request.args.get('format', 'json').lower()
include_missing_only = request.args.get('missing_only', 'false').lower() == 'true'
if format_type not in ['json', 'csv']:
return jsonify({
'status': 'error',
'message': 'Format must be either "json" or "csv"'
}), 400
if format_type == 'json':
data = export_manager.export_anime_list_json(include_missing_only)
response = make_response(jsonify({
'status': 'success',
'data': data
}))
response.headers['Content-Type'] = 'application/json'
else: # CSV
csv_data = export_manager.export_anime_list_csv(include_missing_only)
response = make_response(csv_data)
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=anime_list.csv'
return response
except Exception as e:
raise RetryableError(f"Failed to export anime list: {e}")
@api_integration_bp.route('/api/export/statistics')
@handle_api_errors
@require_api_key(['read', 'export'])
def export_statistics():
"""Export download statistics."""
try:
data = export_manager.export_download_statistics()
return jsonify({
'status': 'success',
'data': data
})
except Exception as e:
raise RetryableError(f"Failed to export statistics: {e}")
# External API Endpoints (for API key authentication)
@api_integration_bp.route('/api/v1/series')
@handle_api_errors
@require_api_key(['read'])
def api_get_series():
"""Get series list via API."""
try:
# This would integrate with the main series app
from app import series_app
if not series_app or not series_app.List:
return jsonify({
'status': 'success',
'data': {
'series': [],
'count': 0
}
})
series_list = []
for serie in series_app.List.GetList():
series_data = {
'name': serie.name or serie.folder,
'folder': serie.folder,
'key': getattr(serie, 'key', None),
'missing_episodes_count': sum(len(episodes) for episodes in serie.episodeDict.values()) if hasattr(serie, 'episodeDict') and serie.episodeDict else 0
}
series_list.append(series_data)
return jsonify({
'status': 'success',
'data': {
'series': series_list,
'count': len(series_list)
}
})
except Exception as e:
raise RetryableError(f"Failed to get series: {e}")
@api_integration_bp.route('/api/v1/series/<serie_folder>/episodes')
@handle_api_errors
@require_api_key(['read'])
def api_get_series_episodes(serie_folder):
"""Get episodes for a specific series via API."""
try:
from app import series_app
if not series_app or not series_app.List:
return jsonify({
'status': 'error',
'message': 'Series data not available'
}), 404
# Find series by folder
target_serie = None
for serie in series_app.List.GetList():
if serie.folder == serie_folder:
target_serie = serie
break
if not target_serie:
return jsonify({
'status': 'error',
'message': 'Series not found'
}), 404
episodes_data = {}
if hasattr(target_serie, 'episodeDict') and target_serie.episodeDict:
for season, episodes in target_serie.episodeDict.items():
episodes_data[str(season)] = list(episodes)
return jsonify({
'status': 'success',
'data': {
'series_name': target_serie.name or target_serie.folder,
'folder': target_serie.folder,
'missing_episodes': episodes_data
}
})
except Exception as e:
raise RetryableError(f"Failed to get series episodes: {e}")
@api_integration_bp.route('/api/v1/download/start', methods=['POST'])
@handle_api_errors
@require_api_key(['download'])
def api_start_download():
"""Start download for specific episodes via API."""
try:
data = request.get_json()
serie_folder = data.get('serie_folder')
season = data.get('season')
episode = data.get('episode')
if not all([serie_folder, season is not None, episode is not None]):
return jsonify({
'status': 'error',
'message': 'serie_folder, season, and episode are required'
}), 400
# This would integrate with the download system
# For now, trigger webhook event
webhook_manager.trigger_event('download.started', {
'serie_folder': serie_folder,
'season': season,
'episode': episode,
'requested_via': 'api'
})
return jsonify({
'status': 'success',
'message': 'Download started',
'data': {
'serie_folder': serie_folder,
'season': season,
'episode': episode
}
})
except Exception as e:
raise RetryableError(f"Failed to start download: {e}")
# Notification Service Endpoints
@api_integration_bp.route('/api/notifications/discord', methods=['POST'])
@handle_api_errors
@require_auth
def setup_discord_notifications():
"""Setup Discord webhook notifications."""
try:
data = request.get_json()
webhook_url = data.get('webhook_url')
name = data.get('name', 'discord')
if not webhook_url:
return jsonify({
'status': 'error',
'message': 'webhook_url is required'
}), 400
notification_service.register_discord_webhook(webhook_url, name)
return jsonify({
'status': 'success',
'message': 'Discord notifications configured'
})
except Exception as e:
raise RetryableError(f"Failed to setup Discord notifications: {e}")
@api_integration_bp.route('/api/notifications/telegram', methods=['POST'])
@handle_api_errors
@require_auth
def setup_telegram_notifications():
"""Setup Telegram bot notifications."""
try:
data = request.get_json()
bot_token = data.get('bot_token')
chat_id = data.get('chat_id')
name = data.get('name', 'telegram')
if not bot_token or not chat_id:
return jsonify({
'status': 'error',
'message': 'bot_token and chat_id are required'
}), 400
notification_service.register_telegram_bot(bot_token, chat_id, name)
return jsonify({
'status': 'success',
'message': 'Telegram notifications configured'
})
except Exception as e:
raise RetryableError(f"Failed to setup Telegram notifications: {e}")
@api_integration_bp.route('/api/notifications/test', methods=['POST'])
@handle_api_errors
@require_auth
def test_notifications():
"""Test notification delivery."""
try:
data = request.get_json()
service_name = data.get('service_name')
notification_service.send_notification(
message="This is a test notification from AniWorld API",
title="Test Notification",
service_name=service_name
)
return jsonify({
'status': 'success',
'message': 'Test notification sent'
})
except Exception as e:
raise RetryableError(f"Failed to send test notification: {e}")
# API Documentation Endpoint
@api_integration_bp.route('/api/docs')
def api_documentation():
"""Get API documentation."""
docs = {
'title': 'AniWorld API Documentation',
'version': '1.0.0',
'description': 'REST API for AniWorld anime download management',
'authentication': {
'type': 'API Key',
'header': 'Authorization: Bearer <api_key>',
'note': 'API keys can be created through the web interface'
},
'endpoints': {
'GET /api/v1/series': {
'description': 'Get list of all series',
'permissions': ['read'],
'parameters': {},
'response': 'List of series with basic information'
},
'GET /api/v1/series/{folder}/episodes': {
'description': 'Get episodes for specific series',
'permissions': ['read'],
'parameters': {
'folder': 'Series folder name'
},
'response': 'Missing episodes for the series'
},
'POST /api/v1/download/start': {
'description': 'Start download for specific episode',
'permissions': ['download'],
'parameters': {
'serie_folder': 'Series folder name',
'season': 'Season number',
'episode': 'Episode number'
},
'response': 'Download status'
},
'GET /api/export/anime-list': {
'description': 'Export anime list',
'permissions': ['read', 'export'],
'parameters': {
'format': 'json or csv',
'missing_only': 'true or false'
},
'response': 'Anime list in requested format'
}
},
'webhook_events': [
'download.started',
'download.completed',
'download.failed',
'scan.started',
'scan.completed',
'scan.failed',
'series.added',
'series.removed'
],
'rate_limits': {
'default': '1000 requests per hour per API key',
'note': 'Rate limits are configurable per API key'
}
}
return jsonify(docs)
# Export the blueprint
__all__ = ['api_integration_bp']