Bootstrap project and safe work-item workflow
This commit is contained in:
110
webapp/runner.py
Normal file
110
webapp/runner.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user