@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.3] - 2026-06-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`LIMIT` env var crash**: non-integer values (e.g. `"30.5"`, `"all"`) now log a warning and fall back to the default instead of raising `ValueError` at startup.
|
||||||
|
|
||||||
|
- **Symlink guard on person output dir**: `shutil.rmtree` is now skipped if `person_dir` resolves to a symlink, preventing traversal out of `OUTPUT_DIR` on a shared volume.
|
||||||
|
|
||||||
|
- **`person["id"]` KeyError**: malformed Immich API responses missing the `id` field now log an error and skip that person instead of crashing the job.
|
||||||
|
|
||||||
|
- **Face data response type validation**: `fetch_face_data` now validates that the `/api/faces` response is a list before indexing, guarding against null or non-list API responses.
|
||||||
|
|
||||||
|
- **Pagination error log includes page number**: the exception log in `fetch_all_assets` now includes the page number that failed.
|
||||||
|
|
||||||
|
- **`Image.open()` wrapped for non-image responses**: PIL parse errors on thumbnail fetches (e.g. reverse-proxy HTML error page returning 200) are now caught and logged instead of propagating.
|
||||||
|
|
||||||
|
- **Frigate version `v`-prefix handling**: `v0.16.0`-style version strings are now correctly parsed; the leading `v` was previously misread, causing the too-old warning to never fire.
|
||||||
|
|
||||||
|
- **`FRIGATE_SCORE_CEILING` parse guard**: a non-float value in `.env` now logs a warning and disables the ceiling instead of crashing at startup.
|
||||||
|
|
||||||
|
- **Dual config file warning**: a log warning is emitted when both `DATA_DIR/.immich_config.json` and the legacy CWD config file exist simultaneously.
|
||||||
|
|
||||||
|
- **PID file write guard**: `OSError` on `/tmp/winnow.pid` write is now caught and logged instead of crashing the scheduler.
|
||||||
|
|
||||||
|
- **Scheduler sleep clamped to 60 s**: bounds recovery time after an NTP clock step.
|
||||||
|
|
||||||
|
- **`get_frigate_person_files` non-list debug log**: consistent with `get_all_frigate_person_files`.
|
||||||
|
|
||||||
## [0.5.2] - 2026-06-14
|
## [0.5.2] - 2026-06-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
|
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
+5
-2
@@ -31,7 +31,10 @@ def _run_scheduler() -> None:
|
|||||||
print("Error: CRON_SCHEDULE environment variable is required.", flush=True)
|
print("Error: CRON_SCHEDULE environment variable is required.", flush=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
Path("/tmp/winnow.pid").write_text(str(os.getpid()))
|
try:
|
||||||
|
Path("/tmp/winnow.pid").write_text(str(os.getpid()))
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Warning: could not write PID file: {e}", flush=True)
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cron = croniter(schedule, now)
|
cron = croniter(schedule, now)
|
||||||
@@ -53,7 +56,7 @@ def _run_scheduler() -> None:
|
|||||||
print(f"winnow run failed: {e}", flush=True)
|
print(f"winnow run failed: {e}", flush=True)
|
||||||
next_run = cron.get_next(float)
|
next_run = cron.get_next(float)
|
||||||
print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True)
|
print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True)
|
||||||
time.sleep(max(1, next_run - time.time()))
|
time.sleep(min(60, max(1, next_run - time.time())))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+15
-1
@@ -93,7 +93,14 @@ class _Config:
|
|||||||
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "20"))
|
self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "20"))
|
||||||
self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes")
|
self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes")
|
||||||
_ceiling_env = os.getenv("FRIGATE_SCORE_CEILING", "").strip()
|
_ceiling_env = os.getenv("FRIGATE_SCORE_CEILING", "").strip()
|
||||||
self.FRIGATE_SCORE_CEILING = float(_ceiling_env) if _ceiling_env else None
|
if _ceiling_env:
|
||||||
|
try:
|
||||||
|
self.FRIGATE_SCORE_CEILING = float(_ceiling_env)
|
||||||
|
except ValueError:
|
||||||
|
logging.warning("FRIGATE_SCORE_CEILING=%r is not a valid float — ignoring", _ceiling_env)
|
||||||
|
self.FRIGATE_SCORE_CEILING = None
|
||||||
|
else:
|
||||||
|
self.FRIGATE_SCORE_CEILING = None
|
||||||
self.ENABLE_FRIGATE_SCORES = os.getenv("ENABLE_FRIGATE_SCORES", "true").lower() in ("true", "1", "yes")
|
self.ENABLE_FRIGATE_SCORES = os.getenv("ENABLE_FRIGATE_SCORES", "true").lower() in ("true", "1", "yes")
|
||||||
self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15"))
|
self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15"))
|
||||||
self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
|
self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
|
||||||
@@ -116,6 +123,13 @@ class _Config:
|
|||||||
# Prefer DATA_DIR/.immich_config.json (volume-safe in Docker) and fall back
|
# Prefer DATA_DIR/.immich_config.json (volume-safe in Docker) and fall back
|
||||||
# to the legacy CWD path so existing installations continue to work.
|
# to the legacy CWD path so existing installations continue to work.
|
||||||
_data_cfg = Path(self.DATA_DIR) / ".immich_config.json"
|
_data_cfg = Path(self.DATA_DIR) / ".immich_config.json"
|
||||||
|
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():
|
if config_file.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
+12
-2
@@ -100,6 +100,9 @@ def execute_jobs(jobs: list[dict]) -> None:
|
|||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
continue
|
continue
|
||||||
# Face crops are transient (uploaded then discarded); wipe before each run.
|
# 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):
|
if os.path.isdir(person_dir):
|
||||||
shutil.rmtree(person_dir)
|
shutil.rmtree(person_dir)
|
||||||
os.makedirs(person_dir, exist_ok=True)
|
os.makedirs(person_dir, exist_ok=True)
|
||||||
@@ -137,7 +140,14 @@ def execute_jobs(jobs: list[dict]) -> None:
|
|||||||
headers=get_headers(),
|
headers=get_headers(),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
img = Image.open(BytesIO(resp.content)) if resp.ok else None
|
if resp.ok:
|
||||||
|
try:
|
||||||
|
img = Image.open(BytesIO(resp.content))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Invalid image data for asset %s", asset["id"])
|
||||||
|
img = None
|
||||||
|
else:
|
||||||
|
img = None
|
||||||
|
|
||||||
if img is None:
|
if img is None:
|
||||||
progress.console.print(f"[red]Failed download {asset['id']}[/red]")
|
progress.console.print(f"[red]Failed download {asset['id']}[/red]")
|
||||||
@@ -208,7 +218,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
|
|||||||
_frigate_version = get_frigate_version()
|
_frigate_version = get_frigate_version()
|
||||||
if _frigate_version is not None:
|
if _frigate_version is not None:
|
||||||
try:
|
try:
|
||||||
parts = [int(x) for x in _frigate_version.split("-")[0].split(".") if x.isdigit()]
|
parts = [int(x) for x in _frigate_version.lstrip("v").split("-")[0].split(".") if x.isdigit()]
|
||||||
if len(parts) >= 2 and (parts[0], parts[1]) < (0, 16):
|
if len(parts) >= 2 and (parts[0], parts[1]) < (0, 16):
|
||||||
rprint(
|
rprint(
|
||||||
f" [yellow]⚠ Frigate {_frigate_version} detected — "
|
f" [yellow]⚠ Frigate {_frigate_version} detected — "
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ def get_frigate_person_files(person_name: str) -> list[str] | None:
|
|||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
files = data.get(person_name)
|
files = data.get(person_name)
|
||||||
return files if isinstance(files, list) else []
|
if files is not None and not isinstance(files, list):
|
||||||
|
logger.debug("Frigate API: unexpected type for %r — got %s, not list", person_name, type(files).__name__)
|
||||||
|
return []
|
||||||
|
return files if files is not None else []
|
||||||
|
|
||||||
|
|
||||||
def recognize_face(file_path: str) -> tuple[str | None, float] | None:
|
def recognize_face(file_path: str) -> tuple[str | None, float] | None:
|
||||||
|
|||||||
+10
-5
@@ -86,7 +86,10 @@ def merge_people(survivor_id: str, merge_ids: list[str]) -> bool:
|
|||||||
def fetch_all_assets(person: dict) -> list[dict]:
|
def fetch_all_assets(person: dict) -> list[dict]:
|
||||||
"""Fetch all assets for a person with pagination."""
|
"""Fetch all assets for a person with pagination."""
|
||||||
name = person.get("name", "Unknown")
|
name = person.get("name", "Unknown")
|
||||||
person_id = person["id"]
|
person_id = person.get("id")
|
||||||
|
if not person_id:
|
||||||
|
logger.error("Person dict missing 'id' field for %s — skipping asset fetch", name)
|
||||||
|
return []
|
||||||
url = f"{Config.IMMICH_URL}/api/search/metadata"
|
url = f"{Config.IMMICH_URL}/api/search/metadata"
|
||||||
page_size = 1000
|
page_size = 1000
|
||||||
|
|
||||||
@@ -120,7 +123,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
|||||||
break
|
break
|
||||||
|
|
||||||
except (requests.RequestException, ValueError) as e:
|
except (requests.RequestException, ValueError) as e:
|
||||||
logger.error("Exception fetching assets for %s: %s", name, e)
|
logger.error("Exception fetching assets for %s (page %s): %s", name, page, e)
|
||||||
break
|
break
|
||||||
|
|
||||||
return assets
|
return assets
|
||||||
@@ -152,18 +155,20 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
faces = resp.json()
|
faces = resp.json()
|
||||||
if not faces:
|
if not isinstance(faces, list) or not faces:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Match the target person if specified
|
# Match the target person if specified
|
||||||
face = None
|
face = None
|
||||||
if person_id:
|
if person_id:
|
||||||
face = next(
|
face = next(
|
||||||
(f for f in faces if (f.get("person") or {}).get("id") == person_id),
|
(f for f in faces if isinstance(f, dict) and (f.get("person") or {}).get("id") == person_id),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if face is None:
|
if face is None:
|
||||||
face = faces[0] # Fall back to first/largest face
|
face = faces[0] if isinstance(faces[0], dict) else None
|
||||||
|
if face is None:
|
||||||
|
return None
|
||||||
|
|
||||||
bbox = (
|
bbox = (
|
||||||
face.get("boundingBoxX1", 0),
|
face.get("boundingBoxX1", 0),
|
||||||
|
|||||||
+13
-2
@@ -68,11 +68,22 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st
|
|||||||
custom_limit = os.environ.get("LIMIT", "").strip()
|
custom_limit = os.environ.get("LIMIT", "").strip()
|
||||||
|
|
||||||
if not has_embedding:
|
if not has_embedding:
|
||||||
limit = int(custom_limit) if custom_limit else 30
|
if custom_limit:
|
||||||
|
try:
|
||||||
|
limit = int(custom_limit)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("LIMIT=%r is not a valid integer — using default 30", custom_limit)
|
||||||
|
limit = 30
|
||||||
|
else:
|
||||||
|
limit = 30
|
||||||
return limit, "time"
|
return limit, "time"
|
||||||
|
|
||||||
if custom_limit:
|
if custom_limit:
|
||||||
return int(custom_limit), "smart"
|
try:
|
||||||
|
return int(custom_limit), "smart"
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("LIMIT=%r is not a valid integer — using adaptive strategy", custom_limit)
|
||||||
|
# fall through to strategy_map
|
||||||
|
|
||||||
strategy_map = {
|
strategy_map = {
|
||||||
"adaptive": ("auto", "smart"),
|
"adaptive": ("auto", "smart"),
|
||||||
|
|||||||
Reference in New Issue
Block a user