411 lines
13 KiB
Python
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
|