Files
photoanalyzer/photo_pipeline/services/library.py

223 lines
8.6 KiB
Python

"""LibraryService — read-only Library and Stats over analysis results.
Ports webapp/query.py (search, facets, stats, top-tag/people/year aggregates) onto
the shared database, re-keyed to ``asset_id`` and joined to ``assets`` for the
current path. The donor read a path-keyed ``photos`` table with an FTS5 index; here
search is a tokenised ``LIKE`` over description + tags.
ponytail: restore FTS5 (rank-ordered relevance) if search recall/latency matters at
real library size — LIKE is fine for browsing tens of thousands of rows.
Extracted from webapp/query.py (donor_ledger.yaml: wa-query-search, wa-query-stats).
Read-only: writes belong to AnalysisService.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from sqlalchemy import String, and_, case, cast, func, or_, select, text
from sqlalchemy.orm import sessionmaker
from photo_pipeline.models import AnalysisResult, Asset
CARD_FIELDS = (
"status",
"description",
"people_count",
"setting",
"time_of_day",
"season",
"mood",
"location_hint",
"approx_year",
)
DONE = ("analyzed",)
_SORTS = {
"year": (AnalysisResult.approx_year.desc(), Asset.current_path),
"people": (AnalysisResult.people_count.desc(), Asset.current_path),
"recent": (AnalysisResult.analyzed_at.desc(), Asset.current_path),
"path": (Asset.current_path,),
}
def _album_of(path: str | None) -> str:
return Path(path).parent.name if path else "(unknown)"
class LibraryService:
def __init__(self, session_factory: sessionmaker) -> None:
self._session_factory = session_factory
def search(self, q="", filters=None, sort="path", offset=0, limit=60) -> dict:
filters = filters or {}
with self._session_factory() as session:
stmt = select(AnalysisResult, Asset.current_path).join(
Asset, Asset.id == AnalysisResult.asset_id
)
conds = _filter_conditions(filters)
for token in re.findall(r"\w+", q, re.UNICODE):
like = f"%{token}%"
conds.append(
or_(AnalysisResult.description.ilike(like), AnalysisResult.tags.ilike(like))
)
if conds:
stmt = stmt.where(and_(*conds))
total = session.scalar(select(func.count()).select_from(stmt.subquery()))
stmt = stmt.order_by(*_SORTS.get(sort, _SORTS["path"])).limit(limit).offset(offset)
rows = [_card(result, path) for result, path in session.execute(stmt)]
return {"rows": rows, "total": total, "offset": offset, "limit": limit}
def stats(self) -> dict:
"""Library-wide totals, aggregated in SQL (US07-06).
This page used to load every analysis row — object, tags, and all — to count
them in Python, which cost half a second at 100k assets and grew from there.
Only the album breakdown still walks rows, and only their path and status:
SQLite has no ``dirname``, and two short strings per asset is cheap.
"""
with self._session_factory() as session:
status = dict(
session.execute(
select(AnalysisResult.status, func.count()).group_by(AnalysisResult.status)
).all()
)
albums: dict[str, dict] = {}
for path, row_status in session.execute(
select(Asset.current_path, AnalysisResult.status).join(
Asset, Asset.id == AnalysisResult.asset_id
)
):
album = _album_of(path)
bucket = albums.setdefault(album, {"album": album, "done": 0, "total": 0})
bucket["total"] += 1
if row_status in DONE:
bucket["done"] += 1
year_counts = dict(
session.execute(
select(AnalysisResult.approx_year, func.count())
.where(AnalysisResult.approx_year.is_not(None))
.group_by(AnalysisResult.approx_year)
).all()
)
people = dict(
session.execute(
select(
case(
(AnalysisResult.people_count >= 3, "3+"),
else_=cast(AnalysisResult.people_count, String),
),
func.count(),
)
.where(AnalysisResult.people_count.is_not(None))
.group_by(
case(
(AnalysisResult.people_count >= 3, "3+"),
else_=cast(AnalysisResult.people_count, String),
)
)
).all()
)
# SQLite's JSON1 counts the tag arrays where they are: parsing 50k JSON
# strings in Python to keep the top 40 is the definition of doing work
# the database already does. Malformed tags are skipped, not fatal.
tag_counts = session.execute(
text(
"SELECT tag.value AS value, count(*) AS total "
"FROM analysis_results, json_each(analysis_results.tags) AS tag "
"WHERE analysis_results.tags IS NOT NULL "
"AND json_valid(analysis_results.tags) "
"GROUP BY tag.value ORDER BY total DESC, value LIMIT 40"
)
).all()
errors = [
{"path": path, "error": message}
for path, message in session.execute(
select(Asset.current_path, AnalysisResult.error_message)
.join(Asset, Asset.id == AnalysisResult.asset_id)
.where(AnalysisResult.status == "error")
)
]
return {
"total": sum(status.values()),
"status": status,
"setting": self._facet("setting"),
"time_of_day": self._facet("time_of_day"),
"season": self._facet("season"),
"people": [{"value": v, "count": n} for v, n in sorted(people.items())],
"years": [{"value": y, "count": year_counts[y]} for y in sorted(year_counts)],
"top_tags": [{"value": t, "count": n} for t, n in tag_counts],
"albums": sorted(albums.values(), key=lambda d: d["album"]),
"errors": sorted(errors, key=lambda e: e["path"] or ""),
}
def facets(self) -> dict:
return {
"setting": self._facet("setting"),
"time_of_day": self._facet("time_of_day"),
"season": self._facet("season"),
"status": self._facet("status"),
}
def _facet(self, field: str) -> list[dict]:
column = getattr(AnalysisResult, field)
with self._session_factory() as session:
rows = session.execute(
select(column, func.count())
.where(column.is_not(None), func.trim(column) != "")
.group_by(column)
.order_by(func.count().desc())
).all()
return [{"value": value, "count": count} for value, count in rows]
def _filter_conditions(filters: dict) -> list:
conds = []
for key, column in (
("setting", AnalysisResult.setting),
("tod", AnalysisResult.time_of_day),
("season", AnalysisResult.season),
("status", AnalysisResult.status),
):
if filters.get(key):
conds.append(column == filters[key])
people = filters.get("people")
if people == "3+":
conds.append(AnalysisResult.people_count >= 3)
elif people in ("0", "1", "2"):
conds.append(AnalysisResult.people_count == int(people))
if filters.get("year_min"):
conds.append(AnalysisResult.approx_year >= int(filters["year_min"]))
if filters.get("year_max"):
conds.append(AnalysisResult.approx_year <= int(filters["year_max"]))
if filters.get("has_location"):
conds.append(
and_(
AnalysisResult.location_hint.is_not(None),
func.trim(AnalysisResult.location_hint) != "",
func.lower(AnalysisResult.location_hint) != "null",
)
)
return conds
def _card(result: AnalysisResult, path: str | None) -> dict:
card = {field: getattr(result, field) for field in CARD_FIELDS}
card.update(
asset_id=result.asset_id,
current_path=path,
album=_album_of(path),
tags=_tags(result.tags),
)
return card
def _tags(raw: str | None) -> list[str]:
if not raw:
return []
try:
return json.loads(raw)
except (ValueError, TypeError):
return []