161 lines
5.4 KiB
Go
161 lines
5.4 KiB
Go
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")
|
|
}
|