Merge remote-tracking branch 'origin/dev'

This commit is contained in:
2026-06-15 00:27:25 +00:00
7 changed files with 48 additions and 19 deletions
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.7] - 2026-06-15
### Fixed
- **Symlink guard moved before `realpath`**: `_safe_person_dir` now checks whether the raw (unresolved) path is a symlink before calling `os.path.realpath`. The previous check in `execute_jobs` ran after `realpath` had already resolved the link, making it unreachable dead code.
- **`fetch_all_assets` returns raw item count**: the function now returns `(assets, total_raw)` where `total_raw` is the total items seen across all pages before non-dict filtering. `auto_configure` and `_configure_person` use `total_raw` for the `MIN_FACE_COUNT` guard and display, so transient non-dict API items cannot cause a person to be incorrectly skipped.
- **Pagination stop on all-non-dict page now logs a warning**: when `valid_assets` is empty but the page was non-empty (all items were non-dict), a `WARNING` is emitted explaining why pagination stopped, distinguishing it from natural end-of-data.
- **`_data_cfg.exists()` called once**: the result is cached in `_data_cfg_exists` so the dual-config warning check and the `config_file` selection always agree — previously two separate `stat()` calls created a TOCTOU window where the log could claim one file while the code loaded another.
## [0.5.6] - 2026-06-15
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.5.6"
version = "0.5.7"
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"
Generated
+1 -1
View File
@@ -862,7 +862,7 @@ wheels = [
[[package]]
name = "winnow"
version = "0.5.6"
version = "0.5.7"
source = { editable = "." }
dependencies = [
{ name = "croniter" },
+3 -2
View File
@@ -123,14 +123,15 @@ class _Config:
# Prefer DATA_DIR/.immich_config.json (volume-safe in Docker) and fall back
# to the legacy CWD path so existing installations continue to work.
_data_cfg = Path(self.DATA_DIR) / ".immich_config.json"
if _data_cfg.exists() and _LEGACY_CONFIG_FILE.exists():
_data_cfg_exists = _data_cfg.exists()
if _data_cfg_exists and _LEGACY_CONFIG_FILE.exists():
logging.warning(
"Two config files found: %s and %s — using %s. Remove the legacy file to silence this.",
_data_cfg,
_LEGACY_CONFIG_FILE,
_data_cfg,
)
config_file = _data_cfg if _data_cfg.exists() else _LEGACY_CONFIG_FILE
config_file = _data_cfg if _data_cfg_exists else _LEGACY_CONFIG_FILE
if config_file.exists():
try:
data = json.loads(config_file.read_text())
+6 -4
View File
@@ -43,8 +43,13 @@ def _safe_person_dir(output_dir: str, person_name: str) -> str:
os.path.join silently discards output_dir when person_name is absolute,
and '../..' sequences resolve outside the tree. Both are rejected here.
Symlinks on the raw (unresolved) path are also rejected — checking after
realpath would be too late because realpath follows the link first.
"""
candidate = os.path.realpath(os.path.join(output_dir, person_name))
raw = os.path.join(output_dir, person_name)
if os.path.islink(raw):
raise ValueError(f"Person name {person_name!r} resolves to a symlink — skipping")
candidate = os.path.realpath(raw)
base = os.path.realpath(output_dir)
# Use the base path as its own prefix when it's the filesystem root ("/"),
# otherwise append os.sep — avoids the false "//" double-slash when base == "/".
@@ -100,9 +105,6 @@ def execute_jobs(jobs: list[dict]) -> None:
logger.error(str(e))
continue
# Face crops are transient (uploaded then discarded); wipe before each run.
if os.path.islink(person_dir):
logger.error("person_dir %s is a symlink — refusing to remove", person_dir)
continue
if os.path.isdir(person_dir):
shutil.rmtree(person_dir)
os.makedirs(person_dir, exist_ok=True)
+17 -5
View File
@@ -83,19 +83,25 @@ def merge_people(survivor_id: str, merge_ids: list[str]) -> bool:
return False
def fetch_all_assets(person: dict) -> list[dict]:
"""Fetch all assets for a person with pagination."""
def fetch_all_assets(person: dict) -> tuple[list[dict], int]:
"""Fetch all assets for a person with pagination.
Returns (assets, raw_total) where assets is the list of valid dict items
and raw_total is the total item count seen before non-dict filtering.
raw_total may exceed len(assets) if Immich returned non-dict items.
"""
name = person.get("name", "Unknown")
person_id = person.get("id")
if not person_id:
logger.error("Person dict missing 'id' field for %s — skipping asset fetch", name)
return []
return [], 0
url = f"{Config.IMMICH_URL}/api/search/metadata"
page_size = 1000
logger.debug("Fetching assets for %s...", name)
assets = []
assets: list[dict] = []
total_raw = 0 # items seen across all pages before non-dict filtering
for page in range(1, MAX_PAGES + 1):
try:
resp = requests.post(
@@ -116,6 +122,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
page_assets = page_assets.get("items", [])
page_count = len(page_assets) # raw count for termination check before filtering
total_raw += page_count
# Single pass: partition valid assets from unexpected non-dict items
valid_assets, skipped_count = [], 0
@@ -128,6 +135,11 @@ def fetch_all_assets(person: dict) -> list[dict]:
logger.warning("%s: skipping %s non-dict item(s) in page %s", name, skipped_count, page)
if not valid_assets:
if page_count > 0:
logger.warning(
"%s: page %s returned %s item(s) but none were valid dicts — stopping pagination",
name, page, page_count,
)
break
assets.extend(valid_assets)
@@ -140,7 +152,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
logger.error("Exception fetching assets for %s (page %s): %s", name, page, e)
break
return assets
return assets, total_raw
def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | None:
+8 -6
View File
@@ -162,10 +162,10 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
console.print(f"Scanning for {name}...")
with console.status("[bold green]Fetching assets...[/bold green]"):
all_assets = fetch_all_assets(person)
all_assets, total_raw = fetch_all_assets(person)
recent_assets = filter_recent_assets(all_assets, years=years)
rprint(f" Found [bold]{len(all_assets)}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
rprint(f" Found [bold]{total_raw}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
# Ask before strategy so the post-dedup count can inform the choice
retry_env = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
@@ -262,16 +262,18 @@ def auto_configure(people: list[dict]) -> list[dict]:
for person in valid_people:
name = person["name"]
all_assets = fetch_all_assets(person)
all_assets, total_raw = fetch_all_assets(person)
recent_assets = filter_recent_assets(all_assets, years=Config.YEARS_FILTER)
rprint(f" {name}: {len(all_assets)} total, {len(recent_assets)} recent")
rprint(f" {name}: {total_raw} total, {len(recent_assets)} recent")
# MIN_FACE_COUNT guard: skip people with too few Immich assets.
# Uses total_raw (pre-filter count) so non-dict items from a transient
# Immich schema issue don't cause a person to be skipped incorrectly.
# Done here (after fetch) rather than upfront because Immich v2.7.5+
# dropped assetCount from the /api/people response.
if min_face_count > 0 and len(all_assets) < min_face_count:
rprint(f" [dim]Skipping {name} ({len(all_assets)} assets < MIN_FACE_COUNT={min_face_count}).[/dim]")
if min_face_count > 0 and total_raw < min_face_count:
rprint(f" [dim]Skipping {name} ({total_raw} assets < MIN_FACE_COUNT={min_face_count}).[/dim]")
continue
# Enforce MAX_AUTO_IMAGES against the tracked file count only.