diff --git a/api/go.mod b/api/go.mod index fb8a77b3..7d5ebbe2 100644 --- a/api/go.mod +++ b/api/go.mod @@ -5,7 +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/barasher/go-exiftool v1.3.2 // indirect github.com/disintegration/imaging v1.6.2 github.com/go-sql-driver/mysql v1.5.0 github.com/gorilla/handlers v1.5.1 @@ -13,6 +13,7 @@ require ( github.com/gorilla/websocket v1.4.2 github.com/h2non/filetype v1.1.1 github.com/joho/godotenv v1.3.0 + github.com/kjeldgaard/go-exiftool v1.3.3 github.com/pkg/errors v0.9.1 github.com/sabhiram/go-gitignore v0.0.0-20201211210132-54b8a0bf510f github.com/vektah/gqlparser/v2 v2.1.0 diff --git a/api/go.sum b/api/go.sum index a103eead..f17d24d2 100644 --- a/api/go.sum +++ b/api/go.sum @@ -117,6 +117,8 @@ github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kjeldgaard/go-exiftool v1.3.3 h1:tDlkcHtTuduu7UgopqGS/ooh1tmEgtIhlBjmTXzjxbg= +github.com/kjeldgaard/go-exiftool v1.3.3/go.mod h1:Nmi1VkXBbr48Kq5ZJjo29RD+a3PdWVGlL+8bSssxFbU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 4a771fa5..3f937ade 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 - "A formated description of the mode for adjusting the exposure of the image" - exposureProgram: String + "An index describing the mode for adjusting the exposure of the image" + exposureProgram: Int } type VideoMetadata { @@ -4616,9 +4616,9 @@ func (ec *executionContext) _MediaEXIF_exposureProgram(ctx context.Context, fiel if resTmp == nil { return graphql.Null } - res := resTmp.(*string) + res := resTmp.(*int64) fc.Result = res - return ec.marshalOString2ᚖstring(ctx, field.Selections, res) + return ec.marshalOInt2ᚖint64(ctx, field.Selections, res) } func (ec *executionContext) _MediaURL_url(ctx context.Context, field graphql.CollectedField, obj *models.MediaURL) (ret graphql.Marshaler) { diff --git a/api/graphql/models/media_exif.go b/api/graphql/models/media_exif.go index f86d552a..3b15fe5c 100644 --- a/api/graphql/models/media_exif.go +++ b/api/graphql/models/media_exif.go @@ -16,7 +16,7 @@ type MediaEXIF struct { FocalLength *float64 Flash *string Orientation *int64 - ExposureProgram *string + ExposureProgram *int64 GPSLatitude *float64 GPSLongitude *float64 } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index de7eb2f0..15042adf 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 - "A formated description of the mode for adjusting the exposure of the image" - exposureProgram: String + "An index describing the mode for adjusting the exposure of the image" + exposureProgram: Int } type VideoMetadata { diff --git a/api/scanner/exif/exif.go b/api/scanner/exif/exif.go index 903f6e4f..be2a0269 100644 --- a/api/scanner/exif/exif.go +++ b/api/scanner/exif/exif.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" "gorm.io/gorm" + "github.com/kjeldgaard/go-exiftool" "github.com/photoview/photoview/api/graphql/models" ) @@ -31,7 +32,16 @@ func SaveEXIF(tx *gorm.DB, media *models.Media) (*models.MediaEXIF, error) { } } - var parser exifParser = &externalExifParser{} + // Decide between internal or external Exif parser + et, err := exiftool.NewExiftool() + et.Close() + var parser exifParser + if err != nil { + parser = &internalExifParser{} + } else { + parser = &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 index 226924fb..31111719 100644 --- a/api/scanner/exif/exif_parser_external.go +++ b/api/scanner/exif/exif_parser_external.go @@ -6,7 +6,7 @@ import ( "strconv" "time" - "github.com/barasher/go-exiftool" + "github.com/kjeldgaard/go-exiftool" "github.com/photoview/photoview/api/graphql/models" ) @@ -14,7 +14,10 @@ type externalExifParser struct{} func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models.MediaEXIF, returnErr error) { // Init ExifTool - et, err := exiftool.NewExiftool() + + et2 := exiftool.Charset("-n") + + et, err := .NewExiftool(et2) if err != nil { log.Printf("Error initializing ExifTool: %s\n", err) return nil, err @@ -60,7 +63,6 @@ func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models. if err == nil { newExif.DateShot = &dateTime } - } // Get exposure time @@ -88,7 +90,7 @@ func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models. focalLen, err := fileInfo.GetString("FocalLength") if err == nil { log.Printf("Focal length: %s", focalLen) - reg, err := regexp.Compile("[0-9.]+") + reg, _ := regexp.Compile("[0-9.]+") focalLenStr := reg.FindString(focalLen) focalLenFloat, err := strconv.ParseFloat(focalLenStr, 64) if err == nil { @@ -104,44 +106,19 @@ func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models. } // Get orientation - orientation, err := fileInfo.GetString("Orientation") - log.Printf("Orientation: %s", orientation) + orientation, err := fileInfo.GetInt("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 - } - } - } - } + log.Printf("Orientation: %d", orientation) + newExif.Orientation = &orientation } // Get exposure program - expProgram, err := fileInfo.GetString("ExposureProgram") + expProgram, err := fileInfo.GetStrings("ExposureProgram") if err == nil { - log.Printf("Exposure Program: %s", expProgram) - newExif.ExposureProgram = &expProgram + for _, value := range expProgram { + log.Printf("%s", value) + } + //log.Printf("Exposure Program: %d", expProgram) } // GPS coordinates - longitude diff --git a/api/scanner/exif/exif_parser_internal.go b/api/scanner/exif/exif_parser_internal.go new file mode 100644 index 00000000..654ec5f4 --- /dev/null +++ b/api/scanner/exif/exif_parser_internal.go @@ -0,0 +1,191 @@ +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 { + iso64 := int64(iso) + newExif.Iso = &iso64 + } + } + + 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 { + orientation64 := int64(*orientation) + newExif.Orientation = &orientation64 + } + + exposureProgram, err := p.readIntegerTag(exifTags, exif.ExposureProgram, media) + if err == nil { + exposureProgram64 := int64(*exposureProgram) + newExif.ExposureProgram = &exposureProgram64 + } + + 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 9548eaa8..321569b0 100644 --- a/ui/src/components/sidebar/MediaSidebar.js +++ b/ui/src/components/sidebar/MediaSidebar.js @@ -134,6 +134,20 @@ const exifNameLookup = { flash: 'Flash', } +// From https://exiftool.org/TagNames/EXIF.html +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 ', + 9: 'Bulb', +} + const SidebarContent = ({ media, hidePreview }) => { let exifItems = [] @@ -152,7 +166,7 @@ const SidebarContent = ({ media, hidePreview }) => { exif.dateShot = new Date(exif.dateShot).toLocaleString() if (exif.exposureProgram) { - exif.exposureProgram = `${exif.exposureProgram}` + exif.exposureProgram = exposurePrograms[exif.exposureProgram] } if (exif.aperture) {