Files
winnow/winnow/immich_api.py
T
flan 728b84dc8c fix: address 10 full-codebase audit findings (v0.5.19)
Correctness:
- fetch_face_data: only fall back to faces[0] when person_id is absent;
  previously a missing person match injected a different person's bbox
- upload_tracker: change PK from (asset_id, status) to
  (asset_id, person_name, status); old PK allowed INSERT OR REPLACE to
  silently overwrite person_name when the same photo appeared in two
  people's jobs, breaking quality-replacement JOINs; auto-migrates DBs
- filter_recent_assets: treat years=0 as "no age filter" instead of
  falling through to Config.YEARS_FILTER via falsy `or`
- _is_module_available: return find_spec(...) is not None; find_spec
  returns None (not raises) for absent top-level modules, so the
  previous code always returned True
- execute_jobs error handler: use asset.get("id", "<unknown>") to avoid
  a secondary KeyError propagating out of execute_jobs on malformed dicts
- upload_to_frigate: also mark_rejected on HTTP 422, not only HTTP 400
  with "face" in body; other permanent errors left assets untracked and
  retried forever
- reconcile_frigate_mappings: sort key lambda f: (_ts(f), f) makes order
  deterministic when timestamps are equal or 0.0; set iteration order is
  hash-randomised, stable sort preserves it

Reuse / cleanup:
- config.py: add _getenv_optional_int delegating to _getenv_num(name, None, int)
- jobs.py: _resolve_strategy uses _getenv_optional_int("LIMIT") instead
  of inline os.environ.get + int() + warning duplicate of _getenv_num
- frigate_api.py: add _get_frigate_url() helper; eliminates 4× copy of
  os.environ.get("FRIGATE_URL", "").rstrip("/")
- quality.py: extract blur_score_from_image(img, max_dim=1440) helper;
  executor.py time-spread blur fallback now uses it instead of inlining
  the resize+RGB+assess_quality sequence, keeping scale logic in one place
2026-06-15 02:56:51 +00:00

299 lines
10 KiB
Python

