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