package meta import ( "fmt" "math" "path/filepath" "runtime/debug" "strconv" "strings" "sync" "time" "github.com/dsoprea/go-exif/v3" "gopkg.in/photoprism/go-tz.v2/tz" exifcommon "github.com/dsoprea/go-exif/v3/common" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/projection" "github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/txt" ) var exifIfdMapping *exifcommon.IfdMapping var exifTagIndex = exif.NewTagIndex() var exifMutex = sync.Mutex{} var exifDateTimeTags = []string{"DateTimeOriginal", "DateTimeCreated", "CreateDate", "DateTime", "DateTimeDigitized"} var exifSubSecTags = []string{"SubSecTimeOriginal", "SubSecTime", "SubSecTimeDigitized"} func init() { exifIfdMapping = exifcommon.NewIfdMapping() if err := exifcommon.LoadStandardIfds(exifIfdMapping); err != nil { log.Errorf("metadata: %s", err.Error()) } } // Exif parses an image file for Exif metadata and returns as Data struct. func Exif(fileName string, fileType fs.Type, bruteForce bool) (data Data, err error) { err = data.Exif(fileName, fileType, bruteForce) return data, err } // Exif parses an image file for Exif metadata and returns as Data struct. func (data *Data) Exif(fileName string, fileFormat fs.Type, bruteForce bool) (err error) { exifMutex.Lock() defer exifMutex.Unlock() defer func() { if e := recover(); e != nil { err = fmt.Errorf("metadata: %s in %s (exif panic)\nstack: %s", e, clean.Log(filepath.Base(fileName)), debug.Stack()) } }() // Resolve file name e.g. in case it's a symlink. if fileName, err = fs.Resolve(fileName); err != nil { return err } // Extract raw Exif block. rawExif, err := RawExif(fileName, fileFormat, bruteForce) if err != nil { return err } logName := clean.Log(filepath.Base(fileName)) // Enumerate data.exif in Exif block. opt := exif.ScanOptions{} entries, _, err := exif.GetFlatExifData(rawExif, &opt) // Create large enough map for values. if data.exif == nil { data.exif = make(map[string]string, len(entries)) } // Ignore IFD1 data.exif with existing IFD0 values. // see https://github.com/photoprism/photoprism/issues/2231 for _, tag := range entries { s := strings.Split(tag.FormattedFirst, "\x00") if tag.TagName == "" || len(s) == 0 { // Do nothing. } else if s[0] != "" && (data.exif[tag.TagName] == "" || tag.IfdPath != exif.ThumbnailFqIfdPath) { data.exif[tag.TagName] = s[0] } } // Abort if no values were found. if len(data.exif) == 0 { return fmt.Errorf("metadata: no exif data in %s", logName) } var ifdIndex exif.IfdIndex _, ifdIndex, err = exif.Collect(exifIfdMapping, exifTagIndex, rawExif) // Find and parse GPS coordinates. if err != nil { log.Debugf("metadata: %s in %s (exif collect)", err, logName) } else { var ifd *exif.Ifd if ifd, err = ifdIndex.RootIfd.ChildWithIfdPath(exifcommon.IfdGpsInfoStandardIfdIdentity); err == nil { var gi *exif.GpsInfo if gi, err = ifd.GpsInfo(); err != nil { log.Debugf("metadata: %s in %s (exif gps-info)", err, logName) } else { if !math.IsNaN(gi.Latitude.Decimal()) && !math.IsNaN(gi.Longitude.Decimal()) { data.Lat, data.Lng = NormalizeGPS(gi.Latitude.Decimal(), gi.Longitude.Decimal()) } else if gi.Altitude != 0 || !gi.Timestamp.IsZero() { log.Warnf("metadata: invalid exif gps coordinates in %s (%s)", logName, clean.Log(gi.String())) } if gi.Altitude != 0 { data.Altitude = float64(gi.Altitude) } if !gi.Timestamp.IsZero() { data.TakenGps = gi.Timestamp } } } } if value, ok := data.exif["Artist"]; ok { data.Artist = SanitizeString(value) } if value, ok := data.exif["Copyright"]; ok { data.Copyright = SanitizeString(value) } if value, ok := data.exif["Model"]; ok { data.CameraModel = SanitizeString(value) } else if value, ok := data.exif["CameraModel"]; ok { data.CameraModel = SanitizeString(value) } if value, ok := data.exif["Make"]; ok { data.CameraMake = SanitizeString(value) } else if value, ok := data.exif["CameraMake"]; ok { data.CameraMake = SanitizeString(value) } if value, ok := data.exif["CameraOwnerName"]; ok { data.CameraOwner = SanitizeString(value) } if value, ok := data.exif["BodySerialNumber"]; ok { data.CameraSerial = SanitizeString(value) } if value, ok := data.exif["LensMake"]; ok { data.LensMake = SanitizeString(value) } if value, ok := data.exif["LensModel"]; ok { data.LensModel = SanitizeString(value) } if value, ok := data.exif["Software"]; ok { data.Software = SanitizeString(value) } if value, ok := data.exif["ExposureTime"]; ok { 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 { value = fmt.Sprintf("1/%d", n1/n0) } } } data.Exposure = value } if value, ok := data.exif["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) data.FNumber = float32(math.Round((number/denom)*1000) / 1000) } } if value, ok := data.exif["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) data.Aperture = float32(math.Round((number/denom)*1000) / 1000) } } if value, ok := data.exif["FocalLengthIn35mmFilm"]; ok { if i, err := strconv.Atoi(value); err == nil { data.FocalLength = i } } else if v, ok := data.exif["FocalLength"]; ok { values := strings.Split(v, "/") 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 := data.exif["ISOSpeedRatings"]; ok { if i, err := strconv.Atoi(value); err == nil { data.Iso = i } } if value, ok := data.exif["ImageUniqueID"]; ok { if id := rnd.SanitizeUUID(value); id != "" { data.DocumentID = id } } if value, ok := data.exif["PixelXDimension"]; ok { if i, err := strconv.Atoi(value); err == nil { data.Width = i } } else if value, ok := data.exif["ImageWidth"]; ok { if i, err := strconv.Atoi(value); err == nil { data.Width = i } } if value, ok := data.exif["PixelYDimension"]; ok { if i, err := strconv.Atoi(value); err == nil { data.Height = i } } else if value, ok := data.exif["ImageLength"]; ok { if i, err := strconv.Atoi(value); err == nil { data.Height = i } } if value, ok := data.exif["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{ Lat: float64(data.Lat), Lon: float64(data.Lng), }) if err == nil && len(zones) > 0 { data.TimeZone = zones[0] } } takenAt := time.Time{} for _, name := range exifDateTimeTags { if dateTime := txt.DateTime(data.exif[name], data.TimeZone); !dateTime.IsZero() { takenAt = dateTime break } } // Fallback to GPS timestamp. if takenAt.IsZero() && !data.TakenGps.IsZero() { takenAt = data.TakenGps.UTC() } // Nanoseconds. if data.TakenNs <= 0 { for _, name := range exifSubSecTags { if s := data.exif[name]; txt.IsPosInt(s) { data.TakenNs = txt.Int(s + strings.Repeat("0", 9-len(s))) break } } } // UniqueID time found in Exif metadata? if !takenAt.IsZero() { if takenAtLocal, err := time.ParseInLocation("2006-01-02T15:04:05", takenAt.Format("2006-01-02T15:04:05"), time.UTC); err == nil { data.TakenAtLocal = takenAtLocal } else { data.TakenAtLocal = takenAt } data.TakenAt = takenAt.UTC() } // 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) } } if value, ok := data.exif["Flash"]; ok { if i, err := strconv.Atoi(value); err == nil && i&1 == 1 { data.AddKeywords(KeywordFlash) data.Flash = true } } if value, ok := data.exif["ImageDescription"]; ok { data.AutoAddKeywords(value) data.Description = SanitizeDescription(value) } if value, ok := data.exif["ProjectionType"]; ok { data.AddKeywords(KeywordPanorama) data.Projection = projection.New(SanitizeString(value)).String() } data.Subject = SanitizeMeta(data.Subject) data.Artist = SanitizeMeta(data.Artist) return nil }