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
This commit is contained in:
@@ -2,12 +2,16 @@ package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
exif "github.com/dsoprea/go-exif/v3"
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
)
|
||||
|
||||
type ImageMeta struct {
|
||||
@@ -29,23 +33,90 @@ func inspect(data []byte) (width, height int, ok bool) {
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
|
||||
x, err := exif.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
type exifInfo struct {
|
||||
TakenAt *time.Time
|
||||
Latitude *float64
|
||||
Longitude *float64
|
||||
Attributes map[string]any
|
||||
}
|
||||
|
||||
func collectTags(ifd *exif.Ifd, dst map[string]any) {
|
||||
if ifd == nil {
|
||||
return
|
||||
}
|
||||
if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
|
||||
if s, err := dt.StringVal(); err == nil {
|
||||
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
|
||||
utc := t.UTC()
|
||||
taken = &utc
|
||||
}
|
||||
for _, ite := range ifd.Entries() {
|
||||
name := ite.TagName()
|
||||
if name == "" || strings.HasPrefix(name, "UnknownTag") {
|
||||
continue
|
||||
}
|
||||
if _, exists := dst[name]; exists {
|
||||
continue
|
||||
}
|
||||
s, err := ite.FormatFirst()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s = strings.TrimSpace(strings.TrimRight(s, "\x00"))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
dst[name] = normalizeExifValue(s)
|
||||
}
|
||||
for _, child := range ifd.Children() {
|
||||
collectTags(child, dst)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExifValue(s string) any {
|
||||
base := strings.TrimSuffix(s, "...")
|
||||
if i := strings.IndexByte(base, '/'); i > 0 {
|
||||
num, err1 := strconv.ParseInt(strings.TrimSpace(base[:i]), 10, 64)
|
||||
den, err2 := strconv.ParseInt(strings.TrimSpace(base[i+1:]), 10, 64)
|
||||
if err1 == nil && err2 == nil && den != 0 {
|
||||
return float64(num) / float64(den)
|
||||
}
|
||||
}
|
||||
if la, lo, err := x.LatLong(); err == nil {
|
||||
lat, lon = &la, &lo
|
||||
if n, err := strconv.ParseInt(base, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
return taken, lat, lon
|
||||
if f, err := strconv.ParseFloat(base, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func extractEXIF(data []byte) exifInfo {
|
||||
out := exifInfo{Attributes: map[string]any{}}
|
||||
raw, err := exif.SearchAndExtractExif(data)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
im, err := exifcommon.NewIfdMappingWithStandard()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
ti := exif.NewTagIndex()
|
||||
_, index, err := exif.Collect(im, ti, raw)
|
||||
if err != nil || index.RootIfd == nil {
|
||||
return out
|
||||
}
|
||||
collectTags(index.RootIfd, out.Attributes)
|
||||
if v, ok := out.Attributes["DateTimeOriginal"]; ok {
|
||||
if t, err := time.Parse("2006:01:02 15:04:05", fmt.Sprintf("%v", v)); err == nil {
|
||||
utc := t.UTC()
|
||||
out.TakenAt = &utc
|
||||
}
|
||||
}
|
||||
if gi, err := index.RootIfd.GpsInfo(); err == nil {
|
||||
lat := gi.Latitude.Decimal()
|
||||
lon := gi.Longitude.Decimal()
|
||||
out.Latitude, out.Longitude = &lat, &lon
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ExtractEXIFAttributes(data []byte) map[string]any {
|
||||
return extractEXIF(data).Attributes
|
||||
}
|
||||
|
||||
func buildVariants(data []byte) (thumb, preview []byte, err error) {
|
||||
|
||||
@@ -62,7 +62,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
||||
if !ok {
|
||||
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
||||
}
|
||||
taken, lat, lon := extractEXIF(in.Data)
|
||||
meta := extractEXIF(in.Data)
|
||||
|
||||
id := auth.NewID()
|
||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
||||
@@ -85,9 +85,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
||||
Width: width,
|
||||
Height: height,
|
||||
MimeType: mime,
|
||||
TakenAt: taken,
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
TakenAt: meta.TakenAt,
|
||||
Latitude: meta.Latitude,
|
||||
Longitude: meta.Longitude,
|
||||
EXIF: meta.Attributes,
|
||||
}
|
||||
if err := s.store.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,6 +2,7 @@ package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
@@ -26,33 +27,50 @@ const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
||||
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)`
|
||||
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)
|
||||
&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)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
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,
|
||||
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
|
||||
).Scan(&p.CreatedAt)
|
||||
}
|
||||
|
||||
@@ -98,14 +116,16 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
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); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -138,3 +158,59 @@ func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user