286 lines
18 KiB
Markdown
286 lines
18 KiB
Markdown
# 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:<random-free-port>`, 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.
|