"""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 import bcrypt 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('/auth/player', methods=['POST']) @rate_limit(max_requests=120, window=60) def authenticate_player(): """Authenticate a player and return auth code and configuration. Request JSON: hostname: Player hostname/identifier (required) password: Player password (optional if using quickconnect) quickconnect_code: Quick connect code (optional if using password) Returns: JSON with auth_code, player_id, group_id, and configuration """ data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 hostname = data.get('hostname') password = data.get('password') quickconnect_code = data.get('quickconnect_code') if not hostname: return jsonify({'error': 'Hostname is required'}), 400 if not password and not quickconnect_code: return jsonify({'error': 'Password or quickconnect code required'}), 400 # Authenticate player player = Player.authenticate(hostname, password, quickconnect_code) if not player: log_action('warning', f'Failed authentication attempt for hostname: {hostname}') return jsonify({'error': 'Invalid credentials'}), 401 # Update player status player.update_status('online') db.session.commit() log_action('info', f'Player authenticated: {player.name} ({player.hostname})') # Return authentication response response = { 'success': True, 'player_id': player.id, 'player_name': player.name, 'hostname': player.hostname, 'auth_code': player.auth_code, 'playlist_id': player.playlist_id, 'orientation': player.orientation, 'status': player.status } return jsonify(response), 200 @api_bp.route('/auth/verify', methods=['POST']) @rate_limit(max_requests=300, window=60) def verify_auth_code(): """Verify an auth code and return player information. Request JSON: auth_code: Player authentication code Returns: JSON with player information if valid """ data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 auth_code = data.get('auth_code') if not auth_code: return jsonify({'error': 'Auth code is required'}), 400 # Find player with this auth code player = Player.query.filter_by(auth_code=auth_code).first() if not player: return jsonify({'error': 'Invalid auth code'}), 401 # Update last seen player.update_status(player.status) db.session.commit() response = { 'valid': True, 'player_id': player.id, 'player_name': player.name, 'hostname': player.hostname, 'playlist_id': player.playlist_id, 'orientation': player.orientation, 'status': player.status } return jsonify(response), 200 @api_bp.route('/playlists', methods=['GET']) @rate_limit(max_requests=30, window=60) def get_playlist_by_quickconnect(): """Get playlist using hostname and quickconnect code (Kivy player compatible). Query parameters: hostname: Player hostname/identifier quickconnect_code: Quick connect code for authentication Returns: JSON with playlist, playlist_version, and hashed_quickconnect """ try: import bcrypt hostname = request.args.get('hostname') quickconnect_code = request.args.get('quickconnect_code') if not hostname or not quickconnect_code: return jsonify({ 'error': 'hostname and quickconnect_code are required', 'playlist': [], 'playlist_version': 0 }), 400 # Find player by hostname and validate quickconnect player = Player.query.filter_by(hostname=hostname).first() if not player: log_action('warning', f'Player not found with hostname: {hostname}') return jsonify({ 'error': 'Player not found', 'playlist': [], 'playlist_version': 0 }), 404 # Validate quickconnect code if not player.quickconnect_code: log_action('warning', f'Player {hostname} has no quickconnect code set') return jsonify({ 'error': 'Quickconnect not configured', 'playlist': [], 'playlist_version': 0 }), 403 # Check if quickconnect matches (using bcrypt verification) if not player.check_quickconnect_code(quickconnect_code): log_action('warning', f'Invalid quickconnect code for player: {hostname}') return jsonify({ 'error': 'Invalid quickconnect code', 'playlist': [], 'playlist_version': 0 }), 403 # Get playlist (with caching) playlist = get_cached_playlist(player.id) # Update player's last seen timestamp and status player.last_seen = datetime.utcnow() player.status = 'online' db.session.commit() # Get playlist version from the assigned playlist playlist_version = 1 if player.playlist_id: from app.models import Playlist assigned_playlist = Playlist.query.get(player.playlist_id) if assigned_playlist: playlist_version = assigned_playlist.version # Hash the quickconnect code for validation on client side hashed_quickconnect = bcrypt.hashpw( quickconnect_code.encode('utf-8'), bcrypt.gensalt() ).decode('utf-8') log_action('info', f'Playlist fetched for player: {player.name} ({hostname})') return jsonify({ 'player_id': player.id, 'player_name': player.name, 'playlist_id': player.playlist_id, 'playlist_version': playlist_version, 'playlist': playlist, 'hashed_quickconnect': hashed_quickconnect, 'count': len(playlist) }), 200 except Exception as e: log_action('error', f'Error getting playlist: {str(e)}') return jsonify({ 'error': 'Internal server error', 'playlist': [], 'playlist_version': 0 }), 500 @api_bp.route('/playlists/', methods=['GET']) @rate_limit(max_requests=300, 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() # Get playlist version from the assigned playlist playlist_version = 1 if player.playlist_id: from app.models import Playlist assigned_playlist = Playlist.query.get(player.playlist_id) if assigned_playlist: playlist_version = assigned_playlist.version return jsonify({ 'player_id': player_id, 'player_name': player.name, 'playlist_id': player.playlist_id, 'playlist_version': playlist_version, '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 @api_bp.route('/playlist-version/', methods=['GET']) @verify_player_auth @rate_limit(max_requests=60, window=60) def get_playlist_version(player_id: int): """Get current playlist version for a player. Lightweight endpoint for players to check if playlist needs updating. 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 # Update last seen player.last_seen = datetime.utcnow() db.session.commit() return jsonify({ 'player_id': player_id, 'playlist_version': player.playlist_version, 'content_count': Content.query.filter_by(player_id=player_id).count() }) except Exception as e: log_action('error', f'Error getting playlist version 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 based on assigned playlist.""" from flask import url_for from app.models import Playlist player = Player.query.get(player_id) if not player or not player.playlist_id: return [] # Get the playlist assigned to this player playlist = Playlist.query.get(player.playlist_id) if not playlist: return [] # Get content from playlist (ordered) content_list = playlist.get_content_ordered() # Build playlist response playlist_data = [] for idx, content in enumerate(content_list, start=1): # Generate full URL for content from flask import request as current_request # Get server base URL server_base = current_request.host_url.rstrip('/') content_url = f"{server_base}/static/uploads/{content.filename}" playlist_data.append({ 'id': content.id, 'file_name': content.filename, # Player expects 'file_name' not 'filename' 'type': content.content_type, 'duration': content._playlist_duration or content.duration or 10, 'position': content._playlist_position or idx, 'url': content_url, # Full URL for downloads 'description': content.description, 'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False) }) return playlist_data @api_bp.route('/player-feedback', methods=['POST']) @rate_limit(max_requests=600, window=60) def receive_player_feedback(): """Receive feedback/status updates from players (Kivy player compatible). Expected JSON payload: { "player_name": "Screen1", "quickconnect_code": "ABC123", "status": "playing|paused|error|restarting", "message": "Status message", "playlist_version": 1, "error_details": "Optional error details", "timestamp": "ISO timestamp" } """ try: data = request.json if not data: log_action('warning', 'Player feedback received with no data') return jsonify({'error': 'No data provided'}), 400 player_name = data.get('player_name') hostname = data.get('hostname') # Also accept hostname quickconnect_code = data.get('quickconnect_code') # Find player by hostname first (more reliable), then by name player = None if hostname: player = Player.query.filter_by(hostname=hostname).first() if not player and player_name: player = Player.query.filter_by(name=player_name).first() # If player not found and no credentials provided, try to infer from IP and recent auth if not player and (not quickconnect_code or (not player_name and not hostname)): # Try to find player by recent authentication from same IP client_ip = request.remote_addr # Look for players with matching IP in recent activity (last 5 minutes) recent_time = datetime.utcnow() - timedelta(minutes=5) possible_player = Player.query.filter( Player.last_seen >= recent_time ).order_by(Player.last_seen.desc()).first() if possible_player: player = possible_player log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity') # Still require quickconnect validation if provided if not player: if not player_name and not hostname: log_action('warning', f'Player feedback missing required fields. Data: {data}') return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400 else: log_action('warning', f'Player feedback from unknown player: {player_name or hostname}') return jsonify({'error': 'Player not found'}), 404 if not player: log_action('warning', f'Player feedback from unknown player: {player_name or hostname}') return jsonify({'error': 'Player not found'}), 404 # Validate quickconnect code if provided (using bcrypt verification) if quickconnect_code and not player.check_quickconnect_code(quickconnect_code): log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})') return jsonify({'error': 'Invalid quickconnect code'}), 403 # Create feedback record status = data.get('status', 'unknown') message = data.get('message', '') error_details = data.get('error_details') feedback = PlayerFeedback( player_id=player.id, status=status, message=message, error=error_details ) db.session.add(feedback) # Update player's last seen and status player.last_seen = datetime.utcnow() player.status = status db.session.commit() log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}') return jsonify({ 'success': True, 'message': 'Feedback received', 'player_id': player.id }), 200 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.route('/player-edit-media', methods=['POST']) @rate_limit(max_requests=60, window=60) @verify_player_auth def receive_edited_media(): """Receive edited media from player. Expected multipart/form-data: - image_file: The edited image file - metadata: JSON string with metadata Metadata JSON structure: { "time_of_modification": "ISO timestamp", "original_name": "original_file.jpg", "new_name": "original_file_v1.jpg", "version": 1, "user": "player_user" } """ try: player = request.player # Check if file is present if 'image_file' not in request.files: return jsonify({'error': 'No image file provided'}), 400 file = request.files['image_file'] if file.filename == '': return jsonify({'error': 'No file selected'}), 400 # Get metadata import json metadata_str = request.form.get('metadata') if not metadata_str: return jsonify({'error': 'No metadata provided'}), 400 try: metadata = json.loads(metadata_str) except json.JSONDecodeError: return jsonify({'error': 'Invalid metadata JSON'}), 400 # Validate required metadata fields required_fields = ['time_of_modification', 'original_name', 'new_name', 'version'] for field in required_fields: if field not in metadata: return jsonify({'error': f'Missing required field: {field}'}), 400 # Import required modules import os from werkzeug.utils import secure_filename from app.models.player_edit import PlayerEdit # Find the original content by filename original_name = metadata['original_name'] content = Content.query.filter_by(filename=original_name).first() if not content: log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}') return jsonify({'error': f'Original content not found: {original_name}'}), 404 # Create versioned folder structure: edited_media// base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads') edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id)) os.makedirs(edited_media_dir, exist_ok=True) # Save the edited file with version suffix version = metadata['version'] new_filename = metadata['new_name'] edited_file_path = os.path.join(edited_media_dir, new_filename) file.save(edited_file_path) # Save metadata JSON file metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json" metadata_path = os.path.join(edited_media_dir, metadata_filename) with open(metadata_path, 'w') as f: json.dump(metadata, f, indent=2) # Copy the versioned image to the main uploads folder import shutil versioned_upload_path = os.path.join(base_upload_dir, new_filename) shutil.copy2(edited_file_path, versioned_upload_path) # Update the content record to reference the new versioned filename old_filename = content.filename content.filename = new_filename # Create edit record time_of_mod = None if metadata.get('time_of_modification'): try: time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00')) except: time_of_mod = datetime.utcnow() edit_record = PlayerEdit( player_id=player.id, content_id=content.id, original_name=original_name, new_name=new_filename, version=version, user=metadata.get('user'), time_of_modification=time_of_mod, metadata_path=metadata_path, edited_file_path=edited_file_path ) db.session.add(edit_record) # Update playlist version to force player refresh if player.playlist_id: from app.models.playlist import Playlist playlist = db.session.get(Playlist, player.playlist_id) if playlist: playlist.version += 1 # Clear playlist cache cache.delete_memoized(get_cached_playlist, player.id) db.session.commit() log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})') return jsonify({ 'success': True, 'message': 'Edited media received and processed', 'edit_id': edit_record.id, 'version': version, 'old_filename': old_filename, 'new_filename': new_filename, 'new_playlist_version': playlist.version if player.playlist_id and playlist else None }), 200 except Exception as e: db.session.rollback() log_action('error', f'Error receiving edited media: {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