internal/format/exiftool.go

135 lines · 2954 bytes

  1package format
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"os/exec"
 10	"strings"
 11	"sync"
 12	"sync/atomic"
 13	"time"
 14)
 15
 16const exiftoolTimeout = 30 * time.Second
 17
 18// exiftool keeps one `exiftool -stay_open` process and sends it one request at a time.
 19type exiftool struct {
 20	mu      sync.Mutex
 21	cmd     *exec.Cmd
 22	stdin   io.WriteCloser
 23	stdout  *bufio.Reader
 24	closed  bool
 25	timeout time.Duration // per request; exiftoolTimeout when zero
 26}
 27
 28var (
 29	sharedExiftool exiftool
 30	exiftoolStarts atomic.Int32
 31)
 32
 33func (e *exiftool) start() error {
 34	cmd := exec.Command("exiftool", "-stay_open", "True", "-@", "-")
 35	stdin, err := cmd.StdinPipe()
 36	if err != nil {
 37		return err
 38	}
 39	stdout, err := cmd.StdoutPipe()
 40	if err != nil {
 41		return err
 42	}
 43	if err := cmd.Start(); err != nil {
 44		return err
 45	}
 46	exiftoolStarts.Add(1)
 47	e.cmd, e.stdin, e.stdout = cmd, stdin, bufio.NewReader(stdout)
 48	return nil
 49}
 50
 51// stop ends the process. exiftool ignores EOF on its argument stream, so a
 52// graceful stop sends "-stay_open False"; otherwise the process is killed.
 53func (e *exiftool) stop(graceful bool) {
 54	if e.cmd == nil {
 55		return
 56	}
 57	if graceful {
 58		io.WriteString(e.stdin, "-stay_open\nFalse\n")
 59	} else {
 60		e.cmd.Process.Kill()
 61	}
 62	e.stdin.Close()
 63	e.cmd.Wait()
 64	e.cmd = nil
 65}
 66
 67// close stops the process for good; later requests fail instead of restarting it.
 68func (e *exiftool) close() {
 69	e.mu.Lock()
 70	defer e.mu.Unlock()
 71	e.closed = true
 72	e.stop(true)
 73}
 74
 75// CloseExiftool stops the shared exiftool process. Call it before exiting.
 76func CloseExiftool() {
 77	sharedExiftool.close()
 78}
 79
 80// run sends one request, one argument per line, and returns its stdout.
 81// A broken pipe or a request exceeding the timeout kills the process; the
 82// next request starts a new one.
 83func (e *exiftool) run(args ...string) ([]byte, error) {
 84	for _, a := range args {
 85		if strings.ContainsAny(a, "\r\n") {
 86			return nil, fmt.Errorf("argument contains a newline: %q", a)
 87		}
 88	}
 89	e.mu.Lock()
 90	defer e.mu.Unlock()
 91	if e.closed {
 92		return nil, errors.New("exiftool closed")
 93	}
 94	if e.cmd == nil {
 95		if err := e.start(); err != nil {
 96			return nil, err
 97		}
 98	}
 99	timeout := e.timeout
100	if timeout == 0 {
101		timeout = exiftoolTimeout
102	}
103	proc := e.cmd.Process
104	var timedOut atomic.Bool
105	timer := time.AfterFunc(timeout, func() {
106		timedOut.Store(true)
107		proc.Kill()
108	})
109	defer timer.Stop()
110
111	fail := func(err error) ([]byte, error) {
112		e.stop(false)
113		if timedOut.Load() {
114			return nil, fmt.Errorf("timed out after %s", timeout)
115		}
116		return nil, err
117	}
118	if _, err := io.WriteString(e.stdin, strings.Join(args, "\n")+"\n-execute\n"); err != nil {
119		return fail(err)
120	}
121	var out []byte
122	for {
123		line, err := e.stdout.ReadBytes('\n')
124		if err != nil {
125			return fail(err)
126		}
127		if string(bytes.TrimRight(line, "\r\n")) == "{ready}" {
128			if !timer.Stop() {
129				e.stop(false) // the timer fired and killed the process as the read completed
130			}
131			return out, nil
132		}
133		out = append(out, line...)
134	}
135}