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