summaryrefslogtreecommitdiffstats
path: root/scripts/update.rb
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/update.rb')
-rw-r--r--scripts/update.rb221
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.'