Files
media-downloader/modules/immich_face_integration.py
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

411 lines
13 KiB
Python

#!/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