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.
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
// }
|
||||
40
api/scanner/externaltools/exif/error.go
Normal 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, "; "))
|
||||
}
|
||||
17
api/scanner/externaltools/exif/error_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
56
api/scanner/externaltools/exif/exif.go
Normal 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
|
||||
}
|
||||
32
api/scanner/externaltools/exif/exif_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
212
api/scanner/externaltools/exif/exiftool.go
Normal 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
|
||||
}
|
||||
258
api/scanner/externaltools/exif/exiftool_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 5.5 MiB After Width: | Height: | Size: 5.5 MiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
5
api/scanner/externaltools/tools.go
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||