Permanently deleting a photo whose album was deleted failed because authorization resolved through the album. Fall back to uploader ownership when the photo is albumless. Add a bulk DELETE /trash that removes every trashed photo the caller may delete (uncapped listing) and returns the deleted count, with an «Очистить корзину» action on the trash screen.
536 lines
17 KiB
Go
536 lines
17 KiB
Go
package photos
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
|
|
"galo/internal/auth"
|
|
"galo/internal/models"
|
|
"galo/internal/sqliteutil"
|
|
)
|
|
|
|
// SQLiteStore is the SQLite implementation of Repository.
|
|
type SQLiteStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
|
|
|
|
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
|
COALESCE(o_o.storage_key, ''),
|
|
COALESCE(o_t.storage_key, ''),
|
|
COALESCE(o_m.storage_key, ''),
|
|
COALESCE(o_p.storage_key, ''),
|
|
COALESCE(o_pb.storage_key, ''),
|
|
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
|
p.kind, COALESCE(p.duration_ms, 0),
|
|
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
|
u.id, u.name, COALESCE(u.avatar_url, ''),
|
|
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
|
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
|
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
|
|
COALESCE(p.exif, '{}'), p.deleted_at,
|
|
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
|
|
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
|
WHERE pt.photo_id = p.id AND t.user_id = $2
|
|
ORDER BY lower(t.name)) t), '[]')`
|
|
|
|
const sqlitePhotoJoins = `
|
|
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
|
|
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
|
|
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
|
|
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
|
|
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
|
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
|
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
|
LEFT JOIN objects o_p ON o_p.id = po_p.object_id
|
|
LEFT JOIN photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback'
|
|
LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id`
|
|
|
|
func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
|
var p models.Photo
|
|
var exifText, tagsText, createdAt string
|
|
var takenAt, deletedAt sql.NullString
|
|
var lat, long sql.NullFloat64
|
|
var liked int
|
|
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
|
&p.Kind, &p.DurationMS,
|
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
|
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil {
|
|
return nil, err
|
|
}
|
|
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
|
if lat.Valid {
|
|
p.Latitude = &lat.Float64
|
|
}
|
|
if long.Valid {
|
|
p.Longitude = &long.Float64
|
|
}
|
|
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
|
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
|
|
p.LikedByMe = liked != 0
|
|
setExif(&p, exifText)
|
|
setTags(&p, tagsText)
|
|
return &p, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error {
|
|
exifJSON, err := json.Marshal(p.EXIF)
|
|
if err != nil || len(exifJSON) == 0 {
|
|
exifJSON = []byte("{}")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
obj.ID, obj.StorageKey, obj.MimeType, obj.Size, obj.Width, obj.Height, obj.Checksum, ObjectPending); err != nil {
|
|
return err
|
|
}
|
|
var createdAt string
|
|
if err := tx.QueryRowContext(ctx,
|
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
RETURNING created_at`,
|
|
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
|
|
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
|
|
mediaKind(p.Kind), p.DurationMS,
|
|
).Scan(&createdAt); err != nil {
|
|
return err
|
|
}
|
|
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)`,
|
|
p.ID, obj.ID, RoleOriginal); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO variant_jobs (photo_id, kind) VALUES ($1, $2) ON CONFLICT (photo_id) DO NOTHING`,
|
|
p.ID, mediaKind(p.Kind)); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteStore) DeleteUpload(ctx context.Context, photoID, objectID string) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+sqlitePhotoColumns+`
|
|
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
|
|
WHERE p.id = $1`, id, viewerID)
|
|
p, err := scanSQLitePhoto(row.Scan)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
|
|
if in.Limit <= 0 || in.Limit > 100 {
|
|
in.Limit = 50
|
|
}
|
|
var since any
|
|
if in.Since != nil {
|
|
since = sqliteutil.FormatTime(*in.Since)
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT `+sqlitePhotoColumns+`
|
|
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
|
|
WHERE p.album_id = $1
|
|
AND p.deleted_at IS NULL
|
|
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
|
AND ($4 IS NULL OR p.created_at > $4)
|
|
ORDER BY p.created_at DESC, p.id DESC
|
|
LIMIT $5`,
|
|
in.AlbumID, in.Viewer, in.Cursor, since, in.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Photo{}
|
|
for rows.Next() {
|
|
p, err := scanSQLitePhoto(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) SetDeleted(ctx context.Context, id string, deleted bool) error {
|
|
var res sql.Result
|
|
var err error
|
|
if deleted {
|
|
res, err = s.db.ExecContext(ctx,
|
|
`UPDATE photos SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`,
|
|
id, sqliteutil.FormatTime(nowUTC()))
|
|
} else {
|
|
res, err = s.db.ExecContext(ctx, `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) UpdateAlbum(ctx context.Context, id, albumID string) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
|
|
exifJSON, err := json.Marshal(src.EXIF)
|
|
if err != nil || len(exifJSON) == 0 {
|
|
exifJSON = []byte("{}")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename,
|
|
nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON),
|
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO photo_objects (photo_id, object_id, role)
|
|
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
|
|
id, src.ID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteStore) HardDelete(ctx context.Context, id string) error {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT `+sqlitePhotoColumns+`, COALESCE(a.title, '')
|
|
FROM photos p
|
|
JOIN users u ON u.id = p.uploader_id
|
|
LEFT JOIN albums a ON a.id = p.album_id`+sqlitePhotoJoins+`
|
|
WHERE p.deleted_at IS NOT NULL
|
|
AND (
|
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
|
)
|
|
ORDER BY p.deleted_at DESC
|
|
LIMIT 200`,
|
|
userID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Photo{}
|
|
for rows.Next() {
|
|
var p models.Photo
|
|
var exifText, tagsText, createdAt, albumTitle string
|
|
var takenAt, deletedAt sql.NullString
|
|
var lat, long sql.NullFloat64
|
|
var liked int
|
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
|
&p.Kind, &p.DurationMS,
|
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
|
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil {
|
|
return nil, err
|
|
}
|
|
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
|
if lat.Valid {
|
|
p.Latitude = &lat.Float64
|
|
}
|
|
if long.Valid {
|
|
p.Longitude = &long.Float64
|
|
}
|
|
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
|
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
|
|
p.AlbumTitle = albumTitle
|
|
p.LikedByMe = liked != 0
|
|
setExif(&p, exifText)
|
|
setTags(&p, tagsText)
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) TrashCount(ctx context.Context, userID string) (int, error) {
|
|
var n int
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM photos p
|
|
LEFT JOIN albums a ON a.id = p.album_id
|
|
WHERE p.deleted_at IS NOT NULL
|
|
AND (
|
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
|
)`, userID).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
func (s *SQLiteStore) ListTrashIdentities(ctx context.Context, userID string) ([]TrashIdentity, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT p.id, COALESCE(p.album_id, ''), p.uploader_id
|
|
FROM photos p
|
|
LEFT JOIN albums a ON a.id = p.album_id
|
|
WHERE p.deleted_at IS NOT NULL
|
|
AND (
|
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
|
)
|
|
ORDER BY p.deleted_at DESC`,
|
|
userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []TrashIdentity{}
|
|
for rows.Next() {
|
|
var it TrashIdentity
|
|
if err := rows.Scan(&it.ID, &it.AlbumID, &it.UploaderID); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) RestoreToAlbum(ctx context.Context, id, albumID string) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) AlbumExists(ctx context.Context, albumID string) (bool, error) {
|
|
var ok int
|
|
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
|
|
return ok != 0, err
|
|
}
|
|
|
|
func (s *SQLiteStore) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, name FROM tags
|
|
WHERE user_id = $1 AND ($2 = '' OR name LIKE '%' || $2 || '%')
|
|
ORDER BY lower(name) LIMIT 50`, userID, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Tag{}
|
|
for rows.Next() {
|
|
var t models.Tag
|
|
if err := rows.Scan(&t.ID, &t.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || len(name) > 64 {
|
|
return nil, ErrNotFound
|
|
}
|
|
var t models.Tag
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
|
Scan(&t.ID, &t.Name)
|
|
if err == nil {
|
|
return &t, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
id := auth.NewID()
|
|
err = s.db.QueryRowContext(ctx,
|
|
`INSERT INTO tags (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, name`, id, userID, name).
|
|
Scan(&t.ID, &t.Name)
|
|
if err != nil {
|
|
if e2 := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
|
Scan(&t.ID, &t.Name); e2 == nil {
|
|
return &t, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) AttachTag(ctx context.Context, photoID, tagID string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, photoID, tagID)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteStore) DetachTag(ctx context.Context, photoID, tagID string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteStore) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
|
|
var ok int
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
|
|
return ok != 0, err
|
|
}
|
|
|
|
func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
|
albumID, photoID)
|
|
}
|
|
|
|
// ClearCoverIfMatches removes the album cover only when it currently points at
|
|
// photoID, so a concurrent cover change is not clobbered.
|
|
func (s *SQLiteStore) ClearCoverIfMatches(ctx context.Context, albumID, photoID string) {
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`,
|
|
albumID, photoID)
|
|
}
|
|
|
|
func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) error {
|
|
res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetMediaMeta records probed video facts: duration on the photo and frame
|
|
// dimensions on the original object.
|
|
func (s *SQLiteStore) SetMediaMeta(ctx context.Context, photoID, objectID string, durationMS int64, width, height int) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.ExecContext(ctx, `UPDATE photos SET duration_ms = $2 WHERE id = $1`, photoID, durationMS); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE objects SET width = $2, height = $3 WHERE id = $1`, objectID, width, height); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob
|
|
// of every photo with empty exif.
|
|
func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT p.id, o.storage_key
|
|
FROM photos p
|
|
JOIN photo_objects po ON po.photo_id = p.id AND po.role = 'original'
|
|
JOIN objects o ON o.id = po.object_id
|
|
WHERE p.exif IS NULL OR p.exif = '{}'
|
|
ORDER BY p.created_at`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var pending []backfillRow
|
|
for rows.Next() {
|
|
var r backfillRow
|
|
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
|
|
rows.Close()
|
|
return 0, err
|
|
}
|
|
pending = append(pending, r)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
updated := 0
|
|
for _, r := range pending {
|
|
data, err := fetch(ctx, r.StorageKey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
meta := extractEXIF(data)
|
|
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
|
|
continue
|
|
}
|
|
attrs, err := json.Marshal(meta.Attributes)
|
|
if err != nil || len(attrs) == 0 {
|
|
attrs = []byte("{}")
|
|
}
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE photos SET exif = $2,
|
|
taken_at = COALESCE(taken_at, $3),
|
|
latitude = COALESCE(latitude, $4),
|
|
longitude = COALESCE(longitude, $5)
|
|
WHERE id = $1`,
|
|
r.ID, string(attrs), nullTime(meta.TakenAt), nullFloat(meta.Latitude), nullFloat(meta.Longitude)); err != nil {
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
return updated, nil
|
|
}
|