精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package exif
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/log"
|
||||
"github.com/photoview/photoview/api/scanner/externaltools/exiftool"
|
||||
)
|
||||
|
||||
var globalExifParser *exiftool.Exiftool
|
||||
var globalInit sync.Once
|
||||
|
||||
func Initialize() (func(), error) {
|
||||
var err error
|
||||
globalInit.Do(func() {
|
||||
globalExifParser, err = exiftool.New()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info(nil, "Found exiftool.", "binary_path", globalExifParser.BinaryPath(), "version", globalExifParser.Version())
|
||||
|
||||
return func() {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalExifParser == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := globalExifParser.Close(); err != nil {
|
||||
log.Error(nil, "Cleanup exiftool error", "error", err)
|
||||
return
|
||||
}
|
||||
globalExifParser = nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
var globalMu sync.Mutex
|
||||
|
||||
func Parse(filepath string) (*models.MediaEXIF, error) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalExifParser == nil {
|
||||
return nil, fmt.Errorf("no exif parser initialized")
|
||||
}
|
||||
|
||||
var values struct {
|
||||
exiftool.PhotoMeta
|
||||
exiftool.TimeAll
|
||||
exiftool.GPS
|
||||
}
|
||||
if err := globalExifParser.QueryJSONTagsByNumber(filepath, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values.PhotoMeta.SanitizeFloats()
|
||||
|
||||
ret := models.MediaEXIF{
|
||||
Camera: values.Model,
|
||||
Maker: values.Make,
|
||||
Lens: values.LensModel,
|
||||
Iso: values.ISO,
|
||||
Flash: values.Flash,
|
||||
Orientation: values.Orientation,
|
||||
ExposureProgram: values.ExposureProgram,
|
||||
Exposure: values.ExposureTime,
|
||||
Aperture: values.Aperture,
|
||||
FocalLength: values.FocalLength,
|
||||
Description: values.ImageDescription,
|
||||
}
|
||||
|
||||
dateShot := values.TimeAll.TimeInLocal()
|
||||
if !dateShot.IsZero() {
|
||||
ret.DateShot = new(dateShot)
|
||||
}
|
||||
|
||||
offsetSec, ok := values.TimeAll.OffsetSecs(dateShot)
|
||||
if ok {
|
||||
ret.OffsetSecShot = &offsetSec
|
||||
}
|
||||
|
||||
if values.GPS.IsValid() {
|
||||
ret.GPSLatitude = values.GPS.GPSLatitude
|
||||
ret.GPSLongitude = values.GPS.GPSLongitude
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func MIMEType(filepath string) (string, error) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalExifParser == nil {
|
||||
return "", fmt.Errorf("no exif parser initialized")
|
||||
}
|
||||
|
||||
var mime exiftool.MIMEType
|
||||
if err := globalExifParser.QueryJSONTagsByNumber(filepath, &mime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if mime.MIMEType == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return *mime.MIMEType, nil
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package exif
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/photoview/photoview/api/test_utils/flags"
|
||||
)
|
||||
|
||||
func resetForTest() {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
if globalExifParser != nil {
|
||||
_ = globalExifParser.Close()
|
||||
}
|
||||
globalExifParser = nil
|
||||
// Allow Initialize() to run again in subsequent tests
|
||||
globalInit = sync.Once{}
|
||||
}
|
||||
|
||||
func TestParseWithoutInit(t *testing.T) {
|
||||
resetForTest()
|
||||
if _, err := Parse("./test_data/bird.jpg"); err == nil {
|
||||
t.Fatalf("Parse() without Init() doesn't return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
resetForTest()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMIMEType(t *testing.T) {
|
||||
resetForTest()
|
||||
|
||||
cleanup, err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
filename := "./test_data/bird.jpg"
|
||||
|
||||
mime, err := MIMEType(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("MIMEType() returns an error: %v", err)
|
||||
}
|
||||
|
||||
if mime == "" {
|
||||
t.Errorf("MIMEType(%q) should not return an empty string", filename)
|
||||
}
|
||||
}
|
||||
|
||||
func fileModifyDateLiteralInUTC(t *testing.T, file string) time.Time {
|
||||
fstat, err := os.Stat(file)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Stat(%q) error: %v", file, err)
|
||||
}
|
||||
|
||||
ret := fstat.ModTime().Truncate(time.Second)
|
||||
_, offset := ret.Zone()
|
||||
ret = ret.Add(time.Duration(offset) * time.Second).UTC()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func mustParseNoTimeZone(t *testing.T, timeStr string) time.Time {
|
||||
layout := "2006:01:02 15:04:05.999"
|
||||
ret, err := time.ParseInLocation(layout, timeStr, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("time.Parse(%q) error: %v", timeStr, err)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestSamplesTime(t *testing.T) {
|
||||
resetForTest()
|
||||
|
||||
cleanup, err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
tests := []struct {
|
||||
file string
|
||||
wantTime time.Time
|
||||
wantOffsetSec *int
|
||||
}{
|
||||
{"./test_data/sample1.heif", fileModifyDateLiteralInUTC(t, "./test_data/sample1.heif"), nil},
|
||||
{"./test_data/sample1_nef.jpg", mustParseNoTimeZone(t, "2008:03:15 07:44:21.49"), new(-7 * 60 * 60)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
metadata, err := Parse(tc.file)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q) returns an error: %v", tc.file, err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatalf("metadata == nil")
|
||||
}
|
||||
if metadata.DateShot == nil {
|
||||
t.Fatalf("metadata.DateShot == nil")
|
||||
}
|
||||
|
||||
if got := *metadata.DateShot; !got.Equal(tc.wantTime) {
|
||||
t.Errorf("metadata.DateShot = %v, want: %v", got, tc.wantTime)
|
||||
}
|
||||
|
||||
if tc.wantOffsetSec == nil {
|
||||
if got := metadata.OffsetSecShot; got != nil {
|
||||
t.Errorf("metadata.OffsetSecShot = %+v, want: nil", got)
|
||||
}
|
||||
} else {
|
||||
if got, want := *metadata.OffsetSecShot, *tc.wantOffsetSec; got != want {
|
||||
t.Errorf("metadata.OffsetSecShot = &(%v), want: &(%v)", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Exiftool launches an external `exiftool` process to query photos' exif info.
|
||||
// It doesn't support concurrency usage.
|
||||
type Exiftool struct {
|
||||
path string
|
||||
version string
|
||||
marker string
|
||||
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdinBuf *bufio.Writer
|
||||
stdout *MarkReader
|
||||
stderr *MarkReader
|
||||
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
const marker = "{ready}\n"
|
||||
const bufferSize = 10240
|
||||
|
||||
// New returns a new instance of Exiftool.
|
||||
func New() (*Exiftool, error) {
|
||||
path, err := exec.LookPath("exiftool")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
voutput, err := exec.Command(path, "-ver").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run `exiftool -ver` error: %w", err)
|
||||
}
|
||||
|
||||
version := string(bytes.Trim(voutput, " \t\n\r"))
|
||||
if version == "" {
|
||||
return nil, fmt.Errorf("run `exiftool -ver` error: no output")
|
||||
}
|
||||
|
||||
cmd := exec.Command(path, "-stay_open", "True", "-@", "-")
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stdin error: %w", err)
|
||||
}
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stdout error: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := NewMarkReader(stdoutPipe, bufferSize, marker)
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stderr error: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := NewMarkReader(stderrPipe, bufferSize, marker)
|
||||
if err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
defer stdin.Close()
|
||||
return nil, fmt.Errorf("run `exiftool -stay_open True -@ -` error: %w", err)
|
||||
}
|
||||
|
||||
return &Exiftool{
|
||||
path: path,
|
||||
version: version,
|
||||
marker: marker,
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdinBuf: bufio.NewWriterSize(stdin, bufferSize),
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BinaryPath returns the path of `exiftool` binary.
|
||||
func (e *Exiftool) BinaryPath() string {
|
||||
return e.path
|
||||
}
|
||||
|
||||
// Version returns the version of `exiftool` binary.
|
||||
func (e *Exiftool) Version() string {
|
||||
return e.version
|
||||
}
|
||||
|
||||
// Close stops the external process of `exiftool`.
|
||||
func (e *Exiftool) Close() (err error) {
|
||||
e.closeOnce.Do(func() {
|
||||
defer e.cmd.Wait()
|
||||
defer e.stdin.Close()
|
||||
|
||||
if err = e.rawSendCommand("-stay_open", "False"); err != nil {
|
||||
_ = e.cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawSendCommand(args ...string) error {
|
||||
e.stdout.Reset()
|
||||
e.stderr.Reset()
|
||||
|
||||
for _, arg := range args {
|
||||
if _, err := e.stdinBuf.WriteString(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.stdinBuf.WriteByte('\n'); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range []string{"-echo4\n", "{ready}\n", "-execute\n"} {
|
||||
if _, err := e.stdinBuf.WriteString(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.stdinBuf.Flush()
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawReadStderr() error {
|
||||
stderrOutput, err := io.ReadAll(e.stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outStr := string(bytes.Trim(stderrOutput, " \n\t\r"))
|
||||
if outStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New(outStr)
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawGetTags(v any, args ...string) (err error) {
|
||||
if err = e.rawSendCommand(append(args, "-json")...); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, e.stdout)
|
||||
err = e.rawReadStderr()
|
||||
}()
|
||||
|
||||
if err = json.NewDecoder(e.stdout).Decode(v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawSaveEmbedFile(outputPath string, args ...string) (hasEmbededFile bool, err error) {
|
||||
if err = e.rawSendCommand(append(args, "-b", "-W", outputPath)...); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, e.stdout)
|
||||
err = e.rawReadStderr()
|
||||
}()
|
||||
|
||||
var output []byte
|
||||
if output, err = io.ReadAll(e.stdout); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
outStr := string(bytes.Trim(output, " \n\r\t"))
|
||||
switch outStr {
|
||||
case "0 output files created":
|
||||
return
|
||||
case "1 output files created":
|
||||
default:
|
||||
err = fmt.Errorf("invalid output: %s", outStr)
|
||||
return
|
||||
}
|
||||
|
||||
if err = e.rawReadStderr(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hasEmbededFile = true
|
||||
return
|
||||
}
|
||||
|
||||
func (e *Exiftool) rawUpdateFile(args ...string) (err error) {
|
||||
if err = e.rawSendCommand(args...); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, e.stdout)
|
||||
err = e.rawReadStderr()
|
||||
}()
|
||||
|
||||
var output []byte
|
||||
if output, err = io.ReadAll(e.stdout); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
outStr := string(bytes.Trim(output, " \n\r\t"))
|
||||
if outStr != "1 image files updated" {
|
||||
err = fmt.Errorf("invalid output: %s", outStr)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// QueryJSONTagsByNumber queries the exif info of `file` with a given struct `value`. Tags are fields of the `value`. The values are a number if possible.
|
||||
// See values.go for example structs.
|
||||
func (e *Exiftool) QueryJSONTagsByNumber(file string, value any) error {
|
||||
rows := []any{value}
|
||||
if err := e.rawGetTags(&rows, "-n", file); err != nil {
|
||||
return fmt.Errorf("query %q tags error: %w", file, err)
|
||||
}
|
||||
|
||||
if len(rows) != 1 {
|
||||
return fmt.Errorf("query %q tags error: return %d responses, should be only 1", file, len(rows))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveJPEGPreview saves a preview jpeg from `src` to `previewOutput`.
|
||||
func (e *Exiftool) SaveJPEGPreview(src string, previewOutput string) (bool, error) {
|
||||
saved, err := e.rawSaveEmbedFile(previewOutput, "-JpgFromRaw", src)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("save jpeg preview for %q error: %w", src, err)
|
||||
}
|
||||
|
||||
if !saved {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err = e.rawUpdateFile("-TagsFromFile", src, "-overwrite_original", previewOutput); err != nil {
|
||||
return false, fmt.Errorf("save tags to jpeg preview for %q error: %w", src, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/photoview/photoview/api/test_utils/flags"
|
||||
)
|
||||
|
||||
func TestExiftool(t *testing.T) {
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("create instance error: %v", err)
|
||||
}
|
||||
|
||||
t.Log("bin:", instance.BinaryPath())
|
||||
t.Log("version:", instance.Version())
|
||||
|
||||
if instance.BinaryPath() == "" {
|
||||
t.Errorf("want exiftool binary, but got an empty string")
|
||||
}
|
||||
|
||||
if instance.Version() == "" {
|
||||
t.Errorf("want exiftool version, but got an emtpy string")
|
||||
}
|
||||
|
||||
if err := instance.Close(); err != nil {
|
||||
t.Errorf("close instance error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryJSONTagsWithEmbed(t *testing.T) {
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
file := "./test_data/correct_gps.jpg"
|
||||
var value struct {
|
||||
TimeAll
|
||||
MIMEType
|
||||
PhotoMeta
|
||||
}
|
||||
|
||||
if err := instance.QueryJSONTagsByNumber(file, &value); err != nil {
|
||||
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", file, err)
|
||||
return
|
||||
}
|
||||
|
||||
if time := value.TimeAll.TimeInLocal(); time.IsZero() {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid TimeAll", file)
|
||||
}
|
||||
if value.MIMEType.MIMEType == nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid MIMEType", file)
|
||||
}
|
||||
if value.PhotoMeta.Model == nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid PhotoMeta", file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryMIMEType(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
want string
|
||||
}{
|
||||
{"./test_data/no_timezone.jpg", "image/jpeg"},
|
||||
{"./test_data/subsec_timezone.heic", "image/heic"},
|
||||
{"./test_data/raw_with_preview_jpg.cr3", "image/x-canon-cr3"},
|
||||
{"./test_data/no_exif.jpg", "image/jpeg"},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ MIMEType }
|
||||
err := instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
if err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
if got := value.MIMEType.MIMEType; got == nil || *got != tc.want {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) = %v, want: %q", tc.file, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkTimeallFieldsHasValue(t *testing.T, time TimeAll, fields []string) {
|
||||
t.Helper()
|
||||
|
||||
tv := reflect.ValueOf(time)
|
||||
|
||||
for _, field := range fields {
|
||||
fv := tv.FieldByName(field)
|
||||
if !fv.IsValid() {
|
||||
t.Errorf("can't find field %q in TimeAll", field)
|
||||
continue
|
||||
}
|
||||
|
||||
if fv.IsNil() || fv.Elem().IsZero() {
|
||||
t.Errorf("field %q is type %T with value %v", field, fv.Type(), fv.Interface())
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryTimeAllHasOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
wantKeys []string
|
||||
wantTime time.Time
|
||||
wantOffsetSec int
|
||||
}{
|
||||
{"./test_data/raw_with_preview_jpg.cr3", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
"MediaCreateDate",
|
||||
"OffsetTime",
|
||||
"OffsetTimeOriginal",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
"TimeZone",
|
||||
"TrackCreateDate",
|
||||
}, mustParseInUTC(t, "2019:09:13 14:36:48.87"), 7200},
|
||||
{"./test_data/subsec_timezone.heic", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
"OffsetTime",
|
||||
"OffsetTimeOriginal",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
}, mustParseInUTC(t, "2025:10:28 14:20:22.164"), 3600},
|
||||
{"./test_data/createdate_timezone_separate.jpg", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"TimeZone",
|
||||
"FileModifyDate",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
}, mustParseInUTC(t, "2008:03:15 07:44:21.49"), -7 * 60 * 60},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ TimeAll }
|
||||
err := instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
if err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
checkTimeallFieldsHasValue(t, value.TimeAll, tc.wantKeys)
|
||||
|
||||
gotTime := value.TimeAll.TimeInLocal()
|
||||
if !gotTime.Equal(tc.wantTime) {
|
||||
t.Errorf("value.TimeAll.TimeInLocal() = %v, want: %v", gotTime, tc.wantTime)
|
||||
}
|
||||
|
||||
if got, ok := value.TimeAll.OffsetSecs(gotTime); !ok || got != tc.wantOffsetSec {
|
||||
t.Errorf("value.TimeAll.OffsetSecs() = (%v, %v), want: (%v, true)", got, ok, tc.wantOffsetSec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fileModifyDateLiteralUTC(t *testing.T, file string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Dir(file)
|
||||
file = filepath.Base(file)
|
||||
|
||||
fsys := os.DirFS(dir)
|
||||
info, err := fs.Stat(fsys, file)
|
||||
if err != nil {
|
||||
t.Fatalf("read file stat error: %v", err)
|
||||
}
|
||||
|
||||
ret := info.ModTime().Truncate(time.Second)
|
||||
|
||||
_, offsetSec := ret.Zone()
|
||||
ret = ret.Add(time.Duration(offsetSec) * time.Second).UTC()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestExiftoolQueryTimeAllNoOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
wantKeys []string
|
||||
wantTime time.Time
|
||||
wantHasOffset bool
|
||||
wantOffset int
|
||||
}{
|
||||
{"./test_data/no_timezone.jpg", []string{
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
}, mustParseInUTC(t, "2012:05:06 15:39:44"), false, 0},
|
||||
{"./test_data/subsec_no_timezone.heic", []string{
|
||||
"CreateDate",
|
||||
"DateTimeOriginal",
|
||||
"FileModifyDate",
|
||||
"OffsetTime",
|
||||
"SubSecCreateDate",
|
||||
"SubSecDateTimeOriginal",
|
||||
}, mustParseInUTC(t, "2025:10:28 14:20:22.164"), true, 3600},
|
||||
{"./test_data/no_exif.jpg", []string{
|
||||
"FileModifyDate",
|
||||
}, fileModifyDateLiteralUTC(t, "./test_data/no_exif.jpg"), false, 0},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ TimeAll }
|
||||
err := instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
if err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
checkTimeallFieldsHasValue(t, value.TimeAll, tc.wantKeys)
|
||||
|
||||
gotTime := value.TimeAll.TimeInLocal()
|
||||
if !gotTime.Equal(tc.wantTime) {
|
||||
t.Errorf("value.TimeAll.TimeInLocal() = %v, want: %v", gotTime, tc.wantTime)
|
||||
}
|
||||
|
||||
gotOffset, gotHasOffset := value.TimeAll.OffsetSecs(gotTime)
|
||||
if gotHasOffset != tc.wantHasOffset {
|
||||
t.Errorf("value.TimeAll.OffsetSecs() = (_, %v), want: (_, %v)", gotHasOffset, tc.wantHasOffset)
|
||||
}
|
||||
if !gotHasOffset {
|
||||
return
|
||||
}
|
||||
if gotOffset != tc.wantOffset {
|
||||
t.Errorf("value.TimeAll.OffsetSecs() = (%v, _), want: (%v, _)", gotOffset, tc.wantHasOffset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolQueryGPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
hasGPS bool
|
||||
wantLat, wantLong float64
|
||||
}{
|
||||
{"./test_data/correct_gps.jpg", true, 44.4789972, 11.2979222},
|
||||
{"./test_data/incorrect_gps.jpg", false, 0, 0},
|
||||
{"./test_data/no_exif.jpg", false, 0, 0},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
var value struct{ GPS }
|
||||
if err := instance.QueryJSONTagsByNumber(tc.file, &value); err != nil {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
return
|
||||
}
|
||||
|
||||
hasGPS := value.GPS.IsValid()
|
||||
if hasGPS != tc.hasGPS {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) has GPS: %v, want GPS: %v", tc.file, hasGPS, tc.hasGPS)
|
||||
return
|
||||
}
|
||||
if !tc.hasGPS {
|
||||
return
|
||||
}
|
||||
|
||||
gpsToString := func(latitude, longitude float64) string {
|
||||
return fmt.Sprintf("(%.7f, %.7f)", latitude, longitude)
|
||||
}
|
||||
|
||||
lat := *value.GPS.GPSLatitude
|
||||
long := *value.GPS.GPSLongitude
|
||||
if got, want := gpsToString(lat, long), gpsToString(tc.wantLat, tc.wantLong); got != want {
|
||||
t.Errorf("QueryJSONTagsByNumber(%q) = %s, want: %s", tc.file, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftoolSaveJPEGPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
wantOK bool
|
||||
}{
|
||||
{"./test_data/raw_with_preview_jpg.cr3", true},
|
||||
{"./test_data/no_timezone.jpg", false},
|
||||
}
|
||||
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
outputDir := t.TempDir()
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
output := filepath.Join(outputDir, "preview.jpg")
|
||||
ok, err := instance.SaveJPEGPreview(tc.file, output)
|
||||
if err != nil {
|
||||
t.Errorf("SaveJPEGPreview(%q, %q) error: %v", tc.file, output, err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("SaveJPEGPreview(%q, %q) = %v, want: %v", tc.file, output, ok, tc.wantOK)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var jpg struct {
|
||||
MIMEType
|
||||
TimeAll
|
||||
}
|
||||
if err := instance.QueryJSONTagsByNumber(output, &jpg); err != nil {
|
||||
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", output, err)
|
||||
return
|
||||
}
|
||||
|
||||
if got, want := jpg.MIMEType.MIMEType, "image/jpeg"; got == nil || *got != want {
|
||||
t.Errorf("MIMEType(%q) = %v, want: %q", output, got, want)
|
||||
return
|
||||
}
|
||||
|
||||
var raw struct{ TimeAll }
|
||||
if err := instance.QueryJSONTagsByNumber(tc.file, &raw); err != nil {
|
||||
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
|
||||
}
|
||||
|
||||
if got, want := jpg.TimeAll.TimeInLocal(), raw.TimeAll.TimeInLocal(); !got.Equal(want) {
|
||||
t.Errorf("jpg.TimeAll.TimeInLocal() = %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestExiftoolError(t *testing.T) {
|
||||
instance, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("new error: %v", err)
|
||||
}
|
||||
defer instance.Close()
|
||||
|
||||
tests := []struct {
|
||||
file string
|
||||
errStr string
|
||||
}{
|
||||
{"./test_data/non_exist.jpg", "File not found"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
checkErr := func(err error, fmtStr string, args ...any) {
|
||||
t.Helper()
|
||||
|
||||
if want := tc.errStr; err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf(fmtStr+" %v, want %v", append(args, err, want)...)
|
||||
}
|
||||
}
|
||||
|
||||
var value MIMEType
|
||||
err = instance.QueryJSONTagsByNumber(tc.file, &value)
|
||||
checkErr(err, "QueryJSONTagsByNumber(%q)", tc.file)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "output.jpg")
|
||||
_, err = instance.SaveJPEGPreview(tc.file, output)
|
||||
checkErr(err, "SaveJPEGPreview(%q, %q)", tc.file, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// MarkReader reads from upstream and stops at marker boundaries.
|
||||
type MarkReader struct {
|
||||
upstream io.Reader
|
||||
buf []byte
|
||||
mark []byte
|
||||
|
||||
// valid data: buf[start:pending]
|
||||
// pending, need to check if contains mark: buf[pending:end]
|
||||
start int
|
||||
pending int
|
||||
end int
|
||||
|
||||
// if true, mark begins from buf[pending]
|
||||
hasMark bool
|
||||
// if true, upstream is EOF
|
||||
upstreamEOF bool
|
||||
// if true, this reader returns EOF
|
||||
paused bool
|
||||
}
|
||||
|
||||
// NewMarkReader creates a reader that stops at each marker and requires Reset to continue.
|
||||
func NewMarkReader(upstream io.Reader, bufferSize int, mark string) (*MarkReader, error) {
|
||||
if bufferSize < 2*len(mark) {
|
||||
return nil, errors.New("buffer too small")
|
||||
}
|
||||
|
||||
return &MarkReader{
|
||||
upstream: upstream,
|
||||
buf: make([]byte, bufferSize),
|
||||
mark: []byte(mark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Reset resumes reading after a previously encountered marker boundary.
|
||||
func (r *MarkReader) Reset() {
|
||||
r.paused = false
|
||||
}
|
||||
|
||||
// Read returns bytes up to (but excluding) the next marker, then reports EOF until Reset.
|
||||
func (r *MarkReader) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if r.paused {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
if r.start == r.pending {
|
||||
// compact buffer
|
||||
copy(r.buf[0:], r.buf[r.start:r.end])
|
||||
r.end -= r.start
|
||||
r.pending -= r.start
|
||||
r.start = 0
|
||||
|
||||
// fill buffer
|
||||
n, err := io.ReadAtLeast(r.upstream, r.buf[r.end:], len(r.mark))
|
||||
r.end += n
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
r.upstreamEOF = true
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
r.checkMarkInPending()
|
||||
}
|
||||
|
||||
// read from valid data
|
||||
n := copy(p, r.buf[r.start:r.pending])
|
||||
r.start += n
|
||||
|
||||
if r.start == r.pending {
|
||||
switch {
|
||||
case r.hasMark:
|
||||
r.pending += len(r.mark)
|
||||
r.start += len(r.mark)
|
||||
r.hasMark = false
|
||||
r.paused = true
|
||||
|
||||
r.checkMarkInPending()
|
||||
case r.upstreamEOF:
|
||||
r.paused = r.start == r.end
|
||||
}
|
||||
}
|
||||
|
||||
if r.paused {
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *MarkReader) checkMarkInPending() {
|
||||
if markAt := bytes.Index(r.buf[r.pending:r.end], r.mark); markAt >= 0 {
|
||||
r.hasMark = true
|
||||
r.pending = r.pending + markAt
|
||||
return
|
||||
}
|
||||
|
||||
if r.upstreamEOF {
|
||||
r.pending = r.end
|
||||
return
|
||||
}
|
||||
|
||||
r.pending = max(r.pending, r.end-len(r.mark)+1)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
)
|
||||
|
||||
func readAllString(t *testing.T, r io.Reader) string {
|
||||
t.Helper()
|
||||
|
||||
var data []byte
|
||||
|
||||
for {
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
var n int
|
||||
var err error
|
||||
|
||||
var memstats runtime.MemStats
|
||||
runtime.ReadMemStats(&memstats)
|
||||
mallocs := 0 - memstats.Mallocs
|
||||
|
||||
n, err = r.Read(buf)
|
||||
|
||||
runtime.ReadMemStats(&memstats)
|
||||
mallocs += memstats.Mallocs
|
||||
|
||||
if mallocs > 0 {
|
||||
t.Errorf("r.Read() allocs %d bytes", mallocs)
|
||||
}
|
||||
|
||||
data = append(data, buf[:n]...)
|
||||
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
t.Error("read error, return 0 byte without errors")
|
||||
}
|
||||
}
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestNewMarkReader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bufferSize int
|
||||
mark string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "BufferTooSmall",
|
||||
bufferSize: 5,
|
||||
mark: "abcd",
|
||||
wantErr: "buffer too small",
|
||||
},
|
||||
{
|
||||
name: "TwiceMarkLen",
|
||||
bufferSize: 8,
|
||||
mark: "abcd",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewMarkReader(strings.NewReader("x"), tc.bufferSize, tc.mark)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("NewMarkReader() returns error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil || err.Error() != tc.wantErr {
|
||||
t.Errorf("err = %v, want: %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkReader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mark string
|
||||
input string
|
||||
bufferSize int
|
||||
wantSegments []string
|
||||
}{
|
||||
{
|
||||
name: "CrossRead",
|
||||
mark: "<<MARK>>",
|
||||
input: "hello<<MARK>>world",
|
||||
bufferSize: 2 * 8, /* len(mark) = 8 */
|
||||
wantSegments: []string{"hello", "world"},
|
||||
},
|
||||
{
|
||||
name: "EndWithMark",
|
||||
mark: "<<MARK>>",
|
||||
input: "foo<<MARK>>bar<<MARK>>",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
name: "TrailingPrefixMark",
|
||||
mark: "<<MARK>>",
|
||||
input: "foo<<MARK>>bar<<MAR",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"foo", "bar<<MAR"},
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
mark: "<<MARK>>",
|
||||
input: "<<MARK>><<MARK>>abc<<MARK>>",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"", "", "abc"},
|
||||
},
|
||||
{
|
||||
name: "LongMark",
|
||||
mark: "01234567890123456789",
|
||||
input: "a01234567890123456789b",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "RepeatMark",
|
||||
mark: "aaaaa", /* a * 5 */
|
||||
input: "0aaaaa1aaa2aaaa3aaaaa4aaaaaa5",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"0", "1aaa2aaaa3", "4", "a5"},
|
||||
},
|
||||
{
|
||||
name: "OneByteMark",
|
||||
mark: "|",
|
||||
input: "0|1|234|5|6",
|
||||
bufferSize: 64,
|
||||
wantSegments: []string{"0", "1", "234", "5", "6"},
|
||||
},
|
||||
}
|
||||
|
||||
readWraper := []struct {
|
||||
name string
|
||||
wrap func(r io.Reader) io.Reader
|
||||
}{
|
||||
{"Original", func(r io.Reader) io.Reader { return r }},
|
||||
{"Half", iotest.HalfReader},
|
||||
{"OneByte", iotest.OneByteReader},
|
||||
}
|
||||
|
||||
for _, wrapper := range readWraper {
|
||||
t.Run(wrapper.name, func(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
up := wrapper.wrap(strings.NewReader(tc.input))
|
||||
|
||||
r, err := NewMarkReader(up, tc.bufferSize, tc.mark)
|
||||
if err != nil {
|
||||
t.Fatal("NewMarkReader() returns error:", err)
|
||||
}
|
||||
|
||||
for i, want := range tc.wantSegments {
|
||||
if got := readAllString(t, r); got != want {
|
||||
t.Errorf("segment[%d] = %q, want: %q", i, got, want)
|
||||
}
|
||||
|
||||
var buf [1024]byte
|
||||
if _, err := r.Read(buf[:]); err != io.EOF {
|
||||
t.Error("r.Read() after readAllString() returns non-EOF error:", err)
|
||||
}
|
||||
|
||||
r.Reset()
|
||||
}
|
||||
|
||||
var buf [1024]byte
|
||||
if _, err := r.Read(buf[:]); err != io.EOF {
|
||||
t.Error("r.Read() after all segments returns non-EOF error:", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GPS stores gps-related tags.
|
||||
type GPS struct {
|
||||
GPSLatitude *float64
|
||||
GPSLongitude *float64
|
||||
}
|
||||
|
||||
// IsValid returns true when GPS data is valid.
|
||||
func (gps GPS) IsValid() bool {
|
||||
if gps.GPSLongitude == nil || gps.GPSLatitude == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if math.IsNaN(*gps.GPSLatitude) {
|
||||
return false
|
||||
}
|
||||
|
||||
if math.IsNaN(*gps.GPSLongitude) {
|
||||
return false
|
||||
}
|
||||
|
||||
if math.Abs(*gps.GPSLatitude) > 90 || math.Abs(*gps.GPSLongitude) > 180 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (gps GPS) String() string {
|
||||
if !gps.IsValid() {
|
||||
return "GPS(invalid)"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("GPS(%.9f, %.9f)", *gps.GPSLatitude, *gps.GPSLongitude)
|
||||
}
|
||||
|
||||
// TimeAll stores tags returned by -time:all.
|
||||
type TimeAll struct {
|
||||
SubSecDateTimeOriginal *string
|
||||
SubSecCreateDate *string
|
||||
|
||||
DateTimeOriginal *string
|
||||
CreateDate *string
|
||||
TrackCreateDate *string
|
||||
MediaCreateDate *string
|
||||
FileModifyDate *string
|
||||
|
||||
OffsetTimeOriginal *string
|
||||
OffsetTime *string
|
||||
TimeZone *int
|
||||
|
||||
GPSDateTime *string
|
||||
}
|
||||
|
||||
const layout = "2006:01:02 15:04:05.999"
|
||||
const layoutWithTimezone = "2006:01:02 15:04:05.999Z07:00"
|
||||
|
||||
// TimeInLocal returns most likely time. The date and time are in local. The timezone is meaningless and always be in UTC. Use `OffsetSecs()` to determine the timezone.
|
||||
func (t TimeAll) TimeInLocal() time.Time {
|
||||
for _, dateP := range []*string{
|
||||
// Keep the order for the priority to generate DateShot
|
||||
t.SubSecDateTimeOriginal,
|
||||
t.SubSecCreateDate,
|
||||
t.DateTimeOriginal,
|
||||
t.CreateDate,
|
||||
t.TrackCreateDate,
|
||||
t.MediaCreateDate,
|
||||
t.FileModifyDate,
|
||||
} {
|
||||
if dateP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
date := *dateP
|
||||
|
||||
// Ignore timezone
|
||||
if zoneIndex := strings.IndexAny(date, "+-Z"); zoneIndex >= 0 {
|
||||
date = date[:zoneIndex]
|
||||
}
|
||||
|
||||
if date, err := time.ParseInLocation(layout, date, time.UTC); err == nil {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// OffsetSecs returns seconds offset by UTC.
|
||||
func (t TimeAll) OffsetSecs(local time.Time) (int, bool) {
|
||||
for _, offsetP := range []*string{
|
||||
t.OffsetTimeOriginal,
|
||||
t.OffsetTime,
|
||||
} {
|
||||
if offsetP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if t, err := time.Parse("-07:00", *offsetP); err == nil {
|
||||
_, offsetSecs := t.Zone()
|
||||
return offsetSecs, true
|
||||
}
|
||||
}
|
||||
|
||||
// TimeZone is in minutes
|
||||
if t.TimeZone != nil {
|
||||
return *t.TimeZone * 60, true
|
||||
}
|
||||
|
||||
// Calculate offset sec with GPS time and local time.
|
||||
if local.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if t.GPSDateTime == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
gpsDate, err := time.Parse(layoutWithTimezone, *t.GPSDateTime)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
gpsDate = gpsDate.UTC()
|
||||
|
||||
// GPS time is always UTC per EXIF spec
|
||||
// offset = local time (in UTC) - GPS UTC time
|
||||
offset := int(local.Sub(gpsDate).Seconds())
|
||||
return offset, true
|
||||
}
|
||||
|
||||
type PhotoMeta struct {
|
||||
ImageDescription *string
|
||||
Model *string
|
||||
Make *string
|
||||
LensModel *string
|
||||
ISO *int64
|
||||
Flash *int64
|
||||
Orientation *int64
|
||||
ExposureProgram *int64
|
||||
ExposureTime *float64
|
||||
Aperture *float64
|
||||
FocalLength *float64
|
||||
}
|
||||
|
||||
func (m *PhotoMeta) SanitizeFloats() {
|
||||
for _, value := range []**float64{
|
||||
&m.ExposureTime,
|
||||
&m.Aperture,
|
||||
&m.FocalLength,
|
||||
} {
|
||||
if *value == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if math.IsNaN(**value) || math.IsInf(**value, 0) {
|
||||
*value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MIMEType struct {
|
||||
MIMEType *string
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGPSIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gps GPS
|
||||
wantValid bool
|
||||
}{
|
||||
{"LatNormalLongNormal", GPS{new(10.0), new(10.0)}, true},
|
||||
{"ZeroValue", GPS{new(0.0), new(0.0)}, true},
|
||||
|
||||
{"LatNilLongNormal", GPS{new(math.NaN()), new(10.0)}, false},
|
||||
{"LatNormalLongNil", GPS{new(10.0), new(math.NaN())}, false},
|
||||
|
||||
{"Lat>90LongNormal", GPS{new(100.0), new(10.0)}, false},
|
||||
{"Lat<-90LongNormal", GPS{new(-100.0), new(10.0)}, false},
|
||||
|
||||
{"LatNormalLong>180", GPS{new(10.0), new(190.0)}, false},
|
||||
{"LatNormalLong<-180", GPS{new(10.0), new(-190.0)}, false},
|
||||
|
||||
{"Empty", GPS{}, false},
|
||||
{"EmptyLat", GPS{nil, new(0.0)}, false},
|
||||
{"EmptyLong", GPS{new(0.0), nil}, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.gps.IsValid()
|
||||
if got != tc.wantValid {
|
||||
t.Fatalf("gps.IsValid(%v) = %v, want: %v", tc.gps, got, tc.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseInUTC(t *testing.T, timeStr string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
ret, err := time.ParseInLocation(layout, timeStr, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("time.ParseInLocation(%q) returns error: %v", timeStr, err)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestTimeAllTimeLocal(t *testing.T) {
|
||||
wantStr := "2025:10:28 14:20:22.164"
|
||||
otherStr := "2024:10:28 14:20:22.164"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeAll TimeAll
|
||||
want time.Time
|
||||
}{
|
||||
{"DateTimeOriginal", TimeAll{
|
||||
DateTimeOriginal: &wantStr,
|
||||
CreateDate: &otherStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"CreateDate", TimeAll{
|
||||
CreateDate: &wantStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"TrackCreateDate", TimeAll{
|
||||
TrackCreateDate: &wantStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"MediaCreateDate", TimeAll{
|
||||
MediaCreateDate: &wantStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"FileModifyDate", TimeAll{
|
||||
FileModifyDate: &wantStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.timeAll.TimeInLocal(); !got.Equal(tc.want) {
|
||||
t.Errorf("timeAll.Time() = %v, want: %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllTimeWithTimezone(t *testing.T) {
|
||||
wantStr := "2025:10:28 14:20:22.164"
|
||||
otherStr := "2024:10:28 14:20:22.164"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeAll TimeAll
|
||||
want time.Time
|
||||
}{
|
||||
{"SubSecDateTimeOriginal", TimeAll{
|
||||
SubSecDateTimeOriginal: &wantStr,
|
||||
SubSecCreateDate: &otherStr,
|
||||
DateTimeOriginal: &otherStr,
|
||||
CreateDate: &otherStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
{"SubSecCreateDate", TimeAll{
|
||||
SubSecCreateDate: &wantStr,
|
||||
DateTimeOriginal: &otherStr,
|
||||
CreateDate: &otherStr,
|
||||
TrackCreateDate: &otherStr,
|
||||
MediaCreateDate: &otherStr,
|
||||
FileModifyDate: &otherStr,
|
||||
}, mustParseInUTC(t, wantStr)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.timeAll.TimeInLocal(); !got.Equal(tc.want) {
|
||||
t.Errorf("timeAll.Time() = %v, want: %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllTimeEmpty(t *testing.T) {
|
||||
var timeAll TimeAll
|
||||
if got := timeAll.TimeInLocal(); !got.IsZero() {
|
||||
t.Errorf("timeAll.Time() is a valid time, which it should not be")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllOffsetSecs(t *testing.T) {
|
||||
wantStr := "+01:00"
|
||||
otherStr := "-01:00"
|
||||
gpsDateTime := "2025:10:28 14:20:22Z"
|
||||
otherTimezone := -120
|
||||
localTime := mustParseInUTC(t, "2025:10:28 13:20:22")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeAll TimeAll
|
||||
want int
|
||||
}{
|
||||
{"OffsetTimeOriginal", TimeAll{
|
||||
OffsetTimeOriginal: &wantStr,
|
||||
OffsetTime: &otherStr,
|
||||
TimeZone: &otherTimezone,
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, 60 * 60},
|
||||
{"OffsetTime", TimeAll{
|
||||
OffsetTime: &wantStr,
|
||||
TimeZone: &otherTimezone,
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, 60 * 60},
|
||||
{"TimeZone", TimeAll{
|
||||
TimeZone: &otherTimezone,
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, -120 * 60},
|
||||
{"GPS", TimeAll{
|
||||
GPSDateTime: &gpsDateTime,
|
||||
}, -60 * 60},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := tc.timeAll.OffsetSecs(localTime)
|
||||
if !ok {
|
||||
t.Fatalf("timeAll.OffsetSecs() is not valid, which it should be")
|
||||
}
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("timeAll.OffsetSecs() = %v, want: %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeAllOffsetSecsEmptyLocal(t *testing.T) {
|
||||
timeAll := TimeAll{
|
||||
GPSDateTime: new("14:20:22 2025:10:28Z"),
|
||||
}
|
||||
|
||||
got, ok := timeAll.OffsetSecs(time.Time{})
|
||||
if ok || got != 0 {
|
||||
t.Errorf("timeAll.OffsetSecs() = (%d, %v), want: (%d, %v)", got, ok, 0, false)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user