library_test.go
⎇
Raw
1package service
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13
14 "vidarchive/internal/models"
15)
16
17// --- test helpers ---
18
19func newLibrary(t *testing.T) (*LibraryService, string) {
20 t.Helper()
21 dir := t.TempDir()
22 return NewLibraryService(dir), dir
23}
24
25// writeItem creates an item directory with a marker and the given files
26// (filename -> contents). A nil/empty marker body still creates a valid item.
27func writeItem(t *testing.T, libraryDir, relPath, markerBody string, files map[string]string) string {
28 t.Helper()
29 itemDir := filepath.Join(libraryDir, filepath.FromSlash(relPath))
30 if err := os.MkdirAll(itemDir, 0755); err != nil {
31 t.Fatalf("mkdir item: %v", err)
32 }
33 if markerBody == "" {
34 markerBody = "duration = -1\n"
35 }
36 if err := os.WriteFile(filepath.Join(itemDir, itemMarkerName), []byte(markerBody), 0644); err != nil {
37 t.Fatalf("write marker: %v", err)
38 }
39 for name, content := range files {
40 if err := os.WriteFile(filepath.Join(itemDir, name), []byte(content), 0644); err != nil {
41 t.Fatalf("write file %s: %v", name, err)
42 }
43 }
44 return itemDir
45}
46
47func requireFFmpeg(t *testing.T) {
48 t.Helper()
49 if _, err := exec.LookPath("ffmpeg"); err != nil {
50 t.Skip("ffmpeg not on PATH")
51 }
52}
53
54// makeTestVideo writes a tiny real video file ffmpeg can decode a frame from.
55func makeTestVideo(t *testing.T, path string) {
56 t.Helper()
57 makeTestVideoSize(t, path, "64x64")
58}
59
60// makeTestVideoSize is makeTestVideo with an explicit WxH size.
61func makeTestVideoSize(t *testing.T, path, size string) {
62 t.Helper()
63 // 3s so the default 1s thumbnail seek lands on a real frame.
64 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
65 "-f", "lavfi", "-i", "testsrc=duration=3:size="+size+":rate=5",
66 "-pix_fmt", "yuv420p", path, "-y")
67 if out, err := cmd.CombinedOutput(); err != nil {
68 t.Fatalf("make test video: %v\n%s", err, out)
69 }
70}
71
72// makeVideoWithCoverAttachment writes an MKV at outPath with a 64x64 video and
73// an embedded "cover.webp" image attachment of the given size — mirroring how
74// yt-dlp embeds thumbnails into MKV (a true attachment stream, not an
75// attached_pic video stream; note ffmpeg muxes an attached .png as a video
76// stream, so webp/jpg must be used to get a real attachment).
77func makeVideoWithCoverAttachment(t *testing.T, outPath, coverSize string) {
78 t.Helper()
79 scratch := t.TempDir()
80 base := filepath.Join(scratch, "base.mp4")
81 makeTestVideoSize(t, base, "64x64")
82 cover := filepath.Join(scratch, "cover.webp")
83 if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
84 "-f", "lavfi", "-i", "color=red:size="+coverSize+":duration=1",
85 "-frames:v", "1", cover, "-y").CombinedOutput(); err != nil {
86 t.Fatalf("make cover: %v\n%s", err, out)
87 }
88 if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
89 "-i", base, "-attach", cover, "-metadata:s:t:0", "mimetype=image/webp",
90 "-c", "copy", outPath, "-y").CombinedOutput(); err != nil {
91 t.Fatalf("attach cover: %v\n%s", err, out)
92 }
93}
94
95// probeImageSize returns the pixel dimensions of an image/video file.
96func probeImageSize(t *testing.T, path string) (int, int) {
97 t.Helper()
98 out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
99 "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path).Output()
100 if err != nil {
101 t.Fatalf("probe %s: %v", path, err)
102 }
103 var w, h int
104 if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%dx%d", &w, &h); err != nil {
105 t.Fatalf("parse dimensions %q: %v", out, err)
106 }
107 return w, h
108}
109
110// --- pure helper tests ---
111
112func TestInfoDuration(t *testing.T) {
113 tests := []struct {
114 name string
115 in map[string]interface{}
116 want int
117 ok bool
118 }{
119 {"float", map[string]interface{}{"duration": 149.4}, 149, true},
120 {"float rounds up", map[string]interface{}{"duration": 149.6}, 150, true},
121 {"int", map[string]interface{}{"duration": 200}, 200, true},
122 {"string", map[string]interface{}{"duration": "12.0"}, 12, true},
123 {"bad string", map[string]interface{}{"duration": "abc"}, 0, false},
124 {"missing", map[string]interface{}{}, 0, false},
125 {"nil", nil, 0, false},
126 }
127 for _, tc := range tests {
128 t.Run(tc.name, func(t *testing.T) {
129 got, ok := infoDuration(tc.in)
130 if ok != tc.ok || got != tc.want {
131 t.Errorf("infoDuration(%v) = (%d,%v), want (%d,%v)", tc.in, got, ok, tc.want, tc.ok)
132 }
133 })
134 }
135}
136
137func TestCommonDuration(t *testing.T) {
138 mk := func(ds ...int) []models.MediaFile {
139 var fs []models.MediaFile
140 for _, d := range ds {
141 fs = append(fs, models.MediaFile{Duration: d})
142 }
143 return fs
144 }
145 tests := []struct {
146 name string
147 files []models.MediaFile
148 want int
149 }{
150 {"empty", nil, -1},
151 {"single", mk(100), 100},
152 {"all equal", mk(100, 100, 100), 100},
153 {"mismatch", mk(100, 90), -1},
154 {"unknown first", mk(-1, 100), -1},
155 }
156 for _, tc := range tests {
157 t.Run(tc.name, func(t *testing.T) {
158 if got := commonDuration(tc.files); got != tc.want {
159 t.Errorf("commonDuration = %d, want %d", got, tc.want)
160 }
161 })
162 }
163}
164
165func TestUrlEncodePath(t *testing.T) {
166 tests := []struct{ in, want string }{
167 {"a/b c", "a/b%20c"},
168 {"/leading/trailing/", "leading/trailing"},
169 {"Hatsune Miku [id]", "Hatsune%20Miku%20%5Bid%5D"},
170 {"plain", "plain"},
171 }
172 for _, tc := range tests {
173 if got := urlEncodePath(tc.in); got != tc.want {
174 t.Errorf("urlEncodePath(%q) = %q, want %q", tc.in, got, tc.want)
175 }
176 }
177}
178
179func TestParseFPS(t *testing.T) {
180 tests := []struct{ in, want string }{
181 {"30/1", "30"},
182 {"30000/1001", "29.97"},
183 {"0/0", ""},
184 {"", ""},
185 {"garbage", ""},
186 }
187 for _, tc := range tests {
188 if got := parseFPS(tc.in); got != tc.want {
189 t.Errorf("parseFPS(%q) = %q, want %q", tc.in, got, tc.want)
190 }
191 }
192}
193
194func TestInfoString(t *testing.T) {
195 if _, ok := infoString(nil, "title"); ok {
196 t.Error("nil info should return false")
197 }
198 if _, ok := infoString(map[string]interface{}{}, "title"); ok {
199 t.Error("missing key should return false")
200 }
201 if v, ok := infoString(map[string]interface{}{"title": "hi"}, "title"); !ok || v != "hi" {
202 t.Errorf("string value = (%q,%v), want (hi,true)", v, ok)
203 }
204 if _, ok := infoString(map[string]interface{}{"title": 5}, "title"); ok {
205 t.Error("numeric value should return false, not stringify")
206 }
207 if _, ok := infoString(map[string]interface{}{"title": map[string]interface{}{"a": 1}}, "title"); ok {
208 t.Error("map value should return false, not stringify")
209 }
210}
211
212func TestHasField(t *testing.T) {
213 tests := []struct {
214 data string
215 key string
216 want bool
217 }{
218 {"duration = 10\n", "duration", true},
219 {"duration=10\n", "duration", true},
220 {" duration = 10\n", "duration", true},
221 {"name = \"x\"\nduration = 5\n", "duration", true},
222 {"description = \"mentions duration = 5 inline\"\n", "duration", false},
223 {"name = \"x\"\n", "duration", false},
224 {"", "duration", false},
225 }
226 for _, tc := range tests {
227 if got := hasField([]byte(tc.data), tc.key); got != tc.want {
228 t.Errorf("hasField(%q, %q) = %v, want %v", tc.data, tc.key, got, tc.want)
229 }
230 }
231}
232
233func TestPrimaryMediaFile(t *testing.T) {
234 dir := t.TempDir()
235 write := func(name string, n int) string {
236 p := filepath.Join(dir, name)
237 if err := os.WriteFile(p, make([]byte, n), 0644); err != nil {
238 t.Fatal(err)
239 }
240 return p
241 }
242
243 // Largest video wins over a larger audio file.
244 mixed := &models.LibraryItem{MediaFiles: []models.MediaFile{
245 {Filename: "small.mp4", Filepath: write("small.mp4", 10)},
246 {Filename: "big.mp4", Filepath: write("big.mp4", 100)},
247 {Filename: "huge.mp3", Filepath: write("huge.mp3", 1000), IsAudio: true},
248 }}
249 if got := primaryMediaFile(mixed); got == nil || got.Filename != "big.mp4" {
250 t.Errorf("expected largest video big.mp4, got %v", got)
251 }
252
253 // Audio-only item falls back to the largest audio file.
254 audio := &models.LibraryItem{MediaFiles: []models.MediaFile{
255 {Filename: "a.mp3", Filepath: write("a.mp3", 5), IsAudio: true},
256 {Filename: "b.mp3", Filepath: write("b.mp3", 50), IsAudio: true},
257 }}
258 if got := primaryMediaFile(audio); got == nil || got.Filename != "b.mp3" {
259 t.Errorf("expected largest audio b.mp3, got %v", got)
260 }
261
262 if got := primaryMediaFile(&models.LibraryItem{}); got != nil {
263 t.Errorf("empty item should yield nil, got %v", got)
264 }
265}
266
267func TestFormatBitrate(t *testing.T) {
268 tests := []struct{ in, want string }{
269 {"128000", "128"},
270 {"", ""},
271 {"notanumber", ""},
272 }
273 for _, tc := range tests {
274 if got := formatBitrate(tc.in); got != tc.want {
275 t.Errorf("formatBitrate(%q) = %q, want %q", tc.in, got, tc.want)
276 }
277 }
278}
279
280// --- listItemFiles / GetAll ---
281
282func TestListItemFilesClassification(t *testing.T) {
283 svc, dir := newLibrary(t)
284 itemDir := writeItem(t, dir, "item", "", map[string]string{
285 "video.mp4": "v",
286 "audio.mp3": "a",
287 "cover.jpg": "img",
288 "thumb.webp": "img",
289 "notes.txt": "ignored",
290 "info.json": `{"title":"x"}`,
291 "video.info.json": `{"title":"x"}`,
292 })
293
294 media, infoPath, err := svc.listItemFiles(itemDir)
295 if err != nil {
296 t.Fatalf("listItemFiles: %v", err)
297 }
298 if len(media) != 2 {
299 t.Fatalf("expected 2 media files, got %d: %+v", len(media), media)
300 }
301 var sawAudio, sawVideo bool
302 for _, m := range media {
303 switch m.Filename {
304 case "audio.mp3":
305 sawAudio = true
306 if !m.IsAudio {
307 t.Error("audio.mp3 should be IsAudio")
308 }
309 case "video.mp4":
310 sawVideo = true
311 if m.IsAudio {
312 t.Error("video.mp4 should not be IsAudio")
313 }
314 default:
315 t.Errorf("unexpected media file %q", m.Filename)
316 }
317 }
318 if !sawAudio || !sawVideo {
319 t.Error("missing expected media files")
320 }
321 if infoPath == "" {
322 t.Error("expected an info.json path")
323 }
324}
325
326func TestListItemFilesSortedAscending(t *testing.T) {
327 svc, dir := newLibrary(t)
328 itemDir := writeItem(t, dir, "item", "", map[string]string{
329 "03 - c.mp4": "c", "01 - a.mp4": "a", "02 - b.mp4": "b",
330 })
331 media, _, err := svc.listItemFiles(itemDir)
332 if err != nil {
333 t.Fatalf("listItemFiles: %v", err)
334 }
335 got := make([]string, len(media))
336 for i, m := range media {
337 got[i] = m.Filename
338 }
339 want := []string{"01 - a.mp4", "02 - b.mp4", "03 - c.mp4"}
340 for i := range want {
341 if got[i] != want[i] {
342 t.Fatalf("media files = %v, want ascending %v", got, want)
343 }
344 }
345}
346
347func TestGetAllSeparatesFoldersAndItems(t *testing.T) {
348 svc, dir := newLibrary(t)
349 // An item (has marker)
350 writeItem(t, dir, "song", "name = \"Song\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
351 // A plain folder (no marker) containing a nested item
352 writeItem(t, dir, "folder/nested", "name = \"Nested\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
353 // The reserved subtitles dir at root must be ignored
354 if err := os.MkdirAll(filepath.Join(dir, subtitlesDirName), 0755); err != nil {
355 t.Fatal(err)
356 }
357
358 items, folders, err := svc.GetAll("", "title", "")
359 if err != nil {
360 t.Fatalf("GetAll: %v", err)
361 }
362 if len(items) != 1 || items[0].Name != "Song" {
363 t.Errorf("expected 1 item 'Song', got %+v", items)
364 }
365 if len(folders) != 1 || folders[0] != "folder" {
366 t.Errorf("expected folder 'folder', got %v", folders)
367 }
368 for _, f := range folders {
369 if f == subtitlesDirName {
370 t.Error("subtitles dir should not be listed as a folder")
371 }
372 }
373}
374
375func TestGetAllFilter(t *testing.T) {
376 svc, dir := newLibrary(t)
377 writeItem(t, dir, "alpha", "name = \"Alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
378 writeItem(t, dir, "beta", "name = \"Beta\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
379
380 items, _, err := svc.GetAll("", "title", "alph")
381 if err != nil {
382 t.Fatalf("GetAll: %v", err)
383 }
384 if len(items) != 1 || items[0].Name != "Alpha" {
385 t.Errorf("filter 'alph' should match only Alpha, got %+v", items)
386 }
387}
388
389func TestGetAllSortByTitle(t *testing.T) {
390 svc, dir := newLibrary(t)
391 writeItem(t, dir, "c", "name = \"Charlie\"\nduration = -1\n", map[string]string{"c.mp4": "v"})
392 writeItem(t, dir, "a", "name = \"alpha\"\nduration = -1\n", map[string]string{"a.mp4": "v"})
393 writeItem(t, dir, "b", "name = \"Bravo\"\nduration = -1\n", map[string]string{"b.mp4": "v"})
394
395 items, _, err := svc.GetAll("", "title", "")
396 if err != nil {
397 t.Fatalf("GetAll: %v", err)
398 }
399 got := []string{items[0].Name, items[1].Name, items[2].Name}
400 want := []string{"alpha", "Bravo", "Charlie"} // case-insensitive
401 for i := range want {
402 if got[i] != want[i] {
403 t.Errorf("sort by title = %v, want %v", got, want)
404 break
405 }
406 }
407}
408
409// --- metadata derivation + marker write behavior ---
410
411func TestScanItemDerivesMetadataFromInfoJSON(t *testing.T) {
412 svc, dir := newLibrary(t)
413 info := `{"title":"Real Title","webpage_url":"https://example.com/v","description":"hello","duration":149.2}`
414 writeItem(t, dir, "x", "duration = -1\n", map[string]string{
415 "x.mp4": "v",
416 "info.json": info,
417 })
418
419 item, err := svc.GetByRelPath(context.Background(), "x")
420 if err != nil {
421 t.Fatalf("GetByRelPath: %v", err)
422 }
423 if item.Name != "Real Title" {
424 t.Errorf("Name = %q, want derived from info.json", item.Name)
425 }
426 if item.SourceURL != "https://example.com/v" {
427 t.Errorf("SourceURL = %q", item.SourceURL)
428 }
429 if item.Description != "hello" {
430 t.Errorf("Description = %q", item.Description)
431 }
432 if item.Duration != 149 {
433 t.Errorf("Duration = %d, want 149", item.Duration)
434 }
435
436 // Derived metadata should be persisted to the marker.
437 markerData, _ := os.ReadFile(filepath.Join(dir, "x", itemMarkerName))
438 if !strings.Contains(string(markerData), "Real Title") {
439 t.Errorf("marker not enriched with derived title: %s", markerData)
440 }
441}
442
443func TestScanItemDoesNotRewriteConvergedMarker(t *testing.T) {
444 svc, dir := newLibrary(t)
445 marker := "name = \"Done\"\nsource_url = \"u\"\nduration = 10\ndescription = \"d\"\n"
446 itemDir := writeItem(t, dir, "y", marker, map[string]string{"y.mp4": "v"})
447 markerPath := filepath.Join(itemDir, itemMarkerName)
448
449 // First read may enrich the marker (e.g. cache per-file durations), after
450 // which it has converged.
451 if _, err := svc.GetByRelPath(context.Background(), "y"); err != nil {
452 t.Fatalf("warm-up read: %v", err)
453 }
454
455 before, err := os.Stat(markerPath)
456 if err != nil {
457 t.Fatal(err)
458 }
459
460 // Subsequent reads must not rewrite a converged marker.
461 for i := 0; i < 3; i++ {
462 if _, err := svc.GetByRelPath(context.Background(), "y"); err != nil {
463 t.Fatalf("GetByRelPath: %v", err)
464 }
465 }
466
467 after, err := os.Stat(markerPath)
468 if err != nil {
469 t.Fatal(err)
470 }
471 if !before.ModTime().Equal(after.ModTime()) {
472 t.Errorf("converged marker was rewritten on read (mtime changed %v -> %v)", before.ModTime(), after.ModTime())
473 }
474}
475
476// --- per-request item cache ---
477
478func TestItemCacheDedupesScans(t *testing.T) {
479 svc, dir := newLibrary(t)
480 writeItem(t, dir, "x", "name = \"X\"\nsource_url = \"u\"\nduration = 5\n", map[string]string{"x.mp4": "v"})
481
482 // Within one cached context, repeated lookups return the same scanned item.
483 ctx := WithItemCache(context.Background())
484 a, err := svc.GetByRelPath(ctx, "x")
485 if err != nil {
486 t.Fatalf("first lookup: %v", err)
487 }
488 b, err := svc.GetByRelPath(ctx, "x")
489 if err != nil {
490 t.Fatalf("second lookup: %v", err)
491 }
492 if a != b {
493 t.Error("cached context should return the same item pointer (no rescan)")
494 }
495
496 // A context without a cache rescans, yielding a distinct item.
497 c, err := svc.GetByRelPath(context.Background(), "x")
498 if err != nil {
499 t.Fatalf("uncached lookup: %v", err)
500 }
501 if c == a {
502 t.Error("uncached context should rescan, not reuse the cached pointer")
503 }
504}
505
506// --- path traversal ---
507
508func TestResolveItemDirRejectsTraversal(t *testing.T) {
509 svc, dir := newLibrary(t)
510 if _, err := svc.resolveItemDir("../../etc"); err == nil {
511 t.Error("expected error for traversal path")
512 }
513 // A legitimate nested path resolves fine.
514 got, err := svc.resolveItemDir("sub/item")
515 if err != nil {
516 t.Fatalf("unexpected error: %v", err)
517 }
518 if !strings.HasPrefix(got, dir) {
519 t.Errorf("resolved path %q not under library %q", got, dir)
520 }
521}
522
523// --- delete safety ---
524
525func TestDeleteRejectsLibraryRoot(t *testing.T) {
526 svc, dir := newLibrary(t)
527 writeItem(t, dir, "keep", "name = \"Keep\"\nduration = -1\n", map[string]string{"k.mp4": "v"})
528
529 for _, rel := range []string{"", "/", "//"} {
530 if err := svc.Delete(rel); err == nil {
531 t.Errorf("Delete(%q) should be refused", rel)
532 }
533 }
534
535 // The guard must not have touched the library or its contents.
536 if _, err := os.Stat(filepath.Join(dir, "keep", "k.mp4")); err != nil {
537 t.Fatalf("library content was deleted by a root-delete attempt: %v", err)
538 }
539
540 // A real item still deletes.
541 if err := svc.Delete("keep"); err != nil {
542 t.Fatalf("Delete(real item): %v", err)
543 }
544 if _, err := os.Stat(filepath.Join(dir, "keep")); !os.IsNotExist(err) {
545 t.Errorf("expected item dir removed, stat err = %v", err)
546 }
547}
548
549// --- Thumbnail behavior ---
550
551func TestThumbnailPrefersExistingGenerated(t *testing.T) {
552 svc, dir := newLibrary(t)
553 writeItem(t, dir, "item", "name = \"I\"\nduration = -1\n", map[string]string{
554 "video.mp4": "v",
555 "video.thumbnail.webp": "GENERATED",
556 })
557
558 path, ok := svc.ThumbnailForFile(context.Background(), "item", "video.mp4")
559 if !ok {
560 t.Fatal("expected a thumbnail")
561 }
562 if !strings.HasSuffix(path, "video.thumbnail.webp") {
563 t.Errorf("expected generated thumbnail, got %q", path)
564 }
565}
566
567func TestThumbnailExtractsOnDemand(t *testing.T) {
568 requireFFmpeg(t)
569 svc, dir := newLibrary(t)
570 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
571 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
572
573 path, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
574 if !ok {
575 t.Fatal("expected on-demand extraction to succeed")
576 }
577 if info, err := os.Stat(path); err != nil || info.Size() == 0 {
578 t.Fatalf("thumbnail file missing/empty: %v", err)
579 }
580 if !strings.Contains(filepath.Base(path), ".thumbnail.") {
581 t.Errorf("unexpected thumbnail name %q", path)
582 }
583 // No leftover temp files from the atomic-write path.
584 entries, _ := os.ReadDir(itemDir)
585 for _, e := range entries {
586 if strings.Contains(e.Name(), ".tmp") {
587 t.Errorf("leftover temp file %q", e.Name())
588 }
589 }
590}
591
592func TestThumbnailRetriesAfterDeletion(t *testing.T) {
593 requireFFmpeg(t)
594 svc, dir := newLibrary(t)
595 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
596 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
597
598 first, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
599 if !ok {
600 t.Fatal("first extraction failed")
601 }
602 if err := os.Remove(first); err != nil {
603 t.Fatal(err)
604 }
605 // A failure/absence must not be cached permanently: re-request re-extracts.
606 second, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
607 if !ok {
608 t.Fatal("re-extraction after deletion failed (failure was cached)")
609 }
610 if info, err := os.Stat(second); err != nil || info.Size() == 0 {
611 t.Fatalf("re-extracted thumbnail missing/empty: %v", err)
612 }
613}
614
615func TestThumbnailConcurrentSingleExtraction(t *testing.T) {
616 requireFFmpeg(t)
617 svc, dir := newLibrary(t)
618 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
619 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
620
621 var wg sync.WaitGroup
622 for i := 0; i < 8; i++ {
623 wg.Add(1)
624 go func() {
625 defer wg.Done()
626 if _, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4"); !ok {
627 t.Error("concurrent Thumbnail failed")
628 }
629 }()
630 }
631 wg.Wait()
632
633 // Exactly one generated thumbnail, no temp leftovers despite the race.
634 entries, _ := os.ReadDir(itemDir)
635 var thumbs int
636 for _, e := range entries {
637 if strings.Contains(e.Name(), ".thumbnail.") {
638 thumbs++
639 }
640 if strings.Contains(e.Name(), ".tmp") {
641 t.Errorf("leftover temp file %q", e.Name())
642 }
643 }
644 if thumbs != 1 {
645 t.Errorf("expected exactly 1 generated thumbnail, got %d", thumbs)
646 }
647}
648
649func TestThumbnailForFileIsPerFile(t *testing.T) {
650 requireFFmpeg(t)
651 svc, dir := newLibrary(t)
652 itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
653 makeTestVideo(t, filepath.Join(itemDir, "a.mp4"))
654 makeTestVideo(t, filepath.Join(itemDir, "b.mp4"))
655
656 ctx := context.Background()
657 pa, ok := svc.ThumbnailForFile(ctx, "multi", "a.mp4")
658 if !ok {
659 t.Fatal("thumbnail for a.mp4 failed")
660 }
661 pb, ok := svc.ThumbnailForFile(ctx, "multi", "b.mp4")
662 if !ok {
663 t.Fatal("thumbnail for b.mp4 failed")
664 }
665 if pa == pb {
666 t.Errorf("expected distinct per-file thumbnails, both = %q", pa)
667 }
668 if !strings.Contains(filepath.Base(pa), "a.thumbnail.") {
669 t.Errorf("a.mp4 thumbnail name = %q", filepath.Base(pa))
670 }
671 if !strings.Contains(filepath.Base(pb), "b.thumbnail.") {
672 t.Errorf("b.mp4 thumbnail name = %q", filepath.Base(pb))
673 }
674
675 // An unknown file yields no thumbnail (caller falls back to an icon).
676 if _, ok := svc.ThumbnailForFile(ctx, "multi", "nope.mp4"); ok {
677 t.Error("unknown file should not produce a thumbnail")
678 }
679
680 // Every thumbnail is keyed to a specific file: an empty filename yields none.
681 if _, ok := svc.ThumbnailForFile(ctx, "multi", ""); ok {
682 t.Error("empty filename should not produce a thumbnail")
683 }
684}
685
686func TestGetMetadataForSelectedFile(t *testing.T) {
687 requireFFmpeg(t)
688 svc, dir := newLibrary(t)
689 itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
690 makeTestVideoSize(t, filepath.Join(itemDir, "small.mp4"), "64x64")
691 makeTestVideoSize(t, filepath.Join(itemDir, "big.mp4"), "128x72")
692
693 ctx := context.Background()
694 m1, err := svc.GetMetadata(ctx, "multi", "small.mp4")
695 if err != nil {
696 t.Fatalf("GetMetadata small: %v", err)
697 }
698 if m1.Resolution != "64x64" {
699 t.Errorf("small.mp4 resolution = %q, want 64x64", m1.Resolution)
700 }
701 m2, err := svc.GetMetadata(ctx, "multi", "big.mp4")
702 if err != nil {
703 t.Fatalf("GetMetadata big: %v", err)
704 }
705 if m2.Resolution != "128x72" {
706 t.Errorf("big.mp4 resolution = %q, want 128x72", m2.Resolution)
707 }
708}
709
710func TestThumbnailConcurrencyBounded(t *testing.T) {
711 requireFFmpeg(t)
712 svc, dir := newLibrary(t)
713 itemDir := writeItem(t, dir, "many", "name = \"M\"\nduration = -1\n", nil)
714 const n = 8
715 for i := 0; i < n; i++ {
716 makeTestVideo(t, filepath.Join(itemDir, fmt.Sprintf("c%d.mp4", i)))
717 }
718
719 var wg sync.WaitGroup
720 for i := 0; i < n; i++ {
721 i := i
722 wg.Add(1)
723 go func() {
724 defer wg.Done()
725 svc.ThumbnailForFile(context.Background(), "many", fmt.Sprintf("c%d.mp4", i))
726 }()
727 }
728 wg.Wait()
729
730 max := atomic.LoadInt32(&svc.extractMaxConcurrent)
731 if max > maxConcurrentThumbnails {
732 t.Errorf("peak concurrent extractions %d exceeded cap %d", max, maxConcurrentThumbnails)
733 }
734 if max < 1 {
735 t.Error("expected at least one extraction to run")
736 }
737}
738
739func TestThumbnailUsesEmbeddedAttachment(t *testing.T) {
740 requireFFmpeg(t)
741 svc, dir := newLibrary(t)
742 itemDir := writeItem(t, dir, "att", "name = \"A\"\nduration = -1\n", nil)
743 mkv := filepath.Join(itemDir, "v.mkv")
744 // 100x100 cover so it's distinguishable from a 64x64 video frame.
745 makeVideoWithCoverAttachment(t, mkv, "100x100")
746
747 if idx := findImageAttachment(mkv); idx < 0 {
748 t.Fatal("findImageAttachment did not find the embedded cover")
749 }
750
751 path, ok := svc.ThumbnailForFile(context.Background(), "att", "v.mkv")
752 if !ok {
753 t.Fatal("thumbnail extraction failed")
754 }
755 // The embedded cover (100x100) must be used in preference to a video frame
756 // (which would be 64x64) — this is the regression the refactor introduced.
757 if w, h := probeImageSize(t, path); w != 100 || h != 100 {
758 t.Errorf("thumbnail is %dx%d, expected 100x100 from the embedded cover (got a video frame instead)", w, h)
759 }
760}
761
762func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
763 svc, dir := newLibrary(t)
764 // A file ffmpeg cannot extract a thumbnail from: every attempt fails.
765 writeItem(t, dir, "bad", "name = \"B\"\nduration = -1\n", map[string]string{
766 "broken.mp4": "not actually a video",
767 })
768 ctx := context.Background()
769
770 if _, ok := svc.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
771 t.Fatal("expected extraction to fail for a non-video file")
772 }
773 attempts1 := atomic.LoadInt32(&svc.extractAttempts)
774 if attempts1 == 0 {
775 t.Fatal("expected at least one extraction attempt")
776 }
777
778 // A second request is served from the in-process negative cache: no new
779 // ffmpeg attempt.
780 if _, ok := svc.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
781 t.Fatal("expected the cached failure to persist")
782 }
783 if attempts2 := atomic.LoadInt32(&svc.extractAttempts); attempts2 != attempts1 {
784 t.Errorf("negative cache should prevent re-extraction; attempts %d -> %d", attempts1, attempts2)
785 }
786
787 // The cache is in-process only: a fresh service (≈ a restart) retries.
788 fresh := NewLibraryService(dir)
789 if _, ok := fresh.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
790 t.Fatal("fresh service still fails (file is unextractable)")
791 }
792 if atomic.LoadInt32(&fresh.extractAttempts) == 0 {
793 t.Error("a fresh service should retry extraction, not inherit the negative cache")
794 }
795}
796
797func TestThumbnailAudioOnlyHasNone(t *testing.T) {
798 requireFFmpeg(t)
799 svc, dir := newLibrary(t)
800 itemDir := writeItem(t, dir, "aud", "name = \"A\"\nduration = -1\n", nil)
801 // A real audio file with no cover art.
802 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
803 "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
804 filepath.Join(itemDir, "aud.mp3"), "-y")
805 if out, err := cmd.CombinedOutput(); err != nil {
806 t.Fatalf("make audio: %v\n%s", err, out)
807 }
808
809 if path, ok := svc.ThumbnailForFile(context.Background(), "aud", "aud.mp3"); ok {
810 t.Errorf("audio-only item should have no thumbnail, got %q", path)
811 }
812}
813