From 1f9f6ebf9aa21bd2d37ebff07eb5a517928be4aa Mon Sep 17 00:00:00 2001
From: Kjeldgaard <Kjeldgaard@users.noreply.github.com>
Date: Sat, 20 Mar 2021 00:26:05 +0100
Subject: [PATCH] Use exiftool for parsing exif data

Use exiftool to parse exif metadata.

Signed-off-by: Kjeldgaard <Kjeldgaard@users.noreply.github.com>
---
 Dockerfile                                |   2 +-
 api/go.mod                                |   1 +
 api/go.sum                                |   2 +
 api/graphql/generated.go                  |  27 ++-
 api/graphql/models/media_exif.go          |   6 +-
 api/graphql/schema.graphql                |   4 +-
 api/scanner/exif/exif.go                  |   2 +-
 api/scanner/exif/exif_parser_external.go  | 206 ++++++++++++++++++++++
 api/scanner/exif/exif_parser_internal.go  | 188 --------------------
 ui/src/components/sidebar/MediaSidebar.js |  14 +-
 10 files changed, 238 insertions(+), 214 deletions(-)
 create mode 100644 api/scanner/exif/exif_parser_external.go
 delete mode 100644 api/scanner/exif/exif_parser_internal.go

diff --git a/Dockerfile b/Dockerfile
index cb0d2a5..2d6c3f8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -73,7 +73,7 @@ COPY api/data /app/data
 
 RUN apt-get update \
   # Required dependencies
