- Go 71%
- Vue 23.8%
- JavaScript 4.1%
- CSS 1%
| backend | ||
| frontend | ||
| .env.example | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
gen-gallery
A self-hosted gallery for your AI-generated image library. gen-gallery ingests tens of thousands of images from a drop folder, extracts the embedded generation metadata (prompts, samplers, seeds, models, LoRAs), and presents everything in a fast, filterable, dark-themed web UI.
It is a companion app to model-manager: every model, LoRA, LoCon, or Textual Inversion referenced by a generation links to its page in model-manager when a match exists (resolved by hash first, then name).
Stack: Go + Gin + GORM + SQLite backend, Vue 3 + Vite frontend — the same stack and conventions as model-manager. In production one binary serves the API, the built SPA, and all image/thumbnail files.
Features
- Bulk ingestion from a drop folder that works as an inbox: files are imported (deduplicated by SHA-256 content hash) and moved into a structured library folder (
YYYY/MM/originalname, collision-safe). Redundant copies of content already in the library are stashed under<library>/_duplicates— never deleted — so the drop folder always empties out and re-scans stay fast. Incremental, resumable, malformed files are logged and never abort a scan. Runs on startup, on a configurable interval, and on demand from the admin panel. Direct uploads flow through the same pipeline into the same library. - Metadata extraction for multiple generator formats, dispatched per file:
- A1111 / Forge — the
parametersPNG text chunk (flat text) and its JSON variant, includingLora hashes:/TI hashes:/Hashes:blocks and<lora:name:weight>prompt tags. Also read from JPEG/WebP EXIFUserComment. - ComfyUI — the embedded JSON node graph: the sampler node is located (highest-step pass wins), positive/negative links are followed through the graph to the text encoders, and checkpoint/LoRA/VAE loader nodes are collected.
- SwarmUI — bare and
sui_image_params-wrapped JSON, with hashes fromsui_models. - The raw metadata string is always stored verbatim, so nothing is lost when parsing misses a field. New formats plug in as one
FormatParserimplementation without touching the others. - Upscale detection is generator-agnostic: after per-format parsing, any leftover metadata key naming a hires-fix / refiner-upscale / second pass (matched by substring, e.g. A1111's
Hires upscale, SwarmUI'srefinerupscale) flags the image and its details land on the detail page; ComfyUI graphs are scanned for*Upscale*-class nodes since it has no flat metadata for this.
- A1111 / Forge — the
- Fast gallery: pre-generated WebP thumbnails (512 px / q80 by default, tunable in admin), numbered page-based pagination (jump to any page, page count driven by the result total), viewport lazy-loading, immutable cache headers on hash-named thumbnails, and grid responses that carry only what the grid renders.
- Search & filters: full-text positive-prompt search (SQLite FTS5, column-scoped so negative-prompt text can't cause false-positive matches, with an automatic LIKE fallback), plus an exact seed / variation-seed match in the same search box, base model family (derived from checkpoint names: Illustrious, Pony, NoobAI, SDXL, Flux, …), NSFW status, favorites, and an exact star rating (e.g. show only 3-star, or only unrated). Filters combine and live in the URL, so views are shareable and bookmarkable. A per-viewer toggle (persisted in the browser) reveals or blurs all NSFW thumbnails at once.
- Detail view: full-resolution image fit to the window, all generation parameters (including variation seed and upscale details when present), file created + imported dates, copy prompt / copy negative buttons, download original, star rating, favorite, inline NSFW toggle, and delete. Referenced resources link to model-manager when resolved and render as plain labels when not — which is the correct permanent behavior for resources model-manager doesn't know.
- Bulk actions & delete: multi-select in any gallery view to set/clear star rating, favorite/unfavorite, mark NSFW/safe, add to a collection, or delete. Deleting removes the DB record and thumbnail and moves the original into
<library>/_trash(recoverable, never re-scanned) rather than unlinking it. - Collections: many-to-many groupings with bulk add from the gallery's select mode, plus two kinds of server-side smart suggestions: heuristic clustering by dominant checkpoint and frequent prompt keywords (no external service needed), and optional AI suggestions from any OpenAI-compatible endpoint — designed for a local LM Studio server (see below).
- Sharing: grant another user read-only access to your whole gallery or a single collection.
- Auth: server-side sessions in an httpOnly SameSite cookie (no JWTs in localStorage), bcrypt password hashes, login rate limiting, session revocation on disable/password reset. The entire app — including image and thumbnail files — requires a session.
- Admin panel: settings (drop folder, scan interval, thumbnail size/quality, page size, NSFW keyword list, model-manager integration, optional AI-categorization endpoint), user management (create/disable/reset/role), scan & backfill job status, resolver seed mappings, a re-run NSFW detection button, and a re-parse upscale / variation seed button that re-derives those two fields for already-imported images from their stored raw metadata (added after the fields themselves, so existing libraries need one run to catch up — no re-scan or file access needed).
Quick start (development)
Prerequisites: Go 1.24+ (newer toolchains auto-download as needed) and Node 20+.
# Backend (port 8081)
cd backend
go run .
# Frontend dev server (port 5174, proxies /api, /images, /thumbnails)
cd frontend
npm install
npm run dev
On first run a bootstrap admin is created — set ADMIN_USERNAME/ADMIN_PASSWORD in .env, or check the log for the generated password. Then log in, open Admin → Settings, set the drop folder path, and trigger a scan from Admin → Jobs.
Production build (single binary)
cd frontend && npm install && npm run build # emits frontend/dist
cd ../backend && go build -o gen-gallery .
./gen-gallery # serves API + SPA + files on PORT
The binary finds the built SPA on its own — it checks frontend/dist relative to both the working directory and the executable's location, so running it from the repo root, from backend/, or as a systemd service with any WorkingDirectory all work (the startup log prints which path it picked, or a warning if none was found). Set FRONTEND_DIST only for nonstandard layouts. Copy .env.example to .env and adjust; .env is read from the working directory.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
PORT |
8081 |
HTTP port |
DATA_DIR |
./data |
App data: DB, thumbnail cache, uploads |
DB_PATH |
DATA_DIR/gallery.db |
SQLite database file |
DROP_FOLDER |
— | Seeds the drop-folder (inbox) setting on first run |
LIBRARY_DIR |
DATA_DIR/library |
Seeds the library-folder setting on first run; imported originals are moved here |
SESSION_SECRET |
generated | HMAC key for session tokens; generated and persisted in the settings table if unset |
COOKIE_SECURE |
false |
Set true when serving over HTTPS (or behind a TLS proxy) to mark the session cookie Secure |
ADMIN_USERNAME |
admin |
Bootstrap admin username (first run only) |
ADMIN_PASSWORD |
generated | Bootstrap admin password (first run only; generated and logged if unset) |
FRONTEND_DIST |
auto-detected | Built SPA location; auto-detection checks frontend/dist next to the working directory and the executable |
GIN_MODE |
debug |
Set release in production |
No secrets are committed: everything sensitive comes from the environment or the admin settings table. .env is gitignored; only .env.example ships.
Example: split storage (bulk pool + fast disk)
Every storage location is independently placeable, so a typical NAS/homelab split is:
DATA_DIR=/tank/gen-gallery # thumbnails + upload temp on the bulk pool
DROP_FOLDER=/tank/gen-gallery/drop # inbox on the pool
#LIBRARY_DIR defaults to DATA_DIR/library # imported originals on the pool
DB_PATH=/var/lib/gen-gallery/gallery.db # SQLite (WAL) on fast local storage
Keeping drop, temp, and library on one filesystem makes every import move an atomic rename. The DB is the one piece that wants low-latency synchronous writes — keep it on local fast storage, never on NFS. Thumbnails are named by content hash, so the thumbnail directory can be relocated wholesale later (just move it and update DATA_DIR) without regenerating anything.
Admin settings
| Setting | Default | Notes |
|---|---|---|
| Drop folder path | — | Inbox scanned for new images; imported files are moved out |
| Library folder path | DATA_DIR/library |
Where imported originals live (YYYY/MM/name) |
| Scan interval (minutes) | 10 | 0 disables periodic scans |
| Thumbnail size | 512 | Long-edge pixels; applies to newly generated thumbnails |
| Thumbnail WebP quality | 80 | 1–100 |
| Gallery page size | 50 | Images per gallery page |
| NSFW keyword list | sensible default | Comma-separated; auto-flags prompts at import by whole-word match (so "sex" flags "foo, sex, bar" but not "sexy"; punctuation/spacing ignored; multi-word phrases supported). Manual toggles always win. Admin → Settings has a Re-run NSFW detection button that re-applies the rules across the library, skipping images you've flagged/cleared by hand. |
| model-manager enabled + base URL + API key | off | Gates the HTTP resolver; the local stub is used when off |
| AI categorization endpoint + model + key | — | OpenAI-compatible base URL for AI suggestions (key optional; LM Studio ignores it). Heuristics work regardless |
AI collection suggestions (LM Studio / OpenAI-compatible)
The AI suggest button on the Collections page asks an LLM to propose thematic collections from a sample of your prompts (most recent 300, budgeted to fit a local model's context). The model returns named themes with matching keywords; the server maps each theme back to your library by keyword matching, shows the match count and a cover, and accepting a theme creates a real collection from the matching images. Themes matching fewer than 2 images are dropped.
Setup for LM Studio on your network:
- In LM Studio, load a model and enable the local server (default port 1234).
- In Admin → Settings, set AI categorization endpoint to
http://<lmstudio-ip>:1234/v1and optionally the AI model name to the loaded model's identifier (blank lets the server use whatever is loaded). The API key is unused by LM Studio and can stay empty. - On the Collections page, click AI suggest. Local models can take a while — the button spins until the model answers.
Anything OpenAI-compatible works the same way (Ollama's /v1, OpenAI itself with an API key, etc.). Nothing is ever sent anywhere unless the endpoint setting is filled in, and requests go only to that endpoint. The heuristic Suggest collections button never calls any service.
Deep analysis (whole library, one-time)
Deep analyze on the Collections page is the big-bang variant for organizing thousands of images at once. It runs as a background job (progress shown on the page; safe to navigate away):
- Cluster — every prompt in the library is pulled with its image count, normalized (lora tags, attention weights, and ordering stripped), and near-duplicate prompts are merged by tag-set similarity. Iterative refinement sessions ("hundreds of generations to narrow down a prompt") collapse to a single line annotated with the image count, e.g.
1girl, knight armor, castle (x347), so a 10k-image library typically shrinks to a few hundred lines. - Map — the clustered lines are split into ~24 KB chunks; each chunk gets its own themes from the model (a failed chunk is skipped, not fatal).
- Reduce — all candidate themes (tiny compared to the prompts) go into one final call that merges duplicates into a cohesive set of 8–15 collections for the whole library. This is what keeps chunk 3's "Fantasy Landscapes" and chunk 17's "Epic Scenery" from becoming two collections.
- Validate — each final theme is matched against every image's prompt in SQL; counts and covers come from real data, and themes matching under 2 images are dropped.
Collection membership is always the SQL keyword match over the full library — if 200 images share one prompt, all 200 join the collection, regardless of what the model saw. Expect a run to take minutes to an hour on a local model depending on library size (one chat call per chunk plus one consolidation call). Results are held in memory until the next run or restart; accepting a theme creates the real collection.
model-manager integration
model-manager does not expose a lookup API yet, so all linking goes through a ModelResolver interface (backend/resolver) — call sites never touch HTTP directly:
StubResolver(default): answers from a local, admin-editable seed table (hash/name → model-manager ID + URL) and returns not-found for everything else. The app is fully functional with the stub; unmatched resources render as plain labels.HTTPModelResolver(provisional, unverified): implements the contract model-manager should expose later:GET {baseURL}/api/models/by-hash/{hash}GET {baseURL}/api/models/by-name/{name}200→{"id": <int>, "name": <string>, "url": <string>}(whereurlis the model's page in the model-manager UI),404→ no match. OptionalAuthorization: Bearer <api key>.
- Every resource reference persists its hash and name regardless of match state, so when the real API lands you enable the integration in admin settings and run Re-resolve model-manager links (Admin → Jobs). The backfill re-checks every unlinked reference across the whole library — no re-import needed. It resolves each distinct hash/name pair once and applies the result to all rows sharing it.
Tests
# Go unit tests (metadata parser for every format, resolver contract,
# backfill, scanner dedupe/resume/NSFW)
cd backend && go test ./...
# Frontend unit tests (Vitest)
cd frontend && npm run test
# End-to-end (Playwright) — needs a running backend with known credentials:
# ADMIN_USERNAME=e2e ADMIN_PASSWORD=e2e-password DATA_DIR=./e2e-data go run .
cd frontend
npx playwright install chromium # first time
E2E_USERNAME=e2e E2E_PASSWORD=e2e-password npm run test:e2e
Design decisions & tradeoffs
- Thumbnails without CGO: WebP encoding uses
gen2brain/webp, which runs libwebp compiled to WebAssembly (wazero). That keeps the single-binary build fully portable (no libvips/CGO toolchain needed on Windows) at a modest CPU cost versus native libvips; decoding and resizing usedisintegration/imaging. If you later want libvips speed, thethumbspackage is the only place to swap. - Pure-Go SQLite (
glebarez/sqlite/ modernc) for the same no-CGO reason; its build includes FTS5. If FTS5 were ever unavailable, prompt search degrades to LIKE automatically (logged at startup). - Move-on-import library: the drop folder is an inbox. Imports move files into
LIBRARY_DIR/YYYY/MM/(dated by file mtime, the best proxy for generation time), name collisions get a short content-hash suffix, and cross-filesystem moves fall back to copy+delete — so the drop folder, library, andDATA_DIRcan all live on different disks (e.g. library on an HDD/ZFS pool, DB and thumbnails on SSD viaDB_PATH/DATA_DIR). If a recorded library file goes missing and the same content is dropped again, the import re-binds the existing DB row to the new copy instead of duplicating it. Deleting library files on disk leaves dead DB rows (a cleanup job would be a natural follow-up). - Page-number pagination ordered by file-created date, newest first (the file mtime, which is the best cross-platform proxy for generation time), with the image id as a tiebreaker. To keep ordering unambiguous — SQLite compares datetimes as text, which is fragile across timezone offsets — the date is stored as an indexed integer epoch column (
file_created_unix) that the sort uses. Pre-existing rows are backfilled fromfile_created_atonce on startup. - Base model detection is a name heuristic (checkpoint files don't record their architecture). The rules live in
backend/parser/basemodel.goand are easy to extend. - Sessions: raw tokens live only in the httpOnly cookie; the DB stores an HMAC digest, so a leaked DB doesn't leak usable sessions. Cookies are not marked
Securebecause self-hosted deployments are commonly plain-HTTP on a LAN — put the app behind TLS and set the flag inbackend/api/auth.goif exposed further. - Smart categories: heuristics (dominant checkpoint + frequent prompt keywords) always work offline; the LLM-backed variant activates only when an OpenAI-compatible endpoint is configured, and its themes are validated against the library (keyword match counts) before being shown, so hallucinated themes that match nothing are silently dropped.
- Drop-folder imports are owned by the first admin (uploads belong to the uploading user). Per-user drop folders would be a follow-up.
- Deferred: filesystem watcher (fsnotify) — periodic + manual scans cover the need; dead-row cleanup for deleted files; LLM-backed categorization.
Project layout
backend/
api/ Gin handlers: auth, images, collections, shares, upload, admin
database/ connection, migrations, FTS5 setup, settings
models/ GORM models (Image, GenerationMeta, ResourceRef, Collection,
User, Session, Share, Setting, ResolverSeed)
parser/ metadata extraction (PNG/EXIF) + per-format parsers
resolver/ ModelResolver interface, stub, provisional HTTP client, backfill
scan/ drop-folder scanner / import pipeline
thumbs/ WebP thumbnail generation
frontend/
src/ Vue 3 SPA (shared GalleryGrid + FilterBar reused everywhere)
e2e/ Playwright tests