#!/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 TRANSCODE_EXTS = %w[avi mkv mov].freeze # not universally browser-playable; convert to MP4 # ── 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['cover'] = '__random__' unless data.key?('cover') 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 and their cached thumbnails removed = data['files'].keys - current removed.each do |n| data['files'].delete(n) thumb = File.join(CACHE_ROOT, rel.empty? ? "#{n}.th.jpg" : "#{rel}/#{n}.th.jpg") if File.exist?(thumb) File.unlink(thumb) puts " Removed: #{n} (+ thumb)" else puts " Removed: #{n}" end end # Transcode non-web-friendly videos to MP4; hide the original current.select { |n| TRANSCODE_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) } .each do |name| base = File.basename(name, '.*') target = "#{base}.mp4" if current.include?(target) # MP4 already exists — just ensure the marker is recorded data['files'][name] ||= {} data['files'][name]['transcoded_to'] = target next end full = File.join(dir, name) dest = File.join(dir, target) puts " Transcoding: #{name} → #{target}" transcode_to_mp4(full, dest) if File.exist?(dest) data['files'][name] ||= {} data['files'][name]['transcoded_to'] = target current << target puts " → done" else warn " Transcode failed: #{name}" end 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) needs_exif = meta['taken_at'].nil? || meta['camera'].nil? || meta['aperture'].nil? || meta['shutter'].nil? || meta['iso'].nil? if needs_exif begin exif = MiniExiftool.new(full, numerical: false) if meta['taken_at'].nil? raw = exif.date_time_original || exif.create_date || exif.date_time if raw meta['taken_at'] = raw.respond_to?(:strftime) ? raw.strftime('%Y-%m-%dT%H:%M:%S') : raw.to_s puts " #{name}: taken_at = #{meta['taken_at']}" end end if meta['camera'].nil? make = exif.make.to_s.strip model = exif.model.to_s.strip cam = model.downcase.start_with?(make.downcase) ? model : [make, model].reject(&:empty?).join(' ') meta['camera'] = cam.empty? ? nil : cam end meta['aperture'] ||= exif.f_number ? "f/#{exif.f_number}" : nil meta['shutter'] ||= exif.exposure_time&.to_s meta['iso'] ||= (exif.iso_speed_ratings || exif.iso)&.to_i 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 transcode_to_mp4(source, dest) system( 'ffmpeg', '-y', '-i', source, '-c:v', 'libx264', '-crf', '23', '-preset', 'medium', '-c:a', 'aac', '-movflags', '+faststart', dest, %i[out err] => '/dev/null' ) rescue StandardError => e warn " Transcode error: #{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.'