package service import ( "fmt" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "testing" ) // makeVideoWithCoverAttachment writes an MKV at outPath with a 64x64 video and // an embedded "cover.webp" image attachment of the given size — mirroring how // yt-dlp embeds thumbnails into MKV (a true attachment stream, not an // attached_pic video stream; note ffmpeg muxes an attached .png as a video // stream, so webp/jpg must be used to get a real attachment). func makeVideoWithCoverAttachment(t *testing.T, outPath, coverSize string) { t.Helper() scratch := t.TempDir() base := filepath.Join(scratch, "base.mp4") makeTestVideoSize(t, base, "64x64") cover := filepath.Join(scratch, "cover.webp") if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=red:size="+coverSize+":duration=1", "-frames:v", "1", cover, "-y").CombinedOutput(); err != nil { t.Fatalf("make cover: %v\n%s", err, out) } if out, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-i", base, "-attach", cover, "-metadata:s:t:0", "mimetype=image/webp", "-c", "copy", outPath, "-y").CombinedOutput(); err != nil { t.Fatalf("attach cover: %v\n%s", err, out) } } // probeImageSize returns the pixel dimensions of an image/video file. func probeImageSize(t *testing.T, path string) (int, int) { t.Helper() out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path).Output() if err != nil { t.Fatalf("probe %s: %v", path, err) } var w, h int if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%dx%d", &w, &h); err != nil { t.Fatalf("parse dimensions %q: %v", out, err) } return w, h } func TestThumbnailPrefersExistingGenerated(t *testing.T) { svc, dir := newLibrary(t) writeItem(t, dir, "item", "name = \"I\"\nduration = -1\n", map[string]string{ "video.mp4": "v", "video.thumbnail.webp": "GENERATED", }) path, ok := svc.ThumbnailForFile("item", "video.mp4") if !ok { t.Fatal("expected a thumbnail") } if !strings.HasSuffix(path, "video.thumbnail.webp") { t.Errorf("expected generated thumbnail, got %q", path) } } func TestThumbnailExtractsOnDemand(t *testing.T) { requireFFmpeg(t) svc, dir := newLibrary(t) itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil) makeTestVideo(t, filepath.Join(itemDir, "vid.mp4")) path, ok := svc.ThumbnailForFile("vid", "vid.mp4") if !ok { t.Fatal("expected on-demand extraction to succeed") } if info, err := os.Stat(path); err != nil || info.Size() == 0 { t.Fatalf("thumbnail file missing/empty: %v", err) } if !strings.Contains(filepath.Base(path), ".thumbnail.") { t.Errorf("unexpected thumbnail name %q", path) } // No leftover temp files from the atomic-write path. entries, _ := os.ReadDir(itemDir) for _, e := range entries { if strings.Contains(e.Name(), ".tmp") { t.Errorf("leftover temp file %q", e.Name()) } } } func TestThumbnailRetriesAfterDeletion(t *testing.T) { requireFFmpeg(t) svc, dir := newLibrary(t) itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil) makeTestVideo(t, filepath.Join(itemDir, "vid.mp4")) first, ok := svc.ThumbnailForFile("vid", "vid.mp4") if !ok { t.Fatal("first extraction failed") } if err := os.Remove(first); err != nil { t.Fatal(err) } // A failure/absence must not be cached permanently: re-request re-extracts. second, ok := svc.ThumbnailForFile("vid", "vid.mp4") if !ok { t.Fatal("re-extraction after deletion failed (failure was cached)") } if info, err := os.Stat(second); err != nil || info.Size() == 0 { t.Fatalf("re-extracted thumbnail missing/empty: %v", err) } } func TestThumbnailConcurrentSingleExtraction(t *testing.T) { requireFFmpeg(t) svc, dir := newLibrary(t) itemDir := writeItem(t, dir, "vid", "name = \"V\"\nduration = -1\n", nil) makeTestVideo(t, filepath.Join(itemDir, "vid.mp4")) var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() if _, ok := svc.ThumbnailForFile("vid", "vid.mp4"); !ok { t.Error("concurrent Thumbnail failed") } }() } wg.Wait() // Exactly one generated thumbnail, no temp leftovers despite the race. entries, _ := os.ReadDir(itemDir) var thumbs int for _, e := range entries { if strings.Contains(e.Name(), ".thumbnail.") { thumbs++ } if strings.Contains(e.Name(), ".tmp") { t.Errorf("leftover temp file %q", e.Name()) } } if thumbs != 1 { t.Errorf("expected exactly 1 generated thumbnail, got %d", thumbs) } } func TestThumbnailForFileIsPerFile(t *testing.T) { requireFFmpeg(t) svc, dir := newLibrary(t) itemDir := writeItem(t, dir, "multi", "name = \"M\"\nduration = -1\n", nil) makeTestVideo(t, filepath.Join(itemDir, "a.mp4")) makeTestVideo(t, filepath.Join(itemDir, "b.mp4")) pa, ok := svc.ThumbnailForFile("multi", "a.mp4") if !ok { t.Fatal("thumbnail for a.mp4 failed") } pb, ok := svc.ThumbnailForFile("multi", "b.mp4") if !ok { t.Fatal("thumbnail for b.mp4 failed") } if pa == pb { t.Errorf("expected distinct per-file thumbnails, both = %q", pa) } if !strings.Contains(filepath.Base(pa), "a.thumbnail.") { t.Errorf("a.mp4 thumbnail name = %q", filepath.Base(pa)) } if !strings.Contains(filepath.Base(pb), "b.thumbnail.") { t.Errorf("b.mp4 thumbnail name = %q", filepath.Base(pb)) } // An unknown file yields no thumbnail (caller falls back to an icon). if _, ok := svc.ThumbnailForFile("multi", "nope.mp4"); ok { t.Error("unknown file should not produce a thumbnail") } // Every thumbnail is keyed to a specific file: an empty filename yields none. if _, ok := svc.ThumbnailForFile("multi", ""); ok { t.Error("empty filename should not produce a thumbnail") } } func TestThumbnailUsesEmbeddedAttachment(t *testing.T) { requireFFmpeg(t) svc, dir := newLibrary(t) itemDir := writeItem(t, dir, "att", "name = \"A\"\nduration = -1\n", nil) mkv := filepath.Join(itemDir, "v.mkv") // 100x100 cover so it's distinguishable from a 64x64 video frame. makeVideoWithCoverAttachment(t, mkv, "100x100") if idx := svc.findImageAttachment(mkv); idx < 0 { t.Fatal("findImageAttachment did not find the embedded cover") } path, ok := svc.ThumbnailForFile("att", "v.mkv") if !ok { t.Fatal("thumbnail extraction failed") } // The embedded cover (100x100) must be used in preference to a video frame // (which would be 64x64) — this is the regression the refactor introduced. if w, h := probeImageSize(t, path); w != 100 || h != 100 { t.Errorf("thumbnail is %dx%d, expected 100x100 from the embedded cover (got a video frame instead)", w, h) } } func TestThumbnailNegativeCacheSkipsReextraction(t *testing.T) { svc, dir := newLibrary(t) // A file ffmpeg cannot extract a thumbnail from: every attempt fails. writeItem(t, dir, "bad", "name = \"B\"\nduration = -1\n", map[string]string{ "broken.mp4": "not actually a video", }) if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok { t.Fatal("expected extraction to fail for a non-video file") } attempts1 := atomic.LoadInt32(&svc.extractAttempts) if attempts1 == 0 { t.Fatal("expected at least one extraction attempt") } // A second request is served from the in-process negative cache: no new // ffmpeg attempt. if _, ok := svc.ThumbnailForFile("bad", "broken.mp4"); ok { t.Fatal("expected the cached failure to persist") } if attempts2 := atomic.LoadInt32(&svc.extractAttempts); attempts2 != attempts1 { t.Errorf("negative cache should prevent re-extraction; attempts %d -> %d", attempts1, attempts2) } // The cache is in-process only: a fresh service (≈ a restart) retries. fresh := NewLibraryService(dir, "ffmpeg", "ffprobe") if _, ok := fresh.ThumbnailForFile("bad", "broken.mp4"); ok { t.Fatal("fresh service still fails (file is unextractable)") } if atomic.LoadInt32(&fresh.extractAttempts) == 0 { t.Error("a fresh service should retry extraction, not inherit the negative cache") } } func TestThumbnailAudioOnlyHasNone(t *testing.T) { requireFFmpeg(t) svc, dir := newLibrary(t) itemDir := writeItem(t, dir, "aud", "name = \"A\"\nduration = -1\n", nil) // A real audio file with no cover art. cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", filepath.Join(itemDir, "aud.mp3"), "-y") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("make audio: %v\n%s", err, out) } if path, ok := svc.ThumbnailForFile("aud", "aud.mp3"); ok { t.Errorf("audio-only item should have no thumbnail, got %q", path) } }