Files
ds-galo/backend/internal/photos/store.go
T
iu e1592549b2 feat(photos): full EXIF metadata extraction, backfill and info panel
- backend: switch EXIF parser to dsoprea/go-exif/v3 (all recognised tags, numeric normalization); store in photos.exif jsonb (migration 000003); add storage.Get and cmd/backfill-exif to backfill existing photos from S3 originals
- frontend: info panel renders all EXIF attributes with RU labels, enum translation and units (1/N, f/N, mm, EV); service tags hidden
- viewer: remove make-cover action (cover managed from panel only), icon-only download
- openspec: sync lightroom-workspace-ui artifacts (EXIF, cover-from-panel) and add plan for configurable info attributes/labels stored in user profile
2026-10-10 20:31:34 +03:00

217 lines
6.0 KiB
Go

package photos
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models"
)
var ErrNotFound = errors.New("photo not found")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
p.original_filename, p.size, p.width, p.height, p.mime_type,
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, '{}'::jsonb)::text`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
var exifText string
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
setExif(&p, exifText)
return &p, nil
}
func setExif(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "{}" {
return
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 {
p.EXIF = m
}
}
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
exifJSON, err := json.Marshal(p.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
return s.pool.QueryRow(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
RETURNING created_at`,
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
).Scan(&p.CreatedAt)
}
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
_, err := s.pool.Exec(ctx,
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
return err
}
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
return scanPhoto(s.pool.QueryRow(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.id = $1`, id, viewerID))
}
type ListParams struct {
AlbumID string
Viewer string
Cursor string
Since *time.Time
Limit int
}
func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
if in.Limit <= 0 || in.Limit > 100 {
in.Limit = 50
}
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.album_id = $1
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
AND ($4::timestamptz IS NULL OR p.created_at > $4)
ORDER BY p.created_at DESC, p.id DESC
LIMIT $5`,
in.AlbumID, in.Viewer, in.Cursor, in.Since, in.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText); err != nil {
return nil, err
}
setExif(&p, exifText)
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) Delete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx,
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
albumID, photoID)
}
func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type backfillRow struct {
ID string
StorageKey string
}
func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, storage_key FROM photos
WHERE exif IS NULL OR exif = '{}'::jsonb
ORDER BY 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("{}")
}
_, err = s.pool.Exec(ctx,
`UPDATE photos SET exif = $2::jsonb,
taken_at = COALESCE(taken_at, $3),
latitude = COALESCE(latitude, $4),
longitude = COALESCE(longitude, $5)
WHERE id = $1`,
r.ID, string(attrs), meta.TakenAt, meta.Latitude, meta.Longitude)
if err != nil {
continue
}
updated++
}
return updated, nil
}