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