410
modules/immich_face_integration.py
Normal file
410
modules/immich_face_integration.py
Normal file
@@ -0,0 +1,410 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user