"""API blueprint for REST endpoints and player communication.""" from flask import Blueprint, request, jsonify, current_app from functools import wraps from datetime import datetime, timedelta import secrets from typing import Optional, Dict, List from app.extensions import db, cache from app.models import Player, Group, Content, PlayerFeedback, ServerLog from app.utils.logger import log_action api_bp = Blueprint('api', __name__, url_prefix='/api') # Simple rate limiting (use Redis-based solution in production) rate_limit_storage = {} def rate_limit(max_requests: int = 60, window: int = 60): """Rate limiting decorator. Args: max_requests: Maximum number of requests allowed window: Time window in seconds """ def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): # Get client identifier (IP address or API key) client_id = request.remote_addr auth_header = request.headers.get('Authorization') if auth_header and auth_header.startswith('Bearer '): client_id = auth_header[7:] # Use API key as identifier now = datetime.now() key = f"{client_id}:{f.__name__}" # Clean old entries if key in rate_limit_storage: rate_limit_storage[key] = [ req_time for req_time in rate_limit_storage[key] if now - req_time < timedelta(seconds=window) ] else: rate_limit_storage[key] = [] # Check rate limit if len(rate_limit_storage[key]) >= max_requests: return jsonify({ 'error': 'Rate limit exceeded', 'retry_after': window }), 429 # Add current request rate_limit_storage[key].append(now) return f(*args, **kwargs) return decorated_function return decorator def verify_player_auth(f): """Decorator to verify player authentication via auth code.""" @wraps(f) def decorated_function(*args, **kwargs): auth_header = request.headers.get('Authorization') if not auth_header or not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid authorization header'}), 401 auth_code = auth_header[7:] # Remove 'Bearer ' prefix # Find player with this auth code player = Player.query.filter_by(auth_code=auth_code).first() if not player: log_action('warning', f'Invalid auth code attempt: {auth_code}') return jsonify({'error': 'Invalid authentication code'}), 403 # Store player in request context request.player = player return f(*args, **kwargs) return decorated_function @api_bp.route('/health', methods=['GET']) def health_check(): """API health check endpoint.""" return jsonify({ 'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'version': '2.0.0' }) @api_bp.route('/playlists/', methods=['GET']) @rate_limit(max_requests=30, window=60) @verify_player_auth def get_player_playlist(player_id: int): """Get playlist for a specific player. Requires player authentication via Bearer token. """ try: # Verify the authenticated player matches the requested player_id if request.player.id != player_id: return jsonify({'error': 'Unauthorized access to this player'}), 403 player = request.player # Get playlist (with caching) playlist = get_cached_playlist(player_id) # Update player's last seen timestamp player.last_seen = datetime.utcnow() db.session.commit() return jsonify({ 'player_id': player_id, 'player_name': player.name, 'group_id': player.group_id, 'playlist': playlist, 'count': len(playlist) }) except Exception as e: log_action('error', f'Error getting playlist for player {player_id}: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @cache.memoize(timeout=300) def get_cached_playlist(player_id: int) -> List[Dict]: """Get cached playlist for a player.""" from flask import url_for player = Player.query.get(player_id) if not player: return [] # Get content based on group assignment if player.group_id: group = Group.query.get(player.group_id) contents = group.contents.order_by(Content.position).all() if group else [] else: # Show all content if not in a group contents = Content.query.order_by(Content.position).all() # Build playlist playlist = [] for content in contents: playlist.append({ 'id': content.id, 'filename': content.filename, 'type': content.content_type, 'duration': content.duration or 10, 'position': content.position, 'url': f"/static/uploads/{content.filename}", 'description': content.description }) return playlist @api_bp.route('/player-feedback', methods=['POST']) @rate_limit(max_requests=100, window=60) @verify_player_auth def receive_player_feedback(): """Receive feedback/status updates from players. Expected JSON payload: { "status": "playing|paused|error", "current_content_id": 123, "message": "Optional status message", "error": "Optional error message" } """ try: player = request.player data = request.json if not data: return jsonify({'error': 'No data provided'}), 400 # Create feedback record feedback = PlayerFeedback( player_id=player.id, status=data.get('status', 'unknown'), current_content_id=data.get('current_content_id'), message=data.get('message'), error=data.get('error') ) db.session.add(feedback) # Update player's last seen player.last_seen = datetime.utcnow() player.status = data.get('status', 'unknown') db.session.commit() return jsonify({ 'success': True, 'message': 'Feedback received' }) except Exception as e: db.session.rollback() log_action('error', f'Error receiving player feedback: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/player-status/', methods=['GET']) @rate_limit(max_requests=60, window=60) def get_player_status(player_id: int): """Get current status of a player (public endpoint for monitoring).""" try: player = Player.query.get_or_404(player_id) # Get latest feedback latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\ .order_by(PlayerFeedback.timestamp.desc())\ .first() # Calculate if player is online (seen in last 5 minutes) is_online = False if player.last_seen: is_online = (datetime.utcnow() - player.last_seen).total_seconds() < 300 return jsonify({ 'player_id': player_id, 'name': player.name, 'location': player.location, 'group_id': player.group_id, 'status': player.status, 'is_online': is_online, 'last_seen': player.last_seen.isoformat() if player.last_seen else None, 'latest_feedback': { 'status': latest_feedback.status, 'message': latest_feedback.message, 'timestamp': latest_feedback.timestamp.isoformat() } if latest_feedback else None }) except Exception as e: log_action('error', f'Error getting player status: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/upload-progress/', methods=['GET']) @rate_limit(max_requests=120, window=60) def get_upload_progress(upload_id: str): """Get progress of a file upload.""" from app.utils.uploads import get_upload_progress as get_progress try: progress = get_progress(upload_id) return jsonify(progress) except Exception as e: log_action('error', f'Error getting upload progress: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/system-info', methods=['GET']) @rate_limit(max_requests=30, window=60) def system_info(): """Get system information and statistics.""" try: # Get counts total_players = Player.query.count() total_groups = Group.query.count() total_content = Content.query.count() # Count online players (seen in last 5 minutes) five_min_ago = datetime.utcnow() - timedelta(minutes=5) online_players = Player.query.filter(Player.last_seen >= five_min_ago).count() # Get recent logs count recent_logs = ServerLog.query.filter( ServerLog.timestamp >= datetime.utcnow() - timedelta(hours=24) ).count() return jsonify({ 'players': { 'total': total_players, 'online': online_players }, 'groups': total_groups, 'content': total_content, 'logs_24h': recent_logs, 'timestamp': datetime.utcnow().isoformat() }) except Exception as e: log_action('error', f'Error getting system info: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/groups', methods=['GET']) @rate_limit(max_requests=60, window=60) def list_groups(): """List all groups with basic information.""" try: groups = Group.query.order_by(Group.name).all() groups_data = [] for group in groups: groups_data.append({ 'id': group.id, 'name': group.name, 'description': group.description, 'player_count': group.players.count(), 'content_count': group.contents.count() }) return jsonify({ 'groups': groups_data, 'count': len(groups_data) }) except Exception as e: log_action('error', f'Error listing groups: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/content', methods=['GET']) @rate_limit(max_requests=60, window=60) def list_content(): """List all content with basic information.""" try: contents = Content.query.order_by(Content.uploaded_at.desc()).all() content_data = [] for content in contents: content_data.append({ 'id': content.id, 'filename': content.filename, 'type': content.content_type, 'duration': content.duration, 'size': content.file_size, 'uploaded_at': content.uploaded_at.isoformat(), 'group_count': content.groups.count() }) return jsonify({ 'content': content_data, 'count': len(content_data) }) except Exception as e: log_action('error', f'Error listing content: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/logs', methods=['GET']) @rate_limit(max_requests=30, window=60) def get_logs(): """Get recent server logs. Query parameters: limit: Number of logs to return (default: 50, max: 200) level: Filter by log level (info, warning, error) since: ISO timestamp to get logs since that time """ try: # Get query parameters limit = min(request.args.get('limit', 50, type=int), 200) level = request.args.get('level') since_str = request.args.get('since') # Build query query = ServerLog.query if level: query = query.filter_by(level=level) if since_str: try: since = datetime.fromisoformat(since_str) query = query.filter(ServerLog.timestamp >= since) except ValueError: return jsonify({'error': 'Invalid since timestamp format'}), 400 # Get logs logs = query.order_by(ServerLog.timestamp.desc()).limit(limit).all() logs_data = [] for log in logs: logs_data.append({ 'id': log.id, 'level': log.level, 'message': log.message, 'timestamp': log.timestamp.isoformat() }) return jsonify({ 'logs': logs_data, 'count': len(logs_data) }) except Exception as e: log_action('error', f'Error getting logs: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.errorhandler(404) def api_not_found(error): """Handle 404 errors in API.""" return jsonify({'error': 'Endpoint not found'}), 404 @api_bp.errorhandler(405) def method_not_allowed(error): """Handle 405 errors in API.""" return jsonify({'error': 'Method not allowed'}), 405 @api_bp.errorhandler(500) def internal_error(error): """Handle 500 errors in API.""" return jsonify({'error': 'Internal server error'}), 500