{"id":51527785,"url":"https://github.com/synapticloop/caddy_media_gallery","last_synced_at":"2026-07-08T23:30:38.742Z","repository":{"id":366947728,"uuid":"1278547024","full_name":"synapticloop/caddy_media_gallery","owner":"synapticloop","description":"A media gallery for Caddy v2 server - a delightful view of your images and videos","archived":false,"fork":false,"pushed_at":"2026-07-01T01:05:44.000Z","size":19181,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-01T03:06:26.781Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/synapticloop.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-23T22:43:20.000Z","updated_at":"2026-07-01T01:05:48.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/synapticloop/caddy_media_gallery","commit_stats":null,"previous_names":["synapticloop/caddy_media_gallery"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/synapticloop/caddy_media_gallery","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/synapticloop%2Fcaddy_media_gallery","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/synapticloop%2Fcaddy_media_gallery/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/synapticloop%2Fcaddy_media_gallery/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/synapticloop%2Fcaddy_media_gallery/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/synapticloop","download_url":"https://codeload.github.com/synapticloop/caddy_media_gallery/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/synapticloop%2Fcaddy_media_gallery/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35281838,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-08T02:00:06.796Z","response_time":61,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-08T23:30:36.706Z","updated_at":"2026-07-08T23:30:38.728Z","avatar_url":"https://github.com/synapticloop.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# caddy_media_gallery\n\n\u003e **Release 1.0.0 — 2026-07-04.** API + Caddyfile directive surface is now stable. Future versions will be additive (new directives, new fields, new optional JSON keys); the existing Caddyfile directives, JSON fields, and URL parameters will not change in breaking ways. Operators can pin to `1.0.0` (or any later version) in their build pipeline.\n\u003e Internationalisation landed in 1.0.0 — 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](docs/03h-feature-i18n.md) for the full architecture.\n\n*The delightful way to serve a directory.*\n\nA Caddy v2 HTTP handler module that renders a directory as a thumbnailed\nmedia gallery. Replaces Caddy's default `file_server browse` with a\nsortable + paginated grid, click-to-expand lightbox, video support,\nand a separate \"Directories\" and \"Other files\" listing for non-media content.\nThe visitor can switch between light and dark mode - with automatic system\ndefined mode pickup - with the in-page toggle (shown in the animated preview below).\n\n![Light theme fading to dark theme](docs/screenshots/synapticloop-caddy_media_gallery_fade.gif)\n\n## Features\n\n- **Drop-in replacement** for `file_server browse` in a `handle_path` block.\n- **Recursive** every subdirectory under the matched route is rendered as a gallery.\n- **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`.\n- **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 (\u003c100µs per file).\n- **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 (\u003c100µ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.\n- **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.\n- **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 `\u003cvideo\u003e` 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.\n- **\"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).\n- **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.\n- **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.\n- **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\u0026type=png` or `?ext=jpg\u0026ext=png`; `?ext=.` filters to no-extension files only).\n- **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.\n- **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.\n- **Vanilla JS lightbox** for click-to-expand, no external JS dependencies.\n\n  ![Lightbox view](docs/screenshots/synapticloop-caddy_media_gallery_lightbox.png)\n\n  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.\n\n- **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.\n- **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.\n- **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 → `localStorage` → `Accept-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/\u003clocale\u003e.json`. Full architecture: [`docs/03h-feature-i18n.md`](docs/03h-feature-i18n.md).\n- **Native `loading=\"lazy\"`** on every thumbnail, plus a subtle shimmer animation while the image loads.\n- **Video support** — videos show a play-button overlay and link to the raw file. ffmpeg extracts the first frame for the poster thumbnail.\n- **\"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.\n- **\"Other files\" section** for non-image/non-video content in a directory.\n- **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](docs/03d-feature-localstorage.md). All keys are namespaced with the `gallery-` prefix to avoid collisions with the host site's own localStorage usage.\n\n## Install\n\n### System install (requires sudo)\n\nBuild a custom Caddy binary with this module baked in:\n\n```bash\nxcaddy build \\\n    --with github.com/caddyserver/caddy@v2.11.4 \\\n    --with github.com/synapticloop/caddy_media_gallery@latest\n```\n\nOr use the included build script (pins Caddy to v2.11.4 and the local module path):\n\n```bash\n./build.sh\n```\n\nThe build script also restarts Caddy via systemd (you may need to be root or use sudo).\n\n### Local install (no root, no sudo)\n\nIf 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:\n\n```bash\n# Build into ~/bin/caddy, generate Caddyfile.user, listen on the\n# default port (3245, see \"Why 3245?\" below). No sudo needed.\n./build.sh --user\n\n# Custom port (must be \u003e 1024; the script enforces this).\n./build.sh --user 9000\n\n# Serve a different directory (default is ~/Pictures).\nCADDY_USER_ROOT=~/photos ./build.sh --user 9000\n```\n\nThis:\n1. Builds the binary into `~/bin/caddy` (no install to `/usr/local/bin`)\n2. Writes a starter `Caddyfile.user` in the project root (only on first run — your edits are preserved on subsequent builds)\n3. Validates the port (must be 1025-65535) and warns if the root directory doesn't exist\n4. Prints the exact commands to start Caddy in the foreground or background\n\nThen to run Caddy:\n\n```bash\n# Foreground (Ctrl+C to stop):\n~/bin/caddy run --config Caddyfile.user\n\n# Background:\nnohup ~/bin/caddy run --config Caddyfile.user \u003e ~/caddy.log 2\u003e\u00261 \u0026\necho $! \u003e ~/caddy.pid\n\n# Stop the background process:\nkill $(cat ~/caddy.pid)\n```\n\nOpen \u003chttp://localhost:3245\u003e 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).\n\n**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`.\n\n\n## Caddyfile usage\n\n```caddyfile\nhandle_path /images/* {\n    root * /var/www/html/images\n    media_gallery         # default: mtime desc, 320px WebP thumbs\n    file_server           # serves direct file requests (e.g. /images/foo.jpg)\n}\n\n# Or with explicit sort:\nhandle_path /images/crosswords/* {\n    root * /var/www/html/images/crosswords\n    media_gallery { sort name }   # alphabetical for curated content\n}\n```\n\nThe `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`).\n\n### Auth\n\nThe 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.\n\n## Caddyfile directive options\n\nThe `media_gallery` directive accepts these sub-options (full reference in [`docs/01-configuration.md`](docs/01-configuration.md)):\n\n| Subdirective | Default | Description |\n|---|---|---|\n| `sort` | `mtime` | Sort field: `mtime` (newest first) or `name` (alphabetical) |\n| `path_prefix` | (none) | URL mount prefix used in breadcrumbs (e.g. `images`). Defaults to the directory name. |\n| `root_name` | (none) | Display name for the root breadcrumb. Defaults to \"media root\". |\n| `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. |\n| `video_types` | built-in list | Space-separated list of video extensions. Default: `mp4 webm m4v mov mkv avi ogv ogg`. |\n| `page_size` | `60 30 120 all` | Space-separated list of dropdown options for the per-page dropdown; the first item is the default. Use `all` as one of the values for \"show all items on one page\" (only included in the dropdown if explicitly listed). The dropdown is always sorted by increasing numeric value with `\"all\"` at the end, so the operator can write the list in any order. Default: `60 30 120 all`. |\n| `thumb_width` | `320` | Max width of generated thumbnails (px). |\n| `thumb_height` | `320` | Max height of generated thumbnails (px). |\n| `thumb_format` | `webp` | Output format: `webp`, `png`, `jpeg` (or `jpg`). |\n| `thumb_ttl` | `1440` | HTTP `Cache-Control: max-age` in minutes for thumb responses. |\n| `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. |\n| `no_thumbs` | `true` | Skip thumbnail generation (use original file in `\u003cimg src\u003e`). 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. |\n| `no_video_thumbs` | `false` | Skip ffmpeg-based video poster extraction. |\n| `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`. |\n| `no_meta` | `true` | Skip video metadata extraction entirely (duration, container, codecs, bitrate, framerate via ffprobe). Separate flag from `no_exif` — `no_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`. |\n| `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`](docs/03h-feature-i18n.md) for the full architecture. |\n| `template` | `gallery.tmpl` | Template file name (relative to `$GALLERY_TEMPLATES_DIR`, no `..` allowed). |\n| `search_match` | `substring` | Filename match rule for search: `substring` (default) or `word` (word-boundary). |\n| `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. |\n\nExample:\n```caddyfile\nmedia_gallery {\n    sort name\n    page_size 30 60 120 all\n    search_match word\n    template themes/dark/gallery.tmpl\n}\n```\n\n### Defaults: file_server browse compatible (since 2026-07-02)\n\nAs 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):\n\n| Directive | Default | Effect when `true` | Opt back in with |\n|---|---|---|---|\n| `no_exif` | **`true`** | Skip EXIF reads (no exiftool / go-exif calls) | `no_exif false` |\n| `no_meta` | **`true`** | Skip video metadata reads (no ffprobe subprocess) | `no_meta false` |\n| `no_thumbs` | **`true`** | Skip thumbnail generation (use original file in `\u003cimg src\u003e`) | `no_thumbs false` |\n\nThese three flags are **independent** — operators can enable any combination. All other defaults (`page_size`, `thumb_width`, `cache_scan`, etc.) are unchanged.\n\nThe 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.\n\nTo get the **full enriched UX** (EXIF pills + video metadata + thumbnails), set all three to `false`:\n\n```caddyfile\nmedia_gallery {\n    no_exif false\n    no_meta false\n    no_thumbs false\n}\n```\n\n## How thumbs work\n\nThumb URLs look like `/_thumbs/\u003cbasename\u003e.webp` (e.g. for source `photo.jpg`, the thumb is at `/_thumbs/photo.webp`). On first request, `serveThumb` calls `GenerateOrLoadThumb` which:\n\n1. Hashes the source's absolute path (sha256, first 16 bytes / 32 hex chars).\n2. Splits the hash into 2 hex chars per level: `\u003caa\u003e/\u003cbb\u003e/\u003crest28\u003e`.\n3. Checks the cache at `/var/cache/caddy-gallery/\u003caa\u003e/\u003cbb\u003e/\u003crest28\u003e.webp` (and the matching `.meta` and `.exif` sidecars).\n4. If the cached thumb is missing OR its mtime is older than the source's mtime, regenerates:\n   - Decode source (jpg, png, gif, webp via stdlib + golang.org/x/image)\n   - Resize to fit within `thumb_width` × `thumb_height` (default 320×320), preserve aspect ratio\n   - Encode as lossless WebP (VP8L) using github.com/HugoSmits86/nativewebp\n   - 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)\n   - Write dimensions to `\u003chash\u003e.webp.meta` sidecar (plain text \"W H\\n\", used for LRU + the W × H watermark)\n   - Read+write EXIF to `\u003chash\u003e.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\n   - Read+write video metadata to `\u003chash\u003e.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\n   - Return the bytes\n5. 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).\n\nCache 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).\n\n**Cache directory** is `/var/cache/caddy-gallery` by default. Override with the `GALLERY_THUMB_CACHE_DIR` env var (useful for testing).\n\n## Caching \u0026 performance\n\nThe 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.\n\n### The three caches\n\n- **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).\n\n- **Thumb cache** (on-disk at `/var/cache/caddy-gallery/`) — 2-level nested hash subdirs (`\u003caa\u003e/\u003cbb\u003e/\u003chash28\u003e.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).\n\n- **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 \u003c source.mtime` → sidecar is stale → re-read source and overwrite. Both sidecars are written/checked atomically.\n\n### First-visit latency budget\n\nFor `/images/media_gallery/` (89 files, 4 with EXIF), cold cache:\n\n| Step | Time |\n|---|---|\n| TLS handshake | ~16ms |\n| Scan (cold, 89 files) | ~20ms |\n| Sync enrich of 89 files (8 workers × ~10ms each) | ~110ms |\n| Template render | ~30ms |\n| Body transfer (gzip 160 KB → 20 KB) | ~50ms |\n| **HTML total** | **~225ms** |\n| First thumb request (cold, decode + resize + WebP encode) | ~250ms |\n| Subsequent thumbs (browser parallelizes 6 at a time) | wave over ~1s |\n| Warm reload (within 24h TTL) | ~30ms |\n\n### HTTP caching\n\n- **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.\n- **Gallery HTML**: `Cache-Control: no-cache` (always fresh). New images need to show up on the next refresh.\n- **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.\n\n### Cache size cap\n\n`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%).\n\n## Dependencies\n\n- [caddyserver/caddy](https://github.com/caddyserver/caddy) v2.11.4 (compile-time)\n- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) — for image resizing and WebP decoding (for dimension reading)\n- [HugoSmits86/nativewebp](https://github.com/HugoSmits86/nativewebp) — pure-Go lossless WebP encoder (no CGO, no libwebp)\n- [dsoprea/go-exif/v3](https://github.com/dsoporea/go-exif) — for EXIF metadata reading (JPEG, PNG, WebP). GPS data is intentionally never read.\n- `ffmpeg` (external binary, optional) — for video thumbnail extraction. If not present, the gallery falls back to a placeholder gradient.\n\n## Build\n\n```bash\n# Clone\ngit clone https://github.com/synapticloop/caddy_media_gallery\ncd caddy_media_gallery\n\n# Build (requires xcaddy and Go 1.21+)\ngo mod download\n./build.sh\n```\n\n## Test\n\n```bash\ngo test ./... -v\ngo test ./... -race       # race detector\n```\n\n416 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.\n\n## Architecture\n\n```\ncaddy_media_gallery/\n├── gallery.go              # Module registration, Caddyfile parser, ServeHTTP\n├── scanner.go              # Directory walker + file classification (image/video/other)\n├── scancache.go            # mtime-keyed in-memory cache of directory scans\n├── render.go               # PageData struct, RenderPage (22 args), URL helpers, FuncMap\n├── thumbnails.go           # WebP thumb generation, mtime cache, LRU eviction\n├── dimensions.go           # Source dimensions reader + .meta sidecar cache\n├── exif.go                 # EXIF reader + .exif sidecar cache (text format)\n├── *_test.go               # Go tests (416 total)\n├── build.sh                # xcaddy build + systemd restart (or --user for local install)\n├── template_embedded.go     # //go:embed directive bundling the gallery template\n├── templates/\n│   └── gallery.tmpl        # HTML template (separate file, //go:embed'd at build time)\n└── README.md               # this file\n```\n\n## Customizing the template\n\nThe HTML template lives in `templates/gallery.tmpl` and is bundled into the Go binary at build time via `//go:embed`. Two ways to customize:\n\n1. **Edit the source template** — modify `templates/gallery.tmpl` and rebuild. The new template is bundled into the binary on the next `./build.sh`.\n\n2. **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.\n\n## Caddyfile example (full)\n\n```caddyfile\n{\n    admin off\n}\n\nyour.caddy.host:443 {\n    tls /etc/caddy/caddy.crt /etc/caddy/caddy.key\n\n    route {\n        basic_auth {\n            youruser $2a$14$bcrypt_hash_here\n        }\n\n        handle_path /images/* {\n            root * /var/www/html/images\n            media_gallery\n            file_server\n        }\n    }\n}\n```\n\n## Documentation\n\nThe full documentation is also available as a single\nPDF: [caddy-media-gallery-book.pdf](caddy-media-gallery-book.pdf)\n\nA full changelog of every commit (224+ entries, grouped by date and category)\nis in [CHANGELOG.md](CHANGELOG.md).\n\nDetailed operator documentation lives in [`docs/`](docs/):\n\n- [docs/00-readme.md](docs/00-readme.md) — documentation index + quickstart\n- [docs/01-configuration.md](docs/01-configuration.md) — Caddyfile directive, JSON config, env vars\n- [docs/02-configuration-reference.md](docs/02-configuration-reference.md) — one-page reference of every config knob\n- [docs/03-templates.md](docs/03-templates.md) — template index (links to the per-topic pages below)\n- [docs/03a-structure.md](docs/03a-structure.md) — how the template loads, what's in the file, template variables\n- [docs/03b-customization.md](docs/03b-customization.md) — editing, walkthroughs, troubleshooting, upgrading\n- [docs/03c-feature-dark-mode.md](docs/03c-feature-dark-mode.md) — dark mode and the theme toggle\n- [docs/03d-feature-localstorage.md](docs/03d-feature-localstorage.md) — localStorage keys\n- [docs/03e-feature-lightbox.md](docs/03e-feature-lightbox.md) — lightbox (image + video), EXIF/META panels\n- [docs/03f-feature-layout.md](docs/03f-feature-layout.md) — header, pagination, footer, section heading, open-in-new-tab\n- [docs/03g-building-pdf.md](docs/03g-building-pdf.md) — building the operator manual PDF locally\n- [docs/04-sort-and-pagination.md](docs/04-sort-and-pagination.md) — the `?sort=\u0026order=\u0026page=` URL API\n- [docs/05-font-credits.md](docs/05-font-credits.md) — SIL OFL 1.1 copyright notice for Libre Baskerville + JetBrains Mono (the two fonts used in the PDF)\n- [docs/06-endplate.md](docs/06-endplate.md) — the back-cover ASCII art (closing plate of the PDF)\n\n## License\n\nMIT. See [LICENSE](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsynapticloop%2Fcaddy_media_gallery","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsynapticloop%2Fcaddy_media_gallery","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsynapticloop%2Fcaddy_media_gallery/lists"}