# Photo Analyzer — Web App UX Concept Transform `photo_analyzer.py` from a terminal pipeline into a **local web app** that reuses the `nsfwtag` review-app design language and architecture. Concept first — implementation follows after review. > **North star:** the analyzed data (descriptions, tags, mood, people, year, …) is > currently invisible — it lives in a SQLite DB nobody can see. The web app's *primary* > job is to surface it (browse + FTS5 search); the analysis pipeline becomes a > monitored background job instead of a foreground terminal takeover. Design validated with the `ui-ux-pro-max` skill: **Data-Dense Dashboard** pattern (KPI cards, status colors green/amber/red, filtering-first, hover tooltips, row highlight), debounced search with a real "no results" state, list virtualization for 25k+ photos, and — to stay dependency-free — **CSS progress bars with values-always- visible** for stats rather than a chart library (the skill's AAA-accessible option). --- ## 1. Architecture (reuse nsfwtag's, unchanged in spirit) - One **stdlib `ThreadingHTTPServer`** on `127.0.0.1:`, opened in the browser. No framework, no build step, no external assets (matches nsfwtag). - One **token-injected HTML page** (`analyzer.html`, `%%TOKEN%%` replacement) + JSON endpoints. Same "real HTML file, not a Python string" rule. - **SQLite stays the single source of truth.** The server never owns pipeline state; it reads the DB and an in-memory run-state object (exactly like the terminal `_Dashboard` reads dicts + a deque). A render/HTTP crash can never corrupt the DB. - **Path args validated against the DB** on every request (nsfwtag validates against the scanned candidate set) — the server can't be pointed at arbitrary files. - The existing analysis engine (`_run_folder_loop`, workers, retry/backoff, `_db_lock`, variant grouping, nsfw-skip, `_IGNORE` exclusion) is **reused verbatim**, driven from a background thread. The web layer is a new view + control surface, not a rewrite. ### Endpoints | Method | Route | Purpose | |--------|-------|---------| | GET | `/` | The app page | | GET | `/img?path=` | Thumbnail bytes *(reuse nsfwtag)* | | GET | `/exif?path=` | Raw EXIF + decimal GPS + Maps URL *(reuse nsfwtag)* | | GET | `/photo?path=` | Full analyzed record for one photo (all DB fields) | | GET | `/search?q=&setting=&tod=&season=&people=&year_min=&year_max=&status=&album=&sort=&offset=` | Paged result set (FTS5 when `q` present) → `{rows:[…], total, offset}` | | GET | `/facets` | Distinct filter values + counts (for the toolbar) | | GET | `/stats` | Aggregates for the Stats view | | GET | `/progress` | Live run state: `{running, totals, tokens, eta, albums:[…], feed:[…]}` | | POST | `/run` | Start analysis with a config body (the CLI flags) | | POST | `/stop` | Signal the current run to stop (reuses the existing `_stop` event) | | GET | `/balance` · `/quota-check` | Proxy the existing functions → JSON | | GET | `/log` | Activity log *(reuse nsfwtag)* | Live updates use **polling** (`/progress` ~1.5 s, `/log` ~2 s) — same pattern as nsfwtag's log panel. No SSE/WebSocket dependency. --- ## 2. Information architecture Flat, three top-level views behind a header **segmented control** (deep-linked via `#library` / `#analyze` / `#stats`; active state highlighted per the skill's `nav-state-active`). An **Activity-log** slide-in panel is available from any view. ``` Header (sticky): [◧ logo] Photo Analyzer ⟨ Library · Analyze · Stats ⟩ [ total 25,318 · analyzed 25,316 · errors 8 ] [☰ log] ^ segmented, active = --text on --elev ``` - **Library** (default) — browse + search the analyzed photos. The payoff. - **Analyze** — run controls + the live dashboard (album progress, feed, tokens/ETA). - **Stats** — aggregates, breakdowns, error list. --- ## 3. View: Library *(browse + FTS5 search)* ``` ┌ Header ─ view: Library ──────────────────────────────────────────────────────────┐ ├ Toolbar (sticky) ─────────────────────────────────────────────────────────────────┤ │ 🔍 Search description, tags, mood, place… Setting▾ Time▾ Season▾ People▾ │ │ Year 1998 ●──────● 2019 ☐ has location Status▾ Sort: Relevance▾ │ ├──────────────┬────────────────────────────────────────────────────────────────────┤ │ Folders │ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ← virtualized / infinite │ │ ▸ Urlaub │ │ [img] │ │ [img] │ │ [img] │ │ [img] │ scroll, server-paged │ │ 2015 USA │ │ '15 👤2│ │ '04 │ │ '11 👤5│ │ ⚠err │ │ │ ▸ job │ └───────┘ └───────┘ └───────┘ └───────┘ │ │ ▸ selfies │ beach sunset… cathedral… wedding… (error: timeout) │ │ … │ … 25,316 results … │ └──────────────┴────────────────────────────────────────────────────────────────────┘ ``` - **Search** (`.field`): debounced 250 ms → `/search` FTS5 over `description, tags, mood, location_hint`. Autocomplete-style live results (skill `Search/Autocomplete`). Empty query = browse-all (paged). **No-results state** shows a message + suggestions ("try a broader term, or clear filters"), never a blank grid. - **Facet filters** map every analyzed field: - Setting (indoor/outdoor) · Time of day · Season → `select` or `.chip` toggles. - People → buckets `0 / 1 / 2 / 3+`. - Year → dual-handle range slider (`approx_year` min–max), tabular numbers. - Has-location → toggle (`location_hint` not null). - Status → analyzed / exif_written / error / pending. - Filters AND with the search; each updates the live result count. - **Folder sidebar** (reuse nsfwtag `.sidebar`/`.fitem`): album = **leaf folder** (reuse `album_label`), with counts; filter to a folder + its subfolders. - **Card** (reuse `.card`/`.thumb`): lazy `/img` thumbnail; overlays — **year badge** + **people badge** (reuse `.score` badge styling), a **status dot** (error=red, pending=faint), and the first line of the description under the thumbnail. Tag chips on hover. - **Grid scale:** 25k+ photos → **server-paged infinite scroll** (append on scroll), not load-all (skill `virtualize-lists`, `content-jumping`). Reserve card height to avoid layout shift. - **Lightbox** (reuse nsfwtag lightbox + keyboard + prev/next within the current result set): left = full image; right = a two-section panel: 1. **Analysis** — description, tags, mood, setting, time-of-day, season, people, year, location. (The EXIF caption Immich will index, made human-readable.) 2. **EXIF** — raw fields + GPS **Google Maps** link (reuse `/exif`). Per-photo action: **Re-analyze this one** (POST `/run` scoped to one path). --- ## 4. View: Analyze *(run controls + live dashboard)* Mirrors the terminal `_Dashboard` (see `DASHBOARD_PLAN.md`) as a web surface. ``` ┌ Header ─ view: Analyze ───────────────────────────────────────────────────────────┐ ├ Run bar ──────────────────────────────────────────────────────────────────────────┤ │ Library [ /…/pictures ▾ ] ☐ Dry-run ☐ Re-analyze ☐ No-EXIF ☐ EXIF-only ☐ Variants │ │ [ Balance ] [ Quota check ] [▶ Start] │ ├ Overall ──────────────────────────────────────────────────────────────────────────┤ │ ████████████████░░░░░░░░ 238 / 550 43 % ok 235 · err 3 │ │ tokens 284,192 · est. remaining ~340,000 · ETA 14:23 [■ Stop] (danger) │ ├ Albums (data-dense) ──────────────────┬ Recent feed ───────────────────────────────┤ │ Camera Roll 238/550 ███████░░ 43% │ ✓ IMG_4821.jpg │ │ Urlaub/Rom 45/ 45 ████████ ✓ │ A golden retriever on a sunny beach │ │ Urlaub/Venedig 12/ 60 ██░░░░░░ 20% │ ✗ DSC_0042.jpg — 4 retries: timeout │ │ WhatsApp Images 0/120 ░░░░░░░░ 0% │ ✓ IMG_4820.jpg │ │ … │ Two children building a sandcastle │ └───────────────────────────────────────┴─────────────────────────────────────────────┘ ``` - **Run bar** = the CLI flags as controls: Library path (default from `LIBRARY` env) · **Dry-run · Re-analyze · No-EXIF · EXIF-only · Group-variants** toggles · **Start** (primary) / **Stop** (danger). One run at a time (guarded); Start on an interrupted library simply **resumes** (DB is truth) — resume-safety is automatic, no separate control. - **Overall panel**: big progress bar, ok/err counts, tokens used + estimated remaining + ETA (all tabular). Status colors: green done, amber running, red error. - **Albums panel**: one row per **leaf-folder album** with a mini progress bar + `✓` when fully done (the DASHBOARD_PLAN target, now clickable → jumps to that album in Library). Future albums shown at 0 %. - **Recent feed**: newest-first list — thumbnail + filename + one-line description; errors in red with the message. Reuses the activity-log line styling. - **Balance / Quota-check**: small buttons → `/balance`, `/quota-check` → toast/inline result. **Debug log**: available via the Activity-log panel. - Everything here is driven by polling `/progress`; the panel degrades to "Idle — no run in progress" with a Start hint when nothing is running (empty state with action). --- ## 5. View: Stats *(aggregates + errors, no chart lib)* ``` ┌ KPI cards ────────────────────────────────────────────────────────────────────────┐ │ 25,318 total 25,316 analyzed 25,316 EXIF-written 8 errors 2 pending │ ├ Breakdowns (CSS bars, counts always visible) ───────────────────────────────────────┤ │ Setting outdoor ██████████████ 14,902 indoor ████████ 9,120 │ │ Season summer ████████ · autumn ██████ · spring ████ · winter ███ · unknown ██ │ │ People 0 ████████ · 1 ██████ · 2 ████ · 3+ ███ │ │ By year 1998▁ 2003▃ 2004▅ 2010▇ 2011█ 2015█ … (histogram of bars) │ │ Top tags beach 1,204 · wedding 980 · city 900 · food 610 … │ ├ Errors ─────────────────────────────────────────────────────────────────────────────┤ │ DSC_0042.jpg — connection timeout (4 retries) [Retry] │ │ … │ └──────────────────────────────────────────────────────────────────────────────────────┘ ``` - KPI cards reuse `.metric`. Breakdowns are **horizontal CSS bars with the count printed** (skill: values-always-visible, AAA; no dependency, no CSP issue). - Error table lists `status='error'` rows with `error_message` + a **Retry** action (re-queues that path for the next run — matches "re-running retries error records"). --- ## 6. Component inventory (reuse existing tokens) Everything below already exists in `nsfwtag/review.html`'s `:root` token set and class library — the web app inherits it wholesale (same dark OLED palette, radii, focus rings, reduced-motion guard). | Component | Source | New/changed | |---|---|---| | Header `.app`/`.brand`/`.logo`/`.titles` | reuse | title copy | | KPI `.metrics`/`.metric` | reuse | used for header counts + Stats cards | | View switcher | reuse `.seg` | active-state = current view | | Toolbar `.toolbar` | reuse | — | | Search `.field` | reuse | — | | Facet `.chip` / `select` / `.seg` | reuse | People/Setting/Season/Status | | Range slider `input[type=range]` | reuse | **dual-handle** year range (small JS) | | Sidebar `.sidebar`/`.fitem` | reuse | album tree | | Grid `.grid`/`.card`/`.thumb` | reuse | + year/people badges, status dot | | Tag chips | reuse `.chip` (mini) | — | | Lightbox + EXIF panel | reuse | + Analysis section | | Buttons `.btn`/`.primary`/`.danger`/`.ghost` | reuse | Start/Stop/Balance | | Toast `.toast` | reuse | — | | Activity log panel | reuse | run events too | | **Progress bar** | **new** `.bar` (fill = `--primary`) | overall + per-album + stat bars | | **Run toggles** | **new** switch styled from tokens | the 5 flags | **One new token:** `--warn:#f59e0b` (amber) for "running / partial" states — the only gap in the current green/red/faint status set. --- ## 7. Interaction model - **View switching**: header segmented control; state deep-linked in the URL hash and preserved on back/forward (skill `deep-linking`, `state-preservation`). - **Search + filters**: debounced; combine as AND; the URL hash encodes `q` + active filters so a search is shareable and survives reload. Result count updates live. - **Browse at scale**: infinite scroll appends server-paged results; card heights reserved (no CLS); thumbnails lazy-load. - **Lightbox**: `←/→` navigate the current result set, `Esc`/backdrop close; EXIF and full record fetched lazily on open. - **Analyze**: Start → POST `/run` (config) → server spawns the run on a background thread → dashboard polls `/progress`. Stop → POST `/stop` → sets the existing `_stop` event; in-flight requests drain, DB stays consistent, restart resumes. - **Accessibility/motion**: keyboard nav + visible focus rings (inherited), status never by color alone (badges carry text/icons), `prefers-reduced-motion` respected (token media query already present), all touch targets ≥ 36–44 px. --- ## 8. CLI → Web feature parity | CLI feature | Web surface | Preserved how | |---|---|---| | `--library` | Analyze › Run bar path (default `LIBRARY` env) | same discovery | | `--dry-run` | Run toggle | passed to engine | | `--reanalyze` | Run toggle | passed to engine | | `--no-exif` | Run toggle | passed to engine | | `--exif-only` | Run toggle | passed to engine | | `--group-variants` | Run toggle | reuses variant grouping | | `--stats` | **Stats view** | `/stats` aggregates | | `--balance` | Balance button | `/balance` | | `--quota-check` | Quota-check button | `/quota-check` | | `--debug` | Activity-log panel | server log | | resume-safety | automatic (Start resumes) | DB source of truth | | `_IGNORE/` exclusion | server-side discovery | `discover_photos` unchanged | | nsfw-skip | automatic in every run | `filter_nsfw_tagged` unchanged | | FTS5 search *(CLI had none)* | **Library search** | new surface for existing FTS5 table | | DB (all fields) | Library cards + lightbox + facets | new surface for existing schema | Nothing is dropped. The only genuinely new capability is **surfacing** what the DB already stores (search/browse/stats) — everything else is the same engine behind a web control panel instead of a terminal. --- ## 9. Proposed file layout (mirrors `nsfwtag/`) ``` webapp/ (or analyzer_web/) ├── __main__.py # CLI: python -m webapp → starts server, opens browser ├── server.py # ThreadingHTTPServer + routes (reuse nsfwtag/server.py shape) ├── page.py # render analyzer.html with %%TOKENS%% (reuse webapp.py shape) ├── analyzer.html # the single-page frontend (inherits nsfwtag tokens/classes) ├── query.py # DB read helpers: search (FTS5), facets, stats, one-photo └── runner.py # drive photo_analyzer's engine on a background thread + run-state ``` `photo_analyzer.py` stays the pipeline library; `runner.py` imports and drives it. No duplication of analysis logic. --- ## 10. Build order (for after approval) 1. **Read-only Library first** — server + page + `/search` + `/facets` + grid + lightbox over the *existing* DB. Immediately useful, zero risk to the pipeline. 2. **Stats view** — `/stats` + CSS breakdown bars. 3. **Analyze view** — `/run` + `/stop` + `/progress` polling + run-state object, driving the existing engine on a thread. 4. **Balance/quota/log** wiring + polish (empty states, deep-link hash, reduced-motion). Each step ships independently; the pipeline keeps working from the CLI throughout.