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}