package library import ( "context" "errors" "io" "log/slog" "os" "path/filepath" "sync" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" ) func TestCoverSharePaths(t *testing.T) { for _, rel := range []string{ "Modeselektor - Happy Birthday! (2007-09-10)/cover.jpg", "Coco Bryce/Albums/(2025-11-28) Noches Sephardies/cover.jpg", "Thoma - Salt Spring (2019)/cover.jpeg", "Franz Ferdinand/2017 - Always Ascending (Single)/Cover.png", "Noisia/[FLAC] CCTV - Factory 5 [199502673] [2009]/cover.jpg", "Kerala Dust/Kerala Dust - Francesca's Frames EP (2018-04-20)/cover.jpg", } { t.Run(rel, func(t *testing.T) { root := t.TempDir() cover := filepath.Join(root, filepath.FromSlash(rel)) require.NoError(t, os.MkdirAll(filepath.Dir(cover), 0o755)) require.NoError(t, os.WriteFile(cover, []byte("shared image"), 0o644)) audio := filepath.Join(filepath.Dir(cover), "01 - Track.flac") require.NoError(t, os.WriteFile(audio, nil, 0o644)) c := Covers{FFmpeg: filepath.Join(root, "missing-ffmpeg")} img, err := c.findAudio(context.Background(), audio) require.NoError(t, err) require.Equal(t, []byte("shared image"), img) }) } } func TestCoverFolderPriorityAndLimit(t *testing.T) { dir := t.TempDir() audio := filepath.Join(dir, "track.flac") require.NoError(t, os.WriteFile(audio, nil, 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("folder"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "COVER.JPEG"), []byte("cover"), 0o644)) c := Covers{FFmpeg: filepath.Join(dir, "missing-ffmpeg")} img, err := c.findAudio(context.Background(), audio) require.NoError(t, err) require.Equal(t, "cover", string(img)) f, err := os.OpenFile(filepath.Join(dir, "COVER.JPEG"), os.O_WRONLY, 0o644) require.NoError(t, err) t.Cleanup(func() { _ = f.Close() }) require.NoError(t, f.Truncate(maxCoverBytes+1)) require.NoError(t, f.Close()) _, err = c.findAudio(context.Background(), audio) require.ErrorContains(t, err, "too large") } func TestCoverReadFailures(t *testing.T) { dir := t.TempDir() c := Covers{FFmpeg: filepath.Join(dir, "missing-ffmpeg")} audio := filepath.Join(dir, "missing.flac") _, err := c.findAudio(context.Background(), audio) require.Error(t, err) require.NoError(t, os.WriteFile(audio, nil, 0o644)) _, err = c.findAudio(context.Background(), audio) require.Error(t, err, "missing converter must not mark an album checked") require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("cover"), 0o644)) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err = c.findAudio(ctx, audio) require.ErrorIs(t, err, context.Canceled) } type failingCoverStore struct { mu sync.Mutex candidates []CoverCandidate visited []uuid.UUID checked int entered chan struct{} release chan struct{} } func (s *failingCoverStore) AlbumsWithoutCover(_ context.Context, _ int, after uuid.UUID) ([]CoverCandidate, error) { // Deliberately short pages exercise pagination independently of the batch size. for i, a := range s.candidates { if a.AlbumID.String() > after.String() { return s.candidates[i:min(i+4, len(s.candidates))], nil } } return nil, nil } func (s *failingCoverStore) GetSource(_ context.Context, id uuid.UUID) (Source, error) { s.mu.Lock() s.visited = append(s.visited, id) s.mu.Unlock() s.entered <- struct{}{} <-s.release return Source{}, errors.New("share temporarily unavailable") } func (s *failingCoverStore) SetAlbumCover(context.Context, uuid.UUID, *string, []string) error { s.mu.Lock() defer s.mu.Unlock() s.checked++ return nil } func TestCoverWorkersRetryFailuresWithoutLooping(t *testing.T) { s := &failingCoverStore{entered: make(chan struct{}, 20), release: make(chan struct{})} for i := byte(1); i <= 7; i++ { id := uuid.UUID{15: i} s.candidates = append(s.candidates, CoverCandidate{AlbumID: id, SourceID: id}) } c := Covers{store: s, Dir: t.TempDir(), log: slog.New(slog.NewTextHandler(io.Discard, nil))} done := make(chan struct{}) go func() { c.Run(context.Background()); close(done) }() for range 4 { select { case <-s.entered: case <-time.After(5 * time.Second): t.Fatal("expected four concurrent cover workers") } } close(s.release) select { case <-done: case <-time.After(5 * time.Second): t.Fatal("failed albums must not loop or prevent later pages") } require.Len(t, s.visited, 7) require.Zero(t, s.checked, "IO failures remain eligible for retry") c.Run(context.Background()) require.Len(t, s.visited, 14, "next run retries failures") } type sourceCoverStore struct{ *failingCoverStore } func (s sourceCoverStore) GetSource(context.Context, uuid.UUID) (Source, error) { return Source{Type: SourceLocal}, nil } func TestCoverRenderFailureRemainsPending(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "track.flac"), nil, 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("invalid image"), 0o644)) s := sourceCoverStore{&failingCoverStore{}} c := Covers{ store: s, svc: &Service{SourceService: &SourceService{cfg: Config{HostRoot: dir}}}, FFmpeg: filepath.Join(dir, "missing-ffmpeg"), Dir: t.TempDir(), log: slog.New(slog.NewTextHandler(io.Discard, nil)), } c.process(context.Background(), CoverCandidate{AlbumID: uuid.New(), RelPath: "track.flac"}) require.Zero(t, s.checked, "conversion errors must not save a missing cover") }