Cap quality replacement against tracked files only, not total Frigate count

Previously, effective_count and the jobs.py cap check used the total
Frigate file count (including manually-added files), so any file a user
curated by hand ate into winnow's managed quota. Now:

- get_tracked_frigate_file_count() returns len(frigate_files) from the
  tracker — only files winnow uploaded and reconciled
- effective_count in the upload loop uses this tracker count so
  manually-added files are invisible to the cap
- jobs.py capacity check uses len(frigate_files) instead of the live
  Frigate API count or cached frigate_count
- Frigate API call for known_frigate_files_at_start is now only used
  for the post-upload reconciliation diff, not for cap enforcement

Side-effect: fixes audit bug #1 — an unreachable Frigate GET no longer
zeroes effective_count and bypasses the cap, because the cap is now
read from the always-available local tracker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 05:35:26 +00:00
co-authored by Claude Sonnet 4.6
parent 1615e2e77e
commit 650dadd102
4 changed files with 38 additions and 11 deletions
+17
View File
@@ -102,6 +102,23 @@ def test_remove_frigate_file_does_not_unmark_asset():
assert filter_already_uploaded(["asset-a1"]) == [] assert filter_already_uploaded(["asset-a1"]) == []
def test_get_tracked_frigate_file_count_zero_when_empty():
from winnow.upload_tracker import get_tracked_frigate_file_count
assert get_tracked_frigate_file_count("Alice") == 0
def test_get_tracked_frigate_file_count_counts_only_mapped():
"""Only files explicitly recorded via record_frigate_file count toward the cap."""
from winnow.upload_tracker import get_tracked_frigate_file_count, mark_uploaded, record_frigate_file
mark_uploaded("asset-a", person_name="Alice")
mark_uploaded("asset-b", person_name="Alice")
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
# asset-b is uploaded but not yet mapped — does not count
assert get_tracked_frigate_file_count("Alice") == 1
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
assert get_tracked_frigate_file_count("Alice") == 2
def test_get_lowest_quality_mapped_file_none_when_empty(): def test_get_lowest_quality_mapped_file_none_when_empty():
from winnow.upload_tracker import get_lowest_quality_mapped_file from winnow.upload_tracker import get_lowest_quality_mapped_file
assert get_lowest_quality_mapped_file("Alice") is None assert get_lowest_quality_mapped_file("Alice") is None
+6 -4
View File
@@ -19,6 +19,7 @@ from .immich_api import fetch_face_data, fetch_full_image
from .log_config import console from .log_config import console
from .upload_tracker import ( from .upload_tracker import (
get_lowest_quality_mapped_file, get_lowest_quality_mapped_file,
get_tracked_frigate_file_count,
mark_rejected, mark_rejected,
mark_uploaded, mark_uploaded,
record_frigate_file, record_frigate_file,
@@ -316,9 +317,11 @@ def upload_to_frigate(jobs: list[dict]) -> None:
person_uploaded = 0 person_uploaded = 0
person_failed = 0 person_failed = 0
known_frigate_files: set[str] = set(get_frigate_person_files(name) or []) # Snapshot live Frigate files for post-upload reconciliation diff only.
known_frigate_files_at_start = set(known_frigate_files) # effective_count is sourced from the tracker (mapped files) so that
effective_count = len(known_frigate_files) # manually-added Frigate files don't consume winnow's managed quota.
known_frigate_files_at_start: set[str] = set(get_frigate_person_files(name) or [])
effective_count = get_tracked_frigate_file_count(name)
quality_replacement = job.get("config", {}).get("quality_replacement", False) quality_replacement = job.get("config", {}).get("quality_replacement", False)
actually_uploaded: list[tuple[str, str | None]] = [] actually_uploaded: list[tuple[str, str | None]] = []
@@ -353,7 +356,6 @@ def upload_to_frigate(jobs: list[dict]) -> None:
) )
if delete_frigate_person_files(name, [worst_frigate_file]): if delete_frigate_person_files(name, [worst_frigate_file]):
remove_frigate_file(name, worst_frigate_file) remove_frigate_file(name, worst_frigate_file)
known_frigate_files.discard(worst_frigate_file)
effective_count -= 1 effective_count -= 1
else: else:
logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement") logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement")
+4 -7
View File
@@ -283,14 +283,11 @@ def auto_configure(people: list[dict]) -> list[dict]:
rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]") rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]")
continue continue
# Enforce MAX_AUTO_IMAGES as a lifetime cap per person. # Enforce MAX_AUTO_IMAGES against the tracked file count only.
# Priority: live Frigate count → last cached Frigate count → local uploaded count. # Manually-added Frigate files are invisible to this cap so users can
# curate their own files without shrinking winnow's managed quota.
person_summary = upload_summary.get(name, {}) person_summary = upload_summary.get(name, {})
if frigate_counts is not None: already_uploaded = len(person_summary.get("frigate_files", {}))
already_uploaded = frigate_counts.get(name, 0)
else:
fc = person_summary.get("frigate_count")
already_uploaded = fc if fc is not None else person_summary.get("uploaded", 0)
capacity = Config.MAX_AUTO_IMAGES - already_uploaded capacity = Config.MAX_AUTO_IMAGES - already_uploaded
if capacity <= 0: if capacity <= 0:
if not Config.QUALITY_REPLACEMENT: if not Config.QUALITY_REPLACEMENT:
+11
View File
@@ -147,6 +147,17 @@ def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})") logger.debug(f"Removed Frigate file mapping {frigate_filename} ({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_lowest_quality_mapped_file(person_name: str) -> tuple[str, str, float] | None: def get_lowest_quality_mapped_file(person_name: str) -> tuple[str, str, float] | None:
"""Return (frigate_filename, asset_id, score) for the mapped file with the lowest """Return (frigate_filename, asset_id, score) for the mapped file with the lowest
confidence score, or None if no mapped files with known scores exist.""" confidence score, or None if no mapped files with known scores exist."""