Files
winnow/winnow/upload_tracker.py
T
flanandClaude Sonnet 4.6 eea7baa19f refactor: rename project to winnow
- Rename Python package directory if_curator/ → winnow/
- Update all imports, entry points, and CLI references
- Update pyproject.toml: name, scripts, package list, repository URL
- Update Dockerfile, compose.yml, entrypoint.sh, scheduler.py
- Update GitHub Actions workflow image names
- Update README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 01:07:46 +00:00

134 lines
4.4 KiB
Python

"""Persistent tracker for Immich asset IDs already uploaded/rejected by Frigate.
Two separate JSON files in CACHE_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
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
REJECT_TRACKER_FILE = "frigate_rejected_ids.json"
def _tracker_path(filename: str) -> Path:
try:
from .config import Config
return Path(Config.CACHE_DIR) / filename
except (ImportError, AttributeError):
return Path(filename)
def _load(filename: str) -> dict:
path = _tracker_path(filename)
if not path.exists():
return {}
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Could not load tracker {filename}: {e}")
return {}
def _save(filename: str, data: dict) -> None:
path = _tracker_path(filename)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
def _flat_key(filename: str) -> str:
return "uploaded_asset_ids" if "uploaded" in filename else "rejected_asset_ids"
def _load_flat(filename: str) -> set[str]:
return set(_load(filename).get(_flat_key(filename), []))
def _mark(filename: str, asset_id: str, person_name: str | None) -> None:
data = _load(filename)
flat_key = _flat_key(filename)
flat = set(data.get(flat_key, []))
flat.add(asset_id)
data[flat_key] = sorted(flat)
if person_name:
by_person = data.setdefault("by_person", {})
person_ids = set(by_person.get(person_name, []))
person_ids.add(asset_id)
by_person[person_name] = sorted(person_ids)
_save(filename, data)
# ── Public API ────────────────────────────────────────────────────────────────
def load_uploaded_ids() -> set[str]:
return _load_flat(UPLOAD_TRACKER_FILE)
def load_rejected_ids() -> set[str]:
return _load_flat(REJECT_TRACKER_FILE)
def mark_uploaded(asset_id: str, person_name: str | None = None) -> None:
_mark(UPLOAD_TRACKER_FILE, asset_id, person_name)
logger.debug(f"Marked {asset_id} as uploaded ({person_name})")
def mark_rejected(asset_id: str, person_name: str | None = None) -> None:
_mark(REJECT_TRACKER_FILE, asset_id, person_name)
logger.debug(f"Marked {asset_id} as rejected ({person_name})")
def reset_person(person_name: str) -> None:
"""Remove all uploaded and rejected records for a given person."""
for filename in (UPLOAD_TRACKER_FILE, REJECT_TRACKER_FILE):
data = _load(filename)
flat_key = _flat_key(filename)
by_person = data.get("by_person", {})
person_ids = set(by_person.pop(person_name, []))
if person_ids:
flat = set(data.get(flat_key, [])) - person_ids
data[flat_key] = sorted(flat)
data["by_person"] = by_person
_save(filename, data)
logger.info(f"Reset tracking data for {person_name}")
def get_person_summary() -> dict[str, dict[str, int]]:
"""Return {person_name: {uploaded: N, rejected: N}} for display."""
uploaded_by = _load(UPLOAD_TRACKER_FILE).get("by_person", {})
rejected_by = _load(REJECT_TRACKER_FILE).get("by_person", {})
names = set(uploaded_by) | set(rejected_by)
return {
name: {
"uploaded": len(uploaded_by.get(name, [])),
"rejected": len(rejected_by.get(name, [])),
}
for name in sorted(names)
}
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