"""Immich API client for fetching people, assets, and face data."""
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from io import BytesIO
import requests
from PIL import Image, ImageOps
from .config import Config, get_headers
logger = logging.getLogger(__name__)
MAX_PAGES = 1000 # Safety limit for pagination
_MAX_ASSETS_PER_PERSON = 5000 # Stop fetching after this many — diversity pool is capped at 3000 anyway
@dataclass
class FaceData:
"""Pre-computed face data from Immich."""
bbox: tuple[float, float, float, float] # (x1, y1, x2, y2)
confidence: float | None
image_width: int
image_height: int
def get_immich_version() -> tuple[int, int, int] | None:
"""Fetch Immich server version from GET /api/server/version.
Returns (major, minor, patch) or None if unreachable or unparseable.
"""
try:
resp = requests.get(
f"{Config.IMMICH_URL}/api/server/version",
headers=get_headers(),
timeout=5,
)
if resp.ok:
data = resp.json()
return (int(data["major"]), int(data["minor"]), int(data["patch"]))
return None
except Exception:
return None
def get_people() -> list[dict]:
"""Fetch all people from Immich."""
try:
resp = requests.get(
f"{Config.IMMICH_URL}/api/people",
headers=get_headers(),
timeout=10,
)
if resp.status_code == 401:
logger.error("Immich API key is invalid or expired (401 Unauthorized). Update API_KEY.")
return []
resp.raise_for_status()
return resp.json().get("people", [])
except (requests.RequestException, ValueError) as e:
logger.error("Failed to fetch people from Immich: %s", e)
return []
def merge_people(survivor_id: str, merge_ids: list[str]) -> bool:
"""Merge duplicate people into survivor via Immich's merge endpoint.
The survivor (identified by survivor_id) absorbs all faces and assets
from the people in merge_ids, which are then removed from Immich.
"""
try:
resp = requests.put(
f"{Config.IMMICH_URL}/api/people/{survivor_id}/merge",
headers={**get_headers(), "Content-Type": "application/json"},
json={"ids": merge_ids},
timeout=30,
)
resp.raise_for_status()
return True
except requests.RequestException as e:
logger.error("Failed to merge people into %s: %s", survivor_id, e)
return False
def fetch_all_assets(person: dict) -> tuple[list[dict], int]:
"""Fetch all assets for a person with pagination.
Returns (assets, total_raw) where assets is the list of valid dict items
and total_raw is the raw item count across pages that had at least one valid
dict. All-garbage pages (every item non-dict) stop pagination and are not
counted. total_raw is a lower bound in two cases: a network error interrupts
pagination (a warning is logged), or an all-garbage page terminates it early
(a warning is logged and later pages are not fetched).
"""
name = person.get("name", "Unknown")
person_id = person.get("id")
if not person_id:
logger.error("Person dict missing 'id' field for %s — skipping asset fetch", name)
return [], 0
url = f"{Config.IMMICH_URL}/api/search/metadata"
page_size = 1000
logger.debug("Fetching assets for %s...", name)
assets: list[dict] = []
total_raw = 0 # raw item count across pages that yielded at least one valid dict
for page in range(1, MAX_PAGES + 1):
try:
resp = requests.post(
url,
json={"personIds": [person_id], "size": page_size, "page": page},
headers=get_headers(),
timeout=30,
)
if not resp.ok:
logger.error("Error fetching assets for %s (page %s): %s", name, page, resp.status_code)
break
page_assets = resp.json().get("assets", [])
# Immich ≥2.x returns {"assets": {"items": [...]}};
# earlier versions returned {"assets": [...]} directly.
if isinstance(page_assets, dict):
page_assets = page_assets.get("items", [])
page_count = len(page_assets) # raw count for termination check before filtering
# Single pass: partition valid assets from unexpected non-dict items
valid_assets, skipped_count = [], 0
for item in page_assets:
if isinstance(item, dict):
valid_assets.append(item)
else:
skipped_count += 1
if skipped_count:
logger.warning("%s: skipping %s non-dict item(s) in page %s", name, skipped_count, page)
if not valid_assets:
if page_count > 0:
logger.warning(
"%s: page %s returned %s item(s) but none were valid dicts — stopping pagination",
name, page, page_count,
)
break
# Count page_count (not just valid items) so that non-dict items from a
# transient schema issue on a mixed page don't cause MIN_FACE_COUNT to
# skip a real person. Pages where every item is a non-dict are excluded —
# they indicate a structural problem and break above without contributing.
total_raw += page_count
assets.extend(valid_assets)
logger.debug("Fetched page %s, total: %s", page, len(assets))
if page_count < page_size or len(assets) >= _MAX_ASSETS_PER_PERSON:
break
except (requests.RequestException, ValueError) as e:
logger.error("Exception fetching assets for %s (page %s): %s", name, page, e)
if page > 1:
logger.warning(
"%s: pagination interrupted at page %s — total_raw=%s may undercount actual assets",
name, page, total_raw,
)
break
return assets, total_raw
def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | None:
"""Fetch pre-computed face data (bbox, confidence) from Immich.
Queries GET /api/faces?id={asset_id} to retrieve face detection results
that Immich already computed using InsightFace Buffalo_L.
Args:
asset_id: The asset to get face data for
person_id: Optional person ID to match the specific face
Returns:
FaceData with bbox and confidence, or None if unavailable
"""
try:
resp = requests.get(
f"{Config.IMMICH_URL}/api/faces",
params={"id": asset_id},
headers=get_headers(),
timeout=10,
)
if not resp.ok:
logger.debug("Face data endpoint returned %s for %s", resp.status_code, asset_id)
return None
faces = resp.json()
if not isinstance(faces, list) or not faces:
return None
# Match the target person if specified; never fall back to a different person's face.
face = None
if person_id:
face = next(
(f for f in faces if isinstance(f, dict) and (f.get("person") or {}).get("id") == person_id),
None,
)
else:
face = faces[0] if isinstance(faces[0], dict) else None
if face is None:
return None
bbox = (
face.get("boundingBoxX1", 0),
face.get("boundingBoxY1", 0),
face.get("boundingBoxX2", 0),
face.get("boundingBoxY2", 0),
)
score = face.get("score")
return FaceData(
bbox=bbox,
confidence=score if score is not None else face.get("confidence"),
image_width=face.get("imageWidth", 0),
image_height=face.get("imageHeight", 0),
)
except requests.RequestException as e:
logger.debug("Failed to fetch face data for %s: %s", asset_id, e)
return None
except (AttributeError, KeyError, TypeError, ValueError) as e:
logger.debug("Failed to parse face data for %s: %s", asset_id, e)
return None
def fetch_full_image(asset_id: str, timeout: int = 60) -> Image.Image | None:
"""Fetch full-resolution image from Immich, falling back to preview thumbnail.
The /original endpoint may return HEIC, RAW, or video files that PIL
cannot open directly. In that case, we fall back to the JPEG thumbnail.
"""
# Try original first
try:
resp = requests.get(
f"{Config.IMMICH_URL}/api/assets/{asset_id}/original",
headers=get_headers(),
timeout=timeout,
)
if resp.ok:
try:
return ImageOps.exif_transpose(Image.open(BytesIO(resp.content)))
except Exception:
logger.debug("PIL can't open original for %s, falling back to preview", asset_id)
except requests.RequestException:
logger.debug("Original request failed for %s, falling back to preview", asset_id)
# Fall back to preview thumbnail (always JPEG)
try:
resp = requests.get(
f"{Config.IMMICH_URL}/api/assets/{asset_id}/thumbnail?size=preview&format=JPEG",
headers=get_headers(),
timeout=30,
)
if resp.ok:
return ImageOps.exif_transpose(Image.open(BytesIO(resp.content)))
except Exception as e:
logger.error("Failed to fetch image %s: %s", asset_id, e)
return None
def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[dict]:
"""Filter assets to keep only those from the last N years. Pass years=0 to include all."""
if years is None:
years = Config.YEARS_FILTER
if not years:
return list(assets)
cutoff = datetime.now(timezone.utc) - timedelta(days=365 * years)
logger.debug("Filtering assets older than %s years (%s)", years, cutoff)
recent, skipped = [], 0
for asset in assets:
created_at_str = asset.get("fileCreatedAt")
if not isinstance(created_at_str, str) or not created_at_str:
continue
try:
# Handle ISO8601 with 'Z' suffix
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
if created_at > cutoff:
recent.append(asset)
else:
skipped += 1
except ValueError:
continue
logger.debug("Retained %s assets (filtered %s old assets).", len(recent), skipped)
return recent