Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// Package worker turns queued photos_photos rows into ready ones by
|
||||
// generating the thumb/preview variants. The rows themselves are the queue
|
||||
// (status/attempts/next_attempt_at) — no Redis, no jobs table. Claims are
|
||||
// optimistic UPDATEs so they are safe on both Postgres and SQLite.
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
pollInterval = 10 * time.Second
|
||||
stuckAfter = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
db *store.DB
|
||||
storage storage.Storage
|
||||
heic *imaging.HeicConverter
|
||||
tmpDir string
|
||||
nudge chan struct{}
|
||||
parallel int
|
||||
}
|
||||
|
||||
func New(db *store.DB, st storage.Storage, heic *imaging.HeicConverter, tmpDir string, parallel int) *Worker {
|
||||
if parallel < 1 {
|
||||
parallel = 1
|
||||
}
|
||||
return &Worker{
|
||||
db: db,
|
||||
storage: st,
|
||||
heic: heic,
|
||||
tmpDir: tmpDir,
|
||||
nudge: make(chan struct{}, 1),
|
||||
parallel: parallel,
|
||||
}
|
||||
}
|
||||
|
||||
// Nudge wakes the worker after an upload so the poll interval is only a
|
||||
// fallback.
|
||||
func (w *Worker) Nudge() {
|
||||
select {
|
||||
case w.nudge <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
if n, err := w.db.RecoverStuckProcessing(ctx, stuckAfter); err != nil {
|
||||
log.Printf("worker: recover stuck: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("worker: requeued %d photos stuck in processing", n)
|
||||
}
|
||||
for i := 0; i < w.parallel; i++ {
|
||||
go w.loop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) loop(ctx context.Context) {
|
||||
for {
|
||||
worked := w.drain(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if worked {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-w.nudge:
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drain processes until the queue is empty; returns whether anything ran.
|
||||
func (w *Worker) drain(ctx context.Context) bool {
|
||||
worked := false
|
||||
for ctx.Err() == nil {
|
||||
photo, err := w.db.ClaimNextPhoto(ctx)
|
||||
if err == store.ErrNotFound {
|
||||
return worked
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("worker: claim: %v", err)
|
||||
return worked
|
||||
}
|
||||
worked = true
|
||||
if err := w.process(ctx, photo); err != nil {
|
||||
log.Printf("worker: photo %s attempt %d failed: %v", photo.ID, photo.Attempts, err)
|
||||
if dberr := w.db.MarkPhotoFailed(ctx, photo.ID, photo.Attempts, err); dberr != nil {
|
||||
log.Printf("worker: mark failed: %v", dberr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return worked
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, p store.Photo) error {
|
||||
work, err := os.MkdirTemp(w.tmpDir, "photo-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
|
||||
// 1. Fetch the original to a local file.
|
||||
src := filepath.Join(work, "original")
|
||||
if err := w.download(ctx, p.OriginalKey, src); err != nil {
|
||||
return fmt.Errorf("fetch original: %w", err)
|
||||
}
|
||||
|
||||
// 2. HEIC → JPEG via the external converter before the pure-Go pipeline.
|
||||
if p.ContentType == "image/heic" {
|
||||
if w.heic == nil {
|
||||
return fmt.Errorf("HEIC converter not installed on this host (install libvips-tools or libheif-examples)")
|
||||
}
|
||||
converted := filepath.Join(work, "converted.jpg")
|
||||
if err := w.heic.ToJPEG(src, converted); err != nil {
|
||||
return err
|
||||
}
|
||||
src = converted
|
||||
}
|
||||
|
||||
// 3. Decode, orient, resize, encode.
|
||||
thumbPath := filepath.Join(work, "thumb.jpg")
|
||||
previewPath := filepath.Join(work, "preview.jpg")
|
||||
res, err := imaging.ProcessFile(src, thumbPath, previewPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Store variants next to the original's key.
|
||||
base := strings.TrimSuffix(filepath.Base(p.OriginalKey), filepath.Ext(p.OriginalKey))
|
||||
prefix := fmt.Sprintf("galleries/%s", p.GalleryID)
|
||||
thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base)
|
||||
previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base)
|
||||
if err := w.upload(ctx, thumbKey, thumbPath); err != nil {
|
||||
return fmt.Errorf("store thumb: %w", err)
|
||||
}
|
||||
if err := w.upload(ctx, previewKey, previewPath); err != nil {
|
||||
return fmt.Errorf("store preview: %w", err)
|
||||
}
|
||||
|
||||
return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, res.Width, res.Height, res.TakenAt)
|
||||
}
|
||||
|
||||
func (w *Worker) download(ctx context.Context, key, dst string) error {
|
||||
r, _, err := w.storage.Open(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) upload(ctx context.Context, key, src string) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.storage.Put(ctx, key, f, info.Size(), "image/jpeg")
|
||||
}
|
||||
Reference in New Issue
Block a user