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// TestScanItemEmptyInfoFieldsDoNotRewriteMarker is a regression test: an
454// info.json with present-but-empty "description"/"webpage_url" must not flip the
455// marker dirty on every scan. infoString returns ("", true) for those fields, so
456// the old code reassigned "" and rewrote the marker forever (e.g. clips whose
457// text lives only in the title). We detect any rewrite via an unknown TOML key,
458// which scanItem's re-encode would drop.
459func TestScanItemEmptyInfoFieldsDoNotRewriteMarker(t *testing.T) {
460 svc, dir := newLibrary(t)
461 svc.scanTTL = 0 // force a real rescan on every read
462
463 info := `{"title":"#hashtags only","description":"","webpage_url":""}`
464 marker := "name = \"#hashtags only\"\nsentinel = \"keep\"\n"
465 writeItem(t, dir, "z", marker, map[string]string{"z.mp4": "v", "info.json": info})
466 markerPath := filepath.Join(dir, "z", itemMarkerName)
467
468 for i := 0; i < 3; i++ {
469 if _, err := svc.GetByRelPath(context.Background(), "z"); err != nil {
470 t.Fatalf("GetByRelPath: %v", err)
471 }
472 }
473
474 data, _ := os.ReadFile(markerPath)
475 if !strings.Contains(string(data), "sentinel") {
476 t.Errorf("marker was rewritten on read (sentinel dropped): %s", data)
477 }
478}
479
480// --- scan cache ---
481
482func TestScanCacheReusesWithinTTL(t *testing.T) {
483 svc, dir := newLibrary(t)
484 writeItem(t, dir, "x", "name = \"X\"\nsource_url = \"u\"\n", map[string]string{"x.mp4": "v"})
485
486 // Within the TTL, repeated lookups return the same cached scan (no rescan).
487 a, err := svc.GetByRelPath(context.Background(), "x")
488 if err != nil {
489 t.Fatalf("first lookup: %v", err)
490 }
491 b, err := svc.GetByRelPath(context.Background(), "x")
492 if err != nil {
493 t.Fatalf("second lookup: %v", err)
494 }
495 if a != b {
496 t.Error("expected the cached scan to be reused (same pointer) within the TTL")
497 }
498
499 // Deleting the item evicts its cached scan immediately.
500 if err := svc.Delete("x"); err != nil {
501 t.Fatalf("Delete: %v", err)
502 }
503 if _, ok := svc.getCachedScan("x"); ok {
504 t.Error("Delete should evict the cached scan")
505 }
506
507 // With caching disabled, each lookup rescans into a distinct item.
508 svc2, dir2 := newLibrary(t)
509 svc2.scanTTL = 0
510 writeItem(t, dir2, "y", "name = \"Y\"\n", map[string]string{"y.mp4": "v"})
511 c, _ := svc2.GetByRelPath(context.Background(), "y")
512 d, _ := svc2.GetByRelPath(context.Background(), "y")
513 if c == nil || d == nil || c == d {
514 t.Error("with TTL disabled each lookup should rescan, not reuse a pointer")
515 }
516}
517
518// --- path traversal ---
519
520func TestResolveItemDirRejectsTraversal(t *testing.T) {
521 svc, dir := newLibrary(t)
522 if _, err := svc.resolveItemDir("../../etc"); err == nil {
523 t.Error("expected error for traversal path")
524 }
525 // A legitimate nested path resolves fine.
526 got, err := svc.resolveItemDir("sub/item")
527 if err != nil {
528 t.Fatalf("unexpected error: %v", err)
529 }
530 if !strings.HasPrefix(got, dir) {
531 t.Errorf("resolved path %q not under library %q", got, dir)
532 }
533}
534
535// --- delete safety ---
536
537func TestDeleteRejectsLibraryRoot(t *testing.T) {
538 svc, dir := newLibrary(t)
539 writeItem(t, dir, "keep", "name = \"Keep\"\nduration = -1\n", map[string]string{"k.mp4": "v"})
540
541 for _, rel := range []string{"", "/", "//"} {
542 if err := svc.Delete(rel); err == nil {
543 t.Errorf("Delete(%q) should be refused", rel)
544 }
545 }
546
547 // The guard must not have touched the library or its contents.
548 if _, err := os.Stat(filepath.Join(dir, "keep", "k.mp4")); err != nil {
549 t.Fatalf("library content was deleted by a root-delete attempt: %v", err)
550 }
551
552 // A real item still deletes.
553 if err := svc.Delete("keep"); err != nil {
554 t.Fatalf("Delete(real item): %v", err)
555 }
556 if _, err := os.Stat(filepath.Join(dir, "keep")); !os.IsNotExist(err) {
557 t.Errorf("expected item dir removed, stat err = %v", err)
558 }
559}
560
561// --- Thumbnail behavior ---
562
563func TestThumbnailPrefersExistingGenerated(t *testing.T) {
564 svc, dir := newLibrary(t)
565 writeItem(t, dir, "item", "name = \"I\"\nduration = -1\n", map[string]string{
566 "video.mp4": "v",
567 "video.thumbnail.webp": "GENERATED",
568 })
569
570 path, ok := svc.ThumbnailForFile(context.Background(), "item", "video.mp4")
571 if !ok {
572 t.Fatal("expected a thumbnail")
573 }
574 if !strings.HasSuffix(path, "video.thumbnail.webp") {
575 t.Errorf("expected generated thumbnail, got %q", path)
576 }
577}
578
579func TestThumbnailExtractsOnDemand(t *testing.T) {
580 requireFFmpeg(t)
581 svc, dir := newLibrary(t)
582 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
583 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
584
585 path, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
586 if !ok {
587 t.Fatal("expected on-demand extraction to succeed")
588 }
589 if info, err := os.Stat(path); err != nil || info.Size() == 0 {
590 t.Fatalf("thumbnail file missing/empty: %v", err)
591 }
592 if !strings.Contains(filepath.Base(path), ".thumbnail.") {
593 t.Errorf("unexpected thumbnail name %q", path)
594 }
595 // No leftover temp files from the atomic-write path.
596 entries, _ := os.ReadDir(itemDir)
597 for _, e := range entries {
598 if strings.Contains(e.Name(), ".tmp") {
599 t.Errorf("leftover temp file %q", e.Name())
600 }
601 }
602}
603
604func TestThumbnailRetriesAfterDeletion(t *testing.T) {
605 requireFFmpeg(t)
606 svc, dir := newLibrary(t)
607 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
608 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
609
610 first, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
611 if !ok {
612 t.Fatal("first extraction failed")
613 }
614 if err := os.Remove(first); err != nil {
615 t.Fatal(err)
616 }
617 // A failure/absence must not be cached permanently: re-request re-extracts.
618 second, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4")
619 if !ok {
620 t.Fatal("re-extraction after deletion failed (failure was cached)")
621 }
622 if info, err := os.Stat(second); err != nil || info.Size() == 0 {
623 t.Fatalf("re-extracted thumbnail missing/empty: %v", err)
624 }
625}
626
627func TestThumbnailConcurrentSingleExtraction(t *testing.T) {
628 requireFFmpeg(t)
629 svc, dir := newLibrary(t)
630 itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil)
631 makeTestVideo(t, filepath.Join(itemDir, "vid.mp4"))
632
633 var wg sync.WaitGroup
634 for i := 0; i < 8; i++ {
635 wg.Add(1)
636 go func() {
637 defer wg.Done()
638 if _, ok := svc.ThumbnailForFile(context.Background(), "vid", "vid.mp4"); !ok {
639 t.Error("concurrent Thumbnail failed")
640 }
641 }()
642 }
643 wg.Wait()
644
645 // Exactly one generated thumbnail, no temp leftovers despite the race.
646 entries, _ := os.ReadDir(itemDir)
647 var thumbs int
648 for _, e := range entries {
649 if strings.Contains(e.Name(), ".thumbnail.") {
650 thumbs++
651 }
652 if strings.Contains(e.Name(), ".tmp") {
653 t.Errorf("leftover temp file %q", e.Name())
654 }
655 }
656 if thumbs != 1 {
657 t.Errorf("expected exactly 1 generated thumbnail, got %d", thumbs)
658 }
659}
660
661func TestThumbnailForFileIsPerFile(t *testing.T) {
662 requireFFmpeg(t)
663 svc, dir := newLibrary(t)
664 itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
665 makeTestVideo(t, filepath.Join(itemDir, "a.mp4"))
666 makeTestVideo(t, filepath.Join(itemDir, "b.mp4"))
667
668 ctx := context.Background()
669 pa, ok := svc.ThumbnailForFile(ctx, "multi", "a.mp4")
670 if !ok {
671 t.Fatal("thumbnail for a.mp4 failed")
672 }
673 pb, ok := svc.ThumbnailForFile(ctx, "multi", "b.mp4")
674 if !ok {
675 t.Fatal("thumbnail for b.mp4 failed")
676 }
677 if pa == pb {
678 t.Errorf("expected distinct per-file thumbnails, both = %q", pa)
679 }
680 if !strings.Contains(filepath.Base(pa), "a.thumbnail.") {
681 t.Errorf("a.mp4 thumbnail name = %q", filepath.Base(pa))
682 }
683 if !strings.Contains(filepath.Base(pb), "b.thumbnail.") {
684 t.Errorf("b.mp4 thumbnail name = %q", filepath.Base(pb))
685 }
686
687 // An unknown file yields no thumbnail (caller falls back to an icon).
688 if _, ok := svc.ThumbnailForFile(ctx, "multi", "nope.mp4"); ok {
689 t.Error("unknown file should not produce a thumbnail")
690 }
691
692 // Every thumbnail is keyed to a specific file: an empty filename yields none.
693 if _, ok := svc.ThumbnailForFile(ctx, "multi", ""); ok {
694 t.Error("empty filename should not produce a thumbnail")
695 }
696}
697
698func TestGetMetadataForSelectedFile(t *testing.T) {
699 requireFFmpeg(t)
700 svc, dir := newLibrary(t)
701 itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil)
702 makeTestVideoSize(t, filepath.Join(itemDir, "small.mp4"), "64x64")
703 makeTestVideoSize(t, filepath.Join(itemDir, "big.mp4"), "128x72")
704
705 ctx := context.Background()
706 m1, err := svc.GetMetadata(ctx, "multi", "small.mp4")
707 if err != nil {
708 t.Fatalf("GetMetadata small: %v", err)
709 }
710 if m1.Resolution != "64x64" {
711 t.Errorf("small.mp4 resolution = %q, want 64x64", m1.Resolution)
712 }
713 m2, err := svc.GetMetadata(ctx, "multi", "big.mp4")
714 if err != nil {
715 t.Fatalf("GetMetadata big: %v", err)
716 }
717 if m2.Resolution != "128x72" {
718 t.Errorf("big.mp4 resolution = %q, want 128x72", m2.Resolution)
719 }
720}
721
722func TestThumbnailConcurrencyBounded(t *testing.T) {
723 requireFFmpeg(t)
724 svc, dir := newLibrary(t)
725 itemDir := writeItem(t, dir, "many", "name = \"M\"\nduration = -1\n", nil)
726 const n = 8
727 for i := 0; i < n; i++ {
728 makeTestVideo(t, filepath.Join(itemDir, fmt.Sprintf("c%d.mp4", i)))
729 }
730
731 var wg sync.WaitGroup
732 for i := 0; i < n; i++ {
733 i := i
734 wg.Add(1)
735 go func() {
736 defer wg.Done()
737 svc.ThumbnailForFile(context.Background(), "many", fmt.Sprintf("c%d.mp4", i))
738 }()
739 }
740 wg.Wait()
741
742 max := atomic.LoadInt32(&svc.extractMaxConcurrent)
743 if max > maxConcurrentThumbnails {
744 t.Errorf("peak concurrent extractions %d exceeded cap %d", max, maxConcurrentThumbnails)
745 }
746 if max < 1 {
747 t.Error("expected at least one extraction to run")
748 }
749}
750
751func TestThumbnailUsesEmbeddedAttachment(t *testing.T) {
752 requireFFmpeg(t)
753 svc, dir := newLibrary(t)
754 itemDir := writeItem(t, dir, "att", "name = \"A\"\nduration = -1\n", nil)
755 mkv := filepath.Join(itemDir, "v.mkv")
756 // 100x100 cover so it's distinguishable from a 64x64 video frame.
757 makeVideoWithCoverAttachment(t, mkv, "100x100")
758
759 if idx := findImageAttachment(mkv); idx < 0 {
760 t.Fatal("findImageAttachment did not find the embedded cover")
761 }
762
763 path, ok := svc.ThumbnailForFile(context.Background(), "att", "v.mkv")
764 if !ok {
765 t.Fatal("thumbnail extraction failed")
766 }
767 // The embedded cover (100x100) must be used in preference to a video frame
768 // (which would be 64x64) — this is the regression the refactor introduced.
769 if w, h := probeImageSize(t, path); w != 100 || h != 100 {
770 t.Errorf("thumbnail is %dx%d, expected 100x100 from the embedded cover (got a video frame instead)", w, h)
771 }
772}
773
774func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) {
775 svc, dir := newLibrary(t)
776 // A file ffmpeg cannot extract a thumbnail from: every attempt fails.
777 writeItem(t, dir, "bad", "name = \"B\"\nduration = -1\n", map[string]string{
778 "broken.mp4": "not actually a video",
779 })
780 ctx := context.Background()
781
782 if _, ok := svc.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
783 t.Fatal("expected extraction to fail for a non-video file")
784 }
785 attempts1 := atomic.LoadInt32(&svc.extractAttempts)
786 if attempts1 == 0 {
787 t.Fatal("expected at least one extraction attempt")
788 }
789
790 // A second request is served from the in-process negative cache: no new
791 // ffmpeg attempt.
792 if _, ok := svc.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
793 t.Fatal("expected the cached failure to persist")
794 }
795 if attempts2 := atomic.LoadInt32(&svc.extractAttempts); attempts2 != attempts1 {
796 t.Errorf("negative cache should prevent re-extraction; attempts %d -> %d", attempts1, attempts2)
797 }
798
799 // The cache is in-process only: a fresh service (≈ a restart) retries.
800 fresh := NewLibraryService(dir)
801 if _, ok := fresh.ThumbnailForFile(ctx, "bad", "broken.mp4"); ok {
802 t.Fatal("fresh service still fails (file is unextractable)")
803 }
804 if atomic.LoadInt32(&fresh.extractAttempts) == 0 {
805 t.Error("a fresh service should retry extraction, not inherit the negative cache")
806 }
807}
808
809func TestThumbnailAudioOnlyHasNone(t *testing.T) {
810 requireFFmpeg(t)
811 svc, dir := newLibrary(t)
812 itemDir := writeItem(t, dir, "aud", "name = \"A\"\nduration = -1\n", nil)
813 // A real audio file with no cover art.
814 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
815 "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
816 filepath.Join(itemDir, "aud.mp3"), "-y")
817 if out, err := cmd.CombinedOutput(); err != nil {
818 t.Fatalf("make audio: %v\n%s", err, out)
819 }
820
821 if path, ok := svc.ThumbnailForFile(context.Background(), "aud", "aud.mp3"); ok {
822 t.Errorf("audio-only item should have no thumbnail, got %q", path)
823 }
824}
825