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