#!/usr/bin/env python3 """ Immich Face Integration Module Integrates with Immich's face recognition system to leverage its existing face clustering and recognition data for media-downloader files. Immich uses: - InsightFace with buffalo_l model (same as media-downloader) - DBSCAN clustering for face grouping - 512-dimensional face embeddings - PostgreSQL for storage Path mapping: - Media-downloader: /opt/immich/md/... - Immich sees: /mnt/media/md/... """ import os import json from pathlib import Path from typing import Optional, List, Dict, Any, Tuple from datetime import datetime import httpx from modules.universal_logger import get_logger logger = get_logger('ImmichFace') class ImmichFaceIntegration: """Interface with Immich's face recognition system.""" # Path mapping between systems LOCAL_BASE = '/opt/immich' IMMICH_BASE = '/mnt/media' def __init__(self, api_url: str = None, api_key: str = None): """ Initialize Immich face integration. Args: api_url: Immich API URL (default: http://localhost:2283/api) api_key: Immich API key """ self.api_url = (api_url or os.getenv('IMMICH_API_URL', 'http://localhost:2283/api')).rstrip('/') self.api_key = api_key or os.getenv('IMMICH_API_KEY', '') self._client = None self._people_cache = None self._people_cache_time = None self._cache_ttl = 300 # 5 minutes @property def is_configured(self) -> bool: """Check if Immich integration is properly configured.""" return bool(self.api_key) def _get_client(self) -> httpx.Client: """Get or create HTTP client.""" if self._client is None: self._client = httpx.Client( base_url=self.api_url, headers={ 'x-api-key': self.api_key, 'Accept': 'application/json' }, timeout=30.0 ) return self._client def _local_to_immich_path(self, local_path: str) -> str: """ Convert local path to Immich's path format. Example: /opt/immich/md/instagram/user/image.jpg -> /mnt/media/md/instagram/user/image.jpg """ return local_path.replace(self.LOCAL_BASE, self.IMMICH_BASE) def _immich_to_local_path(self, immich_path: str) -> str: """ Convert Immich's path to local path format. Example: /mnt/media/md/instagram/user/image.jpg -> /opt/immich/md/instagram/user/image.jpg """ return immich_path.replace(self.IMMICH_BASE, self.LOCAL_BASE) def test_connection(self) -> Dict[str, Any]: """ Test connection to Immich API. Returns: Dict with 'success', 'message', and optionally 'server_info' """ if not self.is_configured: return { 'success': False, 'message': 'Immich API key not configured' } try: client = self._get_client() response = client.get('/server/ping') if response.status_code == 200: # Get server info info_response = client.get('/server/version') server_info = info_response.json() if info_response.status_code == 200 else {} return { 'success': True, 'message': 'Connected to Immich', 'server_info': server_info } else: return { 'success': False, 'message': f'Immich API returned status {response.status_code}' } except httpx.ConnectError as e: return { 'success': False, 'message': f'Cannot connect to Immich at {self.api_url}: {e}' } except Exception as e: return { 'success': False, 'message': f'Immich API error: {e}' } def get_all_people(self, force_refresh: bool = False) -> List[Dict[str, Any]]: """ Get all people/faces from Immich. Returns: List of people with id, name, thumbnailPath, etc. """ if not self.is_configured: return [] # Check cache if not force_refresh and self._people_cache is not None: if self._people_cache_time: age = (datetime.now() - self._people_cache_time).total_seconds() if age < self._cache_ttl: return self._people_cache try: client = self._get_client() response = client.get('/people') if response.status_code == 200: data = response.json() # Immich returns {'people': [...], 'total': N, ...} people = data.get('people', data) if isinstance(data, dict) else data # Cache the result self._people_cache = people self._people_cache_time = datetime.now() logger.info(f"Fetched {len(people)} people from Immich") return people else: logger.error(f"Failed to get people: {response.status_code}") return [] except Exception as e: logger.error(f"Error getting people from Immich: {e}") return [] def get_named_people(self) -> List[Dict[str, Any]]: """ Get only people with names assigned in Immich. Returns: List of named people """ people = self.get_all_people() return [p for p in people if p.get('name')] def get_asset_by_path(self, local_path: str) -> Optional[Dict[str, Any]]: """ Find an Immich asset by its file path. Args: local_path: Local file path (e.g., /opt/immich/md/...) Returns: Asset dict or None if not found """ if not self.is_configured: return None immich_path = self._local_to_immich_path(local_path) try: client = self._get_client() # Search by original path response = client.post('/search/metadata', json={ 'originalPath': immich_path }) if response.status_code == 200: data = response.json() assets = data.get('assets', {}).get('items', []) if assets: return assets[0] return None except Exception as e: logger.error(f"Error searching asset by path: {e}") return None def get_faces_for_asset(self, asset_id: str) -> List[Dict[str, Any]]: """ Get all detected faces for an asset. Args: asset_id: Immich asset ID Returns: List of face data including person info and bounding boxes """ if not self.is_configured: return [] try: client = self._get_client() response = client.get(f'/faces', params={'id': asset_id}) if response.status_code == 200: return response.json() else: logger.warning(f"Failed to get faces for asset {asset_id}: {response.status_code}") return [] except Exception as e: logger.error(f"Error getting faces for asset: {e}") return [] def get_faces_for_file(self, local_path: str) -> Dict[str, Any]: """ Get face recognition results for a local file using Immich. This is the main method for integration - given a local file path, it finds the asset in Immich and returns any detected faces. Args: local_path: Local file path (e.g., /opt/immich/md/...) Returns: Dict with: - found: bool - whether file exists in Immich - faces: list of detected faces with person names - asset_id: Immich asset ID if found """ if not self.is_configured: return { 'found': False, 'error': 'Immich not configured', 'faces': [] } # Find the asset asset = self.get_asset_by_path(local_path) if not asset: return { 'found': False, 'error': 'File not found in Immich', 'faces': [] } asset_id = asset.get('id') # Get faces for the asset faces_data = self.get_faces_for_asset(asset_id) # Process faces into a more usable format faces = [] for face in faces_data: person = face.get('person', {}) faces.append({ 'face_id': face.get('id'), 'person_id': person.get('id'), 'person_name': person.get('name', ''), 'bounding_box': { 'x1': face.get('boundingBoxX1'), 'y1': face.get('boundingBoxY1'), 'x2': face.get('boundingBoxX2'), 'y2': face.get('boundingBoxY2') }, 'image_width': face.get('imageWidth'), 'image_height': face.get('imageHeight') }) # Filter to only named faces named_faces = [f for f in faces if f['person_name']] return { 'found': True, 'asset_id': asset_id, 'faces': faces, 'named_faces': named_faces, 'face_count': len(faces), 'named_count': len(named_faces) } def get_person_by_name(self, name: str) -> Optional[Dict[str, Any]]: """ Find a person in Immich by name. Args: name: Person name to search for Returns: Person dict or None """ people = self.get_all_people() for person in people: if person.get('name', '').lower() == name.lower(): return person return None def get_person_assets(self, person_id: str, limit: int = 1000) -> List[Dict[str, Any]]: """ Get all assets containing a specific person using search API. Args: person_id: Immich person ID limit: Maximum number of assets to return Returns: List of assets """ if not self.is_configured: return [] try: client = self._get_client() # Use the search/metadata endpoint with personIds filter response = client.post('/search/metadata', json={ 'personIds': [person_id], 'size': limit }) if response.status_code == 200: data = response.json() return data.get('assets', {}).get('items', []) else: logger.warning(f"Failed to get assets for person {person_id}: {response.status_code}") return [] except Exception as e: logger.error(f"Error getting person assets: {e}") return [] def get_statistics(self) -> Dict[str, Any]: """ Get Immich face recognition statistics. Returns: Dict with total people, named people, etc. """ people = self.get_all_people() named = [p for p in people if p.get('name')] return { 'total_people': len(people), 'named_people': len(named), 'unnamed_people': len(people) - len(named), 'people_by_face_count': sorted( [{'name': p.get('name', 'Unnamed'), 'count': p.get('faces', 0)} for p in people if p.get('name')], key=lambda x: x['count'], reverse=True )[:20] } def close(self): """Close HTTP client.""" if self._client: self._client.close() self._client = None # Singleton instance _immich_integration = None def get_immich_integration(api_url: str = None, api_key: str = None) -> ImmichFaceIntegration: """ Get or create the Immich face integration instance. Args: api_url: Optional API URL override api_key: Optional API key override Returns: ImmichFaceIntegration instance """ global _immich_integration if _immich_integration is None: _immich_integration = ImmichFaceIntegration(api_url, api_key) elif api_key and api_key != _immich_integration.api_key: # Recreate if API key changed _immich_integration.close() _immich_integration = ImmichFaceIntegration(api_url, api_key) return _immich_integration