From 17c41331f2f39b904bdec096405e5ce930bca9ab Mon Sep 17 00:00:00 2001 From: Brian Zimmer Date: Thu, 4 Nov 2021 18:09:59 -0700 Subject: [PATCH] refactored exif processing to allow extensible parsers (#54) --- .vscode/settings.json | 7 +- cmd/ma/main.go | 10 ++- cp.go | 173 ++++++++++++++---------------------------- cp_test.go | 26 +++++++ docs/manual.md | 3 +- exif.go | 117 ++++++++++++++++++++++++++++ exif_test.go | 81 ++++++++++++++++++++ exiftool.go | 74 ++++++++++++++++++ export.go | 4 +- export_test.go | 4 +- go.mod | 5 +- go.sum | 6 +- ma_test.go | 1 + runtime.go | 2 + 14 files changed, 383 insertions(+), 130 deletions(-) create mode 100644 exif.go create mode 100644 exif_test.go create mode 100644 exiftool.go diff --git a/.vscode/settings.json b/.vscode/settings.json index ac9a2bf..a3b2412 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,8 @@ { - "go.buildTags": "integration", + "gopls": { + "buildFlags": [ + "-tags", + "integration,exiftool" + ], + } } diff --git a/cmd/ma/main.go b/cmd/ma/main.go index 2771e7a..725d29e 100644 --- a/cmd/ma/main.go +++ b/cmd/ma/main.go @@ -53,6 +53,12 @@ func flags() []cli.Flag { Value: false, Required: false, }, + &cli.BoolFlag{ + Name: "monochrome", + Required: false, + Usage: "disable colored output", + Value: false, + }, &cli.BoolFlag{ Name: "debug", Required: false, @@ -74,7 +80,7 @@ func initLogging(c *cli.Context) { log.Logger = log.Output( zerolog.ConsoleWriter{ Out: c.App.ErrWriter, - NoColor: false, + NoColor: c.Bool("monochrome"), TimeFormat: time.RFC3339, }, ) @@ -144,6 +150,7 @@ func main() { Metrics: metric, Encoder: enc, Fs: afero.NewOsFs(), + Exif: ma.NewGoExif(), }, } @@ -152,6 +159,7 @@ func main() { After: ma.Stats, Commands: []*cli.Command{ ma.CommandCopy(), + ma.CommandExif(), ma.CommandExport(), ma.CommandFind(), ma.CommandList(), diff --git a/cp.go b/cp.go index ac645bb..680ca7e 100644 --- a/cp.go +++ b/cp.go @@ -1,136 +1,84 @@ package ma import ( - "bytes" "context" "errors" "fmt" "io" "io/fs" "path/filepath" + "sort" "strings" "time" "github.com/armon/go-metrics" "github.com/rs/zerolog/log" - "github.com/rwcarlsen/goexif/exif" "github.com/spf13/afero" "github.com/urfave/cli/v2" "golang.org/x/sync/errgroup" ) -const ( - defaultBufferSize = 1024 * 1024 - defaultDateFormat = "2006/2006-01/02" -) - -func defaultImages() []string { - return []string{".raf", ".nef", ".dng", ".jpg", ".jpeg"} -} +const defaultDateFormat = "2006/2006-01/02" -func split(fullname string) (dirname, basename string) { +func split(fullname string) identifier { dirname, filename := filepath.Split(fullname) n := strings.LastIndexFunc(filename, func(s rune) bool { return s == '.' }) + var basename string switch n { case -1: basename = filename default: basename = filename[0:n] } - dirname = filepath.Clean(dirname) - return -} - -type dateTimeExif struct { - fs afero.Fs - src string - ext string - info fs.FileInfo -} - -func (b *dateTimeExif) bufferSize() int64 { - switch b.ext { - case ".orf", ".dng", ".nef": - return b.info.Size() - default: - return defaultBufferSize - } + return identifier{dirname: filepath.Clean(dirname), basename: basename} } -func (b *dateTimeExif) dateTime() (time.Time, error) { - fp, err := b.fs.Open(filepath.Join(b.src, b.info.Name())) - if err != nil { - return time.Time{}, err - } - defer fp.Close() - data := make([]byte, b.bufferSize()) - _, err = fp.Read(data) - if err != nil { - return time.Time{}, err - } - x, err := exif.Decode(bytes.NewBuffer(data)) - if err != nil { - return time.Time{}, err - } - tm, err := x.DateTime() - if err != nil { - return time.Time{}, err - } - return tm, err +type identifier struct { + dirname string + basename string } -type fileSet struct { - files []fs.FileInfo +type fileset struct { + identifier identifier + files []fs.FileInfo } -func (f *fileSet) add(info fs.FileInfo) { - f.files = append(f.files, info) -} - -func (f *fileSet) dateTime(afs afero.Fs, dirname string) (time.Time, error) { - // for every file in the fileset attempt to create a time.Time - times := make(map[string]time.Time) +// dateTime attempts to create a time.Time for for every file in the fileset +func (f *fileset) dateTime(afs afero.Fs, ex Exif) (time.Time, error) { + var infos []fs.FileInfo for i := range f.files { info := f.files[i] ext := strings.ToLower(filepath.Ext(info.Name())) switch ext { - case ".jpg", ".jpeg", ".raf", ".dng", ".nef": - dt := &dateTimeExif{fs: afs, src: dirname, ext: ext, info: info} - t, err := dt.dateTime() - if err != nil { - return time.Time{}, err - } - times[ext] = t - case ".mp4", ".mov", ".avi": - // @todo(movies) - case ".orf": - // @todo(orf) case "", ".xmp": // not trustworthy for valid dates + default: + infos = append(infos, info) } } - - // in priority order, find the first non-zero time.Time - for _, ext := range defaultImages() { - t, ok := times[ext] - if ok { - return t, nil + if len(infos) == 0 { + return time.Time{}, nil + } + var times []time.Time + mds := ex.Extract(afs, f.identifier.dirname, infos...) + for i := range mds { + if mds[i].Err != nil { + return time.Time{}, mds[i].Err } + times = append(times, mds[i].DateTime) } - - // found no time - return time.Time{}, nil -} - -type entangle struct { - source string - fileSet *fileSet + sort.SliceStable(times, func(i, j int) bool { + return times[i].Before(times[j]) + }) + // @todo(bzimmer) ensure dates are consistent (within a ~second or so) + return times[0], nil } type entangler struct { fs afero.Fs + exif Exif metrics *metrics.Metrics concurrency int dryrun bool @@ -138,52 +86,50 @@ type entangler struct { } func (c *entangler) cp(ctx context.Context, sources []string, destination string) error { - q := make(chan *entangle) + q := make(chan *fileset) grp, ctx := errgroup.WithContext(ctx) grp.Go(func() error { defer close(q) - sets := make(map[string]map[string]*fileSet) + sets := make(map[identifier][]fs.FileInfo) for i := range sources { select { case <-ctx.Done(): return ctx.Err() default: - if err := afero.Walk(c.fs, sources[i], c.fileSets(sets)); err != nil { + if err := afero.Walk(c.fs, sources[i], c.filesets(sets)); err != nil { return err } } } - for dirname, filesets := range sets { - for _, fileset := range filesets { - select { - case <-ctx.Done(): - return ctx.Err() - case q <- &entangle{source: dirname, fileSet: fileset}: - c.metrics.IncrCounter([]string{"cp", "filesets"}, 1) - } + for id, files := range sets { + select { + case <-ctx.Done(): + return ctx.Err() + case q <- &fileset{identifier: id, files: files}: + c.metrics.IncrCounter([]string{"cp", "filesets"}, 1) } } return nil }) for i := 0; i < c.concurrency; i++ { - grp.Go(c.copyFileSet(q, destination)) + grp.Go(c.copyFileset(q, destination)) } return grp.Wait() } -func (c *entangler) copyFileSet(q <-chan *entangle, destination string) func() error { +func (c *entangler) copyFileset(q <-chan *fileset, destination string) func() error { return func() error { - for ent := range q { + for x := range q { c.metrics.IncrCounter([]string{"cp", "fileset", "attempt"}, 1) - dt, err := ent.fileSet.dateTime(c.fs, ent.source) + dt, err := x.dateTime(c.fs, c.exif) if err != nil { c.metrics.IncrCounter([]string{"cp", "fileset", "failed", "exif"}, 1) return err } if dt.IsZero() { c.metrics.IncrCounter([]string{"cp", "fileset", "skip", "unsupported"}, 1) - for i := range ent.fileSet.files { - filename := ent.fileSet.files[i].Name() + for i := range x.files { + filename := filepath.Join(x.identifier.dirname, x.files[i].Name()) ext := filepath.Ext(filename) ext = strings.TrimPrefix(ext, ".") if ext == "" { @@ -195,9 +141,9 @@ func (c *entangler) copyFileSet(q <-chan *entangle, destination string) func() e continue } df := dt.Format(c.dateFormat) - for i := range ent.fileSet.files { - src := filepath.Join(ent.source, ent.fileSet.files[i].Name()) - dst := filepath.Join(destination, df, ent.fileSet.files[i].Name()) + for i := range x.files { + src := filepath.Join(x.identifier.dirname, x.files[i].Name()) + dst := filepath.Join(destination, df, x.files[i].Name()) if err := c.copyFile(src, dst); err != nil { return err } @@ -265,8 +211,8 @@ func (c *entangler) copyFile(source, destination string) error { return nil } -// fileSets creates fileSets from a directory traversal -func (c *entangler) fileSets(sets map[string]map[string]*fileSet) filepath.WalkFunc { +// filesets creates filesets from a directory traversal +func (c *entangler) filesets(sets map[identifier][]fs.FileInfo) filepath.WalkFunc { return func(path string, info fs.FileInfo, err error) error { if err != nil { if errors.Is(err, fs.ErrPermission) { @@ -286,18 +232,8 @@ func (c *entangler) fileSets(sets map[string]map[string]*fileSet) filepath.WalkF } c.metrics.IncrCounter([]string{"cp", "visited", "files"}, 1) - dirname, basename := split(path) - dirs, ok := sets[dirname] - if !ok { - dirs = make(map[string]*fileSet) - sets[dirname] = dirs - } - fileset, ok := dirs[basename] - if !ok { - fileset = new(fileSet) - dirs[basename] = fileset - } - fileset.add(info) + id := split(path) + sets[id] = append(sets[id], info) return nil } @@ -314,6 +250,7 @@ func cp(c *cli.Context) error { concurrency: c.Int("concurrency"), dryrun: c.Bool("dryrun"), dateFormat: c.String("format"), + exif: runtime(c).Exif, } args := c.Args().Slice() destination, err := filepath.Abs(args[len(args)-1]) @@ -328,7 +265,7 @@ func CommandCopy() *cli.Command { Name: "cp", HelpName: "cp", Usage: "copy files to a the directory structure of `--format`", - Description: "copy files from a source(s) to a destination using the Exif format to create the directory structure", + Description: "copy files from a source(s) to a destination using the image date to layout the directory structure", ArgsUsage: " [, ] ", Flags: []cli.Flag{ &cli.BoolFlag{ diff --git a/cp_test.go b/cp_test.go index f5e7492..d9a6264 100644 --- a/cp_test.go +++ b/cp_test.go @@ -202,6 +202,32 @@ func TestCopy(t *testing.T) { //nolint return nil }, }, + { + name: "two valid files", + args: []string{"ma", "cp", "/foo/bar", "/foo/baz"}, + counters: map[string]int{ + "ma.cp.visited.directories": 1, + "ma.cp.visited.files": 2, + }, + before: func(c *cli.Context) error { + fp := createTestFile(t, runtime(c).Fs) + a.NoError(fp.Close()) + fp, err := runtime(c).Fs.Create("/foo/bar/Nikon_D70_0.jpg") + a.NoError(err) + a.NoError(copyFile(fp, "testdata/Nikon_D70.jpg")) + a.NoError(fp.Close()) + return nil + }, + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") + a.NoError(err) + a.NotNil(stat) + stat, err = runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70_0.jpg") + a.NoError(err) + a.NotNil(stat) + return nil + }, + }, { name: "image + xmp dry-run", args: []string{"ma", "cp", "-n", "/foo/bar", "/foo/baz"}, diff --git a/docs/manual.md b/docs/manual.md index 7a85977..f81bc5e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -10,6 +10,7 @@ All your media archiving needs! |```smugmug-access-token```||smugmug access token| |```smugmug-token-secret```||smugmug token secret| |```json```|```j```|encode all results as JSON and print to stdout| +|```monochrome```||disable colored output| |```debug```||enable debugging of http requests| |```help```|```h```|show help| @@ -61,7 +62,7 @@ $ ma commands [flags] **Description** -copy files from a source(s) to a destination using the Exif format to create the directory structure +copy files from a source(s) to a destination using the image date to layout the directory structure **Syntax** diff --git a/exif.go b/exif.go new file mode 100644 index 0000000..58f8589 --- /dev/null +++ b/exif.go @@ -0,0 +1,117 @@ +package ma + +import ( + "errors" + "io" + "io/fs" + "path/filepath" + "strings" + "time" + + "github.com/rs/zerolog/log" + "github.com/rwcarlsen/goexif/exif" + "github.com/spf13/afero" + "github.com/urfave/cli/v2" +) + +var _ Exif = (*goExif)(nil) + +const exifHeaderSize = 4 + +// MetaData represents the EXIF data about a file +type MetaData struct { + // Info is the analyzed file + Info fs.FileInfo + + // Err is non-nil if an error occurred processing the file + Err error + // DateTime is the best effort `DateTimeOriginal` of the file + DateTime time.Time +} + +// Exif extracts EXIF metadata from files +type Exif interface { + // Extract returns metadata about a file + Extract(afs afero.Fs, dirname string, infos ...fs.FileInfo) []MetaData +} + +func NewGoExif() Exif { + return new(goExif) +} + +type goExif struct{} + +func (x *goExif) datetime(afs afero.Fs, filename string) (time.Time, error) { + fp, err := afs.Open(filename) + if err != nil { + return time.Time{}, err + } + defer fp.Close() + m, err := exif.Decode(fp) + if err != nil { + return time.Time{}, err + } + return m.DateTime() +} + +func (x *goExif) Extract(afs afero.Fs, dirname string, infos ...fs.FileInfo) []MetaData { + mds := make([]MetaData, len(infos)) + for i := range infos { + mds[i] = MetaData{Info: infos[i]} + ext := strings.ToLower(filepath.Ext(mds[i].Info.Name())) + switch ext { + case "", ".pxm", ".xmp": + case ".orf", ".mov", ".avi", ".mp4": + mds[i].DateTime = mds[i].Info.ModTime() + default: + if mds[i].Info.Size() < exifHeaderSize { + // the exif header is four bytes long so bail rather than EOF + continue + } + mds[i].DateTime, mds[i].Err = x.datetime(afs, filepath.Join(dirname, mds[i].Info.Name())) + } + } + return mds +} + +func xif(c *cli.Context) error { + afs := runtime(c).Fs + exf := runtime(c).Exif + for i := 0; i < c.NArg(); i++ { + if err := afero.Walk(afs, c.Args().Get(i), func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + dirname, _ := filepath.Split(path) + for _, m := range exf.Extract(afs, dirname, info) { + if m.Err != nil { + if errors.Is(m.Err, io.EOF) { + log.Warn().Time("datetime", m.DateTime).Str("filename", path).Msg(c.Command.Name) + return nil + } + log.Err(m.Err).Time("datetime", m.DateTime).Str("filename", path).Msg(c.Command.Name) + return m.Err + } + log.Info().Str("filename", path).Time("datetime", m.DateTime).Msg(c.Command.Name) + } + return nil + }); err != nil { + return err + } + } + return nil +} + +func CommandExif() *cli.Command { + return &cli.Command{ + Name: "exif", + HelpName: "exif", + Hidden: true, + Usage: "debugging tool for exif data", + Description: "debugging tool for exif data", + Action: xif, + } +} diff --git a/exif_test.go b/exif_test.go new file mode 100644 index 0000000..cb79833 --- /dev/null +++ b/exif_test.go @@ -0,0 +1,81 @@ +package ma_test + +import ( + "io/fs" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" + + "github.com/bzimmer/ma" +) + +func TestExif(t *testing.T) { + a := assert.New(t) + tests := []harness{ + { + name: "no arguments", + args: []string{"ma", "exif"}, + }, + { + name: "supported exif file", + args: []string{"ma", "exif", "/foo/bar/Nikon_D70.jpg"}, + before: func(c *cli.Context) error { + fp := createTestFile(t, runtime(c).Fs) + a.NotNil(fp) + defer fp.Close() + return nil + }, + }, + { + name: "error does not exist", + args: []string{"ma", "exif", "/foo/bar/Nikon_D70.jpg"}, + err: os.ErrNotExist.Error(), + }, + { + name: "error opening file", + args: []string{"ma", "exif", "/foo/bar/"}, + err: os.ErrPermission.Error(), + before: func(c *cli.Context) error { + fp := createTestFile(t, runtime(c).Fs) + fp.Close() + runtime(c).Fs = &ErrFs{Fs: runtime(c).Fs, err: fs.ErrPermission, name: fp.Name()} + return nil + }, + }, + { + name: "unsupported exif file", + args: []string{"ma", "exif", "/foo/bar/Olympus.orf"}, + before: func(c *cli.Context) error { + afs := runtime(c).Fs + a.NoError(afs.MkdirAll("/foo/bar", 0755)) + fp, err := afs.Create("/foo/bar/Olympus.orf") + a.NoError(err) + a.NotNil(fp) + defer fp.Close() + return nil + }, + }, + { + name: "file with no exif data", + args: []string{"ma", "exif", "/foo/bar/user_cmac.json"}, + before: func(c *cli.Context) error { + afs := runtime(c).Fs + a.NoError(afs.MkdirAll("/foo/bar", 0755)) + fp, err := afs.Create("/foo/bar/user_cmac.json") + a.NoError(err) + a.NotNil(fp) + a.NoError(copyFile(fp, "testdata/user_cmac.json")) + defer fp.Close() + return nil + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + run(t, &tt, nil, ma.CommandExif) + }) + } +} diff --git a/exiftool.go b/exiftool.go new file mode 100644 index 0000000..3e5baee --- /dev/null +++ b/exiftool.go @@ -0,0 +1,74 @@ +//go:build exiftool + +package ma + +/* +This implementation of an `Exif` leverages the external `exiftool`. While `exiftool` +is definitely more capable of a wide range of files it's also far slower and requires an +external dependency. +*/ + +import ( + "errors" + "io/fs" + "path/filepath" + "strings" + "time" + + "github.com/barasher/go-exiftool" + "github.com/spf13/afero" +) + +var _ Exif = (*perl)(nil) + +const exifTimeLayout = "2006:01:02 15:04:05" + +type ExiftoolOption func(*perl) error + +func NewExiftool(options ...ExiftoolOption) (Exif, error) { + x := new(perl) + for _, opt := range options { + if err := opt(x); err != nil { + return nil, err + } + } + if x.tool == nil { + return nil, errors.New("no exiftool.Tool specified") + } + return x, nil +} + +type perl struct { + tool *exiftool.Exiftool +} + +func (x *perl) Extract(_ afero.Fs, dirname string, infos ...fs.FileInfo) []MetaData { + filenames := make([]string, len(infos)) + for i := range infos { + switch ext := strings.ToLower(filepath.Ext(infos[i].Name())); ext { + case "", ".pxm", ".xmp": + default: + filenames[i] = filepath.Join(dirname, infos[i].Name()) + } + } + mds := make([]MetaData, len(infos)) + + for i, m := range x.tool.ExtractMetadata(filenames...) { + if m.Err != nil { + if filenames[i] != "" { + mds[i].Err = m.Err + } + continue + } + if dto, ok := m.Fields["DateTimeOriginal"]; ok { + timeZone := time.Local + tm, err := time.ParseInLocation(exifTimeLayout, dto.(string), timeZone) + if err != nil { + mds[i].Err = err + continue + } + mds[i].DateTime = tm + } + } + return mds +} diff --git a/export.go b/export.go index e96285e..29f18ae 100644 --- a/export.go +++ b/export.go @@ -72,7 +72,7 @@ func (x *exporter) request(image *smugmug.Image, destination string) (*request, return nil, nil } } - req, err := http.NewRequest(http.MethodGet, original.URL, nil) + req, err := http.NewRequest(http.MethodGet, original.URL, http.NoBody) if err != nil { return nil, err } @@ -88,7 +88,7 @@ func (x *exporter) do(ctx context.Context, req *request) (*response, error) { defer func(t time.Time) { x.metrics.AddSample([]string{"export", "download"}, float32(time.Since(t).Seconds())) }(time.Now()) - res, err := x.grab.Do(req.HTTPRequest.WithContext(ctx)) //nolint + res, err := x.grab.Do(req.HTTPRequest.WithContext(ctx)) // nolint if err != nil { return nil, err } diff --git a/export_test.go b/export_test.go index ed0639b..97530e9 100644 --- a/export_test.go +++ b/export_test.go @@ -3,7 +3,7 @@ package ma_test import ( "bytes" "fmt" - "io/ioutil" + "io" "net/http" "os" "testing" @@ -24,7 +24,7 @@ func (g *grab) Do(req *http.Request) (*http.Response, error) { res := &http.Response{ StatusCode: g.status, ContentLength: 0, - Body: ioutil.NopCloser(bytes.NewBuffer(nil)), + Body: io.NopCloser(bytes.NewBuffer(nil)), Header: make(map[string][]string), Request: req, } diff --git a/go.mod b/go.mod index 8b1eb61..fa68ce9 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,10 @@ require ( gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) -require github.com/bzimmer/manual v0.1.0 +require ( + github.com/barasher/go-exiftool v1.7.0 + github.com/bzimmer/manual v0.1.0 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index a30ed0d..02b6e51 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,10 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/armon/go-metrics v0.3.9 h1:O2sNqxBdvq8Eq5xmzljcYzAORli6RWCvEym4cJf9m18= -github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-metrics v0.3.10 h1:FR+drcQStOe+32sYyJYyZ7FIdgoGGBnwLl+flodp8Uo= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/barasher/go-exiftool v1.7.0 h1:EOGb5D6TpWXmqsnEjJ0ai6+tIW2gZFwIoS9O/33Nixs= +github.com/barasher/go-exiftool v1.7.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -15,8 +15,6 @@ github.com/bzimmer/httpwares v0.0.4 h1:ttcM0AtYFpmcyISWVFMDOf2TUUAMSm4m5NUFGTdjJ github.com/bzimmer/httpwares v0.0.4/go.mod h1:7/J2QFL6EDur3v11kij1ZJLK7f2cs9qeNXgT7BTjm8E= github.com/bzimmer/manual v0.1.0 h1:bwjezUGB2iKK0kDUHYIb5IzA4+iR/UNDoBrhZ0+ijW4= github.com/bzimmer/manual v0.1.0/go.mod h1:RftaUkPvNcdXbGj2u3qtXDOeBvJgInJLs3lCjnTknks= -github.com/bzimmer/smugmug v0.3.1 h1:hFl1K9+3gGCtCTPg9qm7nTau1ktKg6gUCF5yLJqw+fM= -github.com/bzimmer/smugmug v0.3.1/go.mod h1:taHNvu+xBMf6FC0YBL9I/AIww15/gYGBl4D6iNWvv3k= github.com/bzimmer/smugmug v0.3.2 h1:+ZdbjtZWdza4rEG+J9AWk60+PzzDyPo4wBc04honlWo= github.com/bzimmer/smugmug v0.3.2/go.mod h1:taHNvu+xBMf6FC0YBL9I/AIww15/gYGBl4D6iNWvv3k= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= diff --git a/ma_test.go b/ma_test.go index 83067f1..82c5f0c 100644 --- a/ma_test.go +++ b/ma_test.go @@ -99,6 +99,7 @@ func NewTestApp(t *testing.T, tt *harness, cmd *cli.Command, url string) *cli.Ap Encoder: enc, Grab: new(http.Client), Fs: afero.NewMemMapFs(), + Exif: ma.NewGoExif(), } c.App.Metadata = map[string]interface{}{ ma.RuntimeKey: rt, diff --git a/runtime.go b/runtime.go index f4b1139..dc97f0b 100644 --- a/runtime.go +++ b/runtime.go @@ -27,6 +27,8 @@ type Runtime struct { Fs afero.Fs // Grab for bulk querying images Grab Grab + // Exif for accessing EXIF metadata + Exif Exif } // Encoder encodes a struct to a specific format