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< 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) }