From b7d7ef38b494e91ce35b83d9f59c4e645a1424d0 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sat, 8 Aug 2026 09:05:25 +1000 Subject: [PATCH] Add Apprise notifications to quality-scan (self-hosted apprise server); dashboard ready --- apprise_helper.py | 42 ++++++++++++++++++++++++++++++++++++++++++ prefect.yaml | 15 +++++++++++++++ quality_scan.py | 24 +++++++++++++++++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 apprise_helper.py diff --git a/apprise_helper.py b/apprise_helper.py new file mode 100644 index 0000000..cb4d2b2 --- /dev/null +++ b/apprise_helper.py @@ -0,0 +1,42 @@ +"""photo-pipeline: Apprise notification helper. + +Posts to the self-hosted Apprise API server (fans out to all configured targets). +Usage: notify("Batch 3 done: 212 photos, 79 near-dups, 3 candidates") + +The Apprise server holds the target config; we just POST title/body. +""" + +import os +import subprocess + +APPRISE_URL = os.environ.get("APPRISE_URL", "https://apprise.lab.audasmedia.com.au/notify") + + +def notify(title: str, body: str, tag: str = None) -> bool: + """Send a notification via the Apprise API server. Returns success.""" + import json + + payload = {"title": title, "body": body} + if tag: + payload["tag"] = tag + + r = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + "-X", "POST", APPRISE_URL, + "-H", "Content-Type: application/json", + "-d", json.dumps(payload)], + capture_output=True, text=True, timeout=30, + ) + ok = r.stdout.strip() in ("204", "200") + print(f"[apprise] {title}: HTTP {r.stdout.strip()}") + return ok + + +if __name__ == "__main__": + # CLI test: python apprise.py "title" "body" + import sys + + t = sys.argv[1] if len(sys.argv) > 1 else "photo-pipeline test" + b = sys.argv[2] if len(sys.argv) > 2 else "Notification test from photo-pipeline" + ok = notify(t, b) + print("sent" if ok else "FAILED") diff --git a/prefect.yaml b/prefect.yaml index 38f0975..71f3520 100644 --- a/prefect.yaml +++ b/prefect.yaml @@ -72,3 +72,18 @@ deployments: name: photo-pool work_queue_name: null job_variables: {} + +- name: import + version: null + tags: [photo-pipeline] + description: "Import staged photos into Immich via server-bundled CLI (checksum dedup)" + schedule: null + flow_name: null + entrypoint: immich_import.py:immich_import + parameters: + src_dir: /mnt/data/01_keep + dry_run: true + work_pool: + name: photo-pool + work_queue_name: null + job_variables: {} diff --git a/quality_scan.py b/quality_scan.py index 470066a..dd66810 100644 --- a/quality_scan.py +++ b/quality_scan.py @@ -87,13 +87,15 @@ def _move(p: Path, dest_root: Path, src_root: Path): @flow(name="photo-quality-scan") -def quality_scan(base_dir: str, move: bool = False): +def quality_scan(base_dir: str, move: bool = False, notify: bool = True): """Audit image quality with CleanVision; classify into keep/review/delete.""" audit = audit_folder(base_dir) print(f"Issue summary: {audit['summary']}") result = classify_and_sort(base_dir, audit["per_image"], move=move) print(f"Verdicts: {result['counts']}") return {"audit": audit["summary"], **result} + if notify: + notify_result(result["counts"], audit["summary"], base_dir) if __name__ == "__main__": @@ -101,3 +103,23 @@ if __name__ == "__main__": d = sys.argv[1] if len(sys.argv) > 1 else "/tmp/cvtest" move = "--move" in sys.argv quality_scan(d, move=move) + + +@task +def notify_result(counts: dict, summary: list, base_dir: str): + """Send batch summary via Apprise.""" + import apprise_helper + + hard = counts.get("delete_candidate", 0) + soft = counts.get("review", 0) + keep = counts.get("keep", 0) + flagged = [s for s in summary if s["num_images"] > 0] + lines = "; ".join(f"{s[issue_type]}: {s[num_images]}" for s in flagged) or "none" + body = ( + f"Scanned: {base_dir}\n" + f"Keep: {keep} | Review: {soft} | Delete-candidates: {hard}\n" + f"Issues: {lines}\n" + f"Review: http://192.168.20.13:8092/review" + ) + apprise_helper.notify("📸 photo-pipeline batch complete", body) + return True