""" Face Recognition Router Handles face recognition operations: - Reference face management (add, delete, retrain) - Face scanning and matching - Dashboard statistics - Batch operations with WebSocket progress """ import asyncio import base64 import io import json from datetime import datetime from pathlib import Path from typing import Dict, List, Optional from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request from PIL import Image from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_app_state from ..core.config import settings from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError from ..core.responses import now_iso8601 from ..core.utils import validate_file_path, get_face_module as _get_face_module, ALLOWED_PATHS from modules.universal_logger import get_logger logger = get_logger('API') router = APIRouter(prefix="/api/face", tags=["Face Recognition"]) # Additional allowed paths for face recognition (includes /opt/immich for media files) FACE_ALLOWED_PATHS = ALLOWED_PATHS + [Path('/opt/immich')] def validate_file_path_for_face(file_path: str) -> Path: """ Validate file path is within allowed directories for face recognition. Uses the shared validate_file_path function with face-specific allowed paths. Raises: ValidationError: If path is outside allowed directories """ from fastapi import HTTPException try: return validate_file_path(file_path, allowed_bases=FACE_ALLOWED_PATHS) except HTTPException as e: if e.status_code == 403: logger.warning(f"Path traversal attempt blocked: {file_path}", module="Security") raise ValidationError("Access denied: file path not in allowed directory") elif e.status_code == 400: logger.error(f"Path validation error for: {file_path}", module="Security") raise ValidationError("Invalid file path") raise def get_face_module(db): """Get or create a cached FaceRecognitionModule instance for the given database.""" return _get_face_module(db, module_name="FaceRecognition") limiter = Limiter(key_func=get_remote_address) # ============================================================================ # PYDANTIC MODELS # ============================================================================ class AddReferenceRequest(BaseModel): file_path: str person_name: str class AddFaceReferenceRequest(BaseModel): file_path: str person_name: Optional[str] = None background: bool = False class BatchAddFaceReferenceRequest(BaseModel): file_paths: List[str] person_name: Optional[str] = None class RetroactiveScanRequest(BaseModel): directory: Optional[str] = None class RetrainRequest(BaseModel): model: str # ============================================================================ # REFERENCE MANAGEMENT ENDPOINTS # ============================================================================ @router.get("/reference-stats") @limiter.limit("60/minute") @handle_exceptions async def get_face_reference_stats( request: Request, current_user: Dict = Depends(get_current_user) ): """Get statistics about reference faces in the database.""" app_state = get_app_state() face_module = get_face_module(app_state.db) refs = face_module.get_reference_faces() # Group by person name by_person = {} for ref in refs: person = ref['person_name'] if person not in by_person: by_person[person] = { 'count': 0, 'first_added': ref['created_at'], 'last_added': ref['created_at'] } by_person[person]['count'] += 1 if ref['created_at'] < by_person[person]['first_added']: by_person[person]['first_added'] = ref['created_at'] if ref['created_at'] > by_person[person]['last_added']: by_person[person]['last_added'] = ref['created_at'] return { 'total': len(refs), 'by_person': by_person } @router.get("/references") @limiter.limit("60/minute") @handle_exceptions async def get_face_references( request: Request, current_user: Dict = Depends(get_current_user) ): """Get all reference faces with image thumbnails.""" app_state = get_app_state() face_module = get_face_module(app_state.db) refs = face_module.get_reference_faces() references_with_images = [] for ref in refs: thumbnail_data = ref.get('thumbnail_data') # Fallback: generate thumbnail on-the-fly if not in database if not thumbnail_data: image_path = Path(ref['reference_image_path']) if image_path.exists(): try: with Image.open(image_path) as img: img.thumbnail((150, 150), Image.Resampling.LANCZOS) buffer = io.BytesIO() img.save(buffer, format='JPEG', quality=85) buffer.seek(0) thumbnail_data = base64.b64encode(buffer.read()).decode('utf-8') except Exception as e: logger.warning(f"Failed to generate thumbnail for {image_path}: {e}", module="FaceRecognition") references_with_images.append({ 'id': ref['id'], 'person_name': ref['person_name'], 'reference_image_path': ref['reference_image_path'], 'created_at': ref['created_at'], 'thumbnail': thumbnail_data }) return {'references': references_with_images} @router.post("/references") @limiter.limit("20/minute") @handle_exceptions async def add_face_reference_simple( request: Request, current_user: Dict = Depends(get_current_user), add_data: AddReferenceRequest = Body(...) ): """Add a new reference face from an existing file.""" # Security: Validate path is within allowed directories (prevent path traversal) file_path = validate_file_path_for_face(add_data.file_path) if not file_path.exists(): # Security: Don't expose full path in error message raise NotFoundError("File not found") app_state = get_app_state() face_module = get_face_module(app_state.db) success = face_module.add_reference_face(add_data.person_name, str(file_path)) if success: return {'success': True, 'message': f'Reference face added for {add_data.person_name}'} else: raise ValidationError('Failed to add reference face - no face detected in image') @router.delete("/references/{reference_id}") @limiter.limit("20/minute") @handle_exceptions async def delete_face_reference( request: Request, reference_id: int, current_user: Dict = Depends(get_current_user) ): """Delete a reference face by ID.""" app_state = get_app_state() face_module = get_face_module(app_state.db) success = face_module.remove_reference_face(reference_id) if success: return {'success': True, 'message': 'Reference face removed'} else: raise NotFoundError('Reference face not found') # ============================================================================ # RETRAIN AND MIGRATION ENDPOINTS # ============================================================================ @router.post("/retrain-references") @limiter.limit("1/minute") @handle_exceptions async def retrain_face_references( request: Request, current_user: Dict = Depends(get_current_user), data: RetrainRequest = Body(...) ): """ Re-train all reference faces with a new InsightFace model. Required when switching models because embeddings are incompatible. """ new_model = data.model if not new_model: raise ValidationError('Model name required') valid_models = ['buffalo_l', 'buffalo_m', 'buffalo_s', 'buffalo_sc', 'antelopev2'] if new_model not in valid_models: raise ValidationError(f'Invalid model. Must be one of: {", ".join(valid_models)}') logger.info(f"Re-training references with model: {new_model}", module="FaceRecognition") app_state = get_app_state() # Update settings with new model with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("SELECT value FROM settings WHERE key = 'face_recognition'") result = cursor.fetchone() if result: settings = json.loads(result[0]) settings['insightface_model'] = new_model cursor.execute( "UPDATE settings SET value = ? WHERE key = 'face_recognition'", (json.dumps(settings),) ) conn.commit() # Store progress in shared state progress_state = { 'current': 0, 'total': 0, 'person_name': '', 'in_progress': True } def progress_callback(current, total, person_name, success): progress_state['current'] = current progress_state['total'] = total progress_state['person_name'] = person_name app_state.face_retrain_progress = progress_state face_module = get_face_module(app_state.db) def run_retrain(): try: return face_module.retrain_all_references_with_model(new_model, progress_callback=progress_callback) finally: progress_state['in_progress'] = False result = await asyncio.to_thread(run_retrain) app_state.face_retrain_progress = None if result['success']: return { 'success': True, 'message': f"Successfully re-trained {result['updated']}/{result['total']} references", 'total': result['total'], 'updated': result['updated'], 'failed': result['failed'], 'errors': result['errors'] } else: return { 'success': False, 'message': f"Re-training completed with errors: {result['updated']}/{result['total']} successful", 'total': result['total'], 'updated': result['updated'], 'failed': result['failed'], 'errors': result['errors'] } @router.get("/retrain-progress") @limiter.limit("300/minute") @handle_exceptions async def get_retrain_progress( request: Request, current_user: Dict = Depends(get_current_user) ): """Get current face recognition retraining progress.""" app_state = get_app_state() progress = getattr(app_state, 'face_retrain_progress', None) if not progress: return { 'in_progress': False, 'current': 0, 'total': 0, 'person_name': '' } return progress @router.post("/migrate-references") @limiter.limit("5/minute") @handle_exceptions async def migrate_face_references( request: Request, current_user: Dict = Depends(get_current_user) ): """ Migrate existing face references to dedicated storage directory. Copies reference images to /opt/media-downloader/data/face_references/ to prevent issues when original files are moved or deleted. """ app_state = get_app_state() face_module = get_face_module(app_state.db) result = face_module.migrate_references_to_storage() logger.info( f"Face reference migration: {result['migrated']} migrated, " f"{result['deactivated']} deactivated, {result['skipped']} skipped", module="FaceRecognition" ) return { "success": len(result['errors']) == 0, "total": result['total'], "migrated": result['migrated'], "deactivated": result['deactivated'], "skipped": result['skipped'], "errors": result['errors'] } # ============================================================================ # DASHBOARD STATISTICS # ============================================================================ @router.get("/dashboard-stats") @limiter.limit("60/minute") @handle_exceptions async def get_face_dashboard_stats( request: Request, current_user: Dict = Depends(get_current_user) ): """Get comprehensive face recognition statistics for dashboard.""" app_state = get_app_state() with app_state.db.get_connection() as conn: cursor = conn.cursor() # Total scans and matches cursor.execute(""" SELECT COUNT(*) as total_scans, SUM(CASE WHEN has_match = 1 THEN 1 ELSE 0 END) as total_matches, SUM(CASE WHEN has_match = 0 THEN 1 ELSE 0 END) as total_no_matches FROM face_recognition_scans """) totals = cursor.fetchone() # Matches by person cursor.execute(""" SELECT matched_person, COUNT(*) as count, AVG(confidence) as avg_confidence, MIN(confidence) as min_confidence, MAX(confidence) as max_confidence FROM face_recognition_scans WHERE has_match = 1 AND matched_person IS NOT NULL GROUP BY matched_person ORDER BY count DESC """) by_person = [] for row in cursor.fetchall(): try: avg_conf = float(row[2]) if row[2] is not None else 0 except (ValueError, TypeError): avg_conf = 0 try: min_conf = float(row[3]) if row[3] is not None else 0 except (ValueError, TypeError): min_conf = 0 try: max_conf = float(row[4]) if row[4] is not None else 0 except (ValueError, TypeError): max_conf = 0 by_person.append({ 'person': row[0], 'count': row[1], 'avg_confidence': round(avg_conf, 2), 'min_confidence': round(min_conf, 2), 'max_confidence': round(max_conf, 2) }) # Confidence distribution cursor.execute(""" SELECT CASE WHEN confidence < 0.2 THEN '0-20%' WHEN confidence < 0.4 THEN '20-40%' WHEN confidence < 0.6 THEN '40-60%' WHEN confidence < 0.8 THEN '60-80%' ELSE '80-100%' END as confidence_range, COUNT(*) as count FROM face_recognition_scans WHERE has_match = 1 AND confidence IS NOT NULL GROUP BY confidence_range ORDER BY confidence_range """) confidence_distribution = {row[0]: row[1] for row in cursor.fetchall()} # Recent matches (last 20) cursor.execute(""" SELECT f.file_path, f.matched_person, f.confidence, f.scan_date, d.source FROM face_recognition_scans f LEFT JOIN downloads d ON f.download_id = d.id WHERE f.has_match = 1 ORDER BY f.scan_date DESC LIMIT 20 """) recent_matches = [] for row in cursor.fetchall(): try: conf = float(row[2]) if row[2] is not None else 0 except (ValueError, TypeError): conf = 0 source = row[4] if not source: file_path = row[0] if file_path: parts = file_path.split('/') for part in parts: if part.lower() in ['instagram', 'tiktok', 'youtube', 'twitter', 'reddit', 'imgur', 'toolzu', 'fastdl']: source = part.lower() break if not source and 'forums' in file_path.lower(): if '/forums/' in file_path: forum_parts = file_path.split('/forums/') if len(forum_parts) > 1: forum_name = forum_parts[1].split('/')[0] source = f'forum:{forum_name}' recent_matches.append({ 'file_path': row[0], 'person': row[1], 'confidence': round(conf, 2), 'scan_date': row[3], 'source': source if source else 'unknown' }) # Scans over time (last 30 days) cursor.execute(""" SELECT DATE(scan_date) as scan_day, COUNT(*) as total_scans, SUM(CASE WHEN has_match = 1 THEN 1 ELSE 0 END) as matches FROM face_recognition_scans WHERE scan_date >= DATE('now', '-30 days') GROUP BY scan_day ORDER BY scan_day """) scans_over_time = [] for row in cursor.fetchall(): scans_over_time.append({ 'date': row[0], 'total_scans': row[1], 'matches': row[2], 'match_rate': round((row[2] / row[1] * 100) if row[1] > 0 else 0, 1) }) match_rate = 0 if totals[0] > 0: match_rate = round((totals[1] / totals[0]) * 100, 1) return { 'total_scans': totals[0] or 0, 'total_matches': totals[1] or 0, 'total_no_matches': totals[2] or 0, 'match_rate': match_rate, 'by_person': by_person, 'confidence_distribution': confidence_distribution, 'recent_matches': recent_matches, 'scans_over_time': scans_over_time } # ============================================================================ # ADD REFERENCE FROM MEDIA (WITH BACKGROUND PROCESSING) # ============================================================================ @router.post("/add-reference") @limiter.limit("20/minute") @handle_exceptions async def add_face_reference( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user), add_data: AddFaceReferenceRequest = Body(...) ): """Add any file as a face recognition reference.""" logger.info(f"API: add_face_reference called for {add_data.file_path}, background={add_data.background}", module="FaceRecognition") # Security: Validate path is within allowed directories (prevent path traversal) source_path = validate_file_path_for_face(add_data.file_path) # Normalize path - handle non-breaking spaces in filesystem if not source_path.exists(): normalized_str = str(source_path).replace(' ', '\u00a0') normalized_path = Path(normalized_str) if normalized_path.exists(): # Re-validate the normalized path too source_path = validate_file_path_for_face(str(normalized_path)) else: # Security: Don't expose full path in error message raise NotFoundError("File not found") app_state = get_app_state() # Get person name from settings if not provided person_name = add_data.person_name if not person_name: settings = app_state.settings.get('face_recognition', {}) person_name = settings.get('person_name', 'Unknown') if add_data.background: async def background_task(): try: logger.info(f"BACKGROUND_TASK: Waiting for semaphore for {source_path}", module="FaceRecognition") async with app_state.face_recognition_semaphore: logger.info(f"BACKGROUND_TASK: Acquired semaphore, starting for {source_path}", module="FaceRecognition") process = await asyncio.create_subprocess_exec( '/opt/media-downloader/venv/bin/python3', '/opt/media-downloader/scripts/add_reference_face.py', person_name, str(source_path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=120) returncode = process.returncode logger.info(f"BACKGROUND_TASK: Process completed with code {returncode}", module="FaceRecognition") if returncode == 0: with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute('SELECT id FROM face_recognition_scans WHERE file_path = ?', (str(source_path),)) if cursor.fetchone(): cursor.execute(''' UPDATE face_recognition_scans SET has_match = 1, matched_person = ?, confidence = NULL, scan_date = CURRENT_TIMESTAMP WHERE file_path = ? ''', (person_name, str(source_path))) else: cursor.execute(''' INSERT INTO face_recognition_scans (file_path, has_match, matched_person, confidence, face_count, scan_type, scan_date) VALUES (?, 1, ?, NULL, 1, 'manual_reference', CURRENT_TIMESTAMP) ''', (str(source_path), person_name)) if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "face_reference_added", "file_path": str(source_path), "filename": source_path.name, "person_name": person_name, "success": True }) else: stderr_str = stderr.decode() if stderr else '' stdout_str = stdout.decode() if stdout else '' full_error = stderr_str.strip() or stdout_str.strip() or "Failed" error_msg = "Processing failed" for line in full_error.split('\n'): if '[FaceRecognition]' in line: parts = line.split('[FaceRecognition]') if len(parts) > 1: error_msg = parts[1].strip() if ':' in error_msg: error_msg = error_msg.split(':')[0].strip() break with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute('SELECT id FROM face_recognition_scans WHERE file_path = ?', (str(source_path),)) if cursor.fetchone(): cursor.execute(''' UPDATE face_recognition_scans SET has_match = 0, matched_person = NULL, confidence = NULL, scan_date = CURRENT_TIMESTAMP WHERE file_path = ? ''', (str(source_path),)) else: cursor.execute(''' INSERT INTO face_recognition_scans (file_path, has_match, matched_person, confidence, face_count, scan_type, scan_date) VALUES (?, 0, NULL, NULL, 0, 'manual_reference_failed', CURRENT_TIMESTAMP) ''', (str(source_path),)) if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "face_reference_added", "file_path": str(source_path), "filename": source_path.name, "person_name": person_name, "success": False, "error": error_msg }) except Exception as e: logger.error(f"BACKGROUND_TASK: Exception for {source_path}: {e}", module="FaceRecognition") if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "face_reference_added", "file_path": str(source_path), "filename": source_path.name, "person_name": person_name, "success": False, "error": str(e) }) asyncio.create_task(background_task()) return { "success": True, "message": f"Processing {source_path.name}...", "person_name": person_name, "file_path": str(source_path) } else: # Run synchronously await asyncio.sleep(1.0) process = await asyncio.create_subprocess_exec( '/opt/media-downloader/venv/bin/python3', '/opt/media-downloader/scripts/add_reference_face.py', person_name, str(source_path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=120) returncode = process.returncode if returncode != 0: stderr_str = stderr.decode() if stderr else '' stdout_str = stdout.decode() if stdout else '' error_msg = stderr_str.strip() or stdout_str.strip() or "Failed to add face reference" raise HTTPException(status_code=500, detail=error_msg) with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute('SELECT id FROM face_recognition_scans WHERE file_path = ?', (str(source_path),)) if cursor.fetchone(): cursor.execute(''' UPDATE face_recognition_scans SET has_match = 1, matched_person = ?, confidence = NULL, scan_date = CURRENT_TIMESTAMP WHERE file_path = ? ''', (person_name, str(source_path))) else: cursor.execute(''' INSERT INTO face_recognition_scans (file_path, has_match, matched_person, confidence, face_count, scan_type, scan_date) VALUES (?, 1, ?, NULL, 1, 'manual_reference', CURRENT_TIMESTAMP) ''', (str(source_path), person_name)) return { "success": True, "message": f"Added {source_path.name} as face reference for {person_name}", "person_name": person_name, "file_path": str(source_path) } # ============================================================================ # BATCH ADD REFERENCE # ============================================================================ @router.post("/batch-add-reference") @limiter.limit("10/minute") @handle_exceptions async def batch_add_face_reference( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user), batch_data: BatchAddFaceReferenceRequest = Body(...) ): """Batch add files as face recognition references - runs in background with WebSocket progress.""" file_paths = batch_data.file_paths # Security: Limit batch size to prevent DoS MAX_BATCH_SIZE = 100 # Lower limit for face recognition (CPU intensive) if len(file_paths) > MAX_BATCH_SIZE: raise ValidationError(f"Batch size exceeds maximum of {MAX_BATCH_SIZE} files") # Security: Pre-validate all paths before starting batch (prevent path traversal) validated_paths = [] for fp in file_paths: try: validated_paths.append(str(validate_file_path_for_face(fp))) except ValidationError: logger.warning(f"Batch add: Path validation failed for {fp}", module="Security") continue if not validated_paths: raise ValidationError("No valid file paths provided") file_paths = validated_paths app_state = get_app_state() person_name = batch_data.person_name if not person_name: settings = app_state.settings.get('face_recognition', {}) person_name = settings.get('person_name', 'Unknown') async def batch_task(): logger.info(f"BATCH_TASK_STARTED: Processing {len(file_paths)} files", module="FaceRecognition") total = len(file_paths) succeeded = 0 failed = 0 manager = getattr(app_state, 'websocket_manager', None) if manager: await manager.broadcast({ "type": "batch_face_reference_started", "total": total }) for idx, file_path_str in enumerate(file_paths, 1): source_path = Path(file_path_str) if not source_path.exists(): normalized_str = str(source_path).replace(' ', '\u00a0') normalized_path = Path(normalized_str) if normalized_path.exists(): source_path = normalized_path filename = source_path.name if manager: await manager.broadcast({ "type": "batch_face_reference_progress", "filename": filename, "file_path": file_path_str, "current": idx, "total": total, "status": "processing" }) try: if not source_path.exists(): raise Exception("File not found") process = await asyncio.create_subprocess_exec( 'nice', '-n', '19', '/opt/media-downloader/venv/bin/python3', '/opt/media-downloader/scripts/add_reference_face.py', person_name, str(source_path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=120) returncode = process.returncode if returncode == 0: with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute('SELECT id FROM face_recognition_scans WHERE file_path = ?', (str(source_path),)) if cursor.fetchone(): cursor.execute(''' UPDATE face_recognition_scans SET has_match = 1, matched_person = ?, confidence = NULL, scan_date = CURRENT_TIMESTAMP WHERE file_path = ? ''', (person_name, str(source_path))) else: cursor.execute(''' INSERT INTO face_recognition_scans (file_path, has_match, matched_person, confidence, face_count, scan_type, scan_date) VALUES (?, 1, ?, NULL, 1, 'manual_reference', CURRENT_TIMESTAMP) ''', (str(source_path), person_name)) succeeded += 1 if manager: await manager.broadcast({ "type": "batch_face_reference_progress", "filename": filename, "file_path": file_path_str, "current": idx, "total": total, "status": "success" }) else: failed += 1 stderr_str = stderr.decode() if stderr else '' stdout_str = stdout.decode() if stdout else '' error_msg = stderr_str.strip() or stdout_str.strip() or "Failed" if manager: await manager.broadcast({ "type": "batch_face_reference_progress", "filename": filename, "file_path": file_path_str, "current": idx, "total": total, "status": "error", "error": error_msg }) except Exception as e: failed += 1 if manager: await manager.broadcast({ "type": "batch_face_reference_progress", "filename": filename, "file_path": file_path_str, "current": idx, "total": total, "status": "error", "error": str(e) }) logger.info(f"BATCH_TASK: COMPLETE - {succeeded} succeeded, {failed} failed", module="FaceRecognition") if manager: await manager.broadcast({ "type": "batch_face_reference_complete", "succeeded": succeeded, "failed": failed, "total": total }) background_tasks.add_task(batch_task) return { "success": True, "message": f"Processing {len(file_paths)} files in background...", "total": len(file_paths) } # ============================================================================ # RETROACTIVE SCAN # ============================================================================ @router.post("/retroactive-scan") @limiter.limit("1/minute") @handle_exceptions async def retroactive_face_scan( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user), scan_data: RetroactiveScanRequest = Body(...) ): """Start a retroactive face recognition scan with live progress updates.""" app_state = get_app_state() async def run_scan(): try: with app_state.db.get_connection() as conn: cursor = conn.cursor() query = ''' SELECT DISTINCT dm.file_path, dm.filename FROM download_metadata dm LEFT JOIN face_recognition_scans frs ON dm.file_path = frs.file_path WHERE dm.media_type IN ('image', 'video') AND (frs.id IS NULL OR frs.has_match = 0) ''' if scan_data.directory: query += ' AND dm.file_path LIKE ?' cursor.execute(query, (f'%{scan_data.directory}%',)) else: cursor.execute(query) files_to_scan = cursor.fetchall() total_files = len(files_to_scan) manager = getattr(app_state, 'websocket_manager', None) if manager: await manager.broadcast({ "type": "face_scan_started", "total_files": total_files, "timestamp": now_iso8601() }) face_module = get_face_module(app_state.db) stats = { 'total': total_files, 'matched': 0, 'unmatched': 0, 'errors': 0, 'processed': 0 } for idx, file_row in enumerate(files_to_scan, 1): file_path = file_row[0] filename = file_row[1] try: if manager: await manager.broadcast({ "type": "face_scan_progress", "file": filename, "file_path": file_path, "current": idx, "total": total_files, "status": "processing", "timestamp": now_iso8601() }) if not Path(file_path).exists(): stats['errors'] += 1 if manager: await manager.broadcast({ "type": "face_scan_progress", "file": filename, "file_path": file_path, "current": idx, "total": total_files, "status": "error", "error": "File not found", "timestamp": now_iso8601() }) continue result = face_module.scan_file(file_path) if result.get('has_match'): stats['matched'] += 1 if manager: await manager.broadcast({ "type": "face_scan_progress", "file": filename, "file_path": file_path, "current": idx, "total": total_files, "status": "matched", "person": result.get('person_name', 'Unknown'), "confidence": result.get('confidence', 0), "timestamp": now_iso8601() }) else: stats['unmatched'] += 1 if manager: await manager.broadcast({ "type": "face_scan_progress", "file": filename, "file_path": file_path, "current": idx, "total": total_files, "status": "no_match", "timestamp": now_iso8601() }) stats['processed'] += 1 # Periodic memory cleanup to prevent OOM during long scans if stats['processed'] % 50 == 0: import gc gc.collect() except Exception as e: stats['errors'] += 1 if manager: await manager.broadcast({ "type": "face_scan_progress", "file": filename, "file_path": file_path, "current": idx, "total": total_files, "status": "error", "error": str(e), "timestamp": now_iso8601() }) if manager: await manager.broadcast({ "type": "face_scan_complete", "stats": stats, "timestamp": now_iso8601() }) except Exception as e: if manager: await manager.broadcast({ "type": "face_scan_error", "error": str(e), "timestamp": now_iso8601() }) background_tasks.add_task(run_scan) return { "success": True, "message": "Face recognition scan started" } # ============================================================================ # IMMICH INTEGRATION ENDPOINTS # ============================================================================ def _get_immich_integration(): """Get configured Immich integration instance.""" from modules.immich_face_integration import ImmichFaceIntegration app_state = get_app_state() immich_settings = app_state.settings.get('immich', {}) if not immich_settings.get('enabled'): return None return ImmichFaceIntegration( api_url=immich_settings.get('api_url'), api_key=immich_settings.get('api_key') ) @router.get("/immich/status") @limiter.limit("60/minute") @handle_exceptions async def get_immich_status( request: Request, current_user: Dict = Depends(get_current_user) ): """Get Immich face integration status and connection info.""" app_state = get_app_state() immich_settings = app_state.settings.get('immich', {}) if not immich_settings.get('enabled'): return { 'enabled': False, 'connected': False, 'message': 'Immich integration is disabled' } immich = _get_immich_integration() if not immich or not immich.is_configured: return { 'enabled': True, 'connected': False, 'message': 'Immich API key not configured' } result = immich.test_connection() return { 'enabled': True, 'connected': result['success'], 'message': result['message'], 'server_info': result.get('server_info'), 'api_url': immich_settings.get('api_url') } @router.put("/immich/settings") @limiter.limit("30/minute") @handle_exceptions async def update_immich_settings( request: Request, current_user: Dict = Depends(get_current_user), enabled: bool = Body(None, embed=True) ): """Update Immich integration settings.""" app_state = get_app_state() immich_settings = app_state.settings.get('immich', {}) if enabled is not None: immich_settings['enabled'] = enabled # Save using settings manager app_state.settings.set( key='immich', value=immich_settings, category='face_recognition', description='Immich face recognition integration', updated_by=current_user.get('username', 'user') ) return { 'success': True, 'enabled': immich_settings.get('enabled', False) } @router.get("/immich/statistics") @limiter.limit("30/minute") @handle_exceptions async def get_immich_statistics( request: Request, current_user: Dict = Depends(get_current_user) ): """Get face recognition statistics from Immich.""" immich = _get_immich_integration() if not immich: raise ValidationError('Immich integration is not enabled') if not immich.is_configured: raise ValidationError('Immich API key not configured') stats = immich.get_statistics() return stats @router.get("/immich/people") @limiter.limit("30/minute") @handle_exceptions async def get_immich_people( request: Request, current_user: Dict = Depends(get_current_user), named_only: bool = True ): """Get all people/faces from Immich.""" immich = _get_immich_integration() if not immich: raise ValidationError('Immich integration is not enabled') if not immich.is_configured: raise ValidationError('Immich API key not configured') if named_only: people = immich.get_named_people() else: people = immich.get_all_people() return { 'people': people, 'count': len(people) } class ImmichFaceLookupRequest(BaseModel): file_path: str @router.post("/immich/faces") @limiter.limit("60/minute") @handle_exceptions async def get_immich_faces_for_file( request: Request, current_user: Dict = Depends(get_current_user), lookup: ImmichFaceLookupRequest = Body(...) ): """ Get face recognition data from Immich for a specific file. This queries Immich's face recognition database for any detected and identified faces in the specified file. """ # Validate path file_path = validate_file_path_for_face(lookup.file_path) immich = _get_immich_integration() if not immich: raise ValidationError('Immich integration is not enabled') if not immich.is_configured: raise ValidationError('Immich API key not configured') result = immich.get_faces_for_file(str(file_path)) return result @router.get("/immich/person/{person_name}/assets") @limiter.limit("30/minute") @handle_exceptions async def get_immich_person_assets( request: Request, person_name: str, current_user: Dict = Depends(get_current_user), limit: int = 100 ): """Get assets containing a specific person from Immich.""" immich = _get_immich_integration() if not immich: raise ValidationError('Immich integration is not enabled') if not immich.is_configured: raise ValidationError('Immich API key not configured') # Find person by name person = immich.get_person_by_name(person_name) if not person: raise NotFoundError(f'Person "{person_name}" not found in Immich') # Get assets for person assets = immich.get_person_assets(person['id']) # Convert Immich paths to local paths and limit results local_assets = [] for asset in assets[:limit]: original_path = asset.get('originalPath', '') local_path = immich._immich_to_local_path(original_path) local_assets.append({ 'asset_id': asset.get('id'), 'local_path': local_path, 'immich_path': original_path, 'type': asset.get('type'), 'created_at': asset.get('fileCreatedAt') }) return { 'person_id': person['id'], 'person_name': person['name'], 'assets': local_assets, 'total': len(assets), 'returned': len(local_assets) }