215 lines
8.2 KiB
Python
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")}
|