package format import ( "bufio" "bytes" "errors" "fmt" "io" "os/exec" "strings" "sync" "sync/atomic" "time" ) const exiftoolTimeout = 30 * time.Second // exiftool keeps one `exiftool -stay_open` process and sends it one request at a time. type exiftool struct { mu sync.Mutex cmd *exec.Cmd stdin io.WriteCloser stdout *bufio.Reader closed bool timeout time.Duration // per request; exiftoolTimeout when zero } var ( sharedExiftool exiftool exiftoolStarts atomic.Int32 ) func (e *exiftool) start() error { cmd := exec.Command("exiftool", "-stay_open", "True", "-@", "-") stdin, err := cmd.StdinPipe() if err != nil { return err } stdout, err := cmd.StdoutPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } exiftoolStarts.Add(1) e.cmd, e.stdin, e.stdout = cmd, stdin, bufio.NewReader(stdout) return nil } // stop ends the process. exiftool ignores EOF on its argument stream, so a // graceful stop sends "-stay_open False"; otherwise the process is killed. func (e *exiftool) stop(graceful bool) { if e.cmd == nil { return } if graceful { io.WriteString(e.stdin, "-stay_open\nFalse\n") } else { e.cmd.Process.Kill() } e.stdin.Close() e.cmd.Wait() e.cmd = nil } // close stops the process for good; later requests fail instead of restarting it. func (e *exiftool) close() { e.mu.Lock() defer e.mu.Unlock() e.closed = true e.stop(true) } // CloseExiftool stops the shared exiftool process. Call it before exiting. func CloseExiftool() { sharedExiftool.close() } // run sends one request, one argument per line, and returns its stdout. // A broken pipe or a request exceeding the timeout kills the process; the // next request starts a new one. func (e *exiftool) run(args ...string) ([]byte, error) { for _, a := range args { if strings.ContainsAny(a, "\r\n") { return nil, fmt.Errorf("argument contains a newline: %q", a) } } e.mu.Lock() defer e.mu.Unlock() if e.closed { return nil, errors.New("exiftool closed") } if e.cmd == nil { if err := e.start(); err != nil { return nil, err } } timeout := e.timeout if timeout == 0 { timeout = exiftoolTimeout } proc := e.cmd.Process var timedOut atomic.Bool timer := time.AfterFunc(timeout, func() { timedOut.Store(true) proc.Kill() }) defer timer.Stop() fail := func(err error) ([]byte, error) { e.stop(false) if timedOut.Load() { return nil, fmt.Errorf("timed out after %s", timeout) } return nil, err } if _, err := io.WriteString(e.stdin, strings.Join(args, "\n")+"\n-execute\n"); err != nil { return fail(err) } var out []byte for { line, err := e.stdout.ReadBytes('\n') if err != nil { return fail(err) } if string(bytes.TrimRight(line, "\r\n")) == "{ready}" { if !timer.Stop() { e.stop(false) // the timer fired and killed the process as the read completed } return out, nil } out = append(out, line...) } }