604849e92c
With these changes the size and type of the RAW file as well as other details can be displayed in the Cards View. This also improves the indexing of camera and lens metadata. Signed-off-by: Michael Mayer <michael@photoprism.app>
395 lines
11 KiB
Go
395 lines
11 KiB
Go
package meta
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime/debug"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"gopkg.in/photoprism/go-tz.v2/tz"
|
|
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
|
"github.com/photoprism/photoprism/pkg/media"
|
|
"github.com/photoprism/photoprism/pkg/projection"
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
|
"github.com/photoprism/photoprism/pkg/txt"
|
|
"github.com/photoprism/photoprism/pkg/video"
|
|
)
|
|
|
|
const MimeVideoMP4 = "video/mp4"
|
|
const MimeQuicktime = "video/quicktime"
|
|
|
|
// Exiftool parses JSON sidecar data as created by Exiftool.
|
|
func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
|
|
defer func() {
|
|
if e := recover(); e != nil {
|
|
err = fmt.Errorf("metadata: %s (exiftool panic)\nstack: %s", e, debug.Stack())
|
|
}
|
|
}()
|
|
|
|
j := gjson.GetBytes(jsonData, "@flatten|@join")
|
|
|
|
logName := "json file"
|
|
|
|
if originalName != "" {
|
|
logName = clean.Log(filepath.Base(originalName))
|
|
}
|
|
|
|
if !j.IsObject() {
|
|
return fmt.Errorf("metadata: data is not an object in %s (exiftool)", logName)
|
|
}
|
|
|
|
data.json = make(map[string]string)
|
|
jsonValues := j.Map()
|
|
|
|
for key, val := range jsonValues {
|
|
data.json[key] = val.String()
|
|
}
|
|
|
|
if fileName, ok := data.json["FileName"]; ok && fileName != "" && originalName != "" && fileName != originalName {
|
|
return fmt.Errorf("metadata: original name %s does not match %s (exiftool)", clean.Log(originalName), clean.Log(fileName))
|
|
} else if fileName != "" && originalName == "" {
|
|
logName = clean.Log(filepath.Base(fileName))
|
|
}
|
|
|
|
v := reflect.ValueOf(data).Elem()
|
|
|
|
// Iterate through all config fields
|
|
for i := 0; i < v.NumField(); i++ {
|
|
fieldValue := v.Field(i)
|
|
|
|
tagData := v.Type().Field(i).Tag.Get("meta")
|
|
|
|
// Automatically assign values to fields with "flag" tag
|
|
if tagData != "" {
|
|
tagValues := strings.Split(tagData, ",")
|
|
|
|
var jsonValue gjson.Result
|
|
var tagValue string
|
|
|
|
for _, tagValue = range tagValues {
|
|
if r, ok := jsonValues[tagValue]; !ok {
|
|
continue
|
|
} else if txt.Empty(r.String()) {
|
|
continue
|
|
} else {
|
|
jsonValue = r
|
|
break
|
|
}
|
|
}
|
|
|
|
// Skip empty values.
|
|
if !jsonValue.Exists() {
|
|
continue
|
|
}
|
|
|
|
switch t := fieldValue.Interface().(type) {
|
|
case time.Time:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
if dateTime := txt.DateTime(jsonValue.String(), ""); !dateTime.IsZero() {
|
|
fieldValue.Set(reflect.ValueOf(dateTime))
|
|
}
|
|
case time.Duration:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
fieldValue.Set(reflect.ValueOf(Duration(jsonValue.String())))
|
|
case int, int64:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
if intVal := jsonValue.Int(); intVal != 0 {
|
|
fieldValue.SetInt(intVal)
|
|
} else if intVal = txt.Int64(jsonValue.String()); intVal != 0 {
|
|
fieldValue.SetInt(intVal)
|
|
}
|
|
case float32, float64:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
if f := jsonValue.Float(); f != 0 {
|
|
fieldValue.SetFloat(f)
|
|
} else if f = txt.Float(jsonValue.String()); f != 0 {
|
|
fieldValue.SetFloat(f)
|
|
}
|
|
case uint, uint64:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
if uintVal := jsonValue.Uint(); uintVal > 0 {
|
|
fieldValue.SetUint(uintVal)
|
|
} else if intVal := txt.Int64(jsonValue.String()); intVal > 0 {
|
|
fieldValue.SetUint(uint64(intVal))
|
|
}
|
|
case []string:
|
|
existing := fieldValue.Interface().([]string)
|
|
fieldValue.Set(reflect.ValueOf(txt.AddToWords(existing, SanitizeUnicode(jsonValue.String()))))
|
|
case Keywords:
|
|
existing := fieldValue.Interface().(Keywords)
|
|
fieldValue.Set(reflect.ValueOf(txt.AddToWords(existing, SanitizeUnicode(jsonValue.String()))))
|
|
case projection.Type:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
fieldValue.Set(reflect.ValueOf(projection.Type(SanitizeUnicode(jsonValue.String()))))
|
|
case string:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
fieldValue.SetString(SanitizeUnicode(jsonValue.String()))
|
|
case bool:
|
|
if !fieldValue.IsZero() {
|
|
continue
|
|
}
|
|
|
|
boolVal := false
|
|
strVal := jsonValue.String()
|
|
|
|
// Cast string to bool.
|
|
switch strVal {
|
|
case "1", "true":
|
|
boolVal = true
|
|
case "", "0", "false":
|
|
boolVal = false
|
|
default:
|
|
boolVal = txt.NotEmpty(strVal)
|
|
}
|
|
|
|
fieldValue.SetBool(boolVal)
|
|
default:
|
|
log.Warnf("metadata: cannot assign value of type %s to %s (exiftool)", t, tagValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nanoseconds.
|
|
if data.TakenNs <= 0 {
|
|
for _, name := range exifSubSecTags {
|
|
if s := data.json[name]; txt.IsPosInt(s) {
|
|
data.TakenNs = txt.Int(s + strings.Repeat("0", 9-len(s)))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set latitude and longitude if known and not already set.
|
|
if data.Lat == 0 && data.Lng == 0 {
|
|
if data.GPSPosition != "" {
|
|
lat, lng := GpsToLatLng(data.GPSPosition)
|
|
data.Lat, data.Lng = NormalizeGPS(lat, lng)
|
|
} else if data.GPSLatitude != "" && data.GPSLongitude != "" {
|
|
data.Lat, data.Lng = NormalizeGPS(GpsToDecimal(data.GPSLatitude), GpsToDecimal(data.GPSLongitude))
|
|
}
|
|
}
|
|
|
|
if data.Altitude == 0 {
|
|
// Parseable floating point number?
|
|
if fl := GpsFloatRegexp.FindAllString(data.json["GPSAltitude"], -1); len(fl) != 1 {
|
|
// Ignore.
|
|
} else if alt, err := strconv.ParseFloat(fl[0], 64); err == nil && alt != 0 {
|
|
data.Altitude = alt
|
|
}
|
|
}
|
|
|
|
hasTimeOffset := false
|
|
|
|
// Has Media Create Date?
|
|
if !data.CreatedAt.IsZero() {
|
|
data.TakenAt = data.CreatedAt
|
|
}
|
|
|
|
// Fallback to GPS UTC Time?
|
|
if data.TakenAt.IsZero() && data.TakenAtLocal.IsZero() && !data.TakenGps.IsZero() {
|
|
data.TimeZone = time.UTC.String()
|
|
data.TakenAt = data.TakenGps.UTC()
|
|
data.TakenAtLocal = time.Time{}
|
|
}
|
|
|
|
// Check plausibility of the local <> UTC time difference.
|
|
if !data.TakenAt.IsZero() && !data.TakenAtLocal.IsZero() {
|
|
if d := data.TakenAt.Sub(data.TakenAtLocal).Abs(); d > time.Hour*27 {
|
|
log.Infof("metadata: %s has an invalid local time offset (%s)", logName, d.String())
|
|
log.Debugf("metadata: %s was taken at %s, local time %s, create time %s, time zone %s", logName, clean.Log(data.TakenAt.UTC().String()), clean.Log(data.TakenAtLocal.String()), clean.Log(data.CreatedAt.String()), clean.Log(data.TimeZone))
|
|
data.TakenAtLocal = data.TakenAt
|
|
data.TakenAt = data.TakenAt.UTC()
|
|
}
|
|
}
|
|
|
|
// Has time zone offset?
|
|
if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() {
|
|
hasTimeOffset = true
|
|
} else if mt, ok := data.json["MIMEType"]; ok && data.TakenAtLocal.IsZero() && (mt == MimeVideoMP4 || mt == MimeQuicktime) {
|
|
// Assume default time zone for MP4 & Quicktime videos is UTC.
|
|
// see https://exiftool.org/TagNames/QuickTime.html
|
|
log.Debugf("metadata: %s uses utc by default (%s)", logName, clean.Log(mt))
|
|
data.TimeZone = time.UTC.String()
|
|
data.TakenAt = data.TakenAt.UTC()
|
|
data.TakenAtLocal = time.Time{}
|
|
}
|
|
|
|
// Set time zone and calculate UTC time.
|
|
if data.Lat != 0 && data.Lng != 0 {
|
|
zones, err := tz.GetZone(tz.Point{
|
|
Lat: float64(data.Lat),
|
|
Lon: float64(data.Lng),
|
|
})
|
|
|
|
if err == nil && len(zones) > 0 {
|
|
data.TimeZone = zones[0]
|
|
}
|
|
|
|
if loc, err := time.LoadLocation(data.TimeZone); err != nil {
|
|
log.Warnf("metadata: unknown time zone %s (exiftool)", data.TimeZone)
|
|
} else if !data.TakenAtLocal.IsZero() {
|
|
if tl, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); err == nil {
|
|
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); err == nil {
|
|
data.TakenAtLocal = localUtc
|
|
}
|
|
|
|
data.TakenAt = tl.Truncate(time.Second).UTC()
|
|
} else {
|
|
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
|
|
}
|
|
} else if !data.TakenAt.IsZero() {
|
|
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); err == nil {
|
|
data.TakenAtLocal = localUtc
|
|
data.TakenAt = data.TakenAt.UTC()
|
|
} else {
|
|
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
|
|
}
|
|
}
|
|
} else if hasTimeOffset {
|
|
if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); err == nil {
|
|
data.TakenAtLocal = localUtc
|
|
}
|
|
|
|
data.TakenAt = data.TakenAt.Truncate(time.Second).UTC()
|
|
}
|
|
|
|
// Set local time if still empty.
|
|
if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() {
|
|
if loc, err := time.LoadLocation(data.TimeZone); data.TimeZone == "" || err != nil {
|
|
data.TakenAtLocal = data.TakenAt
|
|
} else if localUtc, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); err == nil {
|
|
data.TakenAtLocal = localUtc
|
|
data.TakenAt = data.TakenAt.UTC()
|
|
} else {
|
|
log.Errorf("metadata: %s (exiftool)", err.Error()) // this should never happen
|
|
}
|
|
}
|
|
|
|
// Add nanoseconds to the calculated UTC and local time.
|
|
if data.TakenAt.Nanosecond() == 0 {
|
|
if ns := time.Duration(data.TakenNs); ns > 0 && ns <= time.Second {
|
|
data.TakenAt.Truncate(time.Second).UTC().Add(ns)
|
|
data.TakenAtLocal.Truncate(time.Second).Add(ns)
|
|
}
|
|
}
|
|
|
|
// Use actual image width and height if available, see issue #2447.
|
|
if jsonValues["ImageWidth"].Exists() && jsonValues["ImageHeight"].Exists() {
|
|
if val := jsonValues["ImageWidth"].Int(); val > 0 {
|
|
data.Width = int(val)
|
|
}
|
|
|
|
if val := jsonValues["ImageHeight"].Int(); val > 0 {
|
|
data.Height = int(val)
|
|
}
|
|
}
|
|
|
|
// Image orientation, see https://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/.
|
|
if orientation, ok := data.json["Orientation"]; ok && orientation != "" {
|
|
switch orientation {
|
|
case "1", "Horizontal (normal)":
|
|
data.Orientation = 1
|
|
case "2":
|
|
data.Orientation = 2
|
|
case "3", "Rotate 180 CW":
|
|
data.Orientation = 3
|
|
case "4":
|
|
data.Orientation = 4
|
|
case "5":
|
|
data.Orientation = 5
|
|
case "6", "Rotate 90 CW":
|
|
data.Orientation = 6
|
|
case "7":
|
|
data.Orientation = 7
|
|
case "8", "Rotate 270 CW":
|
|
data.Orientation = 8
|
|
}
|
|
}
|
|
|
|
if data.Orientation == 0 {
|
|
// Set orientation based on rotation.
|
|
switch data.Rotation {
|
|
case 0:
|
|
data.Orientation = 1
|
|
case -180, 180:
|
|
data.Orientation = 3
|
|
case 90:
|
|
data.Orientation = 6
|
|
case -90, 270:
|
|
data.Orientation = 8
|
|
}
|
|
}
|
|
|
|
// Normalize codec name.
|
|
data.Codec = strings.ToLower(data.Codec)
|
|
if strings.Contains(data.Codec, CodecJpeg) { // JPEG Image?
|
|
data.Codec = CodecJpeg
|
|
} else if c, ok := video.Codecs[data.Codec]; ok { // Video codec?
|
|
data.Codec = string(c)
|
|
} else if strings.HasPrefix(data.Codec, "a_") { // Audio codec?
|
|
data.Codec = ""
|
|
}
|
|
|
|
// Validate and normalize optional DocumentID.
|
|
if data.DocumentID != "" {
|
|
data.DocumentID = rnd.SanitizeUUID(data.DocumentID)
|
|
}
|
|
|
|
// Validate and normalize optional InstanceID.
|
|
if data.InstanceID != "" {
|
|
data.InstanceID = rnd.SanitizeUUID(data.InstanceID)
|
|
}
|
|
|
|
if projection.Equirectangular.Equal(data.Projection) {
|
|
data.AddKeywords(KeywordPanorama)
|
|
}
|
|
|
|
if data.Description != "" {
|
|
data.AutoAddKeywords(data.Description)
|
|
data.Description = SanitizeDescription(data.Description)
|
|
}
|
|
|
|
data.Title = SanitizeTitle(data.Title)
|
|
data.Subject = SanitizeMeta(data.Subject)
|
|
data.Artist = SanitizeMeta(data.Artist)
|
|
|
|
// Ignore numeric model names as they are probably invalid.
|
|
if txt.IsUInt(data.LensModel) {
|
|
data.LensModel = ""
|
|
}
|
|
|
|
// Flag Samsung/Google Motion Photos as live media.
|
|
if data.EmbeddedVideo && (data.MimeType == fs.MimeTypeJPEG || data.MimeType == fs.MimeTypeHEIC) {
|
|
data.MediaType = media.Live
|
|
}
|
|
|
|
return nil
|
|
}
|