diff --git a/packages/clip-processor-py/src/api_server.py b/packages/clip-processor-py/src/api_server.py index e294027f..00580565 100644 --- a/packages/clip-processor-py/src/api_server.py +++ b/packages/clip-processor-py/src/api_server.py @@ -9,7 +9,7 @@ import os import json import logging -from flask import Flask, request, jsonify, Response, send_file +from flask import request, jsonify, Response, send_file from urllib.parse import urlparse import traceback import re @@ -19,25 +19,8 @@ from pathlib import Path import psycopg2 from datetime import datetime, timedelta -from functools import wraps - -# Configure logging -log_dir = os.path.join(os.path.dirname(__file__), 'logs') -os.makedirs(log_dir, exist_ok=True) -log_file = os.path.join(log_dir, 'api_server.log') -logging.basicConfig( - force=True, - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), - logging.FileHandler(log_file, mode='a') - ] -) logger = logging.getLogger(__name__) -logger.info("=== API Server logging initialized ===") - # Import the hero detection and hero data modules try: from dota_hero_detection import process_clip_url, process_stream_username, load_heroes_data @@ -60,29 +43,6 @@ # Global variable to store preloaded hero data heroes_data = None -# Create Flask app -app = Flask(__name__) -# Define the API key for authentication -API_KEY = os.environ.get('VISION_API_KEY') -if not API_KEY and os.environ.get('RUN_LOCALLY') != 'true': - logger.error("No API key found. Set VISION_API_KEY environment variable.") - raise ValueError("VISION_API_KEY environment variable must be set") - -# Authentication decorator -def require_api_key(f): - @wraps(f) - def decorated_function(*args, **kwargs): - # If running locally, skip authentication - if os.environ.get('RUN_LOCALLY') == 'true': - return f(*args, **kwargs) - - provided_key = request.headers.get('X-API-Key') - if provided_key and provided_key == API_KEY: - return f(*args, **kwargs) - else: - logger.warning(f"Unauthorized access attempt: {request.remote_addr} - {request.path}") - return jsonify({'error': 'Unauthorized. Valid API key required.'}), 401 - return decorated_function # Define the directory for storing frame images TEMP_DIR = Path(os.path.dirname(os.path.abspath(__file__))).parent / "temp" @@ -354,194 +314,12 @@ def process_queue_worker(): logger.info("Queue worker thread stopped") # Initialize app before first request -@app.before_request def before_first_request(): """Ensure app is initialized before handling the first request.""" if not app_initialized: logger.info("Initializing app before first request...") initialize_app() -# Health check endpoint - no authentication required -@app.route('/health', methods=['GET']) -def health_check(): - """Simple health check endpoint.""" - return jsonify({'status': 'ok', 'service': 'dota-hero-detection-api'}) - -# All other endpoints require authentication -@app.route('/queue/debug', methods=['GET']) -@require_api_key -def debug_queue(): - """ - Debug endpoint to check the queue status and worker thread. - - This is for administrative use only. - """ - try: - # Ensure the app is initialized - if not app_initialized: - initialize_app() - - # Get all pending requests - conn = db_client._get_connection() - cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - - # Get counts of requests by status - cursor.execute(f"SELECT status, COUNT(*) FROM {db_client.queue_table} GROUP BY status") - status_counts = cursor.fetchall() - - # Get the 10 most recent requests - cursor.execute(f""" - SELECT request_id, request_type, status, created_at, started_at, completed_at, clip_id, match_id - FROM {db_client.queue_table} - ORDER BY created_at DESC - LIMIT 10 - """) - recent_requests = cursor.fetchall() - - # Format datetime objects for JSON and prepare force processing URL - for req in recent_requests: - # First pass: format datetime objects - for key, value in list(req.items()): - if isinstance(value, datetime): - req[key] = value.isoformat() - - # Second pass: add force processing URL if clip_id and match_id exist - if 'clip_id' in req and 'match_id' in req: - req['force_process_again'] = f"http://localhost:5000/detect?clip_id={req['clip_id']}&force=true&match_id={req['match_id']}" - - cursor.close() - db_client._return_connection(conn) - - # Check worker thread status - worker_status = "running" if worker_running else "not running" - - # Manual restart option - restart = request.args.get('restart', 'false').lower() == 'true' - if restart and not worker_running: - start_worker_thread() - worker_status = "restarted" - - # Manual reset stuck processing requests option - reset_stuck = request.args.get('reset_stuck', 'false').lower() == 'true' - if reset_stuck: - num_reset = reset_stuck_processing_requests() - worker_status = f"{worker_status}, reset {num_reset} stuck requests" - - return jsonify({ - 'worker_status': worker_status, - 'app_initialized': app_initialized, - 'queue_status': [dict(row) for row in status_counts], - 'recent_requests': [dict(row) for row in recent_requests] - }) - except Exception as e: - logger.error(f"Error in debug endpoint: {str(e)}") - logger.error(traceback.format_exc()) - return jsonify({ - 'error': str(e), - 'trace': traceback.format_exc() - }), 500 - -@app.route('/queue/status/', methods=['GET']) -@require_api_key -def check_queue_status(request_id): - """ - Check the status of a queued request. - - Parameters: - - request_id: The unique ID of the request - - Returns: - - Queue status information - """ - queue_info = db_client.get_queue_status(request_id) - - if not queue_info: - return jsonify({'error': 'Request not found'}), 404 - - status_code = 200 - response = { - 'request_id': request_id, - 'clip_id': queue_info.get('clip_id'), - 'status': queue_info['status'], - 'position': queue_info.get('position', 0), - 'created_at': queue_info.get('created_at'), - 'started_at': queue_info.get('started_at'), - 'completed_at': queue_info.get('completed_at'), - 'estimated_wait_seconds': queue_info.get('estimated_wait_seconds', 0), - 'estimated_completion_time': queue_info.get('estimated_completion_time') - } - - # Add result ID for completed requests - if queue_info['status'] in ('completed', 'failed'): - response['result_id'] = queue_info.get('result_id') - - # For completed requests, add the result - if queue_info['status'] == 'completed' and queue_info.get('result_id'): - if queue_info['request_type'] == 'clip': - cached_result = db_client.get_clip_result(queue_info['result_id']) - if cached_result: - # Replace placeholder with real host URL - if 'saved_image_path' in cached_result and cached_result['saved_image_path'] and '__HOST_URL__' in cached_result['saved_image_path']: - host_url = request.host_url.rstrip('/') - cached_result['saved_image_path'] = cached_result['saved_image_path'].replace('__HOST_URL__', host_url) - - response['result'] = cached_result - - return jsonify(response), status_code - -@app.route('/images/', methods=['GET']) -@require_api_key -def serve_image(filename): - """Serve the frame image with security checks.""" - try: - # Reject filenames with path components - if '/' in filename or '\\' in filename or '..' in filename: - logger.warning(f"Attempted path traversal with invalid characters: {filename}") - return jsonify({'error': 'Invalid filename'}), 400 - - # Sanitize filename to prevent path traversal attacks - filename = os.path.basename(filename) - - # Validate file extension - if '.' not in filename or filename.rsplit('.', 1)[1].lower() not in ALLOWED_EXTENSIONS: - logger.warning(f"Invalid file extension requested: {filename}") - return jsonify({'error': 'Invalid file type'}), 400 - - # Construct safe path and check if it exists within IMAGE_DIR - image_path = IMAGE_DIR / filename - - # Convert to absolute paths for strict comparison - image_abs_path = os.path.abspath(image_path) - image_dir_abs_path = os.path.abspath(IMAGE_DIR) - - # Ensure the file path is strictly within IMAGE_DIR - if not image_abs_path.startswith(image_dir_abs_path + os.sep): - logger.warning(f"Attempted directory traversal: {filename}") - return jsonify({'error': 'Access denied'}), 403 - - if not image_path.exists() or not image_path.is_file(): - logger.warning(f"Image not found or is not a file: {filename}") - return jsonify({'error': 'Image not found'}), 404 - - # Additional check to verify the file is within the IMAGE_DIR (prevent symlink attacks) - try: - if not image_path.resolve().is_relative_to(IMAGE_DIR.resolve()): - logger.warning(f"Attempted symlink attack: {filename}") - return jsonify({'error': 'Access denied'}), 403 - except (ValueError, RuntimeError) as e: - logger.warning(f"Path resolution error for {filename}: {e}") - return jsonify({'error': 'Access denied'}), 403 - - # Set security headers - response = send_file(image_path, mimetype=f'image/{image_path.suffix[1:]}') - response.headers['Content-Security-Policy'] = "default-src 'self'" - response.headers['X-Content-Type-Options'] = 'nosniff' - response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' - response.headers['Pragma'] = 'no-cache' - return response - except Exception as e: - logger.error(f"Error serving image {filename}: {e}") - return jsonify({'error': 'Internal server error'}), 500 def extract_clip_id(clip_url): """ @@ -878,154 +656,6 @@ def process_stream_request(username, num_frames=3, debug=False, include_image=Tr else: return {'error': 'Failed to process stream or no heroes detected'} -@app.route('/detect', methods=['GET']) -@require_api_key -def detect_heroes(): - """ - Process a Twitch clip URL or clip ID and return hero detection results. - - Query parameters: - - url: The Twitch clip URL to process (required if clip_id not provided) - - clip_id: The Twitch clip ID (required if url not provided) - - match_id: The Dota 2 match ID to associate with this clip (required) - - debug: Enable debug mode (optional, default=False) - - force: Force reprocessing even if cached (optional, default=False) - - include_image: Include frame image URL in response (optional, default=False) - - queue: Use queue system (optional, default=True) - """ - clip_url = request.args.get('url') - clip_id = request.args.get('clip_id') - match_id = request.args.get('match_id') - debug = request.args.get('debug', 'false').lower() == 'true' - force = request.args.get('force', 'false').lower() == 'true' - include_image = request.args.get('include_image', 'true').lower() == 'true' - use_queue = request.args.get('queue', 'true').lower() == 'true' - - # When running locally, override queue parameter to process immediately - if os.environ.get('RUN_LOCALLY') == 'true': - use_queue = False - logger.info("Running locally, overriding queue parameter to process immediately") - - # Check if match_id is provided - if not match_id: - return jsonify({'error': 'Missing required parameter: match_id'}), 400 - - # Validate match_id is a number - try: - match_id = int(match_id) - except ValueError: - return jsonify({'error': 'Invalid match_id: must be a number'}), 400 - - # Convert back to string for consistent handling in the rest of the code - match_id = str(match_id) - - # Check if either clip_url or clip_id is provided - if not clip_url and not clip_id: - return jsonify({'error': 'Missing required parameter: either url or clip_id must be provided'}), 400 - - # If clip_id is provided but no url, construct the url - if clip_id and not clip_url: - clip_url = f"https://clips.twitch.tv/{clip_id}" - logger.info(f"Constructed clip URL from ID: {clip_url}") - # If only URL is provided, try to extract clip_id - elif clip_url and not clip_id: - extracted_clip_id = extract_clip_id(clip_url) - if extracted_clip_id: - clip_id = extracted_clip_id - logger.info(f"Extracted clip ID from URL: {clip_id}") - else: - logger.warning(f"Could not extract clip ID from URL: {clip_url}") - clip_id = clip_url # Use URL as ID fallback - - # Basic URL validation - try: - parsed_url = urlparse(clip_url) - if not parsed_url.scheme or not parsed_url.netloc: - return jsonify({'error': 'Invalid URL format'}), 400 - - # Check if it's likely a Twitch clip URL - if 'twitch.tv' not in parsed_url.netloc and 'clips.twitch.tv' not in parsed_url.netloc: - logger.warning(f"URL may not be a Twitch clip: {clip_url}") - except Exception as e: - return jsonify({'error': f'URL parsing error: {str(e)}'}), 400 - - try: - # Process the clip or add to queue - result = process_clip_request( - clip_url=clip_url, - clip_id=clip_id, - debug=debug, - force=force, - include_image=include_image, - add_to_queue=use_queue, - match_id=match_id # Pass match_id to the function - ) - - # Return the result - return jsonify(result) - - except Exception as e: - logger.error(f"Error processing clip: {str(e)}", exc_info=True) - error_details = { - 'error': 'Error processing clip', - 'message': str(e), - 'trace': traceback.format_exc() if debug else None - } - return jsonify(error_details), 500 - -@app.route('/detect-stream', methods=['GET']) -@require_api_key -def detect_heroes_from_stream(): - """ - Process a Twitch stream by username and return hero detection results. - - Query parameters: - - username: The Twitch username of the streamer (required) - - frames: Number of frames to capture and analyze (optional, default=3) - - debug: Enable debug mode (optional, default=False) - - include_image: Include frame image URL in response (optional, default=False) - - queue: Use queue system (optional, default=True) - """ - username = request.args.get('username') - num_frames = int(request.args.get('frames', '3')) - debug = request.args.get('debug', 'false').lower() == 'true' - include_image = request.args.get('include_image', 'false').lower() == 'true' - use_queue = request.args.get('queue', 'true').lower() == 'true' - - # When running locally, override queue parameter to process immediately - if os.environ.get('RUN_LOCALLY') == 'true': - use_queue = False - logger.info("Running locally, overriding queue parameter to process immediately") - - # Check if username is provided - if not username: - return jsonify({'error': 'Missing required parameter: username'}), 400 - - # Validate number of frames - if num_frames < 1 or num_frames > 10: - return jsonify({'error': 'Invalid frames parameter: must be between 1 and 10'}), 400 - - try: - # Process the stream or add to queue - result = process_stream_request( - username=username, - num_frames=num_frames, - debug=debug, - include_image=include_image, - add_to_queue=use_queue - ) - - # Return the result - return jsonify(result) - - except Exception as e: - logger.error(f"Error processing stream: {str(e)}", exc_info=True) - error_details = { - 'error': 'Error processing stream', - 'message': str(e), - 'trace': traceback.format_exc() if debug else None - } - return jsonify(error_details), 500 def reset_stuck_processing_requests(timeout_minutes=1): """ @@ -1088,161 +718,10 @@ def reset_stuck_processing_requests(timeout_minutes=1): logger.error(f"Error resetting stuck processing requests: {e}") logger.error(traceback.format_exc()) return 0 +from server import create_app -@app.route('/match/', methods=['GET']) -@require_api_key -def get_match_result(match_id): - """ - Get hero detection results for a Dota 2 match ID. - - Path parameters: - - match_id: The Dota 2 match ID - - Query parameters: - - force: Force reprocessing by submitting a new clip (optional, default=False) - - clip_url: The Twitch clip URL to process if forcing (optional) - - debug: Enable debug mode (optional, default=False) - - include_image: Include frame image URL in response (optional, default=True) - """ - force = request.args.get('force', 'false').lower() == 'true' - clip_url = request.args.get('clip_url') - debug = request.args.get('debug', 'false').lower() == 'true' - include_image = request.args.get('include_image', 'true').lower() == 'true' - - # Check if match_id is provided - if not match_id: - return jsonify({'error': 'Missing required parameter: match_id'}), 400 +app = create_app() - # Check if match id is a number - try: - match_id = int(match_id) - except ValueError: - return jsonify({'error': 'Invalid match_id: must be a number'}), 400 - - # Convert back to string for consistent handling in the rest of the code - match_id = str(match_id) - - try: - # If force is true and clip_url is provided, process the new clip - if force and clip_url: - # Extract clip_id from URL - clip_id = extract_clip_id(clip_url) - if not clip_id: - return jsonify({'error': 'Could not extract clip ID from URL'}), 400 - - # Process the clip with the match_id - result = process_clip_request( - clip_url=clip_url, - clip_id=clip_id, - debug=debug, - force=True, # Always force when explicitly requested - include_image=include_image, - add_to_queue=True, # Always queue for match_id requests - match_id=match_id - ) - - # If the result is queued, return that - if isinstance(result, dict) and result.get('queued'): - return jsonify(result) - - # Check if this match has any existing results or is in queue - match_status = db_client.check_for_match_processing(match_id) - - if not match_status or not match_status.get('found'): - # If no existing processing and no new clip URL provided - if not clip_url: - return jsonify({ - 'error': 'No results found for this match ID', - 'message': 'Please provide a clip_url to process' - }), 404 - - # If we have a clip_url but didn't force, process it anyway - clip_id = extract_clip_id(clip_url) - if not clip_id: - return jsonify({'error': 'Could not extract clip ID from URL'}), 400 - - # Process the clip with the match_id - result = process_clip_request( - clip_url=clip_url, - clip_id=clip_id, - debug=debug, - force=False, - include_image=include_image, - add_to_queue=True, - match_id=match_id - ) - - return jsonify(result) - - elif match_status.get('status') == 'completed': - # If we have a completed result, fetch and return it - result = db_client.get_clip_result_by_match_id(match_id) - - if result: - # Replace placeholder with real host URL if needed - if 'saved_image_path' in result and result['saved_image_path'] and '__HOST_URL__' in result['saved_image_path']: - host_url = request.host_url.rstrip('/') - result['saved_image_path'] = result['saved_image_path'].replace('__HOST_URL__', host_url) - - # Add match_id for context - result['match_id'] = match_id - - return jsonify(result) - else: - # This shouldn't happen if match_status says completed - return jsonify({ - 'error': 'Inconsistent state', - 'message': 'Match is marked as completed but no result found' - }), 500 - - elif match_status.get('status') in ('pending', 'processing'): - # If the match is in queue, return status - return jsonify({ - 'status': match_status.get('status'), - 'clip_id': match_status.get('clip_id'), - 'request_id': match_status.get('request_id'), - 'match_id': match_id, - 'message': f"Match is currently {match_status.get('status')}" - }) - - else: # Failed - # If failed and no new clip_url, report failure - if not clip_url: - return jsonify({ - 'error': 'Previous processing failed', - 'status': 'failed', - 'clip_id': match_status.get('clip_id'), - 'request_id': match_status.get('request_id'), - 'match_id': match_id, - 'message': 'Previous processing failed. Provide a clip_url to try again.' - }), 400 - - # If we have a clip_url and previous processing failed, try again - clip_id = extract_clip_id(clip_url) - if not clip_id: - return jsonify({'error': 'Could not extract clip ID from URL'}), 400 - - # Process the clip with the match_id - result = process_clip_request( - clip_url=clip_url, - clip_id=clip_id, - debug=debug, - force=True, # Force when previous failed - include_image=include_image, - add_to_queue=True, - match_id=match_id - ) - - return jsonify(result) - - except Exception as e: - logger.error(f"Error processing match ID {match_id}: {str(e)}", exc_info=True) - error_details = { - 'error': 'Error processing match', - 'message': str(e), - 'trace': traceback.format_exc() if debug else None - } - return jsonify(error_details), 500 def main(): """Main entry point for the API server.""" diff --git a/packages/clip-processor-py/src/server/__init__.py b/packages/clip-processor-py/src/server/__init__.py new file mode 100644 index 00000000..da68aeb2 --- /dev/null +++ b/packages/clip-processor-py/src/server/__init__.py @@ -0,0 +1,65 @@ +import os +import logging +from functools import wraps +from flask import Flask, request, jsonify + +# Configure logging +log_dir = os.path.join(os.path.dirname(__file__), 'logs') +os.makedirs(log_dir, exist_ok=True) +log_file = os.path.join(log_dir, 'api_server.log') +logging.basicConfig( + force=True, + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler(log_file, mode='a') + ] +) +logger = logging.getLogger(__name__) +logger.info("=== API Server logging initialized ===") + +# Define the API key for authentication +API_KEY = os.environ.get('VISION_API_KEY') +if not API_KEY and os.environ.get('RUN_LOCALLY') != 'true': + logger.error("No API key found. Set VISION_API_KEY environment variable.") + raise ValueError("VISION_API_KEY environment variable must be set") + + +def require_api_key(f): + """Flask decorator enforcing presence of X-API-Key header.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if os.environ.get('RUN_LOCALLY') == 'true': + return f(*args, **kwargs) + + provided_key = request.headers.get('X-API-Key') + if provided_key and provided_key == API_KEY: + return f(*args, **kwargs) + logger.warning( + "Unauthorized access attempt: %s - %s", + request.remote_addr, + request.path, + ) + return jsonify({'error': 'Unauthorized. Valid API key required.'}), 401 + + return decorated_function + +# Import routes after defining require_api_key to avoid circular imports +from . import routes + + +def create_app(): + """Create and configure the Flask application.""" + app = Flask(__name__) + + from ..api_server import initialize_app, app_initialized + + @app.before_request + def ensure_initialized(): + if not app_initialized: + logger.info("Initializing app before first request...") + initialize_app() + + app.register_blueprint(routes.bp) + return app diff --git a/packages/clip-processor-py/src/server/routes.py b/packages/clip-processor-py/src/server/routes.py new file mode 100644 index 00000000..60db205d --- /dev/null +++ b/packages/clip-processor-py/src/server/routes.py @@ -0,0 +1,340 @@ +# Routes for Flask application +import os +import traceback +from pathlib import Path +from datetime import datetime +from urllib.parse import urlparse + +from flask import Blueprint, request, jsonify, send_file +import psycopg2 + +from . import require_api_key +from ..api_server import ( + app_initialized, + initialize_app, + worker_running, + start_worker_thread, + reset_stuck_processing_requests, + process_clip_request, + process_stream_request, + extract_clip_id, + get_image_url, + db_client, + IMAGE_DIR, + ALLOWED_EXTENSIONS, +) + +bp = Blueprint('api', __name__) + + + + +@bp.route('/health', methods=['GET']) +def health_check(): + return jsonify({'status': 'ok', 'service': 'dota-hero-detection-api'}) + + +@bp.route('/queue/debug', methods=['GET']) +@require_api_key +def debug_queue(): + try: + if not app_initialized: + initialize_app() + + conn = db_client._get_connection() + cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + + cursor.execute( + f"SELECT status, COUNT(*) FROM {db_client.queue_table} GROUP BY status" + ) + status_counts = cursor.fetchall() + + cursor.execute( + f""" + SELECT request_id, request_type, status, created_at, started_at, completed_at, clip_id, match_id + FROM {db_client.queue_table} + ORDER BY created_at DESC + LIMIT 10 + """ + ) + recent_requests = cursor.fetchall() + + for req in recent_requests: + for key, value in list(req.items()): + if isinstance(value, datetime): + req[key] = value.isoformat() + if 'clip_id' in req and 'match_id' in req: + req['force_process_again'] = ( + f"http://localhost:5000/detect?clip_id={req['clip_id']}&force=true&match_id={req['match_id']}" + ) + + cursor.close() + db_client._return_connection(conn) + + worker_status = 'running' if worker_running else 'not running' + + restart = request.args.get('restart', 'false').lower() == 'true' + if restart and not worker_running: + start_worker_thread() + worker_status = 'restarted' + + reset_stuck = request.args.get('reset_stuck', 'false').lower() == 'true' + if reset_stuck: + num_reset = reset_stuck_processing_requests() + worker_status = f"{worker_status}, reset {num_reset} stuck requests" + + return jsonify({ + 'worker_status': worker_status, + 'app_initialized': app_initialized, + 'queue_status': [dict(row) for row in status_counts], + 'recent_requests': [dict(row) for row in recent_requests], + }) + except Exception as e: + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + + +@bp.route('/queue/status/', methods=['GET']) +@require_api_key +def check_queue_status(request_id): + queue_info = db_client.get_queue_status(request_id) + + if not queue_info: + return jsonify({'error': 'Request not found'}), 404 + + status_code = 200 + response = { + 'request_id': request_id, + 'clip_id': queue_info.get('clip_id'), + 'status': queue_info['status'], + 'position': queue_info.get('position', 0), + 'created_at': queue_info.get('created_at'), + 'started_at': queue_info.get('started_at'), + 'completed_at': queue_info.get('completed_at'), + 'estimated_wait_seconds': queue_info.get('estimated_wait_seconds', 0), + 'estimated_completion_time': queue_info.get('estimated_completion_time'), + } + + if queue_info['status'] in ('completed', 'failed'): + response['result_id'] = queue_info.get('result_id') + if queue_info['status'] == 'completed' and queue_info.get('result_id'): + cached_result = db_client.get_clip_result(queue_info['result_id']) + if ( + cached_result + and 'saved_image_path' in cached_result + and cached_result['saved_image_path'] + and '__HOST_URL__' in cached_result['saved_image_path'] + ): + host_url = request.host_url.rstrip('/') + cached_result['saved_image_path'] = cached_result['saved_image_path'].replace( + '__HOST_URL__', host_url + ) + response['result'] = cached_result + + return jsonify(response), status_code + + +@bp.route('/images/', methods=['GET']) +@require_api_key +def serve_image(filename): + try: + if '/' in filename or '\\' in filename or '..' in filename: + return jsonify({'error': 'Invalid filename'}), 400 + filename = os.path.basename(filename) + if '.' not in filename or filename.rsplit('.', 1)[1].lower() not in ALLOWED_EXTENSIONS: + return jsonify({'error': 'Invalid file type'}), 400 + image_path = IMAGE_DIR / filename + image_abs_path = os.path.abspath(image_path) + image_dir_abs_path = os.path.abspath(IMAGE_DIR) + if not image_abs_path.startswith(image_dir_abs_path + os.sep): + return jsonify({'error': 'Access denied'}), 403 + if not image_path.exists() or not image_path.is_file(): + return jsonify({'error': 'Image not found'}), 404 + try: + if not image_path.resolve().is_relative_to(IMAGE_DIR.resolve()): + return jsonify({'error': 'Access denied'}), 403 + except (ValueError, RuntimeError): + return jsonify({'error': 'Access denied'}), 403 + response = send_file(image_path, mimetype=f'image/{image_path.suffix[1:]}') + response.headers['Content-Security-Policy'] = "default-src 'self'" + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + response.headers['Pragma'] = 'no-cache' + return response + except Exception: + return jsonify({'error': 'Internal server error'}), 500 + + +@bp.route('/detect', methods=['GET']) +@require_api_key +def detect_heroes(): + clip_url = request.args.get('url') + clip_id = request.args.get('clip_id') + match_id = request.args.get('match_id') + debug = request.args.get('debug', 'false').lower() == 'true' + force = request.args.get('force', 'false').lower() == 'true' + include_image = request.args.get('include_image', 'true').lower() == 'true' + use_queue = request.args.get('queue', 'true').lower() == 'true' + + if os.environ.get('RUN_LOCALLY') == 'true': + use_queue = False + + if not match_id: + return jsonify({'error': 'Missing required parameter: match_id'}), 400 + + try: + match_id = int(match_id) + except ValueError: + return jsonify({'error': 'Invalid match_id: must be a number'}), 400 + match_id = str(match_id) + + if not clip_url and not clip_id: + return jsonify({'error': 'Missing required parameter: either url or clip_id must be provided'}), 400 + + if clip_id and not clip_url: + clip_url = f"https://clips.twitch.tv/{clip_id}" + elif clip_url and not clip_id: + extracted_clip_id = extract_clip_id(clip_url) + clip_id = extracted_clip_id if extracted_clip_id else clip_url + + try: + parsed_url = urlparse(clip_url) + if not parsed_url.scheme or not parsed_url.netloc: + return jsonify({'error': 'Invalid URL format'}), 400 + if 'twitch.tv' not in parsed_url.netloc and 'clips.twitch.tv' not in parsed_url.netloc: + pass + except Exception as e: + return jsonify({'error': f'URL parsing error: {str(e)}'}), 400 + + try: + result = process_clip_request( + clip_url=clip_url, + clip_id=clip_id, + debug=debug, + force=force, + include_image=include_image, + add_to_queue=use_queue, + match_id=match_id, + ) + return jsonify(result) + except Exception as e: + return jsonify({'error': 'Error processing clip', 'message': str(e), 'trace': traceback.format_exc() if debug else None}), 500 + + +@bp.route('/detect-stream', methods=['GET']) +@require_api_key +def detect_heroes_from_stream(): + username = request.args.get('username') + num_frames = int(request.args.get('frames', '3')) + debug = request.args.get('debug', 'false').lower() == 'true' + include_image = request.args.get('include_image', 'false').lower() == 'true' + use_queue = request.args.get('queue', 'true').lower() == 'true' + + if os.environ.get('RUN_LOCALLY') == 'true': + use_queue = False + + if not username: + return jsonify({'error': 'Missing required parameter: username'}), 400 + + if num_frames < 1 or num_frames > 10: + return jsonify({'error': 'Invalid frames parameter: must be between 1 and 10'}), 400 + + try: + result = process_stream_request( + username=username, + num_frames=num_frames, + debug=debug, + include_image=include_image, + add_to_queue=use_queue, + ) + return jsonify(result) + except Exception as e: + return jsonify({'error': 'Error processing stream', 'message': str(e), 'trace': traceback.format_exc() if debug else None}), 500 + + +@bp.route('/match/', methods=['GET']) +@require_api_key +def get_match_result(match_id): + force = request.args.get('force', 'false').lower() == 'true' + clip_url = request.args.get('clip_url') + debug = request.args.get('debug', 'false').lower() == 'true' + include_image = request.args.get('include_image', 'true').lower() == 'true' + + if not match_id: + return jsonify({'error': 'Missing required parameter: match_id'}), 400 + + try: + match_id = int(match_id) + except ValueError: + return jsonify({'error': 'Invalid match_id: must be a number'}), 400 + match_id = str(match_id) + + try: + if force and clip_url: + clip_id = extract_clip_id(clip_url) + if not clip_id: + return jsonify({'error': 'Could not extract clip ID from URL'}), 400 + result = process_clip_request( + clip_url=clip_url, + clip_id=clip_id, + debug=debug, + force=True, + include_image=include_image, + add_to_queue=True, + match_id=match_id, + ) + if isinstance(result, dict) and result.get('queued'): + return jsonify(result) + + match_status = db_client.check_for_match_processing(match_id) + + if not match_status or not match_status.get('found'): + if not clip_url: + return jsonify({'error': 'No results found for this match ID', 'message': 'Please provide a clip_url to process'}), 404 + clip_id = extract_clip_id(clip_url) + if not clip_id: + return jsonify({'error': 'Could not extract clip ID from URL'}), 400 + result = process_clip_request( + clip_url=clip_url, + clip_id=clip_id, + debug=debug, + force=False, + include_image=include_image, + add_to_queue=True, + match_id=match_id, + ) + return jsonify(result) + elif match_status.get('status') == 'completed': + result = db_client.get_clip_result_by_match_id(match_id) + if result: + if 'saved_image_path' in result and result['saved_image_path'] and '__HOST_URL__' in result['saved_image_path']: + host_url = request.host_url.rstrip('/') + result['saved_image_path'] = result['saved_image_path'].replace('__HOST_URL__', host_url) + result['match_id'] = match_id + return jsonify(result) + return jsonify({'error': 'Inconsistent state', 'message': 'Match is marked as completed but no result found'}), 500 + elif match_status.get('status') in ('pending', 'processing'): + return jsonify({ + 'status': match_status.get('status'), + 'clip_id': match_status.get('clip_id'), + 'request_id': match_status.get('request_id'), + 'match_id': match_id, + 'message': f"Match is currently {match_status.get('status')}", + }) + else: + if not clip_url: + return jsonify({'error': 'Previous processing failed', 'status': 'failed', 'clip_id': match_status.get('clip_id'), 'request_id': match_status.get('request_id'), 'match_id': match_id, 'message': 'Previous processing failed. Provide a clip_url to try again.'}), 400 + clip_id = extract_clip_id(clip_url) + if not clip_id: + return jsonify({'error': 'Could not extract clip ID from URL'}), 400 + result = process_clip_request( + clip_url=clip_url, + clip_id=clip_id, + debug=debug, + force=True, + include_image=include_image, + add_to_queue=True, + match_id=match_id, + ) + return jsonify(result) + except Exception as e: + return jsonify({'error': 'Error processing match', 'message': str(e), 'trace': traceback.format_exc() if debug else None}), 500