Files
photoanalyzer/webapp/query.py

215 lines
8.2 KiB
Python

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