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 // PreviewSizeBytes is the size of the preview object, 0 when it has not // been measured yet (rows written before the column existed). PreviewSizeBytes int64 TakenAt time.Time Status string Attempts int NextAttemptAt time.Time LastError string CreatedAt time.Time UpdatedAt time.Time // Checksum is the sha256 of the original bytes, empty when the row has // not been hashed yet (uploaded before duplicate detection existed). Checksum string } const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type, size_bytes, width, height, thumb_key, preview_key, preview_size_bytes, taken_at, status, attempts, next_attempt_at, last_error, created_at, updated_at, checksum` func scanPhoto(s scanner) (Photo, error) { var v [20]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]), PreviewSizeBytes: asInt(v[11]), TakenAt: asTime(v[12]), Status: asString(v[13]), Attempts: int(asInt(v[14])), NextAttemptAt: asTime(v[15]), LastError: asString(v[16]), CreatedAt: asTime(v[17]), UpdatedAt: asTime(v[18]), Checksum: asString(v[19]), }, nil } // InsertPhoto returns an error satisfying IsUniqueViolation when the gallery // already holds a photo with the same checksum; callers treat that as a // duplicate rather than a failure. 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, checksum) 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), nullable(p.Checksum)) return err } // FindPhotoByChecksum looks for an existing photo with the same content in one // gallery — the duplicate check the upload path runs before storing anything. // Duplicate scope is per gallery: the same image in another gallery keeps its // own row and its own stored object. func (db *DB) FindPhotoByChecksum(ctx context.Context, galleryID, checksum string) (Photo, error) { if checksum == "" { return Photo{}, ErrNotFound // never match the not-yet-hashed rows } row := db.QueryRowContext(ctx, db.Rebind( "SELECT "+photoColumns+" FROM photos_photos WHERE gallery_id = ? AND checksum = ?"), galleryID, checksum) p, err := scanPhoto(row) if errors.Is(err, sql.ErrNoRows) { return Photo{}, ErrNotFound } return p, err } // SetPhotoChecksum fills in the hash of an already-stored photo. Used by the // backfill; returns a unique-violation error when the row turns out to // duplicate one already hashed in the same gallery. func (db *DB) SetPhotoChecksum(ctx context.Context, id, checksum string) error { res, err := db.ExecContext(ctx, db.Rebind( "UPDATE photos_photos SET checksum = ? WHERE id = ?"), checksum, id) if err != nil { return err } return errIfNoRows(res) } // PhotosMissingChecksum lists rows still lacking a hash (optionally of one // gallery only), oldest first so backfill keeps the earliest upload of a // duplicate pair as the one that gets the checksum. func (db *DB) PhotosMissingChecksum(ctx context.Context, galleryID string) ([]Photo, error) { q := "SELECT " + photoColumns + " FROM photos_photos WHERE checksum IS NULL" var args []any if galleryID != "" { q += " AND gallery_id = ?" args = append(args, galleryID) } q += " ORDER BY gallery_id, position, created_at" rows, err := db.QueryContext(ctx, db.Rebind(q), args...) 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) 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() } // PhotoObject is one stored object: a photo variant plus the content type it // should be written with. Used by the storage sync, which treats the rows as // the inventory of what exists. type PhotoObject struct { PhotoID string GalleryID string Variant string // original | thumb | preview Key string ContentType string } // AllPhotoObjects lists every object of every photo (optionally of one // gallery only), oldest first. Variant keys are empty until the worker has // processed the photo; those are left out. func (db *DB) AllPhotoObjects(ctx context.Context, galleryID string) ([]PhotoObject, error) { q := `SELECT id, gallery_id, content_type, original_key, thumb_key, preview_key FROM photos_photos` var args []any if galleryID != "" { q += " WHERE gallery_id = ?" args = append(args, galleryID) } q += " ORDER BY gallery_id, position, created_at" rows, err := db.QueryContext(ctx, db.Rebind(q), args...) if err != nil { return nil, err } defer rows.Close() objects := []PhotoObject{} for rows.Next() { var id, galID, ctype, orig, thumb, preview any if err := rows.Scan(&id, &galID, &ctype, &orig, &thumb, &preview); err != nil { return nil, err } variants := []struct { name, key, contentType string }{ {"original", asString(orig), asString(ctype)}, {"thumb", asString(thumb), "image/jpeg"}, {"preview", asString(preview), "image/jpeg"}, } for _, v := range variants { if v.key == "" { continue } objects = append(objects, PhotoObject{ PhotoID: asString(id), GalleryID: asString(galID), Variant: v.name, Key: v.key, ContentType: v.contentType, }) } } return objects, 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, previewSize int64, 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 = ?, preview_size_bytes = ?, width = ?, height = ?, taken_at = ?, last_error = NULL, updated_at = ? WHERE id = ?`), thumbKey, previewKey, previewSize, width, height, takenArg, db.TimeArg(time.Now()), id) return err } // SetPreviewSize records the measured size of an already-generated preview. // Used to fill in rows processed before the column existed; failure to write // is not worth failing a read over, so callers may ignore the error. func (db *DB) SetPreviewSize(ctx context.Context, id string, size int64) error { _, err := db.ExecContext(ctx, db.Rebind( "UPDATE photos_photos SET preview_size_bytes = ? WHERE id = ?"), size, 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) }