111 lines
4.7 KiB
Python
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}
|