"""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 []