thumbnail_test.go
⎇
Raw
1package service
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11)
12
13// makeVideoWithCoverAttachment writes an MKV at outPath with a 64x64 video and
14// an embedded "cover.webp" image attachment of the given size — mirroring how
15// yt-dlp embeds thumbnails into MKV (a true attachment stream, not an
16// attached_pic video stream; note ffmpeg muxes an attached .png as a video
17// stream, so webp/jpg must be used to get a real attachment).
18func makeVideoWithCoverAttachment(t *testing.T, outPath, coverSize string) {
19 t.Helper()
20 scratch := t.TempDir()
21 base := filepath.Join(scratch, "base.mp4")
22 makeTestVideoSize(t, base, "64x64")
23 cover := filepath.Join(scratch, "cover.webp")
24 if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
25 "-f", "lavfi", "-i", "color=red:size="+coverSize+":duration=1",
26 "-frames:v", "1", cover, "-y").CombinedOutput(); err != nil {
27 t.Fatalf("make cover: %v\n%s", err, out)
28 }
29 if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
30 "-i", base, "-attach", cover, "-metadata:s:t:0", "mimetype=image/webp",
31 "-c", "copy", outPath, "-y").CombinedOutput(); err != nil {
32 t.Fatalf("attach cover: %v\n%s", err, out)
33 }
34}
35
36// probeImageSize returns the pixel dimensions of an image/video file.
37func probeImageSize(t *testing.T, path string) (int, int) {
38 t.Helper()
39 out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
40 "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path).Output()
41 if err != nil {
42 t.Fatalf("probe %s: %v", path, err)
43 }
44 var w, h int
45 if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%dx%d", &w, &h); err != nil {
46 t.Fatalf("parse dimensions %q: %v", out, err)
47 }
48 return w, h
49}
50
51func TestThumbnailPrefersExistingGenerated(t *testing.T) {
52 svc, dir := newLibrary(t)
53 writeItem(t, dir, "item", "name = \"I\"\nduration = -1\n", map[string]string{
54 "video.mp4": "v",
55 "video.thumbnail.webp": "GENERATED",
56 })
57
58 path, ok := svc.ThumbnailForFile("item", "video.mp4")
59 if !ok {
60 t.Fatal("expected a thumbnail")
61 }
62 if !strings.HasSuffix(path, "video.thumbnail.webp") {
63 t.Errorf("expected generated thumbnail, got %q", path)
64 }
65}
66
67func TestThumbnailExtractsOnDemand(t *testing.T) {
68 requireFFmpeg(t)
69 svc, dir := newLibrary(t)
70 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
71 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
72
73 path, ok := svc.ThumbnailForFile("vid", "vid.mp4")
74 if !ok {
75 t.Fatal("expected on-demand extraction to succeed")
76 }
77 if info, err := os.Stat(path); err != nil || info.Size() == 0 {
78 t.Fatalf("thumbnail file missing/empty: %v", err)
79 }
80 if !strings.Contains(filepath.Base(path), ".thumbnail.") {
81 t.Errorf("unexpected thumbnail name %q", path)
82 }
83 // No leftover temp files from the atomic-write path.
84 entries, _ := os.ReadDir(itemDir)
85 for _, e := range entries {
86 if strings.Contains(e.Name(), ".tmp") {
87 t.Errorf("leftover temp file %q", e.Name())
88 }
89 }
90}
91
92func TestThumbnailRetriesAfterDeletion(t *testing.T) {
93 requireFFmpeg(t)
94 svc, dir := newLibrary(t)
95 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
96 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
97
98 first, ok := svc.ThumbnailForFile("vid", "vid.mp4")
99 if !ok {
100 t.Fatal("first extraction failed")
101 }
102 if err := os.Remove(first); err != nil {
103 t.Fatal(err)
104 }
105 // A failure/absence must not be cached permanently: re-request re-extracts.
106 second, ok := svc.ThumbnailForFile("vid", "vid.mp4")
107 if !ok {
108 t.Fatal("re-extraction after deletion failed (failure was cached)")
109 }
110 if info, err := os.Stat(second); err != nil || info.Size() == 0 {
111 t.Fatalf("re-extracted thumbnail missing/empty: %v", err)
112 }
113}
114
115func TestThumbnailConcurrentSingleExtraction(t *testing.T) {
116 requireFFmpeg(t)
117 svc, dir := newLibrary(t)
118 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
119 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
120
121 var wg sync.WaitGroup
122 for i := 0; i < 8; i++ {
123 wg.Add(1)
124 go func() {
125 defer wg.Done()
126 if _, ok := svc.ThumbnailForFile("vid", "vid.mp4"); !ok {
127 t.Error("concurrent Thumbnail failed")
128 }
129 }()
130 }
131 wg.Wait()
132
133 // Exactly one generated thumbnail, no temp leftovers despite the race.
134 entries, _ := os.ReadDir(itemDir)
135 var thumbs int
136 for _, e := range entries {
137 if strings.Contains(e.Name(), ".thumbnail.") {
138 thumbs++
139 }
140 if strings.Contains(e.Name(), ".tmp") {
141 t.Errorf("leftover temp file %q", e.Name())
142 }
143 }
144 if thumbs != 1 {
145 t.Errorf("expected exactly 1 generated thumbnail, got %d", thumbs)
146 }
147}
148
149func TestThumbnailForFileIsPerFile(t *testing.T) {
150 requireFFmpeg(t)
151 svc, dir := newLibrary(t)
152 itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
153 makeTestVideo(t, filepath.Join(itemDir, "a.mp4"))
154 makeTestVideo(t, filepath.Join(itemDir, "b.mp4"))
155
156 pa, ok := svc.ThumbnailForFile("multi", "a.mp4")
157 if !ok {
158 t.Fatal("thumbnail for a.mp4 failed")
159 }
160 pb, ok := svc.ThumbnailForFile("multi", "b.mp4")
161 if !ok {
162 t.Fatal("thumbnail for b.mp4 failed")
163 }
164 if pa == pb {
165 t.Errorf("expected distinct per-file thumbnails, both = %q", pa)
166 }
167 if !strings.Contains(filepath.Base(pa), "a.thumbnail.") {
168 t.Errorf("a.mp4 thumbnail name = %q", filepath.Base(pa))
169 }
170 if !strings.Contains(filepath.Base(pb), "b.thumbnail.") {
171 t.Errorf("b.mp4 thumbnail name = %q", filepath.Base(pb))
172 }
173
174 // An unknown file yields no thumbnail (caller falls back to an icon).
175 if _, ok := svc.ThumbnailForFile("multi", "nope.mp4"); ok {
176 t.Error("unknown file should not produce a thumbnail")
177 }
178
179 // Every thumbnail is keyed to a specific file: an empty filename yields none.
180 if _, ok := svc.ThumbnailForFile("multi", ""); ok {
181 t.Error("empty filename should not produce a thumbnail")
182 }
183}
184
185func TestThumbnailUsesEmbeddedAttachment(t *testing.T) {
186 requireFFmpeg(t)
187 svc, dir := newLibrary(t)
188 itemDir := writeItem(t, dir, "att", "name = \"A\"\nduration = -1\n", nil)
189 mkv := filepath.Join(itemDir, "v.mkv")
190 // 100x100 cover so it's distinguishable from a 64x64 video frame.
191 makeVideoWithCoverAttachment(t, mkv, "100x100")
192
193 if idx := svc.findImageAttachment(mkv); idx < 0 {
194 t.Fatal("findImageAttachment did not find the embedded cover")
195 }
196
197 path, ok := svc.ThumbnailForFile("att", "v.mkv")
198 if !ok {
199 t.Fatal("thumbnail extraction failed")
200 }
201 // The embedded cover (100x100) must be used in preference to a video frame
202 // (which would be 64x64) — this is the regression the refactor introduced.
203 if w, h := probeImageSize(t, path); w != 100 || h != 100 {
204 t.Errorf("thumbnail is %dx%d, expected 100x100 from the embedded cover (got a video frame instead)", w, h)
205 }
206}
207
208func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
209 dir, scratch := t.TempDir(), t.TempDir()
210 // Stand-in ffmpeg that always fails and records every invocation, so the
211 // extraction attempts can be counted without a probe into the service.
212 counter := filepath.Join(scratch, "attempts")
213 fakeFFmpeg := writeScript(t, filepath.Join(scratch, "ffmpeg"), "echo x >> "+counter+"\nexit 1")
214 attempts := func() int {
215 data, err := os.ReadFile(counter)
216 if err != nil {
217 return 0
218 }
219 return strings.Count(string(data), "\n")
220 }
221 newSvc := func() *LibraryService {
222 return NewLibraryService(dir, fakeFFmpeg, filepath.Join(scratch, "no-ffprobe"))
223 }
224
225 // A file ffmpeg cannot extract a thumbnail from: every attempt fails.
226 writeItem(t, dir, "bad", "name = \"B\"\nduration = -1\n", map[string]string{
227 "broken.mp4": "not actually a video",
228 })
229
230 svc := newSvc()
231 if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok {
232 t.Fatal("expected extraction to fail for a non-video file")
233 }
234 attempts1 := attempts()
235 if attempts1 == 0 {
236 t.Fatal("expected at least one extraction attempt")
237 }
238
239 // A second request is served from the in-process negative cache: no new
240 // ffmpeg attempt.
241 if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok {
242 t.Fatal("expected the cached failure to persist")
243 }
244 if attempts2 := attempts(); attempts2 != attempts1 {
245 t.Errorf("negative cache should prevent re-extraction; attempts %d -> %d", attempts1, attempts2)
246 }
247
248 // The cache is in-process only: a fresh service (≈ a restart) retries.
249 if _, ok := newSvc().ThumbnailForFile("bad", "broken.mp4"); ok {
250 t.Fatal("fresh service still fails (file is unextractable)")
251 }
252 if attempts3 := attempts(); attempts3 <= attempts1 {
253 t.Errorf("a fresh service should retry extraction, not inherit the negative cache; attempts %d -> %d", attempts1, attempts3)
254 }
255}
256
257func TestThumbnailAudioOnlyHasNone(t *testing.T) {
258 requireFFmpeg(t)
259 svc, dir := newLibrary(t)
260 itemDir := writeItem(t, dir, "aud", "name = \"A\"\nduration = -1\n", nil)
261 // A real audio file with no cover art.
262 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
263 "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
264 filepath.Join(itemDir, "aud.mp3"), "-y")
265 if out, err := cmd.CombinedOutput(); err != nil {
266 t.Fatalf("make audio: %v\n%s", err, out)
267 }
268
269 if path, ok := svc.ThumbnailForFile("aud", "aud.mp3"); ok {
270 t.Errorf("audio-only item should have no thumbnail, got %q", path)
271 }
272}
273