diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,32 +1,3934 @@ +""" +DetectifAI Flask Backend - AI-Powered CCTV Surveillance System +Version: 2026.03.08 -import sys, os, time -print("DEBUG: Python starting...", flush=True) -print(f"DEBUG: Python {sys.version}", flush=True) -print(f"DEBUG: cwd={os.getcwd()}", flush=True) -print(f"DEBUG: PORT={os.environ.get('PORT','?')}", flush=True) +Enhanced Flask API for: +- Video upload and processing with DetectifAI security focus +- Real-time processing status and results +- Object detection with fire/weapon recognition +- Security event analysis and threat assessment +- Frontend integration for surveillance dashboard +- Automated forensic report generation +""" -from flask import Flask, jsonify +import sys as _sys; print("DEBUG[1]: Starting imports...", flush=True) +from flask import Flask, request, jsonify, send_file, send_from_directory, Response, redirect from flask_cors import CORS -from datetime import datetime -print("DEBUG: Flask imported OK", flush=True) +from werkzeug.utils import secure_filename +import os +import sys +import threading +import json +from datetime import datetime, timedelta +import logging +import uuid +import time +import urllib.parse +from typing import List, Dict, Any +print("DEBUG[2]: stdlib imports done", flush=True) +# ── Lightweight imports only (no ML models) ────────────────── +from config import get_security_focused_config, VideoProcessingConfig + +print("DEBUG[3]: config imported", flush=True) +# Heavy modules are imported lazily inside _background_init() to avoid +# blocking Flask startup. We declare the symbols here so the rest of +# the file can reference them after init completes. +CompleteVideoProcessingPipeline = None # set in _background_init +DatabaseIntegratedVideoService = None # set in _background_init + +# Import Report Generation components +REPORT_GENERATION_AVAILABLE = False +ReportGenerator = None +ReportConfig = None +try: + from report_generation import ReportGenerator, ReportConfig + REPORT_GENERATION_AVAILABLE = True +except ImportError as e: + logging.warning(f"Report generation not available: {e}") + +print(f"DEBUG[4]: report_generation={REPORT_GENERATION_AVAILABLE}", flush=True) +# Try to import DetectifAI-specific components +DETECTIFAI_EVENTS_AVAILABLE = False +try: + from detectifai_events import DetectifAIEventType, ThreatLevel + DETECTIFAI_EVENTS_AVAILABLE = True +except ImportError: + logging.warning("DetectifAI events module not available - using basic functionality") + +print(f"DEBUG[5]: detectifai_events={DETECTIFAI_EVENTS_AVAILABLE}", flush=True) +# Try to import caption search (optional - may not be available) +CAPTION_SEARCH_AVAILABLE = False +get_caption_search_engine = None +try: + detectifai_db_path = os.path.join(os.path.dirname(__file__), 'DetectifAI_db') + if detectifai_db_path not in sys.path: + sys.path.insert(0, detectifai_db_path) + from caption_search import get_caption_search_engine + CAPTION_SEARCH_AVAILABLE = True +except ImportError as e: + logging.warning(f"Caption search not available: {e}") + +print(f"DEBUG[6]: caption_search={CAPTION_SEARCH_AVAILABLE}", flush=True) +# Import subscription middleware for feature gating +try: + from subscription_middleware import ( + SubscriptionMiddleware, + require_subscription, + require_feature, + check_usage_limit + ) + SUBSCRIPTION_MIDDLEWARE_AVAILABLE = True +except ImportError as e: + logging.warning(f"Subscription middleware not available: {e}") + SUBSCRIPTION_MIDDLEWARE_AVAILABLE = False + # Create dummy decorators that do nothing + def require_subscription(plan=None): + def decorator(f): + return f + return decorator + def require_feature(feature): + def decorator(f): + return f + return decorator + def check_usage_limit(limit_type, auto_increment=True): + def decorator(f): + return f + return decorator + +print(f"DEBUG[7]: subscription_middleware done", flush=True) +# Initialize Flask app app = Flask(__name__) -CORS(app, resources={r"/api/*": {"origins": "*"}}) +print("DEBUG[8]: Flask app created", flush=True) +# CORS — allow Vercel frontend + localhost dev +_allowed_origins = os.environ.get( + 'CORS_ORIGINS', + 'http://localhost:3000,https://detectif-ai-fyp.vercel.app' +).split(',') +CORS(app, resources={r"/api/*": {"origins": _allowed_origins}}) + +# Configure logging — file handler only when logs/ is writable +_log_handlers = [logging.StreamHandler()] +try: + os.makedirs('logs', exist_ok=True) + _log_handlers.append(logging.FileHandler('logs/detectifai_api.log')) +except OSError: + pass # read-only filesystem (cloud) +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=_log_handlers, +) +logger = logging.getLogger(__name__) + +# DEMO_MODE removed — real Basic/Pro subscription tiers are enforced via Stripe +DEMO_MODE = False + +# Configuration - use absolute paths to handle different working directories +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Project root +UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads') +OUTPUT_FOLDER = os.path.join(BASE_DIR, 'video_processing_outputs') +ALLOWED_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv'} +MAX_CONTENT_LENGTH = 500 * 1024 * 1024 # 500MB max file size + +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH + +# Create necessary directories (ignore errors on read-only FS) +for _dir in [UPLOAD_FOLDER, OUTPUT_FOLDER, 'logs']: + try: + os.makedirs(_dir, exist_ok=True) + except OSError: + pass + +# Store processing status in memory (use Redis in production) +processing_status = {} + +# ── Deferred heavy initialization ──────────────────────────── +# HF Spaces kills containers that don't respond on PORT within ~5 min. +# Model loading (BLIP, SentenceTransformers, YOLO, 3D-ResNet) can take +# 5-10 min on cpu-basic. Solution: start Flask immediately, load models +# in a background thread, and let health-check respond right away. +DATABASE_ENABLED = False +db_video_service = None # will be set by _bg_init +_init_ready = threading.Event() # set when background init is done +_init_error = None # stores error message if init failed + +def _background_init(): + """Run heavy initialization in a background thread. + + Imports main_pipeline and database_video_service HERE (not at module + top-level) because they transitively load YOLO, BLIP, 3D-ResNet, + SentenceTransformers — ~5-10 min on cpu-basic. Flask must be serving + HTTP before that finishes so HF Spaces marks the container RUNNING. + """ + global db_video_service, DATABASE_ENABLED, _init_error + global CompleteVideoProcessingPipeline, DatabaseIntegratedVideoService + try: + logger.info("🔄 Background init: importing heavy modules...") + from main_pipeline import CompleteVideoProcessingPipeline as _CVPP + from database_video_service import DatabaseIntegratedVideoService as _DIVS + CompleteVideoProcessingPipeline = _CVPP + DatabaseIntegratedVideoService = _DIVS + + logger.info("🔄 Background init: creating DatabaseIntegratedVideoService...") + svc = _DIVS(get_security_focused_config()) + db_video_service = svc + DATABASE_ENABLED = True + app.config['DETECTIFAI_DB'] = svc.db_manager.db + logger.info("✅ Background init complete — all models loaded") + except Exception as e: + _init_error = str(e) + logger.error(f"❌ Background init failed: {e}") + import traceback + logger.error(traceback.format_exc()) + finally: + _init_ready.set() + +# Start background init on a daemon thread +_init_thread = threading.Thread(target=_background_init, daemon=True, name="bg-init") +_init_thread.start() + +print("DEBUG[9]: bg thread started, defining health route", flush=True) +# ---- Health check (HF Spaces / Render ping this) ---- @app.route('/') @app.route('/api/health', methods=['GET']) -def health(): +def health_check(): + ready = _init_ready.is_set() return jsonify({ - 'status': 'healthy', + 'status': 'healthy' if ready else 'starting', 'service': 'DetectifAI Backend', 'timestamp': datetime.now().isoformat(), - 'database_enabled': False, - 'models_loaded': False, - 'debug': True, - 'init_error': None, + 'database_enabled': DATABASE_ENABLED, + 'models_loaded': ready, + 'init_error': _init_error + }), 200 + +def allowed_file(filename): + """Check if file extension is allowed""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +def extract_detectifai_results(pipeline_results): + """Extract DetectifAI-specific results from pipeline output""" + try: + detectifai_results = { + # Basic video metrics + 'video_info': { + 'total_keyframes': pipeline_results['outputs'].get('total_keyframes', 0), + 'processing_time': pipeline_results['processing_stats'].get('total_processing_time', 0), + 'output_directory': pipeline_results['outputs'].get('output_directory', '') + }, + + # Security detection results + 'security_detection': { + 'total_object_detections': pipeline_results['outputs'].get('total_object_detections', 0), + 'total_object_events': pipeline_results['outputs'].get('total_object_events', 0), + 'detectifai_events': pipeline_results['outputs'].get('detectifai_events', 0), + 'fire_detections': 0, # Will be populated from actual results + 'weapon_detections': 0, + 'security_alerts': [] + }, + + # Event analysis + 'event_analysis': { + 'canonical_events': pipeline_results['outputs'].get('canonical_events', 0), + 'total_motion_events': pipeline_results['outputs'].get('total_motion_events', 0), + 'high_priority_events': 0, + 'critical_events': 0 + }, + + # Output files + 'output_files': { + 'keyframes_directory': os.path.join(pipeline_results['outputs'].get('output_directory', ''), 'frames'), + 'reports': pipeline_results['outputs'].get('reports', {}), + 'highlight_reels': pipeline_results['outputs'].get('highlight_reels', {}), + 'compressed_video': pipeline_results['outputs'].get('compressed_video', '') + }, + + # System performance + 'performance': { + 'frames_processed': pipeline_results['processing_stats'].get('frames_processed', 0), + 'frames_enhanced': pipeline_results['processing_stats'].get('frames_enhanced', 0), + 'gpu_acceleration': pipeline_results['processing_stats'].get('gpu_used', False) + } + } + + return detectifai_results + + except Exception as e: + logger.error(f"Error extracting DetectifAI results: {e}") + return {'error': 'Failed to extract results'} + +def process_video_async(video_id, video_path, config_type='detectifai'): + """Process video in background thread with DetectifAI focus""" + try: + processing_status[video_id]['status'] = 'processing' + processing_status[video_id]['progress'] = 0 + processing_status[video_id]['message'] = 'Initializing DetectifAI processing...' + + # Select configuration with DetectifAI optimizations + if config_type == 'detectifai' or config_type == 'security': + config = get_security_focused_config() + # Removed robbery detection - using security focused config as default + elif config_type == 'high_recall': + try: + from config import get_high_recall_config + config = get_high_recall_config() + except ImportError: + config = get_security_focused_config() + elif config_type == 'balanced': + try: + from config import get_balanced_config + config = get_balanced_config() + except ImportError: + config = VideoProcessingConfig() + else: + config = VideoProcessingConfig() + + # DetectifAI-specific configuration enhancements + config.enable_object_detection = True + config.enable_facial_recognition = True + config.enable_video_captioning = True # Re-enabled with improved error handling and timeouts + config.keyframe_extraction_fps = 1.0 # Extract 1 frame per second for surveillance + config.enable_adaptive_processing = True + + # Set custom output directory for this video + config.output_base_dir = os.path.join(OUTPUT_FOLDER, video_id) + + # Initialize pipeline with database manager for MongoDB integration + db_manager = None + if DATABASE_ENABLED: + db_manager = db_video_service.db_manager + + pipeline = CompleteVideoProcessingPipeline(config, db_manager=db_manager) + + # Update progress + processing_status[video_id]['progress'] = 10 + processing_status[video_id]['message'] = 'Extracting keyframes for security analysis...' + + # Process video with DetectifAI (with error tolerance) + output_name = os.path.splitext(os.path.basename(video_path))[0] + results = None + processing_errors = [] + + try: + results = pipeline.process_video_complete(video_path, output_name) + logger.info(f"✅ Core pipeline processing completed for {video_id}") + except Exception as pipeline_error: + logger.error(f"⚠️ Pipeline error (but continuing): {str(pipeline_error)}") + processing_errors.append(f"Pipeline: {str(pipeline_error)}") + # Create minimal results structure + results = { + 'outputs': { + 'total_keyframes': 0, + 'total_events': 0, + 'total_motion_events': 0, + 'total_object_events': 0, + 'total_object_detections': 0, + 'canonical_events': [], + 'total_segments': 1, + 'highlight_reels': {}, + 'reports': {}, + 'compressed_video': '' + }, + 'processing_stats': {'total_processing_time': 0} + } + + # Extract DetectifAI-specific results (with error tolerance) + detectifai_results = {} + try: + detectifai_results = extract_detectifai_results(results) + except Exception as extract_error: + logger.error(f"⚠️ Result extraction error (but continuing): {str(extract_error)}") + processing_errors.append(f"Extraction: {str(extract_error)}") + detectifai_results = {'security_detection': {}, 'event_analysis': {}, 'performance': {}} + + # Always mark as completed (even with errors) + processing_status[video_id]['status'] = 'completed' + processing_status[video_id]['progress'] = 100 + completion_message = 'DetectifAI processing completed successfully!' + if processing_errors: + completion_message = f'DetectifAI processing completed with warnings: {len(processing_errors)} non-critical errors' + processing_status[video_id]['message'] = completion_message + processing_status[video_id]['results'] = { + # Original results for backward compatibility + 'total_keyframes': results['outputs']['total_keyframes'], + 'total_events': results['outputs']['total_events'], + 'total_motion_events': results['outputs'].get('total_motion_events', 0), + 'total_object_events': results['outputs'].get('total_object_events', 0), + 'total_object_detections': results['outputs'].get('total_object_detections', 0), + 'canonical_events': results['outputs']['canonical_events'], + 'total_segments': results['outputs']['total_segments'], + 'processing_time': results['processing_stats']['total_processing_time'], + 'highlight_reels': results['outputs'].get('highlight_reels', {}), + 'reports': results['outputs'].get('reports', {}), + 'compressed_video': results['outputs'].get('compressed_video', ''), + 'output_directory': config.output_base_dir, + 'object_detection_enabled': config.enable_object_detection, + + # DetectifAI-specific results + 'detectifai_results': detectifai_results, + 'security_detection': detectifai_results.get('security_detection', {}), + 'event_analysis': detectifai_results.get('event_analysis', {}), + 'performance': detectifai_results.get('performance', {}), + + # Processing status + 'processing_errors': processing_errors, + 'has_warnings': len(processing_errors) > 0 + } + + logger.info(f"Video {video_id} processed successfully") + + except Exception as e: + logger.error(f"Error processing video {video_id}: {str(e)}") + processing_status[video_id]['status'] = 'failed' + processing_status[video_id]['message'] = f'Error: {str(e)}' + processing_status[video_id]['error'] = str(e) + +# ====== SUBSCRIPTION & FEATURE GATING ENDPOINTS ====== + +@app.route('/api/feature/check', methods=['GET']) +def check_feature_access(): + """ + Check if user has access to specific feature based on subscription plan. + Used by frontend to determine feature visibility. + """ + try: + user_id = request.args.get('user_id') + feature = request.args.get('feature') + + if not user_id or not feature: + return jsonify({ + 'success': False, + 'error': 'user_id and feature required' + }), 400 + + if not SUBSCRIPTION_MIDDLEWARE_AVAILABLE: + # If middleware not available, allow all features as fallback + return jsonify({ + 'success': True, + 'feature': feature, + 'has_access': True, + 'current_plan': 'fallback', + 'message': 'Subscription middleware not available - all features enabled' + }), 200 + + db = app.config.get('DETECTIFAI_DB') + middleware = SubscriptionMiddleware(db) + + has_access = middleware.check_feature_access(user_id, feature) + plan_name = middleware.get_user_plan_name(user_id) + + return jsonify({ + 'success': True, + 'feature': feature, + 'has_access': has_access, + 'current_plan': plan_name + }), 200 + + except Exception as e: + logger.error(f"Error checking feature access: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/usage/summary', methods=['GET']) +def get_usage_summary(): + """ + Get user's current usage statistics and limits based on subscription. + Returns usage for video processing, searches, etc. + """ + try: + user_id = request.args.get('user_id') + + if not user_id: + return jsonify({ + 'success': False, + 'error': 'user_id required' + }), 400 + + if not SUBSCRIPTION_MIDDLEWARE_AVAILABLE: + # If middleware not available, return basic usage info as fallback + return jsonify({ + 'success': True, + 'usage': { + 'has_subscription': False, + 'plan': 'free', + 'plan_name': 'Free Tier', + 'status': 'active', + 'message': 'Subscription middleware not available' + } + }), 200 + + db = app.config.get('DETECTIFAI_DB') + middleware = SubscriptionMiddleware(db) + + summary = middleware.get_usage_summary(user_id) + + return jsonify({ + 'success': True, + 'usage': summary + }), 200 + + except Exception as e: + logger.error(f"Error getting usage summary: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/usage/increment', methods=['POST']) +def increment_usage(): + """ + Manually increment usage counter for a user. + Called after successful operations that should count toward limits. + """ + try: + data = request.get_json() or {} + user_id = data.get('user_id') + limit_type = data.get('limit_type') + amount = data.get('amount', 1) + + if not user_id or not limit_type: + return jsonify({ + 'success': False, + 'error': 'user_id and limit_type required' + }), 400 + + if not SUBSCRIPTION_MIDDLEWARE_AVAILABLE: + return jsonify({ + 'success': True, + 'message': 'Usage tracking not available in dev mode' + }), 200 + + db = app.config.get('DETECTIFAI_DB') + middleware = SubscriptionMiddleware(db) + + success = middleware.increment_usage(user_id, limit_type, amount) + + return jsonify({ + 'success': success, + 'message': 'Usage incremented' if success else 'Failed to increment usage' + }), 200 if success else 500 + + except Exception as e: + logger.error(f"Error incrementing usage: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + + +# ====== REPORT GENERATION ENDPOINTS ====== + +@app.route('/api/video/reports/generate', methods=['POST']) +@require_subscription() +@check_usage_limit('report_generation') +def generate_report(): + """Generate forensic report for a video and upload to MinIO""" + if not REPORT_GENERATION_AVAILABLE: + return jsonify({'error': 'Report generation service not available'}), 503 + + try: + data = request.get_json() + video_id = data.get('video_id') + + if not video_id: + return jsonify({'error': 'video_id required'}), 400 + + # Initialize generator + config = ReportConfig() + # Use existing model path or default + if os.path.exists(os.path.join(BASE_DIR, 'report_generation', 'models', 'qwen2.5-3b-instruct-q4_k_m.gguf')): + # Config should pick it up automatically if in expected path + pass + + generator = ReportGenerator(config) + + # Generate report + logger.info(f"Generating report for video: {video_id}") + report = generator.generate_report(video_id=video_id) + + # Define report output directory (local temporary storage) + report_dir = os.path.join(OUTPUT_FOLDER, video_id, 'reports') + os.makedirs(report_dir, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + pdf_filename = f"report_{timestamp}.pdf" + html_filename = f"report_{timestamp}.html" + + pdf_path = os.path.join(report_dir, pdf_filename) + html_path = os.path.join(report_dir, html_filename) + + # Export HTML (always available) + final_html_path = generator.export_html(report, output_path=html_path) + logger.info(f"✅ HTML report exported locally: {final_html_path}") + + # Try to export PDF (optional - may fail if WeasyPrint dependencies missing) + final_pdf_path = None + try: + final_pdf_path = generator.export_pdf(report, output_path=pdf_path) + logger.info(f"✅ PDF report exported locally: {final_pdf_path}") + except Exception as pdf_error: + logger.warning(f"⚠️ PDF export failed (HTML report still available): {pdf_error}") + # Try fallback SimplePDFExporter if available + try: + from report_generation.pdf_exporter import SimplePDFExporter + simple_exporter = SimplePDFExporter(config) + final_pdf_path = simple_exporter.export(report, output_path=pdf_path) + logger.info(f"✅ PDF exported using SimplePDFExporter: {final_pdf_path}") + except Exception as fallback_error: + logger.warning(f"⚠️ SimplePDFExporter also failed: {fallback_error}") + # Continue without PDF - HTML is still available + final_pdf_path = None + + # Upload reports to MinIO and get presigned URLs + html_url = None + pdf_url = None + + try: + # Initialize ReportRepository + from database.config import DatabaseManager + from database.repositories import ReportRepository + + db_manager = DatabaseManager() + report_repo = ReportRepository(db_manager) + + # Upload HTML to MinIO + logger.info(f"📤 Uploading HTML report to MinIO...") + html_minio_path = report_repo.upload_report_to_minio(final_html_path, video_id, html_filename) + html_url = report_repo.get_report_presigned_url(video_id, html_filename, expires=timedelta(hours=24)) + logger.info(f"✅ HTML report uploaded to MinIO: {html_minio_path}") + + # Upload PDF to MinIO if available + if final_pdf_path and os.path.exists(final_pdf_path): + logger.info(f"📤 Uploading PDF report to MinIO...") + pdf_minio_path = report_repo.upload_report_to_minio(final_pdf_path, video_id, pdf_filename) + pdf_url = report_repo.get_report_presigned_url(video_id, pdf_filename, expires=timedelta(hours=24)) + logger.info(f"✅ PDF report uploaded to MinIO: {pdf_minio_path}") + + except Exception as minio_error: + logger.error(f"❌ Failed to upload reports to MinIO: {minio_error}") + # Fall back to local file serving if MinIO upload fails + html_url = f"/api/video/reports/download/{video_id}/{html_filename}" + if final_pdf_path: + pdf_url = f"/api/video/reports/download/{video_id}/{pdf_filename}" + + response_data = { + 'success': True, + 'report_id': report.report_id, + 'html_url': html_url, + 'pdf_available': pdf_url is not None + } + + if pdf_url: + response_data['pdf_url'] = pdf_url + + logger.info(f"✅ Report generation complete for {video_id}") + return jsonify(response_data) + + except Exception as e: + logger.error(f"Report generation error: {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + return jsonify({'error': str(e), 'success': False}), 500 + +@app.route('/api/video/reports/download//', methods=['GET']) +def download_report(video_id, filename): + """Download generated report file""" + try: + report_dir = os.path.join(OUTPUT_FOLDER, video_id, 'reports') + return send_from_directory(report_dir, filename, as_attachment=True) + except Exception as e: + return jsonify({'error': 'File not found'}), 404 + + +# ====== DATABASE-INTEGRATED ENDPOINTS ====== + +@app.route('/api/v2/video/upload', methods=['POST']) +@require_subscription() # Requires any active subscription (Basic or Pro) +@check_usage_limit('video_processing') # Check and increment video processing limit +def upload_video_db(): + """Enhanced video upload with database storage. Requires: Active subscription""" + if not DATABASE_ENABLED: + return jsonify({'error': 'Database service not available'}), 503 + + try: + # Check if file is present + if 'video' not in request.files: + return jsonify({'error': 'No video file provided'}), 400 + + file = request.files['video'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': 'Invalid file type. Allowed: mp4, avi, mov, mkv, wmv, flv'}), 400 + + # Generate video ID with consistent format + video_id = f"video_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.urandom(4).hex()}" + + # Save temporary file with original extension + filename = secure_filename(file.filename) + base, ext = os.path.splitext(filename) + temp_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{video_id}/video{ext}") + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + file.save(temp_path) + + # Get user ID (if authenticated) - TODO: implement proper authentication + user_id = request.form.get('user_id', None) + + # STEP 1: Extract video metadata FIRST (before MongoDB record) + try: + import cv2 + cap = cv2.VideoCapture(temp_path) + fps = cap.get(cv2.CAP_PROP_FPS) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + duration = frame_count / fps if fps > 0 else 0 + cap.release() + + file_size = os.path.getsize(temp_path) + resolution = f"{width}x{height}" + except Exception as e: + logger.warning(f"Could not extract video metadata: {e}") + fps = 30.0 + duration = 0 + file_size = os.path.getsize(temp_path) + resolution = "unknown" + + # STEP 2: Create MongoDB record FIRST (before MinIO upload) + video_record = { + "video_id": video_id, + "user_id": user_id or "system", + "file_path": f"videos/{video_id}/video{ext}", + "minio_object_key": f"original/{video_id}/video{ext}", # Will be confirmed after MinIO upload + "minio_bucket": db_video_service.video_repo.video_bucket, + "codec": "h264", # Default, can be updated later + "fps": float(fps), + "upload_date": datetime.utcnow(), + "duration_secs": int(duration), + "file_size_bytes": int(file_size), + "meta_data": { + "filename": filename, + "original_name": file.filename, + "resolution": resolution, + "processing_status": "uploading", + "processing_progress": 0, + "processing_message": "Creating database record..." + } + } + + # Create MongoDB record immediately + try: + video_doc_id = db_video_service.video_repo.create_video_record(video_record) + logger.info(f"✅ Created MongoDB record for video: {video_id}") + except Exception as e: + logger.error(f"❌ Failed to create MongoDB record: {e}") + return jsonify({'error': f'Failed to create database record: {str(e)}'}), 500 + + # STEP 3: Upload video to MinIO immediately (after MongoDB record exists) + try: + db_video_service.video_repo.update_metadata(video_id, { + "processing_progress": 5, + "processing_message": "Uploading video to MinIO..." + }) + + minio_path = db_video_service.video_repo.upload_video_to_minio(temp_path, video_id) + + # STEP 4: Update MongoDB with MinIO path (link metadata) + db_video_service.video_repo.collection.update_one( + {"video_id": video_id}, + {"$set": { + "minio_object_key": minio_path, + "meta_data.minio_original_path": minio_path + }} + ) + logger.info(f"✅ Uploaded video to MinIO and linked in MongoDB: {minio_path}") + + except Exception as e: + logger.error(f"❌ Failed to upload to MinIO: {e}") + db_video_service.video_repo.update_metadata(video_id, { + "processing_status": "failed", + "error_message": f"MinIO upload failed: {str(e)}" + }) + return jsonify({'error': f'Failed to upload to MinIO: {str(e)}'}), 500 + + # STEP 5: Start background processing (frames, detection, etc.) + try: + thread = threading.Thread( + target=db_video_service.process_video_with_database_storage, + args=(temp_path, video_id, user_id), + daemon=True + ) + thread.start() + + return jsonify({ + 'success': True, + 'video_id': video_id, + 'message': 'Video uploaded successfully. Processing started with database storage.', + 'status_url': f'/api/v2/video/status/{video_id}' + }), 201 + + except Exception as process_error: + logger.error(f"Failed to start video processing: {process_error}") + # Update status in database + db_video_service.video_repo.update_metadata(video_id, { + "processing_status": "failed", + "error_message": str(process_error) + }) + raise + + except Exception as e: + logger.error(f"Database upload error: {str(e)}") + return jsonify({'error': str(e)}), 500 + + except Exception as e: + logger.error(f"Database upload error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/v2/video/status/', methods=['GET']) +def get_video_status_db(video_id): + """Get processing status from database with fallback to in-memory status""" + if not DATABASE_ENABLED: + # Fallback to in-memory status if database not available + if video_id in processing_status: + return jsonify(processing_status[video_id]), 200 + return jsonify({'error': 'Database service not available and video not found in memory'}), 503 + + try: + status_data = db_video_service.get_video_status(video_id) + + if 'error' in status_data: + # Fallback to in-memory status if database lookup fails + if video_id in processing_status: + logger.info(f"Database lookup failed for {video_id}, falling back to in-memory status") + return jsonify(processing_status[video_id]), 200 + return jsonify(status_data), 404 + + return jsonify(status_data), 200 + + except Exception as e: + logger.error(f"Database status check error: {str(e)}") + # Fallback to in-memory status on exception + if video_id in processing_status: + logger.info(f"Database error for {video_id}, falling back to in-memory status") + return jsonify(processing_status[video_id]), 200 + return jsonify({'error': str(e)}), 500 + +@app.route('/api/v2/video/keyframes/', methods=['GET']) +def get_video_keyframes_db(video_id): + """Get keyframes from database with MinIO URLs""" + if not DATABASE_ENABLED: + return jsonify({'error': 'Database service not available'}), 503 + + try: + # Get query parameters + filter_detections = request.args.get('filter_detections', 'false').lower() == 'true' + limit = request.args.get('limit', type=int) + + keyframes_data = db_video_service.get_video_keyframes( + video_id, filter_detections=filter_detections, limit=limit + ) + + return jsonify(keyframes_data), 200 + + except Exception as e: + logger.error(f"Database keyframes retrieval error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/v2/video/events/', methods=['GET']) +def get_video_events_db(video_id): + """Get events from database""" + if not DATABASE_ENABLED: + return jsonify({'error': 'Database service not available'}), 503 + + try: + event_type = request.args.get('type') # motion, object_detection, face_recognition + + events_data = db_video_service.get_video_events(video_id, event_type) + + return jsonify(events_data), 200 + + except Exception as e: + logger.error(f"Database events retrieval error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/v2/video/detections/', methods=['GET']) +def get_video_detections_db(video_id): + """Get object detections from database""" + if not DATABASE_ENABLED: + return jsonify({'error': 'Database service not available'}), 503 + + try: + class_filter = request.args.get('class') # fire, knife, gun, smoke + + detections_data = db_video_service.get_video_detections(video_id, class_filter) + + return jsonify(detections_data), 200 + + except Exception as e: + logger.error(f"Database detections retrieval error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/v2/video/faces/', methods=['GET']) +def get_video_faces_db(video_id): + """Get detected faces from database for a video""" + if not DATABASE_ENABLED: + return jsonify({'error': 'Database service not available'}), 503 + + try: + faces_data = db_video_service.get_video_faces(video_id) + + return jsonify(faces_data), 200 + + except Exception as e: + logger.error(f"Database faces retrieval error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/v2/video/results/', methods=['GET']) +def get_video_results_db(video_id): + """Get comprehensive video results from database""" + if not DATABASE_ENABLED: + return jsonify({'error': 'Database service not available'}), 503 + + try: + # Get video status and basic info + status_data = db_video_service.get_video_status(video_id) + + if 'error' in status_data: + logger.warning(f"Video not found in database: {video_id}") + return jsonify(status_data), 404 + + # Check if processing is completed (check multiple possible status fields) + processing_status = status_data.get('status') or status_data.get('meta_data', {}).get('processing_status', 'unknown') + + # Log status for debugging + logger.info(f"Video {video_id} status: {processing_status}, progress: {status_data.get('processing_progress')}") + + # Allow results even if status is not exactly 'completed' - check if we have detections/events + meta_data = status_data.get('meta_data', {}) + has_detections = meta_data.get('detection_count', 0) > 0 or status_data.get('detection_count', 0) > 0 + has_events = meta_data.get('event_count', 0) > 0 or status_data.get('event_count', 0) > 0 + + if processing_status not in ['completed', 'done'] and not (has_detections or has_events): + return jsonify({ + 'error': 'Processing not completed', + 'current_status': processing_status, + 'progress': status_data.get('processing_progress') or meta_data.get('processing_progress', 0), + 'message': status_data.get('processing_message') or meta_data.get('processing_message', '') + }), 400 + + # Get keyframes, events, and detections + keyframes_data = db_video_service.get_video_keyframes(video_id, limit=50) + events_data = db_video_service.get_video_events(video_id) + detections_data = db_video_service.get_video_detections(video_id) + + # Extract behavior analysis events + all_events = events_data.get('events', []) + behavior_events = [e for e in all_events if e.get('event_type', '').startswith('behavior_')] + + # Summarize behavior detections + behavior_summary = _summarize_behaviors(behavior_events) + + # Get compressed video URL from status + compressed_video_url = status_data.get('compressed_video_url') or f'/api/video/compressed/{video_id}' + compressed_video_available = bool(status_data.get('compressed_video_url') or status_data.get('meta_data', {}).get('minio_compressed_path')) + + # Compile comprehensive results + results = { + 'video_info': status_data, + 'compressed_video_available': compressed_video_available, + 'compressed_video_url': compressed_video_url, + 'keyframes_available': len(keyframes_data.get('keyframes', [])) > 0, + 'keyframes_count': keyframes_data.get('total_keyframes', 0), + 'keyframes_sample': keyframes_data.get('keyframes', [])[:10], # First 10 keyframes + 'events_available': len(events_data.get('events', [])) > 0, + 'events_count': events_data.get('total_events', 0), + 'events_summary': _summarize_events(events_data.get('events', [])), + 'detections_available': len(detections_data.get('detections', [])) > 0, + 'detections_count': detections_data.get('total_detections', 0), + 'detections_summary': _summarize_detections(detections_data.get('detections', [])), + 'behaviors_available': len(behavior_events) > 0, + 'behaviors_count': len(behavior_events), + 'behaviors_summary': behavior_summary, + 'behavior_events': behavior_events[:10], # First 10 behavior events + 'threat_assessment': _assess_threat_level(events_data.get('events', []), detections_data.get('detections', [])) + } + + return jsonify(results), 200 + + except Exception as e: + logger.error(f"Database results retrieval error: {str(e)}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/video/upload', methods=['POST']) +@app.route('/api/upload', methods=['POST']) +@require_subscription() # Requires any active subscription (Basic or Pro) +@check_usage_limit('video_processing') # Check and increment video processing limit +def upload_video(): + """Upload video endpoint. Requires: Active subscription""" + try: + # Check if file is present + if 'video' not in request.files: + return jsonify({'error': 'No video file provided'}), 400 + + file = request.files['video'] + + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': 'Invalid file type. Allowed: mp4, avi, mov, mkv, wmv, flv'}), 400 + + # Get processing configuration (default to DetectifAI optimized) + config_type = request.form.get('config_type', 'detectifai') + + # Generate unique video ID + video_id = f"video_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.urandom(4).hex()}" + + # Save uploaded file + filename = secure_filename(file.filename) + video_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{video_id}_{filename}") + file.save(video_path) + + # Initialize processing status + processing_status[video_id] = { + 'video_id': video_id, + 'filename': filename, + 'status': 'queued', + 'progress': 0, + 'message': 'Video uploaded successfully. Processing queued.', + 'uploaded_at': datetime.now().isoformat(), + 'config_type': config_type + } + + # Start background processing + thread = threading.Thread( + target=process_video_async, + args=(video_id, video_path, config_type) + ) + thread.daemon = True + thread.start() + + return jsonify({ + 'success': True, + 'video_id': video_id, + 'message': 'Video uploaded successfully. Processing started.', + 'status_url': f'/api/status/{video_id}' + }), 200 + + except Exception as e: + logger.error(f"Upload error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/video/status/', methods=['GET']) +@app.route('/api/status/', methods=['GET']) +def get_status(video_id): + """Get processing status for a video""" + # Check memory first + if video_id in processing_status: + return jsonify(processing_status[video_id]), 200 + + # Check if video files exist on disk (recovered processing) + output_dir = os.path.join(OUTPUT_FOLDER, video_id) + if os.path.exists(output_dir): + # Recover status from disk + recovered_status = { + 'video_id': video_id, + 'status': 'completed', + 'progress': 100, + 'message': 'Processing completed (recovered from disk)', + 'uploaded_at': '', + 'filename': f"{video_id}.avi" + } + + # Add back to memory for future requests + processing_status[video_id] = recovered_status + + logger.info(f"🔄 Recovered status for {video_id} from disk") + return jsonify(recovered_status), 200 + + return jsonify({'error': 'Video not found'}), 404 + +@app.route('/api/results/', methods=['GET']) +def get_results(video_id): + """Get processing results for a video""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + status = processing_status[video_id] + + if status['status'] != 'completed': + return jsonify({ + 'error': 'Processing not completed', + 'current_status': status['status'] + }), 400 + + return jsonify(status.get('results', {})), 200 + +@app.route('/api/video/results/', methods=['GET']) +def get_video_results(video_id): + """Get video processing results with availability flags""" + # First check if video is in memory status + if video_id in processing_status: + status = processing_status[video_id] + + if status['status'] == 'processing': + # Return partial results while processing + return jsonify({ + 'video_id': video_id, + 'status': 'processing', + 'progress': status.get('progress', 0), + 'message': status.get('message', 'Processing...'), + 'compressed_video_available': False, + 'keyframes_available': False, + 'reports_available': False + }), 200 + + if status['status'] == 'failed': + return jsonify({ + 'error': 'Processing failed', + 'message': status.get('message', 'Unknown error'), + 'current_status': status['status'] + }), 400 + + # Check if status has results structure (normal processing) + if 'results' in status and 'output_directory' in status['results']: + output_dir = status['results']['output_directory'] + else: + # Fallback to standard directory structure + output_dir = os.path.join(OUTPUT_FOLDER, video_id) + else: + # Check database for video status (for database-integrated processing) + if DATABASE_ENABLED: + try: + db_status = db_video_service.get_video_status(video_id) + if 'error' not in db_status: + # Video found in database, construct results from database metadata + meta_data = db_status.get('meta_data', {}) + + # Check for compressed video in MinIO + compressed_video_available = bool(meta_data.get('minio_compressed_path')) + compressed_video_url = f'/api/video/compressed/{video_id}' if compressed_video_available else None + + # Check for keyframes + keyframes_available = meta_data.get('keyframe_count', 0) > 0 + keyframes_count = meta_data.get('keyframe_count', 0) + + # Check for reports (assume available if processing completed) + reports_available = db_status.get('status') == 'completed' + + return jsonify({ + 'video_id': video_id, + 'status': db_status.get('status', 'unknown'), + 'compressed_video_available': compressed_video_available, + 'compressed_video_url': compressed_video_url, + 'keyframes_available': keyframes_available, + 'keyframes_count': keyframes_count, + 'keyframes_url': f'/api/v2/video/keyframes/{video_id}', # Use v2 endpoint for database + 'reports_available': reports_available, + 'reports': [] # Database doesn't store report files locally + }), 200 + except Exception as e: + logger.warning(f"Database lookup failed for results: {e}") + + # Check if video files exist on disk (for recovered/restarted servers) + output_dir = os.path.join(OUTPUT_FOLDER, video_id) + if not os.path.exists(output_dir): + return jsonify({'error': 'Video not found'}), 404 + + logger.info(f"📁 Found video files on disk for {video_id}, recovering results") + + # Check for compressed video + compressed_dir = os.path.join(output_dir, 'compressed') + compressed_video_available = False + compressed_video_url = None + + if os.path.exists(compressed_dir): + video_files = [f for f in os.listdir(compressed_dir) if f.endswith('.mp4')] + if video_files: + compressed_video_available = True + compressed_video_url = f'/api/video/compressed/{video_id}' + + # Check for keyframes + frames_dir = os.path.join(output_dir, 'frames') + keyframes_available = os.path.exists(frames_dir) and len([f for f in os.listdir(frames_dir) if f.endswith('.jpg')]) > 0 + keyframes_count = len([f for f in os.listdir(frames_dir) if f.endswith('.jpg')]) if keyframes_available else 0 + + # Check for reports + reports_dir = os.path.join(output_dir, 'reports') + reports_available = os.path.exists(reports_dir) + report_files = [] + if reports_available: + report_files = [f for f in os.listdir(reports_dir) if f.endswith('.json')] + + return jsonify({ + 'video_id': video_id, + 'compressed_video_available': compressed_video_available, + 'compressed_video_url': compressed_video_url, + 'keyframes_available': keyframes_available, + 'keyframes_count': keyframes_count, + 'keyframes_url': f'/api/video/keyframes/{video_id}', + 'reports_available': reports_available, + 'reports': report_files + }), 200 + +@app.route('/api/download//', methods=['GET']) +def download_file(video_id, file_type): + """Download processed files""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + status = processing_status[video_id] + + if status['status'] != 'completed': + return jsonify({'error': 'Processing not completed'}), 400 + + output_dir = status['results']['output_directory'] + + try: + if file_type == 'highlight_event': + file_path = status['results']['highlight_reels'].get('event_aware', '') + elif file_type == 'highlight_comprehensive': + file_path = status['results']['highlight_reels'].get('ultra_comprehensive', '') + elif file_type == 'highlight_quality': + file_path = status['results']['highlight_reels'].get('quality_focused', '') + elif file_type == 'compressed_video': + file_path = status['results']['compressed_video'] + elif file_type == 'report_processing': + file_path = status['results']['reports'].get('processing_results', '') + elif file_type == 'report_events': + file_path = status['results']['reports'].get('canonical_events', '') + elif file_type == 'html_gallery': + file_path = status['results']['reports'].get('html_gallery', '') + else: + return jsonify({'error': 'Invalid file type'}), 400 + + if not file_path or not os.path.exists(file_path): + return jsonify({'error': 'File not found'}), 404 + + return send_file(file_path, as_attachment=True) + + except Exception as e: + logger.error(f"Download error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/video/keyframes/', methods=['GET']) +@app.route('/api/keyframes/', methods=['GET']) +def get_keyframes(video_id): + """Get list of extracted keyframes with DetectifAI annotations""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + status = processing_status[video_id] + + if status['status'] != 'completed': + return jsonify({'error': 'Processing not completed'}), 400 + + output_dir = status['results']['output_directory'] + frames_dir = os.path.join(output_dir, 'frames') + + if not os.path.exists(frames_dir): + return jsonify({'error': 'Frames directory not found'}), 404 + + # Load detection metadata if available + detection_metadata = {} + detection_metadata_path = os.path.join(output_dir, 'detection_metadata.json') + if os.path.exists(detection_metadata_path): + try: + with open(detection_metadata_path, 'r') as f: + detection_metadata = json.load(f) + except Exception as e: + logger.warning(f"Could not load detection metadata: {e}") + + # Get filter parameter + filter_detections = request.args.get('filter_detections', 'false').lower() == 'true' + + keyframes = [] + frames_with_detections = {item['original_path']: item for item in detection_metadata.get('detection_summary', [])} + + for filename in sorted(os.listdir(frames_dir)): + if filename.endswith('.jpg') and not filename.endswith('_annotated.jpg'): + # Extract timestamp from filename + timestamp = 0.0 + try: + if '_' in filename: + timestamp_part = filename.split('_')[1].replace('s', '').replace('.jpg', '') + timestamp = float(timestamp_part) + except: + pass + + frame_path = os.path.join(frames_dir, filename) + has_detections = frame_path in frames_with_detections + + # Skip frames without detections if filtering is enabled + if filter_detections and not has_detections: + continue + + keyframe_data = { + 'filename': filename, + 'timestamp': timestamp, + 'url': f'/api/video/{video_id}/keyframe/{filename}', + 'minio_url': f'/api/minio/image/detectifai-keyframes/{video_id}/keyframes/{filename}', + 'has_detections': has_detections + } + + # Add detection details if available + if has_detections: + detection_info = frames_with_detections[frame_path] + keyframe_data.update({ + 'detection_count': detection_info.get('detection_count', 0), + 'objects': detection_info.get('objects', []), + 'confidence_avg': detection_info.get('confidence_avg', 0.0) + }) + + keyframes.append(keyframe_data) + + return jsonify({ + 'video_id': video_id, + 'total_keyframes': detection_metadata.get('total_keyframes', len(keyframes)), + 'keyframes_with_detections': detection_metadata.get('frames_with_detections', 0), + 'keyframes': keyframes, + 'objects_detected': detection_metadata.get('objects_detected', {}), + 'filter_applied': filter_detections + }), 200 + +@app.route('/api/keyframe//', methods=['GET']) +def get_keyframe_image(video_id, filename): + """Serve keyframe image""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + status = processing_status[video_id] + output_dir = status['results']['output_directory'] + frames_dir = os.path.join(output_dir, 'frames') + + return send_from_directory(frames_dir, filename) + +@app.route('/api/video/compressed/', methods=['GET']) +def get_compressed_video(video_id): + """Serve compressed video — delegates to V3 proxy-streaming endpoint""" + # Always delegate to V3 which proxy-streams from MinIO (avoids CORS/redirect issues) + return serve_compressed_video_v3(video_id) + +@app.route('/api/videos', methods=['GET']) +def list_videos(): + """List all processed videos""" + videos = [] + for video_id, status in processing_status.items(): + videos.append({ + 'video_id': video_id, + 'filename': status.get('filename', ''), + 'status': status.get('status', ''), + 'uploaded_at': status.get('uploaded_at', ''), + 'progress': status.get('progress', 0) + }) + + return jsonify({'videos': videos}), 200 + +@app.route('/api/video/processing-summary/', methods=['GET']) +@app.route('/api/processing-summary/', methods=['GET']) +def get_processing_summary(video_id): + """Get detailed processing summary for a video""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + status = processing_status[video_id] + + if status['status'] != 'completed': + return jsonify({'error': 'Processing not completed'}), 400 + + output_dir = status['results']['output_directory'] + + # Load detection metadata + detection_metadata = {} + detection_metadata_path = os.path.join(output_dir, 'detection_metadata.json') + if os.path.exists(detection_metadata_path): + try: + with open(detection_metadata_path, 'r') as f: + detection_metadata = json.load(f) + except Exception as e: + logger.warning(f"Could not load detection metadata: {e}") + + # Get processing stats from status + processing_stats = status['results'].get('processing_stats', {}) + + summary = { + 'video_id': video_id, + 'filename': status.get('filename', ''), + 'processing_time': processing_stats.get('total_processing_time', 0), + 'keyframes_extracted': detection_metadata.get('total_keyframes', 0), + 'keyframes_with_detections': detection_metadata.get('frames_with_detections', 0), + 'objects_detected': detection_metadata.get('objects_detected', {}), + 'total_objects': sum(detection_metadata.get('objects_detected', {}).values()), + 'component_times': processing_stats.get('component_times', {}), + 'output_files': { + 'compressed_video': status['results'].get('compressed_video_path', ''), + 'frames_directory': os.path.join(output_dir, 'frames'), + 'reports_directory': os.path.join(output_dir, 'reports') + } + } + + return jsonify(summary), 200 + +@app.route('/api/delete/', methods=['DELETE']) +def delete_video(video_id): + """Delete video and its processing results""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + try: + # Remove from status + status = processing_status.pop(video_id) + + # Delete output directory + if 'results' in status and 'output_directory' in status['results']: + import shutil + output_dir = status['results']['output_directory'] + if os.path.exists(output_dir): + shutil.rmtree(output_dir) + + # Delete uploaded video + for file in os.listdir(app.config['UPLOAD_FOLDER']): + if file.startswith(video_id): + os.remove(os.path.join(app.config['UPLOAD_FOLDER'], file)) + + return jsonify({'success': True, 'message': 'Video deleted successfully'}), 200 + + except Exception as e: + logger.error(f"Delete error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +# DetectifAI-specific endpoints + +@app.route('/api/detectifai/events/', methods=['GET']) +def get_detectifai_events(video_id): + """Get DetectifAI security events for a video""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + status = processing_status[video_id] + + if status['status'] != 'completed': + return jsonify({'error': 'Processing not completed'}), 400 + + results = status.get('results', {}) + security_events = results.get('security_detection', {}) + + return jsonify({ + 'video_id': video_id, + 'security_events': security_events, + 'total_detections': security_events.get('total_object_detections', 0), + 'fire_detections': security_events.get('fire_detections', 0), + 'weapon_detections': security_events.get('weapon_detections', 0), + 'security_alerts': security_events.get('security_alerts', []) + }), 200 + +@app.route('/api/detectifai/demo', methods=['GET']) +def demo_detectifai(): + """Demo endpoint to process test videos (rob.mp4, fire.avi)""" + try: + demo_videos = [] + + # Check for test videos + test_files = ['rob.mp4', 'fire.avi'] + for test_file in test_files: + if os.path.exists(test_file): + # Create demo processing entry + video_id = f"demo_{test_file.replace('.', '_')}_{int(datetime.now().timestamp())}" + + processing_status[video_id] = { + 'video_id': video_id, + 'filename': test_file, + 'status': 'ready', + 'progress': 0, + 'message': f'Demo video {test_file} ready for DetectifAI processing', + 'uploaded_at': datetime.now().isoformat(), + 'video_path': test_file, + 'is_demo': True, + 'config_type': 'detectifai' + } + + demo_videos.append({ + 'video_id': video_id, + 'filename': test_file, + 'process_url': f'/api/process/{video_id}' + }) + + return jsonify({ + 'demo_videos': demo_videos, + 'message': f'Found {len(demo_videos)} demo videos ready for DetectifAI processing' + }), 200 + + except Exception as e: + logger.error(f"Demo endpoint error: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/process/', methods=['POST']) +def process_existing_video(video_id): + """Process an existing video (useful for demo videos)""" + if video_id not in processing_status: + return jsonify({'error': 'Video not found'}), 404 + + status = processing_status[video_id] + + if status.get('status') not in ['ready', 'failed']: + return jsonify({'error': 'Video is already being processed or completed'}), 400 + + video_path = status.get('video_path', '') + if not os.path.exists(video_path): + return jsonify({'error': 'Video file not found'}), 404 + + config_type = status.get('config_type', 'detectifai') + + # Start background processing + thread = threading.Thread( + target=process_video_async, + args=(video_id, video_path, config_type) + ) + thread.daemon = True + thread.start() + + return jsonify({ + 'success': True, + 'video_id': video_id, + 'message': 'DetectifAI processing started', + 'status_url': f'/api/status/{video_id}' }), 200 +@app.route('/api/debug/compressed/', methods=['GET']) +def debug_compressed_video(video_id): + """Debug endpoint to check compressed video storage and optionally serve it""" + if not DATABASE_ENABLED: + return jsonify({'error': 'Database not enabled'}), 503 + + # Check if user wants to download the video + serve_video = request.args.get('serve', 'false').lower() == 'true' + + try: + video_record = db_video_service.video_repo.get_video_by_id(video_id) + if not video_record: + return jsonify({'error': 'Video not found'}), 404 + + meta_data = video_record.get('meta_data', {}) + bucket = video_record.get('minio_bucket', db_video_service.video_repo.video_bucket) + + # Check MinIO + minio_info = {} + objects = [] + try: + objects = list(db_video_service.video_repo.minio.list_objects(bucket, prefix=f"compressed/{video_id}/", recursive=True)) + minio_info['objects_found'] = len(objects) + minio_info['objects'] = [{'name': obj.object_name, 'size': obj.size} for obj in objects] + except Exception as e: + minio_info['error'] = str(e) + + # If user wants to serve the video, try to serve it + if serve_video and objects: + logger.info(f"🐛 DEBUG: Attempting to serve compressed video for: {video_id}") + try: + # Find video.mp4 in the objects + video_object = None + for obj in objects: + if obj.object_name.endswith('video.mp4'): + video_object = obj + break + + if video_object: + logger.info(f"🐛 DEBUG: Found video object: {video_object.object_name}") + + # Get the video data + minio_client = db_video_service.video_repo.minio + video_data = minio_client.get_object(bucket, video_object.object_name) + + # Create response + def generate(): + try: + for chunk in video_data.stream(8192): + yield chunk + finally: + video_data.close() + + response = Response( + generate(), + mimetype='video/mp4', + headers={ + 'Content-Disposition': f'inline; filename="compressed_{video_id}.mp4"', + 'Accept-Ranges': 'bytes' + } + ) + + logger.info(f"🐛 DEBUG: Successfully serving compressed video") + return response + else: + logger.warning(f"🐛 DEBUG: No video.mp4 found in objects") + + except Exception as serve_e: + logger.error(f"🐛 DEBUG: Failed to serve video: {serve_e}") + return jsonify({ + 'error': f'Failed to serve video: {str(serve_e)}', + 'video_id': video_id, + 'bucket': bucket, + 'minio_info': minio_info + }), 500 + + return jsonify({ + 'video_id': video_id, + 'bucket': bucket, + 'minio_compressed_path': meta_data.get('minio_compressed_path'), + 'compression_info': meta_data.get('compression_info', {}), + 'minio_info': minio_info, + 'help': 'Add ?serve=true to download the video' + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/video//compressed', methods=['GET']) +@app.route('/api/video/annotated/', methods=['GET']) +@app.route('/api/v2/video/annotated/', methods=['GET']) +def serve_annotated_video(video_id): + """Serve annotated video with bounding boxes from MinIO or local storage""" + logger.info(f"🎨 Request to serve annotated video: {video_id}") + try: + # First try to get from database/MinIO + video_record = None + video_exists_in_db = False + status_data = None + meta_data = {} + + if DATABASE_ENABLED: + try: + status_data = db_video_service.get_video_status(video_id) + if 'error' not in status_data: + video_exists_in_db = True + logger.info(f"✅ Found video in database: {video_id}") + + # Get video record directly + try: + video_record = db_video_service.video_repo.get_video_by_id(video_id) + except Exception as e: + logger.warning(f"Could not get video record: {e}") + + # Get metadata + if status_data: + meta_data = status_data.get('meta_data', {}) + if not meta_data and video_record: + meta_data = video_record.get('meta_data', {}) + + # Use detectifai-videos bucket + video_bucket = "detectifai-videos" + if video_record: + record_bucket = video_record.get('minio_bucket') + if record_bucket == "detectifai-videos": + video_bucket = record_bucket + + # Get annotated video path from metadata + minio_annotated_path = meta_data.get('minio_annotated_path') + annotated_video_available = meta_data.get('annotated_video_available', False) + + logger.info(f"📁 MinIO annotated path: {minio_annotated_path}") + logger.info(f"📁 Annotated video available: {annotated_video_available}") + + # Try to serve from MinIO + if minio_annotated_path and annotated_video_available: + try: + from minio.error import S3Error + minio_client = db_video_service.video_repo.minio + + # Check if object exists + try: + minio_client.stat_object(video_bucket, minio_annotated_path) + + # Generate presigned URL + from datetime import timedelta + presigned_url = minio_client.presigned_get_object( + video_bucket, + minio_annotated_path, + expires=timedelta(hours=1) + ) + logger.info(f"✅ Generated presigned URL for annotated video: {minio_annotated_path}") + return redirect(presigned_url) + except S3Error as e: + if e.code == 'NoSuchKey': + logger.warning(f"⚠️ Annotated video not found in MinIO: {minio_annotated_path}") + else: + logger.error(f"❌ MinIO error: {e}") + except Exception as e: + logger.warning(f"⚠️ Failed to get annotated video from MinIO: {e}") + + # Try local file + annotated_video_path = meta_data.get('annotated_video_path') + if annotated_video_path and os.path.exists(annotated_video_path): + logger.info(f"✅ Serving annotated video from local path: {annotated_video_path}") + return send_file(annotated_video_path, mimetype='video/mp4') + + except Exception as e: + logger.error(f"❌ Error getting video status: {e}") + + # Fallback: check local storage + output_dir = os.path.join(OUTPUT_FOLDER, video_id) + annotated_dir = os.path.join(output_dir, 'annotated') + + if os.path.exists(annotated_dir): + video_files = [f for f in os.listdir(annotated_dir) if f.endswith('.mp4')] + if video_files: + video_filename = video_files[0] + logger.info(f"✅ Serving annotated video from local directory: {annotated_dir}/{video_filename}") + return send_from_directory(annotated_dir, video_filename) + + # If no annotated video, fallback to compressed or original + logger.warning(f"⚠️ Annotated video not found for {video_id}, falling back to compressed") + return serve_compressed_video(video_id) + + except Exception as e: + logger.error(f"❌ Error serving annotated video: {e}") + import traceback + logger.error(traceback.format_exc()) + return jsonify({'error': f'Failed to serve annotated video: {str(e)}'}), 500 + +@app.route('/api/v2/video/compressed/', methods=['GET']) +def serve_compressed_video(video_id): + """Serve compressed processed video — delegates to V3 proxy-streaming""" + return serve_compressed_video_v3(video_id) + + # ORIGINAL COMPLEX LOGIC (fallback if simple approach fails) + try: + # First try to get from database/MinIO + video_record = None + video_exists_in_db = False + status_data = None + meta_data = {} + + if DATABASE_ENABLED: + try: + status_data = db_video_service.get_video_status(video_id) + if 'error' not in status_data: + video_exists_in_db = True + logger.info(f"✅ Found video in database: {video_id}") + logger.info(f"📊 Status data keys: {list(status_data.keys())}") + + # Get video record directly to access all fields including bucket + try: + video_record = db_video_service.video_repo.get_video_by_id(video_id) + if video_record: + logger.info(f"📁 Retrieved video record from database") + except Exception as e: + logger.warning(f"Could not get video record: {e}") + else: + logger.warning(f"⚠️ Video not found in database status, but will still try MinIO: {video_id}") + # Still try to get video record directly + try: + video_record = db_video_service.video_repo.get_video_by_id(video_id) + if video_record: + video_exists_in_db = True + logger.info(f"✅ Found video record directly (status lookup failed)") + except Exception as e: + logger.warning(f"Could not get video record: {e}") + + # Try to get from MinIO directly + # meta_data might be nested or at root level + if status_data: + meta_data = status_data.get('meta_data', {}) + if not meta_data and video_record: + meta_data = video_record.get('meta_data', {}) + logger.info(f"📁 Retrieved meta_data from video record") + + # Get bucket from video record (should be "detectifai-videos") + # Always use detectifai-videos bucket as confirmed by user + video_bucket = "detectifai-videos" + if video_record: + record_bucket = video_record.get('minio_bucket') + if record_bucket: + logger.info(f"📦 Video bucket from record: {record_bucket}") + # Use record bucket if it's detectifai-videos, otherwise use default + if record_bucket == "detectifai-videos": + video_bucket = record_bucket + else: + logger.warning(f"⚠️ Record bucket ({record_bucket}) doesn't match expected (detectifai-videos), using detectifai-videos") + video_bucket = "detectifai-videos" + else: + logger.info(f"📦 No bucket in record, using detectifai-videos") + else: + logger.info(f"📦 No video record, using detectifai-videos bucket") + + # Ensure we're using the correct bucket + if video_bucket != "detectifai-videos": + logger.warning(f"⚠️ Bucket mismatch! Expected 'detectifai-videos', got '{video_bucket}'. Forcing to 'detectifai-videos'") + video_bucket = "detectifai-videos" + + logger.info(f"📦 Final video bucket: {video_bucket}") + + minio_compressed_path = meta_data.get('minio_compressed_path') if meta_data else None + + # Also check compression_info for the path + if not minio_compressed_path and meta_data: + compression_info = meta_data.get('compression_info', {}) + minio_compressed_path = compression_info.get('minio_path') + + logger.info(f"📁 MinIO compressed path from metadata: {minio_compressed_path}") + logger.info(f"📁 Processing status: {meta_data.get('processing_status') if meta_data else 'N/A'}") + logger.info(f"📁 Full meta_data keys: {list(meta_data.keys()) if meta_data else 'N/A'}") + except Exception as e: + logger.warning(f"⚠️ Database lookup failed, but will still try MinIO: {e}") + import traceback + logger.debug(f"Database lookup traceback: {traceback.format_exc()}") + + # Always try MinIO first (even if database lookup failed, try standard path) + # This ensures we can serve videos even if database is temporarily unavailable + try: + from io import BytesIO + from minio.error import S3Error + + # Use detectifai-videos bucket as confirmed by user + video_bucket = "detectifai-videos" + + # Get minio_compressed_path from metadata if available + minio_compressed_path = meta_data.get('minio_compressed_path') if meta_data else None + if not minio_compressed_path and meta_data: + compression_info = meta_data.get('compression_info', {}) + minio_compressed_path = compression_info.get('minio_path') + + # Get compressed video path from metadata or use standard path + # User confirmed: bucket is "detectifai-videos" and folder is "compressed" + # Standard path format: compressed/{video_id}/video.mp4 + possible_paths = [] + + # First, try the path from metadata if available + if minio_compressed_path: + # Normalize path - remove leading slash if present + normalized_path = minio_compressed_path.lstrip('/') + possible_paths.append(normalized_path) + logger.info(f"📁 Using path from metadata: {normalized_path}") + + # Always try the standard path format (user confirmed this is correct) + standard_path = f"compressed/{video_id}/video.mp4" + if standard_path not in possible_paths: + possible_paths.insert(0, standard_path) # Try standard path first + + # Also try alternative formats as fallback + alternative_paths = [ + f"compressed/{video_id}/compressed.mp4", + ] + for alt_path in alternative_paths: + if alt_path not in possible_paths: + possible_paths.append(alt_path) + + logger.info(f"🔍 Will try {len(possible_paths)} possible paths in bucket: {video_bucket}") + for i, p in enumerate(possible_paths, 1): + logger.info(f" {i}. {p}") + + # Debug: Log if DATABASE_ENABLED and which minio client we're using + logger.info(f"📋 DEBUG: DATABASE_ENABLED = {DATABASE_ENABLED}") + if DATABASE_ENABLED: + logger.info(f"📋 DEBUG: compression_bucket = {compression_bucket}") + logger.info(f"📋 DEBUG: video_bucket = {video_bucket}") + logger.info(f"📋 DEBUG: minio_client type = {type(minio_client)}") + logger.info(f"📋 DEBUG: minio_client available = {minio_client is not None}") + + video_data = None + successful_path = None + + # Try to get from video bucket (compressed videos are in same bucket as originals) + if DATABASE_ENABLED: + compression_bucket = db_video_service.compression_service.bucket + minio_client = db_video_service.video_repo.minio + else: + compression_bucket = video_bucket + # Need to create a MinIO client if database is not enabled + from database.config import DatabaseManager + db_manager = DatabaseManager() + minio_client = db_manager.minio_client + + # Try each possible path in the video bucket first + logger.info(f"🔍 Trying video bucket: {video_bucket}") + for minio_path in possible_paths: + try: + logger.info(f" Attempting: {video_bucket}/{minio_path}") + # Verify bucket exists first + if not minio_client.bucket_exists(video_bucket): + logger.error(f"❌ Bucket '{video_bucket}' does not exist!") + raise Exception(f"Bucket '{video_bucket}' does not exist") + + video_data = minio_client.get_object( + video_bucket, + minio_path + ) + successful_path = minio_path + logger.info(f"✅ Found compressed video in video bucket: {video_bucket} at {minio_path}") + break + except S3Error as s3_err: + error_code = getattr(s3_err, 'code', 'Unknown') + error_msg = str(s3_err) + logger.warning(f" ❌ S3Error ({error_code}): {error_msg[:200]}") + if error_code == 'NoSuchKey': + logger.info(f" ℹ️ Object '{minio_path}' not found in bucket '{video_bucket}'") + + # DEBUG: Let's list what's actually in the bucket at this path + if error_code == 'NoSuchKey': + try: + prefix = '/'.join(minio_path.split('/')[:-1]) + '/' # Get directory path + logger.info(f" 🔍 DEBUG: Listing objects with prefix '{prefix}' in bucket '{video_bucket}'") + debug_objects = list(minio_client.list_objects(video_bucket, prefix=prefix, recursive=True)) + if debug_objects: + logger.info(f" 📦 DEBUG: Found {len(debug_objects)} objects:") + for obj in debug_objects[:5]: # Show first 5 + logger.info(f" - {obj.object_name} ({obj.size} bytes)") + else: + logger.info(f" 📦 DEBUG: No objects found with prefix '{prefix}'") + except Exception as debug_e: + logger.warning(f" ⚠️ DEBUG: Failed to list objects: {debug_e}") + continue + except Exception as e1: + error_msg = str(e1) + logger.warning(f" ❌ Failed: {error_msg[:200]}") + import traceback + logger.debug(f" Traceback: {traceback.format_exc()}") + continue + + # If not found in video bucket, try compression bucket (should be same, but check anyway) + if not video_data and compression_bucket != video_bucket and DATABASE_ENABLED: + logger.info(f"🔍 Trying compression bucket: {compression_bucket}") + compression_minio = db_video_service.compression_service.minio + for minio_path in possible_paths: + try: + logger.info(f" Attempting: {compression_bucket}/{minio_path}") + if not compression_minio.bucket_exists(compression_bucket): + logger.error(f"❌ Compression bucket '{compression_bucket}' does not exist!") + continue + + video_data = compression_minio.get_object( + compression_bucket, + minio_path + ) + successful_path = minio_path + logger.info(f"✅ Found compressed video in compression bucket: {compression_bucket} at {minio_path}") + break + except S3Error as s3_err: + error_code = getattr(s3_err, 'code', 'Unknown') + logger.warning(f" ❌ S3Error ({error_code}): {str(s3_err)[:200]}") + continue + except Exception as e2: + logger.warning(f" ❌ Failed: {str(e2)[:200]}") + continue + elif not video_data and compression_bucket == video_bucket: + logger.info(f"ℹ️ Compression bucket is same as video bucket, skipping duplicate check") + + # If still not found, try listing objects to see what's available + if not video_data: + logger.warning(f"⚠️ Could not find video with standard paths, listing objects in bucket '{video_bucket}'...") + try: + # List all objects with compressed prefix for this video + search_prefix = f"compressed/{video_id}/" + logger.info(f"🔍 Listing objects in '{video_bucket}' with prefix '{search_prefix}'") + + if not minio_client.bucket_exists(video_bucket): + logger.error(f"❌ Bucket '{video_bucket}' does not exist! Cannot list objects.") + else: + objects = list(minio_client.list_objects(video_bucket, prefix=search_prefix, recursive=True)) + logger.info(f"📦 Found {len(objects)} objects in video bucket '{video_bucket}' with prefix '{search_prefix}'") + + if objects: + logger.info(f"📋 Available objects:") + for obj in objects: + logger.info(f" - {obj.object_name} ({obj.size} bytes, modified: {obj.last_modified})") + + # Try the first object found + actual_path = objects[0].object_name + logger.info(f"🔄 Trying first object found: {actual_path}") + try: + video_data = minio_client.get_object(video_bucket, actual_path) + successful_path = actual_path + logger.info(f"✅ Successfully retrieved video from path: {actual_path}") + except Exception as get_err: + logger.error(f"❌ Failed to get object '{actual_path}': {get_err}") + else: + logger.warning(f"⚠️ No objects found with prefix '{search_prefix}' in bucket '{video_bucket}'") + + # Try listing all objects in compressed folder + logger.info(f"🔍 Listing all objects in 'compressed/' folder...") + all_compressed = list(minio_client.list_objects(video_bucket, prefix="compressed/", recursive=True)) + logger.info(f"📦 Found {len(all_compressed)} total objects in 'compressed/' folder") + if all_compressed: + logger.info(f"📋 Sample objects in compressed folder:") + for obj in all_compressed[:10]: # Show first 10 + logger.info(f" - {obj.object_name}") + + # Also check compression bucket if different + if not video_data and compression_bucket != video_bucket and DATABASE_ENABLED: + logger.info(f"🔍 Listing objects in compression bucket '{compression_bucket}' with prefix '{search_prefix}'") + compression_minio = db_video_service.compression_service.minio + if compression_minio.bucket_exists(compression_bucket): + objects2 = list(compression_minio.list_objects(compression_bucket, prefix=search_prefix, recursive=True)) + logger.info(f"📦 Found {len(objects2)} objects in compression bucket") + if objects2: + for obj in objects2: + logger.info(f" - {obj.object_name} ({obj.size} bytes)") + actual_path = objects2[0].object_name + logger.info(f"🔄 Trying actual path found: {actual_path}") + video_data = compression_minio.get_object(compression_bucket, actual_path) + successful_path = actual_path + except Exception as list_err: + logger.error(f"❌ Failed to list objects: {list_err}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + + if video_data: + # Successfully found video in MinIO + video_bytes = video_data.read() + video_data.close() + video_data.release_conn() + + response = send_file( + BytesIO(video_bytes), + mimetype='video/mp4', + as_attachment=False, + download_name=f"{video_id}_compressed.mp4" + ) + response.headers['Accept-Ranges'] = 'bytes' + response.headers['Cache-Control'] = 'no-cache' + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Range' + response.headers['Content-Type'] = 'video/mp4' + logger.info(f"✅ Served compressed video from MinIO for {video_id}") + return response + else: + logger.warning(f"⚠️ Could not retrieve video from MinIO. Tried {len(possible_paths)} paths in buckets {video_bucket} and {compression_bucket}") + # Fall through to local storage check + except S3Error as e: + logger.warning(f"⚠️ MinIO retrieval failed (S3Error), falling back to local storage: {e}") + import traceback + logger.error(f"S3Error traceback: {traceback.format_exc()}") + # Don't return, continue to local fallback + except Exception as e: + logger.warning(f"⚠️ MinIO retrieval failed, falling back to local storage: {e}") + import traceback + logger.error(f"Exception traceback: {traceback.format_exc()}") + # Don't return, continue to local fallback + + # Fallback: Find the compressed video file locally (ALWAYS try this, even if database lookup failed) + logger.info(f"🔍 Searching local file system for compressed video: {video_id}") + + # Get the local path from compression service if available + local_path_from_service = None + if DATABASE_ENABLED: + try: + # Try to get local path from compression service result + if not video_record: + video_record = db_video_service.video_repo.get_video_by_id(video_id) + if video_record: + meta_data = video_record.get('meta_data', {}) + # Check if we have compression info with local path + compression_info = meta_data.get('compression_info', {}) + if compression_info and 'local_path' in compression_info: + local_path_from_service = compression_info['local_path'] + logger.info(f"📁 Found local path from compression info: {local_path_from_service}") + # Also check for compressed_path in compression_info (alternative field name) + elif compression_info and 'compressed_path' in compression_info: + local_path_from_service = compression_info['compressed_path'] + logger.info(f"📁 Found local path from compression_info.compressed_path: {local_path_from_service}") + # Also check minio_compressed_path - might be a local path + elif meta_data.get('minio_compressed_path'): + potential_path = meta_data.get('minio_compressed_path') + if os.path.exists(potential_path) and not potential_path.startswith('compressed/'): + local_path_from_service = potential_path + logger.info(f"📁 Found local path from minio_compressed_path: {local_path_from_service}") + except Exception as e: + logger.debug(f"Could not get local path from service: {e}") + + # List of possible local directories to check + possible_dirs = [] + + # Add path from compression service if available + if local_path_from_service: + if os.path.exists(local_path_from_service): + possible_dirs.append(os.path.dirname(local_path_from_service)) + elif os.path.exists(local_path_from_service): + # If it's a file path, use its directory + possible_dirs.append(os.path.dirname(local_path_from_service)) + + # Add standard locations (check multiple possible locations) + possible_dirs.extend([ + os.path.join("video_processing_outputs", "compressed", video_id), # Standard location from compression service + os.path.join(OUTPUT_FOLDER, video_id, 'compressed'), + os.path.join("video_processing_outputs", video_id, "compressed"), + os.path.join("backend", "video_processing_outputs", "compressed", video_id), # If running from root + os.path.join(".", "video_processing_outputs", "compressed", video_id), # Current directory + os.path.join("video_processing_outputs", "compressed"), # Check root compressed dir + os.path.join(OUTPUT_FOLDER, "compressed", video_id), # Alternative location + ]) + + # Also add direct file paths that might be stored in metadata + possible_file_paths = [ + os.path.join("video_processing_outputs", "compressed", f"{video_id}_compressed.mp4"), + os.path.join(OUTPUT_FOLDER, "compressed", f"{video_id}_compressed.mp4"), + os.path.join("video_processing_outputs", "compressed", video_id, "video.mp4"), + os.path.join(OUTPUT_FOLDER, video_id, "compressed", "video.mp4"), + ] + + # Check direct file paths first + for file_path in possible_file_paths: + if os.path.exists(file_path) and os.path.isfile(file_path) and os.path.getsize(file_path) > 0: + logger.info(f"✅ Found compressed video file: {file_path} ({os.path.getsize(file_path)} bytes)") + try: + response = send_file( + file_path, + mimetype='video/mp4', + as_attachment=False, + download_name=os.path.basename(file_path) + ) + response.headers['Accept-Ranges'] = 'bytes' + response.headers['Cache-Control'] = 'no-cache' + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Range' + response.headers['Content-Type'] = 'video/mp4' + logger.info(f"✅ Serving compressed video from file path: {file_path}") + return response + except Exception as e: + logger.warning(f"Failed to serve from file path {file_path}: {e}") + continue + + # Also check if local_path_from_service is a direct file path + if local_path_from_service and os.path.exists(local_path_from_service) and os.path.isfile(local_path_from_service): + logger.info(f"✅ Found compressed video file directly: {local_path_from_service}") + try: + response = send_file( + local_path_from_service, + mimetype='video/mp4', + as_attachment=False, + download_name=os.path.basename(local_path_from_service) + ) + response.headers['Accept-Ranges'] = 'bytes' + response.headers['Cache-Control'] = 'no-cache' + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Range' + response.headers['Content-Type'] = 'video/mp4' + logger.info(f"✅ Serving compressed video from direct path: {local_path_from_service}") + return response + except Exception as e: + logger.warning(f"Failed to serve from direct path: {e}") + + # Remove duplicates while preserving order + seen = set() + unique_dirs = [] + for d in possible_dirs: + if d not in seen: + seen.add(d) + unique_dirs.append(d) + + logger.info(f"🔍 Checking {len(unique_dirs)} possible local directories") + + for output_dir in unique_dirs: + logger.info(f"🔍 Checking directory: {output_dir}") + if os.path.exists(output_dir): + # Look for compressed video files + try: + files = os.listdir(output_dir) + logger.info(f"📁 Files in {output_dir}: {files}") + + for file in files: + if file.endswith('.mp4'): + video_path = os.path.join(output_dir, file) + if os.path.exists(video_path) and os.path.getsize(video_path) > 0: + logger.info(f"✅ Found compressed video locally: {video_path} ({os.path.getsize(video_path)} bytes)") + response = send_file( + video_path, + mimetype='video/mp4', + as_attachment=False, + download_name=file + ) + # Add headers for video playback and streaming + response.headers['Accept-Ranges'] = 'bytes' + response.headers['Cache-Control'] = 'no-cache' + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Range' + response.headers['Content-Type'] = 'video/mp4' + logger.info(f"✅ Serving compressed video from local storage: {video_path}") + return response + except Exception as dir_err: + logger.warning(f"⚠️ Error reading directory {output_dir}: {dir_err}") + continue + + logger.error(f"❌ No compressed video found for {video_id} in any location") + logger.error(f" Checked {len(unique_dirs)} directories: {unique_dirs}") + + # Use video_exists_in_db from earlier check, or check again if not set + if not video_exists_in_db and DATABASE_ENABLED: + try: + if not video_record: + video_record = db_video_service.video_repo.get_video_by_id(video_id) + video_exists_in_db = video_record is not None + except Exception as e: + logger.warning(f"Could not check if video exists: {e}") + + if not video_exists_in_db: + logger.error(f"❌ Video {video_id} does not exist in database") + return jsonify({'error': 'Video not found', 'video_id': video_id}), 404 + else: + processing_status = 'unknown' + if video_record: + processing_status = video_record.get('meta_data', {}).get('processing_status', 'unknown') + logger.error(f"❌ Video {video_id} exists but compressed video not found") + logger.error(f" Processing status: {processing_status}") + logger.error(f" Checked {len(unique_dirs)} directories: {unique_dirs}") + return jsonify({ + 'error': 'Compressed video not found', + 'video_id': video_id, + 'checked_dirs': unique_dirs, + 'processing_status': processing_status, + 'message': 'Video exists but compressed version not available. Processing may still be in progress or compression may have failed.' + }), 404 + + except Exception as e: + logger.error(f"Error serving compressed video: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/video//keyframes', methods=['GET']) +def get_video_keyframes(video_id): + """Get list of keyframes with detection results""" + try: + frames_dir = os.path.join(OUTPUT_FOLDER, video_id, 'frames') + if not os.path.exists(frames_dir): + return jsonify({'error': 'Keyframes not found'}), 404 + + # Load detection metadata + detection_metadata = {} + detection_metadata_path = os.path.join(OUTPUT_FOLDER, video_id, 'detection_metadata.json') + if os.path.exists(detection_metadata_path): + try: + with open(detection_metadata_path, 'r') as f: + detection_metadata = json.load(f) + except Exception as e: + logger.warning(f"Could not load detection metadata: {e}") + + # Build detection lookup dictionary + detection_lookup = {} + for item in detection_metadata.get('detection_summary', []): + original_filename = os.path.basename(item['original_path']) + annotated_filename = os.path.basename(item['annotated_path']) if 'annotated_path' in item else None + detection_lookup[original_filename] = { + 'has_detections': True, + 'detection_count': item.get('detection_count', 0), + 'objects': item.get('objects', []), + 'confidence_avg': item.get('confidence_avg', 0.0), + 'annotated_filename': annotated_filename + } + + keyframes = [] + for file in os.listdir(frames_dir): + # Filter out annotated versions - only include original keyframes + if file.endswith('.jpg') and not file.endswith('_annotated.jpg'): + # Extract timestamp safely + timestamp = 0.0 + try: + if '_' in file: + timestamp_part = file.split('_')[1].replace('s', '').replace('.jpg', '') + timestamp = float(timestamp_part) + except (ValueError, IndexError): + timestamp = 0.0 + + # Build keyframe data with detection info + keyframe_data = { + 'filename': file, + 'url': f'/api/video/{video_id}/keyframe/{file}', + 'timestamp': timestamp, + 'has_detections': file in detection_lookup + } + + # Add detection details and annotated frame URL if available + if file in detection_lookup: + detection_info = detection_lookup[file] + keyframe_data['detection_count'] = detection_info['detection_count'] + keyframe_data['objects'] = detection_info['objects'] + keyframe_data['confidence_avg'] = detection_info['confidence_avg'] + + # Provide annotated frame URL if it exists + if detection_info['annotated_filename']: + keyframe_data['annotated_url'] = f'/api/video/{video_id}/keyframe/{detection_info["annotated_filename"]}' + + keyframes.append(keyframe_data) + + # Sort by timestamp + keyframes.sort(key=lambda x: x['timestamp']) + + return jsonify({ + 'video_id': video_id, + 'keyframes': keyframes, + 'total_keyframes': len(keyframes), + 'keyframes_with_detections': detection_metadata.get('frames_with_detections', 0), + 'objects_detected': detection_metadata.get('objects_detected', {}) + }) + + except Exception as e: + logger.error(f"Error getting keyframes: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/video//keyframe/', methods=['GET']) +@app.route('/api/v2/video/keyframe//', methods=['GET']) +def serve_keyframe(video_id, filename): + """Serve individual keyframe image from MinIO or local storage""" + try: + # First try to get from MinIO (database-integrated) + if DATABASE_ENABLED: + try: + # Construct MinIO path from filename + # Filename format: frame_000001.jpg + # Try both path patterns (keyframes subfolder and flat) + from io import BytesIO + from minio.error import S3Error + + minio_paths_to_try = [ + f"{video_id}/keyframes/{filename}", + f"{video_id}/{filename}", + ] + + keyframe_bytes = None + for minio_path in minio_paths_to_try: + try: + keyframe_data = db_video_service.keyframe_repo.minio.get_object( + db_video_service.keyframe_repo.bucket, + minio_path + ) + keyframe_bytes = keyframe_data.read() + keyframe_data.close() + keyframe_data.release_conn() + logger.info(f"✅ Served keyframe from MinIO: {minio_path}") + break + except S3Error: + continue + + if keyframe_bytes: + response = send_file( + BytesIO(keyframe_bytes), + mimetype='image/jpeg', + as_attachment=False + ) + response.headers['Cache-Control'] = 'public, max-age=3600' + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type' + return response + else: + logger.warning(f"Keyframe not found in MinIO for any path: {minio_paths_to_try}") + except Exception as e: + logger.warning(f"MinIO retrieval failed, trying local: {e}") + + # Fallback: Try local filesystem (multiple possible locations) + local_paths_to_try = [ + os.path.join(OUTPUT_FOLDER, video_id, 'frames', filename), + os.path.join('video_processing_outputs', 'keyframes', video_id, filename), + os.path.join(OUTPUT_FOLDER, video_id, filename), + ] + for keyframe_path in local_paths_to_try: + if os.path.exists(keyframe_path): + response = send_file( + keyframe_path, + mimetype='image/jpeg', + as_attachment=False + ) + response.headers['Access-Control-Allow-Origin'] = '*' + return response + + return jsonify({'error': 'Keyframe not found'}), 404 + + except Exception as e: + logger.error(f"Error serving keyframe: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/minio/image//', methods=['GET']) +def serve_minio_image(bucket, object_path): + """ + Unified endpoint to serve images from MinIO buckets + Supports: + - Keyframes: detectifai-keyframes/{video_id}/keyframes/frame_*.jpg + - Live stream keyframes: detectifai-keyframes/live/{camera_id}/*.jpg + - NLP images: nlp-images/*.jpg + - Face images: detectifai-faces/*.jpg + """ + try: + from io import BytesIO + from minio.error import S3Error + + if not DATABASE_ENABLED: + return jsonify({'error': 'Database service not available'}), 503 + + # Get MinIO client + minio_client = db_video_service.db_manager.minio_client + + # Verify bucket exists + if not minio_client.bucket_exists(bucket): + logger.warning(f"Bucket {bucket} does not exist") + return jsonify({'error': f'Bucket {bucket} not found'}), 404 + + try: + # Get object from MinIO + image_data = minio_client.get_object(bucket, object_path) + image_bytes = image_data.read() + image_data.close() + image_data.release_conn() + + # Determine content type from file extension + content_type = 'image/jpeg' + if object_path.lower().endswith('.png'): + content_type = 'image/png' + elif object_path.lower().endswith('.webp'): + content_type = 'image/webp' + elif object_path.lower().endswith('.gif'): + content_type = 'image/gif' + + response = send_file( + BytesIO(image_bytes), + mimetype=content_type, + as_attachment=False + ) + response.headers['Cache-Control'] = 'public, max-age=3600' + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type' + + logger.info(f"✅ Served image from MinIO: {bucket}/{object_path}") + return response + + except S3Error as e: + logger.error(f"MinIO error retrieving {bucket}/{object_path}: {e}") + if e.code == 'NoSuchKey': + return jsonify({'error': 'Image not found in MinIO'}), 404 + return jsonify({'error': f'MinIO error: {str(e)}'}), 500 + + except Exception as e: + logger.error(f"Error serving MinIO image: {e}") + return jsonify({'error': f'Error serving image: {str(e)}'}), 500 + + + +@app.route('/api/v3/video/compressed/', methods=['GET']) +def serve_compressed_video_v3(video_id): + """Serve compressed video — proxy from MinIO/B2 to avoid CORS/redirect issues with