Files
winnow/winnow/upload_tracker.py
T
flan 01fa307031 Fix Frigate faces crash, tracker races, and batch write loss
- frigate_api._get_faces_data now validates the /api/faces response is a
  dict before returning it, so a malformed body no longer crashes
  data.items() in get_all_frigate_person_files (and the equivalent
  data.get() in get_frigate_person_files).
- upload_tracker now takes an exclusive flock on a DATA_DIR lock file for
  every tracker load-mutate-save cycle, so a scheduled run and a manual
  docker exec against the same DATA_DIR can no longer race a
  read-modify-write and silently drop the loser's marks.
- begin_batch()/flush_batch() now flush to disk every 10 marks instead of
  deferring the whole per-person upload loop, bounding how many uploaded
  marks a crash mid-batch can lose.
2026-07-19 20:46:09 +00:00

589 lines
24 KiB
Python

"""Persistent tracker for Immich asset IDs already uploaded/rejected by Frigate.
Two separate JSON files in DATA_DIR:
frigate_uploaded_ids.json — successfully uploaded assets
frigate_rejected_ids.json — assets Frigate rejected (e.g. no face detected)
Both are excluded from future candidate pools. To reset:
- All: delete both files
- One person: call reset_person("Name") or set RESET_PERSON=Name
- Rejects only: delete frigate_rejected_ids.json, or set RETRY_REJECTED=true
by_person schema (frigate_uploaded_ids.json):
{
"asset_ids": ["immich-id-1", ...], # all assets we attempted to upload
"scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time
"frigate_scores": {"immich-id-1": 0.87}, # Frigate recognition confidence (0-1) pre-upload
"frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID
"crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time
"frigate_count": 42 # last known Frigate training image count
}
frigate_scores stores pre-upload recognize scores (0-1 sigmoid-mapped cosine
similarity). High score = the existing training set already covers this face
condition well. Low score = a gap — novel/diverse for the training set.
frigate_files only contains files winnow uploaded — files added manually through
Frigate's UI are never mapped here and are never touched by quality replacement.
"""
import fcntl
import json
import logging
import os
from contextlib import contextmanager
from pathlib import Path
from .frigate_api import _get_frigate_url, delete_frigate_person_files
logger = logging.getLogger(__name__)
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
REJECT_TRACKER_FILE = "frigate_rejected_ids.json"
LOCK_FILE = ".tracker.lock"
# Number of deferred _save calls a batch accumulates before it is flushed to disk
# early. Bounds how many marks a crash mid-batch (SIGKILL/OOM/host crash) can lose —
# without this, begin_batch()/flush_batch() defer every write for an entire
# per-person upload loop, so a crash could lose every mark from that person's batch
# even though the files are already live in Frigate.
_BATCH_FLUSH_EVERY = 10
# Write-through in-memory cache keyed by the resolved file path.
# Reduces per-call JSON reads from O(calls) to O(1) after the first load.
# Keyed by full path so tests with isolated tmp dirs never share entries.
_cache: dict[str, dict] = {}
_deferred: set[str] = set() # paths whose disk writes are batched until flush_batch()
_dirty: set[str] = set() # deferred paths that received at least one _save during the batch
_batch_writes: dict[str, int] = {} # deferred _save calls since the last disk write, per path
# Cross-process lock guarding the load-mutate-save cycle below. Two winnow
# invocations against the same DATA_DIR (e.g. a scheduled run overlapping a
# manual `docker exec`, which the docs explicitly instruct) can otherwise race
# a read-modify-write and silently lose whichever one saves first. Reentrant
# within a single process (depth-counted) so begin_batch()/flush_batch() pairs
# and nested tracker calls made while a batch is open don't self-deadlock.
_lock_fd: int | None = None
_lock_depth = 0
def _tracker_path(filename: str) -> Path:
try:
from .config import Config
return Path(Config.DATA_DIR) / filename
except (ImportError, AttributeError):
return Path(filename)
def _lock_path() -> Path:
return _tracker_path(LOCK_FILE)
def _acquire_lock() -> None:
"""Acquire the cross-process tracker lock. Reentrant within this process."""
global _lock_fd, _lock_depth
if _lock_depth == 0:
path = _lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_CREAT | os.O_RDWR)
fcntl.flock(fd, fcntl.LOCK_EX) # blocks until any other process's lock is released
_lock_fd = fd
# Cache entries not part of an in-progress deferred batch may be stale —
# another process could have written to disk since we last read them.
# Drop them so the critical section that follows re-reads from disk.
for key in list(_cache):
if key not in _deferred:
del _cache[key]
_lock_depth += 1
def _release_lock() -> None:
global _lock_fd, _lock_depth
if _lock_depth <= 0:
return
_lock_depth -= 1
if _lock_depth == 0 and _lock_fd is not None:
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
os.close(_lock_fd)
_lock_fd = None
@contextmanager
def _locked():
"""Context manager wrapping a single load-mutate-save cycle in the tracker lock."""
_acquire_lock()
try:
yield
finally:
_release_lock()
def _load(filename: str) -> dict:
path = _tracker_path(filename)
key = str(path)
if key in _cache:
return _cache[key]
data: dict = {}
if path.exists():
try:
with open(path) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Could not load tracker {filename}: {e}")
_cache[key] = data
return data
def _write_to_disk(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
try:
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)
except Exception:
tmp.unlink(missing_ok=True)
raise
def _save(filename: str, data: dict) -> None:
path = _tracker_path(filename)
key = str(path)
if key in _deferred:
_cache[key] = data # accumulate in cache; disk write deferred until flush_batch()
_dirty.add(key)
# Flush early every _BATCH_FLUSH_EVERY marks so a crash mid-batch (SIGKILL/OOM/
# host crash) loses at most a bounded number of marks instead of the whole batch.
# Safe without re-acquiring the lock: begin_batch() already holds it for the
# duration of the batch.
_batch_writes[key] = _batch_writes.get(key, 0) + 1
if _batch_writes[key] >= _BATCH_FLUSH_EVERY:
_write_to_disk(path, data)
_dirty.discard(key)
_batch_writes[key] = 0
return
_write_to_disk(path, data)
_cache[key] = data # update cache only after successful write
def begin_batch(filename: str) -> None:
"""Defer tracker disk writes for filename. All _save calls accumulate in the
in-memory cache until flush_batch() is called (with periodic early flushes —
see _BATCH_FLUSH_EVERY). Use around per-person upload loops to reduce N writes
towards 1.
Acquires the cross-process tracker lock, held until the matching flush_batch()
releases it, so a concurrent winnow invocation against the same DATA_DIR can't
interleave a read-modify-write with this batch.
If a previous batch for this file was interrupted before flush_batch() was called
(e.g. an exception escaped the upload loop), the leftover cache state is flushed
to disk here before starting fresh so that partial progress is not silently lost.
"""
_acquire_lock()
path = _tracker_path(filename)
key = str(path)
if key in _deferred and key in _dirty:
try:
_write_to_disk(path, _cache[key])
except Exception:
logger.warning(
"begin_batch: could not flush leftover deferred state for %s"
" — partial progress may be lost",
path,
)
_deferred.discard(key)
_dirty.discard(key)
_deferred.add(key)
_batch_writes[key] = 0
def flush_batch(filename: str) -> None:
"""Write the accumulated cache state for filename to disk and release the tracker lock
acquired by the matching begin_batch().
The lock release always runs, even if the disk write raises, so a write failure
(e.g. a full disk) can't leave the tracker lock held for the rest of the process.
"""
path = _tracker_path(filename)
key = str(path)
try:
if key in _dirty and key in _cache:
_write_to_disk(path, _cache[key])
finally:
_deferred.discard(key)
_dirty.discard(key)
_batch_writes.pop(key, None)
_release_lock()
def _flat_key(filename: str) -> str:
return "uploaded_asset_ids" if filename == UPLOAD_TRACKER_FILE else "rejected_asset_ids"
def _get_ids(entry: list | dict) -> list[str]:
"""Extract asset_ids from either the old list format or the new dict format."""
if isinstance(entry, list):
return entry
return entry.get("asset_ids", [])
def _migrate_entry(entry: list | dict) -> dict:
"""Ensure by_person entry is in the current dict format."""
if isinstance(entry, list):
return {"asset_ids": sorted(entry), "scores": {}, "frigate_scores": {}, "frigate_files": {}, "crop_dims": {}}
# Copy top-level and all nested dicts so callers' mutations never reach the cache.
result = dict(entry)
result["asset_ids"] = list(result.get("asset_ids", []))
result["scores"] = dict(result.get("scores", {}))
result["frigate_scores"] = dict(result.get("frigate_scores", {}))
result["frigate_files"] = dict(result.get("frigate_files", {}))
result["crop_dims"] = dict(result.get("crop_dims", {}))
return result
def _mark(
filename: str,
asset_id: str,
person_name: str | None,
score: float | None = None,
crop_dims: tuple[int, int] | None = None,
frigate_score: float | None = None,
) -> None:
if not person_name:
logger.warning("_mark called with empty person_name for asset %s — asset not recorded", asset_id)
return
with _locked():
data = _load(filename)
by_person = dict(data.get("by_person", {}))
entry = _migrate_entry(by_person.get(person_name, {}))
ids = set(entry["asset_ids"])
ids.add(asset_id)
entry["asset_ids"] = sorted(ids)
if score is not None:
entry["scores"][asset_id] = round(score, 4)
if crop_dims is not None:
entry["crop_dims"][asset_id] = [crop_dims[0], crop_dims[1]]
if frigate_score is not None:
entry["frigate_scores"][asset_id] = round(frigate_score, 4)
by_person[person_name] = entry
new_data = dict(data)
new_data["by_person"] = by_person
_save(filename, new_data)
logger.debug("Marked %s in %s (%s)", asset_id, filename, person_name)
# ── Public API ────────────────────────────────────────────────────────────────
def load_uploaded_ids() -> set[str]:
"""Return all asset IDs recorded as uploaded. Derives from by_person (primary)
plus any legacy flat list still present in old tracker files."""
data = _load(UPLOAD_TRACKER_FILE)
ids = {aid for e in data.get("by_person", {}).values() for aid in _get_ids(e)}
ids.update(data.get("uploaded_asset_ids", [])) # backward compat with pre-0.6.1 files
return ids
def load_rejected_ids() -> set[str]:
"""Return all asset IDs recorded as rejected. Derives from by_person (primary)
plus any legacy flat list still present in old tracker files."""
data = _load(REJECT_TRACKER_FILE)
ids = {aid for e in data.get("by_person", {}).values() for aid in _get_ids(e)}
ids.update(data.get("rejected_asset_ids", [])) # backward compat with pre-0.6.1 files
return ids
def mark_uploaded(
asset_id: str,
person_name: str | None = None,
score: float | None = None,
crop_dims: tuple[int, int] | None = None,
frigate_score: float | None = None,
) -> None:
_mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score, crop_dims=crop_dims, frigate_score=frigate_score)
def mark_rejected(asset_id: str, person_name: str | None = None) -> None:
_mark(REJECT_TRACKER_FILE, asset_id, person_name)
def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None:
"""Record a single Frigate filename → asset_id mapping."""
record_frigate_files_batch(person_name, {frigate_filename: asset_id})
def record_frigate_files_batch(person_name: str, mappings: dict[str, str]) -> None:
"""Record multiple Frigate filename → asset_id mappings in a single load/save."""
if not mappings:
return
with _locked():
src = _load(UPLOAD_TRACKER_FILE)
by_person = dict(src.get("by_person", {}))
entry = _migrate_entry(by_person.get(person_name, {}))
entry["frigate_files"].update(mappings)
by_person[person_name] = entry
data = dict(src)
data["by_person"] = by_person
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Batch-mapped {len(mappings)} Frigate file(s) for {person_name}")
def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
"""Remove a Frigate filename from the mapping after it has been deleted.
Does NOT unmark the source asset_id — the deletion was deliberate and
we don't want to re-upload the inferior image on the next run.
"""
remove_frigate_files_batch(person_name, [frigate_filename])
def remove_frigate_files_batch(person_name: str, frigate_filenames: list[str]) -> None:
"""Remove multiple Frigate filenames in a single load/save."""
with _locked():
src = _load(UPLOAD_TRACKER_FILE)
raw = src.get("by_person", {}).get(person_name)
if raw is None:
return
entry = _migrate_entry(raw)
for fn in frigate_filenames:
asset_id = entry["frigate_files"].pop(fn, None)
if asset_id is not None and asset_id not in entry["frigate_files"].values():
entry["frigate_scores"].pop(asset_id, None)
by_person = dict(src.get("by_person", {})) # copy so assignment does not mutate the cache
by_person[person_name] = entry
data = dict(src)
data["by_person"] = by_person
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Removed {len(frigate_filenames)} Frigate file mapping(s) for {person_name}")
def get_tracked_frigate_file_count(person_name: str) -> int:
"""Return the number of Frigate training files winnow has mapped for this person.
Used as the cap baseline so that manually-added Frigate files do not
consume slots from winnow's managed quota.
"""
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
return len(entry["frigate_files"])
def get_tracked_frigate_filenames(person_name: str) -> set[str]:
"""Return the set of Frigate filenames currently mapped in the tracker for a person.
Used as a pre-upload baseline when the Frigate GET API is unreachable at
upload start, so reconciliation can still identify newly uploaded files.
"""
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
return set(entry["frigate_files"].keys())
def has_frigate_scores(person_name: str) -> bool:
"""Return True if any mapped file for this person has a stored Frigate recognition score."""
data = _load(UPLOAD_TRACKER_FILE)
raw = data.get("by_person", {}).get(person_name)
if not raw or isinstance(raw, list):
return False
frigate_files = raw.get("frigate_files", {})
frigate_scores = raw.get("frigate_scores", {})
return any(asset_id in frigate_scores for asset_id in frigate_files.values())
def _pick_mapped_file(
person_name: str, score_key: str, *, highest: bool, exclude: set[str] | None = None
) -> tuple[str, str, float] | None:
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
scores = entry.get(score_key, {})
seen_assets: set[str] = set()
candidates = []
for ff, asset_id in entry.get("frigate_files", {}).items():
if (exclude is None or ff not in exclude) and asset_id in scores and asset_id not in seen_assets:
seen_assets.add(asset_id)
candidates.append((ff, asset_id, scores[asset_id]))
if not candidates:
return None
return max(candidates, key=lambda x: x[2]) if highest else min(candidates, key=lambda x: x[2])
def get_lowest_quality_mapped_file(
person_name: str, exclude: set[str] | None = None
) -> tuple[str, str, float] | None:
"""Return (frigate_filename, asset_id, score) for the mapped file with the lowest
blur score, or None if no mapped files with known scores exist.
Used for quality replacement when no Frigate scores are available.
Pass `exclude` to skip files that failed to delete this run.
"""
return _pick_mapped_file(person_name, "scores", highest=False, exclude=exclude)
def get_most_redundant_mapped_file(
person_name: str, exclude: set[str] | None = None
) -> tuple[str, str, float] | None:
"""Return (frigate_filename, asset_id, score) for the mapped file with the highest
Frigate recognition score, or None if no mapped files with Frigate scores exist.
High Frigate score = the training set already covers this face condition well
= the most redundant file and therefore the best replacement target.
Pass `exclude` to skip files that failed to delete this run.
"""
return _pick_mapped_file(person_name, "frigate_scores", highest=True, exclude=exclude)
def find_by_crop_dimension(size: int) -> list[dict]:
"""Return all tracked crops whose width or height matches `size` pixels.
Returns a list of dicts: {person, asset_id, width, height, blur_score, frigate_filename}.
frigate_filename is None when the Frigate mapping was lost to a reconciliation race.
"""
data = _load(UPLOAD_TRACKER_FILE)
results = []
for person_name, raw_entry in data.get("by_person", {}).items():
entry = _migrate_entry(raw_entry)
scores = entry.get("scores", {})
frigate_files = entry.get("frigate_files", {})
asset_to_frigate: dict[str, str] = {}
for fn, aid in frigate_files.items():
asset_to_frigate.setdefault(aid, fn) # first-seen wins; plain inversion silently drops duplicates
frigate_scores = entry.get("frigate_scores", {})
for asset_id, dims in entry.get("crop_dims", {}).items():
if not isinstance(dims, (list, tuple)) or len(dims) < 2:
continue
w, h = dims[0], dims[1]
if w == size or h == size:
results.append({
"person": person_name,
"asset_id": asset_id,
"width": w,
"height": h,
"blur_score": scores.get(asset_id),
"frigate_score": frigate_scores.get(asset_id),
"frigate_filename": asset_to_frigate.get(asset_id),
})
return results
def update_frigate_count(person_name: str, count: int) -> None:
"""Record Frigate's authoritative training image count for a person."""
with _locked():
data = _load(UPLOAD_TRACKER_FILE)
by_person = dict(data.get("by_person", {}))
entry = _migrate_entry(by_person.get(person_name, {}))
entry["frigate_count"] = count
by_person[person_name] = entry
new_data = dict(data)
new_data["by_person"] = by_person
_save(UPLOAD_TRACKER_FILE, new_data)
def reset_all_people() -> None:
"""Reset all tracking data in two writes (O(P) Frigate API calls, O(1) disk writes).
Preferred over calling reset_person() in a loop when RESET_PERSON=* — that
approach is O(P²) because each call rebuilds the flat list from all remaining entries.
"""
with _locked():
upload_data = _load(UPLOAD_TRACKER_FILE)
frigate_url = _get_frigate_url()
if not frigate_url:
logger.info("FRIGATE_URL not set — skipping Frigate file deletion")
for person_name, raw_entry in upload_data.get("by_person", {}).items():
entry = _migrate_entry(raw_entry)
frigate_filenames = list(entry.get("frigate_files", {}).keys())
if not frigate_filenames:
continue
if frigate_url:
if delete_frigate_person_files(person_name, frigate_filenames):
logger.info(f"Deleted {len(frigate_filenames)} Frigate file(s) for {person_name}")
else:
logger.warning(
f"Could not delete Frigate files for {person_name} — tracker reset proceeding anyway"
)
_save(UPLOAD_TRACKER_FILE, {})
_save(REJECT_TRACKER_FILE, {})
logger.info("Reset all tracking data")
def reset_person(person_name: str) -> None:
"""Remove all uploaded and rejected records for a given person.
Also deletes winnow-managed Frigate training files so the next run starts
clean rather than uploading on top of orphaned files. Manually-added Frigate
files (not in frigate_files) are never touched. Proceeds with tracker reset
even if Frigate is unreachable.
"""
with _locked():
upload_data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(upload_data.get("by_person", {}).get(person_name, {}))
frigate_filenames = list(entry.get("frigate_files", {}).keys())
if frigate_filenames:
if not _get_frigate_url():
logger.info(f"FRIGATE_URL not set — skipping Frigate file deletion for {person_name}")
elif delete_frigate_person_files(person_name, frigate_filenames):
logger.info(f"Deleted {len(frigate_filenames)} Frigate file(s) for {person_name}")
else:
logger.warning(f"Could not delete Frigate files for {person_name} — tracker reset proceeding anyway")
changed = False
for filename in (UPLOAD_TRACKER_FILE, REJECT_TRACKER_FILE):
src = upload_data if filename == UPLOAD_TRACKER_FILE else _load(REJECT_TRACKER_FILE)
by_person = dict(src.get("by_person", {})) # copy so pop() does not mutate the cache
tracker_entry = by_person.pop(person_name, None)
if tracker_entry is not None:
data = dict(src)
data["by_person"] = by_person
flat_key = _flat_key(filename)
person_ids = set(_get_ids(tracker_entry))
if person_ids and flat_key in data and not isinstance(data[flat_key], list):
logger.warning(
"reset_person: %s has unexpected type for %s (%s) — skipping flat-list cleanup;"
" all persons' legacy IDs in this field are unaffected but unreadable",
filename, flat_key, type(data[flat_key]).__name__,
)
elif person_ids and flat_key in data:
data[flat_key] = sorted(set(data[flat_key]) - person_ids)
_save(filename, data)
changed = True
if changed:
logger.info(f"Reset tracking data for {person_name}")
else:
logger.debug(f"reset_person: no tracking data found for {person_name}")
def get_person_summary() -> dict[str, dict]:
"""Return {person_name: {uploaded, rejected, frigate_count, scores, frigate_files}} for display/capacity."""
uploaded_data = _load(UPLOAD_TRACKER_FILE).get("by_person", {})
rejected_data = _load(REJECT_TRACKER_FILE).get("by_person", {})
names = set(uploaded_data) | set(rejected_data)
result = {}
for name in sorted(names):
u_entry = _migrate_entry(uploaded_data.get(name, {}))
r_entry = _migrate_entry(rejected_data.get(name, {}))
result[name] = {
"uploaded": len(u_entry["asset_ids"]),
"rejected": len(r_entry["asset_ids"]),
"frigate_count": u_entry.get("frigate_count"),
"scores": u_entry["scores"],
"frigate_files": u_entry["frigate_files"],
}
return result
def filter_already_uploaded(
asset_ids: list[str],
retry_rejected: bool = False,
) -> list[str]:
"""Return asset IDs not yet uploaded (and not rejected, unless retry_rejected)."""
exclude = load_uploaded_ids()
if not retry_rejected:
exclude |= load_rejected_ids()
new_ids = [aid for aid in asset_ids if aid not in exclude]
skipped = len(asset_ids) - len(new_ids)
if skipped:
logger.info(f"Skipping {skipped} assets already uploaded or rejected by Frigate")
return new_ids