Commit a9b9f7c357

a9b9f7c357b678335d8c804c09143755b3050a22

parent: c097185877

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-26 18:51 UTC

Reuse one exiftool process; serve before the first scan

Metadata goes through a single exiftool -stay_open process instead of
one process per file. exiftool ignores EOF on its argument stream, so it
is stopped explicitly with -stay_open False on shutdown (and after the
format tests). The server starts with an empty library and fills it
when the first scan finishes.

Closes #4

Layout: unified · split

cmd/gallery/main.go +8 −3
@@ -101,9 +101,13 @@ func main() {
101 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil { 101 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
102 log.Fatal(err) 102 log.Fatal(err)
103 } 103 }
104 if err := rescan(); err != nil { 104 // Serve an empty library while the first scan runs.
105 log.Fatal(err) 105 store.Set(&library.Library{})
106 } 106 go func() {
107 if err := rescan(); err != nil {
108 log.Fatal(err)
109 }
110 }()
107 111
108 opt := web.Options{Title: *title, ThemePath: *theme} 112 opt := web.Options{Title: *title, ThemePath: *theme}
109 if *templates != "" { 113 if *templates != "" {
@@ -128,4 +132,5 @@ func main() {
128 log.Fatal(err) 132 log.Fatal(err)
129 } 133 }
130 <-done 134 <-done
135 format.CloseExiftool()
131} 136}
internal/format/exiftool.go added +103
@@ -0,0 +1,103 @@
1package format
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "io"
8 "os/exec"
9 "strings"
10 "sync"
11 "sync/atomic"
12)
13
14// exiftool keeps one `exiftool -stay_open` process and sends it one request at a time.
15type exiftool struct {
16 mu sync.Mutex
17 cmd *exec.Cmd
18 stdin io.WriteCloser
19 stdout *bufio.Reader
20}
21
22var (
23 sharedExiftool exiftool
24 exiftoolStarts atomic.Int32
25)
26
27func (e *exiftool) start() error {
28 cmd := exec.Command("exiftool", "-stay_open", "True", "-@", "-")
29 stdin, err := cmd.StdinPipe()
30 if err != nil {
31 return err
32 }
33 stdout, err := cmd.StdoutPipe()
34 if err != nil {
35 return err
36 }
37 if err := cmd.Start(); err != nil {
38 return err
39 }
40 exiftoolStarts.Add(1)
41 e.cmd, e.stdin, e.stdout = cmd, stdin, bufio.NewReader(stdout)
42 return nil
43}
44
45// stop ends the process. exiftool ignores EOF on its argument stream, so a
46// graceful stop sends "-stay_open False"; otherwise the process is killed.
47func (e *exiftool) stop(graceful bool) {
48 if e.cmd == nil {
49 return
50 }
51 if graceful {
52 io.WriteString(e.stdin, "-stay_open\nFalse\n")
53 } else {
54 e.cmd.Process.Kill()
55 }
56 e.stdin.Close()
57 e.cmd.Wait()
58 e.cmd = nil
59}
60
61func (e *exiftool) close() {
62 e.mu.Lock()
63 defer e.mu.Unlock()
64 e.stop(true)
65}
66
67// CloseExiftool stops the shared exiftool process, if one is running.
68func CloseExiftool() {
69 sharedExiftool.close()
70}
71
72// run sends one request, one argument per line, and returns its stdout.
73// The process is restarted on the next call if the pipe breaks.
74func (e *exiftool) run(args ...string) ([]byte, error) {
75 for _, a := range args {
76 if strings.ContainsAny(a, "\r\n") {
77 return nil, fmt.Errorf("argument contains a newline: %q", a)
78 }
79 }
80 e.mu.Lock()
81 defer e.mu.Unlock()
82 if e.cmd == nil {
83 if err := e.start(); err != nil {
84 return nil, err
85 }
86 }
87 if _, err := io.WriteString(e.stdin, strings.Join(args, "\n")+"\n-execute\n"); err != nil {
88 e.stop(false)
89 return nil, err
90 }
91 var out []byte
92 for {
93 line, err := e.stdout.ReadBytes('\n')
94 if err != nil {
95 e.stop(false)
96 return nil, err
97 }
98 if string(bytes.TrimRight(line, "\r\n")) == "{ready}" {
99 return out, nil
100 }
101 out = append(out, line...)
102 }
103}
internal/format/exiftool_test.go added +34
@@ -0,0 +1,34 @@
1package format
2
3import (
4 "errors"
5 "os"
6 "syscall"
7 "testing"
8)
9
10func TestMain(m *testing.M) {
11 code := m.Run()
12 CloseExiftool()
13 os.Exit(code)
14}
15
16func TestExiftoolCloseEndsProcess(t *testing.T) {
17 requireTools(t)
18 var e exiftool
19 if _, err := e.run("-ver"); err != nil {
20 t.Fatal(err)
21 }
22 pid := e.cmd.Process.Pid
23 e.close()
24 if e.cmd != nil {
25 t.Fatal("cmd not cleared")
26 }
27 if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
28 t.Fatalf("exiftool pid %d still running after close (kill 0: %v)", pid, err)
29 }
30 if _, err := e.run("-ver"); err != nil {
31 t.Fatalf("run after close should restart: %v", err)
32 }
33 e.close()
34}
internal/format/magick.go +5 −2
@@ -24,12 +24,15 @@ func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
24func (m *Magick) Kind() Kind { return KindImage } 24func (m *Magick) Kind() Kind { return KindImage }
25 25
26func (m *Magick) Metadata(path string) (Meta, error) { 26func (m *Magick) Metadata(path string) (Meta, error) {
27 out, err := exec.Command("exiftool", "-json", "-n", 27 if strings.HasPrefix(path, "-") {
28 path = "./" + path
29 }
30 out, err := sharedExiftool.run("-json", "-n",
28 "-ImageWidth", "-ImageHeight", "-Orientation", 31 "-ImageWidth", "-ImageHeight", "-Orientation",
29 "-DateTimeOriginal", "-CreateDate", 32 "-DateTimeOriginal", "-CreateDate",
30 "-Make", "-Model", "-LensModel", 33 "-Make", "-Model", "-LensModel",
31 "-FNumber", "-ExposureTime", "-ISO", 34 "-FNumber", "-ExposureTime", "-ISO",
32 path).Output() 35 path)
33 if err != nil { 36 if err != nil {
34 return Meta{}, fmt.Errorf("exiftool %s: %w", path, err) 37 return Meta{}, fmt.Errorf("exiftool %s: %w", path, err)
35 } 38 }
internal/format/magick_test.go +32
@@ -118,3 +118,35 @@ func TestDefaultRegistry(t *testing.T) {
118 t.Error("heic should not match in v1") 118 t.Error("heic should not match in v1")
119 } 119 }
120} 120}
121
122func TestMagickMetadataManyFiles(t *testing.T) {
123 requireTools(t)
124 f := NewMagick("jpeg", ".jpg")
125 dir := t.TempDir()
126 before := exiftoolStarts.Load()
127 for i := 1; i <= 20; i++ {
128 p := filepath.Join(dir, fmt.Sprintf("%02d.jpg", i))
129 run(t, "magick", "-size", fmt.Sprintf("%dx10", i*10), "xc:gray", p)
130 if i == 10 {
131 if _, err := f.Metadata(filepath.Join(dir, "missing.jpg")); err == nil {
132 t.Fatal("missing file: want error")
133 }
134 }
135 m, err := f.Metadata(p)
136 if err != nil {
137 t.Fatalf("%s: %v", p, err)
138 }
139 if m.Width != i*10 || m.Height != 10 {
140 t.Fatalf("%s: got %dx%d, want %dx10", p, m.Width, m.Height, i*10)
141 }
142 }
143 if n := exiftoolStarts.Load() - before; n > 1 {
144 t.Fatalf("started exiftool %d times for 21 calls, want at most 1", n)
145 }
146}
147
148func TestMagickMetadataRejectsNewlineInPath(t *testing.T) {
149 if _, err := NewMagick("jpeg", ".jpg").Metadata("a\nb.jpg"); err == nil {
150 t.Fatal("want error")
151 }
152}
internal/web/server_test.go +14
@@ -209,3 +209,17 @@ func TestScript(t *testing.T) {
209 t.Fatal("album page missing script tag") 209 t.Fatal("album page missing script tag")
210 } 210 }
211} 211}
212
213func TestEmptyLibraryBeforeFirstScan(t *testing.T) {
214 store := &library.Store{}
215 store.Set(&library.Library{})
216 h, err := New(store, render.New(t.TempDir(), 1), Options{Title: "gallery"})
217 if err != nil {
218 t.Fatal(err)
219 }
220 for path, code := range map[string]int{"/": 200, "/tokyo/": 404, "/tokyo/a.jpg/": 404, "/img/tokyo/a.jpg/480.jpg": 404, "/info/": 404} {
221 if got := get(h, path).Code; got != code {
222 t.Errorf("%s: %d, want %d", path, got, code)
223 }
224 }
225}