- upload_tracker: replace executescript() in _migrate_schema_v2 with
individual execute() calls inside a transaction so a crash between DROP
and RENAME rolls back instead of permanently destroying tracked_assets
- frigate_api: _get_frigate_url now strips leading/trailing whitespace
before rstrip('/') so whitespace-only FRIGATE_URL is treated as unset
- executor: upload_to_frigate now uses _get_frigate_url() eliminating
double-slash upload paths when FRIGATE_URL has a trailing slash
- executor: corrupt thumbnail (resp.ok=True, Image.open fails) now calls
mark_rejected() so permanently broken assets are not retried forever
- upload_tracker: reset_person now uses _get_frigate_url() instead of
inline os.environ.get('FRIGATE_URL', '').strip()
- image_processing: _save_jpeg writes to a .tmp file and calls
os.replace() so a disk-full error never leaves a truncated JPEG
- cli: _handle_duplicate_people falls back to local deduplication when
all Immich merges fail, preventing two jobs from overwriting the same
Frigate folder
- config: _getenv_optional_float now delegates to _getenv_num() like
_getenv_optional_int, eliminating the inconsistent duplicate
- reconcile: _ts() uses rsplit('.', 1)[0] instead of .replace('.webp','')
so FIFO mapping works with any Frigate training-file extension
172 lines
6.5 KiB
Python
172 lines
6.5 KiB
Python
"""Frigate API helpers for querying face training state."""
|
|
|
|
import logging
|
|
import os
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_frigate_url() -> str:
|
|
"""Return normalized FRIGATE_URL with whitespace and trailing slash stripped, or '' if unset."""
|
|
return os.environ.get("FRIGATE_URL", "").strip().rstrip("/")
|
|
|
|
|
|
def get_frigate_version() -> str | None:
|
|
"""Fetch Frigate's version string from GET /api/version.
|
|
|
|
Returns the version string (e.g. "0.16.0-beta4") or None if FRIGATE_URL
|
|
is unset, the endpoint is unreachable, or the response is not parseable.
|
|
"""
|
|
frigate_url = _get_frigate_url()
|
|
if not frigate_url:
|
|
return None
|
|
try:
|
|
resp = requests.get(f"{frigate_url}/api/version", timeout=5)
|
|
if resp.ok:
|
|
return resp.text.strip().strip('"')
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _get_faces_data() -> dict | None:
|
|
"""Fetch raw GET /api/faces response. Returns None if unavailable."""
|
|
frigate_url = _get_frigate_url()
|
|
if not frigate_url:
|
|
return None
|
|
try:
|
|
resp = requests.get(f"{frigate_url}/api/faces", timeout=10)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.warning("Could not query Frigate faces API: %s", e)
|
|
return None
|
|
|
|
|
|
def get_all_frigate_person_files() -> dict[str, list[str]] | None:
|
|
"""Return {person_name: [filename, ...]} for every person in Frigate.
|
|
|
|
Single call used to build per-person snapshots before the upload loop,
|
|
avoiding one GET /api/faces per person. Returns None if unavailable.
|
|
"""
|
|
data = _get_faces_data()
|
|
if data is None:
|
|
return None
|
|
# Response: {person_name: [file, ...], "train": [...], ...}
|
|
# "train" is a flat pending list, not a person — skip it.
|
|
# TODO(frigate-api): "train" is the only known special key as of Frigate v0.16.
|
|
# Log unexpected non-list values so future Frigate schema additions are visible.
|
|
result = {}
|
|
for name, files in data.items():
|
|
if name == "train":
|
|
continue
|
|
if isinstance(files, list):
|
|
result[name] = files
|
|
else:
|
|
logger.debug("Frigate API: skipping unexpected key %r (got %s, not list)", name, type(files).__name__)
|
|
return result
|
|
|
|
|
|
def get_frigate_face_counts() -> dict[str, int] | None:
|
|
"""Return {person_name: training_image_count} from Frigate's train directory.
|
|
|
|
Returns None if FRIGATE_URL is not set or the API is unreachable, so callers
|
|
can distinguish "API unavailable" from "person has 0 images."
|
|
"""
|
|
all_files = get_all_frigate_person_files()
|
|
if all_files is None:
|
|
return None
|
|
return {name: len(files) for name, files in all_files.items()}
|
|
|
|
|
|
def get_frigate_person_files(person_name: str) -> list[str] | None:
|
|
"""Return the list of training filenames for a person in Frigate.
|
|
|
|
Returns None if the API is unreachable. Returns an empty list if the
|
|
person exists but has no training images yet.
|
|
"""
|
|
data = _get_faces_data()
|
|
if data is None:
|
|
return None
|
|
files = data.get(person_name)
|
|
if files is not None and not isinstance(files, list):
|
|
logger.debug("Frigate API: unexpected type for %r — got %s, not list", person_name, type(files).__name__)
|
|
return []
|
|
return files if files is not None else []
|
|
|
|
|
|
def recognize_face(file_path: str) -> tuple[str | None, float] | None:
|
|
"""Submit an image to Frigate's recognize endpoint.
|
|
|
|
Returns (face_name, score) where face_name is the best-matching person
|
|
(may be "unknown" if below Frigate's confidence threshold) and score is
|
|
the sigmoid-mapped cosine similarity (0-1) against that person's mean
|
|
embedding.
|
|
|
|
Returns None if FRIGATE_URL is unset, the API is unreachable, no face is
|
|
detected, or face recognition is not enabled in Frigate.
|
|
|
|
LIMITATION — mean embedding comparison: the score reflects similarity to
|
|
the arithmetic mean of all training embeddings, not to individual ones.
|
|
A bimodal training set (e.g. frontals + profiles) has a mean that sits
|
|
between both clusters, making candidates from either cluster look more
|
|
novel than they are. Winnow could add redundant frontals while the score
|
|
suggests novelty, because the mean is pulled toward profiles.
|
|
TODO(frigate-api): if Frigate exposes per-file embeddings via the API,
|
|
replace mean-comparison with nearest-neighbour distance across individual
|
|
training embeddings for accurate coverage detection.
|
|
"""
|
|
frigate_url = _get_frigate_url()
|
|
if not frigate_url:
|
|
return None
|
|
try:
|
|
with open(file_path, "rb") as f:
|
|
resp = requests.post(
|
|
f"{frigate_url}/api/faces/recognize",
|
|
files={"file": (os.path.basename(file_path), f, "image/jpeg")},
|
|
timeout=15,
|
|
)
|
|
if not resp.ok:
|
|
return None
|
|
data = resp.json()
|
|
if data.get("success") and "score" in data:
|
|
return (data.get("face_name"), round(float(data["score"]), 4))
|
|
return None
|
|
except Exception as e:
|
|
logger.debug("Frigate recognize failed for %s: %s", file_path, e)
|
|
return None
|
|
|
|
|
|
def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool:
|
|
"""Delete specific training files for a person from Frigate.
|
|
|
|
Uses POST /api/faces/{name}/delete with body {"ids": [filename, ...]}.
|
|
Returns True on success, False if unreachable or the request fails.
|
|
"""
|
|
frigate_url = _get_frigate_url()
|
|
if not frigate_url or not filenames:
|
|
return False
|
|
from urllib.parse import quote
|
|
encoded_name = quote(person_name, safe="")
|
|
try:
|
|
resp = requests.post(
|
|
f"{frigate_url}/api/faces/{encoded_name}/delete",
|
|
json={"ids": filenames},
|
|
timeout=10,
|
|
)
|
|
if resp.ok:
|
|
logger.debug("Deleted %s Frigate file(s) for %s", len(filenames), person_name)
|
|
return True
|
|
if resp.status_code == 404:
|
|
# File already absent — stale tracker entry. Return True so the caller
|
|
# removes it from the tracker and frees the slot cleanly.
|
|
logger.warning("Frigate file(s) not found for %s (stale tracker entry?): %s", person_name, filenames)
|
|
return True
|
|
logger.warning("Frigate delete returned %s for %s", resp.status_code, person_name)
|
|
return False
|
|
except Exception as e:
|
|
logger.warning("Failed to delete Frigate files for %s: %s", person_name, e)
|
|
return False
|