Move exif under externaltools. (#1297)

* Move exif.

* Better name.

* Add tests and fix codes.

* Rename cleanup. Remove context because it doesn't use.

* Update comments.

* Protect the global var with sync.Once.

* Avoid exit

* Fix the pointer.

* Apply recommends.

* Fix parsing date.

* Fix shadow name.

* Add log when exif parsing has parse failures.

* Add error context.

* Fix advices.
This commit is contained in:
Googol Lee
2025-08-31 18:48:29 +02:00
committed by GitHub
parent 0b65d32d05
commit 7ab1f89795
20 changed files with 677 additions and 700 deletions

View File

@@ -1,24 +0,0 @@
package dataloader
import (
"time"
"github.com/barasher/go-exiftool"
)
func NewExiftoolLoader(et *exiftool.Exiftool) *ExiftoolLoader {
return &ExiftoolLoader{
wait: 100 * time.Millisecond,
maxBatch: 100,
fetch: func(keys []string) ([]exiftool.FileMetadata, []error) {
metadata := et.ExtractMetadata(keys...)
exifErrors := make([]error, len(metadata))
for i := 0; i < len(metadata); i++ {
exifErrors[i] = metadata[i].Err
}
return metadata, exifErrors
},
}
}

View File

@@ -1,221 +0,0 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package dataloader
import (
"sync"
"time"
"github.com/barasher/go-exiftool"
)
// ExiftoolLoaderConfig captures the config to create a new ExiftoolLoader
type ExiftoolLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []string) ([]exiftool.FileMetadata, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewExiftoolLoader creates a new ExiftoolLoader given a fetch, wait, and maxBatch
// func NewExiftoolLoader(config ExiftoolLoaderConfig) *ExiftoolLoader {
// return &ExiftoolLoader{
// fetch: config.Fetch,
// wait: config.Wait,
// maxBatch: config.MaxBatch,
// }
// }
// ExiftoolLoader batches and caches requests
type ExiftoolLoader struct {
// this method provides the data for the loader
fetch func(keys []string) ([]exiftool.FileMetadata, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[string]exiftool.FileMetadata
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *exiftoolLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type exiftoolLoaderBatch struct {
keys []string
data []exiftool.FileMetadata
error []error
closing bool
done chan struct{}
}
// Load a exiftool.FileMetadata by key, batching and caching will be applied automatically
func (l *ExiftoolLoader) Load(key string) (exiftool.FileMetadata, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a exiftool.FileMetadata.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *ExiftoolLoader) LoadThunk(key string) func() (exiftool.FileMetadata, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (exiftool.FileMetadata, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &exiftoolLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (exiftool.FileMetadata, error) {
<-batch.done
var data exiftool.FileMetadata
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *ExiftoolLoader) LoadAll(keys []string) ([]exiftool.FileMetadata, []error) {
results := make([]func() (exiftool.FileMetadata, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
FileMetadatas := make([]exiftool.FileMetadata, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
FileMetadatas[i], errors[i] = thunk()
}
return FileMetadatas, errors
}
// LoadAllThunk returns a function that when called will block waiting for a FileMetadatas.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *ExiftoolLoader) LoadAllThunk(keys []string) func() ([]exiftool.FileMetadata, []error) {
results := make([]func() (exiftool.FileMetadata, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]exiftool.FileMetadata, []error) {
FileMetadatas := make([]exiftool.FileMetadata, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
FileMetadatas[i], errors[i] = thunk()
}
return FileMetadatas, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *ExiftoolLoader) Prime(key string, value exiftool.FileMetadata) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
l.unsafeSet(key, value)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *ExiftoolLoader) Clear(key string) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *ExiftoolLoader) unsafeSet(key string, value exiftool.FileMetadata) {
if l.cache == nil {
l.cache = map[string]exiftool.FileMetadata{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *exiftoolLoaderBatch) keyIndex(l *ExiftoolLoader, key string) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *exiftoolLoaderBatch) startTimer(l *ExiftoolLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *exiftoolLoaderBatch) end(l *ExiftoolLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -1,67 +0,0 @@
package exif
import (
"log"
"fmt"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/photoview/photoview/api/graphql/models"
)
var globalExifParser *ExifParser
func InitializeEXIFParser() {
var err error
globalExifParser, err = NewExiftoolParser()
if err != nil {
panic(fmt.Sprintf("Failed to init exiftool: %v", err))
}
log.Println("Found exiftool")
}
// SaveEXIF scans the media file for exif metadata and saves it in the database if found
func SaveEXIF(tx *gorm.DB, media *models.Media) (*models.MediaEXIF, error) {
{
// Check if EXIF data already exists
if media.ExifID != nil {
var exif models.MediaEXIF
if err := tx.First(&exif, media.ExifID).Error; err != nil {
return nil, errors.Wrap(err, "get EXIF for media from database")
}
return &exif, nil
}
}
if globalExifParser == nil {
return nil, errors.New("No exif parser initialized")
}
exif, err := globalExifParser.ParseExif(media.Path)
if err != nil {
return nil, errors.Wrap(err, "failed to parse exif data")
}
if exif == nil {
return nil, nil
}
// Add EXIF to database and link to media
if err := tx.Model(&media).Association("Exif").Replace(exif); err != nil {
return nil, errors.Wrap(err, "save media exif to database")
}
if exif.DateShot != nil && !exif.DateShot.Equal(media.DateShot) {
media.DateShot = *exif.DateShot
if err := tx.Save(media).Error; err != nil {
return nil, errors.Wrap(err, "update media date_shot")
}
}
return exif, nil
}

View File

@@ -1,232 +0,0 @@
package exif
import (
"fmt"
"log"
"math"
"time"
"github.com/barasher/go-exiftool"
"github.com/photoview/photoview/api/dataloader"
"github.com/photoview/photoview/api/graphql/models"
)
type ExifParser struct {
et *exiftool.Exiftool
dataLoader *dataloader.ExiftoolLoader
}
func NewExiftoolParser() (*ExifParser, error) {
buf := make([]byte, 256*1024)
et, err := exiftool.NewExiftool(exiftool.NoPrintConversion(), exiftool.Buffer(buf, 64*1024))
if err != nil {
log.Printf("Error initializing ExifTool: %s\n", err)
return nil, err
}
return &ExifParser{
et: et,
dataLoader: dataloader.NewExiftoolLoader(et),
}, nil
}
// isFloatReal returns true when the float value represents a real number
// (different than +Inf, -Inf or NaN)
func isFloatReal(v float64) bool {
if math.IsInf(v, 1) {
return false
} else if math.IsInf(v, -1) {
return false
} else if math.IsNaN(v) {
return false
}
return true
}
// sanitizeEXIF removes any EXIF float64 field that is not a real number (+Inf,
// -Inf or Nan)
func sanitizeEXIF(exif *models.MediaEXIF) {
if exif.Exposure != nil && !isFloatReal(*exif.Exposure) {
exif.Exposure = nil
}
if exif.Aperture != nil && !isFloatReal(*exif.Aperture) {
exif.Aperture = nil
}
if exif.FocalLength != nil && !isFloatReal(*exif.FocalLength) {
exif.FocalLength = nil
}
if (exif.GPSLatitude != nil && !isFloatReal(*exif.GPSLatitude)) ||
(exif.GPSLongitude != nil && !isFloatReal(*exif.GPSLongitude)) {
exif.GPSLatitude = nil
exif.GPSLongitude = nil
}
}
func extractValidGpsData(fileInfo *exiftool.FileMetadata, mediaPath string) (*float64, *float64) {
var GPSLat, GPSLong *float64
// GPS coordinates - longitude
longitudeRaw, err := fileInfo.GetFloat("GPSLongitude")
if err == nil {
GPSLong = &longitudeRaw
}
// GPS coordinates - latitude
latitudeRaw, err := fileInfo.GetFloat("GPSLatitude")
if err == nil {
GPSLat = &latitudeRaw
}
// GPS data validation
if (GPSLat != nil && math.Abs(*GPSLat) > 90) || (GPSLong != nil && math.Abs(*GPSLong) > 180) {
latStr := "<empty>"
if GPSLat != nil {
latStr = fmt.Sprintf("%f", *GPSLat)
}
longStr := "<empty>"
if GPSLong != nil {
longStr = fmt.Sprintf("%f", *GPSLong)
}
log.Printf(
"Incorrect GPS data in the %s Exif metadata: %s, %s, (expected latitude '-90'..'90' / longitude '-180'..'180'). Ignoring GPS data.",
mediaPath, latStr, longStr)
return nil, nil
}
return GPSLat, GPSLong
}
func (p *ExifParser) ParseExif(mediaPath string) (returnExif *models.MediaEXIF, returnErr error) {
// ExifTool - No print conversion mode
if p.et == nil {
et, err := exiftool.NewExiftool(exiftool.NoPrintConversion())
p.et = et
if err != nil {
log.Printf("Error initializing ExifTool: %s\n", err)
return nil, err
}
}
fileInfo, err := p.dataLoader.Load(mediaPath)
if err != nil {
return nil, err
}
newExif := models.MediaEXIF{}
foundExif := false
// Get description
description, err := fileInfo.GetString("ImageDescription")
if err == nil {
foundExif = true
newExif.Description = &description
}
// Get camera model
model, err := fileInfo.GetString("Model")
if err == nil {
foundExif = true
newExif.Camera = &model
}
// Get Camera make
make, err := fileInfo.GetString("Make")
if err == nil {
foundExif = true
newExif.Maker = &make
}
// Get lens
lens, err := fileInfo.GetString("LensModel")
if err == nil {
foundExif = true
newExif.Lens = &lens
}
//Get time of photo
createDateKeys := []string{"CreationDate", "DateTimeOriginal", "CreateDate", "TrackCreateDate", "MediaCreateDate", "FileCreateDate", "ModifyDate", "TrackModifyDate", "MediaModifyDate", "FileModifyDate"}
for _, createDateKey := range createDateKeys {
date, err := fileInfo.GetString(createDateKey)
if err == nil {
layout := "2006:01:02 15:04:05"
dateTime, err := time.Parse(layout, date)
if err == nil {
foundExif = true
newExif.DateShot = &dateTime
} else {
layoutWithOffset := "2006:01:02 15:04:05-07:00"
dateTime, err = time.Parse(layoutWithOffset, date)
if err == nil {
foundExif = true
newExif.DateShot = &dateTime
}
}
break
}
}
// Get exposure time
exposureTime, err := fileInfo.GetFloat("ExposureTime")
if err == nil {
foundExif = true
newExif.Exposure = &exposureTime
}
// Get aperture
aperture, err := fileInfo.GetFloat("Aperture")
if err == nil {
foundExif = true
newExif.Aperture = &aperture
}
// Get ISO
iso, err := fileInfo.GetInt("ISO")
if err == nil {
foundExif = true
newExif.Iso = &iso
}
// Get focal length
focalLen, err := fileInfo.GetFloat("FocalLength")
if err == nil {
foundExif = true
newExif.FocalLength = &focalLen
}
// Get flash info
flash, err := fileInfo.GetInt("Flash")
if err == nil {
foundExif = true
newExif.Flash = &flash
}
// Get orientation
orientation, err := fileInfo.GetInt("Orientation")
if err == nil {
foundExif = true
newExif.Orientation = &orientation
}
// Get exposure program
expProgram, err := fileInfo.GetInt("ExposureProgram")
if err == nil {
foundExif = true
newExif.ExposureProgram = &expProgram
}
// Get GPS data
newExif.GPSLatitude, newExif.GPSLongitude = extractValidGpsData(&fileInfo, mediaPath)
if (newExif.GPSLatitude != nil) && (newExif.GPSLongitude != nil) {
foundExif = true
}
if !foundExif {
return nil, nil
}
returnExif = &newExif
sanitizeEXIF(returnExif)
return
}

View File

@@ -1,147 +0,0 @@
package exif_test
import (
"fmt"
"path"
"testing"
"time"
"github.com/barasher/go-exiftool"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/exif"
"github.com/photoview/photoview/api/test_utils"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
test_utils.IntegrationTestRun(m)
}
func TestExifParsers(t *testing.T) {
test_utils.FilesystemTest(t)
externalParser, err := exif.NewExiftoolParser()
if err != nil {
t.Fatalf("can't init exiftool: %v", err)
}
parsers := []struct {
name string
parser *exif.ExifParser
}{
{
name: "external",
parser: externalParser,
},
}
images := []struct {
path string
assert func(t *testing.T, exif *models.MediaEXIF, err error)
}{
{
path: "./test_data/bird.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.NoError(t, err)
assert.EqualValues(t, *exif.Description, "Photo of a Bird")
assert.WithinDuration(t, *exif.DateShot, time.Unix(1336318784, 0).UTC(), time.Minute)
assert.EqualValues(t, *exif.Camera, "Canon EOS 600D")
assert.EqualValues(t, *exif.Maker, "Canon")
assert.WithinDuration(t, *exif.DateShot, time.Unix(1336318784, 0).UTC(), time.Minute)
assert.InDelta(t, *exif.Exposure, 1.0/4000.0, 0.0001)
assert.EqualValues(t, *exif.Aperture, 6.3)
assert.EqualValues(t, *exif.Iso, 800)
assert.EqualValues(t, *exif.FocalLength, 300)
assert.EqualValues(t, *exif.Flash, 16)
assert.EqualValues(t, *exif.Orientation, 1)
assert.InDelta(t, *exif.GPSLatitude, 65.01681388888889, 0.0001)
assert.InDelta(t, *exif.GPSLongitude, 25.466863888888888, 0.0001)
},
},
{
path: "./test_data/stripped.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.NoError(t, err)
if exif == nil {
assert.Nil(t, exif)
} else {
assert.Equal(t, 0, exif.ID)
assert.True(t, exif.CreatedAt.IsZero())
assert.True(t, exif.UpdatedAt.IsZero())
assert.Nil(t, exif.Description)
assert.Nil(t, exif.Camera)
assert.Nil(t, exif.Maker)
assert.Nil(t, exif.Lens)
assert.Nil(t, exif.Exposure)
assert.Nil(t, exif.Aperture)
assert.Nil(t, exif.Iso)
assert.Nil(t, exif.FocalLength)
assert.Nil(t, exif.Flash)
assert.Nil(t, exif.Orientation)
assert.Nil(t, exif.ExposureProgram)
assert.Nil(t, exif.GPSLatitude)
assert.Nil(t, exif.GPSLongitude)
}
},
},
{
path: "./test_data/bad-exif.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.NoError(t, err)
assert.Nil(t, exif.Exposure)
},
},
{
path: "./test_data/IncorrectGPS.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.Nil(t, exif.GPSLatitude,
"GPSLatitude expected to be NULL for an incorrect input data: %+v", exif.GPSLatitude)
assert.Nil(t, exif.GPSLongitude,
"GPSLongitude expected to be NULL for an incorrect input data: %+v", exif.GPSLongitude)
},
},
{
path: "./test_data/CorrectGPS.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
const precision = 1e-7
assert.NoError(t, err)
assert.NotNil(t, exif.GPSLatitude,
"GPSLatitude expected to be Not-NULL for a correct input data: %+v", exif.GPSLatitude)
assert.NotNil(t, exif.GPSLongitude,
"GPSLongitude expected to be Not-NULL for a correct input data: %+v", exif.GPSLongitude)
assert.InDelta(t, *exif.GPSLatitude, 44.478997222222226, precision,
"The exact value from input data is expected: %+v", exif.GPSLatitude)
assert.InDelta(t, *exif.GPSLongitude, 11.297922222222223, precision,
"The exact value from input data is expected: %+v", exif.GPSLongitude)
},
},
}
for _, p := range parsers {
for _, img := range images {
t.Run(fmt.Sprintf("%s:%s", p.name, path.Base(img.path)), func(t *testing.T) {
if p.name == "external" {
_, err := exiftool.NewExiftool()
if err != nil {
t.Skip("failed to get exiftool, skipping test")
}
}
exif, err := p.parser.ParseExif(img.path)
img.assert(t, exif, err)
})
}
}
}
// func TestExternalExifParser(t *testing.T) {
// parser := externalExifParser{}
// exif, err := parser.ParseExif((bird_path))
// if assert.NoError(t, err) {
// assert.Equal(t, exif, &bird_exif)
// }
// }

View File

@@ -0,0 +1,40 @@
package exif
import (
"errors"
"fmt"
"strings"
"github.com/barasher/go-exiftool"
)
type parseFailure struct {
key string
err error
}
func (e parseFailure) String() string {
return fmt.Sprintf(`%q: %v`, e.key, e.err)
}
type ParseFailures []parseFailure
func (e *ParseFailures) Append(key string, err error) {
if errors.Is(err, exiftool.ErrKeyNotFound) {
return
}
*e = append(*e, parseFailure{
key: key,
err: err,
})
}
func (e ParseFailures) String() string {
errStrs := make([]string, 0, len(e))
for _, pe := range e {
errStrs = append(errStrs, pe.String())
}
return fmt.Sprintf("[%s]", strings.Join(errStrs, "; "))
}

View File

@@ -0,0 +1,17 @@
package exif
import (
"errors"
"fmt"
"testing"
)
func TestParseFailures(t *testing.T) {
var pErr ParseFailures
pErr.Append("key1", errors.New("error1"))
pErr.Append("key2", errors.New("error2"))
if got, want := fmt.Sprintf("%v", pErr), `["key1": error1; "key2": error2]`; got != want {
t.Errorf("fmt.Sprintf(pErr) = %q, want: %q", got, want)
}
}

View File

@@ -0,0 +1,56 @@
package exif
import (
"fmt"
"sync"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
)
var globalExifParser *ExifParser
var globalInit sync.Once
func Initialize() (func(), error) {
var err error
globalInit.Do(func() {
globalExifParser, err = NewExifParser()
})
if err != nil {
return nil, err
}
log.Info(nil, "Found exiftool")
return func() {
if err := globalExifParser.Close(); err != nil {
log.Error(nil, "Cleanup exiftool error:", err)
return
}
globalExifParser = nil
}, nil
}
var globalMu sync.Mutex
func Parse(filepath string) (*models.MediaEXIF, error) {
if globalExifParser == nil {
return nil, fmt.Errorf("no exif parser initialized")
}
globalMu.Lock()
defer globalMu.Unlock()
exif, failures, err := globalExifParser.ParseExif(filepath)
if err != nil {
return nil, err
}
if len(failures) > 0 {
log.Warn(nil, "Parse exif failures", "filepath", filepath, "errors", failures)
}
return exif, nil
}

View File

@@ -0,0 +1,32 @@
package exif
import (
"testing"
_ "github.com/photoview/photoview/api/test_utils/flags"
)
func TestParseWithoutInit(t *testing.T) {
if _, err := Parse("./test_data/bird.jpg"); err == nil {
t.Fatalf("Parse() without Init() doesn't return an error")
}
}
func TestParse(t *testing.T) {
cleanup, err := Initialize()
if err != nil {
t.Fatalf("Initialize() error: %v", err)
}
defer cleanup()
filename := "./test_data/bird.jpg"
metadata, err := Parse(filename)
if err != nil {
t.Fatalf("Parse() returns an error: %v", err)
}
if metadata == nil {
t.Errorf("Parse(%q) should not return nil", filename)
}
}

View File

@@ -0,0 +1,212 @@
package exif
import (
"fmt"
"math"
"time"
"github.com/barasher/go-exiftool"
"github.com/photoview/photoview/api/graphql/models"
)
// ExifParser is a parser to get exif data.
type ExifParser struct {
exiftool *exiftool.Exiftool
}
// NewExifParser creates a ExifParser.
func NewExifParser() (*ExifParser, error) {
buf := make([]byte, 256*1024)
et, err := exiftool.NewExiftool(exiftool.NoPrintConversion(), exiftool.Buffer(buf, 64*1024))
if err != nil {
return nil, fmt.Errorf("error initializing ExifTool: %w", err)
}
return &ExifParser{
exiftool: et,
}, nil
}
// Close cleans up the buffer of the parser.
func (p *ExifParser) Close() error {
return p.exiftool.Close()
}
// ParseExif returns the exif data.
func (p *ExifParser) ParseExif(mediaPath string) (*models.MediaEXIF, ParseFailures, error) {
fileInfos := p.exiftool.ExtractMetadata(mediaPath)
if l := len(fileInfos); l != 1 {
return nil, nil, fmt.Errorf("invalid file infos with %q, len(fileInfos) = %d", mediaPath, l)
}
fileInfo := fileInfos[0]
if err := fileInfo.Err; err != nil {
return nil, nil, fmt.Errorf("invalid parse %q exif: %w", mediaPath, err)
}
retEXIF := models.MediaEXIF{}
foundExif := false
var failures ParseFailures
for field, ptr := range map[string]**string{
"ImageDescription": &retEXIF.Description,
"Model": &retEXIF.Camera, // camera model
"Make": &retEXIF.Maker, // camera make
"LensModel": &retEXIF.Lens,
} {
value, err := fileInfo.GetString(field)
if err != nil {
failures.Append(field, err)
} else {
*ptr = &value
foundExif = true
}
}
for field, ptr := range map[string]**int64{
"ISO": &retEXIF.Iso,
"Flash": &retEXIF.Flash,
"Orientation": &retEXIF.Orientation,
"ExposureProgram": &retEXIF.ExposureProgram,
} {
value, err := fileInfo.GetInt(field)
if err != nil {
failures.Append(field, err)
} else {
*ptr = &value
foundExif = true
}
}
for field, ptr := range map[string]**float64{
"ExposureTime": &retEXIF.Exposure,
"Aperture": &retEXIF.Aperture,
"FocalLength": &retEXIF.FocalLength,
} {
value, err := fileInfo.GetFloat(field)
if err != nil {
failures.Append(field, err)
} else {
*ptr = &value
foundExif = true
}
}
// Get time of photo
layout := "2006:01:02 15:04:05"
layoutWithOffset := "2006:01:02 15:04:05-07:00"
CREATE_DATE:
for _, createDateKey := range []string{
// Keep the order for the priority to generate DateShot
"CreationDate",
"DateTimeOriginal",
"CreateDate",
"TrackCreateDate",
"MediaCreateDate",
"FileCreateDate",
"ModifyDate",
"TrackModifyDate",
"MediaModifyDate",
"FileModifyDate",
} {
dateStr, err := fileInfo.GetString(createDateKey)
if err != nil {
continue
}
if date, err := time.Parse(layout, dateStr); err == nil {
retEXIF.DateShot = &date
foundExif = true
break CREATE_DATE
}
if date, err := time.Parse(layoutWithOffset, dateStr); err == nil {
retEXIF.DateShot = &date
foundExif = true
break CREATE_DATE
} else {
failures.Append(createDateKey, err)
}
}
// Get GPS data
lat, long, err := extractValidGPSData(&fileInfo)
if err != nil {
failures.Append("gps", err)
} else {
retEXIF.GPSLatitude, retEXIF.GPSLongitude = &lat, &long
foundExif = true
}
if !foundExif {
return nil, nil, nil
}
sanitizeEXIF(&retEXIF)
return &retEXIF, failures, nil
}
// isFloatReal returns true when the float value represents a real number
// (different than +Inf, -Inf or NaN)
func isFloatReal(v float64) bool {
if math.IsInf(v, 0) || math.IsNaN(v) {
return false
}
return true
}
// sanitizeEXIF removes any EXIF float64 field that is not a real number (+Inf,
// -Inf or Nan)
func sanitizeEXIF(exif *models.MediaEXIF) {
if exif.Exposure != nil && !isFloatReal(*exif.Exposure) {
exif.Exposure = nil
}
if exif.Aperture != nil && !isFloatReal(*exif.Aperture) {
exif.Aperture = nil
}
if exif.FocalLength != nil && !isFloatReal(*exif.FocalLength) {
exif.FocalLength = nil
}
if (exif.GPSLatitude != nil && !isFloatReal(*exif.GPSLatitude)) ||
(exif.GPSLongitude != nil && !isFloatReal(*exif.GPSLongitude)) {
exif.GPSLatitude = nil
exif.GPSLongitude = nil
}
}
func extractValidGPSData(fileInfo *exiftool.FileMetadata) (float64, float64, error) {
var latitude, longitude *float64
// GPS coordinates - latitude
rawLatitude, err := fileInfo.GetFloat("GPSLatitude")
if err == nil {
latitude = &rawLatitude
}
// GPS coordinates - longitude
rawLongitude, err := fileInfo.GetFloat("GPSLongitude")
if err == nil {
longitude = &rawLongitude
}
if latitude == nil || longitude == nil {
return 0, 0, exiftool.ErrKeyNotFound
}
// GPS data validation
if math.Abs(*latitude) > 90 || math.Abs(*longitude) > 180 {
latStr := fmt.Sprintf("%f", *latitude)
longStr := fmt.Sprintf("%f", *longitude)
return 0, 0, fmt.Errorf("incorrect GPS data: latitude %s should be (-90, 90), longitude %s should be (-180, 180)", latStr, longStr)
}
return *latitude, *longitude, nil
}

View File

@@ -0,0 +1,258 @@
package exif
import (
"math"
"path"
"testing"
"time"
"github.com/barasher/go-exiftool"
"github.com/photoview/photoview/api/graphql/models"
"github.com/stretchr/testify/assert"
)
func TestExifParser(t *testing.T) {
parser, err := NewExifParser()
if err != nil {
t.Fatalf("can't init exiftool: %v", err)
}
defer parser.Close()
images := []struct {
path string
assert func(t *testing.T, exif *models.MediaEXIF, err error)
}{
{
path: "./test_data/bird.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.NoError(t, err)
assert.EqualValues(t, *exif.Description, "Photo of a Bird")
assert.EqualValues(t, *exif.Camera, "Canon EOS 600D")
assert.EqualValues(t, *exif.Maker, "Canon")
assert.WithinDuration(t, *exif.DateShot, time.Unix(1336318784, 0).UTC(), time.Minute)
assert.InDelta(t, *exif.Exposure, 1.0/4000.0, 0.0001)
assert.EqualValues(t, *exif.Aperture, 6.3)
assert.EqualValues(t, *exif.Iso, 800)
assert.EqualValues(t, *exif.FocalLength, 300)
assert.EqualValues(t, *exif.Flash, 16)
assert.EqualValues(t, *exif.Orientation, 1)
assert.InDelta(t, *exif.GPSLatitude, 65.01681388888889, 0.0001)
assert.InDelta(t, *exif.GPSLongitude, 25.466863888888888, 0.0001)
},
},
{
path: "./test_data/CorrectGPS.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
const precision = 1e-7
assert.NoError(t, err)
assert.NotNil(t, exif.GPSLatitude,
"GPSLatitude expected to be Not-NULL for a correct input data: %+v", exif.GPSLatitude)
assert.NotNil(t, exif.GPSLongitude,
"GPSLongitude expected to be Not-NULL for a correct input data: %+v", exif.GPSLongitude)
assert.InDelta(t, *exif.GPSLatitude, 44.478997222222226, precision,
"The exact value from input data is expected: %+v", exif.GPSLatitude)
assert.InDelta(t, *exif.GPSLongitude, 11.297922222222223, precision,
"The exact value from input data is expected: %+v", exif.GPSLongitude)
},
},
{
// stripped.jpg has a file modified date with the offset.
path: "./test_data/stripped.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.NoError(t, err)
assert.Equal(t, 0, exif.ID)
assert.True(t, exif.CreatedAt.IsZero())
assert.True(t, exif.UpdatedAt.IsZero())
assert.Nil(t, exif.Description)
assert.Nil(t, exif.Camera)
assert.Nil(t, exif.Maker)
assert.Nil(t, exif.Lens)
assert.Nil(t, exif.Exposure)
assert.Nil(t, exif.Aperture)
assert.Nil(t, exif.Iso)
assert.Nil(t, exif.FocalLength)
assert.Nil(t, exif.Flash)
assert.Nil(t, exif.Orientation)
assert.Nil(t, exif.ExposureProgram)
assert.Nil(t, exif.GPSLatitude)
assert.Nil(t, exif.GPSLongitude)
},
},
{
path: "./test_data/bad-exif.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.NoError(t, err)
assert.Nil(t, exif.Exposure)
},
},
}
for _, img := range images {
t.Run(path.Base(img.path), func(t *testing.T) {
exif, failures, err := parser.ParseExif(img.path)
if len(failures) != 0 {
t.Errorf("parse failures: %v", failures)
}
img.assert(t, exif, err)
})
}
}
func TestExifParserWithFailure(t *testing.T) {
parser, err := NewExifParser()
if err != nil {
t.Fatalf("can't init exiftool: %v", err)
}
defer parser.Close()
imagesWithFailures := []struct {
path string
assert func(t *testing.T, exif *models.MediaEXIF, err error)
}{
{
path: "./test_data/IncorrectGPS.jpg",
assert: func(t *testing.T, exif *models.MediaEXIF, err error) {
assert.Nil(t, exif.GPSLatitude,
"GPSLatitude expected to be NULL for an incorrect input data: %+v", exif.GPSLatitude)
assert.Nil(t, exif.GPSLongitude,
"GPSLongitude expected to be NULL for an incorrect input data: %+v", exif.GPSLongitude)
},
},
}
for _, img := range imagesWithFailures {
t.Run(path.Base(img.path), func(t *testing.T) {
exif, failures, err := parser.ParseExif(img.path)
if len(failures) == 0 {
t.Errorf("parse failures: %v, should have at least one failure", failures)
}
img.assert(t, exif, err)
})
}
}
func TestExtractValidGPSData(t *testing.T) {
tests := []struct {
name string
latitude, longitude float64
wantOK bool
}{
{"LatNormalLongNormal", 10.0, 10.0, true},
{"LatNilLongNormal", math.NaN(), 10.0, false},
{"LatNormalLongNil", 10.0, math.NaN(), false},
{"Lat>90LongNormal", 100.0, 10.0, false},
{"Lat<-90LongNormal", -100.0, 10.0, false},
{"LatNormalLong>180", 10.0, 190.0, false},
{"LatNormalLong<-180", 10.0, -190.0, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
metadata := exiftool.EmptyFileMetadata()
if !math.IsNaN(tc.latitude) {
metadata.SetFloat("GPSLatitude", tc.latitude)
}
if !math.IsNaN(tc.longitude) {
metadata.SetFloat("GPSLongitude", tc.longitude)
}
lat, long, err := extractValidGPSData(&metadata)
gotOK := err == nil
if got, want := gotOK, tc.wantOK; got != want {
t.Fatalf("extractValidGPSData({lat: %f, long: %f}) got an error: %v, want: %v", tc.latitude, tc.longitude, err, want)
}
if err != nil {
// no need to check data if there is an error
return
}
if got, want := lat, tc.latitude; math.Abs(got-want) >= math.SmallestNonzeroFloat64 {
t.Fatalf("extractValidGPSData({lat: %f, long: %f}) got latitude: %v, want: %v", tc.latitude, tc.longitude, got, want)
}
if got, want := long, tc.longitude; math.Abs(got-want) >= math.SmallestNonzeroFloat64 {
t.Fatalf("extractValidGPSData({lat: %f, long: %f}) got longitude: %v, want: %v", tc.latitude, tc.longitude, got, want)
}
})
}
}
func TestIsFloatReal(t *testing.T) {
tests := []struct {
name string
value float64
want bool
}{
{"Normal", 10.0, true},
{"+Inf", math.Inf(1), false},
{"-Inf", math.Inf(-1), false},
{"NaN", math.NaN(), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := isFloatReal(tc.value)
if got != tc.want {
t.Errorf("isFloatReal(%f) = %v, want: %v", tc.value, got, tc.want)
}
})
}
}
func TestSanitizeEXIF(t *testing.T) {
nan := math.NaN()
var exif models.MediaEXIF
tests := []struct {
field string
ptr **float64
}{
{"Exposure", &exif.Exposure},
{"Aperture", &exif.Aperture},
{"FocalLength", &exif.FocalLength},
{"GPSLatitude", &exif.GPSLatitude},
{"GPSLongitude", &exif.GPSLongitude},
}
for _, tc := range tests {
t.Run(tc.field, func(t *testing.T) {
*tc.ptr = &nan
sanitizeEXIF(&exif)
if got := *tc.ptr; got != nil {
t.Errorf("after sanitizeEXIF(), exif.%s = %v, want: nil", tc.field, got)
}
})
}
}
func TestSanitizeEXIF_GPS(t *testing.T) {
nan := math.NaN()
valid := float64(10.0)
var exif models.MediaEXIF
tests := []struct {
field string
ptr **float64
}{
{"GPSLatitude", &exif.GPSLatitude},
{"GPSLongitude", &exif.GPSLongitude},
}
for _, tc := range tests {
t.Run(tc.field, func(t *testing.T) {
exif.GPSLatitude = &valid
exif.GPSLongitude = &valid
*tc.ptr = &nan
sanitizeEXIF(&exif)
if exif.GPSLatitude != nil || exif.GPSLongitude != nil {
t.Errorf("after sanitizeEXIF(), exif.GPSLatitude = %v, exif.GPSLongitude = %v, want both: nil", exif.GPSLatitude, exif.GPSLongitude)
}
})
}
}

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 5.5 MiB

