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>
272 lines
8.5 KiB
Go
272 lines
8.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
const maxAttempts = 5
|
|
|
|
type Photo struct {
|
|
ID string
|
|
GalleryID string
|
|
Position int
|
|
OriginalKey string
|
|
OriginalFilename string
|
|
ContentType string
|
|
SizeBytes int64
|
|
Width int
|
|
Height int
|
|
ThumbKey string
|
|
PreviewKey string
|
|
TakenAt time.Time
|
|
Status string
|
|
Attempts int
|
|
NextAttemptAt time.Time
|
|
LastError string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
|
|
size_bytes, width, height, thumb_key, preview_key, taken_at, status, attempts, next_attempt_at,
|
|
last_error, created_at, updated_at`
|
|
|
|
func scanPhoto(s scanner) (Photo, error) {
|
|
var v [18]any
|
|
dest := make([]any, len(v))
|
|
for i := range v {
|
|
dest[i] = &v[i]
|
|
}
|
|
if err := s.Scan(dest...); err != nil {
|
|
return Photo{}, err
|
|
}
|
|
return Photo{
|
|
ID: asString(v[0]),
|
|
GalleryID: asString(v[1]),
|
|
Position: int(asInt(v[2])),
|
|
OriginalKey: asString(v[3]),
|
|
OriginalFilename: asString(v[4]),
|
|
ContentType: asString(v[5]),
|
|
SizeBytes: asInt(v[6]),
|
|
Width: int(asInt(v[7])),
|
|
Height: int(asInt(v[8])),
|
|
ThumbKey: asString(v[9]),
|
|
PreviewKey: asString(v[10]),
|
|
TakenAt: asTime(v[11]),
|
|
Status: asString(v[12]),
|
|
Attempts: int(asInt(v[13])),
|
|
NextAttemptAt: asTime(v[14]),
|
|
LastError: asString(v[15]),
|
|
CreatedAt: asTime(v[16]),
|
|
UpdatedAt: asTime(v[17]),
|
|
}, nil
|
|
}
|
|
|
|
func (db *DB) InsertPhoto(ctx context.Context, p Photo) error {
|
|
_, err := db.ExecContext(ctx, db.Rebind(`
|
|
INSERT INTO photos_photos
|
|
(id, gallery_id, position, original_key, original_filename, content_type, size_bytes,
|
|
status, attempts, next_attempt_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`),
|
|
p.ID, p.GalleryID, p.Position, p.OriginalKey, nullable(p.OriginalFilename), p.ContentType,
|
|
p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt))
|
|
return err
|
|
}
|
|
|
|
func (db *DB) GetPhoto(ctx context.Context, id string) (Photo, error) {
|
|
row := db.QueryRowContext(ctx,
|
|
db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id)
|
|
p, err := scanPhoto(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Photo{}, ErrNotFound
|
|
}
|
|
return p, err
|
|
}
|
|
|
|
// ListPhotos returns a gallery's photos in position order. When readyOnly is
|
|
// set (public callers), photos still processing or failed are omitted.
|
|
func (db *DB) ListPhotos(ctx context.Context, galleryID string, readyOnly bool) ([]Photo, error) {
|
|
q := "SELECT " + photoColumns + " FROM photos_photos WHERE gallery_id = ?"
|
|
if readyOnly {
|
|
q += " AND status = 'ready'"
|
|
}
|
|
q += " ORDER BY position, created_at"
|
|
rows, err := db.QueryContext(ctx, db.Rebind(q), galleryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
photos := []Photo{}
|
|
for rows.Next() {
|
|
p, err := scanPhoto(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
photos = append(photos, p)
|
|
}
|
|
return photos, rows.Err()
|
|
}
|
|
|
|
func (db *DB) NextPosition(ctx context.Context, galleryID string) (int, error) {
|
|
var v any
|
|
err := db.QueryRowContext(ctx,
|
|
db.Rebind("SELECT COALESCE(MAX(position), -1) + 1 FROM photos_photos WHERE gallery_id = ?"),
|
|
galleryID).Scan(&v)
|
|
return int(asInt(v)), err
|
|
}
|
|
|
|
// ReorderPhotos rewrites positions to match ids order, in one transaction.
|
|
// ids must be exactly the gallery's photo ids (validated by the handler).
|
|
func (db *DB) ReorderPhotos(ctx context.Context, galleryID string, ids []string) error {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
q := db.Rebind("UPDATE photos_photos SET position = ?, updated_at = ? WHERE id = ? AND gallery_id = ?")
|
|
now := db.TimeArg(time.Now())
|
|
for i, id := range ids {
|
|
if _, err := tx.ExecContext(ctx, q, i, now, id, galleryID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (db *DB) DeletePhoto(ctx context.Context, id string) error {
|
|
res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_photos WHERE id = ?"), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return errIfNoRows(res)
|
|
}
|
|
|
|
// PhotoKeys returns every storage key of a gallery, for object cleanup
|
|
// before the rows cascade away.
|
|
func (db *DB) PhotoKeys(ctx context.Context, galleryID string) ([]string, error) {
|
|
rows, err := db.QueryContext(ctx, db.Rebind(
|
|
"SELECT original_key, thumb_key, preview_key FROM photos_photos WHERE gallery_id = ?"), galleryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var keys []string
|
|
for rows.Next() {
|
|
var a, b, c any
|
|
if err := rows.Scan(&a, &b, &c); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, k := range []string{asString(a), asString(b), asString(c)} {
|
|
if k != "" {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
}
|
|
return keys, rows.Err()
|
|
}
|
|
|
|
// ClaimNextPhoto picks the oldest due queued/failed photo and marks it
|
|
// processing. Optimistic claim (RowsAffected check) works identically on
|
|
// Postgres and SQLite; returns ErrNotFound when the queue is empty.
|
|
func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) {
|
|
now := time.Now()
|
|
var idRaw any
|
|
err := db.QueryRowContext(ctx, db.Rebind(`
|
|
SELECT id FROM photos_photos
|
|
WHERE status IN ('queued','failed') AND attempts < ? AND next_attempt_at <= ?
|
|
ORDER BY created_at LIMIT 1`),
|
|
maxAttempts, db.TimeArg(now)).Scan(&idRaw)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Photo{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Photo{}, err
|
|
}
|
|
id := asString(idRaw)
|
|
res, err := db.ExecContext(ctx, db.Rebind(`
|
|
UPDATE photos_photos SET status = 'processing', attempts = attempts + 1, updated_at = ?
|
|
WHERE id = ? AND status IN ('queued','failed')`),
|
|
db.TimeArg(now), id)
|
|
if err != nil {
|
|
return Photo{}, err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return Photo{}, ErrNotFound // lost the race; caller loops again
|
|
}
|
|
return db.GetPhoto(ctx, id)
|
|
}
|
|
|
|
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, width, height int, takenAt time.Time) error {
|
|
var takenArg any
|
|
if !takenAt.IsZero() {
|
|
takenArg = db.TimeArg(takenAt)
|
|
}
|
|
_, err := db.ExecContext(ctx, db.Rebind(`
|
|
UPDATE photos_photos
|
|
SET status = 'ready', thumb_key = ?, preview_key = ?, width = ?, height = ?, taken_at = ?,
|
|
last_error = NULL, updated_at = ?
|
|
WHERE id = ?`),
|
|
thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), id)
|
|
return err
|
|
}
|
|
|
|
func (db *DB) MarkPhotoFailed(ctx context.Context, id string, attempts int, cause error) error {
|
|
backoff := time.Duration(1<<min(attempts, 6)) * time.Minute
|
|
msg := fmt.Sprintf("%v", cause)
|
|
if len(msg) > 1000 {
|
|
msg = msg[:1000]
|
|
}
|
|
_, err := db.ExecContext(ctx, db.Rebind(`
|
|
UPDATE photos_photos SET status = 'failed', last_error = ?, next_attempt_at = ?, updated_at = ?
|
|
WHERE id = ?`),
|
|
msg, db.TimeArg(time.Now().Add(backoff)), db.TimeArg(time.Now()), id)
|
|
return err
|
|
}
|
|
|
|
// RequeuePhoto resets a failed photo for a fresh round of attempts.
|
|
func (db *DB) RequeuePhoto(ctx context.Context, id string) error {
|
|
res, err := db.ExecContext(ctx, db.Rebind(`
|
|
UPDATE photos_photos SET status = 'queued', attempts = 0, next_attempt_at = ?, updated_at = ?
|
|
WHERE id = ? AND status IN ('failed','queued')`),
|
|
db.TimeArg(time.Now()), db.TimeArg(time.Now()), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return errIfNoRows(res)
|
|
}
|
|
|
|
// RecoverStuckProcessing requeues photos left in 'processing' by a crash.
|
|
func (db *DB) RecoverStuckProcessing(ctx context.Context, olderThan time.Duration) (int64, error) {
|
|
res, err := db.ExecContext(ctx, db.Rebind(`
|
|
UPDATE photos_photos SET status = 'queued', updated_at = ?
|
|
WHERE status = 'processing' AND updated_at < ?`),
|
|
db.TimeArg(time.Now()), db.TimeArg(time.Now().Add(-olderThan)))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
// GalleryCoverKeys returns thumb keys for cover selection: the explicit
|
|
// cover photo's thumb when set and ready, else the first ready photo's.
|
|
func (db *DB) GalleryCoverKey(ctx context.Context, g Gallery) (photoID, thumbKey string) {
|
|
if g.CoverPhotoID != "" {
|
|
if p, err := db.GetPhoto(ctx, g.CoverPhotoID); err == nil && p.Status == "ready" && p.GalleryID == g.ID {
|
|
return p.ID, p.ThumbKey
|
|
}
|
|
}
|
|
row := db.QueryRowContext(ctx, db.Rebind(`
|
|
SELECT id, thumb_key FROM photos_photos
|
|
WHERE gallery_id = ? AND status = 'ready'
|
|
ORDER BY position, created_at LIMIT 1`), g.ID)
|
|
var idRaw, keyRaw any
|
|
if err := row.Scan(&idRaw, &keyRaw); err != nil {
|
|
return "", ""
|
|
}
|
|
return asString(idRaw), asString(keyRaw)
|
|
}
|