"""Read-only DB access for the web UI: FTS5 search, facets, stats, one-photo. The analysis pipeline (photo_analyzer.py) owns writes; this module only reads. Album = leaf folder relative to the library (same rule as photo_analyzer.album_label). """ import json import os import re import sqlite3 from pathlib import Path from . import PAGE_SIZE # Columns sent to the browser for a card / lightbox. raw_response is intentionally # omitted from lists (big); the lightbox fetches it via /photo if ever needed. CARD_COLS = ("path", "status", "description", "tags", "people_count", "setting", "time_of_day", "season", "mood", "location_hint", "approx_year") DONE = ("analyzed", "exif_written") def connect(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path, check_same_thread=False) # server is multi-threaded conn.row_factory = sqlite3.Row return conn def album_of(path: str, library: Path | None) -> str: """Leaf folder relative to the library — mirrors photo_analyzer.album_label.""" parent = Path(path).parent if library: try: rel = parent.relative_to(library) return "(root)" if str(rel) == "." else str(rel) except ValueError: pass return parent.name or str(parent) def _fts_query(q: str) -> str | None: """Turn free text into a safe FTS5 MATCH string. Each word becomes a prefix term (AND-ed), so 'beach sun' matches 'beachy sunset'. Returns None if empty (→ caller browses instead of matching), so raw punctuation can't crash MATCH.""" tokens = re.findall(r"\w+", q, re.UNICODE) return " ".join(f'"{t}"*' for t in tokens) if tokens else None def _where(filters: dict) -> tuple[list[str], list]: """Build a WHERE clause fragment list + params from the facet filters.""" clauses, params = [], [] if v := filters.get("setting"): clauses.append("p.setting = ?"); params.append(v) if v := filters.get("tod"): clauses.append("p.time_of_day = ?"); params.append(v) if v := filters.get("season"): clauses.append("p.season = ?"); params.append(v) if v := filters.get("status"): clauses.append("p.status = ?"); params.append(v) people = filters.get("people") if people == "3+": clauses.append("p.people_count >= 3") elif people in ("0", "1", "2"): clauses.append("p.people_count = ?"); params.append(int(people)) if filters.get("year_min"): clauses.append("p.approx_year >= ?"); params.append(int(filters["year_min"])) if filters.get("year_max"): clauses.append("p.approx_year <= ?"); params.append(int(filters["year_max"])) if filters.get("has_location"): clauses.append("p.location_hint IS NOT NULL AND TRIM(p.location_hint) != '' " "AND LOWER(p.location_hint) != 'null'") if album := filters.get("album"): # album is an absolute leaf-folder path; match it and its subfolders clauses.append("(p.path LIKE ? OR p.path LIKE ?)") params += [f"{album}/%", f"{album}%/%"] return clauses, params _SORTS = { "year": "p.approx_year DESC NULLS LAST, p.path", "people": "p.people_count DESC NULLS LAST, p.path", "recent": "p.analyzed_at DESC NULLS LAST, p.path", "path": "p.path", } def _row_to_card(r: sqlite3.Row) -> dict: d = {k: r[k] for k in CARD_COLS} try: d["tags"] = json.loads(r["tags"]) if r["tags"] else [] except (ValueError, TypeError): d["tags"] = [] return d def search(conn, q="", filters=None, sort="relevance", offset=0, limit=PAGE_SIZE): """Paged result set. FTS5 when `q` is present, plain filter/browse otherwise. Returns {rows, total, offset, limit}.""" filters = filters or {} clauses, params = _where(filters) match = _fts_query(q) if q else None if match: base = ("FROM photos p JOIN photos_fts f ON p.id = f.rowid " "WHERE photos_fts MATCH ?") params = [match] + params order = "f.rank" if sort in ("relevance", "") else _SORTS.get(sort, "f.rank") else: base = "FROM photos p WHERE 1=1" order = _SORTS.get(sort if sort != "relevance" else "path", "p.path") if clauses: base += " AND " + " AND ".join(clauses) total = conn.execute(f"SELECT COUNT(*) {base}", params).fetchone()[0] rows = conn.execute( f"SELECT p.* {base} ORDER BY {order} LIMIT ? OFFSET ?", params + [limit, offset], ).fetchall() return {"rows": [_row_to_card(r) for r in rows], "total": total, "offset": offset, "limit": limit} def photo(conn, path: str) -> dict | None: r = conn.execute("SELECT * FROM photos WHERE path = ?", (path,)).fetchone() if not r: return None d = {k: r[k] for k in r.keys() if k != "raw_response"} try: d["tags"] = json.loads(r["tags"]) if r["tags"] else [] except (ValueError, TypeError): d["tags"] = [] return d def _col_counts(conn, col: str) -> list[dict]: rows = conn.execute( f"SELECT {col} AS v, COUNT(*) AS n FROM photos " f"WHERE {col} IS NOT NULL AND TRIM({col}) != '' GROUP BY {col} ORDER BY n DESC" ).fetchall() return [{"value": r["v"], "count": r["n"]} for r in rows] def facets(conn, library: Path | None) -> dict: """Distinct filter values + counts, plus the album tree, for the toolbar.""" year = conn.execute( "SELECT MIN(approx_year), MAX(approx_year) FROM photos WHERE approx_year IS NOT NULL" ).fetchone() albums: dict[str, int] = {} for (p,) in conn.execute("SELECT path FROM photos"): a = album_of(p, library) albums[a] = albums.get(a, 0) + 1 return { "setting": _col_counts(conn, "setting"), "tod": _col_counts(conn, "time_of_day"), "season": _col_counts(conn, "season"), "status": _col_counts(conn, "status"), "year_min": year[0], "year_max": year[1], "albums": [{"album": a, "count": n} for a, n in sorted(albums.items())], } def _people_buckets(conn) -> list[dict]: rows = conn.execute( "SELECT CASE WHEN people_count >= 3 THEN '3+' ELSE CAST(people_count AS TEXT) END AS b, " "COUNT(*) AS n FROM photos WHERE people_count IS NOT NULL GROUP BY b" ).fetchall() return [{"value": r["b"], "count": r["n"]} for r in rows] def _top_tags(conn, limit=40) -> list[dict]: counts: dict[str, int] = {} for (t,) in conn.execute("SELECT tags FROM photos WHERE tags IS NOT NULL"): try: for tag in json.loads(t): tag = str(tag).strip() if tag: counts[tag] = counts.get(tag, 0) + 1 except (ValueError, TypeError): continue top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:limit] return [{"value": t, "count": n} for t, n in top] def stats(conn, library: Path | None) -> dict: status = {r["status"]: r["n"] for r in conn.execute( "SELECT status, COUNT(*) AS n FROM photos GROUP BY status")} total = sum(status.values()) years = [{"value": r["y"], "count": r["n"]} for r in conn.execute( "SELECT approx_year AS y, COUNT(*) AS n FROM photos " "WHERE approx_year IS NOT NULL GROUP BY approx_year ORDER BY approx_year")] albums: dict[str, dict] = {} for r in conn.execute("SELECT path, status FROM photos"): a = album_of(r["path"], library) d = albums.setdefault(a, {"album": a, "done": 0, "total": 0}) d["total"] += 1 if r["status"] in DONE: d["done"] += 1 errors = [{"path": r["path"], "error": r["error_message"]} for r in conn.execute( "SELECT path, error_message FROM photos WHERE status = 'error' ORDER BY path")] return { "total": total, "status": status, "setting": _col_counts(conn, "setting"), "time_of_day": _col_counts(conn, "time_of_day"), "season": _col_counts(conn, "season"), "people": _people_buckets(conn), "years": years, "top_tags": _top_tags(conn), "albums": sorted(albums.values(), key=lambda d: d["album"]), "errors": errors, } def all_paths(conn) -> set: """Every registered path — the allow-set for /img and /exif (path validation).""" return {r[0] for r in conn.execute("SELECT path FROM photos")}