2020-01-07 17:36:49 +01:00
|
|
|
package meta
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"math"
|
2020-05-15 15:29:56 +02:00
|
|
|
"path/filepath"
|
2020-07-13 10:41:45 +02:00
|
|
|
"runtime/debug"
|
2020-01-07 17:36:49 +01:00
|
|
|
"strconv"
|
|
|
|
"strings"
|
2020-11-21 23:43:53 +01:00
|
|
|
"sync"
|
2020-01-07 17:36:49 +01:00
|
|
|
"time"
|
|
|
|
|
2020-07-17 09:41:37 +02:00
|
|
|
"github.com/dsoprea/go-exif/v3"
|
|
|
|
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
2020-07-19 16:39:43 +02:00
|
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
2020-07-23 15:34:20 +02:00
|
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
2020-05-15 15:29:56 +02:00
|
|
|
"github.com/photoprism/photoprism/pkg/txt"
|
2020-01-07 17:36:49 +01:00
|
|
|
"gopkg.in/ugjka/go-tz.v2/tz"
|
|
|
|
)
|
|
|
|
|
2020-07-16 20:32:45 +02:00
|
|
|
var exifIfdMapping *exifcommon.IfdMapping
|
|
|
|
var exifTagIndex = exif.NewTagIndex()
|
2020-11-21 23:43:53 +01:00
|
|
|
var exifMutex = sync.Mutex{}
|
2020-07-17 12:47:12 +02:00
|
|
|
|
2020-05-15 11:15:15 +02:00
|
|
|
const DateTimeZero = "0000:00:00 00:00:00"
|
|
|
|
|
2020-07-16 20:32:45 +02:00
|
|
|
func init() {
|
2020-07-17 09:41:37 +02:00
|
|
|
exifIfdMapping = exifcommon.NewIfdMapping()
|
2020-07-16 20:32:45 +02:00
|
|
|
|
2020-07-17 09:41:37 +02:00
|
|
|
if err := exifcommon.LoadStandardIfds(exifIfdMapping); err != nil {
|
2020-07-16 20:32:45 +02:00
|
|
|
log.Errorf("metadata: %s", err.Error())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-15 11:15:15 +02:00
|
|
|
// ValidDateTime returns true if a date string looks valid and is not zero.
|
|
|
|
func ValidDateTime(s string) bool {
|
2020-05-15 15:29:56 +02:00
|
|
|
return len(s) == len(DateTimeZero) && s != DateTimeZero
|
2020-05-15 11:15:15 +02:00
|
|
|
}
|
|
|
|
|
2020-01-07 18:13:53 +01:00
|
|
|
// Exif parses an image file for Exif meta data and returns as Data struct.
|
2020-12-12 17:20:31 +01:00
|
|
|
func Exif(fileName string, fileType fs.FileFormat) (data Data, err error) {
|
2020-07-19 16:39:43 +02:00
|
|
|
err = data.Exif(fileName, fileType)
|
2020-05-13 20:53:15 +02:00
|
|
|
|
|
|
|
return data, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Exif parses an image file for Exif meta data and returns as Data struct.
|
2020-12-12 17:20:31 +01:00
|
|
|
func (data *Data) Exif(fileName string, fileType fs.FileFormat) (err error) {
|
2020-11-21 23:43:53 +01:00
|
|
|
exifMutex.Lock()
|
|
|
|
defer exifMutex.Unlock()
|
|
|
|
|
2020-01-07 17:36:49 +01:00
|
|
|
defer func() {
|
|
|
|
if e := recover(); e != nil {
|
2020-07-13 10:41:45 +02:00
|
|
|
err = fmt.Errorf("metadata: %s in %s (exif panic)\nstack: %s", e, txt.Quote(filepath.Base(fileName)), debug.Stack())
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2020-01-14 01:47:26 +01:00
|
|
|
// Extract raw EXIF block.
|
2020-08-28 09:27:25 +02:00
|
|
|
rawExif, err := RawExif(fileName, fileType)
|
2020-01-14 01:47:26 +01:00
|
|
|
|
2020-08-28 09:27:25 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
2020-05-15 11:15:15 +02:00
|
|
|
}
|
2020-01-14 01:47:26 +01:00
|
|
|
|
2020-08-28 09:27:25 +02:00
|
|
|
logName := txt.Quote(filepath.Base(fileName))
|
2020-01-07 17:36:49 +01:00
|
|
|
|
2020-05-13 20:53:15 +02:00
|
|
|
if data.All == nil {
|
|
|
|
data.All = make(map[string]string)
|
|
|
|
}
|
2020-01-07 17:36:49 +01:00
|
|
|
|
2020-07-16 20:32:45 +02:00
|
|
|
// Enumerate tags in EXIF block.
|
2020-07-17 09:41:37 +02:00
|
|
|
opt := exif.ScanOptions{}
|
|
|
|
entries, _, err := exif.GetFlatExifData(rawExif, &opt)
|
2020-01-07 17:36:49 +01:00
|
|
|
|
2020-07-16 20:32:45 +02:00
|
|
|
for _, entry := range entries {
|
|
|
|
if entry.TagName != "" && entry.Formatted != "" {
|
|
|
|
data.All[entry.TagName] = strings.Split(entry.FormattedFirst, "\x00")[0]
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
2020-07-16 20:32:45 +02:00
|
|
|
}
|
2020-01-07 17:36:49 +01:00
|
|
|
|
2020-07-16 20:32:45 +02:00
|
|
|
tags := data.All
|
2020-01-07 17:36:49 +01:00
|
|
|
|
2020-07-16 20:32:45 +02:00
|
|
|
_, index, err := exif.Collect(exifIfdMapping, exifTagIndex, rawExif)
|
2020-01-07 17:36:49 +01:00
|
|
|
|
2020-07-16 20:32:45 +02:00
|
|
|
if err != nil {
|
2020-07-20 23:29:14 +02:00
|
|
|
log.Debugf("metadata: %s in %s (exif collect)", err.Error(), logName)
|
2020-07-16 20:32:45 +02:00
|
|
|
} else {
|
2020-07-17 09:41:37 +02:00
|
|
|
if ifd, err := index.RootIfd.ChildWithIfdPath(exifcommon.IfdGpsInfoStandardIfdIdentity); err == nil {
|
2020-12-23 18:43:52 +01:00
|
|
|
if gi, err := ifd.GpsInfo(); err != nil {
|
|
|
|
log.Debugf("exif: %s in %s (gps info)", err, logName)
|
|
|
|
log.Infof("metadata: failed parsing gps coordinates in %s (exif)", logName)
|
|
|
|
} else if math.IsNaN(gi.Latitude.Decimal()) || math.IsNaN(gi.Longitude.Decimal()) {
|
|
|
|
log.Warnf("metadata: invalid gps coordinates in %s (exif)", logName)
|
|
|
|
} else {
|
2020-07-16 20:32:45 +02:00
|
|
|
data.Lat = float32(gi.Latitude.Decimal())
|
|
|
|
data.Lng = float32(gi.Longitude.Decimal())
|
|
|
|
data.Altitude = gi.Altitude
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["Artist"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.Artist = SanitizeString(value)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["Copyright"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.Copyright = SanitizeString(value)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["Model"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.CameraModel = SanitizeString(value)
|
2020-05-15 11:15:15 +02:00
|
|
|
} else if value, ok := tags["CameraModel"]; ok {
|
|
|
|
data.CameraModel = SanitizeString(value)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["Make"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.CameraMake = SanitizeString(value)
|
2020-05-15 11:15:15 +02:00
|
|
|
} else if value, ok := tags["CameraMake"]; ok {
|
|
|
|
data.CameraMake = SanitizeString(value)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
2020-02-07 14:40:06 +01:00
|
|
|
if value, ok := tags["CameraOwnerName"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.CameraOwner = SanitizeString(value)
|
2020-02-07 14:40:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["BodySerialNumber"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.CameraSerial = SanitizeString(value)
|
2020-02-07 14:40:06 +01:00
|
|
|
}
|
|
|
|
|
2020-01-07 17:36:49 +01:00
|
|
|
if value, ok := tags["LensMake"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.LensMake = SanitizeString(value)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["LensModel"]; ok {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.LensModel = SanitizeString(value)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["ExposureTime"]; ok {
|
2020-04-26 12:17:49 +02:00
|
|
|
if n := strings.Split(value, "/"); len(n) == 2 {
|
|
|
|
if n[0] != "1" && len(n[0]) < len(n[1]) {
|
|
|
|
n0, _ := strconv.ParseUint(n[0], 10, 64)
|
|
|
|
if n1, err := strconv.ParseUint(n[1], 10, 64); err == nil && n0 > 0 && n1 > 0 {
|
2020-04-26 14:31:33 +02:00
|
|
|
value = fmt.Sprintf("1/%d", n1/n0)
|
2020-04-26 12:17:49 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-07 17:36:49 +01:00
|
|
|
data.Exposure = value
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["FNumber"]; ok {
|
|
|
|
values := strings.Split(value, "/")
|
|
|
|
|
|
|
|
if len(values) == 2 && values[1] != "0" && values[1] != "" {
|
|
|
|
number, _ := strconv.ParseFloat(values[0], 64)
|
|
|
|
denom, _ := strconv.ParseFloat(values[1], 64)
|
|
|
|
|
2020-04-26 11:41:54 +02:00
|
|
|
data.FNumber = float32(math.Round((number/denom)*1000) / 1000)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["ApertureValue"]; ok {
|
|
|
|
values := strings.Split(value, "/")
|
|
|
|
|
|
|
|
if len(values) == 2 && values[1] != "0" && values[1] != "" {
|
|
|
|
number, _ := strconv.ParseFloat(values[0], 64)
|
|
|
|
denom, _ := strconv.ParseFloat(values[1], 64)
|
|
|
|
|
2020-04-26 11:41:54 +02:00
|
|
|
data.Aperture = float32(math.Round((number/denom)*1000) / 1000)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["FocalLengthIn35mmFilm"]; ok {
|
|
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
data.FocalLength = i
|
|
|
|
}
|
|
|
|
} else if value, ok := tags["FocalLength"]; ok {
|
|
|
|
values := strings.Split(value, "/")
|
|
|
|
|
|
|
|
if len(values) == 2 && values[1] != "0" && values[1] != "" {
|
|
|
|
number, _ := strconv.ParseFloat(values[0], 64)
|
|
|
|
denom, _ := strconv.ParseFloat(values[1], 64)
|
|
|
|
|
|
|
|
data.FocalLength = int(math.Round((number/denom)*1000) / 1000)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["ISOSpeedRatings"]; ok {
|
|
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
data.Iso = i
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["ImageUniqueID"]; ok {
|
2020-07-23 15:34:20 +02:00
|
|
|
if id := rnd.SanitizeUUID(value); id != "" {
|
|
|
|
data.DocumentID = id
|
|
|
|
}
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
2020-05-18 17:38:14 +02:00
|
|
|
if value, ok := tags["PixelXDimension"]; ok {
|
2020-01-07 17:36:49 +01:00
|
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
data.Width = i
|
|
|
|
}
|
2020-05-18 17:38:14 +02:00
|
|
|
} else if value, ok := tags["ImageWidth"]; ok {
|
2020-02-07 14:40:06 +01:00
|
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
data.Width = i
|
|
|
|
}
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
2020-05-18 17:38:14 +02:00
|
|
|
if value, ok := tags["PixelYDimension"]; ok {
|
2020-01-07 17:36:49 +01:00
|
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
data.Height = i
|
|
|
|
}
|
2020-05-18 17:38:14 +02:00
|
|
|
} else if value, ok := tags["ImageLength"]; ok {
|
2020-02-07 14:40:06 +01:00
|
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
data.Height = i
|
|
|
|
}
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["Orientation"]; ok {
|
|
|
|
if i, err := strconv.Atoi(value); err == nil {
|
|
|
|
data.Orientation = i
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
data.Orientation = 1
|
|
|
|
}
|
|
|
|
|
|
|
|
if data.Lat != 0 && data.Lng != 0 {
|
|
|
|
zones, err := tz.GetZone(tz.Point{
|
2020-04-26 11:41:54 +02:00
|
|
|
Lat: float64(data.Lat),
|
|
|
|
Lon: float64(data.Lng),
|
2020-01-07 17:36:49 +01:00
|
|
|
})
|
|
|
|
|
2020-04-29 16:12:09 +02:00
|
|
|
if err == nil && len(zones) > 0 {
|
|
|
|
data.TimeZone = zones[0]
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-15 11:15:15 +02:00
|
|
|
var takenAt string
|
|
|
|
|
|
|
|
if value, ok := tags["DateTimeOriginal"]; ok && ValidDateTime(value) {
|
|
|
|
takenAt = value
|
2020-05-16 09:27:21 +02:00
|
|
|
} else if value, ok := tags["DateTimeDigitized"]; ok && ValidDateTime(value) {
|
|
|
|
takenAt = value
|
2020-05-15 11:15:15 +02:00
|
|
|
} else if value, ok := tags["CreateDate"]; ok && ValidDateTime(value) {
|
|
|
|
takenAt = value
|
|
|
|
} else if value, ok := tags["DateTime"]; ok && ValidDateTime(value) {
|
|
|
|
takenAt = value
|
|
|
|
}
|
|
|
|
|
|
|
|
if ValidDateTime(takenAt) {
|
2020-07-07 17:11:36 +02:00
|
|
|
takenAt = strings.ReplaceAll(takenAt, "/", ":")
|
|
|
|
takenAt = strings.ReplaceAll(takenAt, "-", ":")
|
|
|
|
|
2020-05-15 11:15:15 +02:00
|
|
|
if taken, err := time.Parse("2006:01:02 15:04:05", takenAt); err == nil {
|
2020-04-29 16:12:09 +02:00
|
|
|
data.TakenAtLocal = taken.Round(time.Second)
|
2020-04-29 16:42:06 +02:00
|
|
|
data.TakenAt = data.TakenAtLocal
|
2020-04-29 16:12:09 +02:00
|
|
|
|
2020-04-29 16:42:06 +02:00
|
|
|
if loc, err := time.LoadLocation(data.TimeZone); err != nil {
|
2020-07-02 09:44:45 +02:00
|
|
|
log.Warnf("metadata: unknown time zone %s in %s (exif)", data.TimeZone, logName)
|
2020-05-15 11:15:15 +02:00
|
|
|
} else if tl, err := time.ParseInLocation("2006:01:02 15:04:05", takenAt, loc); err == nil {
|
2020-04-29 16:42:06 +02:00
|
|
|
data.TakenAt = tl.Round(time.Second).UTC()
|
2020-04-29 16:12:09 +02:00
|
|
|
} else {
|
2020-07-14 11:00:49 +02:00
|
|
|
log.Errorf("metadata: %s in %s (exif time)", err.Error(), logName) // this should never happen
|
2020-04-29 16:12:09 +02:00
|
|
|
}
|
2020-01-07 17:36:49 +01:00
|
|
|
} else {
|
2020-07-02 09:44:45 +02:00
|
|
|
log.Warnf("metadata: invalid time %s in %s (exif)", takenAt, logName)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["Flash"]; ok {
|
|
|
|
if i, err := strconv.Atoi(value); err == nil && i&1 == 1 {
|
2020-07-13 14:20:43 +02:00
|
|
|
data.AddKeyword(KeywordFlash)
|
2020-01-07 17:36:49 +01:00
|
|
|
data.Flash = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if value, ok := tags["ImageDescription"]; ok {
|
2020-07-13 14:20:43 +02:00
|
|
|
data.AutoAddKeywords(value)
|
2020-06-27 14:15:25 +02:00
|
|
|
data.Description = SanitizeDescription(value)
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|
|
|
|
|
2020-07-16 13:02:48 +02:00
|
|
|
if value, ok := tags["ProjectionType"]; ok {
|
|
|
|
data.AddKeyword(KeywordPanorama)
|
|
|
|
data.Projection = SanitizeString(value)
|
|
|
|
}
|
|
|
|
|
2020-10-19 11:50:54 +02:00
|
|
|
data.Keywords = SanitizeMeta(data.Keywords)
|
|
|
|
data.Subject = SanitizeMeta(data.Subject)
|
|
|
|
data.Artist = SanitizeMeta(data.Artist)
|
|
|
|
|
2020-01-07 17:36:49 +01:00
|
|
|
data.All = tags
|
|
|
|
|
2020-05-13 20:53:15 +02:00
|
|
|
return nil
|
2020-01-07 17:36:49 +01:00
|
|
|
}
|