-  && apt-get install -y curl gpg libdlib19 ffmpeg
+  && apt-get install -y curl gpg libdlib19 ffmpeg exiftool
 
 # Install Darktable if building for a supported architecture
 RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ] || [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
diff --git a/api/go.mod b/api/go.mod
index 6aa9063..fb8a77b 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -5,6 +5,7 @@ go 1.16
 require (
 	github.com/99designs/gqlgen v0.13.0
 	github.com/Kagami/go-face v0.0.0-20200825065730-3dd2d74dccfb
+	github.com/barasher/go-exiftool v1.3.2
 	github.com/disintegration/imaging v1.6.2
 	github.com/go-sql-driver/mysql v1.5.0
 	github.com/gorilla/handlers v1.5.1
diff --git a/api/go.sum b/api/go.sum
index 6bc5e98..a103eea 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -11,6 +11,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
 github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
 github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
+github.com/barasher/go-exiftool v1.3.2 h1:yWUIGOsM6PLbbHxe84ASTo/eyORMTyMH/5Qv1yBcC7s=
+github.com/barasher/go-exiftool v1.3.2/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index 0094165..4a771fa 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -1890,8 +1890,8 @@ type MediaEXIF {
   focalLength: Float
   "A formatted description of the flash settings, when the image was taken"
   flash: String
-  "An index describing the mode for adjusting the exposure of the image"
-  exposureProgram: Int
+  "A formated description of the mode for adjusting the exposure of the image"
+  exposureProgram: String
 }
 
 type VideoMetadata {
@@ -4520,9 +4520,9 @@ func (ec *executionContext) _MediaEXIF_iso(ctx context.Context, field graphql.Co
 	if resTmp == nil {
 		return graphql.Null
 	}
-	res := resTmp.(*int)
+	res := resTmp.(*int64)
 	fc.Result = res
-	return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
+	return ec.marshalOInt2ᚖint64(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) _MediaEXIF_focalLength(ctx context.Context, field graphql.CollectedField, obj *models.MediaEXIF) (ret graphql.Marshaler) {
@@ -4616,9 +4616,9 @@ func (ec *executionContext) _MediaEXIF_exposureProgram(ctx context.Context, fiel
 	if resTmp == nil {
 		return graphql.Null
 	}
-	res := resTmp.(*int)
+	res := resTmp.(*string)
 	fc.Result = res
-	return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) _MediaURL_url(ctx context.Context, field graphql.CollectedField, obj *models.MediaURL) (ret graphql.Marshaler) {
@@ -11788,6 +11788,21 @@ func (ec *executionContext) marshalOInt2ᚖint(ctx context.Context, sel ast.Sele
 	return graphql.MarshalInt(*v)
 }
 
+func (ec *executionContext) unmarshalOInt2ᚖint64(ctx context.Context, v interface{}) (*int64, error) {
+	if v == nil {
+		return nil, nil
+	}
+	res, err := graphql.UnmarshalInt64(v)
+	return &res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOInt2ᚖint64(ctx context.Context, sel ast.SelectionSet, v *int64) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return graphql.MarshalInt64(*v)
+}
+
 func (ec *executionContext) marshalOMedia2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx context.Context, sel ast.SelectionSet, v *models.Media) graphql.Marshaler {
 	if v == nil {
 		return graphql.Null
diff --git a/api/graphql/models/media_exif.go b/api/graphql/models/media_exif.go
index 38aaba9..f86d552 100644
--- a/api/graphql/models/media_exif.go
+++ b/api/graphql/models/media_exif.go
@@ -12,11 +12,11 @@ type MediaEXIF struct {
 	DateShot        *time.Time
 	Exposure        *string
 	Aperture        *float64
-	Iso             *int
+	Iso             *int64
 	FocalLength     *float64
 	Flash           *string
-	Orientation     *int
-	ExposureProgram *int
+	Orientation     *int64
+	ExposureProgram *string
 	GPSLatitude     *float64
 	GPSLongitude    *float64
 }
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index 15042ad..de7eb2f 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -313,8 +313,8 @@ type MediaEXIF {
   focalLength: Float
   "A formatted description of the flash settings, when the image was taken"
   flash: String
-  "An index describing the mode for adjusting the exposure of the image"
-  exposureProgram: Int
+  "A formated description of the mode for adjusting the exposure of the image"
+  exposureProgram: String
 }
 
 type VideoMetadata {
diff --git a/api/scanner/exif/exif.go b/api/scanner/exif/exif.go
index 1e3bf75..903f6e4 100644
--- a/api/scanner/exif/exif.go
+++ b/api/scanner/exif/exif.go
@@ -31,7 +31,7 @@ func SaveEXIF(tx *gorm.DB, media *models.Media) (*models.MediaEXIF, error) {
 		}
 	}
 
-	var parser exifParser = &internalExifParser{}
+	var parser exifParser = &externalExifParser{}
 
 	exif, err := parser.ParseExif(media)
 	if err != nil {
diff --git a/api/scanner/exif/exif_parser_external.go b/api/scanner/exif/exif_parser_external.go
new file mode 100644
index 0000000..226924f
--- /dev/null
+++ b/api/scanner/exif/exif_parser_external.go
@@ -0,0 +1,206 @@
+package exif
+
+import (
+	"log"
+	"regexp"
+	"strconv"
+	"time"
+
+	"github.com/barasher/go-exiftool"
+	"github.com/photoview/photoview/api/graphql/models"
+)
+
+type externalExifParser struct{}
+
+func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models.MediaEXIF, returnErr error) {
+	// Init ExifTool
+	et, err := exiftool.NewExiftool()
+	if err != nil {
+		log.Printf("Error initializing ExifTool: %s\n", err)
+		return nil, err
+	}
+	defer et.Close()
+
+	fileInfos := et.ExtractMetadata(media.Path)
+	newExif := models.MediaEXIF{}
+
+	for _, fileInfo := range fileInfos {
+		if fileInfo.Err != nil {
+			log.Printf("Fileinfo error\n")
+			continue
+		}
+
+		// Get camera model
+		model, err := fileInfo.GetString("Model")
+		if err == nil {
+			log.Printf("Camera model: %v", model)
+			newExif.Camera = &model
+		}
+
+		// Get Camera make
+		make, err := fileInfo.GetString("Make")
+		if err == nil {
+			log.Printf("Camera make: %v", make)
+			newExif.Maker = &make
+		}
+
+		// Get lens
+		lens, err := fileInfo.GetString("LensModel")
+		if err == nil {
+			log.Printf("Lens: %v", lens)
+			newExif.Lens = &lens
+		}
+
+		//Get time of photo
+		date, err := fileInfo.GetString("DateTimeOriginal")
+		if err == nil {
+			log.Printf("Date shot: %s", date)
+			layout := "2006:01:02 15:04:05"
+			dateTime, err := time.Parse(layout, date)
+			if err == nil {
+				newExif.DateShot = &dateTime
+			}
+
+		}
+
+		// Get exposure time
+		exposureTime, err := fileInfo.GetString("ExposureTime")
+		if err == nil {
+			log.Printf("Exposure time: %s", exposureTime)
+			newExif.Exposure = &exposureTime
+		}
+
+		// Get aperture
+		aperture, err := fileInfo.GetFloat("Aperture")
+		if err == nil {
+			log.Printf("Aperture: %f", aperture)
+			newExif.Aperture = &aperture
+		}
+
+		// Get ISO
+		iso, err := fileInfo.GetInt("ISO")
+		if err == nil {
+			log.Printf("ISO: %d", iso)
+			newExif.Iso = &iso
+		}
+
+		// Get focal length
+		focalLen, err := fileInfo.GetString("FocalLength")
+		if err == nil {
+			log.Printf("Focal length: %s", focalLen)
+			reg, err := regexp.Compile("[0-9.]+")
+			focalLenStr := reg.FindString(focalLen)
+			focalLenFloat, err := strconv.ParseFloat(focalLenStr, 64)
+			if err == nil {
+				newExif.FocalLength = &focalLenFloat
+			}
+		}
+
+		// Get flash info
+		flash, err := fileInfo.GetString("Flash")
+		if err == nil {
+			log.Printf("Flash: %s", flash)
+			newExif.Flash = &flash
+		}
+
+		// Get orientation
+		orientation, err := fileInfo.GetString("Orientation")
+		log.Printf("Orientation: %s", orientation)
+		if err == nil {
+			if orientation == "Horizontal (normal)" {
+				var orientationInt int64 = 0
+				newExif.Orientation = &orientationInt
+			} else {
+				reg, err := regexp.Compile("CCW")
+				if err == nil {
+					if reg.MatchString(orientation) {
+						//Counter clockwise
+						reg, err := regexp.Compile("[0-9.]+")
+						orientationStr := reg.FindString(orientation)
+						orientationInt, err := strconv.ParseInt(orientationStr, 10, 64)
+						if err == nil {
+							log.Printf("Orientation: %d", orientationInt)
+							newExif.Orientation = &orientationInt
+						}
+					} else {
+						// Clockwise
+						reg, err := regexp.Compile("[0-9.]+")
+						orientationStr := reg.FindString(orientation)
+						orientationInt, err := strconv.ParseInt(orientationStr, 10, 64)
+						if err == nil {
+							orientationInt = orientationInt * -1
+							log.Printf("Orientation: %d", orientationInt)
+							newExif.Orientation = &orientationInt
+						}
+					}
+				}
+			}
+		}
+
+		// Get exposure program
+		expProgram, err := fileInfo.GetString("ExposureProgram")
+		if err == nil {
+			log.Printf("Exposure Program: %s", expProgram)
+			newExif.ExposureProgram = &expProgram
+		}
+
+		// GPS coordinates - longitude
+		longitudeRaw, err := fileInfo.GetString("GPSLongitude")
+		if err == nil {
+			log.Printf("GPS longitude: %s", longitudeRaw)
+			value, err := ConvertCoodinateToFloat(longitudeRaw)
+			if err == nil {
+				newExif.GPSLongitude = &value
+			}
+		}
+
+		// GPS coordinates - latitude
+		latitudeRaw, err := fileInfo.GetString("GPSLatitude")
+		if err == nil {
+			log.Printf("GPS latitude: %s", latitudeRaw)
+			value, err := ConvertCoodinateToFloat(latitudeRaw)
+			if err == nil {
+				newExif.GPSLatitude = &value
+			}
+		}
+	}
+
+	returnExif = &newExif
+	return
+}
+
+func ConvertCoodinateToFloat(coordinate string) (value float64, err error) {
+	reg, err := regexp.Compile("[0-9.]+")
+	if err != nil {
+		return 0, err
+	}
+
+	coordinateStr := reg.FindAllString(coordinate, -1)
+	log.Printf("GPS: %s length: %d\n", coordinateStr, len(coordinateStr))
+	if len(coordinateStr) != 3 {
+		return 0, err
+	}
+
+	deg, err := strconv.ParseFloat(coordinateStr[0], 64)
+	if err != nil {
+		return 0, err
+	}
+
+	minute, err := strconv.ParseFloat(coordinateStr[1], 64)
+	if err != nil {
+		return 0, err
+	}
+
+	second, err := strconv.ParseFloat(coordinateStr[2], 64)
+	if err != nil {
+		return 0, err
+	}
+
+	var multiplier float64 = 1
+	if deg < 0 {
+		multiplier = -1
+	}
+
+	value = (deg + minute / 60 + second / 3600) * multiplier
+	return value, nil
+}
diff --git a/api/scanner/exif/exif_parser_internal.go b/api/scanner/exif/exif_parser_internal.go
deleted file mode 100644
index b4bf6a3..0000000
--- a/api/scanner/exif/exif_parser_internal.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package exif
-
-import (
-	"fmt"
-	"log"
-	"math/big"
-	"os"
-
-	"github.com/photoview/photoview/api/graphql/models"
-	"github.com/pkg/errors"
-	"github.com/xor-gate/goexif2/exif"
-	"github.com/xor-gate/goexif2/mknote"
-)
-
-// internalExifParser is an exif parser that parses the media without the use of external tools
-type internalExifParser struct{}
-
-func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models.MediaEXIF, returnErr error) {
-	photoFile, err := os.Open(media.Path)
-	if err != nil {
-		return nil, err
-	}
-
-	exif.RegisterParsers(mknote.All...)
-
-	// Recover if exif.Decode panics
-	defer func() {
-		if err := recover(); err != nil {
-			log.Printf("Recovered from panic: Exif decoding: %s\n", err)
-			returnErr = errors.New(fmt.Sprintf("Exif decoding panicked: %s\n", err))
-		}
-	}()
-
-	exifTags, err := exif.Decode(photoFile)
-	if err != nil {
-		return nil, errors.Wrap(err, "Could not decode EXIF")
-	}
-
-	newExif := models.MediaEXIF{}
-
-	model, err := p.readStringTag(exifTags, exif.Model, media)
-	if err == nil {
-		newExif.Camera = model
-	}
-
-	maker, err := p.readStringTag(exifTags, exif.Make, media)
-	if err == nil {
-		newExif.Maker = maker
-	}
-
-	lens, err := p.readStringTag(exifTags, exif.LensModel, media)
-	if err == nil {
-		newExif.Lens = lens
-	}
-
-	date, err := exifTags.DateTime()
-	if err == nil {
-		newExif.DateShot = &date
-	}
-
-	exposure, err := p.readRationalTag(exifTags, exif.ExposureTime, media)
-	if err == nil {
-		exposureStr := exposure.RatString()
-		newExif.Exposure = &exposureStr
-	}
-
-	apertureRat, err := p.readRationalTag(exifTags, exif.FNumber, media)
-	if err == nil {
-		aperture, _ := apertureRat.Float64()
-		newExif.Aperture = &aperture
-	}
-
-	isoTag, err := exifTags.Get(exif.ISOSpeedRatings)
-	if err != nil {
-		log.Printf("WARN: Could not read ISOSpeedRatings from EXIF: %s\n", media.Title)
-	} else {
-		iso, err := isoTag.Int(0)
-		if err != nil {
-			log.Printf("WARN: Could not parse EXIF ISOSpeedRatings as integer: %s\n", media.Title)
-		} else {
-			newExif.Iso = &iso
-		}
-	}
-
-	focalLengthTag, err := exifTags.Get(exif.FocalLength)
-	if err == nil {
-		focalLengthRat, err := focalLengthTag.Rat(0)
-		if err == nil {
-			focalLength, _ := focalLengthRat.Float64()
-			newExif.FocalLength = &focalLength
-
-		} else {
-			// For some photos, the focal length cannot be read as a rational value,
-			// but is instead the second value read as an integer
-
-			if err == nil {
-				focalLength, err := focalLengthTag.Int(1)
-				if err != nil {
-					log.Printf("WARN: Could not parse EXIF FocalLength as rational or integer: %s\n%s\n", media.Title, err)
-				} else {
-					focalLenFloat := float64(focalLength)
-					newExif.FocalLength = &focalLenFloat
-				}
-			}
-		}
-	}
-
-	flash, err := exifTags.Flash()
-	if err == nil {
-		newExif.Flash = &flash
-	}
-
-	orientation, err := p.readIntegerTag(exifTags, exif.Orientation, media)
-	if err == nil {
-		newExif.Orientation = orientation
-	}
-
-	exposureProgram, err := p.readIntegerTag(exifTags, exif.ExposureProgram, media)
-	if err == nil {
-		newExif.ExposureProgram = exposureProgram
-	}
-
-	lat, long, err := exifTags.LatLong()
-	if err == nil {
-		newExif.GPSLatitude = &lat
-		newExif.GPSLongitude = &long
-	}
-
-	returnExif = &newExif
-	return
-}
-
-func (p *internalExifParser) readStringTag(tags *exif.Exif, name exif.FieldName, media *models.Media) (*string, error) {
-	tag, err := tags.Get(name)
-	if err != nil {
-		return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media.Title)
-	}
-
-	if tag != nil {
-		value, err := tag.StringVal()
-		if err != nil {
-			return nil, errors.Wrapf(err, "could not parse %s from EXIF as string: %s", name, media.Title)
-		}
-
-		return &value, nil
-	}
-
-	log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media.Title)
-	return nil, errors.New("exif tag returned null")
-}
-
-func (p *internalExifParser) readRationalTag(tags *exif.Exif, name exif.FieldName, media *models.Media) (*big.Rat, error) {
-	tag, err := tags.Get(name)
-	if err != nil {
-		return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media.Title)
-	}
-
-	if tag != nil {
-		value, err := tag.Rat(0)
-		if err != nil {
-			return nil, errors.Wrapf(err, "could not parse %s from EXIF as rational: %s", name, media.Title)
-		}
-
-		return value, nil
-	}
-
-	log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media.Title)
-	return nil, errors.New("exif tag returned null")
-}
-
-func (p *internalExifParser) readIntegerTag(tags *exif.Exif, name exif.FieldName, media *models.Media) (*int, error) {
-	tag, err := tags.Get(name)
-	if err != nil {
-		return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media.Title)
-	}
-
-	if tag != nil {
-		value, err := tag.Int(0)
-		if err != nil {
-			return nil, errors.Wrapf(err, "Could not parse %s from EXIF as integer: %s", name, media.Title)
-		}
-
-		return &value, nil
-	}
-
-	log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media.Title)
-	return nil, errors.New("exif tag returned null")
-}
diff --git a/ui/src/components/sidebar/MediaSidebar.js b/ui/src/components/sidebar/MediaSidebar.js
index 906d29b..9548eaa 100644
--- a/ui/src/components/sidebar/MediaSidebar.js
+++ b/ui/src/components/sidebar/MediaSidebar.js
@@ -134,18 +134,6 @@ const exifNameLookup = {
   flash: 'Flash',
 }
 
-const exposurePrograms = {
-  0: 'Not defined',
-  1: 'Manual',
-  2: 'Normal program',
-  3: 'Aperture priority',
-  4: 'Shutter priority',
-  5: 'Creative program',
-  6: 'Action program',
-  7: 'Portrait mode',
-  8: 'Landscape mode ',
-}
-
 const SidebarContent = ({ media, hidePreview }) => {
   let exifItems = []
 
@@ -164,7 +152,7 @@ const SidebarContent = ({ media, hidePreview }) => {
 
     exif.dateShot = new Date(exif.dateShot).toLocaleString()
     if (exif.exposureProgram) {
-      exif.exposureProgram = exposurePrograms[exif.exposureProgram]
+      exif.exposureProgram = `${exif.exposureProgram}`
     }
 
     if (exif.aperture) {
