""" Review Router Handles review queue operations: - List files in review queue - Filter options for review - Keep/delete/batch operations - Face recognition rescan - File serving for preview - Add reference images """ import asyncio import hashlib import json import mimetypes import re import shutil from datetime import datetime from pathlib import Path from typing import Dict, List, Optional from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_current_user_media, get_app_state from ..core.config import settings from ..core.exceptions import ( handle_exceptions, RecordNotFoundError, MediaFileNotFoundError as CustomFileNotFoundError, FileOperationError, ValidationError ) from ..core.responses import now_iso8601 from modules.universal_logger import get_logger from modules.date_utils import DateHandler from ..core.utils import get_media_dimensions, get_media_dimensions_batch logger = get_logger('API') router = APIRouter(prefix="/api/review", tags=["Review Queue"]) limiter = Limiter(key_func=get_remote_address) # Import shared utilities from ..core.utils import get_face_module as _get_face_module, update_file_path_in_all_tables def get_face_module(db): """Get or create a cached FaceRecognitionModule instance for the given database.""" return _get_face_module(db, module_name="Review") REVIEW_BASE = Path("/opt/immich/review") MAX_BATCH_SIZE = 500 # Maximum files per batch operation MEDIA_BASE = Path("/opt/immich/md") # ============================================================================ # PYDANTIC MODELS # ============================================================================ class ReviewKeepRequest(BaseModel): file_path: str destination: Optional[str] = None class ReviewAddRequest(BaseModel): file_path: str person_name: str destination: Optional[str] = None # NOTE: update_file_path_in_all_tables is imported from core.utils # ============================================================================ # LIST AND FILTER ENDPOINTS # ============================================================================ @router.get("/list") @limiter.limit("5000/minute") @handle_exceptions async def get_review_queue( request: Request, current_user: Dict = Depends(get_current_user), limit: int = Query(50, ge=1, le=500, description="Max items to return (1-500)"), offset: int = Query(0, ge=0, description="Number of items to skip"), platform: Optional[str] = None, source: Optional[str] = None, media_type: str = Query("all", pattern="^(all|image|video)$"), sort_by: str = Query("post_date", pattern="^(post_date|created_date|added_date|file_size|filename|source|platform|confidence)$"), sort_order: str = Query("desc", pattern="^(asc|desc)$"), date_from: Optional[str] = Query(None, pattern="^\\d{4}-\\d{2}-\\d{2}$", description="Filter by post date from (YYYY-MM-DD)"), date_to: Optional[str] = Query(None, pattern="^\\d{4}-\\d{2}-\\d{2}$", description="Filter by post date to (YYYY-MM-DD)"), size_min: Optional[int] = Query(None, ge=0, description="Minimum file size in bytes"), size_max: Optional[int] = Query(None, ge=0, description="Maximum file size in bytes"), search: Optional[str] = Query(None, max_length=200, description="Search filename") ): """Get list of media files in review queue from file_inventory.""" app_state = get_app_state() with app_state.db.get_connection() as conn: cursor = conn.cursor() query = ''' SELECT fi.id, fi.file_path, fi.filename, fi.platform, fi.source, fi.content_type, fi.file_size, fi.width, fi.height, fi.created_date, fi.last_verified, COALESCE(d_max.max_post_date, fi.created_date) as post_date, fi.video_id, COALESCE(frs.confidence, -1) as face_confidence, frs.has_match, frs.matched_person, frs.scan_date FROM file_inventory fi LEFT JOIN ( SELECT file_path, MAX(post_date) as max_post_date FROM downloads GROUP BY file_path ) d_max ON d_max.file_path = fi.file_path LEFT JOIN face_recognition_scans frs ON frs.file_path = fi.file_path WHERE fi.location = 'review' ''' params = [] if platform: query += ' AND fi.platform = ?' params.append(platform) if source: query += ' AND fi.source = ?' params.append(source) if media_type != "all": query += ' AND fi.content_type = ?' params.append(media_type) if date_from: query += ' AND DATE(COALESCE(d_max.max_post_date, fi.created_date)) >= ?' params.append(date_from) if date_to: query += ' AND DATE(COALESCE(d_max.max_post_date, fi.created_date)) <= ?' params.append(date_to) if size_min is not None: query += ' AND fi.file_size >= ?' params.append(size_min) if size_max is not None: query += ' AND fi.file_size <= ?' params.append(size_max) if search: search_term = f'%{search}%' query += ' AND (fi.filename LIKE ? OR fi.platform LIKE ? OR fi.source LIKE ? OR fi.content_type LIKE ?)' params.extend([search_term, search_term, search_term, search_term]) # Count query count_query = ''' SELECT COUNT(*) FROM file_inventory fi LEFT JOIN ( SELECT file_path, MAX(post_date) as max_post_date FROM downloads GROUP BY file_path ) d_max ON d_max.file_path = fi.file_path WHERE fi.location = 'review' ''' count_params = [] if platform: count_query += ' AND fi.platform = ?' count_params.append(platform) if source: count_query += ' AND fi.source = ?' count_params.append(source) if media_type != "all": count_query += ' AND fi.content_type = ?' count_params.append(media_type) if date_from: count_query += ' AND DATE(COALESCE(d_max.max_post_date, fi.created_date)) >= ?' count_params.append(date_from) if date_to: count_query += ' AND DATE(COALESCE(d_max.max_post_date, fi.created_date)) <= ?' count_params.append(date_to) if size_min is not None: count_query += ' AND fi.file_size >= ?' count_params.append(size_min) if size_max is not None: count_query += ' AND fi.file_size <= ?' count_params.append(size_max) if search: search_term = f'%{search}%' count_query += ' AND (fi.filename LIKE ? OR fi.platform LIKE ? OR fi.source LIKE ? OR fi.content_type LIKE ?)' count_params.extend([search_term, search_term, search_term, search_term]) cursor.execute(count_query, count_params) total = cursor.fetchone()[0] # Sorting field_mapping = { 'post_date': 'post_date', 'added_date': 'fi.last_verified', 'file_size': 'fi.file_size', 'filename': 'fi.filename', 'source': 'fi.source', 'platform': 'fi.platform', 'confidence': 'face_confidence' } db_sort_field = field_mapping.get(sort_by, 'post_date') sort_direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC' if db_sort_field in ['post_date', 'fi.last_verified']: query += f' ORDER BY {db_sort_field} {sort_direction} NULLS LAST' else: query += f' ORDER BY {db_sort_field} {sort_direction}' query += ' LIMIT ? OFFSET ?' params.extend([limit, offset]) cursor.execute(query, params) rows = cursor.fetchall() # Batch lookup dimensions for items missing width/height paths_needing_dimensions = [row[1] for row in rows if row[7] is None or row[8] is None] dimensions_cache = get_media_dimensions_batch(paths_needing_dimensions) if paths_needing_dimensions else {} files = [] for row in rows: # Use DB values if available, else check batch cache if row[7] is not None and row[8] is not None: width, height = row[7], row[8] else: width, height = dimensions_cache.get(row[1], (row[7], row[8])) file_item = { "filename": row[2], "file_path": row[1], "file_size": row[6] if row[6] else 0, "added_date": row[10] if row[10] else '', "post_date": row[11] if row[11] else '', "platform": row[3], "source": row[4] if row[4] else 'unknown', "width": width, "height": height, "video_id": row[12] # For YouTube thumbnail support } # Add face recognition data from JOIN query # row[12]=video_id, row[13]=face_confidence, row[14]=has_match, row[15]=matched_person, row[16]=scan_date face_confidence = row[13] if row[13] is not None else -1 has_match = row[14] matched_person = row[15] scan_date = row[16] if face_confidence >= 0: # Has face scan data (confidence >= 0 means scan exists) file_item['face_recognition'] = { 'scanned': True, 'matched': bool(has_match), 'person_name': matched_person, 'confidence': face_confidence, 'scan_date': scan_date } else: file_item['face_recognition'] = {'scanned': False} files.append(file_item) return {"files": files, "total": total} @router.get("/filters") @limiter.limit("5000/minute") @handle_exceptions async def get_review_filters( request: Request, platform: Optional[str] = Query(None, description="Filter sources by platform"), current_user: Dict = Depends(get_current_user) ): """Get available filter options for review queue.""" from cache_manager import cache_manager cache_key = f"filters:review_filters:{platform or 'all'}" cached_result = cache_manager.get(cache_key) if cached_result: return cached_result app_state = get_app_state() with app_state.db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT DISTINCT platform FROM file_inventory WHERE location = 'review' ORDER BY platform """) platforms = sorted([row[0] for row in cursor.fetchall()]) if platform: cursor.execute(""" SELECT DISTINCT source FROM file_inventory WHERE location = 'review' AND source IS NOT NULL AND source != 'unknown' AND platform = ? ORDER BY source """, (platform.lower(),)) else: cursor.execute(""" SELECT DISTINCT source FROM file_inventory WHERE location = 'review' AND source IS NOT NULL AND source != 'unknown' ORDER BY source """) sources = [row[0] for row in cursor.fetchall()] result = { "platforms": platforms, "sources": sources } cache_manager.set(cache_key, result) return result @router.get("/all-paths") @limiter.limit("5000/minute") @handle_exceptions async def get_all_review_paths( request: Request, current_user: Dict = Depends(get_current_user) ): """Get all file paths in review queue (no pagination).""" app_state = get_app_state() with app_state.db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT file_path FROM file_inventory WHERE location = 'review' ORDER BY created_date DESC """) file_paths = [row[0] for row in cursor.fetchall()] return {"file_paths": file_paths} # ============================================================================ # FILE OPERATIONS # ============================================================================ def process_review_keep_background(file_path: str, destination_hint: str, filename: str, app_state): """Background task to move review file to final destination.""" try: source_path = Path(file_path) destination = destination_hint # If no destination provided, try to load from database metadata if not destination: try: with app_state.db.get_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT metadata FROM downloads WHERE file_path = ? ''', (str(source_path),)) row = cursor.fetchone() if row and row['metadata']: metadata = json.loads(row['metadata']) intended_path = metadata.get('intended_path') if intended_path: destination = intended_path except Exception as e: logger.warning(f"Could not load intended path from database: {e}", module="Database") # Fallback: infer destination from current path structure if not destination: try: # Use relative_to() for safe path validation (prevents symlink bypass) source_path.resolve().relative_to(REVIEW_BASE.resolve()) relative_path = source_path.relative_to(REVIEW_BASE) # For Instagram/TikTok, add username subdirectory from database dest_base = MEDIA_BASE / relative_path.parent # Get username (source) from database try: with app_state.db.get_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT source, platform FROM file_inventory WHERE file_path = ?', (str(source_path),)) row = cursor.fetchone() if row and row[0] and row[0] != 'unknown': username = row[0] platform = row[1] if row[1] else '' if platform in ('instagram', 'tiktok') or 'instagram' in str(relative_path).lower() or 'tiktok' in str(relative_path).lower(): dest_base = dest_base / username except Exception as e: logger.warning(f"Could not get source from database: {e}", module="Database") destination = str(dest_base / source_path.name) except ValueError: pass # Path not in REVIEW_BASE except Exception as e: logger.warning(f"Could not infer destination: {e}", module="Database") if not destination: try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: app_state.websocket_manager.broadcast_sync({ "type": "batch_move_progress", "filename": filename, "success": False, "error": "Destination required (no original path found)" }) except Exception as e: logger.debug(f"Failed to broadcast error status: {e}", module="Review") return dest_path = Path(destination) if not dest_path.is_absolute(): dest_path = MEDIA_BASE / destination if dest_path.name != source_path.name: dest_path = dest_path / source_path.name dest_path.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(source_path), str(dest_path)) # Update file timestamps and EXIF to match post date from filename try: date_match = re.match(r'^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})_', filename) if date_match: file_date = datetime( int(date_match.group(1)), # year int(date_match.group(2)), # month int(date_match.group(3)), # day int(date_match.group(4)), # hour int(date_match.group(5)), # minute int(date_match.group(6)) # second ) DateHandler.update_file_timestamps(dest_path, file_date) logger.debug(f"Updated file timestamps for {filename} to {file_date}", module="API") except Exception as e: logger.debug(f"Could not update file timestamps for {filename}: {e}", module="API") # Update file_inventory try: success = app_state.db.update_file_inventory_location( file_path=str(source_path), new_location='final', new_file_path=str(dest_path) ) if success: logger.info(f"Updated file_inventory: {filename} location changed from 'review' to 'final'", module="API") app_state.db.queue_file_for_discovery(str(dest_path), ['embedding'], priority=5) except Exception as e: logger.warning(f"Failed to update file_inventory for {filename}: {e}", module="API") update_file_path_in_all_tables(app_state.db, str(source_path), str(dest_path)) # Create downloads record if none exists (for files that came from review queue) try: with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Check if downloads record exists cursor.execute('SELECT id FROM downloads WHERE file_path = ?', (str(dest_path),)) if not cursor.fetchone(): # Get file_inventory info for platform/source and created_date cursor.execute('SELECT platform, source, created_date FROM file_inventory WHERE file_path = ?', (str(dest_path),)) fi_row = cursor.fetchone() platform = fi_row[0] if fi_row else 'unknown' source = fi_row[1] if fi_row else 'unknown' original_created_date = fi_row[2] if fi_row else None # Extract post_date from filename (format: YYYYMMDD_HHMMSS_...) post_date = None date_match = re.match(r'^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})_', filename) if date_match: try: # Use ISO format with T separator for consistent timezone handling in frontend post_date = f"{date_match.group(1)}-{date_match.group(2)}-{date_match.group(3)}T{date_match.group(4)}:{date_match.group(5)}:{date_match.group(6)}" except Exception as e: logger.debug(f"Could not parse date from filename {filename}: {e}", module="Review") # Fallback to file modification time if not post_date: try: mtime = dest_path.stat().st_mtime # Use ISO format with T separator for consistent timezone handling post_date = datetime.fromtimestamp(mtime).strftime('%Y-%m-%dT%H:%M:%S') except Exception as e: logger.debug(f"Could not get mtime for {dest_path}: {e}", module="Review") # Insert downloads record, preserving original download date from file_inventory url_hash = hashlib.sha256(str(dest_path).encode()).hexdigest() # Use original created_date from file_inventory as download_date (when file was first added) if original_created_date: if isinstance(original_created_date, datetime): download_date = original_created_date.strftime('%Y-%m-%dT%H:%M:%S') else: download_date = str(original_created_date).replace(' ', 'T') else: download_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%S') cursor.execute(''' INSERT INTO downloads (url_hash, url, platform, source, filename, file_path, post_date, download_date, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'completed') ''', (url_hash, str(dest_path), platform, source, filename, str(dest_path), post_date, download_date)) conn.commit() logger.info(f"Created downloads record for {filename} with post_date={post_date}, download_date={download_date}", module="API") except Exception as e: logger.warning(f"Failed to create downloads record for {filename}: {e}", module="API") try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: app_state.websocket_manager.broadcast_sync({ "type": "batch_move_progress", "filename": filename, "success": True, "destination": str(dest_path) }) except Exception: pass except Exception as e: try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: app_state.websocket_manager.broadcast_sync({ "type": "batch_move_progress", "filename": filename, "success": False, "error": str(e) }) except Exception: pass @router.post("/keep") @limiter.limit("1000/minute") @handle_exceptions async def keep_review_file( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user), keep_data: ReviewKeepRequest = Body(...) ): """Move file from review queue to final destination.""" app_state = get_app_state() source_path = Path(keep_data.file_path) # Use relative_to() for safe path validation (prevents symlink bypass) try: source_path.resolve().relative_to(REVIEW_BASE.resolve()) except ValueError: raise ValidationError("Source must be in review directory") if not source_path.exists(): raise CustomFileNotFoundError("File not found", {"path": str(source_path)}) filename = source_path.name background_tasks.add_task( process_review_keep_background, keep_data.file_path, keep_data.destination or '', filename, app_state ) return { "success": True, "processing": True, "message": "File move started, processing in background" } @router.post("/batch-delete") @limiter.limit("100/minute") @handle_exceptions async def batch_delete_review_files( request: Request, current_user: Dict = Depends(get_current_user), file_paths: List[str] = Body(..., embed=True) ): """Batch delete files from review queue. Maximum 500 files per request.""" # Security: Limit batch size to prevent DoS if len(file_paths) > MAX_BATCH_SIZE: raise ValidationError(f"Batch size exceeds maximum of {MAX_BATCH_SIZE} files") app_state = get_app_state() succeeded = 0 failed = 0 errors = [] for file_path in file_paths: try: file_to_delete = Path(file_path) # Use relative_to() for safe path validation (prevents symlink bypass) try: file_to_delete.resolve().relative_to(REVIEW_BASE.resolve()) except ValueError: failed += 1 errors.append(f"{file_path}: Not in review directory") continue if not file_to_delete.exists(): with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM file_inventory WHERE file_path = ?", (str(file_to_delete),)) failed += 1 continue recycle_id = app_state.db.move_to_recycle_bin( file_path=str(file_to_delete), deleted_from='review', deleted_by=current_user.get('sub'), metadata={} ) if recycle_id: succeeded += 1 else: failed += 1 errors.append(f"{file_path}: Failed to move to recycle bin") except Exception as e: failed += 1 errors.append(f"{file_path}: {str(e)}") # Broadcast try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_batch_delete_completed", "succeeded": succeeded, "failed": failed }) except Exception: pass return { "success": True, "succeeded": succeeded, "failed": failed, "errors": errors[:10] } @router.delete("/delete") @limiter.limit("1000/minute") @handle_exceptions async def delete_review_file( request: Request, current_user: Dict = Depends(get_current_user), file_path: str = Body(..., embed=True) ): """Move file from review queue to recycle bin.""" app_state = get_app_state() file_to_delete = Path(file_path) # Use relative_to() for safe path validation (prevents symlink bypass) try: file_to_delete.resolve().relative_to(REVIEW_BASE.resolve()) except ValueError: raise ValidationError("File must be in review directory") if not file_to_delete.exists(): with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM file_inventory WHERE file_path = ?", (str(file_to_delete),)) conn.commit() raise CustomFileNotFoundError("File not found (database record removed)") recycle_id = app_state.db.move_to_recycle_bin( file_path=str(file_to_delete), deleted_from='review', deleted_by=current_user.get('sub'), metadata={} ) if not recycle_id: raise FileOperationError("Failed to move to recycle bin") # Broadcast try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_delete_completed", "file": file_path }) except Exception: pass return {"success": True} @router.get("/file") @limiter.limit("5000/minute") @handle_exceptions async def get_review_file( request: Request, file_path: str, token: str = None, current_user: Dict = Depends(get_current_user_media) ): """Serve a file from the review queue.""" requested_file = Path(file_path) try: resolved_file = requested_file.resolve() # Use relative_to() for safe path validation (prevents symlink bypass) resolved_file.relative_to(REVIEW_BASE.resolve()) except ValueError: raise ValidationError("File must be in review directory") except Exception: raise ValidationError("Invalid file path") if not resolved_file.exists() or not resolved_file.is_file(): raise CustomFileNotFoundError("File not found") mime_type, _ = mimetypes.guess_type(str(resolved_file)) if not mime_type: mime_type = "application/octet-stream" return FileResponse( path=str(resolved_file), media_type=mime_type, filename=resolved_file.name ) # ============================================================================ # FACE RECOGNITION RESCAN # ============================================================================ @router.post("/rescan") @limiter.limit("10/minute") @handle_exceptions async def rescan_review_queue( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user) ): """Rescan all files in review queue for face recognition.""" app_state = get_app_state() # Get all files in review queue all_files = [] for ext in ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.mp4', '*.mov', '*.webm', '*.avi', '*.mkv']: all_files.extend(REVIEW_BASE.rglob(ext)) all_files.extend(REVIEW_BASE.rglob(ext.upper())) if not all_files: return {"success": True, "message": "No files to scan", "total": 0} # Filter out already scanned files (include those with confidence OR has_match set) with app_state.db.get_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT file_path FROM face_recognition_scans WHERE confidence IS NOT NULL OR has_match IS NOT NULL') already_scanned = {row[0] for row in cursor.fetchall()} files_to_scan = [f for f in all_files if str(f) not in already_scanned] skipped_count = len(all_files) - len(files_to_scan) total_files = len(files_to_scan) if total_files == 0: return {"success": True, "message": f"All {skipped_count} files already scanned", "total": 0, "skipped": skipped_count} # Get settings fr_settings = app_state.settings.get('face_recognition', {}) base_tolerance = float(fr_settings.get('tolerance', 0.20)) video_tolerance = float(fr_settings.get('video_tolerance', 0.45)) source_tolerances = fr_settings.get('source_tolerances', {}) async def run_rescan(): try: from modules.face_recognition_module import FaceRecognitionModule app_state.review_rescan_running = True app_state.review_rescan_progress = {"current": 0, "total": total_files, "current_file": ""} try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_rescan_started", "total_files": total_files, "timestamp": now_iso8601() }) except Exception: pass face_module = get_face_module(app_state.db) stats = {'total': total_files, 'updated': 0, 'errors': 0, 'processed': 0} video_extensions = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.gif'} for idx, file_path in enumerate(files_to_scan, 1): filename = file_path.name app_state.review_rescan_progress = {"current": idx, "total": total_files, "current_file": filename} try: source = filename.split('_')[0] if '_' in filename else None is_video = file_path.suffix.lower() in video_extensions if source and source in source_tolerances: tolerance = max(source_tolerances[source], video_tolerance) if is_video else source_tolerances[source] else: tolerance = video_tolerance if is_video else base_tolerance # Broadcast progress try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_rescan_progress", "file": filename, "file_path": str(file_path), "current": idx, "total": total_files, "status": "processing", "timestamp": now_iso8601() }) except Exception: pass loop = asyncio.get_event_loop() if is_video: result = await loop.run_in_executor(None, lambda: face_module.check_video_multiframe(str(file_path), tolerance=tolerance)) else: result = await loop.run_in_executor(None, lambda: face_module.check_image(str(file_path), tolerance=tolerance)) # Update database 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(file_path),)) existing = cursor.fetchone() matched_person = result.get('person_name') or result.get('best_candidate') confidence = float(result.get('confidence', 0.0)) if existing: cursor.execute(''' UPDATE face_recognition_scans SET has_match = ?, matched_person = ?, confidence = ?, face_count = ?, scan_date = CURRENT_TIMESTAMP, scan_type = 'rescan' WHERE file_path = ? ''', (result['has_match'], matched_person, confidence, result.get('face_count', 0), str(file_path))) else: cursor.execute(''' INSERT INTO face_recognition_scans (file_path, has_match, matched_person, confidence, face_count, scan_type) VALUES (?, ?, ?, ?, ?, 'rescan') ''', (str(file_path), result['has_match'], matched_person, confidence, result.get('face_count', 0))) conn.commit() stats['updated'] += 1 stats['processed'] += 1 # Broadcast result try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_rescan_progress", "file": filename, "file_path": str(file_path), "current": idx, "total": total_files, "status": "matched" if result.get('has_match') else "updated", "person": matched_person, "confidence": confidence, "timestamp": now_iso8601() }) except Exception: pass except Exception as e: stats['errors'] += 1 stats['processed'] += 1 try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_rescan_progress", "file": filename, "file_path": str(file_path), "current": idx, "total": total_files, "status": "error", "error": str(e), "timestamp": now_iso8601() }) except Exception: pass # Completion app_state.review_rescan_running = False app_state.review_rescan_progress = {"current": 0, "total": 0, "current_file": "", "complete": True, "stats": stats} try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_rescan_complete", "stats": stats, "timestamp": now_iso8601() }) except Exception: pass except Exception as e: app_state.review_rescan_running = False app_state.review_rescan_progress = {"current": 0, "total": 0, "current_file": "", "error": str(e)} try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "review_rescan_error", "error": str(e), "timestamp": now_iso8601() }) except Exception: pass background_tasks.add_task(run_rescan) return { "success": True, "message": f"Rescan started for {total_files} files", "total": total_files } @router.get("/rescan/status") @limiter.limit("100/minute") @handle_exceptions async def get_rescan_status( request: Request, current_user: Dict = Depends(get_current_user) ): """Get the current status of a review rescan operation.""" app_state = get_app_state() return { "running": getattr(app_state, 'review_rescan_running', False), "progress": getattr(app_state, 'review_rescan_progress', {}) } # ============================================================================ # ADD REFERENCE ENDPOINTS # ============================================================================ @router.post("/add-reference") @limiter.limit("100/minute") @handle_exceptions async def add_reference_from_review( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user), add_data: ReviewAddRequest = Body(...) ): """ Add a reference image from review queue and move file to final destination. This is a combined operation that: 1. Extracts face from the file 2. Adds it as a reference for the specified person 3. Moves the file to the final destination """ app_state = get_app_state() source_path = Path(add_data.file_path) # Use relative_to() for safe path validation (prevents symlink bypass) try: source_path.resolve().relative_to(REVIEW_BASE.resolve()) except ValueError: raise ValidationError("Source must be in review directory") if not source_path.exists(): raise CustomFileNotFoundError("File not found") if not add_data.person_name or not add_data.person_name.strip(): raise ValidationError("Person name is required") # Add face reference using cached module face_module = get_face_module(app_state.db) result = face_module.add_reference_image( str(source_path), add_data.person_name.strip(), source_info="review_queue" ) if not result.get('success'): raise FileOperationError( result.get('error', 'Failed to add reference'), {"file": str(source_path)} ) # Queue move to final destination in background filename = source_path.name background_tasks.add_task( process_review_keep_background, add_data.file_path, add_data.destination or '', filename, app_state ) return { "success": True, "message": f"Reference added for {add_data.person_name}", "reference_count": result.get('reference_count', 1), "processing": True }