summaryrefslogtreecommitdiffstats
path: root/app.rb
diff options
context:
space:
mode:
Diffstat (limited to 'app.rb')
-rw-r--r--app.rb177
1 files changed, 177 insertions, 0 deletions
diff --git a/app.rb b/app.rb
index a923b4d..5172eeb 100644
--- a/app.rb
+++ b/app.rb
@@ -25,6 +25,10 @@ MEDIA_EXTS = (IMAGE_EXTS + VIDEO_EXTS + AUDIO_EXTS).freeze
APP_CONFIG = (File.exist?(CONFIG_PATH) ? YAML.load_file(CONFIG_PATH, symbolize_names: true) : {}).freeze
FACES_ENABLED = (APP_CONFIG.dig(:faces, :enabled) == true).freeze
+VENV_PYTHON = (ENV['VENV_PYTHON'] || '/opt/albumen/venv/bin/python3').freeze
+CLUSTER_SCRIPT = File.join(__dir__, 'scripts', 'cluster_faces.py').freeze
+PEOPLE_PATH = File.join(MEDIA_ROOT, 'people.json').freeze
+FACES_CACHE = (ENV['FACES_CACHE'] || '/opt/albumen/cache/faces').freeze
# ── Sinatra config ─────────────────────────────────────────────────────────────
@@ -231,6 +235,39 @@ helpers do
type = @og_use_media ? 'media' : 'thumb'
"#{request.base_url}/#{type}/#{@og_image_rel}"
end
+
+ def load_people_data
+ return {} unless File.exist?(PEOPLE_PATH)
+ JSON.parse(File.read(PEOPLE_PATH))
+ rescue JSON::ParserError
+ {}
+ end
+
+ def face_crop_cache(rel, box)
+ File.join(FACES_CACHE, File.dirname(rel), "#{File.basename(rel)}.#{box.join('-')}.jpg")
+ end
+
+ def generate_face_crop(full_path, box, cache_path)
+ top, right, bottom, left = box.map(&:to_i)
+ img = MiniMagick::Image.open(full_path)
+ iw, ih = img.width, img.height
+ pad_y = ((bottom - top) * 0.5).to_i
+ pad_x = ((right - left) * 0.5).to_i
+ y1 = [top - pad_y, 0].max
+ x1 = [left - pad_x, 0].max
+ y2 = [bottom + pad_y, ih].min
+ x2 = [right + pad_x, iw].min
+ cw = [x2 - x1, 1].max
+ ch = [y2 - y1, 1].max
+ img.combine_options do |c|
+ c.auto_orient
+ c.crop "#{cw}x#{ch}+#{x1}+#{y1}"
+ c.resize '100x100!'
+ end
+ img.write(cache_path)
+ rescue StandardError => e
+ warn "face crop error #{full_path}: #{e.message}"
+ end
end
# ── Public routes ──────────────────────────────────────────────────────────────
@@ -597,6 +634,146 @@ post '/admin/upload' do
{ job_id: job_id, saved: saved, album_rel: target_rel }.to_json
end
+# ── Face crops ────────────────────────────────────────────────────────────────
+
+get '/face/*' 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?
+
+ box = params[:box].to_s.split(',').map(&:to_i)
+ halt 400 unless box.length == 4
+
+ cache = face_crop_cache(rel, box)
+ unless File.exist?(cache)
+ FileUtils.mkdir_p(File.dirname(cache))
+ generate_face_crop(full, box, cache)
+ end
+ halt 404 unless File.exist?(cache)
+ send_file cache, type: 'image/jpeg'
+end
+
+# ── People ─────────────────────────────────────────────────────────────────────
+
+get '/admin/people' do
+ require_admin!
+ data = load_people_data
+ people = data['people'] || {}
+
+ @title = 'People'
+ @total = people.length
+ @named_count = people.count { |_, p| p['name'] }
+ @updated_at = data['updated_at']
+
+ named = people.select { |_, p| p['name'] }.sort_by { |_, p| p['name'].downcase }
+ unnamed = people.reject { |_, p| p['name'] }.sort_by { |_, p| -(p['members']&.length || 0) }
+
+ @clusters = (named + unnamed).first(200).map do |uid, p|
+ members = p['members'] || []
+ { uuid: uid, name: p['name'], slug: p['slug'],
+ count: members.length,
+ samples: members.first(6).map { |m| { rel: m['rel'], box: m['box'] } } }
+ end
+
+ erb :'admin/people'
+end
+
+post '/admin/people/recluster' do
+ require_admin!
+ job_id = SecureRandom.hex(8)
+ cmd = [VENV_PYTHON, CLUSTER_SCRIPT]
+
+ UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id] = { status: :running, lines: [] } }
+ Thread.new do
+ begin
+ IO.popen(cmd, err: [:child, :out]) do |io|
+ io.each_line { |l| UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:lines] << l.chomp } }
+ end
+ code = $?.exitstatus
+ UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:status] = code.zero? ? :done : :error }
+ rescue => e
+ UPDATE_JOBS_MUTEX.synchronize do
+ UPDATE_JOBS[job_id][:status] = :error
+ UPDATE_JOBS[job_id][:lines] << "Error: #{e.message}"
+ end
+ end
+ end
+
+ content_type :json
+ { job_id: job_id }.to_json
+end
+
+post '/admin/people/:uuid' do
+ require_admin!
+ uid = params[:uuid]
+ name = params['name'].to_s.strip
+
+ data = load_people_data
+ people = data['people'] || {}
+ halt 404 unless people.key?(uid)
+
+ slug = nil
+ unless name.empty?
+ base = name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-+|-+$/, '')
+ used = people.reject { |k, _| k == uid }.values.map { |p| p['slug'] }.compact
+ if used.include?(base)
+ i = 2
+ i += 1 while used.include?("#{base}-#{i}")
+ slug = "#{base}-#{i}"
+ else
+ slug = base
+ end
+ end
+
+ people[uid]['name'] = name.empty? ? nil : name
+ people[uid]['slug'] = slug
+ data['people'] = people
+
+ atomic_write(PEOPLE_PATH, JSON.pretty_generate(data))
+ redirect '/admin/people'
+end
+
+get '/people' do
+ data = load_people_data
+ people = (data['people'] || {}).select { |_, p| p['name'] && p['slug'] }
+
+ @title = 'People'
+ @people = people.filter_map do |_, p|
+ face = p['members']&.first
+ next unless face
+ { slug: p['slug'], name: p['name'],
+ count: (p['members'] || []).length,
+ face: { rel: face['rel'], box: face['box'] } }
+ end.sort_by { |p| p[:name].downcase }
+
+ erb :people
+end
+
+get '/people/:slug' do
+ data = load_people_data
+ person = (data['people'] || {}).values.find { |p| p['slug'] == params[:slug] }
+ halt 404 unless person
+
+ seen = {}
+ photos = (person['members'] || []).filter_map do |m|
+ next if seen[m['rel']]
+ seen[m['rel']] = true
+ parts = m['rel'].split('/')
+ { rel: m['rel'], filename: parts.last, dir_rel: parts[0..-2].join('/') }
+ end
+
+ @title = person['name']
+ @person_name = person['name']
+ @photos = photos
+ @count = photos.length
+ erb :person
+end
+
# ── Thumbnail generation ───────────────────────────────────────────────────────
def generate_thumb(source, dest, ext)