After

Width:  |  Height:  |  Size: 5.5 MiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,5 @@
// Package externaltools provides wrappers for tools outside the Go runtime.
// These tools are provided by the runtime environment. Some require initialization
// and must be cleaned up properly when finished.
// Packages under externaltools should have as few dependencies as possible to avoid cycles.
package externaltools

View File

@@ -1,10 +1,13 @@
package scanner_tasks
import (
"log"
"fmt"
"gorm.io/gorm"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/exif"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner/externaltools/exif"
"github.com/photoview/photoview/api/scanner/scanner_task"
)
@@ -13,14 +16,48 @@ type ExifTask struct {
}
func (t ExifTask) AfterMediaFound(ctx scanner_task.TaskContext, media *models.Media, newMedia bool) error {
if !newMedia {
return nil
}
_, err := exif.SaveEXIF(ctx.GetDB(), media)
if err != nil {
log.Printf("WARN: SaveEXIF for %s failed: %s\n", media.Title, err)
if err := SaveEXIF(ctx.GetDB(), media); err != nil {
log.Warn(ctx, "SaveEXIF failed", "title", media.Title, "error", err, "path", media.Path)
}
return nil
}
// SaveEXIF scans the media file for exif metadata and saves it in the database if found
func SaveEXIF(tx *gorm.DB, media *models.Media) error {
// Check if EXIF data already exists
if media.ExifID != nil {
var exif models.MediaEXIF
if err := tx.First(&exif, media.ExifID).Error; err != nil {
return fmt.Errorf("failed to get EXIF for %q from database: %w", media.Path, err)
}
return nil
}
exifData, err := exif.Parse(media.Path)
if err != nil {
return fmt.Errorf("failed to parse exif data: %w", err)
}
if exifData == nil {
return nil
}
// Add EXIF to database and link to media
if err := tx.Model(media).Association("Exif").Replace(exifData); err != nil {
return fmt.Errorf("failed to save media exif to database: %w", err)
}
if exifData.DateShot != nil && !exifData.DateShot.Equal(media.DateShot) {
media.DateShot = *exifData.DateShot
if err := tx.Save(media).Error; err != nil {
return fmt.Errorf("failed to update media date_shot: %w", err)
}
}
return nil

View File

@@ -20,7 +20,7 @@ import (
"github.com/photoview/photoview/api/graphql/auth"
graphql_endpoint "github.com/photoview/photoview/api/graphql/endpoint"
"github.com/photoview/photoview/api/routes"
"github.com/photoview/photoview/api/scanner/exif"
"github.com/photoview/photoview/api/scanner/externaltools/exif"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
"github.com/photoview/photoview/api/scanner/periodic_scanner"
@@ -53,6 +53,12 @@ func main() {
log.Panicf("Could not migrate database: %s\n", err)
}
exifCleanup, err := exif.Initialize()
if err != nil {
log.Panicf("Could not initialize exif parser: %s", err)
}
defer exifCleanup()
if err := scanner_queue.InitializeScannerQueue(db); err != nil {
log.Panicf("Could not initialize scanner queue: %s\n", err)
}
@@ -61,8 +67,6 @@ func main() {
log.Panicf("Could not initialize periodic scanner: %s", err)
}
exif.InitializeEXIFParser()
if err := face_detection.InitializeFaceDetector(db); err != nil {
log.Panicf("Could not initialize face detector: %s\n", err)
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/joho/godotenv"
"github.com/photoview/photoview/api/scanner/externaltools/exif"
"github.com/photoview/photoview/api/scanner/media_encoding/executable_worker"
"github.com/photoview/photoview/api/test_utils/flags"
"github.com/photoview/photoview/api/utils"
@@ -46,6 +47,12 @@ func IntegrationTestRun(m *testing.M) {
faceModelsPath := PathFromAPIRoot("data", "models")
utils.ConfigureTestFaceRecognitionModelsPath(faceModelsPath)
exifCleanup, err := exif.Initialize()
if err != nil {
log.Panicf("init exif error: %v", err)
}
defer exifCleanup()
terminateWorkers := executable_worker.Initialize()
defer terminateWorkers()