summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKen <ken@jots.org>2026-05-09 04:41:03 +0000
committerKen <ken@jots.org>2026-05-09 04:41:03 +0000
commitc75beda743dfd6af63f512e928d0889d9ead3973 (patch)
treebed91fd4f9d36a905be0b1ef990457a1e37e567b
Initial commit — Albumen photo album
Ruby/Sinatra self-hosted photo album with directory hierarchy, per-photo captions and visibility, lightbox, slideshow, admin UI, and Let's Encrypt HTTPS via Apache reverse proxy on prouter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--.gitignore9
-rw-r--r--Gemfile8
-rw-r--r--README.md198
-rw-r--r--app.rb371
-rw-r--r--config.ru2
-rw-r--r--config/albumen.service19
-rw-r--r--config/nginx-albumen.conf18
-rw-r--r--config/puma.rb9
-rw-r--r--public/css/style.css351
-rw-r--r--public/js/album.js111
-rw-r--r--public/js/slideshow.js113
-rw-r--r--scripts/set_password.rb26
-rw-r--r--scripts/update.rb221
-rw-r--r--setup.sh52
-rw-r--r--views/admin/album.erb101
-rw-r--r--views/admin/login.erb14
-rw-r--r--views/album.erb99
-rw-r--r--views/layout.erb25
-rw-r--r--views/slideshow.erb41
19 files changed, 1788 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..86109a1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+config.yml
+config.yml.tmp.*
+cache/
+tmp/
+log/
+vendor/
+.bundle/
+*.gem
+Gemfile.lock
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..826647b
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,8 @@
+source 'https://rubygems.org'
+
+gem 'sinatra', '~> 4.0'
+gem 'puma', '~> 6.4'
+gem 'mini_magick', '~> 4.12'
+gem 'mini_exiftool', '~> 2.10'
+gem 'bcrypt', '~> 3.1'
+gem 'rack-session', '~> 2.0'
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..168edfd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,198 @@
+# Albumen — Photo Album Server
+
+Self-hosted photo/video album. Directory hierarchy = album hierarchy. Ruby/Sinatra
+back end, plain HTML/CSS/JS front end. Live at **https://albumen.jots.org**.
+
+---
+
+## Directory layout
+
+| Path | Purpose |
+|---|---|
+| `/opt/albumen/` | Application root |
+| `/opt/albumen/app.rb` | Sinatra application |
+| `/opt/albumen/views/` | ERB templates |
+| `/opt/albumen/public/` | CSS, JS, static assets |
+| `/opt/albumen/scripts/` | CLI utilities |
+| `/opt/albumen/cache/thumbs/` | Auto-generated thumbnails (safe to delete/regenerate) |
+| `/opt/albumen/config.yml` | Admin password hash + session secret (mode 600) |
+| `/opt/albumen/log/` | Puma stdout/stderr logs |
+| `/var/albumen/` | **Your photos live here** |
+
+Each subdirectory under `/var/albumen/` is an album. Nesting is unlimited.
+The directory name is the album name; the admin UI lets you assign a prettier title.
+
+---
+
+## Uploading photos
+
+Drop files into `/var/albumen/` (or any subdirectory) via `rsync` or `scp`
+from your local machine:
+
+```bash
+rsync -av ~/Pictures/2024-Italy/ root@albumen.jots.org:/var/albumen/2024-Italy/
+
+# or a single file
+scp photo.jpg root@albumen.jots.org:/var/albumen/2024-Italy/
+```
+
+After any upload **run the update script** (see below).
+
+---
+
+## Running the update script
+
+The update script walks the media tree, creates/updates `album.json` files with
+EXIF dates and image dimensions, and pre-generates thumbnails.
+
+```bash
+# On the server — process the entire tree
+ruby /opt/albumen/scripts/update.rb
+
+# Process only one album (and its sub-albums)
+ruby /opt/albumen/scripts/update.rb 2024-Italy
+
+# With an absolute path
+ruby /opt/albumen/scripts/update.rb /var/albumen/2024-Italy
+```
+
+**Resilience guarantees — safe to interrupt and re-run at any point:**
+- `album.json` is written atomically (temp file + rename); no partial writes.
+- Thumbnails that already exist are skipped entirely.
+- EXIF metadata already recorded is not re-extracted.
+- Deleted files are pruned from `album.json` automatically.
+
+Typical workflow:
+
+```bash
+rsync -av ~/Pictures/trip/ root@albumen.jots.org:/var/albumen/trip/
+ssh root@albumen.jots.org 'ruby /opt/albumen/scripts/update.rb trip'
+```
+
+---
+
+## album.json reference
+
+Each directory gets an `album.json` created by the update script.
+You can edit these by hand or through the admin UI at `/admin`.
+
+```jsonc
+{
+ "title": "Italy 2024", // overrides the directory name in the UI
+ "description": "Two weeks in Rome and Florence",
+ "cover": "DSC_0042.jpg", // which file to use as album thumbnail
+ "cover_dynamic": false, // true = play video/animation on hover
+ "visible": true, // false = hidden from non-admin users
+ "files": {
+ "DSC_0042.jpg": {
+ "title": "Colosseum", // shown in lightbox (defaults to filename)
+ "caption": "Just after sunrise, no crowds yet.",
+ "visible": true,
+ "taken_at": "2024-06-03T06:14:00", // from EXIF; set by update script
+ "width": 6000,
+ "height": 4000
+ }
+ }
+}
+```
+
+Fields set by the update script (`taken_at`, `width`, `height`) are not
+overwritten if already present — safe to correct by hand.
+
+---
+
+## Admin interface
+
+Go to `https://albumen.jots.org/admin` and log in. From there:
+
+- Edit album title, description, cover image, visibility
+- Edit per-file title, caption, visibility
+- Navigate into sub-albums
+
+### Changing the admin password
+
+```bash
+ssh root@albumen.jots.org
+ruby /opt/albumen/scripts/set_password.rb
+```
+
+The bcrypt hash is stored in `/opt/albumen/config.yml` (readable only by the
+`albumen` service user).
+
+---
+
+## Service management
+
+```bash
+systemctl status albumen # is it running?
+systemctl restart albumen # restart (e.g. after editing app.rb)
+journalctl -u albumen -f # live service logs
+tail -f /opt/albumen/log/puma.stdout.log # Puma access log
+tail -f /opt/albumen/log/puma.stderr.log # Puma error log
+```
+
+The service runs as the `albumen` user. App code lives in `/opt/albumen/`.
+
+---
+
+## Thumbnail cache
+
+Thumbnails are stored in `/opt/albumen/cache/thumbs/`, mirroring the media
+tree. The cache is fully regenerable — delete any or all of it and the app
+regenerates on demand (or run the update script to pre-generate).
+
+```bash
+# Regenerate all thumbnails for one album
+rm -rf /opt/albumen/cache/thumbs/2024-Italy
+ruby /opt/albumen/scripts/update.rb 2024-Italy
+```
+
+---
+
+## Infrastructure
+
+| Component | Location | Notes |
+|---|---|---|
+| App server | `192.168.10.245` | Puma on port 4567, nginx on 80 |
+| Reverse proxy | `192.168.10.1` (prouter) | Apache, handles TLS termination |
+| DNS | `mirkwood.jots.org` (209.141.48.158) | BIND 9, zone `/etc/bind/zones/jots.org.zone` |
+| TLS cert | `/etc/letsencrypt/live/albumen.jots.org/` on prouter | Expires 2026-08-07; auto-renewed by certbot |
+| Public URL | `https://albumen.jots.org` | → prouter → 192.168.10.245:80 |
+
+### Sinatra settings required for the proxy setup
+
+Two settings in `app.rb` are necessary when running behind an HTTPS reverse proxy:
+
+- `set :absolute_redirects, false` — Sinatra redirects use relative paths (`/foo`)
+ so the browser stays on HTTPS rather than following an `http://` Location header.
+- `set :protection, except: :http_origin` — prevents Rack::Protection from
+ dropping the admin session when the `Origin` header's scheme (`https://`)
+ doesn't match the backend connection scheme (`http://`).
+
+### Cert renewal
+
+Certbot auto-renewal is managed by the system cron on prouter. To test:
+
+```bash
+ssh root@192.168.10.1 'certbot renew --dry-run'
+```
+
+---
+
+## Re-deploying after code changes
+
+```bash
+# From your workstation
+scp -r /home/ken/albumen/. root@albumen.jots.org:/opt/albumen/
+ssh root@albumen.jots.org 'chown -R albumen:albumen /opt/albumen && systemctl restart albumen'
+```
+
+---
+
+## Supported file types
+
+| Category | Extensions |
+|---|---|
+| Images | jpg jpeg png gif webp heic heif tiff bmp |
+| Videos | mp4 mov avi mkv webm m4v ogv |
+| Audio | mp3 flac ogg wav m4a aac |
diff --git a/app.rb b/app.rb
new file mode 100644
index 0000000..be88e37
--- /dev/null
+++ b/app.rb
@@ -0,0 +1,371 @@
+# frozen_string_literal: true
+
+require 'sinatra'
+require 'json'
+require 'yaml'
+require 'bcrypt'
+require 'mini_magick'
+require 'fileutils'
+require 'securerandom'
+
+# ── Constants ──────────────────────────────────────────────────────────────────
+
+MEDIA_ROOT = (ENV['MEDIA_ROOT'] || '/var/albumen').freeze
+CACHE_ROOT = (ENV['CACHE_ROOT'] || '/opt/albumen/cache/thumbs').freeze
+CONFIG_PATH = (ENV['CONFIG_PATH'] || '/opt/albumen/config.yml').freeze
+THUMB_SIZE = 300
+
+IMAGE_EXTS = %w[jpg jpeg png gif webp heic heif tiff bmp].freeze
+VIDEO_EXTS = %w[mp4 mov avi mkv webm m4v ogv].freeze
+AUDIO_EXTS = %w[mp3 flac ogg wav m4a aac].freeze
+MEDIA_EXTS = (IMAGE_EXTS + VIDEO_EXTS + AUDIO_EXTS).freeze
+
+APP_CONFIG = (File.exist?(CONFIG_PATH) ? YAML.load_file(CONFIG_PATH, symbolize_names: true) : {}).freeze
+
+# ── Sinatra config ─────────────────────────────────────────────────────────────
+
+configure do
+ enable :sessions
+ set :session_secret, APP_CONFIG[:session_secret] || SecureRandom.hex(32)
+ set :protection, except: :http_origin
+ set :absolute_redirects, false
+ set :views, File.join(__dir__, 'views')
+ set :public_folder, File.join(__dir__, 'public')
+ set :bind, '127.0.0.1'
+ set :port, 4567
+ set :logging, true
+end
+
+configure :production do
+ set :show_exceptions, false
+end
+
+# Trust the scheme the upstream proxy reports so redirects use https://
+# and Rack::Protection::HttpOrigin sees the correct origin.
+before do
+ if request.env['HTTP_X_FORWARDED_PROTO'] == 'https'
+ request.env['rack.url_scheme'] = 'https'
+ end
+end
+
+# ── Helpers ────────────────────────────────────────────────────────────────────
+
+helpers do
+ def admin?
+ session[:admin] == true
+ end
+
+ def require_admin!
+ halt 401, erb(:'admin/login') unless admin?
+ end
+
+ def media_type_for(name)
+ ext = File.extname(name).downcase.delete_prefix('.')
+ return :image if IMAGE_EXTS.include?(ext)
+ return :video if VIDEO_EXTS.include?(ext)
+ return :audio if AUDIO_EXTS.include?(ext)
+ nil
+ end
+
+ def load_album(dir)
+ path = File.join(dir, 'album.json')
+ return default_album unless File.exist?(path)
+ JSON.parse(File.read(path))
+ rescue JSON::ParserError
+ default_album
+ end
+
+ def default_album
+ { 'files' => {}, 'visible' => true }
+ end
+
+ def album_files(dir, data)
+ files = Dir.children(dir)
+ .sort
+ .select { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) }
+
+ entries = files.filter_map do |name|
+ meta = (data['files'] || {})[name] || {}
+ next if meta['visible'] == false && !admin?
+ {
+ name: name,
+ title: meta['title'] || name,
+ caption: meta['caption'],
+ visible: meta.fetch('visible', true),
+ type: media_type_for(name),
+ taken_at: meta['taken_at'],
+ }
+ end
+
+ if entries.any? { |e| e[:taken_at] }
+ entries.sort_by { |e| [e[:taken_at] ? 0 : 1, e[:taken_at].to_s, e[:name]] }
+ else
+ entries
+ end
+ end
+
+ def child_albums(dir, data)
+ albums = Dir.children(dir)
+ .sort
+ .select { |n| !n.start_with?('.') && File.directory?(File.join(dir, n)) }
+ .filter_map do |name|
+ sub_dir = File.join(dir, name)
+ sub_data = load_album(sub_dir)
+ next if sub_data['visible'] == false && !admin?
+ {
+ name: name,
+ title: sub_data['title'] || name,
+ cover: album_cover(sub_dir, sub_data),
+ dynamic: sub_data['cover_dynamic'] == true,
+ }
+ end
+ data['sort_reverse'] ? albums.reverse : albums
+ end
+
+ def album_cover(dir, data)
+ if (cover = data['cover'])
+ return cover if File.exist?(File.join(dir, cover))
+ end
+ Dir.children(dir).sort.find { |n| (IMAGE_EXTS + VIDEO_EXTS).include?(File.extname(n).downcase.delete_prefix('.')) }
+ end
+
+ # Returns the absolute path or halts with 404.
+ # rel may be empty (means MEDIA_ROOT itself).
+ def resolve_dir(rel)
+ return MEDIA_ROOT if rel.empty?
+ full = File.expand_path(rel, MEDIA_ROOT)
+ halt 404 unless full.start_with?("#{MEDIA_ROOT}/")
+ full
+ end
+
+ def resolve_file(rel)
+ halt 404 if rel.empty?
+ full = File.expand_path(rel, MEDIA_ROOT)
+ halt 404 unless full.start_with?("#{MEDIA_ROOT}/")
+ full
+ end
+
+ def thumb_cache_path(rel)
+ File.join(CACHE_ROOT, "#{rel}.th.jpg")
+ end
+
+ def breadcrumbs(rel)
+ return [] if rel.empty?
+ parts = rel.split('/')
+ parts.each_with_index.map { |p, i| { name: p, path: parts[0..i].join('/') } }
+ end
+
+ def blank_to_nil(s)
+ v = s.to_s.strip
+ v.empty? ? nil : v
+ end
+end
+
+# ── Public routes ──────────────────────────────────────────────────────────────
+
+get '/' do
+ redirect '/browse/'
+end
+
+get '/browse' do
+ redirect '/browse/'
+end
+
+get '/browse/' do
+ browse_album('')
+end
+
+get '/browse/*' do
+ browse_album(params[:splat].first.chomp('/'))
+end
+
+def browse_album(rel)
+ dir = resolve_dir(rel)
+ halt 404 unless File.directory?(dir)
+ data = load_album(dir)
+ halt 403 if data['visible'] == false && !admin?
+
+ @rel = rel
+ @title = data['title'] || (rel.empty? ? 'Albums' : File.basename(dir))
+ @desc = data['description']
+ @albums = child_albums(dir, data)
+ @entries = album_files(dir, data)
+ @crumbs = breadcrumbs(rel)
+ erb :album
+end
+
+get '/thumb/*' do
+ rel = params[:splat].first
+ full = resolve_file(rel)
+ halt 404 unless File.file?(full)
+
+ # Audio: serve static placeholder
+ ext = File.extname(full).downcase.delete_prefix('.')
+ if AUDIO_EXTS.include?(ext)
+ send_file File.join(settings.public_folder, 'img', 'audio.svg'), type: 'image/svg+xml'
+ return
+ end
+
+ dir = File.dirname(full)
+ name = File.basename(full)
+ meta = (load_album(dir)['files'] || {})[name] || {}
+ halt 403 if meta['visible'] == false && !admin?
+
+ cache = thumb_cache_path(rel)
+ unless File.exist?(cache)
+ FileUtils.mkdir_p(File.dirname(cache))
+ generate_thumb(full, cache, ext)
+ end
+
+ send_file cache, type: 'image/jpeg'
+end
+
+get '/media/*' do
+ rel = params[:splat].first
+ full = resolve_file(rel)
+ halt 404 unless File.file?(full)
+
+ dir = File.dirname(full)
+ name = File.basename(full)
+ meta = (load_album(dir)['files'] || {})[name] || {}
+ halt 403 if meta['visible'] == false && !admin?
+
+ send_file full
+end
+
+get '/slideshow/' do
+ slideshow_view('')
+end
+
+get '/slideshow/*' do
+ slideshow_view(params[:splat].first.chomp('/'))
+end
+
+def slideshow_view(rel)
+ dir = resolve_dir(rel)
+ halt 404 unless File.directory?(dir)
+ data = load_album(dir)
+ halt 403 if data['visible'] == false && !admin?
+
+ @rel = rel
+ @title = data['title'] || (rel.empty? ? 'Albums' : File.basename(dir))
+ @entries = album_files(dir, data).select { |e| %i[image video].include?(e[:type]) }
+ erb :slideshow
+end
+
+# ── Admin routes ───────────────────────────────────────────────────────────────
+
+get '/admin' do
+ redirect(admin? ? '/admin/edit/' : '/admin/login')
+end
+
+get '/admin/login' do
+ erb :'admin/login'
+end
+
+post '/admin/login' do
+ hash = APP_CONFIG[:admin_password_hash].to_s
+ if !hash.empty? && BCrypt::Password.new(hash) == params['password']
+ session[:admin] = true
+ redirect params['return_to']&.start_with?('/') ? params['return_to'] : '/admin/edit/'
+ else
+ @error = 'Invalid password'
+ erb :'admin/login'
+ end
+end
+
+get '/admin/logout' do
+ session.clear
+ redirect '/'
+end
+
+get '/admin/edit' do
+ require_admin!
+ redirect '/admin/edit/'
+end
+
+get '/admin/edit/' do
+ require_admin!
+ edit_album('')
+end
+
+get '/admin/edit/*' do
+ require_admin!
+ edit_album(params[:splat].first.chomp('/'))
+end
+
+def edit_album(rel)
+ @dir = resolve_dir(rel)
+ halt 404 unless File.directory?(@dir)
+ @rel = rel
+ @data = load_album(@dir)
+ @title = @data['title'] || (rel.empty? ? 'Root' : File.basename(@dir))
+ @files = Dir.children(@dir).sort.select { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) }
+ @sub_dirs = Dir.children(@dir).sort.select { |n| !n.start_with?('.') && File.directory?(File.join(@dir, n)) }
+ erb :'admin/album'
+end
+
+post '/admin/edit/' do
+ require_admin!
+ save_edits('', MEDIA_ROOT)
+ redirect '/admin/edit/'
+end
+
+post '/admin/edit/*' do
+ require_admin!
+ rel = params[:splat].first.chomp('/')
+ dir = resolve_dir(rel)
+ save_edits(rel, dir)
+ redirect "/admin/edit/#{rel}"
+end
+
+def save_edits(rel, dir)
+ data = load_album(dir)
+ data['title'] = blank_to_nil(params['album_title'])
+ data['description'] = blank_to_nil(params['album_description'])
+ data['cover'] = blank_to_nil(params['album_cover'])
+ data['cover_dynamic'] = params['album_cover_dynamic'] == '1'
+ data['sort_reverse'] = params['album_sort_reverse'] == '1'
+ data['visible'] = params['album_visible'] == '1'
+
+ data['files'] ||= {}
+ (params['file_title'] || {}).each_key do |name|
+ data['files'][name] ||= {}
+ data['files'][name]['title'] = blank_to_nil((params['file_title'] || {})[name])
+ data['files'][name]['caption'] = blank_to_nil((params['file_caption'] || {})[name])
+ data['files'][name]['visible'] = (params['file_visible'] || {})[name] == '1'
+ end
+
+ atomic_write(File.join(dir, 'album.json'), JSON.pretty_generate(data))
+end
+
+# ── Thumbnail generation ───────────────────────────────────────────────────────
+
+def generate_thumb(source, dest, ext)
+ if VIDEO_EXTS.include?(ext)
+ system('ffmpeg', '-y', '-ss', '2', '-i', source,
+ '-vframes', '1',
+ '-vf', "scale=#{THUMB_SIZE}:#{THUMB_SIZE}:force_original_aspect_ratio=increase,crop=#{THUMB_SIZE}:#{THUMB_SIZE}",
+ dest, %i[out err] => '/dev/null')
+ else
+ img = MiniMagick::Image.open(source)
+ img.combine_options do |c|
+ c.auto_orient
+ c.thumbnail "#{THUMB_SIZE}x#{THUMB_SIZE}^"
+ c.gravity 'center'
+ c.extent "#{THUMB_SIZE}x#{THUMB_SIZE}"
+ end
+ img.write(dest)
+ end
+rescue StandardError => e
+ warn "thumb failed for #{source}: #{e.message}"
+end
+
+def atomic_write(path, content)
+ tmp = "#{path}.tmp.#{Process.pid}"
+ File.write(tmp, content)
+ File.rename(tmp, path)
+rescue StandardError
+ File.unlink(tmp) rescue nil
+ raise
+end
diff --git a/config.ru b/config.ru
new file mode 100644
index 0000000..665f125
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,2 @@
+require_relative 'app'
+run Sinatra::Application
diff --git a/config/albumen.service b/config/albumen.service
new file mode 100644
index 0000000..9fea12f
--- /dev/null
+++ b/config/albumen.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Albumen photo album (Puma)
+After=network.target
+
+[Service]
+Type=simple
+User=albumen
+Group=albumen
+WorkingDirectory=/opt/albumen
+Environment=RACK_ENV=production
+Environment=MEDIA_ROOT=/var/albumen
+Environment=CACHE_ROOT=/opt/albumen/cache/thumbs
+Environment=CONFIG_PATH=/opt/albumen/config.yml
+ExecStart=/usr/local/bin/bundle exec puma -C config/puma.rb
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/config/nginx-albumen.conf b/config/nginx-albumen.conf
new file mode 100644
index 0000000..d540bcd
--- /dev/null
+++ b/config/nginx-albumen.conf
@@ -0,0 +1,18 @@
+server {
+ listen 80;
+ server_name albumen.jots.org;
+
+ client_max_body_size 0; # no limit for large video uploads via rsync
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+
+ location / {
+ proxy_pass http://127.0.0.1:4567;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ }
+}
diff --git a/config/puma.rb b/config/puma.rb
new file mode 100644
index 0000000..d60c643
--- /dev/null
+++ b/config/puma.rb
@@ -0,0 +1,9 @@
+workers 1
+threads 4, 8
+
+bind 'tcp://127.0.0.1:4567'
+environment ENV.fetch('RACK_ENV', 'development')
+
+pidfile '/opt/albumen/tmp/puma.pid'
+state_path '/opt/albumen/tmp/puma.state'
+stdout_redirect '/opt/albumen/log/puma.stdout.log', '/opt/albumen/log/puma.stderr.log', true
diff --git a/public/css/style.css b/public/css/style.css
new file mode 100644
index 0000000..1f31e42
--- /dev/null
+++ b/public/css/style.css
@@ -0,0 +1,351 @@
+/* ── Reset & base ─────────────────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg: #111;
+ --bg2: #1c1c1c;
+ --bg3: #2a2a2a;
+ --border: #333;
+ --text: #e0e0e0;
+ --text-dim: #888;
+ --accent: #4a90d9;
+ --accent-hv: #6aaff7;
+ --radius: 6px;
+ --thumb: 220px;
+ --gap: 12px;
+ --font: system-ui, -apple-system, sans-serif;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 15px;
+ line-height: 1.5;
+ min-height: 100vh;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { color: var(--accent-hv); text-decoration: underline; }
+
+/* ── Site header ───────────────────────────────────────────────────────── */
+.site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 24px;
+ background: var(--bg2);
+ border-bottom: 1px solid var(--border);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.site-logo {
+ font-size: 1.2rem;
+ font-weight: 700;
+ letter-spacing: .04em;
+ color: var(--text);
+}
+.site-logo:hover { color: var(--accent-hv); text-decoration: none; }
+
+.site-nav { display: flex; gap: 16px; align-items: center; }
+.site-nav a { color: var(--text-dim); font-size: .9rem; }
+.site-nav a:hover { color: var(--text); text-decoration: none; }
+
+/* ── Buttons ───────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-block;
+ padding: 6px 16px;
+ background: var(--bg3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ cursor: pointer;
+ font-size: .9rem;
+ transition: background .15s;
+}
+.btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; text-decoration: none; }
+.btn-sm { padding: 4px 10px; font-size: .8rem; }
+
+/* ── Main content ──────────────────────────────────────────────────────── */
+main { max-width: 1400px; margin: 0 auto; padding: 24px; }
+
+/* ── Album header ──────────────────────────────────────────────────────── */
+.album-header { margin-bottom: 24px; }
+.album-header h1 { font-size: 1.6rem; font-weight: 600; margin-bottom: 6px; }
+
+.breadcrumbs { font-size: .85rem; color: var(--text-dim); margin-bottom: 8px; }
+.breadcrumbs a { color: var(--text-dim); }
+.breadcrumbs a:hover { color: var(--text); }
+.breadcrumbs .sep { margin: 0 4px; }
+
+.album-desc { color: var(--text-dim); margin-bottom: 10px; }
+.album-actions { margin-top: 10px; }
+
+/* ── Grid ──────────────────────────────────────────────────────────────── */
+.grid-section { margin-bottom: 32px; }
+.section-label { font-size: .85rem; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); margin-bottom: 12px; }
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(var(--thumb), 1fr));
+ gap: var(--gap);
+}
+
+/* ── Cards ─────────────────────────────────────────────────────────────── */
+.card {
+ border-radius: var(--radius);
+ overflow: hidden;
+ background: var(--bg2);
+ cursor: pointer;
+ transition: transform .15s, box-shadow .15s;
+}
+.card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.5); }
+
+.thumb-wrap {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 1;
+ overflow: hidden;
+ background: var(--bg3);
+}
+.thumb-wrap img {
+ width: 100%; height: 100%;
+ object-fit: cover;
+ display: block;
+ transition: transform .2s;
+}
+.card:hover .thumb-wrap img { transform: scale(1.04); }
+
+.thumb-placeholder {
+ width: 100%; height: 100%;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 3rem;
+ background: var(--bg3);
+}
+
+.card-overlay {
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ padding: 24px 8px 8px;
+ background: linear-gradient(transparent, rgba(0,0,0,.7));
+ opacity: 0;
+ transition: opacity .2s;
+}
+.card:hover .card-overlay { opacity: 1; }
+.card-label { color: #fff; font-size: .85rem; font-weight: 500; }
+
+.type-badge {
+ position: absolute;
+ top: 6px; right: 6px;
+ background: rgba(0,0,0,.6);
+ border-radius: 4px;
+ padding: 2px 6px;
+ font-size: .8rem;
+ color: #fff;
+ pointer-events: none;
+}
+
+.album-label { padding: 6px 8px; font-size: .88rem; font-weight: 500; color: var(--text); }
+.card-meta { padding: 6px 8px; }
+.card-caption { font-size: .8rem; color: var(--text-dim); }
+
+.hidden-item { opacity: .45; outline: 1px dashed var(--border); }
+.empty-album { color: var(--text-dim); text-align: center; padding: 60px 0; }
+
+/* ── Lightbox ──────────────────────────────────────────────────────────── */
+.lightbox {
+ position: fixed; inset: 0;
+ z-index: 1000;
+ background: rgba(0,0,0,.92);
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+}
+.lightbox.hidden { display: none; }
+
+.lb-stage {
+ flex: 1;
+ display: flex; align-items: center; justify-content: center;
+ width: 100%;
+ cursor: zoom-out;
+ overflow: hidden;
+ padding: 16px;
+}
+/* inline-block shrink-wraps to the rendered image size;
+ arrows inside are then anchored to the photo's actual edges */
+.lb-media {
+ position: relative;
+ display: inline-block;
+ line-height: 0;
+ cursor: default;
+}
+#lb-img { max-width: calc(100vw - 32px); max-height: calc(100vh - 130px); object-fit: contain; display: block; }
+#lb-video { max-width: calc(100vw - 32px); max-height: calc(100vh - 130px); display: block; }
+#lb-audio { width: 400px; max-width: 90vw; display: block; }
+
+.lb-caption-bar {
+ padding: 8px 80px;
+ text-align: center;
+ font-size: .9rem;
+ color: var(--text-dim);
+ display: flex; gap: 16px; justify-content: center; align-items: baseline;
+ flex-shrink: 0;
+ width: 100%;
+}
+#lb-title { color: var(--text); font-weight: 500; }
+#lb-counter { margin-left: auto; font-size: .8rem; }
+.lb-action { font-size: .78rem; padding: 3px 10px; opacity: .7; }
+.lb-action:hover { opacity: 1; }
+
+.lb-btn {
+ position: absolute;
+ background: rgba(0,0,0,.45);
+ border: none;
+ color: #fff;
+ cursor: pointer;
+ border-radius: var(--radius);
+ transition: background .15s, opacity .15s;
+ z-index: 10;
+}
+.lb-btn:hover { background: rgba(0,0,0,.75); }
+
+/* Close stays fixed to the viewport corner */
+.lb-close {
+ position: fixed;
+ top: 12px; right: 16px;
+ font-size: 1.1rem;
+ padding: 6px 12px;
+ z-index: 1001;
+}
+
+/* Prev/next are anchored to the photo's own edges */
+.lb-prev, .lb-next {
+ top: 50%; transform: translateY(-50%);
+ font-size: 2.8rem;
+ padding: 20px 12px;
+ opacity: 0;
+ transition: opacity .2s, background .15s;
+}
+.lb-media:hover .lb-prev,
+.lb-media:hover .lb-next { opacity: .85; }
+.lb-prev:hover, .lb-next:hover { opacity: 1 !important; }
+.lb-prev { left: 0; border-radius: 0 var(--radius) var(--radius) 0; }
+.lb-next { right: 0; border-radius: var(--radius) 0 0 var(--radius); }
+
+/* ── Slideshow ─────────────────────────────────────────────────────────── */
+.slideshow-page { background: #000; overflow: hidden; height: 100vh; display: flex; }
+
+#slideshow {
+ width: 100%; height: 100vh;
+ display: flex; flex-direction: column;
+ background: #000;
+}
+#ss-stage {
+ flex: 1;
+ display: flex; align-items: center; justify-content: center;
+ overflow: hidden;
+ position: relative;
+}
+#ss-img, #ss-video {
+ max-width: 100%; max-height: 100%;
+ object-fit: contain;
+ opacity: 1;
+ transition: opacity .5s ease;
+}
+#ss-img.fading, #ss-video.fading { opacity: 0; }
+
+#ss-caption-bar {
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ padding: 32px 24px 8px;
+ background: linear-gradient(transparent, rgba(0,0,0,.7));
+ color: #fff;
+ text-align: center;
+ font-size: .95rem;
+ pointer-events: none;
+}
+#ss-title { font-weight: 600; display: block; }
+
+#ss-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 20px;
+ background: rgba(0,0,0,.7);
+ flex-shrink: 0;
+}
+.ss-interval-label { color: var(--text-dim); font-size: .85rem; display: flex; align-items: center; gap: 4px; margin-left: auto; }
+.ss-interval-label input { width: 48px; background: var(--bg3); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 2px 6px; }
+
+#ss-counter { position: absolute; top: 12px; right: 16px; color: rgba(255,255,255,.5); font-size: .8rem; pointer-events: none; }
+
+/* ── Admin ─────────────────────────────────────────────────────────────── */
+.admin-login {
+ max-width: 360px; margin: 80px auto;
+ padding: 32px;
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+.admin-login h1 { margin-bottom: 20px; font-size: 1.3rem; }
+.admin-login label { display: block; margin-bottom: 16px; color: var(--text-dim); font-size: .9rem; }
+.admin-login input { display: block; width: 100%; margin-top: 4px; padding: 8px 12px; background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 1rem; }
+.form-error { color: #f55; margin-bottom: 12px; font-size: .9rem; }
+
+.admin-album { max-width: 1200px; }
+.admin-album h1 { font-size: 1.4rem; margin: 16px 0; }
+.admin-nav { display: flex; gap: 8px; margin-bottom: 8px; }
+
+fieldset.album-settings {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px;
+ margin-bottom: 24px;
+}
+legend { padding: 0 8px; color: var(--text-dim); font-size: .85rem; }
+
+.form-row { margin-bottom: 12px; }
+.form-row label { display: block; color: var(--text-dim); font-size: .85rem; margin-bottom: 4px; }
+.form-row input[type=text], .form-row textarea, .form-row select {
+ width: 100%;
+ background: var(--bg3); border: 1px solid var(--border);
+ border-radius: var(--radius); color: var(--text);
+ padding: 7px 10px; font-size: .9rem;
+}
+.form-row textarea { resize: vertical; }
+.form-row-inline { display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap; }
+.form-row-inline label { flex: 1; min-width: 180px; }
+.checkbox-label { display: flex !important; align-items: center; gap: 6px; flex: 0 0 auto !important; color: var(--text) !important; }
+.checkbox-label input { width: auto !important; }
+
+.files-section h2 { font-size: 1rem; color: var(--text-dim); margin-bottom: 10px; }
+.files-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
+.files-table th { text-align: left; padding: 6px 10px; border-bottom: 2px solid var(--border); color: var(--text-dim); font-weight: 500; }
+.files-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
+.files-table tr:hover td { background: var(--bg2); }
+.filename { color: var(--text-dim); font-size: .8rem; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.visible-cell { text-align: center; }
+.files-table input[type=text] {
+ width: 100%;
+ background: var(--bg3); border: 1px solid transparent;
+ border-radius: 4px; color: var(--text);
+ padding: 4px 8px; font-size: .85rem;
+}
+.files-table input[type=text]:focus { border-color: var(--accent); outline: none; }
+
+.form-actions { margin-top: 20px; }
+.sub-albums-section { margin-top: 32px; }
+.sub-albums-section h2 { font-size: 1rem; color: var(--text-dim); margin-bottom: 10px; }
+.sub-album-list { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
+.sub-album-list a { padding: 4px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); font-size: .9rem; }
+
+/* ── Responsive ────────────────────────────────────────────────────────── */
+@media (max-width: 600px) {
+ :root { --thumb: 140px; --gap: 8px; }
+ main { padding: 12px; }
+ .lb-prev { left: 4px; }
+ .lb-next { right: 4px; }
+ .lb-stage { padding: 48px 40px; }
+ .files-table { font-size: .78rem; }
+}
diff --git a/public/js/album.js b/public/js/album.js
new file mode 100644
index 0000000..74d524c
--- /dev/null
+++ b/public/js/album.js
@@ -0,0 +1,111 @@
+'use strict';
+
+let lbIndex = 0;
+
+function openLightbox(i) {
+ lbIndex = i;
+ renderLightbox();
+ document.getElementById('lightbox').classList.remove('hidden');
+ document.body.style.overflow = 'hidden';
+ document.addEventListener('keydown', lbKey);
+}
+
+function closeLightbox() {
+ document.getElementById('lightbox').classList.add('hidden');
+ document.body.style.overflow = '';
+ document.removeEventListener('keydown', lbKey);
+ ['lb-video', 'lb-audio'].forEach(id => {
+ const el = document.getElementById(id);
+ el.pause && el.pause();
+ });
+ history.replaceState(null, '', location.pathname + location.search);
+}
+
+function lbNav(delta) {
+ let next = lbIndex + delta;
+ while (next >= 0 && next < ENTRIES.length && !ENTRIES[next].type) next += delta;
+ if (next >= 0 && next < ENTRIES.length) {
+ lbIndex = next;
+ renderLightbox();
+ }
+}
+
+function renderLightbox() {
+ const e = ENTRIES[lbIndex];
+ const img = document.getElementById('lb-img');
+ const vid = document.getElementById('lb-video');
+ const aud = document.getElementById('lb-audio');
+
+ [img, vid, aud].forEach(el => { el.classList.add('hidden'); el.pause && el.pause(); });
+
+ if (e.type === 'image') {
+ img.src = e.src; img.alt = e.title;
+ img.classList.remove('hidden');
+ } else if (e.type === 'video') {
+ vid.src = e.src;
+ vid.classList.remove('hidden');
+ vid.play().catch(() => {});
+ } else if (e.type === 'audio') {
+ aud.src = e.src;
+ aud.classList.remove('hidden');
+ aud.play().catch(() => {});
+ }
+
+ document.getElementById('lb-title').textContent = e.title !== e.name ? e.title : '';
+ document.getElementById('lb-caption').textContent = e.caption || '';
+ document.getElementById('lb-counter').textContent = `${lbIndex + 1} / ${ENTRIES.length}`;
+
+ const dl = document.getElementById('lb-download');
+ dl.href = e.src;
+ dl.download = e.name;
+
+ // Update URL hash so the address bar is the shareable link
+ history.replaceState(null, '', location.pathname + location.search + '#photo=' + encodeURIComponent(e.name));
+}
+
+function lbCopyLink() {
+ navigator.clipboard.writeText(location.href).then(() => {
+ const btn = document.getElementById('lb-copylink');
+ const orig = btn.textContent;
+ btn.textContent = '✓ Copied!';
+ setTimeout(() => { btn.textContent = orig; }, 1800);
+ }).catch(() => {
+ // Fallback: select a temporary input
+ const tmp = document.createElement('input');
+ tmp.value = location.href;
+ document.body.appendChild(tmp);
+ tmp.select();
+ document.execCommand('copy');
+ document.body.removeChild(tmp);
+ });
+}
+
+function lbKey(ev) {
+ if (ev.key === 'Escape') closeLightbox();
+ else if (ev.key === 'ArrowLeft') lbNav(-1);
+ else if (ev.key === 'ArrowRight') lbNav(1);
+}
+
+// Restore lightbox from URL hash on page load
+window.addEventListener('DOMContentLoaded', () => {
+ const m = location.hash.match(/^#photo=(.+)$/);
+ if (m) {
+ const name = decodeURIComponent(m[1]);
+ const idx = ENTRIES.findIndex(e => e.name === name);
+ if (idx >= 0) openLightbox(idx);
+ }
+});
+
+// Touch swipe
+(function () {
+ let startX = null;
+ const lb = document.getElementById('lightbox');
+ if (!lb) return;
+ lb.addEventListener('touchstart', e => { startX = e.changedTouches[0].clientX; }, { passive: true });
+ lb.addEventListener('touchend', e => {
+ if (startX === null) return;
+ const dx = e.changedTouches[0].clientX - startX;
+ if (Math.abs(dx) > 50) lbNav(dx < 0 ? 1 : -1);
+ startX = null;
+ }, { passive: true });
+})();
diff --git a/public/js/slideshow.js b/public/js/slideshow.js
new file mode 100644
index 0000000..2260034
--- /dev/null
+++ b/public/js/slideshow.js
@@ -0,0 +1,113 @@
+'use strict';
+
+let ssIdx = 0;
+let ssTimer = null;
+let ssPlaying = true;
+
+const img = document.getElementById('ss-img');
+const vid = document.getElementById('ss-video');
+const title = document.getElementById('ss-title');
+const cap = document.getElementById('ss-caption');
+const ctr = document.getElementById('ss-counter');
+const btn = document.getElementById('ss-play-btn');
+
+function ssInterval() {
+ return (parseFloat(document.getElementById('ss-interval').value) || 4) * 1000;
+}
+
+function ssShow(i, instant) {
+ const e = SS_ENTRIES[i];
+ if (!e) return;
+ ssIdx = i;
+
+ title.textContent = e.title !== e.name ? e.title : '';
+ cap.textContent = e.caption || '';
+ ctr.textContent = `${i + 1} / ${SS_ENTRIES.length}`;
+
+ if (instant) {
+ // First load — show without transition
+ applyEntry(e);
+ return;
+ }
+
+ if (e.type === 'video') {
+ // No preload possible for video; fade out then swap
+ crossFade(() => applyEntry(e));
+ } else {
+ // Preload the image so the fade-in shows it immediately
+ const preload = new Image();
+ preload.onload = preload.onerror = () => crossFade(() => applyEntry(e));
+ preload.src = e.src;
+ }
+}
+
+function applyEntry(e) {
+ if (e.type === 'video') {
+ img.style.display = 'none';
+ vid.style.display = '';
+ vid.src = e.src;
+ void vid.offsetWidth; // flush so transition fires
+ vid.classList.remove('fading');
+ vid.play().catch(() => {});
+ } else {
+ vid.pause && vid.pause();
+ vid.style.display = 'none';
+ img.style.display = '';
+ img.src = e.src;
+ img.alt = e.title;
+ void img.offsetWidth; // flush so transition fires
+ img.classList.remove('fading');
+ }
+}
+
+function crossFade(cb) {
+ img.classList.add('fading');
+ vid.classList.add('fading');
+ setTimeout(cb, 500); // matches CSS transition duration
+}
+
+function ssSchedule() {
+ clearTimeout(ssTimer);
+ if (ssPlaying && SS_ENTRIES.length > 1) {
+ ssTimer = setTimeout(() => {
+ ssShow((ssIdx + 1) % SS_ENTRIES.length);
+ ssSchedule();
+ }, ssInterval());
+ }
+}
+
+function ssNext() {
+ ssShow((ssIdx + 1) % SS_ENTRIES.length);
+ if (ssPlaying) ssSchedule();
+}
+
+function ssPrev() {
+ ssShow((ssIdx - 1 + SS_ENTRIES.length) % SS_ENTRIES.length);
+ if (ssPlaying) ssSchedule();
+}
+
+function ssToggle() {
+ ssPlaying = !ssPlaying;
+ btn.textContent = ssPlaying ? '⏸ Pause' : '▶ Play';
+ if (ssPlaying) ssSchedule(); else clearTimeout(ssTimer);
+}
+
+document.addEventListener('keydown', e => {
+ if (e.key === 'ArrowRight') ssNext();
+ if (e.key === 'ArrowLeft') ssPrev();
+ if (e.key === ' ') { e.preventDefault(); ssToggle(); }
+});
+
+let swipeX = null;
+document.addEventListener('touchstart', e => { swipeX = e.changedTouches[0].clientX; }, { passive: true });
+document.addEventListener('touchend', e => {
+ if (swipeX === null) return;
+ const dx = e.changedTouches[0].clientX - swipeX;
+ if (Math.abs(dx) > 50) (dx < 0 ? ssNext : ssPrev)();
+ swipeX = null;
+}, { passive: true });
+
+if (SS_ENTRIES.length > 0) {
+ ssShow(0, true);
+ ssSchedule();
+}
diff --git a/scripts/set_password.rb b/scripts/set_password.rb
new file mode 100644
index 0000000..71bdc41
--- /dev/null
+++ b/scripts/set_password.rb
@@ -0,0 +1,26 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+# Usage: ruby scripts/set_password.rb
+# Sets (or resets) the admin password in config.yml.
+
+require 'bcrypt'
+require 'yaml'
+require 'securerandom'
+
+CONFIG_PATH = ENV['CONFIG_PATH'] || '/opt/albumen/config.yml'
+
+print 'New admin password: '
+STDOUT.flush
+password = $stdin.gets&.chomp
+abort 'No password given.' if password.nil? || password.strip.empty?
+
+config = File.exist?(CONFIG_PATH) ? (YAML.load_file(CONFIG_PATH) || {}) : {}
+config['admin_password_hash'] = BCrypt::Password.create(password).to_s
+config['session_secret'] ||= SecureRandom.hex(32)
+
+tmp = "#{CONFIG_PATH}.tmp.#{Process.pid}"
+File.write(tmp, config.to_yaml)
+File.rename(tmp, CONFIG_PATH)
+File.chmod(0o600, CONFIG_PATH)
+
+puts "Password set. Config written to #{CONFIG_PATH}"
diff --git a/scripts/update.rb b/scripts/update.rb
new file mode 100644
index 0000000..7ff0007
--- /dev/null
+++ b/scripts/update.rb
@@ -0,0 +1,221 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+#
+# Usage: ruby update.rb [relative/path]
+# Without argument: process entire MEDIA_ROOT tree.
+# With argument: process only that subdirectory (and its children).
+#
+# Resilience guarantees:
+# - album.json is written atomically (temp-file + rename), so a crash
+# mid-write never corrupts an existing file.
+# - Thumbnails are checked before generation; already-done work is skipped.
+# - EXIF and dimension extraction are skipped if already recorded.
+# - Safe to re-run at any time; all operations are idempotent.
+
+require 'json'
+require 'yaml'
+require 'fileutils'
+require 'mini_magick'
+require 'mini_exiftool'
+
+MEDIA_ROOT = (ENV['MEDIA_ROOT'] || '/var/albumen').freeze
+CACHE_ROOT = (ENV['CACHE_ROOT'] || '/opt/albumen/cache/thumbs').freeze
+THUMB_SIZE = 300
+
+IMAGE_EXTS = %w[jpg jpeg png gif webp heic heif tiff bmp].freeze
+VIDEO_EXTS = %w[mp4 mov avi mkv webm m4v ogv].freeze
+AUDIO_EXTS = %w[mp3 flac ogg wav m4a aac].freeze
+MEDIA_EXTS = (IMAGE_EXTS + VIDEO_EXTS + AUDIO_EXTS).freeze
+
+# ── Directory processing ───────────────────────────────────────────────────────
+
+def process_dir(dir)
+ rel = dir.delete_prefix(MEDIA_ROOT).delete_prefix('/')
+ label = rel.empty? ? '(root)' : rel
+ puts "Scanning #{label}"
+
+ json_path = File.join(dir, 'album.json')
+ data = load_json(json_path)
+ data['files'] ||= {}
+ data['visible'] = true unless data.key?('visible')
+
+ # Enumerate current media files
+ current = Dir.children(dir)
+ .select { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) }
+ .sort
+
+ # Remove stale entries for deleted files
+ removed = data['files'].keys - current
+ if removed.any?
+ puts " Removing stale entries: #{removed.join(', ')}"
+ removed.each { |n| data['files'].delete(n) }
+ end
+
+ # Process each file
+ current.each do |name|
+ full = File.join(dir, name)
+ ext = File.extname(name).downcase.delete_prefix('.')
+ data['files'][name] ||= {}
+ meta = data['files'][name]
+ meta['visible'] = true unless meta.key?('visible')
+
+ if IMAGE_EXTS.include?(ext)
+ enrich_image(full, name, meta)
+ elsif VIDEO_EXTS.include?(ext)
+ enrich_video(full, name, meta)
+ end
+
+ generate_thumb_if_needed(full, rel, name, ext)
+ end
+
+ atomic_write_json(json_path, data)
+end
+
+# ── Metadata enrichment ────────────────────────────────────────────────────────
+
+def enrich_image(full, name, meta)
+ # EXIF date (skip if already recorded)
+ if meta['taken_at'].nil?
+ begin
+ exif = MiniExiftool.new(full, numerical: false)
+ raw = exif.date_time_original || exif.create_date || exif.date_time
+ if raw
+ meta['taken_at'] = raw.respond_to?(:iso8601) ? raw.iso8601 : raw.to_s
+ puts " #{name}: taken_at = #{meta['taken_at']}"
+ end
+ rescue StandardError => e
+ warn " #{name}: EXIF error — #{e.message}"
+ end
+ end
+
+ # Dimensions (skip if already recorded)
+ if meta['width'].nil?
+ begin
+ img = MiniMagick::Image.open(full)
+ meta['width'] = img.width
+ meta['height'] = img.height
+ rescue StandardError => e
+ warn " #{name}: dimension error — #{e.message}"
+ end
+ end
+end
+
+def enrich_video(full, name, meta)
+ return unless meta['duration'].nil?
+ begin
+ out = `ffprobe -v error -select_streams v:0 -show_entries stream=duration -of csv=p=0 "#{full}" 2>/dev/null`.strip
+ meta['duration'] = out.to_f.round unless out.empty?
+ rescue StandardError => e
+ warn " #{name}: ffprobe error — #{e.message}"
+ end
+end
+
+# ── Thumbnail generation ───────────────────────────────────────────────────────
+
+def generate_thumb_if_needed(full, rel, name, ext)
+ return if AUDIO_EXTS.include?(ext) # audio uses a static icon
+
+ cache = File.join(CACHE_ROOT, rel.empty? ? "#{name}.th.jpg" : "#{rel}/#{name}.th.jpg")
+ return if File.exist?(cache)
+
+ puts " Generating thumb: #{name}"
+ FileUtils.mkdir_p(File.dirname(cache))
+
+ if VIDEO_EXTS.include?(ext)
+ generate_video_thumb(full, cache)
+ else
+ generate_image_thumb(full, cache)
+ end
+end
+
+def generate_image_thumb(source, dest)
+ img = MiniMagick::Image.open(source)
+ img.combine_options do |c|
+ c.auto_orient
+ c.thumbnail "#{THUMB_SIZE}x#{THUMB_SIZE}^"
+ c.gravity 'center'
+ c.extent "#{THUMB_SIZE}x#{THUMB_SIZE}"
+ end
+ img.write(dest)
+rescue StandardError => e
+ warn " Thumb error (image): #{e.message}"
+end
+
+def generate_video_thumb(source, dest)
+ system(
+ 'ffmpeg', '-y', '-ss', '2', '-i', source,
+ '-vframes', '1',
+ '-vf', "scale=#{THUMB_SIZE}:#{THUMB_SIZE}:force_original_aspect_ratio=increase,crop=#{THUMB_SIZE}:#{THUMB_SIZE}",
+ dest,
+ %i[out err] => '/dev/null'
+ )
+rescue StandardError => e
+ warn " Thumb error (video): #{e.message}"
+end
+
+# ── Helpers ────────────────────────────────────────────────────────────────────
+
+def load_json(path)
+ return {} unless File.exist?(path)
+ JSON.parse(File.read(path))
+rescue JSON::ParserError => e
+ warn " Warning: could not parse #{path} — #{e.message}. Starting fresh."
+ {}
+end
+
+# Fields the admin controls — never overwrite with stale values from our earlier read.
+ADMIN_ALBUM_KEYS = %w[title description cover cover_dynamic sort_reverse visible].freeze
+ADMIN_FILE_KEYS = %w[title caption visible].freeze
+
+def atomic_write_json(path, data)
+ # Re-read the file right before writing so any admin saves that happened
+ # while we were processing (EXIF, thumbnails) are preserved.
+ if File.exist?(path)
+ fresh = JSON.parse(File.read(path))
+ ADMIN_ALBUM_KEYS.each { |k| data[k] = fresh[k] if fresh.key?(k) }
+ (fresh['files'] || {}).each do |name, meta|
+ next unless data['files'].key?(name)
+ ADMIN_FILE_KEYS.each { |k| data['files'][name][k] = meta[k] if meta.key?(k) }
+ end
+ end
+ tmp = "#{path}.tmp.#{Process.pid}"
+ File.write(tmp, JSON.pretty_generate(data))
+ File.rename(tmp, path)
+rescue StandardError
+ File.unlink(tmp) rescue nil
+ raise
+end
+
+# ── Entry point ────────────────────────────────────────────────────────────────
+
+start = if ARGV[0]
+ arg = ARGV[0].chomp('/')
+ arg.start_with?('/') ? arg : File.join(MEDIA_ROOT, arg)
+else
+ MEDIA_ROOT
+end
+
+unless File.directory?(start)
+ abort "Error: #{start} is not a directory"
+end
+
+# Fix ownership so the web app (albumen user) can read everything we just scanned.
+# Safe no-op when already correct; only meaningful when run as root after an rsync.
+if Process.uid == 0
+ service_user = 'albumen'
+ begin
+ require 'etc'
+ pw = Etc.getpwnam(service_user)
+ puts "Fixing ownership of #{start} → #{service_user}"
+ FileUtils.chown_R(pw.uid, pw.gid, start)
+ rescue ArgumentError
+ warn "Warning: user '#{service_user}' not found; skipping chown"
+ end
+end
+
+# Walk the tree: process each directory (depth-first, parent before children)
+dirs = [start]
+dirs += Dir.glob("#{start}/**/*/").sort
+dirs.uniq.each { |d| process_dir(d) }
+
+puts 'Done.'
diff --git a/setup.sh b/setup.sh
new file mode 100644
index 0000000..5c83d11
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Run on the server as root after rsyncing the app files.
+set -euo pipefail
+
+APP_DIR=/opt/albumen
+MEDIA_DIR=/var/albumen
+
+echo "==> Creating directories"
+mkdir -p "$APP_DIR"/{cache/thumbs,tmp,log,public/img}
+mkdir -p "$MEDIA_DIR"
+
+echo "==> Creating service user"
+id albumen &>/dev/null || useradd -r -s /usr/sbin/nologin -d "$APP_DIR" albumen
+
+echo "==> Installing gems"
+cd "$APP_DIR"
+bundle install --without development test
+
+echo "==> Generating audio placeholder SVG"
+cat > "$APP_DIR/public/img/audio.svg" <<'SVG'
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
+ <rect width="300" height="300" fill="#1c1c1c"/>
+ <text x="150" y="175" font-size="120" text-anchor="middle" fill="#555">♪</text>
+</svg>
+SVG
+
+echo "==> Setting permissions"
+chown -R albumen:albumen "$APP_DIR" "$MEDIA_DIR"
+chmod +x "$APP_DIR/scripts/"*.rb
+
+echo "==> Setting up nginx"
+cp "$APP_DIR/config/nginx-albumen.conf" /etc/nginx/sites-available/albumen
+ln -sf /etc/nginx/sites-available/albumen /etc/nginx/sites-enabled/albumen
+rm -f /etc/nginx/sites-enabled/default
+nginx -t
+systemctl enable nginx
+systemctl restart nginx
+
+echo "==> Setting up systemd service"
+cp "$APP_DIR/config/albumen.service" /etc/systemd/system/
+systemctl daemon-reload
+systemctl enable albumen
+
+echo ""
+echo "==> Setup complete!"
+echo ""
+echo "Next steps:"
+echo " 1. Set admin password: ruby $APP_DIR/scripts/set_password.rb"
+echo " 2. Start the service: systemctl start albumen"
+echo " 3. Check logs: journalctl -u albumen -f"
+echo " 4. Drop photos into: $MEDIA_DIR"
+echo " 5. Run update script: ruby $APP_DIR/scripts/update.rb"
diff --git a/views/admin/album.erb b/views/admin/album.erb
new file mode 100644
index 0000000..f6b80d6
--- /dev/null
+++ b/views/admin/album.erb
@@ -0,0 +1,101 @@
+<div class="admin-album">
+ <div class="admin-nav">
+ <% if @rel.empty? %>
+ <a href="/browse/" class="btn btn-sm">View Root</a>
+ <% else %>
+ <% parent = @rel.include?('/') ? @rel.split('/')[0..-2].join('/') : '' %>
+ <a href="/admin/edit/<%= parent %>" class="btn btn-sm">← Parent</a>
+ <a href="/browse/<%= @rel %>" class="btn btn-sm">View Album</a>
+ <% end %>
+ </div>
+
+ <h1>Edit: <%= @title %></h1>
+
+ <form method="post" action="/admin/edit/<%= @rel %>">
+ <fieldset class="album-settings">
+ <legend>Album</legend>
+ <div class="form-row">
+ <label>Title override
+ <input type="text" name="album_title" value="<%= @data['title'] %>" placeholder="(use directory name)">
+ </label>
+ </div>
+ <div class="form-row">
+ <label>Description
+ <textarea name="album_description" rows="2"><%= @data['description'] %></textarea>
+ </label>
+ </div>
+ <div class="form-row form-row-inline">
+ <label>Cover image
+ <select name="album_cover">
+ <option value="">— auto (first image) —</option>
+ <% @files.each do |name| %>
+ <option value="<%= name %>"<%= ' selected' if @data['cover'] == name %>><%= name %></option>
+ <% end %>
+ </select>
+ </label>
+ <label class="checkbox-label">
+ <input type="checkbox" name="album_cover_dynamic" value="1"<%= ' checked' if @data['cover_dynamic'] %>>
+ Dynamic cover
+ </label>
+ <label class="checkbox-label">
+ <input type="checkbox" name="album_sort_reverse" value="1"<%= ' checked' if @data['sort_reverse'] %>>
+ Reverse sub-album order
+ </label>
+ <label class="checkbox-label">
+ <input type="hidden" name="album_visible" value="0">
+ <input type="checkbox" name="album_visible" value="1"<%= ' checked' if @data['visible'] != false %>>
+ Visible
+ </label>
+ </div>
+ </fieldset>
+
+ <% unless @files.empty? %>
+ <div class="files-section">
+ <h2>Files</h2>
+ <table class="files-table">
+ <thead>
+ <tr>
+ <th>Thumb</th>
+ <th>Filename</th>
+ <th>Title</th>
+ <th>Caption</th>
+ <th>Visible</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% @files.each do |name| %>
+ <% meta = (@data['files'] || {})[name] || {} %>
+ <% file_rel = @rel.empty? ? name : "#{@rel}/#{name}" %>
+ <tr>
+ <td><img src="/thumb/<%= file_rel %>" width="60" height="60" loading="lazy" style="object-fit:cover"></td>
+ <td class="filename"><code><%= name %></code></td>
+ <td><input type="text" name="file_title[<%= name %>]" value="<%= ERB::Util.html_escape(meta['title'].to_s) %>" placeholder="<%= ERB::Util.html_escape(name) %>"></td>
+ <td><input type="text" name="file_caption[<%= name %>]" value="<%= ERB::Util.html_escape(meta['caption'].to_s) %>" placeholder="caption…"></td>
+ <td class="visible-cell">
+ <input type="hidden" name="file_visible[<%= name %>]" value="0">
+ <input type="checkbox" name="file_visible[<%= name %>]" value="1"<%= ' checked' if meta['visible'] != false %>>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+ <% end %>
+
+ <div class="form-actions">
+ <button type="submit" class="btn">Save</button>
+ </div>
+ </form>
+
+ <% unless @sub_dirs.empty? %>
+ <section class="sub-albums-section">
+ <h2>Sub-albums</h2>
+ <ul class="sub-album-list">
+ <% @sub_dirs.each do |name| %>
+ <% sub_rel = @rel.empty? ? name : "#{@rel}/#{name}" %>
+ <li><a href="/admin/edit/<%= sub_rel %>"><%= name %></a></li>
+ <% end %>
+ </ul>
+ </section>
+ <% end %>
+</div>
diff --git a/views/admin/login.erb b/views/admin/login.erb
new file mode 100644
index 0000000..16f12d2
--- /dev/null
+++ b/views/admin/login.erb
@@ -0,0 +1,14 @@
+<div class="admin-login">
+ <h1>Admin Login</h1>
+ <% if defined?(@error) && @error %>
+ <p class="form-error"><%= @error %></p>
+ <% end %>
+ <form method="post" action="/admin/login">
+ <input type="hidden" name="return_to" value="<%= ERB::Util.html_escape(params['return_to'].to_s) %>">
+ <label>
+ Password
+ <input type="password" name="password" autofocus autocomplete="current-password">
+ </label>
+ <button type="submit" class="btn">Login</button>
+ </form>
+</div>
diff --git a/views/album.erb b/views/album.erb
new file mode 100644
index 0000000..077e119
--- /dev/null
+++ b/views/album.erb
@@ -0,0 +1,99 @@
+<div class="album-header">
+ <% unless @crumbs.empty? %>
+ <nav class="breadcrumbs">
+ <a href="/browse/">Home</a>
+ <% @crumbs.each do |c| %>
+ <span class="sep">›</span>
+ <a href="/browse/<%= c[:path] %>"><%= c[:name] %></a>
+ <% end %>
+ </nav>
+ <% end %>
+ <h1><%= @title %></h1>
+ <% if @desc %><p class="album-desc"><%= @desc %></p><% end %>
+ <div class="album-actions">
+ <% if @entries.any? { |e| %i[image video].include?(e[:type]) } %>
+ <a href="/slideshow/<%= @rel %>" class="btn">Slideshow</a>
+ <% end %>
+ </div>
+</div>
+
+<% unless @albums.empty? %>
+<section class="grid-section">
+ <% if @entries.any? %><h2 class="section-label">Albums</h2><% end %>
+ <div class="grid">
+ <% @albums.each do |a| %>
+ <% href = @rel.empty? ? "/browse/#{a[:name]}" : "/browse/#{@rel}/#{a[:name]}" %>
+ <a href="<%= href %>" class="card album-card">
+ <div class="thumb-wrap">
+ <% cover_rel = @rel.empty? ? "#{a[:name]}/#{a[:cover]}" : "#{@rel}/#{a[:name]}/#{a[:cover]}" %>
+ <% if a[:cover] %>
+ <img src="/thumb/<%= cover_rel %>" alt="<%= a[:title] %>" loading="lazy">
+ <% else %>
+ <div class="thumb-placeholder">📁</div>
+ <% end %>
+ </div>
+ <div class="album-label"><%= a[:title] %></div>
+ </a>
+ <% end %>
+ </div>
+</section>
+<% end %>
+
+<% unless @entries.empty? %>
+<section class="grid-section">
+ <% if @albums.any? %><h2 class="section-label">Photos &amp; Videos</h2><% end %>
+ <div class="grid" id="photo-grid">
+ <% @entries.each_with_index do |e, i| %>
+ <% file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}" %>
+ <div class="card media-card<%= ' hidden-item' unless e[:visible] %>"
+ data-index="<%= i %>"
+ data-type="<%= e[:type] %>"
+ data-src="/media/<%= file_rel %>"
+ data-title="<%= ERB::Util.html_escape(e[:title]) %>"
+ data-caption="<%= ERB::Util.html_escape(e[:caption].to_s) %>"
+ onclick="openLightbox(<%= i %>)">
+ <div class="thumb-wrap">
+ <img src="/thumb/<%= file_rel %>" alt="<%= ERB::Util.html_escape(e[:title]) %>" loading="lazy">
+ <% if e[:type] == :video %><span class="type-badge video-badge">▶</span><% end %>
+ <% if e[:type] == :audio %><span class="type-badge audio-badge">♪</span><% end %>
+ </div>
+ <% if e[:caption] %>
+ <div class="card-meta"><p class="card-caption"><%= e[:caption] %></p></div>
+ <% end %>
+ </div>
+ <% end %>
+ </div>
+</section>
+<% end %>
+
+<% if @albums.empty? && @entries.empty? %>
+ <p class="empty-album">This album is empty.</p>
+<% end %>
+
+<div id="lightbox" class="lightbox hidden" role="dialog" aria-modal="true">
+ <button class="lb-btn lb-close" onclick="closeLightbox()" aria-label="Close">✕</button>
+ <div class="lb-stage" onclick="closeLightbox()">
+ <div class="lb-media" onclick="event.stopPropagation()">
+ <img id="lb-img" src="" alt="" class="hidden">
+ <video id="lb-video" controls class="hidden"></video>
+ <audio id="lb-audio" controls class="hidden"></audio>
+ <button class="lb-btn lb-prev" onclick="lbNav(-1)" aria-label="Previous">‹</button>
+ <button class="lb-btn lb-next" onclick="lbNav(1)" aria-label="Next">›</button>
+ </div>
+ </div>
+ <div class="lb-caption-bar">
+ <span id="lb-title"></span>
+ <span id="lb-caption"></span>
+ <a id="lb-download" href="" download class="btn btn-sm lb-action">↓ Original</a>
+ <button id="lb-copylink" onclick="lbCopyLink()" class="btn btn-sm lb-action">⧉ Copy link</button>
+ <span id="lb-counter"></span>
+ </div>
+</div>
+
+<script>
+const ENTRIES = <%= @entries.map { |e|
+ file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}"
+ e.merge(src: "/media/#{file_rel}")
+}.to_json %>;
+</script>
+<script src="/js/album.js"></script>
diff --git a/views/layout.erb b/views/layout.erb
new file mode 100644
index 0000000..3e798c4
--- /dev/null
+++ b/views/layout.erb
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><%= @title %> — Albumen</title>
+ <link rel="stylesheet" href="/css/style.css">
+</head>
+<body>
+ <header class="site-header">
+ <a href="/browse/" class="site-logo">Albumen</a>
+ <nav class="site-nav">
+ <% if admin? %>
+ <a href="/admin/edit/<%= defined?(@rel) ? @rel : '' %>">Edit Album</a>
+ <a href="/admin/logout">Logout</a>
+ <% else %>
+ <a href="/admin/login" class="nav-admin">Admin</a>
+ <% end %>
+ </nav>
+ </header>
+ <main>
+ <%= yield %>
+ </main>
+</body>
+</html>
diff --git a/views/slideshow.erb b/views/slideshow.erb
new file mode 100644
index 0000000..847da65
--- /dev/null
+++ b/views/slideshow.erb
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><%= @title %> — Slideshow</title>
+ <link rel="stylesheet" href="/css/style.css">
+</head>
+<body class="slideshow-page">
+ <div id="slideshow">
+ <div id="ss-stage">
+ <img id="ss-img" src="" alt="" style="display:none">
+ <video id="ss-video" playsinline style="display:none"></video>
+ <div id="ss-caption-bar">
+ <span id="ss-title"></span>
+ <span id="ss-caption"></span>
+ </div>
+ </div>
+ <div id="ss-controls">
+ <a href="/browse/<%= @rel %>" class="btn btn-sm">← Album</a>
+ <button onclick="ssPrev()" class="btn btn-sm">‹ Prev</button>
+ <button onclick="ssToggle()" id="ss-play-btn" class="btn btn-sm">⏸ Pause</button>
+ <button onclick="ssNext()" class="btn btn-sm">Next ›</button>
+ <label class="ss-interval-label">
+ Interval
+ <input type="number" id="ss-interval" value="4" min="1" max="60" step="1"> s
+ </label>
+ </div>
+ <div id="ss-counter"></div>
+ </div>
+
+ <script>
+ const SS_ENTRIES = <%= @entries.map { |e|
+ file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}"
+ e.merge(src: "/media/#{file_rel}")
+ }.to_json %>;
+ const SS_REL = <%= @rel.to_json %>;
+ </script>
+ <script src="/js/slideshow.js"></script>
+</body>
+</html>