Files
MealQuest/internal/recipes/photo.go
T
dev_agent 58e8f30f0e
CI / ci (push) Successful in 1m38s
Photo cache and /media endpoint (#68)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-05 14:24:11 -04:00

255 lines
8.4 KiB
Go

package recipes
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io/fs"
"math"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"sync"
"github.com/oklog/ulid/v2"
"golang.org/x/image/draw"
"golang.org/x/image/webp"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
// PhotoDir is where cached photos are stored, relative to the data
// directory.
PhotoDir = "media/photos"
// PhotoMinShortSide is the least a photo's shorter side may measure, in
// pixels.
PhotoMinShortSide = 300
// PhotoMaxPixels bounds the pixels a photo may decode to, so a small
// compressed file cannot claim gigabytes of memory.
PhotoMaxPixels = 40_000_000
// PhotoQuality is the JPEG quality of every stored size.
PhotoQuality = 82
)
// PhotoSizes are the long-side lengths, in pixels, each photo is stored at.
var PhotoSizes = []int{1200, 400}
var (
// ErrPhotoTooSmall is returned for an image whose shorter side is under
// PhotoMinShortSide.
ErrPhotoTooSmall = errors.New("recipes: photo is too small")
// ErrPhotoTooLarge is returned for an image over PhotoMaxPixels.
ErrPhotoTooLarge = errors.New("recipes: photo has too many pixels")
// ErrPhotoFormat is returned for bytes that are not a JPEG, PNG, WebP
// or GIF image.
ErrPhotoFormat = errors.New("recipes: photo is not a JPEG, PNG, WebP or GIF image")
)
// photoDecoders are the formats a photo may be in. A GIF decodes to its
// first frame.
var photoDecoders = map[string]func([]byte) (image.Image, error){
"jpeg": func(b []byte) (image.Image, error) { return jpeg.Decode(bytes.NewReader(b)) },
"png": func(b []byte) (image.Image, error) { return png.Decode(bytes.NewReader(b)) },
"gif": func(b []byte) (image.Image, error) { return gif.Decode(bytes.NewReader(b)) },
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
}
// PhotoCache downloads photos through a Fetcher and stores each at every
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
// PhotoCache is safe for concurrent use; photos are processed one at a time.
type PhotoCache struct {
fetcher *Fetcher
store *store.Store
// dir is the absolute photo directory.
dir string
// mu serialises the dedupe lookup, processing and insert, so one image
// is never stored twice.
mu sync.Mutex
}
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
// the photo directory if it is absent.
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
if fetcher == nil {
return nil, errors.New("recipes: nil fetcher")
}
if st == nil {
return nil, errors.New("recipes: nil store")
}
dir, err := filepath.Abs(filepath.Join(dataDir, filepath.FromSlash(PhotoDir)))
if err != nil {
return nil, fmt.Errorf("recipes: resolve photo directory: %w", err)
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
}
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
}
// CachePhoto downloads the image at rawURL and returns its photos row. An
// image whose bytes are already cached returns the existing row. Otherwise
// it is decoded, refused if its shorter side is under PhotoMinShortSide,
// scaled so its long side is each of PhotoSizes, encoded as JPEG and
// recorded. The row's width and height are the original image's, and its
// local_path is the largest size's file relative to the data directory.
func (c *PhotoCache) CachePhoto(ctx context.Context, rawURL string) (db.Photo, error) {
page, err := c.fetcher.FetchImage(ctx, rawURL)
if err != nil {
return db.Photo{}, err
}
digest := sha256.Sum256(page.Body)
sum := hex.EncodeToString(digest[:])
c.mu.Lock()
defer c.mu.Unlock()
existing, err := db.New(c.store.Reader).GetPhotoBySHA256(ctx, sum)
if err == nil {
return existing, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return db.Photo{}, fmt.Errorf("recipes: look up photo: %w", err)
}
img, err := decodePhoto(page.Body)
if err != nil {
return db.Photo{}, fmt.Errorf("%w: %s", err, page.URL)
}
id := store.NewID()
var written []string
removeWritten := func() error {
var errs []error
for _, p := range written {
errs = append(errs, os.Remove(p))
}
return errors.Join(errs...)
}
for _, size := range PhotoSizes {
p := c.path(id, size)
if err := writeJPEG(p, scaleLongSide(img, size)); err != nil {
return db.Photo{}, errors.Join(err, removeWritten())
}
written = append(written, p)
}
now := store.Now().String()
bounds := img.Bounds()
photo, err := db.New(c.store.Writer).CreatePhoto(ctx, db.CreatePhotoParams{
ID: id,
SourceUrl: page.URL,
LocalPath: path.Join(PhotoDir, photoFileName(id, PhotoSizes[0])),
Width: int64(bounds.Dx()),
Height: int64(bounds.Dy()),
Sha256: sum,
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return db.Photo{}, errors.Join(fmt.Errorf("recipes: record photo: %w", err), removeWritten())
}
return photo, nil
}
// OpenPhoto opens the stored file of photo id at size. An id that is not a
// canonical ULID, a size not in PhotoSizes, an id with no photos row, or a
// photo whose file is not stored is an error matching fs.ErrNotExist.
func (c *PhotoCache) OpenPhoto(ctx context.Context, id string, size int) (*os.File, error) {
parsed, err := ulid.ParseStrict(id)
if err != nil || parsed.String() != id || !slices.Contains(PhotoSizes, size) {
return nil, fmt.Errorf("recipes: photo %q at size %d: %w", id, size, fs.ErrNotExist)
}
if _, err := db.New(c.store.Reader).GetPhoto(ctx, id); errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("recipes: photo %q: %w", id, fs.ErrNotExist)
} else if err != nil {
return nil, fmt.Errorf("recipes: look up photo %q: %w", id, err)
}
return os.Open(c.path(id, size))
}
func (c *PhotoCache) path(id string, size int) string {
return filepath.Join(c.dir, photoFileName(id, size))
}
func photoFileName(id string, size int) string {
return id + "-" + strconv.Itoa(size) + ".jpg"
}
// decodePhoto decodes a JPEG, PNG, WebP or GIF image, checking its pixel
// count from the header before decoding the pixels, and the shorter side of
// the decoded image: a GIF's first frame may be smaller than its screen.
func decodePhoto(b []byte) (image.Image, error) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrPhotoFormat, err)
}
decode, ok := photoDecoders[format]
if !ok {
return nil, fmt.Errorf("%w: format %q", ErrPhotoFormat, format)
}
if int64(cfg.Width)*int64(cfg.Height) > PhotoMaxPixels {
return nil, fmt.Errorf("%w: %dx%d", ErrPhotoTooLarge, cfg.Width, cfg.Height)
}
img, err := decode(b)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrPhotoFormat, err)
}
if size := img.Bounds().Size(); min(size.X, size.Y) < PhotoMinShortSide {
return nil, fmt.Errorf("%w: %dx%d, shorter side under %d px", ErrPhotoTooSmall, size.X, size.Y, PhotoMinShortSide)
}
return img, nil
}
// scaleLongSide returns img scaled so its longer side is long pixels,
// keeping its aspect ratio. Transparent areas become white, since JPEG has
// no alpha.
func scaleLongSide(img image.Image, long int) *image.RGBA {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dw, dh := long, long
if w >= h {
dh = max(1, int(math.Round(float64(h)*float64(long)/float64(w))))
} else {
dw = max(1, int(math.Round(float64(w)*float64(long)/float64(h))))
}
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
draw.Draw(dst, dst.Bounds(), image.White, image.Point{}, draw.Src)
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
// writeJPEG encodes img at PhotoQuality into a temporary file beside path
// and renames it into place, so a reader never sees a partial file.
func writeJPEG(path string, img image.Image) error {
f, err := os.CreateTemp(filepath.Dir(path), ".photo-*.tmp")
if err != nil {
return fmt.Errorf("recipes: create photo file: %w", err)
}
tmp := f.Name()
err = jpeg.Encode(f, img, &jpeg.Options{Quality: PhotoQuality})
if err == nil {
err = f.Sync()
}
err = errors.Join(err, f.Close())
if err == nil {
err = os.Rename(tmp, path)
}
if err != nil {
return errors.Join(fmt.Errorf("recipes: write photo %s: %w", filepath.Base(path), err), os.Remove(tmp))
}
return nil
}