No description
  • Go 71%
  • Vue 23.8%
  • JavaScript 4.1%
  • CSS 1%
Find a file
2026-07-20 11:51:08 -04:00
backend Added support for Civitai-based imports 2026-07-20 02:05:09 -04:00
frontend Added keyboard shortcuts 2026-07-20 11:51:08 -04:00
.env.example Broad security improvements 2026-07-19 23:46:24 -04:00
.gitignore Initial app 2026-07-18 14:03:30 -04:00
LICENSE Initial app 2026-07-18 14:03:30 -04:00
README.md Broad security improvements 2026-07-19 23:46:24 -04:00

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 parameters PNG text chunk (flat text) and its JSON variant, including Lora hashes: / TI hashes: / Hashes: blocks and <lora:name:weight> prompt tags. Also read from JPEG/WebP EXIF UserComment.
    • 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 from sui_models.
    • The raw metadata string is always stored verbatim, so nothing is lost when parsing misses a field. New formats plug in as one FormatParser implementation 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's refinerupscale) 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.
  • 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 1100
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:

  1. In LM Studio, load a model and enable the local server (default port 1234).
  2. In Admin → Settings, set AI categorization endpoint to http://<lmstudio-ip>:1234/v1 and 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.
  3. 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):

  1. 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.
  2. 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).
  3. Reduce — all candidate themes (tiny compared to the prompts) go into one final call that merges duplicates into a cohesive set of 815 collections for the whole library. This is what keeps chunk 3's "Fantasy Landscapes" and chunk 17's "Epic Scenery" from becoming two collections.
  4. 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>} (where url is the model's page in the model-manager UI), 404 → no match. Optional Authorization: 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 use disintegration/imaging. If you later want libvips speed, the thumbs package 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, and DATA_DIR can all live on different disks (e.g. library on an HDD/ZFS pool, DB and thumbnails on SSD via DB_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 from file_created_at once on startup.
  • Base model detection is a name heuristic (checkpoint files don't record their architecture). The rules live in backend/parser/basemodel.go and 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 Secure because self-hosted deployments are commonly plain-HTTP on a LAN — put the app behind TLS and set the flag in backend/api/auth.go if 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