Commit Graph
100 Commits
Author SHA1 Message Date
flan 8bdce9253a fix: address 10 codebase audit findings — API guards, reconcile, merge fallback, tracker guards
- immich_api: guard resp.json() with isinstance(dict) check in get_people and
  fetch_all_assets so AttributeError doesn't escape on proxy/CDN non-dict responses
- executor: move actually_uploaded.append outside try/else so Frigate filename→asset_id
  mapping is created via reconcile even when the tracker write fails
- cli: fall back to pre-merge people list when re-fetch after merge returns empty
  (transient error) instead of silently dropping all people
- cli: treat ENABLE_FRIGATE_SCORES=false / BLUR_THRESHOLD=0 as not-set in
  the unsupported-vars warning (falsy string check replaces raw truthiness)
- upload_tracker: guard set(data[flat_key]) with isinstance(list) check in
  reset_person so a corrupted non-iterable legacy field doesn't crash mid-reset
- upload_tracker: guard dims[0]/dims[1] in find_by_crop_dimension with a
  length check so a truncated crop_dims entry doesn't raise IndexError
- cache: wrap os.remove() in clear() with try/except OSError to handle
  TOCTOU race with concurrent put() calls
- diversity: default conf_array to 0.5 (was 1.0) for faces with missing
  confidence so they receive a moderate diversity boost instead of being
  treated as high-confidence
- diversity: sort assets in the fast path (len <= limit) so return order is
  consistent with the sorted-by-fileCreatedAt path
2026-06-16 20:51:14 +00:00
flan 34fccf8839 fix: address 10 full-codebase audit findings + lint
Correctness:
- jobs: cap auto-diversity limit for brand-new people (was never capped,
  could exceed MAX_AUTO_IMAGES on first run)
- image_processing: separate None/0 guard for imageWidth/imageHeight so
  missing field is explicit rather than silently aliased to img_w
- upload_tracker (_mark, update_frigate_count): copy-before-mutate so
  exceptions between cache access and _save don't corrupt in-process state
- jobs: reject LIMIT=0 on no-embedding path (was silently empty run)
- jobs: add STRATEGY=skip to strategy_map so env var is honoured
- embeddings: convert to RGB before cvtColor so RGBA/grayscale thumbnails
  don't raise cv2.error and silently drop from diversity selection
- config: use falsy guard for OUTPUT_DIR so blank env var falls through
  to config file value
- reconcile: _ts() returns float("inf") on parse failure so unrecognised
  filenames sort last instead of collapsing to 0.0 and corrupting FIFO mapping
- diversity: remove dead selected_set (never read; -np.inf sentinel already
  prevents re-selection)

Lint (ruff):
- executor: sort upload_tracker import block (I001)
- executor: replace lambda is_better_than with operator.lt/gt (E731 x2)
- executor, upload_tracker: wrap long logger.warning calls (E501 x4)
2026-06-16 18:40:13 +00:00
flan 7a268d1ea2 chore: sync dev with main (v0.6.3) 2026-06-16 18:18:42 +00:00
flan 44cbedaf91 Merge branch 'main' of github.com:sudolulo/winnow 2026-06-16 18:16:27 +00:00
flan 3c2ce80282 Merge branch 'main' of github.com:sudolulo/winnow into dev 2026-06-16 18:16:14 +00:00
flan 3c0ef47fdc release: v0.6.3 2026-06-16 18:13:42 +00:00
flan cf7660595d chore: update lockfile 2026-06-16 18:13:42 +00:00
flan 14f759e960 fix: address 3 quality review findings — record_frigate_files_batch cache mutation, tracker_ok flag, LIMIT guard
- record_frigate_files_batch: copy-before-mutate so a write failure
  doesn't leave cache ahead of disk (same fix as remove_frigate_files_batch)
- executor: replace tracker_ok boolean with try/else
- jobs: collapse duplicate custom_limit is not None checks into one guard

Bump version to 0.6.3.
2026-06-16 18:13:36 +00:00
flan e8cb390fe4 fix: address 2 quality review findings — begin_batch dirty guard, LIMIT<=0 warning 2026-06-16 18:04:38 +00:00
flan 54b52b0a73 fix: address 3 quality review findings — batch reject tracker, skip flush when clean, hoist frigate url check 2026-06-16 17:55:34 +00:00
flan b622e58f1b fix: address 3 quality review findings — flush_batch finally guard, _laplacian_var helper, has_frigate_scores no-copy 2026-06-16 17:09:00 +00:00
flan f3622b8d41 fix: address 4 quality review findings — flush_batch order, batch finally guard, cache copy, LIMIT=0 fallthrough 2026-06-16 16:59:43 +00:00
flan 817fa17e41 fix: address 3 quality review findings — tracker_ok gate, LIMIT=0 warning, cache write log level 2026-06-16 16:41:32 +00:00
flan 8846a4f1df fix: address 3 post-fix audit findings — begin_batch flush guard, misleading debug log, shared asset_id score deletion 2026-06-16 16:19:54 +00:00
flan a6bae5da05 fix: address 10 audit findings — import bug, fscore stale flag, cache mutation, batch safety, falsy guards 2026-06-16 16:16:28 +00:00
flan 4cdd4657d6 fix: v0.6.2 — structural tracker refactor, batch writes, multi-instance prep
- Drop flat list as primary storage; derive uploaded/rejected IDs from by_person
  (single source of truth). Legacy flat lists in existing files still read for
  backward compat. Removes dual-representation sync hazard.
- Add begin_batch/flush_batch: per-person upload loop now does 1 os.replace
  instead of N (one per mark_uploaded call). Benefit on slow storage.
- reset_all_people(): RESET_PERSON=* is now O(1) disk writes instead of O(P^2).
- blur_score_from_image inlines cv2.Laplacian directly, removing assess_quality
  call overhead and decoupling from the full quality pipeline.
