diff options
Diffstat (limited to 'scripts/update.rb')
| -rw-r--r-- | scripts/update.rb | 221 |
1 files changed, 221 insertions, 0 deletions
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.' |
