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
This commit is contained in:
2026-06-15 02:56:51 +00:00
parent 3c4479a110
commit 728b84dc8c
11 changed files with 126 additions and 37 deletions
+12 -16
View File
@@ -22,7 +22,7 @@ from .frigate_api import (
from .image_processing import process_face_mode
from .immich_api import fetch_full_image
from .log_config import console
from .quality import assess_quality
from .quality import blur_score_from_image
from .reconcile import enrich_asset_with_face_data, reconcile_frigate_mappings
from .upload_tracker import (
get_lowest_quality_mapped_file,
@@ -176,20 +176,12 @@ def execute_jobs(jobs: list[dict]) -> None:
if isinstance(saved, tuple):
dims_map[filename] = saved
# Time-spread path: compute blur score from the downloaded
# image. Cap at 1440px so the scale matches the preview
# thumbnails the embedding path uses for scoring — Laplacian
# variance grows with resolution, making full-res and
# thumbnail scores incomparable if left uncapped.
# image. Capped at 1440px via blur_score_from_image() so the
# scale matches the preview thumbnails the embedding path uses
# — Laplacian variance grows with resolution, making full-res
# and thumbnail scores incomparable if left uncapped.
if score_map[filename] is None:
try:
score_img = img.convert("RGB") if img.mode != "RGB" else img
if score_img.width > 1440 or score_img.height > 1440:
score_img = score_img.copy()
score_img.thumbnail((1440, 1440), Image.LANCZOS)
score_map[filename] = assess_quality(score_img).blur_score
except Exception as exc:
logger.debug("Quality score fallback for %s: %s", asset["id"], exc)
score_map[filename] = 0.0 # unknown quality — treat as lowest
score_map[filename] = blur_score_from_image(img)
count += 1
else:
@@ -197,7 +189,7 @@ def execute_jobs(jobs: list[dict]) -> None:
f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]"
)
except Exception as e:
logger.error("Failed to process asset %s: %s", asset["id"], e)
logger.error("Failed to process asset %s: %s", asset.get("id", "<unknown>"), e)
progress.advance(job_task)
progress.advance(overall_task)
@@ -534,7 +526,11 @@ def upload_to_frigate(jobs: list[dict]) -> None:
progress.console.print(f" [dim]{error_detail}[/dim]")
else:
logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail)
if resp.status_code == 400 and "face" in full_body.lower():
_is_permanent = (
(resp.status_code == 400 and "face" in full_body.lower())
or resp.status_code == 422
)
if _is_permanent:
asset_id = asset_map.get(fname)
if asset_id:
mark_rejected(asset_id, person_name=name)