docs/specs/2026-09-25-gallery-design.org

main
gallery/docs/specs/2026-09-25-gallery-design.org rendered · source · history · blame · raw

248 lines · 10172 bytes

  1#+title: gallery: v1 design
  2#+date: 2026-09-25
  3
  4* Scope
  5
  6A single Go binary that serves a photo gallery from a folder tree. Resized
  7copies are generated on request and cached on disk. Photos and the cache live
  8outside the repository.
  9
 10** In v1
 11
 12- Albums from folders: =photos/<album>/*.{jpg,png}=, optional =album.org= per
 13  album.
 14- Pages: home, album, photo. Stable URLs.
 15- On-demand resize at a fixed set of widths, disk cache, =srcset=.
 16- EXIF line: camera, lens, aperture, shutter, ISO, date.
 17- One dark theme driven by CSS variables, overridable at runtime.
 18- Keyboard and swipe navigation on the photo page.
 19- New and changed files appear without a restart.
 20
 21** Deferred
 22
 23Private albums, upload, tags and search, RSS, likes and comments,
 24watermarking, admin UI, WebP/AVIF derivatives, HEIC/RAW/video formats.
 25
 26* Architecture
 27
 28** Layout
 29
 30#+begin_example
 31cmd/gallery/main.go   flags, wiring, HTTP server
 32internal/library/     scan photos/ into albums and items; watch for changes
 33internal/format/      Format interface and registry (jpeg, png)
 34internal/render/      derivative cache: lookup, generate, store
 35internal/web/         handlers, html/template pages, embedded CSS/JS
 36#+end_example
 37
 38** Configuration
 39
 40Flags, each with an environment fallback:
 41
 42| Flag         | Env                 | Default   | Purpose                        |
 43|--------------+---------------------+-----------+--------------------------------|
 44| =-photos=    | =GALLERY_PHOTOS=    | required  | photo root, read-only          |
 45| =-cache=     | =GALLERY_CACHE=     | required  | derivative and metadata cache  |
 46| =-addr=      | =GALLERY_ADDR=      | =:8080=   | listen address                 |
 47| =-theme=     | =GALLERY_THEME=     | embedded  | replacement =theme.css=        |
 48| =-templates= | =GALLERY_TEMPLATES= | embedded  | replacement template directory |
 49| =-title=     | =GALLERY_TITLE=     | =gallery= | site name in the nav           |
 50
 51At startup the binary checks that =magick= and =exiftool= are on =PATH= and
 52exits with a message naming whichever is missing.
 53
 54** Formats
 55
 56#+begin_src go
 57type Kind int
 58
 59const (
 60	KindImage Kind = iota
 61	// KindVideo later; selects a different template partial.
 62)
 63
 64type Meta struct {
 65	Width, Height int // after orientation is applied
 66	Taken         time.Time
 67	Camera, Lens  string
 68	Aperture      string
 69	Shutter       string
 70	ISO           int
 71}
 72
 73type Format interface {
 74	Name() string
 75	Match(path string) bool
 76	Metadata(path string) (Meta, error)
 77	Resize(src string, width int, dst io.Writer) error
 78	Kind() Kind
 79}
 80#+end_src
 81
 82- A package-level registry holds formats in registration order; the first
 83  =Match= wins. Files no format matches are ignored.
 84- v1 registers =jpeg= (=.jpg=, =.jpeg=) and =png=, matched by extension,
 85  case-insensitive.
 86- Both share one ImageMagick-backed implementation parameterised by extensions:
 87  - =Metadata=: =exiftool -json -n= with the fields above. Width and height are
 88    swapped when the EXIF orientation is 5-8.
 89  - =Resize=: =magick -limit memory 512MiB -limit map 1GiB <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-=
 90    streamed to =dst=. Output is always JPEG in v1.
 91- Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video
 92  is a new =Kind= plus a template partial. Neither touches =library= or
 93  =render=.
 94
 95** Library
 96
 97- Each immediate subdirectory of =-photos= is an album; the slug is the
 98  directory name. Nested directories are ignored in v1. Hidden files and
 99  directories are skipped.
100- =album.org= (optional) supplies keywords:
101  #+begin_example
102  ,#+title: Tokyo
103  ,#+date: 2025-03
104  ,#+cover: DSCF0412.jpg
105  ,#+order: 10
106  ,#+sort: taken
107  #+end_example
108  The body, if any, is album description text rendered from org to HTML.
109  - =title= defaults to the slug.
110  - =cover= defaults to the first item after sorting.
111  - =order= sorts albums ascending on the home page. Albums without it follow,
112    newest =date= (else newest item taken time) first.
113  - =sort= is =taken= (default) or =name=.
114- =photos/about.org= (optional) is rendered as the Info page. When absent, the
115  Info link is hidden.
116- Metadata is cached in =<cache>/meta.json=, keyed by relative path, mtime and
117  size. Only new or changed files are passed to =exiftool=.
118- The library is an immutable snapshot swapped atomically behind an
119  =atomic.Pointer=. =fsnotify= watches the root and each album directory;
120  events are debounced (500 ms) and trigger a full rescan. Rescans are cheap
121  because unchanged files are served from the metadata cache.
122- A file that fails metadata extraction is logged and excluded.
123
124** Rendering and cache
125
126- URL: =/img/<album>/<file>/<width>.jpg=.
127- Allowed widths: 480, 960, 1600, 2400. Anything else is 400. Widths larger
128  than the source are clamped to the source width.
129- Cache path: =<cache>/img/<album>/<file>/<mtime>-<size>/<width>.jpg=. A
130  changed source gets a new directory; stale directories are removed on the
131  next rescan.
132- Concurrent requests for the same key are collapsed with =singleflight=.
133- Output is written to a temp file in the target directory and renamed into
134  place, so a failed or interrupted resize never leaves a partial file.
135- A process-wide semaphore caps concurrent =magick= runs at =runtime.GOMAXPROCS(0)=.
136- Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL
137  changes when the source does because pages link with a =?v=<mtime>= query.
138
139* Pages
140
141** Visual direction
142
143Dark, minimal, cinematic: near-black background, light grey text, small
144uppercase labels with wide letter-spacing. Photos are never cropped.
145
146** Nav (all pages)
147
148Site title left; album names, Timeline and Info right, in small spaced
149capitals. The current page is highlighted.
150
151** Home: =/=
152
153Album covers laid out as justified rows (same algorithm as the album page), one
154cover per album, with album title and year in small capitals below each. Covers
155link to the album.
156
157** Album: =/<album>/=
158
159Title line (title left, year and count right), optional description, then all
160items as justified rows with a 4 px gap. No lead image. Each tile links to the
161photo page.
162
163** Timeline: =/timeline/=
164
165Every item from every album, newest first, grouped under month headings
166("September 2025", with the item count on the right). Each month's items are
167justified rows linking to the item's photo page; navigation there stays within
168the album. Dates are =Item.Taken= (EXIF date, else file mtime), grouped by
169calendar month. =timeline= is a reserved album slug alongside =img=, =info=
170and =_=.
171
172** Photo: =/<album>/<file>/=
173
174- Image fitted to the viewport (=object-fit: contain=), =srcset= over the
175  allowed widths.
176- Previous and next arrows at the sides; =←= =→= keys and horizontal swipe
177  navigate; =Esc= returns to the album. First and last items do not wrap.
178- Bottom line: title or filename with position (=12 / 24=) on the left, EXIF
179  line on the right. Missing EXIF fields are omitted.
180- The next and previous images are preloaded.
181
182** Justified rows
183
184- The server emits each item's aspect ratio as =--ar= (inline style) and
185  =data-ar=.
186- Base CSS: =display: flex; flex-wrap: wrap;= with each tile's width set from
187  =--row-height × --ar=, so layout is uncropped and stable without JS.
188- A script (target: under 50 lines) partitions items into rows whose summed
189  aspect ratios fill the container at =--row-height=, sets exact heights, and
190  recomputes on resize (debounced). The last row is not stretched.
191- Width and height attributes are set on every =<img>= to prevent layout shift.
192
193** Theme
194
195=theme.css= is embedded and replaceable with =-theme=. Variables:
196
197| Variable       | Default                           |
198|----------------+-----------------------------------|
199| =--bg=         | =#0a0a0a=                         |
200| =--fg=         | =#dddddd=                         |
201| =--muted=      | =#777777=                         |
202| =--accent=     | =#ffffff=                         |
203| =--gap=        | =4px=                             |
204| =--row-height= | =320px=                           |
205| =--font=       | Helvetica Neue, Arial, sans-serif |
206| =--tracking=   | =0.25em=                          |
207
208Templates (=base=, =home=, =album=, =photo=, =info=, =error=) are embedded and
209replaceable with =-templates=.
210
211** Org rendering
212
213=album.org= bodies and =about.org= are rendered with
214=github.com/niklasfasching/go-org=.
215
216* Errors
217
218| Condition                           | Result                                   |
219|-------------------------------------+------------------------------------------|
220| Unknown album or file               | 404, styled error page                   |
221| Width not in the allowed set        | 400                                      |
222| =magick= fails                      | 500, logged, nothing cached              |
223| =exiftool= fails for a file         | file excluded, logged                    |
224| Malformed =album.org=               | defaults used, logged                    |
225| =magick= or =exiftool= missing      | exit at startup with the missing name    |
226| Path traversal in any URL segment   | 404; slugs are validated against the library, never joined raw |
227
228* Deployment
229
230- Multi-stage =Dockerfile=: =golang:1= builds a static binary;
231  =alpine= runtime with =imagemagick=, =imagemagick-heic=, =imagemagick-webp=,
232  =imagemagick-jpeg=, and =exiftool=.
233- =compose.yml= in =~/docker/gallery= on =homelab=:
234  - =127.0.0.1:8003:8080=
235  - photos mounted read-only at =/photos=, cache read-write at =/cache=
236- Host nginx proxies to =127.0.0.1:8003=, matching the existing services.
237
238* Testing
239
240- =library=: scanning, =album.org= parsing and defaults, sort orders, hidden
241  and unmatched files skipped, metadata cache invalidation on mtime/size.
242- =format=: registry matching and precedence.
243- =render=: width validation and clamping, cache path derivation,
244  =singleflight= collapse, no partial files on failure.
245- Integration, skipped when =magick= or =exiftool= is absent: fixture JPEG,
246  PNG, and an orientation-6 JPEG through real =Metadata= and =Resize=, checking
247  dimensions and orientation.
248- HTTP: =httptest= against every route and every row in the errors table.