2026-06-16 15:44:41 +00:00
flan dc2efb5ac4 fix: v0.6.1 — tracker integrity, quality replacement correctness, code review fixes
- Catch OSError alongside PIL.UnidentifiedImageError for corrupt thumbnails
- Fix quality replacement mode flip mid-loop (person_has_fscores no longer re-evaluated)
- reset_person rebuilds flat list from remaining entries instead of subtracting
- _save cache updated only after os.replace succeeds (prevents cache/disk split-brain)
- Stale Frigate file cleanup uses remove_frigate_files_batch (N writes → 1)
- _migrate_entry deep-copies nested dicts so .pop() cannot mutate the cache
- find_by_crop_dimension and _pick_mapped_file consistent on duplicate asset→file mapping
- Atomic JSON write (tmp + os.replace) guards against truncated files on crash
- get_person_summary uses _migrate_entry instead of three isinstance guards
- Quality floor check allows None-scored candidates through (don't block freed slots)
- Fix comment-only if body (IndentationError on import) in full-res download path
- Merge duplicate if-stale guard into one block
- _flat_key uses constant equality instead of substring match
- remove_frigate_file returns early when person absent (no ghost entries)
- skip_ids extracted to _smaller_duplicate_ids() helper (was duplicated 3×)
- blur_score_from_image returns None on error instead of 0.0
2026-06-16 15:21:12 +00:00
flan 2de0c02c4e Merge pull request #34 from sudolulo/dev
release: v0.6.0 — revert SQLite tracker to JSON backend
2026-06-15 11:53:23 -04:00
flan 9e84e276da Merge remote-tracking branch 'origin/main' into dev 2026-06-15 15:52:29 +00:00
flan 794dbe2a1d revert: replace SQLite tracker with JSON backend (v0.6.0) (#32)
* revert: replace SQLite tracker with JSON backend (v0.6.0)

The SQLite migration (v0.5.0) spawned 21 bug-fix releases in two days:
data-loss risk in the migration layer, schema PK conflicts on per-person
tracking, tracker isolation races under concurrent runs, and a disk-full
error that triggered duplicate Frigate uploads. The complexity cost
outweighs the benefit.

Restored the pre-SQL JSON tracker (frigate_uploaded_ids.json /
frigate_rejected_ids.json in DATA_DIR). Public API is identical — all
callers in executor.py, jobs.py, cli.py, and reconcile.py work unchanged.
Existing JSON files are read automatically; frigate_tracker.db can be
deleted once verified.

* fix: narrow corrupt-thumbnail exception to UnidentifiedImageError; restore IMMICH_URL empty-string fallback

* docs: rewrite v0.6.0 changelog, strip v0.5.x entries, fix README SQLite references

* chore: remove dead get_frigate_filename_for_asset (orphaned since FRIGATE_SCORE_THRESHOLD removal in v0.4.0)

* fix: sort imports in executor.py (ruff I001)
2026-06-15 11:44:41 -04:00
flan 86c76ba6a7 Update README.md (#33) 2026-06-15 11:44:12 -04:00
flan 0602c4ac04 fix: v0.5.21 — diversity cap, fetch rejection, tracker isolation, merge dedup, env parsing
- diversity: cap k-medoids seed count at target so _cluster_aware_selection
  never returns more images than requested (violated MAX_AUTO_IMAGES when
  remaining capacity was 1-4 slots); add early return for limit=0 to
  prevent k-medoids from running with a zero budget; slice return to target
  as a final guard
- executor: mark_rejected() when fetch_full_image returns None so assets
  that can't be fetched (both original and preview) aren't retried every run
- executor: wrap mark_uploaded() in its own try/except so a SQLite disk-full
  error after a successful HTTP 200 doesn't retry the Frigate POST (duplicate
  upload) — the upload succeeded; only the tracker write failed
- cli: apply skip_ids deduplication to the re-fetched people list after a
  partial merge (some groups succeed, some fail) so unmerged duplicates
  don't produce two jobs for the same Frigate folder
- jobs: strip whitespace from SKIP_PEOPLE/ONLY_PEOPLE elements on split
  so "Alice, Bob" (space after comma) correctly matches "Bob"
2026-06-15 03:56:57 +00:00
flan 95fb39ed50 fix: v0.5.20 — migration safety, URL normalization, image rejection, atomic writes
- upload_tracker: replace executescript() in _migrate_schema_v2 with
  individual execute() calls inside a transaction so a crash between DROP
  and RENAME rolls back instead of permanently destroying tracked_assets
- frigate_api: _get_frigate_url now strips leading/trailing whitespace
  before rstrip('/') so whitespace-only FRIGATE_URL is treated as unset
- executor: upload_to_frigate now uses _get_frigate_url() eliminating
  double-slash upload paths when FRIGATE_URL has a trailing slash
- executor: corrupt thumbnail (resp.ok=True, Image.open fails) now calls
  mark_rejected() so permanently broken assets are not retried forever
- upload_tracker: reset_person now uses _get_frigate_url() instead of
  inline os.environ.get('FRIGATE_URL', '').strip()
- image_processing: _save_jpeg writes to a .tmp file and calls
  os.replace() so a disk-full error never leaves a truncated JPEG
- cli: _handle_duplicate_people falls back to local deduplication when
  all Immich merges fail, preventing two jobs from overwriting the same
  Frigate folder
- config: _getenv_optional_float now delegates to _getenv_num() like
  _getenv_optional_int, eliminating the inconsistent duplicate
- reconcile: _ts() uses rsplit('.', 1)[0] instead of .replace('.webp','')
  so FIFO mapping works with any Frigate training-file extension
2026-06-15 03:26:51 +00:00
flan 728b84dc8c fix: address 10 full-codebase audit findings (v0.5.19)
Correctness:
- fetch_face_data: only fall back to faces[0] when person_id is absent;
  previously a missing person match injected a different person's bbox
- upload_tracker: change PK from (asset_id, status) to
  (asset_id, person_name, status); old PK allowed INSERT OR REPLACE to
  silently overwrite person_name when the same photo appeared in two
  people's jobs, breaking quality-replacement JOINs; auto-migrates DBs
- filter_recent_assets: treat years=0 as "no age filter" instead of
  falling through to Config.YEARS_FILTER via falsy `or`
- _is_module_available: return find_spec(...) is not None; find_spec
  returns None (not raises) for absent top-level modules, so the
  previous code always returned True
- execute_jobs error handler: use asset.get("id", "<unknown>") to avoid
  a secondary KeyError propagating out of execute_jobs on malformed dicts
- upload_to_frigate: also mark_rejected on HTTP 422, not only HTTP 400
  with "face" in body; other permanent errors left assets untracked and
  retried forever
- reconcile_frigate_mappings: sort key lambda f: (_ts(f), f) makes order
  deterministic when timestamps are equal or 0.0; set iteration order is
  hash-randomised, stable sort preserves it

Reuse / cleanup:
- config.py: add _getenv_optional_int delegating to _getenv_num(name, None, int)
- jobs.py: _resolve_strategy uses _getenv_optional_int("LIMIT") instead
  of inline os.environ.get + int() + warning duplicate of _getenv_num
- frigate_api.py: add _get_frigate_url() helper; eliminates 4× copy of
  os.environ.get("FRIGATE_URL", "").rstrip("/")
- quality.py: extract blur_score_from_image(img, max_dim=1440) helper;
  executor.py time-spread blur fallback now uses it instead of inlining
  the resize+RGB+assess_quality sequence, keeping scale logic in one place
2026-06-15 02:56:51 +00:00
flan 3c4479a110 fix: replace inline FORCE_CPU check in benchmark.py with _getenv_bool
scripts/benchmark.py retained the old os.getenv inline pattern after
_getenv_bool was introduced in v0.5.16. Now uses a deferred local
import of _getenv_bool, consistent with the script's pattern of keeping
all winnow imports inside function bodies rather than at the top level.
2026-06-15 02:29:30 +00:00
flan 06c2c4a584 fix: strip empty env vars in _getenv_num/_getenv_bool; unify FORCE_CPU
- _getenv_num: add raw.strip() + empty-string guard so numeric vars set
  to "" (common Compose pattern for "use default") return the default
  silently instead of warning "not a valid int/float"
- _getenv_bool: same guard so True-defaulted flags set to "" return
  the configured default instead of silently returning False
- embeddings.py: replace inline FORCE_CPU bool parse with _getenv_bool
2026-06-15 02:20:34 +00:00
flan de6804226d refactor: unify env var helpers and remove magic slice in cache
- Add _getenv_num as shared core for _getenv_int/_getenv_float
- Add _getenv_optional_float for FRIGATE_SCORE_CEILING (replaces 9-line inline block)
- Add _getenv_bool; replace 11 inline .lower()-in-("true","1","yes") sites
  across config.py, jobs.py, and cli.py with single call site
- _resolve_strategy no-embedding branch: inline try/except → _getenv_int("LIMIT", 30)
- cache.py: final[:-4] → final.removesuffix(".npy") — assumption is now explicit
2026-06-15 02:10:00 +00:00
flan fe5e1574ac release: v0.5.15 — fix cache regression and structural cleanup
- cache.py: fix np.save extension bug from v0.5.13 — tmp path used
  final+".tmp" (abc.npy.tmp) but np.save auto-appends .npy to paths not
  ending in .npy, writing to abc.npy.tmp.npy instead; os.replace then
  raised FileNotFoundError silently, making every cache write a no-op
  and leaking *.npy.tmp.npy files. Fixed by inserting .tmp before .npy:
  tmp = final[:-4] + ".tmp.npy"

- config.py: remove str(default) round-trip in _getenv_int/_getenv_float
  — use raw = os.getenv(name); return default if raw is None else int(raw)
  so a future float default can't cause a spurious "not a valid integer"
  warning and return the wrong type

- executor.py: consolidate 4 progress.remove_task calls into one
  try/finally around the per-job body; continue inside try/finally
  executes the finally before the next iteration, making the invariant
  structurally enforced rather than relying on discipline across 4 sites
2026-06-15 01:45:43 +00:00
flan 5bcc5975bc release: v0.5.14 — graceful fallback for invalid numeric env vars
YEARS_FILTER, MIN_FACE_WIDTH, MIN_FACE_COUNT, MAX_AUTO_IMAGES,
BLUR_THRESHOLD, MIN_CONFIDENCE, and FACE_MARGIN used bare int()/float()
calls with no error handler. A typo (trailing space, non-numeric value)
raised ValueError inside __getattr__, producing a cryptic traceback on
the first config access rather than at the validate() step. Values are
now parsed by _getenv_int/_getenv_float helpers that warn and fall back
to the documented default, matching the existing FRIGATE_SCORE_CEILING
pattern.
2026-06-15 01:35:27 +00:00
flan 51f7ed3961 release: v0.5.13 — robustness fixes from full-project audit
- executor.py: wrap shutil.rmtree/os.makedirs in try/except OSError so a
  permission failure logs and skips the job rather than aborting the run
- cache.py: write embeddings to a .tmp file and atomically rename into place
  via os.replace so a process kill can't leave a corrupted .npy cache slot
- immich_api.py: guard fileCreatedAt with isinstance(str) check before calling
  .replace() so a non-string timestamp doesn't raise AttributeError and kill
  the entire filter_recent_assets pass
- upload_tracker.py: raise SQLite busy timeout from 5 s to 30 s to handle
  concurrent cron+manual run overlap without dropping upload-tracking records
2026-06-15 01:19:38 +00:00
flan 850ae2f9fc fix: remove progress task on skipped jobs (v0.5.12)
progress.add_task() fires unconditionally at the top of the job loop;
both continue paths (ValueError from _safe_person_dir and the symlink
TOCTOU guard) skipped remove_task(), leaving orphaned 0% rows in the
terminal for the rest of the run.
2026-06-15 01:05:49 +00:00
flan 796aded2da fix: log+skip on symlink TOCTOU in execute_jobs (v0.5.11)
The v0.5.10 compound guard 'isdir and not islink' silently skipped the
rmtree when person_dir was a symlink-to-directory, then let makedirs
follow the symlink — allowing crop writes outside output_dir with no
diagnostic. Replace with an explicit islink pre-check that logs an error
and continues, matching the ValueError path from _safe_person_dir.
2026-06-15 01:02:28 +00:00
flan 480bf80534 fix: reconcile < target severity and rmtree symlink guard (v0.5.10)
- reconcile.py: re-escalate the < target branch from INFO to WARNING and
  add 'permanently unmapped' label. Both post-loop branches produce identical
  permanent mapping loss; v0.5.9 incorrectly treated the timeout case as
  recoverable.

- executor.py: guard shutil.rmtree with 'not os.path.islink(person_dir)'
  so a race-replaced symlink-to-directory is skipped rather than raising
  an unhandled OSError that aborts all remaining jobs. Correct comment:
  rmtree raises OSError, not NotADirectoryError.
2026-06-15 00:58:22 +00:00
flan 2d39291fe7 fix: correct reconcile log severity, docstring gaps, and _entry allocation (v0.5.9)
- reconcile.py: swap log levels — external-upload path (permanent mapping
  loss) escalated to WARNING; timeout path (transient, retries next cycle)
  downgraded to INFO. Also extend the warning message to note the files are
  permanently unmapped.

- immich_api.py: extend fetch_all_assets docstring to document that
  all-garbage page termination (in addition to network errors) makes
  total_raw a lower bound.

- executor.py: add comment above shutil.rmtree noting that POSIX rmtree
  raises NotADirectoryError on a top-level symlink, documenting why the
  removed islink guard is safe to omit.

- upload_tracker.py: replace setdefault with explicit guard in _entry() —
  setdefault evaluates its default-dict argument before checking key
  presence, allocating and discarding a dict on every already-present call.
2026-06-15 00:51:21 +00:00
flan 1556d90bcc fix: correct path traversal docstring, total_raw inflation, and config re-stat (v0.5.8)
- executor.py: fix _safe_person_dir docstring — realpath+startswith is the
  load-bearing traversal guard; islink is a supplementary early-exit for the
  symlink sub-case only. The previous comment "checking after realpath would be
  too late" implied islink was the primary guard, which is backwards.

- immich_api.py: move total_raw accumulation to after the dead-end-page break
  so all-garbage pages don't inflate the count and produce misleading
  "N total, 0 recent" output. Mixed pages (some valid, some non-dict) still
  count page_count so transient schema issues don't shrink MIN_FACE_COUNT below
  threshold. Add warning when a RequestException interrupts pagination mid-way
  so operators know total_raw is a lower bound.

- config.py: eliminate residual TOCTOU — change `if config_file.exists():` to
  `if _data_cfg_exists or config_file.exists():` so _data_cfg is never
  stat'd twice (the v0.5.7 fix cached the first check but not the second).
2026-06-15 00:41:00 +00:00
flan 00e378b375 Merge remote-tracking branch 'origin/dev' 2026-06-15 00:27:25 +00:00
flan 9685c310af fix: symlink guard placement, fetch_all_assets raw count, config TOCTOU (#31)
executor.py:
- Move islink check into _safe_person_dir on the raw path, before realpath
  resolves it; the previous check at the rmtree site was unreachable dead code
  because realpath already followed any symlink

immich_api.py / jobs.py:
- fetch_all_assets now returns (assets, total_raw) where total_raw is the
  item count seen before non-dict filtering; callers use it for MIN_FACE_COUNT
  guard and display so transient non-dict API items can't incorrectly skip people
- Add WARNING when pagination stops because a page had items but all were non-dict

config.py:
- Cache _data_cfg.exists() in _data_cfg_exists so the dual-config warning
  and config_file selection always read from the same stat() result; previously
  two calls created a TOCTOU window where log and code could disagree

Bump version to 0.5.7
2026-06-14 20:27:16 -04:00
flan e5b9293861 Merge remote-tracking branch 'origin/dev' 2026-06-15 00:16:33 +00:00
flan 363190dbe5 fix: pagination runaway, double iteration, and reconciliation efficiency (#30)
immich_api.py:
- Move empty-page break after non-dict filtering — a page of all-null
  items no longer loops to MAX_PAGES without terminating
- Single-pass partition replaces two inverse isinstance scans per page
- Upgrade non-dict item log from DEBUG to WARNING (silent asset loss)

reconcile.py:
- Check Frigate before the first sleep so fast responses return
  immediately rather than always paying a 1 s delay
- Compute set difference once per poll iteration instead of twice

Bump version to 0.5.6
2026-06-14 20:16:27 -04:00
flan 78e01d9621 Merge remote-tracking branch 'origin/dev' 2026-06-15 00:04:29 +00:00
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
flan df98169398 Merge pull request #28 from sudolulo/dev
release: 0.5.4
2026-06-14 19:53:48 -04:00
flan 84ebd91929 fix: quality replacement slot floor uses deleted file's score not failed candidate's (#27)
* fix: quality replacement slot floor uses deleted file's score not failed candidate's

When a blur-score replacement deletes a low-quality Frigate file but the
subsequent upload fails, min_quality_score_for_slot was set to candidate_score
(the good file that failed to upload). This filtered out any subsequent
candidate that didn't beat the failed upload, even if it was better than
the file we just deleted — leaving the freed slot unfilled unnecessarily.

The comment on the guard already documented the correct intent: 'require
the next candidate to beat the deleted file's score'. Fix: use target_score
(the deleted file's blur score) as the floor instead of candidate_score.

* chore: bump version to 0.5.4
2026-06-14 19:53:33 -04:00
flan c77069f2e2 Merge pull request #26 from sudolulo/dev
release: 0.5.3
2026-06-14 19:39:51 -04:00
flan 2e08504682 fix: audit hardening — input validation, error handling, and robustness (#25)
* fix: audit hardening — input validation, error handling, and robustness

- immich_api: guard person["id"] with .get() + early return on missing field
- immich_api: include page number in pagination exception log
- immich_api: validate faces response is a list before indexing
- executor: wrap Image.open() in try/except for non-image HTTP responses
- executor: strip leading 'v' from Frigate version before parsing (v0.16.0 was misread)
- config: wrap FRIGATE_SCORE_CEILING float() parse in try/except with warning
- config: warn when both DATA_DIR and legacy CWD config files exist simultaneously
- scheduler: wrap PID file write in try/except so /tmp failures don't crash startup
- scheduler: clamp sleep to 60s max to bound recovery time after NTP clock jumps
- frigate_api: log unexpected non-list type in get_frigate_person_files at DEBUG

* fix: LIMIT env var crash and symlink guard on person output dir

- jobs: wrap int(LIMIT) parse in try/except — bad value (e.g. "30.5", "all")
  now logs a warning and falls back to the default instead of crashing
- executor: check for symlink before shutil.rmtree on person_dir — prevents
  following a symlink out of OUTPUT_DIR on a shared volume

* chore: bump version to 0.5.3
2026-06-14 19:39:35 -04:00
flan 8bf23edc85 Merge pull request #24 from sudolulo/dev
release: 0.5.2
2026-06-14 19:17:24 -04:00
flan 166729a17d Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	CHANGELOG.md
#	pyproject.toml
#	uv.lock
2026-06-14 23:17:16 +00:00
flanandgithub-actions[bot] 8acf8b52b8 fix: Immich v2.7.5 compat, supply-chain hardening, and quality fixes (0.5.2) (#23)
* fix: Immich v2.7.5 compat, supply-chain hardening, and quality fixes (0.5.2)

- Remove assetCount pre-filter broken by Immich v2.7.5 API change; check
  MIN_FACE_COUNT after fetch_all_assets instead
- Replace curl|sh uv installer with COPY --from Docker stage (supply chain)
- Fix HEALTHCHECK to use kill -0 on PID file instead of static file test
- Fix CONFIG_FILE path to resolve inside DATA_DIR for volume persistence
- Fix EmbeddingCache singleton to re-init when cache_dir changes
- Fix fd leak in _suppress_output() with nested finally closes
- Fix silent exception on SQLite connection close in upload_tracker
- Log unexpected Frigate API keys at DEBUG in get_all_frigate_person_files
- Add reconcile FIFO-mapping debug log
- Pin all CI action SHAs; update setup-uv v8.2.0, upload/download-artifact,
  ruff-action v4.0.0

* chore: update lockfile

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 19:15:20 -04:00
flanandgithub-actions[bot] e795a42e20 refactor: rename CACHE_DIR → DATA_DIR, container path .if_cache → data (#21) (#22)
* refactor: rename CACHE_DIR to DATA_DIR, default path .if_cache → data

CACHE_DIR held both the embedding cache and the SQLite tracker DB, making
the name misleading. DATA_DIR is more accurate.

- Config reads DATA_DIR first; falls back to CACHE_DIR with a deprecation
  warning so existing setups don't break on upgrade
- Default local path: data (was .if_cache)
- Docker default path: /app/data (was /app/.if_cache)
- Internal references (embeddings.py, upload_tracker.py) updated to DATA_DIR
- compose.yml, .env.example, README, wiki, and changelog updated
- Version bumped to 0.5.1

* chore: update lockfile

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 18:00:08 -04:00
flanandgithub-actions[bot] d99d607fc8 refactor: rename CACHE_DIR → DATA_DIR, container path .if_cache → data (#21)
* refactor: rename CACHE_DIR to DATA_DIR, default path .if_cache → data

CACHE_DIR held both the embedding cache and the SQLite tracker DB, making
the name misleading. DATA_DIR is more accurate.

- Config reads DATA_DIR first; falls back to CACHE_DIR with a deprecation
  warning so existing setups don't break on upgrade
- Default local path: data (was .if_cache)
- Docker default path: /app/data (was /app/.if_cache)
- Internal references (embeddings.py, upload_tracker.py) updated to DATA_DIR
- compose.yml, .env.example, README, wiki, and changelog updated
- Version bumped to 0.5.1

* chore: update lockfile

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 17:59:48 -04:00
flan d30f2956e0 Merge pull request #20 from sudolulo/docs/sync-readme-to-main
docs: sync README accuracy fixes to main
2026-06-14 17:45:11 -04:00
flan 8f30379262 Merge pull request #19 from sudolulo/fix/readme-accuracy
fix: README accuracy for 0.5.0
2026-06-14 17:43:40 -04:00
flan 6c23755654 fix: README accuracy — remove object-mode reference, correct :latest arch to amd64-only 2026-06-14 21:42:33 +00:00
flan 98734d071a Merge pull request #18 from sudolulo/release/workflow-fix
fix: release workflow for main (0.5.0 Docker builds)
2026-06-14 17:30:31 -04:00
flan a77b7b1cc8 Merge pull request #17 from sudolulo/fix/release-workflow-and-docs
fix: release workflow and docs for 0.5.0
2026-06-14 17:25:36 -04:00
flan 4e0e8032ef fix: update release workflow and docs for 0.5.0 single-lockfile refactor
- release.yml: replace per-variant lockfile generation loop with a
  single 'uv lock'; add idempotent release creation (skip if tag
  already has a release so re-triggered runs don't 422)
- docker-publish.yml: remove stale uv-cpu/rocm/intel.lock entries
  from paths-ignore (those files no longer exist)
- README: CACHE_DIR description now names winnow_tracker.db; tracker
  description mentions SQLite
2026-06-14 21:24:06 +00:00
flan 08623088f3 Merge pull request #16 from sudolulo/dev
release: winnow 0.5.0
2026-06-14 17:18:11 -04:00
flan edd22407e9 chore: merge main into dev, resolve lockfile consolidation conflicts 2026-06-14 21:16:29 +00:00
flan d6cb9c6ab8 Merge pull request #15 from sudolulo/release/0.5.0
chore: release 0.5.0
2026-06-14 17:03:23 -04:00
flan a4571f59f6 chore: release 0.5.0 — version bump, changelog, clean up .env.example 2026-06-14 21:01:41 +00:00
flan 51ea7bd43c Merge pull request #14 from sudolulo/refactor/code-quality
refactor: collapse Config proxy, SQLite tracker, split reconcile, consolidate pyproject
2026-06-14 16:58:52 -04:00
flan 2a2c6b0c47 Merge remote-tracking branch 'origin/dev' into refactor/code-quality 2026-06-14 20:57:31 +00:00
flan 043ebf85d7 fix: prevent silent reject-ID data loss on partial migration rename
Two fixes found during post-refactor audit:

1. upload_tracker: remove COUNT(*) guard from _maybe_migrate. The guard
   blocked re-migration when a previous run successfully committed both
   JSON files but a PermissionError on the second rename() left it on
   disk. On the next startup COUNT > 0 → early return → rejected IDs
   permanently unimported. INSERT OR IGNORE is idempotent so re-running
   migration is always safe; guard not needed.

   Also wrap each rename() in its own try/except so a failure on one
   file is logged and does not propagate uncaught.

2. reconcile: break early when new_count > target is detected in the
   poll loop. Previously the loop ran all four delay intervals (1+2+4+8s)
   before the post-loop > target branch fired, wasting up to 15 seconds
   when a concurrent external upload was visible on the first poll.
2026-06-14 20:54:39 +00:00
flan 886b51fdce Revert "chore: update example URLs to local instance addresses"
This reverts commit caa916a508.
2026-06-14 20:40:45 +00:00
flan caa916a508 chore: update example URLs to local instance addresses 2026-06-14 20:40:27 +00:00
flan cabdb9c0eb fix: address 9 code review findings
- upload_tracker: partial migration now rolls back atomically on failure;
  JSON renamed only after successful commit so failed runs retry cleanly
- upload_tracker: allowlist score_col in _pick_mapped_file to close
  latent SQL injection surface
- config: move load_dotenv() from module import into _load() so no I/O
  at import time and reset() fully resets env loading
- config: use is None checks for IMMICH_URL/OUTPUT_DIR config-file
  fallback so explicitly empty env vars are not overridden by the file
- executor: skip reconcile when Frigate API is unreachable at upload
  start — tracker baseline is incomplete and would mis-trigger the
  external-upload guard, permanently losing file mappings
- reconcile: change polling break condition from >= to == target so
  transient overshoots don't prematurely exit the loop and trigger
  the external-upload guard
- jobs: apply capacity cap as the selection limit rather than truncating
  post-selection by position, so the diversity algorithm works within
  the right budget from the start
- Dockerfile: explicit gpu branch + exit 1 on unknown VARIANT instead
  of silent fallback
2026-06-14 20:37:51 +00:00
flan 71f1924f1a fix: restore original dedup/prompt order in jobs.py, move time import to module level in reconcile.py
_build_job no longer calls filter_already_uploaded internally; callers pass
pre-filtered assets so there's no double DB hit and the interactive path
restores the original prompt order (retry_rejected asked before strategy,
so post-dedup count informs the choice). Skip-count rprint restored in
auto_configure. Late 'import time' inside reconcile_frigate_mappings moved
to module level.
2026-06-14 20:14:55 +00:00
flan 02c56493f6 fix: add platform markers to GPU extras, use --extra cpu in CI, simplify lockfile workflow
- rocm/intel/gpu extras are x86_64-only; aarch64 wheels don't exist so uv
  failed to resolve them when required-environments includes aarch64
- test.yml: switch from --all-extras (broken by conflicts + missing wheels)
  to --extra cpu which is cross-platform and sufficient for unit tests
- update-lockfile.yml: drop old file-swap loop; single pyproject means a
  single uv lock run and a single uv.lock to commit
2026-06-14 20:04:25 +00:00
flan e2a1924fb0 refactor: collapse Config proxy, migrate tracker to SQLite, split reconcile module
- Config: remove _ConfigAccessor and ConfigManager; use __getattr__ for lazy
  loading on single _Config class; re-register self as _instance in __getattr__
  so reset() always clears the correct object (item 1)
- upload_tracker: replace hand-rolled JSON store with sqlite3; auto-migrates
  existing JSON on first run; remove dead record_frigate_file function;
  connection re-opens when CACHE_DIR changes for test isolation (items 2, 8)
- diversity: move ThreadPoolExecutor import to module level; inject optional
  fetch_fn parameter for testability (items 3, 6)
- pyproject: consolidate 4 variant files into extras (gpu/rocm/intel/cpu);
  update Dockerfile to use --extra flag; delete variant pyproject/lock files;
  uv.lock needs regen with `uv lock` after this change (item 4)
- jobs: extract _build_job helper to separate business logic from terminal I/O;
  auto_configure delegates dedup/selection to _build_job (item 5)
- logging: convert f-string log calls to % interpolation throughout all winnow/
  modules (item 7)
- reconcile: new module with reconcile_frigate_mappings and
  enrich_asset_with_face_data extracted from executor.py (item 9)
- scheduler: print next scheduled run time after startup and after each run;
  fix f-string logger.error call (item 10)
2026-06-14 19:59:17 +00:00
flan 321b6c66e1 Merge pull request #13 from sudolulo/feature/bump-ubuntu-26-04
build: bump amd64 rocm and cpu bases to Ubuntu 26.04
2026-06-14 15:38:00 -04:00
flan 237dd2091b build: bump amd64 gpu base to NVIDIA CUDA ubuntu24.04
Highest Ubuntu version NVIDIA currently publishes for CUDA 12.8.1.
26.04 not yet available from NVIDIA's image registry.
2026-06-14 19:17:13 +00:00
flan e7bfe00d5d build: bump amd64 rocm and cpu bases to Ubuntu 26.04
22.04 non-GPU amd64 bases were never updated when arm64 moved to 24.04.
Python 3.13 is still pulled from deadsnakes PPA (26.04 ships 3.14 natively).

intel stays on 22.04: the Intel GPU repo URL is pinned to the "jammy"
codename and cannot be bumped until Intel publishes 26.04 packages.
2026-06-14 18:55:50 +00:00
flan ad1fbd4c2a Merge pull request #12 from sudolulo/feature/document-limitations
docs+fix: annotate limitations; fix cache invalidation and version checks
2026-06-14 14:54:48 -04:00
flan 28424b0f16 fix: implement fixable limitations from annotation pass
cache.py — model fingerprint auto-invalidation:
  Replace hardcoded "buffalo_l_v1" version string with a fingerprint
  derived from buffalo_l .onnx file sizes and mtimes. EmbeddingCache now
  computes this at init time; stale embeddings from replaced or updated
  model files are automatically invalidated. Falls back to the static
  string before the model is downloaded.
  Note: existing caches built against the old key will miss on the first
  run after upgrade and recompute cleanly.

frigate_api.py — Frigate version check:
  Add get_frigate_version() (GET /api/version). Called at the start of
  upload_to_frigate(); warns if below v0.16 where the face training API
  endpoints don't exist.

immich_api.py + cli.py — Immich version check:
  Add get_immich_version() (GET /api/server/version). Called at startup
  before get_people(); warns if below v1.106 where the face data and
  merge APIs winnow depends on aren't guaranteed present.

Remaining TODO(frigate-api) annotations are left in place — they require
Frigate to expose per-file embeddings or a rebuild-complete signal before
they can be addressed.
2026-06-14 18:48:07 +00:00
flan 1d44df6e96 docs: annotate known limitations and Frigate API improvement hooks
Adds inline LIMITATION / TODO(frigate-api) comments at each specific
code site rather than a separate doc that would drift from the code.

frigate_api.py — recognize_face:
  Mean-embedding limitation: score reflects the arithmetic mean of all
  training embeddings. A bimodal set (frontals + profiles) has a mean
  between clusters, making both ends look more novel than they are.
  Fixable if Frigate exposes per-file embeddings for nearest-neighbour
  comparison.

frigate_api.py — get_all_frigate_person_files:
  "train" key exclusion is a hardcoded string. If Frigate adds other
  special top-level keys in /api/faces they'll be silently treated as
  person names. Needs a typed schema when Frigate documents the contract.

executor.py — recognize_face call site:
  Async rebuild: each deletion triggers a background model rebuild in
  Frigate. Subsequent recognize calls in the same run return None
  (rebuild in progress), degrading quality replacement for later
  candidates. Fixable with a rebuild-complete signal from Frigate.

executor.py — effective_count / manual file handling:
  Manually-added files are invisible to diversity decisions. Winnow
  observes their effect only indirectly via the Frigate score, not by
  measuring their embedding distribution. Per-file embeddings from
  Frigate would allow direct diversity measurement against the full set.

executor.py — Frigate version assumption:
  All face training endpoints are v0.16+. No version check at startup;
  failures on older versions are opaque 404s.

cache.py — MODEL_VERSIONS:
  Version string is a hardcoded constant. Manual model file replacement
  (custom weights, InsightFace update) won't invalidate cached embeddings.
  Needs file-checksum-derived versioning or a CLEAR_EMBEDDING_CACHE flag.

diversity.py — thumbnail-resolution embeddings:
  Diversity selection runs InsightFace on preview thumbnails; the actual
  training crop comes from full-resolution originals. Negligible in
  practice but degrades if Immich preview quality is low.
2026-06-14 18:41:37 +00:00
flan 4147dbec1c test: add diversity algorithm tests (60 → 93) (#11)
Tests cover the core ML pipeline algorithms in diversity.py — previously
untested. No network or model dependencies; all pure-function or
numpy-only paths:

- Face bbox and confidence extraction from Immich metadata, including
  person_id filtering and missing-data edge cases
- Face crop scaling: verifies bbox coordinates are correctly scaled when
  the thumbnail dimensions differ from the metadata image dimensions
- Near-duplicate dedup: removal below cosine threshold, quality-score
  preference between duplicates, zero-quality-score treated as zero not
  missing (falsy bug guard)
- K-Medoids: correct medoid count, distinctness, valid index range, and
  full-N edge case
- Adaptive threshold: positive output, floor at 0.05 for identical
  embeddings, single-point, scales with embedding spread
- Time-spread fallback: exact count, all-under-limit passthrough,
  auto→30 default, first/last inclusion
- Cluster-aware selection: exact limit, subset invariant, auto-stop on
  tight cluster, hard-example confidence weighting accepted
2026-06-14 14:33:38 -04:00
flan 588a5b2af8 Merge branch 'main' of github.com:sudolulo/winnow 2026-06-14 18:05:14 +00:00
flan ea99ad10e3 Merge branch 'dev' 2026-06-14 18:05:00 +00:00
flan a237983777 chore: update uv.lock for v0.4.11 2026-06-14 18:04:21 +00:00
flan a2d0541493 chore: bump to v0.4.11 and update changelog
Documents object mode removal cleanup, dead embedding path removal,
FutureWarning fix, variant pyproject sync, and docs/wiki updates.
2026-06-14 18:04:05 +00:00
flan 1574aed7e4 docs: expand MERGE_DUPLICATE_PEOPLE docs and fix stale output dir description 2026-06-14 17:52:22 +00:00
flan 0192b6cb5b chore: sync variant pyproject files with main after object pipeline removal
Remove torch/torchvision/transformers/ultralytics from all three variant
pyproject files (rocm/cpu/intel) — no longer needed since SigLIP and YOLO
were removed in v0.4.10. Update version to 0.4.10 and description.

NOTE: uv-rocm.lock, uv-cpu.lock, uv-intel.lock are now stale and must be
regenerated in their respective platform environments before the next Docker
build of those variants.

Also fix fetch_face_data() docstring to not mention embedding (removed field).
2026-06-14 17:51:26 +00:00
flan 0ef15c5c12 chore: remove dead embedding paths and suppress insightface FutureWarning in image_processing
- immich_api.py: remove FaceData.embedding field and numpy import — Immich face
  embeddings were never consumed after being fetched; bbox/confidence is all that's used
- embeddings.py: remove immich_embedding param from get_embedding() — never passed by
  any caller; simplify docstring accordingly
- image_processing.py: wrap insightface_app.get() in warnings filter to suppress the
  scikit-image FutureWarning about estimate being deprecated (already suppressed in
  embeddings.py for the diversity path, was leaking from the crop-alignment path)
- README.md: remove two stale "object mode" / "object classification" fragments
2026-06-14 17:47:30 +00:00
flan 72dbbfa18a chore: remove object classification from package docstring 2026-06-14 17:43:17 +00:00
flan 274d50ee99 chore: remove remaining dead-mode references from executor, jobs, compose, Dockerfile
After removing the object pipeline, several dead 'mode' artifacts remained:

- executor.py: unpack `config` from job even though it was no longer read
- jobs.py: set `"mode": "face"` in both configure paths (key never consumed)
- compose.yml: TRAINING_MODE=face env, OBJECT_CLASS comment, HF_HOME, stale MAX_AUTO_IMAGES default note
- Dockerfile: "and object classification" label, /models/huggingface mkdir, HF_HOME ENV
2026-06-14 17:42:27 +00:00
flan 2d7b52470e chore: remove dead object/SigLIP references after pipeline removal
- cache.py: drop siglip MODEL_VERSIONS entry
- log_config.py: drop ultralytics/transformers/torch from noise silencers
- scheduler.py: drop HF_HOME and HuggingFace model check
- scripts/benchmark.py: drop bench_siglip and make_random_image
2026-06-14 17:31:54 +00:00
flan 835016e0e3 feat: remove object mode pipeline (YOLO, SigLIP, TRAINING_MODE, OBJECT_CLASS)
winnow is a face recognition training tool. Object mode required manual
file placement with no Frigate API, pulled in torch/torchvision/transformers/
ultralytics (~2 GB), and was architecturally misaligned with the project goal.

Removed:
- process_object_mode (YOLO inference), get_yolo_model
- SigLIP model stack (get_siglip_model, get_object_embedding, batch variant)
- entity_type branching throughout diversity, embeddings, jobs, executor
- TRAINING_MODE and OBJECT_CLASS env vars
- torch, torchvision, transformers, ultralytics dependencies
- pytorch index entries from pyproject.toml
- Object mode from README (Modes section, env var table, How It Works)
2026-06-14 17:29:25 +00:00
flan 3e030b361e Update README.md 2026-06-14 13:15:57 -04:00
flan 6ec075b9bf Update README.md 2026-06-14 13:15:20 -04:00
flan d12abbc543 docs: clarify winnow's role as supplement to manual Frigate training 2026-06-14 17:11:59 +00:00
flan efce4e3443 Merge branch 'dev' of github.com:sudolulo/winnow into dev 2026-06-14 17:11:37 +00:00
flan cb670dd555 docs: clarify winnow fills the gap where manual Frigate training images are absent 2026-06-14 17:11:34 +00:00
flan 4e989b042e Merge branch 'main' of github.com:sudolulo/winnow 2026-06-14 17:08:43 +00:00
flan d63bcfc10b chore: bump version to 0.4.10 2026-06-14 17:08:33 +00:00
flan 650629d3ed release: v0.4.10 2026-06-14 17:08:33 +00:00
flan d7dfc1446a config: lower MAX_AUTO_IMAGES default from 80 to 20 2026-06-14 17:07:52 +00:00
flan dfaa03de47 release: v0.4.9 2026-06-14 17:05:46 +00:00
flan 9d5741f626 feat: warn on launch when unsupported advanced tuning vars are set; move ENABLE_FRIGATE_SCORES to unsupported section 2026-06-14 16:59:46 +00:00
flan d70a246a1c docs: move FRIGATE_SCORE_CEILING to unsupported advanced tuning section 2026-06-14 16:56:24 +00:00
flan 51cc7032eb docs: README accuracy audit — novelty gate, rejection scope, calibrated defaults
- How It Works step 9: "upload freely" → note novelty gate may skip below-cap candidates
- Persistence note: "Frigate rejections" → "rejected assets" (covers confidence skips too)
- RETRY_REJECTED description: explicitly covers all rejection types, not just Frigate
- Image Quality section: split into user-adjustable controls and calibrated image
  processing defaults with a support disclaimer to deter blind tuning
2026-06-14 16:54:56 +00:00