Files
winnow/winnow/frigate_api.py
T
flan 3a052db1ce chore: quality cleanup — extract magic numbers, improve docs and naming (#29)
- diversity.py: extract 3000/20/32 to _POOL_CAP/_POOL_SCALE/_EMBEDDING_BATCH_SIZE
- reconcile.py: extract (1,2,4,8) poll delays to _RECONCILE_POLL_DELAYS with comment
- immich_api.py: document dual response shape; debug-log skipped non-dict items
- frigate_api.py: rename `encoded` → `encoded_name` for clarity
- upload_tracker.py: atomic-write note on record_frigate_files_batch docstring;
  get_person_summary() uses setdefault to eliminate four repeated default dicts;
  _VALID_SCORE_COLS comment explains SQL-injection guard intent

Bump version to 0.5.5
2026-06-14 20:04:24 -04:00

167 lines
6.4 KiB
Python

"""Frigate API helpers for querying face training state."""
import logging
import os
import requests
logger = logging.getLogger(__name__)
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 = os.environ.get("FRIGATE_URL", "").rstrip("/")
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 = os.environ.get("FRIGATE_URL", "").rstrip("/")
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 = os.environ.get("FRIGATE_URL", "").rstrip("/")
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 = os.environ.get("FRIGATE_URL", "").rstrip("/")
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