"""Drive photo_analyzer.py as a subprocess and expose live run-state to the web UI. Why a subprocess and not an in-process thread: the pipeline already is a complete, resume-safe CLI that owns SQLite as the source of truth. Running it as `python photo_analyzer.py …` keeps the two fully decoupled — the web server only *reads* the same DB for progress, Stop is the analyzer's own tested SIGINT drain, and a web crash can't corrupt a run (or vice-versa). Progress is derived from the DB, so it survives a browser reload and matches `--stats` exactly. """ import signal import subprocess import sys import threading from pathlib import Path from . import query REPO = Path(__file__).resolve().parent.parent # dir holding photo_analyzer.py _FLAGS = ("dry-run", "reanalyze", "no-exif", "exif-only", "group-variants") # Exit-after-action modes: each runs one maintenance task and quits (no analysis). # Passed alone — the boolean _FLAGS are ignored when an action is chosen. _ACTIONS = ("backfill-phash", "list-dupes", "dedupe") class Runner: def __init__(self, db_path: str, log): self.db = db_path self.log = log self.proc: subprocess.Popen | None = None self.lock = threading.Lock() def running(self) -> bool: return self.proc is not None and self.proc.poll() is None def start(self, opts: dict) -> dict: with self.lock: if self.running(): return {"error": "A run is already in progress."} lib = (opts.get("library") or "").strip() if not lib: return {"error": "Library path is required."} argv = [sys.executable, str(REPO / "photo_analyzer.py"), "--library", lib, "--db", self.db] action = opts.get("action") if action in _ACTIONS: argv.append(f"--{action}") # maintenance action, runs alone else: argv += [f"--{f}" for f in _FLAGS if opts.get(f)] try: self.proc = subprocess.Popen( argv, cwd=str(REPO), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) except Exception as e: return {"error": f"Could not start: {e}"} threading.Thread(target=self._reader, args=(self.proc,), daemon=True).start() self.log("run started: " + " ".join(argv[2:])) return {"ok": True} def _reader(self, proc): # Surface the analyzer's own log lines in the browser's Activity panel. for line in proc.stdout: line = line.rstrip() if line: self.log(line) self.log(f"run finished (exit {proc.poll()})") def stop(self) -> dict: with self.lock: if self.running(): self.proc.send_signal(signal.SIGINT) # reuse the tested drain path self.log("stop requested") return {"ok": True} def progress(conn, library: Path | None, running: bool) -> dict: """DB-derived run state for /progress. Cheap full scan (~25k rows, a few ms); ponytail: if the library grows past ~200k, cache album counts between polls.""" st = {r["status"]: r["n"] for r in conn.execute( "SELECT status, COUNT(*) AS n FROM photos GROUP BY status")} done = st.get("analyzed", 0) + st.get("exif_written", 0) dup = st.get("duplicate", 0) # Duplicates are deliberately skipped, not pending work — exclude them from the # denominator so the bar reflects real progress rather than dragging to <100%. work_total = sum(st.values()) - dup albums: dict[str, dict] = {} for r in conn.execute("SELECT path, status FROM photos"): if r["status"] == "duplicate": continue # not work; keep albums reaching 100% a = query.album_of(r["path"], library) d = albums.setdefault(a, {"album": a, "done": 0, "total": 0}) d["total"] += 1 if r["status"] in query.DONE: d["done"] += 1 # incomplete albums first — that's what a running job is working on alist = sorted(albums.values(), key=lambda d: (d["done"] >= d["total"], d["album"])) feed = [] for r in conn.execute( "SELECT path, status, description, error_message FROM photos " "WHERE analyzed_at IS NOT NULL ORDER BY analyzed_at DESC LIMIT 14" ): ok = r["status"] != "error" feed.append({"ok": ok, "name": Path(r["path"]).name, "msg": (r["description"] if ok else r["error_message"]) or ""}) return {"running": running, "totals": {"done": done, "total": work_total, "ok": done, "err": st.get("error", 0), "dup": dup}, "albums": alist[:60], "feed": feed, "tokens": None}