261 lines
5.7 KiB
Go
261 lines
5.7 KiB
Go
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
|
|
}
|