#+title: gallery: v1 design #+date: 2026-09-25 * Scope A single Go binary that serves a photo gallery from a folder tree. Resized copies are generated on request and cached on disk. Photos and the cache live outside the repository. ** In v1 - Albums from folders: =photos//*.{jpg,png}=, optional =album.org= per album. - Pages: home, album, photo. Stable URLs. - On-demand resize at a fixed set of widths, disk cache, =srcset=. - EXIF line: camera, lens, aperture, shutter, ISO, date. - One dark theme driven by CSS variables, overridable at runtime. - Keyboard and swipe navigation on the photo page. - New and changed files appear without a restart. ** Deferred Private albums, upload, tags and search, RSS, likes and comments, watermarking, admin UI, WebP/AVIF derivatives, HEIC/RAW/video formats. * Architecture ** Layout #+begin_example cmd/gallery/main.go flags, wiring, HTTP server internal/library/ scan photos/ into albums and items; watch for changes internal/format/ Format interface and registry (jpeg, png) internal/render/ derivative cache: lookup, generate, store internal/web/ handlers, html/template pages, embedded CSS/JS #+end_example ** Configuration Flags, each with an environment fallback: | Flag | Env | Default | Purpose | |--------------+---------------------+-----------+--------------------------------| | =-photos= | =GALLERY_PHOTOS= | required | photo root, read-only | | =-cache= | =GALLERY_CACHE= | required | derivative and metadata cache | | =-addr= | =GALLERY_ADDR= | =:8080= | listen address | | =-theme= | =GALLERY_THEME= | embedded | replacement =theme.css= | | =-templates= | =GALLERY_TEMPLATES= | embedded | replacement template directory | | =-title= | =GALLERY_TITLE= | =gallery= | site name in the nav | At startup the binary checks that =magick= and =exiftool= are on =PATH= and exits with a message naming whichever is missing. ** Formats #+begin_src go type Kind int const ( KindImage Kind = iota // KindVideo later; selects a different template partial. ) type Meta struct { Width, Height int // after orientation is applied Taken time.Time Camera, Lens string Aperture string Shutter string ISO int } type Format interface { Name() string Match(path string) bool Metadata(path string) (Meta, error) Resize(src string, width int, dst io.Writer) error Kind() Kind } #+end_src - A package-level registry holds formats in registration order; the first =Match= wins. Files no format matches are ignored. - v1 registers =jpeg= (=.jpg=, =.jpeg=) and =png=, matched by extension, case-insensitive. - Both share one ImageMagick-backed implementation parameterised by extensions: - =Metadata=: =exiftool -json -n= with the fields above. Width and height are swapped when the EXIF orientation is 5-8. - =Resize=: =magick -limit memory 512MiB -limit map 1GiB -auto-orient -resize x -strip -quality 82 jpg:-= streamed to =dst=. Output is always JPEG in v1. - Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video is a new =Kind= plus a template partial. Neither touches =library= or =render=. ** Library - Each immediate subdirectory of =-photos= is an album; the slug is the directory name. Nested directories are ignored in v1. Hidden files and directories are skipped. - =album.org= (optional) supplies keywords: #+begin_example ,#+title: Tokyo ,#+date: 2025-03 ,#+cover: DSCF0412.jpg ,#+order: 10 ,#+sort: taken #+end_example The body, if any, is album description text rendered from org to HTML. - =title= defaults to the slug. - =cover= defaults to the first item after sorting. - =order= sorts albums ascending on the home page. Albums without it follow, newest =date= (else newest item taken time) first. - =sort= is =taken= (default) or =name=. - =photos/about.org= (optional) is rendered as the Info page. When absent, the Info link is hidden. - Metadata is cached in =/meta.json=, keyed by relative path, mtime and size. Only new or changed files are passed to =exiftool=. - The library is an immutable snapshot swapped atomically behind an =atomic.Pointer=. =fsnotify= watches the root and each album directory; events are debounced (500 ms) and trigger a full rescan. Rescans are cheap because unchanged files are served from the metadata cache. - A file that fails metadata extraction is logged and excluded. ** Rendering and cache - URL: =/img///.jpg=. - Allowed widths: 480, 960, 1600, 2400. Anything else is 400. Widths larger than the source are clamped to the source width. - Cache path: =/img///-/.jpg=. A changed source gets a new directory; stale directories are removed on the next rescan. - Concurrent requests for the same key are collapsed with =singleflight=. - Output is written to a temp file in the target directory and renamed into place, so a failed or interrupted resize never leaves a partial file. - A process-wide semaphore caps concurrent =magick= runs at =runtime.GOMAXPROCS(0)=. - Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL changes when the source does because pages link with a =?v== query. * Pages ** Visual direction Dark, minimal, cinematic: near-black background, light grey text, small uppercase labels with wide letter-spacing. Photos are never cropped. ** Nav (all pages) Site title left; album names, Timeline and Info right, in small spaced capitals. The current page is highlighted. ** Home: =/= Album covers laid out as justified rows (same algorithm as the album page), one cover per album, with album title and year in small capitals below each. Covers link to the album. ** Album: =//= Title line (title left, year and count right), optional description, then all items as justified rows with a 4 px gap. No lead image. Each tile links to the photo page. ** Timeline: =/timeline/= Every item from every album, newest first, grouped under month headings ("September 2025", with the item count on the right). Each month's items are justified rows linking to the item's photo page; navigation there stays within the album. Dates are =Item.Taken= (EXIF date, else file mtime), grouped by calendar month. =timeline= is a reserved album slug alongside =img=, =info= and =_=. ** Photo: =///= - Image fitted to the viewport (=object-fit: contain=), =srcset= over the allowed widths. - Previous and next arrows at the sides; =←= =→= keys and horizontal swipe navigate; =Esc= returns to the album. First and last items do not wrap. - Bottom line: title or filename with position (=12 / 24=) on the left, EXIF line on the right. Missing EXIF fields are omitted. - The next and previous images are preloaded. ** Justified rows - The server emits each item's aspect ratio as =--ar= (inline style) and =data-ar=. - Base CSS: =display: flex; flex-wrap: wrap;= with each tile's width set from =--row-height × --ar=, so layout is uncropped and stable without JS. - A script (target: under 50 lines) partitions items into rows whose summed aspect ratios fill the container at =--row-height=, sets exact heights, and recomputes on resize (debounced). The last row is not stretched. - Width and height attributes are set on every == to prevent layout shift. ** Theme =theme.css= is embedded and replaceable with =-theme=. Variables: | Variable | Default | |----------------+-----------------------------------| | =--bg= | =#0a0a0a= | | =--fg= | =#dddddd= | | =--muted= | =#777777= | | =--accent= | =#ffffff= | | =--gap= | =4px= | | =--row-height= | =320px= | | =--font= | Helvetica Neue, Arial, sans-serif | | =--tracking= | =0.25em= | Templates (=base=, =home=, =album=, =photo=, =info=, =error=) are embedded and replaceable with =-templates=. ** Org rendering =album.org= bodies and =about.org= are rendered with =github.com/niklasfasching/go-org=. * Errors | Condition | Result | |-------------------------------------+------------------------------------------| | Unknown album or file | 404, styled error page | | Width not in the allowed set | 400 | | =magick= fails | 500, logged, nothing cached | | =exiftool= fails for a file | file excluded, logged | | Malformed =album.org= | defaults used, logged | | =magick= or =exiftool= missing | exit at startup with the missing name | | Path traversal in any URL segment | 404; slugs are validated against the library, never joined raw | * Deployment - Multi-stage =Dockerfile=: =golang:1= builds a static binary; =alpine= runtime with =imagemagick=, =imagemagick-heic=, =imagemagick-webp=, =imagemagick-jpeg=, and =exiftool=. - =compose.yml= in =~/docker/gallery= on =homelab=: - =127.0.0.1:8003:8080= - photos mounted read-only at =/photos=, cache read-write at =/cache= - Host nginx proxies to =127.0.0.1:8003=, matching the existing services. * Testing - =library=: scanning, =album.org= parsing and defaults, sort orders, hidden and unmatched files skipped, metadata cache invalidation on mtime/size. - =format=: registry matching and precedence. - =render=: width validation and clamping, cache path derivation, =singleflight= collapse, no partial files on failure. - Integration, skipped when =magick= or =exiftool= is absent: fixture JPEG, PNG, and an orientation-6 JPEG through real =Metadata= and =Resize=, checking dimensions and orientation. - HTTP: =httptest= against every route and every row in the errors table.