Files
photoanalyzer/webapp/runner.py

111 lines
4.7 KiB
Python

"""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}