18 KiB
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
ThreadingHTTPServeron127.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
_Dashboardreads 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,_IGNOREexclusion) 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 →/searchFTS5 overdescription, tags, mood, location_hint. Autocomplete-style live results (skillSearch/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 →
selector.chiptoggles. - People → buckets
0 / 1 / 2 / 3+. - Year → dual-handle range slider (
approx_yearmin–max), tabular numbers. - Has-location → toggle (
location_hintnot null). - Status → analyzed / exif_written / error / pending.
- Filters AND with the search; each updates the live result count.
- Setting (indoor/outdoor) · Time of day · Season →
- Folder sidebar (reuse nsfwtag
.sidebar/.fitem): album = leaf folder (reusealbum_label), with counts; filter to a folder + its subfolders. - Card (reuse
.card/.thumb): lazy/imgthumbnail; overlays — year badge- people badge (reuse
.scorebadge styling), a status dot (error=red, pending=faint), and the first line of the description under the thumbnail. Tag chips on hover.
- people badge (reuse
- 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:
- Analysis — description, tags, mood, setting, time-of-day, season, people, year, location. (The EXIF caption Immich will index, made human-readable.)
- EXIF — raw fields + GPS Google Maps link (reuse
/exif). Per-photo action: Re-analyze this one (POST/runscoped 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
LIBRARYenv) · 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 witherror_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_stopevent; 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-motionrespected (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)
- Read-only Library first — server + page +
/search+/facets+ grid + lightbox over the existing DB. Immediately useful, zero risk to the pipeline. - Stats view —
/stats+ CSS breakdown bars. - Analyze view —
/run+/stop+/progresspolling + run-state object, driving the existing engine on a thread. - Balance/quota/log wiring + polish (empty states, deep-link hash, reduced-motion).
Each step ships independently; the pipeline keeps working from the CLI throughout.