diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a51214..ff7b7cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.9] - 2026-06-15 + +### Fixed + +- **Reconcile log severity corrected**: the external-upload branch (`len(new_files) > target`) was logged at `INFO` while the timeout branch (`< target`) was logged at `WARNING`. The severity is now inverted to match impact: external upload causes permanent mapping loss (those files are never eligible for quality replacement) and is now `WARNING`; timeout is transient and recoverable next cycle and is now `INFO`. + +- **`fetch_all_assets` docstring: lower-bound caveat now covers both interruption cases**: previously only noted that a network error makes `total_raw` a lower bound. An all-garbage page (every item non-dict) also terminates pagination early, leaving later pages unfetched — this case is now documented alongside the network error case. + +- **`shutil.rmtree` symlink safety documented**: added a comment above the `rmtree` call in `execute_jobs` noting that POSIX `shutil.rmtree` raises `NotADirectoryError` on a top-level symlink, so a race-replaced symlink cannot cause out-of-tree deletion. + +- **`_entry()` in `get_person_summary` no longer allocates default dict for present keys**: `setdefault` evaluates its default argument before checking whether the key exists, allocating and immediately discarding a 5-key dict on every call for an already-present person. Replaced with an explicit `if name not in summary` guard. + ## [0.5.8] - 2026-06-15 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 10d0883..35e851b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.5.8" +version = "0.5.9" description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition." license = "AGPL-3.0-or-later" requires-python = ">=3.13" diff --git a/winnow/executor.py b/winnow/executor.py index 155a2f3..8c09595 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -107,6 +107,8 @@ def execute_jobs(jobs: list[dict]) -> None: logger.error(str(e)) continue # Face crops are transient (uploaded then discarded); wipe before each run. + # shutil.rmtree raises NotADirectoryError on a top-level symlink (POSIX), + # so a race-replaced symlink cannot cause deletion outside output_dir. if os.path.isdir(person_dir): shutil.rmtree(person_dir) os.makedirs(person_dir, exist_ok=True) diff --git a/winnow/immich_api.py b/winnow/immich_api.py index 9309da2..0e1517c 100644 --- a/winnow/immich_api.py +++ b/winnow/immich_api.py @@ -89,8 +89,9 @@ def fetch_all_assets(person: dict) -> tuple[list[dict], int]: Returns (assets, total_raw) where assets is the list of valid dict items and total_raw is the raw item count across pages that had at least one valid dict. All-garbage pages (every item non-dict) stop pagination and are not - counted. If pagination is interrupted by a network error, total_raw is a - lower bound (a warning is logged). + counted. total_raw is a lower bound in two cases: a network error interrupts + pagination (a warning is logged), or an all-garbage page terminates it early + (a warning is logged and later pages are not fetched). """ name = person.get("name", "Unknown") person_id = person.get("id") diff --git a/winnow/reconcile.py b/winnow/reconcile.py index d6659d5..53bb43e 100644 --- a/winnow/reconcile.py +++ b/winnow/reconcile.py @@ -76,15 +76,16 @@ def reconcile_frigate_mappings( } record_frigate_files_batch(person_name, mappings) elif len(new_files) > target: - logger.info( + logger.warning( "%s: %s new Frigate files for %s uploads" - " (external upload detected) — skipping file mapping", + " (external upload detected) — skipping file mapping;" + " these files are permanently unmapped", person_name, len(new_files), target, ) else: - logger.warning( + logger.info( "%s: only %s of %s expected Frigate files" " appeared after reconciliation — mapping skipped", person_name, diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index 9f39b26..d10b464 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -443,9 +443,9 @@ def get_person_summary() -> dict[str, dict]: ).fetchall() def _entry(summary: dict, name: str) -> dict: - return summary.setdefault( - name, {"uploaded": 0, "rejected": 0, "frigate_count": None, "scores": {}, "frigate_files": {}} - ) + if name not in summary: + summary[name] = {"uploaded": 0, "rejected": 0, "frigate_count": None, "scores": {}, "frigate_files": {}} + return summary[name] summary: dict[str, dict] = {} for r in rows: