Move physical file facts out of the photos table into an objects ledger:
- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix
Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.
OpenSpec change: media-object-layer
149 lines
3.4 KiB
Go
149 lines
3.4 KiB
Go
package photos
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"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"
|
|
)
|
|
|
|
type ImageMeta struct {
|
|
Width int
|
|
Height int
|
|
MimeType string
|
|
TakenAt *time.Time
|
|
Latitude *float64
|
|
Longitude *float64
|
|
Thumb []byte
|
|
Preview []byte
|
|
}
|
|
|
|
func inspect(data []byte) (width, height int, ok bool) {
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
return cfg.Width, cfg.Height, true
|
|
}
|
|
|
|
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
|
|
}
|
|
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 n, err := strconv.ParseInt(base, 10, 64); err == nil {
|
|
return n
|
|
}
|
|
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) {
|
|
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
|
|
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
|
|
|
|
opts := webp.Options{Quality: 82}
|
|
var thumbBuf bytes.Buffer
|
|
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var previewBuf bytes.Buffer
|
|
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
|
|
}
|
|
|
|
// checksum returns the hex-encoded SHA-256 of the given bytes.
|
|
func checksum(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|