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>
186 lines
4.6 KiB
Go
186 lines
4.6 KiB
Go
// 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")
|
|
}
|