1249 lines
45 KiB
Python
1249 lines
45 KiB
Python
"""
|
|
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)
|
|
}
|