gallery

package module
v0.0.0-...-428222e Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 4, 2026 License: MIT Imports: 35 Imported by: 0

README

NOTE: whilst useful and used, until this is designated as release 1.0.0, the functionality will change (and bugs will be squashed) Internationalisation landed on 2026-07-04 — 8 locales bundled (en/de/es/fr/ja/ko/zh/pt), header language picker, locale persists in localStorage + cookie. See docs/03h-feature-i18n.md for the full architecture.

The delightful way to serve a directory.

A Caddy v2 HTTP handler module that renders a directory as a thumbnailed media gallery. Replaces Caddy's default file_server browse with a sortable + paginated grid, click-to-expand lightbox, video support, and a separate "Directories" and "Other files" listing for non-media content. The visitor can switch between light and dark mode - with automatic system defined mode pickup - with the in-page toggle (shown in the animated preview below).

Light theme fading to dark theme

Features

  • Drop-in replacement for file_server browse in a handle_path block.

  • Recursive — every subdirectory under the matched route is rendered as a gallery.

  • WebP thumbnails generated on the fly, cached on disk, invalidated by source mtime. The thumb's mtime matches the source's mtime, and an LRU eviction runs when the cache exceeds max_cache_size_mb.

  • Source dimensions (W × H) shown at the bottom-left of each thumbnail as a watermark. Sourced from image.DecodeConfig for images (fast — reads only the header) and from ffprobe for videos. Both are cached in .meta sidecar files alongside the thumbnails so the second page load hits the sidecar fast path (<100µs per file).

  • EXIF metadata displayed in the lightbox for images that have it. CAMERA fields only (Make, Model, Lens, Date taken, Shutter, Aperture, ISO, Focal length) — GPS data is never read for privacy. An "EXIF" pill on the card lets the visitor know which images have metadata. EXIF + dimensions are computed synchronously during the page request (the 60 visible-page files take ~75ms with 8 parallel workers, hidden behind the HTML render), then cached in .meta and .exif sidecar files alongside the thumb. The next page load hits the sidecar fast path (<100µs per file). EXIF is OPT-IN as of 2026-07-02 — no_exif defaults to true (per user request, so the gallery behaves like caddys stock file_server browse out of the box). Operators who want EXIF reading set no_exif false in the Caddyfile. When no_exif=true (the default), no .exif sidecar is created, no EXIF pill on cards, no EXIF panel in lightbox.

  • Collapsible EXIF/META panel in the lightbox. Click the panel header ("EXIF" or "META") to collapse to a slim vertical bar on the right side of the image; click again to expand. State persists in localStorage (gallery-lb-exif-collapsed / gallery-lb-video-meta-collapsed) so the choice is remembered across visits. The bar's text is rotated 90° anticlockwise (top-to-bottom) via writing-mode: vertical-lr. Position is JS-computed to sit at img.right - panel.width + 8, slightly overlapping the image's right edge.

  • Video metadata (META panel) — parallel to EXIF for videos. Shows duration (e.g. "0:05"), container (e.g. "mov,mp4,m4a"), video codec (e.g. "h264"), audio codec, bitrate (e.g. "2.3 Mbps"), and framerate (e.g. "24 fps"). Extracted from ffprobe -v error -show_format -show_streams -of json, cached in .vmeta sidecars (parallel to .exif). A "META" pill on the card lets the visitor know which videos have metadata. The META panel hides when the video is playing and reappears when paused (play/pause handler on the <video> element). Video metadata is OPT-IN as of 2026-07-02 — no_meta defaults to true (per user request). Operators who want video metadata extraction set no_meta false in the Caddyfile. When no_meta=true (the default), no .vmeta sidecar is created, no META pill on cards, no META panel in lightbox.

  • "Back To Top" button with scroll percentage. Fixed at the bottom-center of the viewport, appears after scrolling past one full viewport height. The button shows the current scroll percentage as a small badge (e.g. "Back To Top [50%]"). The percentage is computed as scrollY / (scrollHeight - clientHeight) * 100 and updated via requestAnimationFrame on every scroll event (no jitter, no scroll-event spam).

  • Filename search with two operator-configurable match modes (search_match word|substring, default substring). Live (client-side) as the visitor types, plus a "Search All" button for server-side full-directory search. Non-matching cards stay visible but are dimmed (not hidden) so the visitor keeps spatial context.

  • Hover tooltip on each thumbnail showing the filename with the extension stripped and underscores / hyphens replaced by spaces — e.g. misty_bamboo_forest_path.jpg shows as misty bamboo forest path. Two layers: a native browser tooltip (via the HTML title attribute) for accessibility, plus a custom CSS tooltip (via :before) for instant visual feedback.

  • Type filter — Images / Videos / Other checkboxes in the header (with a "Filter" submit button and a "Reset" pill that clears the type filter). The Other dropdown includes a (none) entry for files without an extension (e.g. Makefile, welcome) — clicking it as a strict filter shows ONLY no-extension files. Combined with the search filter via the URL (?type=jpg&type=png or ?ext=jpg&ext=png; ?ext=. filters to no-extension files only).

  • Pagination with a configurable per-page dropdown (operator sets the list, e.g. page_size 30 60 120 all; the first item is the default). Changing the page size resets to page 1 so the visitor doesn't end up on a non-existent page.

  • Click-to-sort table headers on the Directories and Other Files tables. Sort state persists in the URL AND localStorage so the visitor's choice is remembered.

  • Vanilla JS lightbox for click-to-expand, no external JS dependencies.

    Lightbox view

    Click any media tile (image or video) to open it fullscreen. The lightbox supports keyboard navigation (Esc closes, arrow keys go back/forward), a click-outside-to-close behaviour, and a play/pause control for videos. Videos show a poster (the first frame, extracted by ffmpeg if available) before the video plays, so the click area is always meaningful even before playback. The EXIF panel below the caption shows camera, lens, date taken, and exposure details for images that have it.

  • Light + dark mode with a visitor-toggleable theme (auto / light / dark), persisted in localStorage. White card on grey background in light mode, dark card on near-black in dark mode. Blue accent links.

  • No-flash theme — a tiny inline script reads the visitor's saved theme preference and applies it BEFORE the page renders, so there's no "white flash" when the visitor uses dark mode.

  • Internationalisation (i18n) with 8 bundled locales (English, German, Spanish, French, Japanese, Korean, Chinese, Portuguese). A language picker sits in the header, left of the dark/light mode toggle. Locale is resolved via a 6-level priority chain: ?lang= URL → gallery-language cookie → localStorageAccept-Language header → default_language directive → en. Once a visitor picks a language, it's persisted to both localStorage and a 1-year cookie, so subsequent visits render in the chosen locale on the first request with no further reloads. Operators can add a new language without rebuilding Caddy — just drop a JSON file at /etc/caddy/gallery-templates/lang/<locale>.json. Full architecture: docs/03h-feature-i18n.md.

  • Native loading="lazy" on every thumbnail, plus a subtle shimmer animation while the image loads.

  • Video support — videos show a play-button overlay and link to the raw file. ffmpeg extracts the first frame for the poster thumbnail.

  • "Directories" section for sub-directories with breadcrumbs (showing the path from the gallery root), counts (# items, # sub-dirs), and the directory's total size. The header shows Directories (N) plus + Parent Directory (italicized) when there's an Up entry.

  • "Other files" section for non-image/non-video content in a directory.

  • Visitor preferences persisted in localStorage — theme choice (auto/light/dark), table sort state (dirs + others), and per-section collapse state. Full reference: see What the template stores in localStorage. All keys are namespaced with the gallery- prefix to avoid collisions with the host site's own localStorage usage.

Install

System install (requires sudo)

Build a custom Caddy binary with this module baked in:

xcaddy build \
    --with github.com/caddyserver/caddy@v2.11.4 \
    --with github.com/synapticloop/caddy_media_gallery@latest

Or use the included build script (pins Caddy to v2.11.4 and the local module path):

./build.sh

The build script also restarts Caddy via systemd (you may need to be root or use sudo).

Local install (no root, no sudo)

If you don't have sudo access (shared host, locked-down laptop, etc.), you can still build and run Caddy entirely from your home directory. The bundled build script has a --user mode that does the right thing:

# Build into ~/bin/caddy, generate Caddyfile.user, listen on the
# default port (3245, see "Why 3245?" below). No sudo needed.
./build.sh --user

# Custom port (must be > 1024; the script enforces this).
./build.sh --user 9000

# Serve a different directory (default is ~/Pictures).
CADDY_USER_ROOT=~/photos ./build.sh --user 9000

This:

  1. Builds the binary into ~/bin/caddy (no install to /usr/local/bin)
  2. Writes a starter Caddyfile.user in the project root (only on first run — your edits are preserved on subsequent builds)
  3. Validates the port (must be 1025-65535) and warns if the root directory doesn't exist
  4. Prints the exact commands to start Caddy in the foreground or background

Then to run Caddy:

# Foreground (Ctrl+C to stop):
~/bin/caddy run --config Caddyfile.user

# Background:
nohup ~/bin/caddy run --config Caddyfile.user > ~/caddy.log 2>&1 &
echo $! > ~/caddy.pid

# Stop the background process:
kill $(cat ~/caddy.pid)

Open http://localhost:3245 in your browser to see the gallery. If 3245 is taken, choose another port — any number from 1025 to 65535 works (the script validates this for you).

Why 3245 as the default? Small easter egg for the project's homepage — 3245 = 0xCAD in hex (and C-A-D happen to be valid hex digits, which makes for a memorable abbreviation). If you prefer something more conventional, pass --user 8080 or set CADDY_USER_PORT=8080.

Caddyfile usage

handle_path /images/* {
    root * /var/www/html/images
    media_gallery         # default: mtime desc, 320px WebP thumbs
    file_server           # serves direct file requests (e.g. /images/foo.jpg)
}

# Or with explicit sort:
handle_path /images/crosswords/* {
    root * /var/www/html/images/crosswords
    media_gallery { sort name }   # alphabetical for curated content
}

The media_gallery directive MUST come before file_server in the handle block — that way it gets a chance to handle the request (gallery HTML, thumbnail requests), and only falls through to file_server for direct file access (e.g. /images/foo.jpg).

Auth

The gallery slots behind any standard Caddy auth layer (basic_auth, forward_auth, JWT, etc.) — it's just a regular HTTP handler. It does not implement its own auth.

Caddyfile directive options

The media_gallery directive accepts these sub-options (full reference in docs/01-configuration.md):

Subdirective Default Description
sort mtime Sort field: mtime (newest first) or name (alphabetical)
path_prefix (none) URL mount prefix used in breadcrumbs (e.g. images). Defaults to the directory name.
root_name (none) Display name for the root breadcrumb. Defaults to "media root".
image_types built-in list Space-separated list of file extensions the gallery treats as images (e.g. image_types jpg png webp). Default: jpg jpeg png gif webp. HEIC, AVIF, and SVG are NOT in the defaults — Go's stdlib can't decode them. Files with those extensions are classified as "other" files (in the "Other files" section, shown with a 📄 icon). Operators can opt in with image_types .heic .avif .svg if they have external tooling to handle these formats.
video_types built-in list Space-separated list of video extensions. Default: mp4 webm m4v mov mkv avi ogv ogg.
page_size 60 Per-page default (the first item in page_sizes if set).
page_sizes 60 30 120 all Space-separated list of dropdown options; the first item is the default. Use all for "show all on one page".
thumb_width 320 Max width of generated thumbnails (px).
thumb_height 320 Max height of generated thumbnails (px).
thumb_format webp Output format: webp, png, jpeg (or jpg).
thumb_ttl 1440 HTTP Cache-Control: max-age in minutes for thumb responses.
cache_scan 1440 (24h) In-memory scan cache TTL in minutes. The primary invalidation is the directory mtime check on every access; the TTL is a safety net.
no_thumbs true Skip thumbnail generation (use original file in <img src>). Operators who want rich thumbnails set no_thumbs false. Default flipped to true 2026-07-02 so the gallery behaves like caddys stock file_server browse by default — no on-the-fly thumb generation, no thumb cache, no ffmpeg CPU overhead. The directive name is now a misnomer (since its the default) but kept for backward compatibility.
no_video_thumbs false Skip ffmpeg-based video poster extraction.
no_exif true Skip EXIF reading entirely. Disables both the per-thumb sidecar creation in serveThumb and the sync visible-page enrich in ServeHTTP. Cards no longer show the "EXIF" pill; lightbox hides the EXIF panel. Default flipped to true 2026-07-02 so the gallery behaves like caddys stock file_server browse by default — no exiftool / go-exif calls, no GPS or camera metadata surfaced. The directive name is now a misnomer (since its the default) but kept for backward compatibility. Operators who want EXIF reading set no_exif false.
no_meta true Skip video metadata extraction entirely (duration, container, codecs, bitrate, framerate via ffprobe). Separate flag from no_exifno_exif affects image EXIF, no_meta affects video metadata. When set, the sync visible-page enrich skips readVideoMetaCached, no .vmeta sidecars are written, and the lightbox META panel is hidden for videos (cards no longer show the "META" pill). Default flipped to true 2026-07-02 so the gallery behaves like caddys stock file_server browse by default — no ffprobe subprocess calls, no extra dependencies required. The directive name is now a misnomer (since its the default) but kept for backward compatibility. Operators who want video metadata enrichment set no_meta false.
default_language en Default locale for the i18n feature (added 2026-07-04). Used as the fallback when the visitor hasn't yet specified a language via ?lang= URL parameter, gallery-language cookie, localStorage["gallery-language"], or Accept-Language HTTP header. Must be one of the supported locales: en, de, es, fr, ja, ko, zh, pt. Unknown values fall back to en. Visitors can override this via the language picker in the header (left of the dark/light mode toggle). See docs/03h-feature-i18n.md for the full architecture.
template gallery.tmpl Template file name (relative to $GALLERY_TEMPLATES_DIR, no .. allowed).
search_match substring Filename match rule for search: substring (default) or word (word-boundary).
max_cache_size_mb 1024 (1 GB) Cap on the on-disk thumb cache in MB. When the cache exceeds this, the oldest thumbs (by file mtime) are evicted until the cache is at 80% of the cap. Set to 0 to disable the cap entirely (unbounded — the pre-feature behavior). Enforced via an on-write check (cheap, runs in a goroutine after each cache write) and a background sweep every 30 min.

Example:

media_gallery {
    sort name
    page_sizes 30 60 120 all
    search_match word
    template themes/dark/gallery.tmpl
}
Defaults: file_server browse compatible (since 2026-07-02)

As of 2026-07-02, three directives default to true so the gallery behaves like caddy's stock file_server browse out of the box (no enrichment, no extra dependencies, no I/O beyond the directory scan):

Directive Default Effect when true Opt back in with
no_exif true Skip EXIF reads (no exiftool / go-exif calls) no_exif false
no_meta true Skip video metadata reads (no ffprobe subprocess) no_meta false
no_thumbs true Skip thumbnail generation (use original file in <img src>) no_thumbs false

These three flags are independent — operators can enable any combination. All other defaults (page_size, thumb_width, cache_scan, etc.) are unchanged.

The directive names are now technically misnomers (since they're the default), but the names are kept for backward compatibility — operators who already had no_exif or no_thumbs in their Caddyfile see no behavior change.

To get the full enriched UX (EXIF pills + video metadata + thumbnails), set all three to false:

media_gallery {
    no_exif false
    no_meta false
    no_thumbs false
}

How thumbs work

Thumb URLs look like /_thumbs/<basename>.webp (e.g. for source photo.jpg, the thumb is at /_thumbs/photo.webp). On first request, serveThumb calls GenerateOrLoadThumb which:

  1. Hashes the source's absolute path (sha256, first 16 bytes / 32 hex chars).
  2. Splits the hash into 2 hex chars per level: <aa>/<bb>/<rest28>.
  3. Checks the cache at /var/cache/caddy-gallery/<aa>/<bb>/<rest28>.webp (and the matching .meta and .exif sidecars).
  4. If the cached thumb is missing OR its mtime is older than the source's mtime, regenerates:
    • Decode source (jpg, png, gif, webp via stdlib + golang.org/x/image)
    • Resize to fit within thumb_width × thumb_height (default 320×320), preserve aspect ratio
    • Encode as lossless WebP (VP8L) using github.com/HugoSmits86/nativewebp
    • Set the thumb's mtime to match the source's mtime (so the staleness check works: !thumb.mtime.Before(source.mtime) means the thumb is fresh)
    • Write dimensions to <hash>.webp.meta sidecar (plain text "W H\n", used for LRU + the W × H watermark)
    • Read+write EXIF to <hash>.webp.exif sidecar (plain text with Human-Readable keys: Camera Make, Lens Model, Exposure Time, etc.; first line is has=true|false); skipped if no_exif is set
    • Read+write video metadata to <hash>.webp.vmeta sidecar (plain text with Human-Readable keys: Duration, Container, Video Codec, Audio Codec, Bitrate, Framerate; first line is has=true|false); skipped if no_meta is set
    • Return the bytes
  5. Subsequent requests serve the cached file directly. serveThumb then calls touchMetaAtUse to update the .meta mtime to time.Now() (the LRU marker — eviction sorts by .meta mtime, oldest first).

Cache invalidation is purely mtime-based — no cron job, no inotify watcher. The nested layout keeps each leaf directory to ~5 entries (well under ext4's ~10k-entry degradation threshold).

Cache directory is /var/cache/caddy-gallery by default. Override with the GALLERY_THUMB_CACHE_DIR env var (useful for testing).

Caching & performance

The gallery has a layered cache: scan cache (in-memory) + thumb cache (on-disk) + sidecar caches (in-memory + on-disk). The design pattern is eager metadata, lazy thumbs: metadata + EXIF are computed synchronously during the page request so the visitor sees them on the first visit; thumbnails are generated lazily when the browser requests them.

The three caches
  • Scan cache (in-memory) — []FileInfo per directory, keyed by absolute dir path. Invalidation is directory mtime (checked on every access — adding or removing a file changes the dir mtime and forces a fresh scan). The TTL (cache_scan directive, default 1440 = 24h) is a safety net for edge cases (clock skew, manual mtime changes, in-place file modifications that don't bump dir mtime). Cache state is internally consistent within a TTL window (enrichment happens via atomic SetFiles swap, no in-progress mutation is observable).

  • Thumb cache (on-disk at /var/cache/caddy-gallery/) — 2-level nested hash subdirs (<aa>/<bb>/<hash28>.webp). Default cap: 1 GB (max_cache_size_mb). LRU eviction when the cap is hit (sorts by .meta mtime, oldest first). 2 hex chars per level keeps leaf dirs to ~5 entries (well under ext4's ~10k-entry slowdown threshold). LRU marker is the .meta mtime, touched on every thumb access (decouples eviction from the thumb's own mtime which mirrors the source's mtime for semantic correctness).

  • Sidecar caches (per thumb file, in the same subdir) — .webp.meta (dimensions, plain text "W H\n") and .webf.exif (EXIF data, plain text with Human-Readable keys: Camera Make, Lens Model, Exposure Time, etc.; first line is has=true|false). Created lazily on the first thumb request (synchronous with the thumb gen) and reused on subsequent requests. Staleness check: sidecar.mtime < source.mtime → sidecar is stale → re-read source and overwrite. Both sidecars are written/checked atomically.

First-visit latency budget

For /images/media_gallery/ (89 files, 4 with EXIF), cold cache:

Step Time
TLS handshake ~16ms
Scan (cold, 89 files) ~20ms
Sync enrich of 89 files (8 workers × ~10ms each) ~110ms
Template render ~30ms
Body transfer (gzip 160 KB → 20 KB) ~50ms
HTML total ~225ms
First thumb request (cold, decode + resize + WebP encode) ~250ms
Subsequent thumbs (browser parallelizes 6 at a time) wave over ~1s
Warm reload (within 24h TTL) ~30ms
HTTP caching
  • Thumb responses: Cache-Control: public, max-age=86400 (24h, set via the thumb_ttl directive). Thumbs are immutable per source mtime, so the visitor's browser cache is safe.
  • Gallery HTML: Cache-Control: no-cache (always fresh). New images need to show up on the next refresh.
  • Caddy-level encode: encode zstd gzip for all text responses (HTML, CSS, JS, JSON). 8x compression ratio on the 160 KB gallery HTML — 7.7x smaller wire size.
Cache size cap

max_cache_size_mb (default 1024 MB = 1 GB) — when the on-disk thumb cache grows past the cap, the oldest thumbs (by .meta mtime) are evicted in a background goroutine until the cache is at 80% of the cap. A 30-min background sweep catches the case where the cache grows without new writes. Operators can set max_cache_size_mb 0 for unbounded (the pre-feature behavior). The footer shows current usage as a 2-digit hex percentage (01 = 1%, 28 = 40%, 64 = 100%).

Dependencies

  • caddyserver/caddy v2.11.4 (compile-time)
  • golang.org/x/image — for image resizing and WebP decoding (for dimension reading)
  • HugoSmits86/nativewebp — pure-Go lossless WebP encoder (no CGO, no libwebp)
  • dsoprea/go-exif/v3 — for EXIF metadata reading (JPEG, PNG, WebP). GPS data is intentionally never read.
  • ffmpeg (external binary, optional) — for video thumbnail extraction. If not present, the gallery falls back to a placeholder gradient.

Build

# Clone
git clone https://github.com/synapticloop/caddy_media_gallery
cd caddy_media_gallery

# Build (requires xcaddy and Go 1.21+)
go mod download
./build.sh

Test

go test ./... -v
go test ./... -race       # race detector

416 tests, all standard library + stdlib-friendly patterns. No test fixtures in the repo — the test for thumbnail generation uses a programmatically-generated 640x480 JPEG. Tests cover rendering, EXIF parsing, dimension reading, search filter, sort, pagination, scan cache, and the Caddyfile parser.

Architecture

caddy_media_gallery/
├── gallery.go              # Module registration, Caddyfile parser, ServeHTTP
├── scanner.go              # Directory walker + file classification (image/video/other)
├── scancache.go            # mtime-keyed in-memory cache of directory scans
├── render.go               # PageData struct, RenderPage (22 args), URL helpers, FuncMap
├── thumbnails.go           # WebP thumb generation, mtime cache, LRU eviction
├── dimensions.go           # Source dimensions reader + .meta sidecar cache
├── exif.go                 # EXIF reader + .exif sidecar cache (text format)
├── *_test.go               # Go tests (416 total)
├── build.sh                # xcaddy build + systemd restart (or --user for local install)
├── template_embedded.go     # //go:embed directive bundling the gallery template
├── templates/
│   └── gallery.tmpl        # HTML template (separate file, //go:embed'd at build time)
└── README.md               # this file

Customizing the template

The HTML template lives in templates/gallery.tmpl and is bundled into the Go binary at build time via //go:embed. Two ways to customize:

  1. Edit the source template — modify templates/gallery.tmpl and rebuild. The new template is bundled into the binary on the next ./build.sh.

  2. Override at runtime — place a modified gallery.tmpl at $GALLERY_TEMPLATES_DIR/gallery.tmpl (default /etc/caddy/gallery-templates/gallery.tmpl). The operator-installed template takes precedence over the bundled one. If the file is missing on startup, it's auto-extracted from the embedded copy, so you always have a starting point to edit.

Caddyfile example (full)

{
    admin off
}

your.caddy.host:443 {
    tls /etc/caddy/caddy.crt /etc/caddy/caddy.key

    route {
        basic_auth {
            youruser $2a$14$bcrypt_hash_here
        }

        handle_path /images/* {
            root * /var/www/html/images
            media_gallery
            file_server
        }
    }
}

Documentation

The full documentation is also available as a single PDF: caddy-media-gallery-book.pdf

A full changelog of every commit (224+ entries, grouped by date and category) is in CHANGELOG.md.

Detailed operator documentation lives in docs/:

License

MIT. See LICENSE

Documentation

Overview

Package gallery implements a media-gallery HTTP handler for Caddy v2. Supports images (jpg, png, gif, webp, avif, svg) and videos (mp4, webm, mov, mkv, etc.) with on-demand WebP thumbnails for images and ffmpeg-extracted first-frame thumbnails for videos. Replaces Caddy's default with a richer directory listing that includes a lightbox, on-the-fly image thumbnailing, and video preview generation.

Per user request 2026-07-04: internationalisation (i18n) support for the caddy_media_gallery module. The visitor can switch between languages via:

  1. ?lang=<locale> URL parameter (highest priority)
  2. <lang> cookie (set by JS when visitor changes lang)
  3. localStorage 'gallery-language' (set by JS — kept in sync with the cookie so JS-only visitors don't lose their preference on first visit after a server restart)
  4. Accept-Language request header
  5. Default language (set by the operator in the Caddyfile via `default_language = "..."`; falls back to "en")

Operator-side: new languages can be added by dropping a JSON file at `<templates_dir>/lang/<locale>.json`. No rebuild needed — the disk file overrides the embedded one (or adds a brand-new locale).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DetectLocale

func DetectLocale(r *http.Request, supported []string, defaultLocale string) string

DetectLocale resolves the visitor's preferred locale using the priority order documented at the top of this file:

  1. ?lang=<locale> URL parameter
  2. <lang> cookie
  3. localStorage 'gallery-language' header (NOT handled here — localStorage is client-side only. The cookie layer (item 2) is the server-readable proxy for it)
  4. Accept-Language request header
  5. operator default (from Caddyfile `default_language`)
  6. "en"

Returns the best match (in canonical short form, e.g. "de" not "de-de"). If the visitor's preference is for a locale we don't support, fall back to the operator default (then to "en").

func EagerGenPageThumbs

func EagerGenPageThumbs(resolved string, paged, offPage []FileInfo, cacheDir, ffmpegPath string, cfg ThumbConfig)

EagerGenPageThumbs pre-generates the on-page thumbs for the visible files in paged, then spawns a background goroutine to generate the rest (offPage) so subsequent page navigations are also warm.

Per user request 2026-07-01: when a directory's scan cache TTL has expired (or the directory is being cached for the first time), the browser has to wait for ~60 thumbs to be generated — that's ~6 seconds of latency on the first navigation. This helper generates the page-visible thumbs synchronously (in the request goroutine) so they're ready by the time the browser requests them, then finishes the rest in the background with a small rate limit to avoid thundering-herd against ffmpeg/the disk.

paged: the files visible on the current page (sorted

and filter-applied). These get SYNCHRONOUS
pre-gen.

offPage: all OTHER media files in the directory not

visible on the current page (so navigation
to other pages is also warm). These get
BACKGROUND pre-gen.

cacheDir, ffmpegPath, cfg: the standard thumb-gen args. ffmpegPath may be "" — video thumbs are silently skipped in that case (the rest of the pipeline 404s them).

IMPORTANT: this is best-effort. Any thumb-gen errors are silently ignored (the browser will just see a 404 / lazy generate when it requests the thumbnail). The whole point of this helper is to AVOID that lazy-gen latency, not to guarantee it.

Performance characteristics:

| Files   | Sync (paged)            | Background (offPage)     |
|---------|-------------------------|---------------------------|
| 60      | ~600 ms (10x parallel)  | rate-limited (~200 ms each)|
| 200     | ~600 ms                 | rate-limited (~40 sec)    |
| 1000    | ~600 ms                 | rate-limited (~3 min)     |

The sync phase is bounded by the page size (default 60), not the directory size, so it's always "fast enough" even for huge directories.

func GenerateOrLoadThumb

func GenerateOrLoadThumb(src, cacheDir string, cfg ThumbConfig) ([]byte, error)

GenerateOrLoadThumb returns the thumbnail bytes for src, generating and caching on first call and serving from cache on subsequent calls. The cfg parameter (Width, Height, Format) controls the output size and output format. The source is fit-within-bounds (aspect ratio preserved, longest edge becomes the configured value). Sources already within the box are encoded without resizing.

The cache file is at <cacheDir>/<sha256(absolute source path)>.<ext> keyed by (source path, cfg.Format) so changing the format invalidates the old cache automatically. The cache is also regenerated when the source mtime is newer than the cache mtime.

func GenerateOrLoadVideoThumb

func GenerateOrLoadVideoThumb(src, cacheDir, ffmpegPath string, cfg ThumbConfig) ([]byte, error)

GenerateOrLoadVideoThumb extracts the first frame from a video using ffmpeg, saves it as a WebP thumbnail, and returns the thumbnail bytes. Mirrors GenerateOrLoadThumb's caching behavior: if a cache file already exists and is not older than the source video, the cached bytes are returned without invoking ffmpeg.

The ffmpegPath argument must be the absolute path to a working ffmpeg binary (use Gallery.ffmpegPath, set in Provision via exec.LookPath). If ffmpegPath is empty, the function returns an error — callers should check gallery.VideoThumbsEnabled() first to know whether to call this function.

ffmpeg invocation:

ffmpeg -y -i input.mp4 -vframes 1 -vf "scale=W:H:force_original_aspect_ratio=decrease" output.webp

-y: overwrite output without prompting -i: input -vframes 1: extract exactly one frame -vf scale=W:H:force_original_aspect_ratio=decrease: fit-within-bounds output: webp (or other format if cfg.Format is set; we default to webp since that's what the rest of the thumb pipeline uses)

Note on seeking: we use -vframes 1 which extracts the FIRST frame. For most videos this is fine. Some videos have an all- black opening frame (the "fade-in" frame); if that becomes a problem we can add -ss 0.5 to seek forward half a second.

func RenderPage

func RenderPage(title, pathPrefix, thumbPrefix, relPath, tmplName string, noThumbs, noVideoThumbs bool, pageSize int, pageSizes []string, files []FileInfo, query url.Values, imageExts, videoExts map[string]bool, breadcrumbRoot, absolutePrefix, searchMatch, locale string, translator *Translator, cacheStatsXX, cacheStatsYY, cacheStatsZZ, cacheStatsAA string) (string, error)

RenderPage renders the gallery page for a directory. The caller provides the raw directory listing (output of Scanner.Scan); RenderPage does the split / sort / paginate / format work.

`title` is the page heading (e.g. "Generated Images"). It is derived by the caller — typically the basename of the current directory.

`pathPrefix` and `thumbPrefix` are the URL prefixes for relative links. The defaults used in the live config are "./" and "./_thumbs/" — both relative so they work for any subdir the gallery is mounted at.

`relPath` is the path within the gallery (the request's post-handle_path-stripped path, no leading slash). Empty for the gallery root. When non-empty, an ".." entry is prepended to the directories list so the user can navigate up.

`query` is the request's URL query values; sort and page are read from it. RenderPage renders the gallery. `tmplName` is the configured template name (relative to the templates dir). Pass "" to use the default ("gallery.tmpl"). The name is validated inside loadTemplate. `noThumbs` is the configured no_thumbs flag - when true, image tiles use the original file as the <img src> instead of /_thumbs/<name>.webp (no thumb generation). `pageSize` is the configured page_size - the number of image entries per page. Pass 0 for the default of 60 (per user request 2026-06-20). `pageSizes` is the list of per-page options the visitor can choose from in the dropdown (e.g. [30, 60, 120, "all"]). "all" is a special token meaning "show all items on one page" - only included if the operator explicitly listed it. Default: [30, 60, 120, "all"]. `imageExts` and `videoExts` are the Gallery's configured extension sets (used to build the filter UI's sub-type groups). `breadcrumbRoot` is the gallery's URL mount prefix (e.g. "images" for /images/*) - used as the first segment of the breadcrumb. `absolutePrefix` is the absolute URL path (e.g. "/images/") - used as the prefix for absolute breadcrumb links.

func ThumbPath

func ThumbPath(src, cacheDir string) string

ThumbPath returns the on-disk path where the thumbnail for src should be cached. The filename is the first 16 bytes of the SHA256 of src's absolute path, hex-encoded (32 hex chars) + ".webp". Using a content-hash means cache entries are stable across renames of the parent directory (as long as the absolute source path stays the same) and collisions are effectively impossible. ThumbPath returns the on-disk path where the thumbnail for src would be cached, using the default .webp extension. The layout is <cacheDir>/<aa>/<bb>/<rest>.webp (a 2-level hash directory tree — see cachePath for rationale). The hash is the first 16 bytes of SHA-256 of the absolute source path, hex-encoded (32 chars total). Using a content hash means the path is stable across renames of the parent directory (as long as the absolute path stays the same) and collisions are effectively impossible.

ThumbPath is the "external" interface used by the tests and by other packages that need to know where a thumb lives. It's equivalent to thumbCachePath(src, cacheDir, "webp").

Types

type BreadcrumbSegment struct {
	Name string
	Href string
}

BreadcrumbSegment is one segment of the breadcrumb path. Each segment represents a directory level; Name is the human-readable label, Href is the URL to navigate to that level. The last segment (the current directory) is rendered as plain text (not a link) by the template.

type ExifData

type ExifData struct {
	CameraMake   string
	CameraModel  string
	LensModel    string
	DateTaken    string // formatted "2024-11-08 06:23:14" (already EXIF format)
	ExposureTime string // formatted "1/250 s", "2 s", etc.
	Aperture     string // formatted "f/2.8"
	ISO          string // formatted "ISO 400"
	FocalLength  string // formatted "50 mm"
}

ExifData holds the EXIF metadata for a single image. Only the 8 fields we display are populated; the rest of the EXIF block (GPS coordinates, software, copyright, etc.) is NEVER read.

Per user request 2026-06-27: GPS data is intentionally excluded for privacy. We never extract the GPS IFD, so the values are never loaded into memory, never stored in the scan cache, and never displayed. The original file's GPS data is preserved (we don't modify files on disk); the privacy guarantee is at the gallery-display layer only.

func (*ExifData) HasAny

func (e *ExifData) HasAny() bool

HasAny returns true if at least one field is populated. Used to decide whether to render the "EXIF" pill on the card and the EXIF panel in the lightbox. An empty ExifData (all empty strings) is treated as "no EXIF" — no UI elements shown.

type FileInfo

type FileInfo struct {
	Name    string   `json:"name"`
	ModTime int64    `json:"mtime"`
	Size    int64    `json:"size"`
	Kind    FileKind `json:"kind"`
	// Width and Height are the pixel dimensions of the source
	// image or video (the file the thumbnail was generated
	// from). Zero means "dimensions not available" — for
	// unsupported formats (AVIF, HEIC, SVG) the scanner
	// skips readDimensions. Per user request 2026-06-27:
	// dimensions are read at scan time and cached alongside
	// the rest of the file metadata.
	Width  int `json:"width,omitempty"`
	Height int `json:"height,omitempty"`
	// CountItems is the number of NON-directory entries
	// (files, symlinks-to-files, broken symlinks, etc.) inside
	// this subdirectory. Only meaningful for KindDir entries.
	// Populated by Scanner.Scan; the count is computed by
	// reading the subdir's contents (one extra os.ReadDir
	// syscall per subdir, then discarded).
	// Exif is the CAMERA-subset EXIF metadata for this file
	// (only meaningful for image files). Per user request
	// 2026-06-29: EXIF is read LAZILY (on lightbox open via
	// the ?exif=1 endpoint), NOT at scan time. This field
	// is always nil from the scanner — it's only set by
	// the EXIF endpoint handler when the lightbox asks.
	// Keeping the field for backward compatibility with
	// any code that might check it; the rendered HTML
	// no longer references FileInfo.Exif.
	//
	// The legacy CAMERA subset (Make, Model, Lens,
	// DateTimeOriginal, ExposureTime, FNumber,
	// ISOSpeedRatings, FocalLength) is preserved; GPS is
	// never extracted. See exif.go for details.
	Exif *ExifData `json:"exif,omitempty"`
	// Per user request 2026-07-02: VideoMeta holds the
	// extracted video metadata (duration, container,
	// codecs, bitrate, framerate). Only meaningful for
	// KindVideo entries; nil for non-videos. Populated
	// lazily via the same background-enrich pipeline
	// that handles EXIF (when the scanner runs, it
	// can also queue a video metadata extraction).
	VideoMeta  *VideoMeta `json:"video_meta,omitempty"`
	CountItems int        `json:"count_items"`
	// CountDirs is the number of directories inside this
	// subdirectory. Includes real directories AND symlinks
	// that point to directories (per user request 2026-06-27).
	// Only meaningful for KindDir entries.
	CountDirs int `json:"count_dirs"`
}

FileInfo describes a single entry in a gallery directory. This is the type that flows into the template renderer.

ModTime is unix nanoseconds. int64 keeps the type JSON-friendly (no time.Time marshalling quirks) and nanosecond resolution preserves sub-second ordering of files written close together.

type FileKind

type FileKind string

FileKind categorises an entry in a gallery directory.

const (
	// KindDir is a subdirectory. The Name is the directory basename
	// (not the full path); the scanner joins it to Root at render
	// time.
	KindDir FileKind = "dir"
	// KindImage is an image file (jpg, jpeg, png, gif, webp — the formats
	// the default image_types list supports). HEIC, AVIF, and SVG are
	// NOT classified as KindImage by default — they appear in the
	// "Other files" section.
	KindImage FileKind = "image"
	// KindVideo is a video file (mp4, webm).
	KindVideo FileKind = "video"
	// KindOther is a non-image, non-video file (html, txt, etc.).
	// These are shown in the "Other files" strip above the image
	// grid.
	KindOther FileKind = "other"
)

func Classify

func Classify(name string, imageExts, videoExts map[string]bool) FileKind

Classify returns the FileKind for a filename based on its extension. Directories are not classified by name; the scanner uses the entry's IsDir() to set KindDir directly.

The image and video extension sets come from the Gallery (operator-configurable via the Caddyfile). If neither set is provided, the defaults are used (jpg/jpeg/png/gif/webp for images; mp4/webm/m4v/mov/mkv/avi/ogv/ogg for videos).

type FileView

type FileView struct {
	Name     string // basename ("photo.jpg" or "subdir")
	Href     string // relative link
	ThumbURL string // for images, the relative thumb URL; empty for non-images
	IsDir    bool
	IsUp     bool // true for the synthetic "../" up-link entry (rendered with ↑ icon, no trailing /)
	IsImage  bool
	IsVideo  bool
	IsOther  bool
	// Per user request 2026-06-30: DisplayName is the name
	// shown to the visitor as a hover tooltip on each
	// thumbnail. It's the filename without the extension,
	// with underscores ("_") and hyphens ("-") replaced by
	// spaces — so "misty_bamboo_forest_path.jpg" becomes
	// "misty bamboo forest path" (human-readable). Used in
	// the HTML title attribute (native browser tooltip) AND
	// in a CSS-only ::after pseudo-element tooltip (custom
	// styled, appears on hover with no delay).
	DisplayName string

	// ParentDir is set ONLY on the up-link FileView. It is the
	// basename of the parent directory (one level up from the
	// current page). Rendered as part of the up chip's display
	// text — "Up (../{ParentDir})" — so the user sees which
	// directory they'll land in. Empty at the gallery root or
	// in a top-level subdir (parent is the gallery root, no name
	// to show). Per user request 2026-06-17.
	ParentDir string

	// Internal fields used by buildFileView to format Size/Date
	// without round-tripping through fmt in the template.
	Size string
	Date string
	Type string

	// Per user request 2026-07-01: SizePct is the relative size
	// of this file/directory as a percentage of the largest
	// in the same section (dirs or others). 0-100, where 100 is
	// the largest. The template puts this on the .col-size cell
	// as a CSS custom property (--size-pct) so a ::before pseudo-
	// element can draw a light-grey bar across the cell width
	// proportional to the size. Set by computeSizePercentages()
	// after buildFileViews() for the dirs and others slices.
	// Zero for sections with a single item (or all items the
	// same size — the first item gets 100, the rest get less).
	SizePct int

	// ModTime is the raw int64 timestamp (nanoseconds since
	// epoch). Used by the JS header-sort feature to sort
	// the others table by Modified. Formatted display goes
	// through Date (the human-readable form).
	ModTime int64

	// Width and Height are the pixel dimensions of the source
	// image/video. Zero for unsupported formats (AVIF, HEIC,
	// SVG — per user request 2026-06-27). Dimensions is the
	// formatted "WIDTH × HEIGHT" string for the watermark;
	// empty when Width or Height is zero.
	Width      int
	Height     int
	Dimensions string

	// Exif is no longer populated by the scanner. Per
	// user request 2026-06-29: EXIF is read LAZILY when
	// the lightbox opens (via the ?exif=1 endpoint). The
	// card overlay no longer shows the "EXIF" pill (we
	// don't know if EXIF exists until the lightbox
	// fetches it). The lightbox JS fetches the EXIF
	// separately and populates the EXIF panel.
	// This field is kept for backward compatibility; it's
	// always nil. The ExifData type is still defined for
	// the JSON shape returned by the ?exif=1 endpoint.
	Exif *ExifData
	// ExifAttrs is the pre-rendered EXIF attribute string
	// (e.g. ` data-exif-camera-make="Canon" data-exif-camera-model="EOS R5" ...`).
	// Empty if no EXIF. Per optimization 2026-06-30: this
	// replaces 8 separate template field accesses
	// ({{.Exif.CameraMake}}, etc.) — each of which was a
	// reflection-based field lookup. The template now just
	// does {{.ExifAttrs}} which is a simple struct field
	// access (no reflection, no function call).
	ExifAttrs template.HTMLAttr // pre-rendered EXIF data attributes (trusted HTML attribute — values are html.EscapeString'd, template trusts the result)

	// Per user request 2026-07-02: VideoMeta holds the
	// extracted video metadata (duration, container,
	// codecs, bitrate, framerate). Only meaningful for
	// KindVideo entries; nil for non-videos.
	VideoMeta *VideoMeta
	// VideoMetaAttrs is the pre-rendered `data-video-*`
	// attribute string (similar to ExifAttrs for EXIF).
	// Empty if no video metadata. The lightbox JS
	// reads these attributes and populates a separate
	// "video metadata" panel.
	VideoMetaAttrs template.HTMLAttr // pre-rendered video data attributes (trusted HTML — values are html.EscapeString'd)

	// CardHTML is the entire card markup pre-rendered as a
	// single string. Per optimization 2026-07-01: instead of
	// the template walking 60×(~50) nodes for 60 cards (the
	// biggest render bottleneck per the CPU profile), Go
	// builds the card HTML once per file in buildFileView,
	// and the template does {{.CardHTML}} — a single
	// template.HTML substitution per card (zero reflection per
	// card, just string interpolation).
	//
	// Per-page savings: ~60% of template-execute time
	// (~1.5ms shaved off a 3ms render = ~50% total
	// render speedup for a 60-card page).
	CardHTML template.HTML

	// CountItems is the number of NON-directory entries inside
	// this subdirectory (files + symlinks to files + broken
	// symlinks, etc.). Only set on directory entries (IsDir=true).
	// Populated by buildFileView from FileInfo.CountItems
	// (set by Scanner.Scan via countSubdirStats).
	CountItems int
	// CountDirs is the number of directories inside this
	// subdirectory. Includes real directories AND symlinks to
	// directories (per user request 2026-06-27). Only set on
	// directory entries.
	CountDirs int
}

FileView is the template-friendly representation of a single entry. All display strings are pre-formatted (no template computation needed). Href and ThumbURL are relative to the current page.

type FilterGroup

type FilterGroup struct {
	Label    string // "Images", "Videos", "Other"
	Options  []FilterOption
	Selected int // number of Options with Selected=true
	Total    int // len(Options)
}

FilterGroup is one dropdown in the filter UI (Images, Videos, or Other). It has a label, the count of selected sub-types vs the total (for the (N/M) chip), and the list of sub-type options.

type FilterOption

type FilterOption struct {
	Ext        string // lowercase, with leading dot, e.g. ".jpg" (or "." sentinel for files without extensions (".jpg" form value; the dot-prefix is consistent with other extensions like ".jpg"); see FilterOption.IsNone)
	DisplayExt string // canonical-case form for display, e.g. "JPG" (vs "jpg"), or "(none)" for files without extensions
	Count      int    // files in the current directory with this ext
	Selected   bool   // currently in the active ?type= filter
	// Per user request 2026-06-30: IsNone marks the
	// "(none)" option for files without an extension.
	// The form uses a sentinel value (".") instead of
	// an empty string because browsers skip empty checkboxes
	// when serializing the form, which would lose the
	// distinction between "unchecked" and "checked but empty".
	IsNone bool // true for the (none) entry (files without an extension)
}

FilterOption is one checkbox in the filter UI — represents a single file extension (e.g. ".jpg") within a filter group (Images, Videos, or Other). The count is the number of files in the current directory with this extension; Selected is true if the extension is in the active ?type= filter.

type Gallery struct {
	// Root is the on-disk directory to render. Set automatically by
	// Caddy's  directive (via Provision), or can be set in JSON
	// config.
	Root string

	// PathPrefix is the URL mount prefix for the gallery
	// (e.g. "images" if the gallery is mounted at /images/*
	// in the Caddyfile). It is used by the breadcrumb so
	// the first segment matches what the user sees in the
	// URL. Defaults to filepath.Base(Root) if empty.
	PathPrefix string
	// RootName is the operator-configurable display name for
	// the first breadcrumb segment (the gallery's root). Set
	// in the Caddyfile via . If empty,
	// the resolved rootName (set in Provision) defaults to
	// "media root" — a generic name that works for any gallery.
	RootName string

	// Sort is the field used to order the gallery. Valid values:
	//   "mtime" (default) — newest first
	//   "name"           — alphabetical
	Sort string

	// Template is the name of the template file to use, relative to
	// the templates dir (, default
	// /etc/caddy/gallery-templates). If empty, defaults to
	// "gallery.tmpl". The path is validated at Provision to
	// reject absolute paths and any traversal above the templates
	// dir (no  allowed). The template dir is the chroot; the
	// operator can only reference files inside it.
	Template string

	// ImageExts is the set of file extensions the gallery treats
	// as images. Set from the  Caddyfile subdirective
	// (space-separated, case-insensitive, with or without leading
	// dot). If empty (the default), the plugin uses a built-in list
	// of common image extensions (jpg, jpeg, png, gif, webp).
	// HEIC, AVIF, and SVG are NOT in the default list — Go's
	// stdlib can't decode them. Operators can still add them
	// via `image_types .heic .avif .svg` if they have external
	// tooling to handle these formats. Provision() converts this
	// list to a map for the Scanner to use.
	ImageExts []string

	// VideoExts is the set of file extensions the gallery treats
	// as videos. Set from the  Caddyfile subdirective
	// (same syntax as image_types). Empty (default) uses the
	// built-in video list (mp4, webm, m4v, mov, mkv, avi, ogv, ogg).
	VideoExts []string

	// NoThumbs disables the on-the-fly WebP thumbnail generation.
	// When true, the gallery uses the original image as the tile
	// <img src> instead of . Requests to the
	// thumb URL fall through to the next handler (typically
	// file_server, which 404s since no _thumbs/ dir exists).
	// Tradeoffs: no thumb cache, no CPU cost, but the browser
	// downloads the full image per tile (bigger page payload, slower
	// load on dirs of large photos).
	//
	// Per user request 2026-07-02: NoThumbs now defaults to
	// TRUE so the gallery behaves like caddy's stock
	// file_server browse by default (no on-the-fly thumb
	// generation, no thumb cache, no ffmpeg CPU overhead).
	// Operators who want the rich thumbnail UX opt in
	// via `no_thumbs false` in the Caddyfile.
	NoThumbs bool
	// NoThumbsSet is true when the operator explicitly set
	// no_thumbs in the Caddyfile. Used by Provision to
	// distinguish "explicit false" (preserve the false)
	// from "no directive" (apply the default true).
	NoThumbsSet bool

	// NoVideoThumbs disables the on-demand WebP thumbnail generation
	// for VIDEO files (extracted from the first frame via ffmpeg).
	// When true, videos still display in the gallery (with the
	// placeholder gradient + play button on each tile) but no
	// per-frame thumbnail is produced or served. When false (the
	// default), video thumbnails ARE generated IF ffmpeg is
	// available on the host — if ffmpeg is missing, video thumbs
	// fall back to the placeholder regardless of this setting
	// (there's no way to generate a frame without a tool that can
	// decode the video).
	// Caddyfile:  (no arg → true) or
	//             (re-enable).
	NoVideoThumbs bool

	// NoExif controls whether EXIF metadata is read from
	// image files. Per user request 2026-07-02: defaults
	// to TRUE so the gallery behaves like caddy's stock
	// file_server browse (no extra dependencies / I/O
	// required by default). When true (the default), the
	// scanner skips the readExif call entirely (no I/O,
	// no parsing) — FileInfo.Exif is left nil for all
	// files. The card overlay shows no "EXIF" pill (the pill
	// only renders when Exif is non-nil) and the lightbox
	// shows no EXIF panel. When false, EXIF is read for
	// every image file at scan time (EAGER loading — see
	// scanner.go and exif.go).
	//
	// Operators who want the rich EXIF metadata UX can
	// opt back in via the Caddyfile:
	//   no_exif false    # re-enable EXIF reading
	//
	// The "no_exif" directive is now a misnomer (since it's
	// the default), but we keep the name for backward
	// compatibility — operators who already set "no_exif"
	// in their Caddyfile see no change. The presence of
	// the directive with no arg still means "disable" (i.e.
	// set to true), and the operator can use "no_exif false"
	// to opt back in to reading EXIF.
	//
	// Useful for enabling EXIF:
	//   - Metadata-rich photo galleries (camera/lens info)
	//   - Any operator who wants the EXIF pill on cards
	//   - Note: EXIF does NOT include GPS by default (see
	//     exif.go — GPS is never extracted).
	// Caddyfile: no_exif (no arg → true) or no_exif false
	// (re-enable EXIF reading).
	NoExif bool
	// NoExifSet is true when the operator explicitly set
	// no_exif in the Caddyfile (including with value
	// "false"). Used by Provision to distinguish
	// "operator set to true" / "operator set to false" /
	// "operator didn't set (use the default true)". The
	// Caddyfile parser sets this flag whenever the
	// directive appears, even with value "false".
	NoExifSet bool
	// Per user request 2026-07-02: NoMeta controls whether
	// video metadata is read (duration, container, codecs,
	// bitrate, framerate extracted via ffprobe). Defaults
	// to TRUE so the gallery behaves like caddy's stock
	// file_server browse (no extra ffprobe dependency
	// required by default). This is a separate flag from
	// NoExif — NoExif affects image EXIF; NoMeta affects
	// video metadata. When true (the default), the scanner
	// skips the readVideoMetaCached call entirely (no
	// ffprobe subprocess, no .vmeta sidecar writes, no
	// parsing). FileInfo.VideoMeta is left nil for all
	// files, so the lightbox META panel stays hidden and
	// the "META" pill on video cards is not rendered.
	//
	// Operators who want the rich video metadata UX can
	// opt back in via the Caddyfile:
	//   no_meta false    # re-enable video metadata extraction
	//
	// As with NoExif, the directive name "no_meta" is a
	// bit of a misnomer since it now defaults to true, but
	// we keep the name for backward compatibility.
	// Caddyfile: no_meta (no arg → true) or no_meta false
	// (re-enable video metadata extraction).
	NoMeta bool
	// NoMetaSet is true when the operator explicitly set
	// no_meta in the Caddyfile. Used by Provision to
	// distinguish "operator set to true" / "operator set
	// to false" / "operator didn't set (use the default
	// true)". The Caddyfile parser sets this flag whenever
	// the directive appears, even with value "false".
	NoMetaSet bool

	// PageSize is the number of image entries per page. Default
	// is 50 (set in Provision if zero). The user can override
	// per-route via the Caddyfile: .
	// Validation: must be > 0. A zero or negative value is rejected
	// by UnmarshalCaddyfile (the Caddyfile parser).
	PageSize int
	// PageSizes is the list of page sizes the visitor can
	// choose from in the per-page dropdown (e.g. [30, 60, 120,
	// "all"]). Set in the Caddyfile via
	// (space-separated; "all" as a token means "show all items
	// in one page" - only included if explicitly listed).
	// If empty (default), the resolved PageSizes (set in
	// Provision) is [30, 60, 120, "all"]. The CURRENT page size
	// is set via the URL ?page_size=N parameter, which is
	// validated against this list (unknown values fall back to
	// the first item).
	PageSizes []string

	// SearchMatch controls how filenames are matched against
	// the search query. Two values:
	//   "substring" (default) — the query can match anywhere in
	//     the filename. "cat" matches "scatter.png".
	//   "word" — the query must match the start of a word
	//     boundary. "cat" matches "cat.jpg" and "my_cat.webp"
	//     but NOT "scatter.png". Uses the same word boundaries
	//     as the URL/PATH separators (_, -, space).
	//
	// Caddyfile:  (or omit for default).
	// Validation: must be one of the two; any other value
	// defaults silently to "substring" in Provision.
	SearchMatch string

	// ThumbWidth is the maximum width in pixels of generated
	// thumbnails. The source image is fit-within-bounds (aspect
	// ratio preserved, longest edge becomes the configured value).
	// Default: 320. Caddyfile: .
	// Validation: must be > 0; zero/negative rejected.
	ThumbWidth int

	// ThumbHeight is the maximum height in pixels of generated
	// thumbnails. Fit-within-bounds behavior — see ThumbWidth.
	// Default: 320. Caddyfile: .
	// Validation: must be > 0.
	ThumbHeight int

	// ThumbFormat is the output format for generated thumbnails.
	// One of: "jpeg" (or "jpg"), "png", or "webp" (the current
	// default, lossless). Default: "webp". Caddyfile:
	// . Validation: must be one of the three.
	ThumbFormat string

	// CacheScanMinutes is the in-memory scan cache TTL in
	// minutes. Default: 1440 (24 hours).
	//
	// Per user request 2026-07-01: the scan cache's primary
	// invalidation is the DIRECTORY MTIME (checked on every
	// Get). Adding or removing a file changes the dir mtime,
	// so the cache automatically invalidates. The TTL is a
	// SAFETY NET for edge cases (clock skew, manual mtime
	// changes, stat cache invalidation). Setting it to 24
	// hours means the cache stays warm for typical
	// interactive use without forcing periodic re-scans that
	// would re-do the os.ReadDir + per-file stat() work.
	//
	// Caddyfile: . Validation: must be > 0.
	CacheScanMinutes int

	// ThumbTTLMinutes is the HTTP Cache-Control max-age in
	// minutes for thumb responses. Thumbs are immutable per
	// source mtime, so a long TTL is safe. Default: 1440
	// (= 24 hours, matches the previous 86400-second value).
	// Caddyfile: . Validation: must be > 0.
	ThumbTTLMinutes int
	// MaxCacheSizeMB is the on-disk thumb cache size cap in
	// MB. When the cache directory exceeds this size, the
	// oldest thumbs (by file mtime) are evicted until the
	// cache is at 80% of the cap (20% headroom to avoid
	// thrashing). Default: 1024 (= 1 GB). Set to 0 to
	// disable the cap entirely (unbounded cache — current
	// pre-feature behavior).
	//
	// The cap is enforced by:
	//   1. An on-write check after each thumb is written
	//      (cheap, runs in a goroutine, doesn't block the
	//      request that triggered the cache write).
	//   2. A background sweep every 30 minutes (catches the
	//      case where the cache grows without new writes).
	//   3. An initial sweep at startup if the cache is
	//      already over the cap (so the operator's existing
	//      over-cap cache gets trimmed down on the first
	//      restart after they set the cap).
	//
	// Eviction policy: FIFO by file mtime. The cache file
	// names are sha256(source path) — opaque hex, not
	// sorted. We use the file's mtime on disk (which is the
	// WRITE time) to determine the oldest. For a true LRU,
	// enable filesystem atime or use a separate LRU log.
	MaxCacheSizeMB int
	// MaxCacheSizeSet is true when the operator explicitly
	// set max_cache_size_mb in the Caddyfile (including
	// the value 0). Used to distinguish "operator set 0
	// (no cap)" from "operator didn't set the directive
	// (use the default)". The default (when neither
	// Caddyfile nor JSON sets it) is 1024 MB.
	MaxCacheSizeSet bool

	// DefaultLanguage is the fallback locale when neither
	// the URL parameter, cookie, nor Accept-Language header
	// produces a match. Per user request 2026-07-04:
	// operators can set this in the Caddyfile via
	//   default_language = "de"
	// Empty string falls back to "en". Validated at
	// Provision time (must match a known locale or be empty).
	// Per user request 2026-07-04: visitors can ALWAYS
	// override this with ?lang=<locale> or the cookie,
	// regardless of what the operator sets here.
	DefaultLanguage string

	// Cache holds the in-memory scan cache. Initialised in Provision
	// if nil. Excluded from JSON config (runtime state only).
	Cache *ScanCache

	// CacheStatsTracker records evictions and exposes the
	// most recent cacheStats snapshot via atomic.Pointer.
	// Initialised in Provision. Used by evictIfOver
	// (recordEvictions) and the stats-refresh goroutine
	// (snapshot + atomic swap). Excluded from JSON config.
	CacheStatsTracker *cacheStatsTracker
	// contains filtered or unexported fields
}

Gallery is a Caddy HTTP handler that renders a directory as a dark-themed image/video gallery. See the README for behaviour.

func (Gallery) CaddyModule

func (Gallery) CaddyModule() caddy.ModuleInfo

func (*Gallery) Cleanup

func (g *Gallery) Cleanup() error

func (*Gallery) Provision

func (g *Gallery) Provision(caddy.Context) error

Provision sets up the module. Creates a default scan cache if one isn't already set.

func (*Gallery) ServeHTTP

func (g *Gallery) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error

ServeHTTP renders the gallery for the directory at the current request path, or falls through to the next handler (typically file_server) if the request is for a file.

Path semantics after handle_path /images/* strips the prefix:

r.URL.Path = ""                    → render gallery for root
r.URL.Path = "subdir"              → render gallery for subdir
r.URL.Path = "subdir/"             → render gallery for subdir
r.URL.Path = "photo.jpg"           → fall through to file_server
r.URL.Path = "subdir/photo.jpg"    → fall through to file_server
r.URL.Path = "_thumbs/photo.webp"  → serve as thumbnail
r.URL.Path = "subdir/_thumbs/x.webp" → serve as thumbnail in subdir

On transient errors (scan failures, template parse), it falls through to the next handler rather than returning a 500.

func (*Gallery) UnmarshalCaddyfile

func (g *Gallery) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

func (*Gallery) Validate

func (*Gallery) Validate() error

type PageData

type PageData struct {
	Title       string
	PathPrefix  string // e.g. "./" — prefix for relative links
	ThumbPrefix string // e.g. "./_thumbs/" — prefix for thumb URLs

	// Three sections. OtherFiles is shown in full regardless of
	// pagination/sort (per the user's spec — it always appears
	// at the top, horizontal). Images is the paginated/sorted
	// set. The directories section is split into two
	// elements: Up (the synthetic ../ entry, rendered on its
	// own line, always first) and Subdirs (the actual subdirs,
	// rendered in a tight row with no gap between them, per
	// the user's 2026-06-17 spec).
	Up         *FileView  // the up entry, or nil at the gallery root
	Subdirs    []FileView // the actual subdirs (no up entry)
	OtherFiles []FileView
	Images     []FileView

	// Pagination
	Page     int // 1-based
	PageSize int
	// Query is the current URL query (sort, filter, page, breadcrumb, etc.) — passed to the template so the page-size form can include hidden inputs to preserve other params on submit.
	Query url.Values
	// CacheStatsXX, CacheStatsYY, CacheStatsZZ, CacheStatsAA
	// are the four hex values displayed in the footer:
	//
	//   XX  = cache usage percent, 00-FF (or "∞" if
	//         MaxCacheSizeMB is 0 / unbounded)
	//   YY  = peak evictions in any 1-hour bucket in the
	//         last 24 hours, 00-FF
	//   ZZ  = peak evictions in any 1-hour bucket in the
	//         last 7 days, 00-FF
	//   AA  = peak evictions in any 1-hour bucket in the
	//         last 28 days (4 weeks), 00-FF
	//
	// When MaxCacheSizeMB is 0, XX is rendered as the
	// infinity symbol (∞) and YY/ZZ/AA are always "00".
	//
	// Per user request 2026-06-27. Refreshed every 30 sec
	// by the stats-refresh goroutine in Provision (atomic
	// pointer swap; readers don't lock).
	CacheStatsXX string
	CacheStatsYY string
	CacheStatsZZ string
	CacheStatsAA string
	// Locale is the visitor's resolved locale (e.g. "en",
	// "de", "ja"). Detected by DetectLocale using URL param
	// > cookie > Accept-Language > operator default.
	// Per user request 2026-07-04: the template uses this
	// for the <html lang="..."> attribute (so screen
	// readers and CSS :lang() selectors work correctly)
	// and for any locale-aware formatting. Defaults to
	// "en" if empty.
	Locale string
	// Translator is the i18n resolver passed into the
	// template via the "t" function. Per user request
	// 2026-07-04: when nil (which shouldn't happen in
	// production but might in unit tests), the template
	// function falls back to English-only lookups via
	// a default-constructed Translator. Keeping this
	// field on PageData rather than threading it through
	// the func map separately lets the same data struct
	// flow through both server-side tests and the live
	// page render without code changes.
	Translator *Translator
	// AvailableLocales is the sorted list of supported
	// locales. Used by the language picker (rendered in
	// Phase 3 / localStorage persistence commit) and by
	// the <html lang="..."> attribute selector. Empty
	// if no translator is wired up.
	AvailableLocales []string
	// SearchQuery is the raw ?q= value (for the search
	// input's `value=""` attribute and for hidden inputs
	// that preserve the query on form submit).
	SearchQuery string
	// SearchMatch is the operator-configured filename matching
	// rule for the search feature. Either "substring" (the
	// default — query can match anywhere) or "word" (query
	// must match the start of a word boundary). The template
	// uses this to render a data-search-match attribute on
	// the search input; the inline JS reads the attribute
	// and switches the matching rule accordingly.
	SearchMatch string
	// IsServerSearchActive is true when the page was
	// rendered with ?q= in the URL (the visitor clicked
	// "Search all" or typed into the URL bar). In this case
	// the file list is ALREADY filtered server-side; the
	// header shows "Media (showing N of M)" where M is the
	// total filtered count in the directory. False when
	// the visitor is just typing in the search box (the
	// client-side filter is doing the work, M = N).
	IsServerSearchActive bool
	// FilteredTotal is the total number of files in the
	// directory that match the active ?q= search. Only
	// meaningful when IsServerSearchActive is true; zero
	// otherwise. The header uses this as the "M" value in
	// "showing N of M".
	FilteredTotal int
	// OnPageMatchedCount is the number of files visible on
	// the current page (already after the server-side
	// filter). The header uses this as the "M" value in
	// "search showing M of N <em>This page</em>" when
	// search is active.
	OnPageMatchedCount int
	// OnPageTotalCount is the number of items that would be
	// on this page if no search filter were applied. For
	// most pages this is the configured pageSize. For the
	// last page, it's the truncated count (e.g. 29 instead
	// of 60 if there are 89 total items). The header uses
	// this as the "N" value in "search showing M of N
	// <em>This page</em>".
	OnPageTotalCount int
	// PageSizes is the list of per-page options the visitor
	// can choose from in the dropdown (e.g. [30, 60, 120, "all"]).
	// Configured via the `page_sizes` Caddyfile directive;
	// defaults to [30, 60, 120, "all"].
	PageSizes []string
	// TotalImages is the total media count (images + videos)
	// AFTER the search/type filters have been applied. Used
	// for the pagination math and the visibility check on
	// the images grid section. If the user has ?q=foo in
	// the URL, this is the count of items matching "foo".
	TotalImages int
	// DirectoryTotal is the total media count in the
	// directory BEFORE any search/type filters are applied.
	// Used for the "Media (N -" prefix in the section
	// header so the visitor sees the total directory size
	// at a glance even while searching. Per user request
	// 2026-06-30: the prefix should keep the directory
	// total (not the filtered total) so the user knows
	// they're seeing N out of TOTAL.
	DirectoryTotal int
	// ImageCount is the count of image files only — used for
	// the "N images" label in the header meta line (so the
	// label is accurate; videos are no longer miscounted as
	// images). Per user request 2026-06-17: separate video
	// indicator in the header.
	ImageCount int
	// ImageStart and ImageEnd are the 1-based range of images
	// shown on the current page (e.g. 1-60 for page 1, 61-89
	// for page 2). Used in the media section header: "Media
	// (89 - Showing 1-60)". Both are 0 if there are no images.
	ImageStart int
	ImageEnd   int
	// TotalVideos is the count of video files only — shown in
	// the header meta line as "N videos" (after the images
	// count, only if > 0).
	TotalVideos int
	// TotalFiles is the sum of all files in the directory:
	// ImageCount + TotalVideos + len(OtherFiles). Computed
	// in RenderPage (not the template) and shown at the start
	// of the header meta line as "N files" (per user request
	// 2026-06-19: a quick "how many files are in this dir"
	// answer at the top of the meta line).
	TotalFiles int
	// TotalAllFilesSize is the pre-formatted (via humanSize) total
	// size of ALL files in the directory: images + videos + other
	// files. Excludes subdirectories (which don't have a Size
	// field). Shown in the header meta line as a separate segment
	// wrapped in `//` separators, per user request 2026-06-18:
	//   "the X.X KB is the total for all files in the directory"
	// e.g. "34 images ·8 videos ·2 other files // (8.3 MB) //
	//        ·26 directories ·50 per page"
	// The `//` separators visually distinguish the size from the
	// other meta items (which use `·`). Operator sees at a glance
	// how much disk the whole directory's media + sidecar files
	// take.
	TotalAllFilesSize string
	TotalPages        int
	HasPrev           bool
	HasNext           bool
	// PageNumbers is the list of page numbers (and 0 for
	// ellipsis) to show in the Google-style bottom pagination.
	// Computed by pageNumbers(current, total). Empty when
	// total <= 1 (no pagination needed).
	PageNumbers []int

	// Breadcrumb is the list of path segments from the
	// gallery root to the current directory. Each segment has
	// a Name (human-readable) and an Href (relative URL).
	// The first segment is the gallery root; the last is the
	// current directory (rendered as plain text, not a link).
	Breadcrumb []BreadcrumbSegment

	// Sort
	Sort SortSpec

	// TypeFilter is the parsed ?type= query param (set of
	// file extensions to show). nil = no filter (show all
	// files). An empty (non-nil) map = "show nothing"
	// (the filter UI shows the empty state). Use
	// IsTypeFilterActive to distinguish "no filter" from
	// "filter that selects nothing" in the UI.
	TypeFilter map[string]bool

	// IsTypeFilterActive is true if the URL has a non-empty
	// ?type= query param. This is the source of truth for
	// whether the filter UI should show the "filtered" state.
	IsTypeFilterActive bool

	// TypeFilterQuery is the raw ?type= value (or "" if no
	// filter). Pass through to the filter UI's form action
	// so the user can re-submit their selection.
	TypeFilterQuery string

	// FilterImageOptions / FilterVideoOptions / FilterOtherOptions
	// are the three filter dropdowns (Images / Videos / Other).
	// Each contains the extensions present in the current
	// directory, with their counts, marked as Selected if
	// currently in the active filter. The UI renders these
	// as three side-by-side dropdowns + an Apply button.
	FilterImageOptions FilterGroup
	FilterVideoOptions FilterGroup
	FilterOtherOptions FilterGroup
}

PageData is the top-level template data for a gallery page. All values are pre-formatted for direct template use — no template-level computations needed.

type ScanCache

type ScanCache struct {
	// contains filtered or unexported fields
}

ScanCache is a small in-memory cache of recent directory scans. Cache entries are keyed by absolute directory path; an entry is valid as long as the directory's mtime has not changed AND the entry's TTL has not expired.

The cache eliminates the per-request os.ReadDir cost in directories that don't change often (the common case for a server with photos on disk). For 100+ image directories like /images/generated, this drops per-request work from milliseconds to microseconds.

This is intentionally simple: no eviction policy beyond TTL — old entries are dropped when re-accessed after the TTL. For a server with <1000 galleries in active rotation, memory is bounded by (number of dirs) * (average file count) * (size of FileInfo).

func NewScanCache

func NewScanCache(ttl time.Duration) *ScanCache

NewScanCache returns a cache with the given TTL. A TTL of 1 minute is a good default; it limits staleness if files are added/removed while also avoiding constant rescans for active directories.

func (*ScanCache) Get

func (c *ScanCache) Get(dir, sortMode string, imageExts, videoExts map[string]bool, noExif, noMeta bool, thumbCacheDir, thumbFormat string) ([]FileInfo, error)

Get returns the cached []FileInfo for dir, or runs a fresh scan if the cache is empty/expired/stale. The sortMode is part of the cache key — sorting by name vs mtime gives different results.

imageExts and videoExts are the Gallery's configured extension sets (used by Scanner.Classify to decide KindImage vs KindVideo vs KindOther). They are part of the cache key because a Gallery reconfigured to recognise a new extension should re-scan.

func (*ScanCache) SetFiles

func (c *ScanCache) SetFiles(dir string, files []FileInfo)

SetFiles atomically replaces the files slice for a cached entry. Used by the background enrichment goroutine after it finishes populating EXIF + dimensions on the previously-stored non-enriched file list.

Per user report 2026-07-01: the previous pattern (mutating entry.files in place from the goroutine) caused a data race — subsequent cache hits within the TTL would return a copy of the slice at an arbitrary moment in the enrichment, so the same page could return different EXIF data on each refresh until the enrichment finally completed.

The fix: the cache holds a non-enriched snapshot while the enrichment runs (callers get a copy of that snapshot). When the enrichment finishes, the goroutine calls SetFiles which atomically swaps in the enriched slice. Future cache hits see the enriched data; no in-progress mutation is observable.

SetFiles is a no-op if the entry no longer exists (e.g. the cache TTL expired and the entry was dropped, or the dir mtime changed and the entry was replaced by a fresh scan).

type Scanner

type Scanner struct {
	Root      string
	Sort      string // "mtime" (default) or "name"
	ImageExts map[string]bool
	VideoExts map[string]bool
	// NoExif, when true, disables the readExif call for
	// image files (no I/O, no parsing). Set by the
	// Gallery's no_exif Caddyfile directive; passed
	// through ScanCache.Get to the per-directory Scanner.
	// When false (the default), EXIF is read eagerly at
	// scan time. See gallery.go for the full rationale.
	NoExif bool
	// NoMeta, per user request 2026-07-02: a SEPARATE flag
	// from NoExif. NoExif affects image EXIF; NoMeta
	// affects video metadata extraction (duration,
	// container, codecs, bitrate, framerate via ffprobe).
	// When true, the enrich step skips the
	// readVideoMetaCached call entirely (no ffprobe
	// subprocess, no .vmeta sidecar writes, no parsing).
	// Useful for galleries with many videos where the
	// operator doesn't need the metadata enrichment.
	// See gallery.go for the full rationale.
	NoMeta bool
	// ThumbCacheDir is the on-disk thumb cache dir. When set
	// (always set in production, via thumbCacheDir() in
	// gallery.go), the scanner uses readDimensionsCached to
	// avoid re-parsing source image headers on every scan.
	// The source dimensions are stored in a sidecar file
	// alongside the thumb. See readDimensionsCached in
	// dimensions.go for the cache file format.
	ThumbCacheDir string
	// ThumbFormat is the thumb file extension (e.g. "webp").
	// Used to derive the sidecar path. Defaults to "webp" if
	// empty (so unit-mode tests that don't set up the full
	// cache dir still work).
	ThumbFormat string
}

Scanner reads a directory and produces a sorted []FileInfo. Both directories and files are included; the Kind field tells them apart.

func NewScanner

func NewScanner(root string) *Scanner

NewScanner returns a Scanner for the given root directory with default sort order (mtime desc — newest first) and the default image / video extension sets.

func (*Scanner) Enrich

func (s *Scanner) Enrich(files []FileInfo)

Enrich populates the EXIF and pixel-dimension fields on each FileInfo in files. This is the slow path — it reads image headers (~1-5ms per image for dimensions, ~1-5ms for EXIF) — and is meant to be called in a background goroutine via EnrichInBackground.

For each image, Enrich tries the on-disk sidecar first (readExifCached / readDimensionsCached): if the sidecar exists and isn't stale, the read is ~50µs (a small text file). The slow path is the FIRST time we see an image — the sidecar doesn't exist yet, so we have to parse the image's header and write the sidecar. For 4491 images this can take ~45 seconds with one worker; parallelized to ~5 seconds with 10 workers.

Errors are silently swallowed (logged to stderr). This is best-effort: missing EXIF data shows up as "no EXIF pill on the card", missing dimensions shows up as "no W × H watermark". The page still renders, just with less metadata.

Safe to call concurrently with reads of the same files slice (e.g. when ScanCache.Get is serving requests while a previous Enrich is still running). The mutations are idempotent — a stale-or-younger value would just be overwritten. For race-free behavior, callers should pass a slice that NOBODY else is reading concurrently.

func (*Scanner) EnrichInBackground

func (s *Scanner) EnrichInBackground(files []FileInfo, cache *ScanCache, dir string)

EnrichInBackground spawns a goroutine that enriches a COPY of files in parallel, then atomically replaces the cache entry's files slice with the enriched copy (via cache.SetFiles). Returns immediately.

Per user report 2026-07-01: the previous pattern (mutating the cached files slice in place from the goroutine) caused a data race — subsequent cache hits within the TTL would return a copy of the slice at an arbitrary moment in the enrichment, so the same page could return different EXIF data on each refresh until the enrichment finally completed.

This version makes its own copy, then enriches the copy IN PLACE. The caller's slice is never touched after EnrichInBackground returns. When the goroutine finishes, it calls cache.SetFiles(dir, enriched) which atomically swaps the new slice into the cache. Future cache hits see the enriched data; no in-progress mutation is observable by other readers.

maxParallel workers process files concurrently. The default of 8 was chosen empirically: image-header parsing is CPU-bound (Go's stdlib decoders), 8 workers saturates a typical 4-8 core machine without causing too much disk thrashing on slow storage. Thumbs are still served from a separate goroutine pool (EagerGenPageThumbs) and aren't blocked by this — they only need the source file, not the EXIF/dimensions cache.

For 4500 files at ~10ms each: 4500/8 * 10ms = ~5.6 seconds of background work per fresh scan (vs ~45s single-threaded). The visitor's first page render isn't blocked by this — the HTML response is sent as soon as Scan() returns.

func (*Scanner) Scan

func (s *Scanner) Scan() ([]FileInfo, error)

Scan walks the directory and returns a SORTED slice of FileInfo. Both files and subdirectories are included (Kind = KindDir for directories). Symlinks are followed: a symlink to a directory is classified as KindDir, and the FileInfo's Size and ModTime come from the symlink's target (not the symlink itself, which would report the length of the target path string and the link's own mtime). Broken symlinks (target missing or inaccessible) are silently skipped.

Sort order:

  • "mtime" (default): newest first by modification time
  • "name": alphabetical by name (case-insensitive)

Returns an error only if the directory cannot be read.

Per user report 2026-07-01: Scan is now FAST (~20ms for 4497 files). It does NOT read EXIF or pixel dimensions — those happen in the background via Enrich. The first page render after a fresh scan shows cards without the EXIF pill and dimensions watermark; subsequent renders (after the background enrich completes) show the full data. This trades a minor UX degradation on the FIRST visit for a major speedup on the critical path.

Callers that need the enriched data should invoke s.EnrichInBackground(files) immediately after Scan returns. RenderPage works fine with the unenriched data — the Exif, Width, and Height fields just stay at their zero values.

type SortSpec

type SortSpec struct {
	Field string
	Order string
}

SortSpec describes the current sort state. Field is one of "name", "type", "date", "size", or "mtime" (the default). Order is "asc" or "desc".

type ThumbConfig

type ThumbConfig struct {
	// Width and Height are the max-dim bounding box (in pixels)
	// for the generated thumb. The source image is fit-within-
	// bounds: aspect ratio is preserved and the longest edge
	// becomes the configured value.
	Width  int
	Height int
	// Format is the output format: "jpeg" (or "jpg"), "png", or
	// "webp" (the default, lossless). Encoded with stdlib
	// image/jpeg (quality 75), stdlib image/png, or
	// github.com/HugoSmits86/nativewebp respectively.
	Format string
	// MaxCacheSizeMB is the configured cache cap (from
	// the Caddyfile). 0 = no cap (the pre-feature
	// behavior). Passed to the eviction helper after
	// each successful cache write.
	MaxCacheSizeMB int
	// CacheStatsTracker records eviction counts so the
	// footer can show peak evictions per period. nil is
	// safe (recordEvictions is a no-op). Set in
	// Provision; passed through ThumbConfig to
	// GenerateOrLoadThumb.
	CacheStatsTracker *cacheStatsTracker
}

ThumbConfig holds the runtime configuration for thumb generation. Set at Provision time from the Gallery's Caddyfile-configured values (or defaults).

type Translator

type Translator struct {
	// contains filtered or unexported fields
}

Translator resolves translation keys to strings for the given locale. Constructed once per Gallery (at Provision time) and shared across all ServeHTTP calls (read-only after construction). The disk-override layer is consulted at construction; embedded defaults are layered on top so any key missing in the disk file falls through to the embedded value (or to "en" if neither has it).

func NewTranslator

func NewTranslator(diskDir string) (*Translator, error)

NewTranslator builds a Translator from the embedded lang/ directory plus an optional disk-override directory.

diskDir may be empty (no overrides). Operator-supplied translations in diskDir override the embedded ones, OR add brand-new locales that weren't embedded.

func (*Translator) HasLocale

func (t *Translator) HasLocale(locale string) bool

HasLocale reports whether the given locale is supported.

func (*Translator) Locales

func (t *Translator) Locales() []string

Locales returns the sorted list of supported locales.

func (*Translator) NativeName

func (t *Translator) NativeName(locale string) string

NativeName returns the native-script name of a locale (e.g. "English" for "en", "Deutsch" for "de", "日本語" for "ja"). The name is ALWAYS rendered in the locale's OWN language — we read from the requested locale's translation file, not from "en". So when the visitor is viewing the page in Japanese, the options show their native names (英語, ドイツ語, etc.) because each locale's JSON file has the native name of every other locale translated.

Falls back to the locale code itself if the native name isn't found (e.g. a new locale was added but the native name wasn't populated in any JSON file).

Per user request 2026-07-04: the dropdown UI shows native script names regardless of the visitor's current locale — the visitor's eye picks out their language from the dropdown instantly.

func (*Translator) SelfName

func (t *Translator) SelfName(locale string) string

SelfName returns the native name of the translator's own locale (the key used for the current locale trigger button). Equivalent to NativeName(locale) but reads directly without the locale arg — callers don't need to track the current locale separately.

func (*Translator) T

func (t *Translator) T(locale, key string, args ...any) string

T looks up a translation key in the given locale and returns the formatted string. The fallback chain is:

  1. Requested locale
  2. "en" (English — always the canonical fallback)
  3. The key itself (so a missing key is visible in the rendered page rather than crashing)

Placeholders use Go's fmt syntax: {key}, {name}, {n}, etc. If args are supplied, they're substituted via fmt.Sprintf. If no args are supplied, the raw string is returned (so single-brace text without substitutions just works).

type VideoMeta

type VideoMeta struct {
	Duration   string // "1:23" or "1:23:45"
	Container  string // "mov,mp4,m4a,3gp,3g2,mj2" (ffprobe output)
	VideoCodec string // "h264", "vp9", "av1", etc.
	AudioCodec string // "aac", "opus", "vorbis", etc. (empty if no audio)
	Bitrate    string // "5.2 Mbps", "842 kbps" (human-readable)
	Framerate  string // "24 fps", "29.97 fps" (human-readable)
}

VideoMeta holds the extracted metadata for a video file. All fields are optional (empty string = not available). See file-level comment for the parallel with ExifData.

func (*VideoMeta) HasAny

func (v *VideoMeta) HasAny() bool

HasAny returns true if at least one field is populated. Used to decide whether to render the "video metadata" panel in the lightbox. An empty VideoMeta (all empty strings) is treated as "no metadata" — no UI shown.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL